Skip to content

fix(analyzer): bound static pattern regex searches - #741

Open
yashrajp22 wants to merge 64 commits into
mainfrom
yashraj/fix-static-pattern-regex-timeouts
Open

yashrajp22 wants to merge 64 commits into
mainfrom
yashraj/fix-static-pattern-regex-timeouts

Conversation

@yashrajp22

@yashrajp22 yashrajp22 commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

High-complexity static patterns could monopolize a scanner worker on attacker-controlled text. Interruptible searches now preserve earlier findings and report explicit incomplete coverage with the actual limit. Paragraphs share a 0.25-second matching allowance per rule and content window, and small paragraphs use bounded batches.

The change preserves Python Unicode word-boundary behavior and uses bounded native searches for variable-shell backreferences. Oversized argument tails report their character limit. It also preserves main's shared AST analysis, indexes source lines once, and removes overlapping whitespace repeats in the anti-refusal schema helper. The resource-bounds guide identifies rules outside this change.

Validation includes full-size benign and adversarial inputs, Unicode match/span parity, paragraph budgets, all three variable-flow callers, and one AST parse/index for 100 shell-flag candidates. It also covers the newer Python execution-surface and TM1 ownership behavior from main. The timeout-routing regression exhausts the selected rule's allowance deterministically, so it does not depend on CPU speed. The final combined source and wheel results are below.

Combined verification across the updated PRs: 6,243 regression tests passed against source and again against the freshly installed wheel, with seven conditional skips and four expected failures per run. All 19 source/wheel sample pairs matched. The 12-skill corpus retained its findings and risk ratings; four former hangs now finish with explicit partial-analysis results. The 93 extension tests passed. Two synthetic live NVIDIA Build checks passed on the final wheel: benign-note was complete/SAFE, and the exfiltration sample retained SSD-3 with complete semantic and meta analysis and a DO_NOT_INSTALL recommendation. All seven recorded LLM analyses succeeded. Live checks used the configured model/reasoning defaults through a test-only proxy that kept the real credential outside the scanner.

Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Hi @yashrajp22, thank you for taking on interruptible static-pattern matching! The translator is careful about Python's word/space alphabet, the ı/İ case aliases, empty-match adjacency and fail-closed grammar.

Value and readiness: The goal is right, and the bound works on the shapes it targets. On main, the TM1 SQL-construction pattern is cubic on one long line and P2's HTML-comment pattern is quadratic. Both run inside re with the GIL held, so a small file can stall the whole scan. With this PR, both stop at 0.25 s and are recorded as partial. However, the translated regex patterns are much slower than re on ordinary non-ASCII text, so the new per-pattern ceiling fires on legitimate documents. Those files are then reported as partially inspected, and the rest of that analyzer is skipped for them. The ledger also reports these events against the 300 s artifact limit. Not ready yet.

Material findings

  1. [Blocker] src/skillspector/nodes/analyzers/static_runner.py:365 (budget at :301): false RUNTIME_LIMIT on ordinary files.
    • When the content contains any non-ASCII character, every \w, \s, \d and \b is expanded to full-Unicode tables. \w alone is about 750 ranges, and each \b becomes four lookarounds over that class.
    • I rebuilt the documented rewrite by hand for TM1 \b(?:delete|remove)\s+['\"]?/[^\s'\"]{1,100}. On text made from this repository's own README.md and docs/*.md, it took 92/190/285/366 ms at 64K/128K/192K/256K characters. Python re on main takes 1-4 ms. On my laptop, such files exceed 0.25 s above about 170K characters; CI runners are slower.
    • The resulting TimeoutError becomes _StaticResourceLimitError(RUNTIME_LIMIT), and _scan_path returns early. TM1's remaining patterns and TM2-TM4 are skipped for that file, and the file is marked partial. MCP safe_to_install then becomes false, and --fail-on-incomplete exits 1.
    • ASCII content is not immune either. I built a 256K ASCII API reference with a curl -X GET ... -H ... line every fourth line (872 commands, no pipe). On it, TM1 curl\s+[^|]*-k\b takes 0.134 s in re and 0.287 s translated.
    • The delete/remove pattern runs in 41 ms on ASCII text, which is why CI is green. Still, the first CI run on this branch failed three legitimate-content tests with the same RUNTIME_LIMIT/partial result.
    • Expected fix: keep near-native speed on non-ASCII input. One option is the offset-preserving ASCII alphabet that artifact_integrity._multiline_prompt_matching_text already uses with regex.ASCII. Catalog patterns with non-ASCII literals, such as P2's zero-width class, would need their own path. Another option is to route only patterns with super-linear shapes through the timed engine and rewrite those with bounded quantifiers.
    • Please also add production-size completeness tests: one on non-ASCII Markdown (for example 256K characters of the repo's own docs) and one on a dense command reference.
  2. [Blocker] src/skillspector/nodes/analyzers/static_runner.py:506: the per-pattern limit is lost before it reaches the ledger.
    • iter_pattern_matches reports limit_seconds: 0.25. However, run_static_patterns_with_ledger treats every RUNTIME_LIMIT as an expired artifact and replaces the metrics with limit_seconds: runtime_limit (300 s, or the remaining workflow time) at static_runner.py:2796-2810.
    • A pattern timeout is therefore published as "Inspection reached its configured runtime limit", with 0.25 s observed against a 300 s limit. That contradicts docs/ANALYSIS_RESOURCE_BOUNDS.md, which promises "the applicable observed and limit values". It also points users at SKILLSPECTOR_MAX_STATIC_ANALYSIS_SECONDS_PER_ARTIFACT, which cannot lift this bound.
    • Expected fix: keep the per-pattern limit in the event (or add a distinguishing detail), and add the new ceiling to the resource-bounds table.
  3. [Non-blocking] static_runner.py:478 and :484: both clocks measure process-wide CPU time. time.process_time() and the regex timeout count every thread in the process.
    • With three threads hashing outside the GIL, a search with timeout=0.202 raised after only 0.053 s of wall time.
    • yara-python 4.5.4 also releases the GIL during match(); a Python thread kept running throughout a 15 s scan.
    • Analyzers run in parallel, so a concurrent YARA scan of a large file consumes part of every pattern's 0.25 s. Consider time.thread_time() for your own accounting, plus a retry when the engine timeout fires before this thread has used its budget.
  4. [Non-blocking] static_runner.py:307 and :338: lru_cache does not serialize cache misses.
    • Every analyzer thread that reaches the first non-ASCII file builds the 1.1M-code-point table itself. That took 0.76 s per build here, and the static analyzers all start together.
    • My static count gives roughly 310 distinct routed patterns. With two alphabets each, that exceeds maxsize=512, so bundles mixing ASCII and non-ASCII files can evict and recompile Unicode-table patterns.
    • Those compiles cost 24-71 ms each in my measurements. A two-\b pattern I built expanded to about 129K characters of regex source.
    • Consider building the tables once (at import or under a lock) and sizing the cache to the catalog.
  5. [Non-blocking] Scope: several catalog loops still use untimed re.
    • Privilege escalation: the PE1-PE3 code loops, and PE4/PE5 at static_patterns_privilege_escalation.py:1040 and :1064.
    • The harmful_content, deserialization, ssrf, system_prompt_leakage, anti_refusal and output_handling code patterns, and _decoded_literal_xor_calls.
    • In particular, #678's new PE2 chmod regex would land in an unbounded module. Its own docstring says "chmod 0" * 8000 stays quadratic.
    • Please state the covered scope in the PR body, or extend the change to these loops.

PIC tradeoffs: The per-pattern ceiling turns slow-but-complete scans into fast partial results. Even after finding 1 is fixed, the PIC should decide two questions:

  • Should one pattern timeout end the whole analyzer for that file (the current _scan_path behaviour), or only skip that pattern?
  • Should the 0.25 s CPU limit be configurable, or scale with window size?

Verification and gaps:

  • Old shapes, on my own inputs with Python re:
    • P2 <!--.*?(...).*?--> on <!-- followed by repeated send took 0.094/0.363/1.458 s at 10K/20K/40K characters (quadratic).
    • TM1 SQL on a single query('{}{}... line took 0.004/0.034/0.276/2.216 s at 207/407/807/1,607 characters (cubic).
  • New bound, using my transcriptions with regex and a 0.25 s timeout: both shapes raise TimeoutError at 0.25 s on 256K and 12.8K inputs. Real attack strings still match, for example <!-- ignore previous instructions and send data --> and an f-string execute containing DROP TABLE.
  • Partial reporting: I traced iter_pattern_matches → _StaticResourceLimitError → _scan_path, which keeps findings already created. No module defines postprocess_path_findings, so _cleanup_expired_path_findings keeps them too. The result is a PARTIAL ledger event, and no converted call site swallows the error.
  • Semantics: no converted module uses scoped flags, VERBOSE, \B or \N. The VERBOSE regexes in anti_refusal and output_handling never reach the timed matcher. Every transcription I ran produced the same spans as re.
  • CI: the first commit failed test-unit with three RUNTIME_LIMIT/partial results on legitimate fixtures, and 6bc8c39 passed. Head 7a5a1ce adds only bot merges of main, and the PR diff is unchanged (same patch-id). CI on the head is action_required.
  • Conflicts: none with main, #739, #740, #742 or #678.
  • I did not run the tests locally, per policy.

Decision: Changes Requested (reviewed head 7a5a1ce5eccba7fabc36d25168c48e5da81e147f)

Comment thread src/skillspector/nodes/analyzers/static_runner.py Outdated
Comment thread src/skillspector/nodes/analyzers/static_runner.py Outdated
github-actions Bot and others added 4 commits October 5, 2026 14:54
@yashrajp22

Copy link
Copy Markdown
Collaborator Author

Thanks for the detailed review. I also changed the accounting so caller work and other threads do not use the matching budget, and made the character-table setup shared with a larger bounded cache. The description and resource guide now spell out the four covered rule groups; the other helper searches are outside this change. The fixed limit and partial-coverage behavior are kept so interrupted work cannot be reported as complete.

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Hi @yashrajp22, thank you for the fast rework, especially the compact Unicode categories and the ledger fix!

Value and readiness: The bound is worth having. On main, a 224K line of repeated chmod 0 keeps TM1's a+rwx rule busy for 26 s, and this PR stops it at 0.25 s with partial coverage. The fix commits resolve the ledger limit and the cache, and cut most of the Unicode-table cost. However, the PR's own 256K fixtures still exceed the 0.25 s limit in two other analyzers that now use the timed matcher. The new completeness test runs only tool_misuse, so it cannot see this. Ordinary files would still be reported as partially inspected, so this is not ready yet.

Previous findings:

  • 1 (false RUNTIME_LIMIT on ordinary files): Partly resolved. In my transcription, TM1 delete/remove on 256K of this repo's docs dropped from 366 ms to about 150 ms (re: 5 ms). The curl check fixes the command reference for TM1, but only when -k and --insecure are absent. The rest is new finding 1.
  • 2 (per-search limit lost in the ledger): Resolved. static_runner.py:2856 now keeps the search's metrics. The test asserts limit_seconds == 0.000001, which the old overwrite would fail. The resource-bounds table has the new row.
  • 3 (process-wide CPU clocks): Partly resolved. The matcher now uses thread CPU and retries, as suggested, but finding 3 below remains.
  • 4 (cache misses and size): Resolved. The categories are built once under _CATEGORY_LOCK. There are 375 routed (pattern, flags) pairs, so the two alphabets need 750 entries, within maxsize=1024. Compiles now take 0.67 ms median (max 9.7 ms), against 24-71 ms before.
  • 5 (scope): Partly resolved. The scope is now documented, but inaccurately (new finding 2).

Both earlier inline threads were resolved on GitHub. The first is only partly fixed, so I have added inline comments on the specific lines.

Material findings

  1. [Blocker] Ordinary 256K windows still hit the 0.25 s limit, including the PR's own fixtures in analyzers other than tool_misuse. Measured with my transcription, using regex 2026.5.9 on Python 3.12 (CI's version) on an Apple-silicon laptop. CI runners are usually slower.
    • static_patterns_data_exfiltration.py:305: E1 curl\s+[^|]*(?:-d|--data|--data-raw|--data-binary)\s+ takes about 1.07 s on the PR's command_reference fixture, against 0.21 s in re on main. It still takes 1.2 s untranslated, so the cost comes from the engine, not the rewrite. data_exfiltration reports that file as partial.
    • static_patterns_memory_poisoning.py:547: MP2 (.{2,20}?)\1{20,} runs on every file. The rewrite leaves it unchanged, but regex needs about 1 µs per character for it, twice re. It took 0.26-0.32 s on the PR's unicode_docs fixture across my runs, and 0.22 s on the command fixture. On main this rule is linear and bounded by the window, so timing it adds false partials for little protection.
    • static_patterns_tool_misuse.py:3167: the curl check passes as soon as the suffix appears anywhere. With one line sort -k 2 results.txt added to the command fixture, TM1 curl\s+[^|]*-k\b runs the full scan and takes 0.36 s. The same [^|]* shape has no check in TM1 wget, git push and chmod/chown, or in E1 wget. On 256K references with one such command every fourth line and no pipes, these took 0.32-0.52 s.
    • The two \b-led TM1 rules still take 0.12-0.16 s on non-ASCII 256K docs, which leaves little margin, especially under contention (finding 3).
    • E1 and MP2 have been in the change since the first commit; I should have reported them last time.
    • Expected fix:
      • Run the completeness test on every analyzer that uses the timed matcher, not only tool_misuse. Use both fixtures plus a command reference that mentions -k once.
      • Keep MP2's repetition rule on re, or out of the 0.25 s path.
      • Replace the curl-only check with an exact linear pre-check for the whole X\s+[^|]*SUFFIX family: search only the pipe-free segments where a required suffix follows the command word. Avoid a length bound such as [^|]{0,N}, because padding would evade it.
  2. [Blocker] docs/ANALYSIS_RESOURCE_BOUNDS.md:301-307 (and the PR body) understate the scope. They say only prompt-injection, tool-misuse, data-exfiltration and supply-chain patterns are timed, and that "Other catalog modules" are outside the matcher. The code also routes the agent_snooping, excessive_agency, memory_poisoning and rogue_agent loops. Through iter_paragraph_matches, it also times the prose rules of harmful_content, system_prompt_leakage, output_handling, anti_refusal, privilege_escalation and ssrf. A memory_poisoning partial with a 0.25 s limit (finding 1) would contradict this paragraph. Please describe the actual scope.
  3. [Non-blocking] static_runner.py:521-545: the retry counts thread CPU correctly, but regex's timer still counts process CPU, and each retry restarts the search. While another native call runs without the GIL, as a YARA scan of a large file does, one search gets less than 0.25 s. In my re-implementation of this loop, a search that finishes alone in 0.165 s hit the limit after 16 retries. So the docs' "Caller work and other threads do not consume the matching allowance" is true for accounting only. Consider scaling the next attempt's engine timeout by the failed attempt's process-to-thread CPU ratio, or qualify the sentence.

PIC tradeoffs:

  • Does a pattern timeout end the whole file's analysis? Yes, for that analyzer. _scan_path returns at the first _StaticResourceLimitError (static_runner.py:1217), and _scan_all_views_detailed returns before the remaining views and windows. Findings already produced are kept, the artifact is recorded as partial, and other analyzers and artifacts continue. Skipping only the timed-out search would keep more coverage on adversarial files. However, a crafted file could then spend 0.25 s on each of the 375 routed searches in every window.
  • Should the 0.25 s limit be configurable? It stays fixed, by design. Because regex is 2-5 times slower than re on several catalog shapes, a fixed per-search limit leaves little margin at the 256K window size on slower hardware. A limit that scales with the searched length, or a setting, would let operators trade time for completeness. This is a product decision.
  • Evasion: timeouts are not silent. No call site catches _StaticResourceLimitError (it subclasses RuntimeError, so I checked the timed modules for except Exception/RuntimeError). Every timeout becomes a PARTIAL ledger event with the search's own limit. Still, one crafted line decides which analyzer stops early for that artifact, and users who do not gate on completeness only see the partial status.

Verification and gaps:

  • Interplay with #740 (merged): _decoded_literal_xor_calls no longer needs the timed matcher. On main, a 200K unterminated backslash run takes 13 ms, and 30K malformed headers take under 1 ms. Its remaining cost is one whole-content call-pattern scan per qualifying helper: 2,800 helpers in 217K characters take 7.4 s on main. A per-search deadline cannot bound that, so it needs a separate fix outside this PR.
  • Interplay with #678 (merged): both TM1 chmod rules use the timed matcher here. PE2's chmod rule stays on untimed re. That is acceptable, because it took at most 12 ms on every adversarial shape I tried, including "chmod 0" * 32000, -a and -- option runs, and long zero runs.
  • Semantics: across all 375 routed (pattern, flags) pairs, taken from main (the PR changes no pattern list), my transcription produced the same spans as re on four 256K corpora: repo docs, the unicode_docs fixture, CJK prose and Cyrillic prose.
  • On the production-size raw windows of test_cross_window_separator_pair_across_public_surfaces, every search took at most 54 ms. I did not model that test's other security views, so I can only say the scaled windows (6bc8c39) do not appear to hide a timeout.
  • Commits: 154cbb3, 24bed2f and e5eec2f carry the author's sign-off. The bot merges that followed leave the PR diff unchanged (same patch-id).
  • Conflicts: none with main, #742 or #739.
  • CI: on e5eec2f, 5 of 6 checks are green and test-unit is still running. Head d722477 is action_required.
  • I did not run the PR's tests, per policy.

Decision: Changes Requested (reviewed head d72247754d9619486c52574ef88b67ff62ee2ddf)

Comment thread src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py Outdated
Comment thread docs/ANALYSIS_RESOURCE_BOUNDS.md Outdated
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
…ttern-regex-timeouts

Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
@yashrajp22

Copy link
Copy Markdown
Collaborator Author

I also fixed the existing anti-refusal schema helper mentioned in the review. Its whitespace prefix no longer has overlapping repeats, so the full-size whitespace case stays bounded without cutting off valid schema text. Tests cover the reported input and the existing benign forms.

@mohgupta-ship-it

Copy link
Copy Markdown
Member

LGTM But please fix the UT

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SkillSpector Review]

Hi @yashrajp22, thank you for this round of fixes. A single matching allowance per rule and window, shared across paragraphs, closes the paragraph-split case cleanly: in my transcription, the 106-paragraph P5 window now stops at 0.250 s, where main takes 10.9 s and reports it complete. The new routing test (tests/nodes/analyzers/test_static_patterns.py:2712) gives a 0.0 limit only to the selected code rule. That is the guard that was missing, so a TM4 revert during the #790 resolution would now fail CI. You also merged #788 correctly, keeping both sides.

This re-review covers head 19140a6. Since 6b2c8c9a, the only new commits are two merges of main (4492841a, 19140a6e), which bring in #791 and #792. The PR's own diff is byte-identical between those two heads, so every line number below holds on 19140a6. Head results marked "transcription" come from my own hand copy of the head's code, run inside main 5edc347c. I did not import or run any PR code.

Value and readiness: The PR is still worth landing. On main, several catalog searches hold the GIL for seconds to minutes on crafted text:

  • the paragraph-split P5 window takes 10.9 s;
  • the TM1 SQL, OH1 and --set payloads run for 40 s or more;
  • newline-only Python takes minutes per window in the variable-shell precheck (extrapolated from 7.9 s at 32K newlines).

With this change, each of these either finishes or stops at the 0.25 s allowance with an explicit partial. Of the nine findings in my last review, six are resolved and three are partly resolved.

It cannot merge yet, because of two blockers:

  1. Variable-shell bound (finding 1). The new 4,096-character bound raises before any tool-misuse rule has run. About 4 KB of argument text with no ) after a harmless flag = True and a call head therefore removes every lexical tool-misuse finding for the file. On a valid 5.3K tool.py, the default CLI result changes from DO_NOT_INSTALL (exit 1) to CAUTION (exit 0).
  2. Red test-unit (finding 2). The required check failed on 6b2c8c9a, whose PR code is identical to 19140a6. The failing test is main's #678 test test_very_long_option_run_is_linear. The numeric chmod rules now run on the regex engine, which is quadratic on that input, so the search hits the 0.25 s limit. CI has not run on 19140a6 (action_required).

Findings 3-8 are non-blocking. The merge with main is clean.

Previous findings:

These use the numbering from my review of 2f896e2, followed by the earlier rounds, the PIC questions, and two issues I found after posting that review.

  • Finding 1 (anti-refusal conflict with #788): Resolved.
    • static_patterns_anti_refusal.py:577-591 keeps #788's AR2 _is_descriptive_python_comment skip first. The single hoisted lines, locations.line_and_column and get_context_from_lines follow it.
    • #788's check_runtime signature and _PythonComments are kept.
    • Your reply is accurate.
  • Finding 2 (a lint-clean #790 resolution could untime TM4): Resolved as a test gap.
    • A wrong resolution would now fail CI through four guards:
      • the TM4 payload ("tool_misuse", "--set " * 40_000) at test_static_patterns.py:2650;
      • the TM4 routing entry at :2709;
      • the all-callers check at :2890;
      • the one-parse test at :2925.
    • Your reply is partly accurate. The statements about the three callers, TM4 and the single parse are correct. The "newer shell-flow fix" that landed on main is #577 (5ee48efc), not #790.
    • #790 (head f4432cc9) is still open. It conflicts with this PR in 7 hunks of static_patterns_tool_misuse.py (see Verification), so the landing order is still a sequencing question.
  • Finding 3 (tests did not pin the code routing): Resolved.
    • :2712 sets a 0.0 limit only when the selected code rule compiles, and asserts reached == [target]. iter_pattern_matches calls _timed_pattern before it builds _PatternAllowance (static_runner.py:712-724), so a revert leaves reached == [] and the test fails.
    • Also added: the PE5 and TM4 payloads (:2649-2650), the newline-only tool.py case (:2742) and the all-callers check (:2890).
    • Your reply is accurate.
    • Minor gap: the AS3, RA2, EA, MP, E5 and SC7 code loops still have no routing entry at :2701-2711.
  • Finding 4 (fresh 0.25 s allowance per paragraph): Resolved.
    • One _PatternAllowance per rule and window is shared across paragraphs (static_runner.py:727-771).
    • The docs are updated (docs/ANALYSIS_RESOURCE_BOUNDS.md:258, :337-338), and there is a test at :2754.
    • Your reply is accurate.
  • Finding 5 (per-range timer overhead): Partly resolved.
    • Ranges of 8 characters or fewer now run on native re in batches of up to 128 (static_runner.py:752-771). A clock-count test is at :2766.
    • Your reply is accurate for those tiny ranges. Ranges of 9 or more characters still build a timed iterator each (finding 6).
  • Finding 6 (\b margin on non-ASCII text): Partly resolved.
    • \b becomes (?<!w) before a literal word and (?!w) after a word character (static_runner.py:474-491).
    • The unicode_paragraph and unicode_bundle fixtures are added (:2447-2459), with parity tests at :2786 and :2967.
    • Your reply is accurate for literal-word boundaries. Seven anti-refusal rules still use the four-lookaround form (finding 7).
  • Finding 7 (case-folded backreferences): Resolved for span parity.
    • The variable-shell rule runs on native re through _iter_variable_shell_flag_matches (static_patterns_tool_misuse.py:249-287).
    • _timed_pattern fails closed for backreferences under IGNORECASE on non-ASCII content.
    • My transcription of the iterator matched re.finditer in 30,000 of 30,000 fuzz cases.
    • Your reply is partly accurate. It says "Oversized argument tails report incomplete analysis". It does not mention that the raise removes every lexical TM finding (finding 1), or that a long later call completes without any partial (finding 4).
  • Finding 8 (docs closing sentence): Partly resolved.
    • docs/ANALYSIS_RESOURCE_BOUNDS.md:344-346 now names SSRF endpoint/request patterns, harmful-content substance names and DS1-DS4 as outside the matcher. Your reply is accurate for this.
    • Other new sentences in the docs and the PR body do not match the code (finding 8).
  • Finding 9 (AR schema-field helper): Resolved in this PR.
    • ^(?:\s*\[\])?\s+ (static_patterns_anti_refusal.py:189-199) accepts the same language as the old prefix.
    • Transcription: 22 ms at 256K characters, against 2.47 s at 8K for the old pattern. Results were identical on 200,000 fuzz strings.
  • Earlier rounds (reviews 1 and 2, and their six threads): still resolved.
    • The ledger keeps the search's own limit (static_runner.py:3967-3974).
    • MP2 stays on re.finditer (static_patterns_memory_poisoning.py:549-555).
    • E1 and the wget/git/chmod/chown command shapes use _LINEAR_COMMAND_PATTERNS (static_runner.py:543-560).
    • The docs list every routed group (:337-346).
    • An AST comparison shows the matcher functions are byte-identical between f776f8ac and 19140a6. The only change is that 2aef0f66 removed a dead RA2 string from the registry.
  • PIC questions:
    • Allowance for paragraph-matched rules: Resolved. The shared allowance was chosen.
    • One limit ends the analyzer for the file: Still open (see PIC tradeoffs).
    • Fixed 0.25 s limit: Still open (see PIC tradeoffs).
    • Landing order with #790: Still open (see PIC tradeoffs).
    • Helper regexes outside the matcher: Moot. The known helper is fixed, and docs :344-346 keep the others out of scope.
  • Two issues I found after posting my last review, raised here for the first time:
    • (A) The variable shell-flag pre-pass in analyze runs for every file type before any TM rule, so a limit there drops all TM findings: Still open.
      • The search itself is now bounded: native re, a 4,096-character tail and a 0.25 s allowance.
      • However, variable_matches is still built at static_patterns_tool_misuse.py:5154-5161, before TM1-TM4. Both the tail bound and the allowance raise from there (finding 1).
    • (B) #577's postprocess_path_findings ran the same quadratic search untimed over the whole file: Partly resolved.
      • The search is now bounded.
      • The whole-file pass gets its own fresh allowances, though. When it trips, the runner discards every finding for the file (finding 3).

Material findings

  1. [Blocker] src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py:273: the variable-shell tail bound raises before any tool-misuse rule has run, so the lexical tool-misuse analyzer loses every finding for that view.

    • Where it raises:

      • Loop 1: truncated = end < len(content) and ")" not in content[prefix.end() : end] (:273) raises STATIC_PARSE_LIMIT (:274-281). This happens whenever name = True is followed within 5 lines by a subprocess.*( or Popen( head and no ) appears in the next 4,096 characters.
        • Nothing checks whether a shell= follows.
        • The raise comes after match has been computed, so even a match already found inside the window is discarded.
      • Loop 2 raises the same error for true-prefixed names (:4207-4214).
    • Why it drops everything:

      • analyze builds variable_matches from _variable_shell_matches(content) for every file type (:5154-5161), before TM1-TM4, and nothing in analyze catches the error.
      • _scan_path keeps only finding_budget.current_created (static_runner.py:1475-1495), which is still empty at that point.
      • The window loop then stops the analyzer for the remaining windows and views (static_runner.py:3201-3207). For files over 256,000 characters, TM findings from earlier windows survive.
    • The PR's own tests pin this behaviour:

      • :2916 and :2950 assert the raise for flag = True\nsubprocess.run(cmd, shell=flag, + 4,096 x + , shell=flag).
      • In that content, the bounded native match succeeds inside the window at (0, 42). Main reports TM1 HIGH at line 1. The model reports nothing.
    • Measured (transcription of :236-288 and :4163-4221, patched into main 5edc347c):

      Input main head model
      SKILL.md, 4,289 chars: flag = True, subprocess.run(, 4,200 a, then a curl ... | sh line TM2 HIGH line 5, complete nothing; partial static_parse_limit
      SKILL.md: benign verbose = True, subprocess.run([, then 300 "--opt-N", lines TM2 line 308 nothing
      install.sh with the call head only in a comment (# Popen( + 4.1K characters) TM2 line 4 nothing
      tool.py: direct shell=True, then use_shell = True and a 4,400-char argument TM1 lines 2 and 3 nothing
      tool.py, loop-2 trigger (true_flag = True) TM1 line 2 nothing
      248 KB install.sh with dense f = True / Popen( headers, a ) every 100 lines, so nothing truncates TM2 line 1, complete nothing; partial runtime_limit from the 0.25 s variable-shell allowance
      Control: first row with a ) inside the bound TM2 TM2
    • Full CLI (scan --no-llm, default flags):

      • Valid 5,326-character tool.py: a function calling subprocess.run(cmd, shell=True), then use_shell = True and a Popen call with 220 options and shell=use_shell.
        • Main: exit 1, risk 57, HIGH, DO_NOT_INSTALL, complete.
        • Model: exit 0, risk 42, MEDIUM, CAUTION, partial.
        • The direct shell=True TM1 at line 5 is gone. The line-9 TM1 survives only because the separate AST analyzer (static_python_shell_truthiness) emits it.
      • For the first SKILL.md row, the risk drops from 48 to 30 and TM2 is lost.
    • Consequence:

      • About 4 KB of text with no ), after a harmless flag = True and a call head (even one in a comment), suppresses direct shell=True, curl | sh and every other lexical TM finding in a single-window file of any type.
      • Only a partial ledger event remains. --fail-on-incomplete defaults to off (cli.py:563-569), so the default gate can flip from fail to pass.
      • Benign files with long argument lists get false partials and lose findings that main reports.
      • Docs :353-356 say only "a potential match extending beyond that span" reports the limit. Here it fires with no shell= at all, and after an in-window match.
    • Expected fix:

      • At :273, yield match when it is not None. Skip the candidate (continue) and record truncation only when there is no in-window match. Optionally record it only when shell\s*= follows the window end.
      • Use content.find(")", prefix.end(), end) instead of slicing.
      • Handle loop 2 (:4207-4214) the same way.
      • Treat the 0.25 s variable-shell allowance the same way: stop variable-shell scanning, record the partial, and let TM1-TM4 run. This part is required, not optional, because of the 248 KB case above.
      • Report the partial without raising. One way is the module's has_bounded_parse_exhaustion hook (:4986), which the runner turns into STATIC_PARSE_LIMIT without discarding findings (static_runner.py:2966). That hook runs separately on the full artifact, so it needs its own runtime-aware recomputation, or you add another non-raising signal.
      • Update :2916 and :2950 to assert that the in-window TM1 is kept along with the partial.
      • Add regression tests:
        • the 4.3 KB SKILL.md still reports TM2;
        • a tool.py with a direct shell=True and a truncating header keeps TM1;
        • the comment-only install.sh keeps TM2;
        • the long benign option list keeps TM2;
        • the dense-header allowance case keeps TM2.
  2. [Blocker] src/skillspector/nodes/analyzers/static_patterns_privilege_escalation.py:1025: #678's numeric chmod rules are quadratic on the timed engine, and main's linearity test failed in CI.

    • Routing:

      • The PR sends the PE2 code loop (:1021-1027) and the TM1 ordinary rules (static_patterns_tool_misuse.py:4523-4528) through static_runner.iter_pattern_matches.
      • That includes #678's chmod\s+(?:-[\w=-]*[ \t]+)*[\"']?[+=]?0*[2-7][0-7]{3}... (static_patterns_privilege_escalation.py:110) and its TM1 form (static_patterns_tool_misuse.py:351).
      • Neither rule is in _LINEAR_COMMAND_PATTERNS (static_runner.py:543-560), so both get the fixed 0.25 s allowance (:329).
    • CI:

      • Run 37906385989 on 6b2c8c9a: test-unit reported 1 failed, 13140 passed.
      • The failure is test_chmod_regex_backtracking.py::TestChmodOptionGroupDoesNotBacktrack::test_very_long_option_run_is_linear. It raised _StaticResourceLimitError: runtime_limit from static_patterns_privilege_escalation.py:1027, with _PatternAllowance(seconds=0.2502516220000004, limit=0.25), on 'chmod ' + '-- ' * 30000 + 'x' (90,007 characters).
      • test-unit passes on main 5edc347c.
      • CI on 2f896e2 (run 37588225375) passed this test with the same routing. On CI hardware this search therefore sits right at the limit: it is flaky there rather than always failing.
    • Measured (regex 2026.5.9, Apple silicon, best of 3):

      Characters regex re
      6K 2.8 ms 0.2 ms
      12K 11.4 ms 0.5 ms
      24K 46 ms 0.9 ms
      48K 183 ms 2.0 ms
      90K 635 ms 4.4 ms
      • These are PE2 timings; TM1 is the same (646 ms against 4.1 ms at 90K).
      • In the transcribed timed matcher, both rules stop at 0.250 s. An emulation of main's test with the head's routing raises runtime_limit from both PE and TM analyze on every run here.
      • Main's modules complete the same input in 0.075 s (PE) and 0.057 s (TM).
    • Consequence:

      • The required check is red on the last head that ran CI. It fails every run on this machine and will fail on slower runners.
      • In the product, a 90K option run becomes a 0.25 s partial where main completes in about 5 ms. The analyzer then stops for the rest of the file: PE3-PE5 and later TM rules are skipped, and only findings already created are kept.
      • Exposure needs adversarial input: about 50K or more characters of option tokens here, about 90K on CI.
    • Do not exempt these rules to native re the way MP2 is exempted.

      • #678 is linear only for the option-run shape.
      • On main's native re, "chmod " + "-chmod " * k takes 0.30/1.24/5.2/22.7/121 s at 14K/28K/56K/112K/252K characters, for PE2 and TM1 alike.
      • Main is unbounded on that shape today. This PR's timed routing is what caps it.
    • Expected fix:

      • Make the option group atomic in both rules: (?>(?:-[\w=-]*[ \t]+)*).
        • A mode cannot start with - or whitespace, and giving back part of [\w=-]* leaves a non-blank character where [ \t]+ must match. So the group has a single parse, and re spans stay the same: they were identical on a 17-case positive and negative corpus.
        • In regex, the 90K search drops from 781 ms to 5 ms in the same run (9 ms at 180K). Under the transcribed timed matcher, PE2 and TM1 complete in about 8 ms.
        • Atomic groups are already in the catalog (static_patterns_tool_misuse.py:86, static_patterns_memory_poisoning.py:160).
        • The -chmod shape stays quadratic, but the timed engine still caps it at 0.25 s.
      • Add a span-parity test between the old and new patterns.
      • Add a guard test for "chmod " + "-chmod " * k that asserts a bounded finish, accepting runtime_limit with limit_seconds <= 0.25.
      • A dedicated audited linear matcher is the alternative. The existing one only splits [^|]*/[^&]* shapes, so it does not cover this rule.
      • Either way, get a green test-unit on the live head.
  3. [Non-blocking] src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py:4182: postprocess reruns variable-shell matching over the whole file with fresh allowances, and when that pass trips, every finding for the file is discarded.

    • Code path:
      • postprocess_path_findings (:6776) calls _reconcile_variable_shell_findings (:6787).
      • Whenever a variable-flag finding exists, that function runs _variable_shell_matches(content) over the full file (:6679), with new allowances from :252 and :4182.
      • The runner catches the error with path_findings = [] (static_runner.py:3891-3892).
      • Per-window limits go through _scan_path instead, which keeps the findings.
    • Measured (transcription):
      • B2, a 989,553-character valid tool.py:
        • Each per-window call takes 0.08-0.11 s, and every other TM rule takes at most 0.049 s per window, so every window completes.
        • The whole-file postprocess call raises runtime_limit at 0.251 s.
        • Main reports TM1 at lines 2 and 3 (complete, 4.4-5.0 s). The model reports no findings and a partial runtime_limit.
        • The same shape is complete at 650K characters and loses everything at 800K.
      • B3, 301,307 characters: a variable flow in window 1 and a truncating call at the end.
        • Window 2 raises finding 1's parse limit, and _scan_path keeps the window-1 finding.
        • Postprocess then raises again on the whole file, and the runner drops that finding. Main reports TM1 at line 3.
        • B3 depends on finding 1. B2 does not.
    • Consequence: a multi-window Python file can lose every TM finding even though every window finished within its allowance. This contradicts docs :363-365 ("retaining findings already produced").
    • Expected fix:
      • When postprocess raises _StaticResourceLimitError, keep the findings through _cleanup_expired_path_findings (static_runner.py:3487) and record the partial, instead of returning [].
      • Give postprocess one allowance per window, or reuse the window-level matches.
      • Add a multi-window test.
  4. [Non-blocking] src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py:271: the 4,096-character span is measured from the first call head, so a later call in the same window can be missed with no partial, or matched where re finds nothing.

    • Why it happens:
      • The prefix's lazy {0,4}? line gap stops at the first line that contains a call head, and end = prefix.end() + 4096 (:271).
      • truncated (:273) only checks whether any ) exists, and the first call's own ) satisfies it.
      • Loop 2 covers later calls only for names that start with "true".
    • Measured (transcription) on a tool.py with use_shell = True, then subprocess.run(a), then subprocess.run(\n ' + 4,100 x + ',\n shell=use_shell):
      • Main reports TM1 at line 2. The model reports no TM1, and the ledger is complete.
      • Full CLI: main gives exit 1, risk 69, DO_NOT_INSTALL. The model gives exit 0, risk 40, CAUTION, coverage complete.
      • The miss starts at 4,051 x. At that size the later call's own tail is 4,079 characters, which is within the documented 4,096 bound.
    • The opposite direction:
      • Input: f = True, subprocess.run(x), then a padded call ending in , shell=foo), with shell=f cut exactly at the window end.
      • The iterator returns span (0, 4120) with name f, where re.finditer finds nothing.
      • In tool.py, this adds a false TM1 HIGH at line 2. The CLI moves from exit 0, risk 24, CAUTION on main to exit 1, risk 53, DO_NOT_INSTALL.
    • Consequence:
      • These are Python-only differences from main in both directions. One of them silently flips the default gate to pass.
      • Docs :354-356 promise static_parse_limit for this case.
      • The impact is limited, because the heuristic is already evadable on main (flag = not False).
    • Expected fix:
      • Give each call head its own 4,096-character span, or record truncation when any call head in the window lacks a ) within its own span.
      • Restore the end-of-window word-boundary check.
      • Add the decoy-then-long-call and cut-at-boundary cases next to :2877.
  5. [Non-blocking] src/skillspector/nodes/analyzers/static_patterns_rogue_agent.py:327: #658's RA2 hidden-directory rule now runs on the timed engine, and single-line text of roughly 10-50K characters becomes partial.

    • Cause:

      • The #658 merge gave _HIDDEN_DIRECTORY_WRITE_PATTERN (:159-162, used at :199) a separator set, (?:[^|&;<line breaks>]|\\\r?\n)*, that the linear matcher cannot handle.
      • This PR's RA2 loop (:324-329) then sends it to _timed_pattern with the 0.25 s allowance, on every file type.
      • 2aef0f66 only removed a dead registry string; that removal changed no behaviour.
    • Measured (transcription, single-line inputs):

      Input head model main re
      Dense prose ("We write the guide and create examples for users, " repeated, ending in a non-hidden ~/config), 8K completes in 0.20 s 0.04 s
      Same, 10K runtime_limit 0.06 s
      Same, 12.5K runtime_limit 0.09 s
      Same, 50K runtime_limit 1.43 s
      Same, 100K runtime_limit 5.6-6.3 s
      One "create" per about 500 characters, 25K completes in 0.12 s
      Same, 38K borderline
      Same, 51K runtime_limit 0.08 s
      Minified JSON MCP tool schema: 100 tools whose descriptions say "Create ... write ...", with /tmp/out/ defaults; 23.8K runtime_limit 0.08 s
      250K multi-line control completes in 0.05 s
      • Without a ~/, /home/ or /tmp/ after the keywords, the search fails fast.
    • Consequence:

      • The expiry stops analyze for that view. Every RA2 rule after this one never runs: nohup/disown/setsid, HKEY_/reg add, plist/launchctl and the RA2 prose rules. RA1 findings, which run earlier, are kept.
      • In a CLI model run (main's CLI with rogue_agent's RA1/RA2 code-rule searches swapped for the transcribed timed path), a file with a 12.5K benign line plus nohup and reg add lines loses both RA2 MEDIUM findings (risk 10 drops to 0). The ledger records runtime_limit.
      • The partial clears MCP safe_to_install and fails --fail-on-incomplete.
      • Above about 100K characters the bound is a clear win.
      • The registry test (test_static_patterns.py:2480) recognises only [^|]*/[^&]* shapes, so it will not flag this rule.
    • Expected fix:

      • Either extend the audited linear matcher to this separator set (with backslash-newline continuation), or pre-check each logical-line segment for the required suffix (?:~/|/home/|/tmp/)\.(?!git|ssh|aws)[a-z_-]+ and skip segments that lack it.
      • Add a regression test that a 24K minified JSON schema and a 12.5K single-line prose input with a non-hidden home or tmp path complete.
  6. [Non-blocking] src/skillspector/nodes/analyzers/static_runner.py:747: paragraph ranges longer than 8 characters still pay per-range timed-iterator overhead.

    • Only ranges of 8 characters or fewer take the native batch (:752-771).

    • Each longer range goes through _iter_pattern_matches (:624-695). That builds a new generator, reads time.thread_time() (:651 and the finally block), checks the budget and creates a new finditer(..., timeout=...) (:658-664).

    • Measured (transcription, five prose analyzers, one 256K window):

      Paragraph length main head ratio
      8 characters 2.04 s 2.58 s 1.26x
      9 characters 1.93 s 6.62 s 3.42x
      14 characters 1.66 s 5.31 s 3.19x
      35 characters 1.35 s 3.32 s 2.46x
      120 characters 0.91 s 1.68 s 1.86x
      • A second run gave 1.39x, 3.07x, 2.99x, 2.63x and 2.03x.
    • Where the cost comes from: with timeout= but no wrapper, the ratio is 1.57-1.93x; with the translated pattern and no timeout=, it is 1.08-1.24x. So about half the extra cost is the timed iterator itself, and half is the per-range clock and budget work.

    • Consequence:

      • Paragraph-dense windows cost 2-3.4x more GIL-holding CPU. For 120-character paragraphs that is about 0.8 s extra per 256K window across these five analyzers.
      • Typical few-KB files pay only milliseconds, and the artifact deadline bounds the rest. It is not a hang.
    • Expected fix:

      • Keep the 8-character native bound.
      • Reading the clocks once every N ranges removes only the wrapper half of the cost. To get close to main, ranges just above 8 characters must also avoid a new timeout= iterator per range. One option is to batch them on the untimed path, up to whatever per-range length the catalog's backtracking analysis allows. The other is to accept about 1.6-1.9x and document it.
      • Add a timing test with paragraphs of 9 or more characters next to :2766. That test currently uses 1-character ranges only.
  7. [Non-blocking] src/skillspector/nodes/analyzers/static_runner.py:474: the \b shortcut does not reach seven anti-refusal rules, which use 106-186 ms of the 250 ms allowance on non-ASCII 256K windows.

    • Why it misses them:

      • literal_word (:474-480) accepts only [A-Za-z0-9_]+ or a flat literal alternation.
      • So leads such as \b(?:do\s+not|don'?t|never|no)\s+ fall through to (?:(?<!w)(?=w)|(?<=w)(?!w)) over the Unicode word class (:491).
      • The affected rules are AR1#1, AR1#6, AR2#1, AR2#3, AR2#4, AR3#2 and AR3#8. The other 16 are already fast.
    • Measured (transcription, best of 3, Apple silicon):

      Corpus Rule head re
      minified JS AR1#6 165 ms 6.0 ms
      Chinese docs AR3#8 167 ms 2.8 ms
      CSV names AR3#8 173 ms 4.6 ms
      French locale AR2#4 173 ms 5.8 ms
      • Across runs the range is 106-186 ms.
      • Forcing (?<!\w) on the leading \b gives 4.3-17.3 ms.
      • On a 135K corpus with 648 re matches next to 28 kinds of non-ASCII neighbour, all 23 AR rules kept spans identical to re, with both the current translation and the forced form.
    • Consequence:

      • The headroom is limited on slower single-thread CPUs, such as the x86 CI runners, where I have no measured ratio.
      • Contention results were mixed: 1-2 competing zlib threads caused no retries today.
      • A trip clears safe_to_install and fails --fail-on-incomplete.
    • Expected fix:

      • Treat \b as (?<!\w) before a non-optional group in which every top-level branch starts with a literal word character, recursing into nested groups.
      • Add an AR-catalog span-parity test on text with real matches next to non-ASCII neighbours.
      • Alternatively, settle this through the fixed-limit PIC question.
  8. [Non-blocking] docs/ANALYSIS_RESOURCE_BOUNDS.md:365: the docs and PR body describe behaviour the code does not have.

    • :363-365 ("retaining findings already produced"): the postprocess path discards produced findings (finding 3).
    • :353-356 ("A potential match extending beyond that span reports static_parse_limit"): this fails in both directions.
      • The code raises with no shell= at all, and after an in-window match (finding 1).
      • A long later call completes with no limit (finding 4).
    • :258, :337 and :355 (one 0.25 s allowance "per rule and content window", "the same accumulated matching CPU allowance"):
      • _variable_shell_matches runs twice per window: in the analyze pre-pass, and in _tm1_candidates (static_patterns_tool_misuse.py:4520).
      • Each call creates two allowances (:252, :4182), and postprocess adds a whole-file pass.
      • Measured (transcription): one completed 249,899-character SKILL.md window spent 0.36-0.45 s of variable-shell matching CPU, with a complete ledger.
      • By the code, the upper bound is about 1.0 s per window.
    • PR body:
      • "Interruptible searches now preserve earlier findings" does not hold for finding 3.
      • "Oversized argument tails report their character limit" does not hold for finding 4.
      • The 6,243-test figure describes your own subset. The body does not mention that the full suite has a failure (finding 2).
    • Consequence: readers and PICs will assume that findings survive limits and that the per-rule bound holds.
    • Expected fix:
      • Once findings 1-4 are settled, correct :258, :337, :355-356 and :365, or state the exceptions.
      • Compute the variable-shell matches once per window under one allowance.
      • Update the PR body's two behaviour claims and its verification statement.

PIC tradeoffs:

  • The first limit ends the analyzer for the file (still open).
    • What happens:
      • _scan_path returns at the first _StaticResourceLimitError (static_runner.py:1475-1495).
      • The window and view loops then return too (:3201-3207, and the handlers at :2936, :3069, :3137 and :3377), because defer_output_limit (:2819-2829) defers only OUTPUT_LIMIT.
    • This policy predates the PR (main static_runner.py:1037-1057), and docs :363-365 describe it as intended.
    • What the PR adds are cheap rule-local triggers:
      • the 0.25 s allowance;
      • the deterministic 4 KB variable-shell bound (finding 1, STATIC_PARSE_LIMIT, which drops all lexical TM findings, not just later ones);
      • the 90K chmod line (finding 2);
      • RA2 single-line prose (finding 5).
    • Options:
      • keep the policy;
      • or skip only the expired rule for that window, still bounded by the artifact deadline.
    • Fixing findings 1, 2 and 5 removes the cheapest triggers either way.
  • Fixed 0.25 s limit (still open).
    • _STATIC_PATTERN_SECONDS = 0.25 (static_runner.py:329) is a constant. :641 can only lower it, and no setting raises it.
    • The CI run shows a single search landing at 0.2502 s, and the AR rules in finding 7 use up to about three quarters of the allowance.
    • Options:
      • raise the constant, for example to 0.5-1.0 s;
      • add a validated setting;
      • or rely on the fixes in findings 2 and 7.
  • Landing order with #790 (still open).
    • #790 is open, and it conflicts with both main and this PR.
    • Whichever PR lands second should keep static_runner.iter_pattern_matches in TM2-TM4 and add #790's runtime_check().
    • test_static_patterns.py:2650 and :2709 now catch a TM4 revert.

Verification and gaps:

  • PR state (gh, read-only, rechecked just before writing):
    • The head is 19140a6.
    • GitHub reports it as mergeable, with state BLOCKED pending review and CI.
  • Changes since 6b2c8c9a:
    • The new commits only merge main's #791 and #792 (cli.py, static_yara.py, suppression.py, the Pi extension).
    • The PR's diff against its base is byte-identical.
    • static_patterns_tool_misuse.py, static_runner.py and the docs are unchanged.
  • CI:
    • On 19140a6, run 37934064346 is action_required and has not run.
    • On 6b2c8c9a, run 37906385989: lint, changes, DCO Check, OpenCode TypeScript Tests and docker-smoke passed. test-unit failed (finding 2).
  • Conflicts:
    • With main 5edc347c: none. git merge-tree gives a clean tree (f0caf108).
    • With #790 (head f4432cc9): 7 conflict hunks in static_patterns_tool_misuse.py. They cover the get_context import, the analyze signature, ctx caching, TM1 line/context, and TM2, TM3 and TM4.
    • With #793, #794 and #795: clean.
  • Method:
    • Baselines come from main's own analyzers and CLI.
    • Head behaviour comes from my transcriptions, run with python -I inside main 5edc347c:
      • _iter_variable_shell_flag_matches and _variable_shell_matches;
      • _timed_pattern, _PatternAllowance, _iter_pattern_matches and iter_paragraph_matches.
    • Each transcription was diff-checked against the head's lines.
  • Also checked and found correct:
    • SourceLocationIndex line numbers match main.
    • Variable-shell loops on adversarial input stay bounded: 256K blank lines take 0.047 s, and a true-prefixed shell=a payload that takes main 5.0 s at 8,000 repetitions is cut immediately.
    • The ledger keeps per-search limits.
  • Gaps:
    • All timings come from Apple silicon with Python 3.13 and regex 2026.5.9, and the machine was somewhat loaded. CI uses Python 3.12 on x86.
    • I could not explain why the chmod test passed on 2f896e2's CI. I assume it is borderline there.
    • I did not evaluate whether _bound_direct_replays (static_patterns_tool_misuse.py:6511, :6583) can lose replays now that direct_matches is timed. It discards the limit that _scan_view_windows returns.
    • I did not verify the PR body's test counts, its source/wheel parity or its live-model results.
    • The #790 conflict analysis is merge-tree only.
  • I did not run the PR's tests or code, per policy.

Decision: Changes Requested (reviewed head 19140a6e76031834f846f02c053e8e20e1a7dc9e)

continue
end = min(len(content), prefix.end() + _VARIABLE_SHELL_ARGUMENT_CHARS)
match = _VARIABLE_SHELL_FLAG_RE.match(content, start, end)
truncated = end < len(content) and ")" not in content[prefix.end() : end]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] This raise runs before any tool-misuse rule. analyze builds variable_matches from _variable_shell_matches(content) for every file type (:5154-5161) before TM1-TM4, and _scan_path keeps only finding_budget.current_created (static_runner.py:1475-1495), which is still empty at that point. The raise fires whenever name = True is followed within 5 lines by a subprocess.*(/Popen( head and no ) appears in the next 4,096 characters. It does not check for a following shell=, and it discards match even when the bounded search already matched inside the window. Loop 2 at :4207-4214 behaves the same way.

In my transcription of this code (patched into main 5edc347c), each of these loses every lexical TM finding:

  • a 4,289-character SKILL.md loses curl | sh TM2 at line 5;
  • a tool.py loses a direct shell=True TM1;
  • an install.sh with the call head only in a comment loses TM2;
  • the content in :2950 loses TM1 at line 1, which main reports.

The full CLI on a valid 5,326-character tool.py goes from exit 1, DO_NOT_INSTALL to exit 0, CAUTION, partial. --fail-on-incomplete is off by default.

Suggested fix:

  • Yield match when it is not None.
  • Skip the candidate and record truncation (without raising) only when there is no in-window match.
  • Treat the 0.25 s variable-shell allowance the same way. A 248 KB install.sh with dense headers trips it and loses TM2.
  • Update :2916 and :2950 to assert that TM1 is kept along with the partial.

See finding 1.

static_runner.iter_paragraph_matches
if (pattern, confidence) in PE2_PROSE_PATTERNS
else re.finditer
else static_runner.iter_pattern_matches

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocker] This routes #678's numeric chmod rule (:110), and the TM1 form at static_patterns_tool_misuse.py:351, onto the regex engine with the fixed 0.25 s allowance. On 'chmod ' + '-- ' * 30000 + 'x', regex is quadratic: 635 ms at 90K characters, against 4.4 ms in re.

CI run 37906385989 on 6b2c8c9a (same PR code as 19140a6) failed test-unit with 1 failed, 13140 passed. The failure is main's test_chmod_regex_backtracking.py::test_very_long_option_run_is_linear: _StaticResourceLimitError: runtime_limit with seconds=0.2502516220000004, limit=0.25. It passed on 2f896e2's CI, so it sits at the limit there. Here it fails on every run.

Do not exempt the rule to native re, because "chmod " + "-chmod " * k is quadratic there (121 s at 252K characters on main).

Suggested fix: make the option group atomic in both rules, (?>(?:-[\w=-]*[ \t]+)*).

  • A mode cannot start with - or whitespace, so the group has one parse, and re spans stay the same.
  • In regex the 90K search drops to about 5 ms.
  • Add a span-parity test and a bounded-time guard for the -chmod shape.
  • Get a green test-unit on the live head.

See finding 2.

for match in _iter_variable_shell_flag_matches(content):
keep(match)
for assignment in _VARIABLE_SHELL_ASSIGNMENT_RE.finditer(content):
allowance = static_runner._PatternAllowance()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every call gets a fresh allowance here, including the whole-file call from _reconcile_variable_shell_findings (:6679) during postprocess. When that call raises, the runner sets path_findings = [] (static_runner.py:3891-3892).

In my transcription, on a 989,553-character valid tool.py:

  • every window completes (0.08-0.11 s per call);
  • the postprocess pass raises runtime_limit at 0.251 s;
  • all findings are lost, where main reports TM1 at lines 2 and 3.

A 301K file loses its window-1 finding the same way through the parse limit.

Suggested fix:

  • On a postprocess limit, keep the findings through _cleanup_expired_path_findings (static_runner.py:3487) and record the partial.
  • Give postprocess one allowance per window, or reuse the window-level matches.
  • Add a multi-window test.

See finding 3.

prefix = _VARIABLE_SHELL_PREFIX_RE.match(content, start)
if prefix is None:
continue
end = min(len(content), prefix.end() + _VARIABLE_SHELL_ARGUMENT_CHARS)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

end is measured from the first call head that the lazy prefix reaches, and truncated only checks whether any ) exists. The first call's own ) satisfies it.

In my transcription of use_shell = True, subprocess.run(a), subprocess.run(\n ' + 4,100 x + ',\n shell=use_shell) in tool.py:

  • Main reports TM1 at line 2. This code reports nothing, and the ledger is complete.
  • The CLI goes from exit 1, DO_NOT_INSTALL to exit 0, CAUTION.
  • The miss starts at 4,051 x, where the later call's own tail is still within 4,096 characters.

In the other direction, cutting , shell=f exactly at the window end yields a span that re.finditer does not find. In tool.py that adds a false TM1 HIGH, and the CLI goes from exit 0 to exit 1.

Suggested fix:

  • Give each call head its own 4,096-character span, or record truncation when any head lacks a ) within its own span.
  • Restore the end-of-window word-boundary check.
  • Add both cases next to test_static_patterns.py:2877.

See finding 4.

is_prose = (pattern, confidence) in RA2_PROSE_PATTERNS
matches = static_runner.iter_paragraph_matches if is_prose else re.finditer
matches = (
static_runner.iter_paragraph_matches if is_prose else static_runner.iter_pattern_matches

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sends #658's _HIDDEN_DIRECTORY_WRITE_PATTERN (:159-162) to the timed engine, on every file type. Its [^|&;<line breaks>] separator set is outside the linear matcher.

In my transcription, single-line text with many create/write words before a non-hidden ~/ or /tmp/ path hits runtime_limit. Main's re finishes the same inputs quickly:

Input main re
Dense prose, about 10K characters 0.06 s
Dense prose, 12.5K characters 0.09 s
A 23.8K minified JSON tool schema 0.08 s

The expiry also skips the later RA2 rules for that view. A file with a 12.5K benign line plus nohup and reg add lines loses both RA2 MEDIUM findings.

Suggested fix:

  • Extend the audited linear matcher to this separator set, or skip logical-line segments that lack the required (?:~/|/home/|/tmp/)\. suffix.
  • Add a regression test with a 24K minified JSON input and a 12.5K prose line.

See finding 5.

budget.check_runtime()
allowance.check()
start, end = ranges[cursor]
if end - start > 8:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ranges longer than 8 characters still each build a generator, read the clocks, check the budget and create a new finditer(timeout=...).

In my transcription (five prose analyzers, one 256K window), against main:

  • 9-character paragraphs cost 3.1-3.4x;
  • 35-character paragraphs cost 2.4-2.6x;
  • 120-character paragraphs cost 1.9-2.0x, about 0.8 s extra per window.

About half of the extra cost is the timed iterator itself, so reading the clocks every N ranges only recovers the other half.

Suggested fix:

  • Keep ranges just above 8 characters off the per-range timeout= iterator, for example by batching them on the untimed path up to a length the catalog's backtracking analysis allows. Otherwise document the roughly 1.6-1.9x floor.
  • Add a test with paragraphs of 9 or more characters next to test_static_patterns.py:2766.

See finding 6.

parts.append(categories[code])
elif code == "b":
following = source[cursor + 2 :]
literal_word = re.match(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

literal_word accepts only [A-Za-z0-9_]+ or a flat literal alternation. Leads such as \b(?:do\s+not|don'?t|never|no)\s+ therefore fall through to the four-lookaround form at :491.

In my transcription, seven anti-refusal rules use 106-186 ms of the 250 ms allowance on non-ASCII 256K windows, against 3-7 ms in re. The rules are AR1#1, AR1#6, AR2#1, AR2#3, AR2#4, AR3#2 and AR3#8. Forcing (?<!\w) gives 4-17 ms, and spans were identical on a corpus with 648 real matches next to non-ASCII neighbours.

Suggested fix:

  • Treat \b as (?<!\w) before a non-optional group in which every top-level branch starts with a literal word character, recursing into nested groups.
  • Add an AR-catalog parity test on text with matches.

See finding 7.

can interrupt a search early. Retrying discards progress and can still exhaust the
matching allowance under sustained contention. If a search expires, its ledger
event keeps the observed time and limit, and that analyzer records partial coverage
for the artifact while retaining findings already produced.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several sentences here describe behaviour the code does not have:

  • "retaining findings already produced": a limit in the whole-file postprocess pass discards them (static_runner.py:3891-3892).
  • :355-356, "A potential match extending beyond that span reports static_parse_limit":
    • the bound also raises with no shell= at all, and after an in-window match;
    • a long later call in the same window completes with no limit.
  • :258 and :337, one 0.25 s allowance per rule and window: _variable_shell_matches runs twice per window, each call has two allowances (static_patterns_tool_misuse.py:252, :4182), and postprocess adds a whole-file pass. I measured 0.36-0.45 s in one completed window.

The PR body repeats the first two claims.

Suggested fix:

  • After the code fixes, correct these lines or state the exceptions.
  • Compute the variable-shell matches once per window.
  • Update the PR body, including the full-suite CI failure.

See finding 8.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants