Repository navigation
fix(analyzer): bound static pattern regex searches - #741
yashrajp22 wants to merge 64 commits into
Conversation
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>
rng1995
left a comment
There was a problem hiding this comment.
[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
- [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,\dand\bis expanded to full-Unicode tables.\walone is about 750 ranges, and each\bbecomes 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 ownREADME.mdanddocs/*.md, it took 92/190/285/366 ms at 64K/128K/192K/256K characters. Pythonreonmaintakes 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_pathreturns early. TM1's remaining patterns and TM2-TM4 are skipped for that file, and the file is marked partial. MCPsafe_to_installthen becomes false, and--fail-on-incompleteexits 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, TM1curl\s+[^|]*-k\btakes 0.134 s inreand 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_textalready uses withregex.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.
- When the content contains any non-ASCII character, every
- [Blocker]
src/skillspector/nodes/analyzers/static_runner.py:506: the per-pattern limit is lost before it reaches the ledger.iter_pattern_matchesreportslimit_seconds: 0.25. However,run_static_patterns_with_ledgertreats every RUNTIME_LIMIT as an expired artifact and replaces the metrics withlimit_seconds: runtime_limit(300 s, or the remaining workflow time) atstatic_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 atSKILLSPECTOR_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.
- [Non-blocking]
static_runner.py:478and:484: both clocks measure process-wide CPU time.time.process_time()and theregextimeoutcount every thread in the process.- With three threads hashing outside the GIL, a search with
timeout=0.202raised 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.
- With three threads hashing outside the GIL, a search with
- [Non-blocking]
static_runner.py:307and:338:lru_cachedoes 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-
\bpattern 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.
- [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:1040and: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" * 8000stays quadratic. - Please state the covered scope in the PR body, or extend the change to these loops.
- Privilege escalation: the PE1-PE3 code loops, and PE4/PE5 at
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_pathbehaviour), 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 repeatedsendtook 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).
- P2
- New bound, using my transcriptions with
regexand 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-stringexecutecontainingDROP TABLE. - Partial reporting: I traced
iter_pattern_matches→_StaticResourceLimitError→_scan_path, which keeps findings already created. No module definespostprocess_path_findings, so_cleanup_expired_path_findingskeeps 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,
\Bor\N. The VERBOSE regexes in anti_refusal and output_handling never reach the timed matcher. Every transcription I ran produced the same spans asre. - CI: the first commit failed
test-unitwith three RUNTIME_LIMIT/partial results on legitimate fixtures, and6bc8c39passed. Head7a5a1ceadds only bot merges ofmain, and the PR diff is unchanged (same patch-id). CI on the head isaction_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)
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
|
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
left a comment
There was a problem hiding this comment.
[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-kand--insecureare absent. The rest is new finding 1. - 2 (per-search limit lost in the ledger): Resolved.
static_runner.py:2856now keeps the search's metrics. The test assertslimit_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, withinmaxsize=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
- [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: E1curl\s+[^|]*(?:-d|--data|--data-raw|--data-binary)\s+takes about 1.07 s on the PR'scommand_referencefixture, against 0.21 s inreonmain. 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, butregexneeds about 1 µs per character for it, twicere. It took 0.26-0.32 s on the PR'sunicode_docsfixture across my runs, and 0.22 s on the command fixture. Onmainthis 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 linesort -k 2 results.txtadded to the command fixture, TM1curl\s+[^|]*-k\bruns 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
-konce. - 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+[^|]*SUFFIXfamily: 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.
- 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
- [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. Throughiter_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. - [Non-blocking]
static_runner.py:521-545: the retry counts thread CPU correctly, butregex'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_pathreturns at the first_StaticResourceLimitError(static_runner.py:1217), and_scan_all_views_detailedreturns 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
regexis 2-5 times slower thanreon 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 subclassesRuntimeError, so I checked the timed modules forexcept 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_callsno longer needs the timed matcher. Onmain, 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 onmain. 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,-aand--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 asreon four 256K corpora: repo docs, theunicode_docsfixture, 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)
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>
Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
…ttern-regex-timeouts Signed-off-by: yashrajbasav <yashrajbasav@nvidia.com>
|
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. |
|
LGTM But please fix the UT |
rng1995
left a comment
There was a problem hiding this comment.
[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
--setpayloads 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:
- 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 harmlessflag = Trueand a call head therefore removes every lexical tool-misuse finding for the file. On a valid 5.3Ktool.py, the default CLI result changes from DO_NOT_INSTALL (exit 1) to CAUTION (exit 0). - Red
test-unit(finding 2). The required check failed on6b2c8c9a, whose PR code is identical to19140a6. The failing test is main's #678 testtest_very_long_option_run_is_linear. The numeric chmod rules now run on theregexengine, which is quadratic on that input, so the search hits the 0.25 s limit. CI has not run on19140a6(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.
- 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)attest_static_patterns.py:2650; - the TM4 routing entry at
:2709; - the all-callers check at
:2890; - the one-parse test at
:2925.
- the TM4 payload
- 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 ofstatic_patterns_tool_misuse.py(see Verification), so the landing order is still a sequencing question.
- A wrong resolution would now fail CI through four guards:
- Finding 3 (tests did not pin the code routing): Resolved.
:2712sets a 0.0 limit only when the selected code rule compiles, and assertsreached == [target].iter_pattern_matchescalls_timed_patternbefore it builds_PatternAllowance(static_runner.py:712-724), so a revert leavesreached == []and the test fails.- Also added: the PE5 and TM4 payloads (
:2649-2650), the newline-onlytool.pycase (: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
_PatternAllowanceper 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.
- One
- Finding 5 (per-range timer overhead): Partly resolved.
- Ranges of 8 characters or fewer now run on native
rein 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).
- Ranges of 8 characters or fewer now run on native
- Finding 6 (
\bmargin on non-ASCII text): Partly resolved.\bbecomes(?<!w)before a literal word and(?!w)after a word character (static_runner.py:474-491).- The
unicode_paragraphandunicode_bundlefixtures are added (:2447-2459), with parity tests at:2786and: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
rethrough_iter_variable_shell_flag_matches(static_patterns_tool_misuse.py:249-287). _timed_patternfails closed for backreferences under IGNORECASE on non-ASCII content.- My transcription of the iterator matched
re.finditerin 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).
- The variable-shell rule runs on native
- Finding 8 (docs closing sentence): Partly resolved.
docs/ANALYSIS_RESOURCE_BOUNDS.md:344-346now 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
f776f8acand19140a6. The only change is that2aef0f66removed a dead RA2 string from the registry.
- The ledger keeps the search's own limit (
- 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-346keep 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
analyzeruns 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_matchesis still built atstatic_patterns_tool_misuse.py:5154-5161, before TM1-TM4. Both the tail bound and the allowance raise from there (finding 1).
- The search itself is now bounded: native
- (B) #577's
postprocess_path_findingsran 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).
- (A) The variable shell-flag pre-pass in
Material findings
-
[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) raisesSTATIC_PARSE_LIMIT(:274-281). This happens whenevername = Trueis followed within 5 lines by asubprocess.*(orPopen(head and no)appears in the next 4,096 characters.- Nothing checks whether a
shell=follows. - The raise comes after
matchhas been computed, so even a match already found inside the window is discarded.
- Nothing checks whether a
- Loop 2 raises the same error for true-prefixed names (
:4207-4214).
- Loop 1:
-
Why it drops everything:
analyzebuildsvariable_matchesfrom_variable_shell_matches(content)for every file type (:5154-5161), before TM1-TM4, and nothing inanalyzecatches the error._scan_pathkeeps onlyfinding_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:
:2916and:2950assert the raise forflag = 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-288and:4163-4221, patched into main5edc347c):Input main head model SKILL.md, 4,289 chars: flag = True,subprocess.run(, 4,200a, then acurl ... | shlineTM2 HIGH line 5, complete nothing; partial static_parse_limitSKILL.md: benign verbose = True,subprocess.run([, then 300"--opt-N",linesTM2 line 308 nothing install.shwith the call head only in a comment (# Popen(+ 4.1K characters)TM2 line 4 nothing tool.py: directshell=True, thenuse_shell = Trueand a 4,400-char argumentTM1 lines 2 and 3 nothing tool.py, loop-2 trigger (true_flag = True)TM1 line 2 nothing 248 KB install.shwith densef = True/Popen(headers, a)every 100 lines, so nothing truncatesTM2 line 1, complete nothing; partial runtime_limitfrom the 0.25 s variable-shell allowanceControl: first row with a )inside the boundTM2 TM2 -
Full CLI (
scan --no-llm, default flags):- Valid 5,326-character
tool.py: a function callingsubprocess.run(cmd, shell=True), thenuse_shell = Trueand aPopencall with 220 options andshell=use_shell.- Main: exit 1, risk 57, HIGH, DO_NOT_INSTALL, complete.
- Model: exit 0, risk 42, MEDIUM, CAUTION, partial.
- The direct
shell=TrueTM1 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.
- Valid 5,326-character
-
Consequence:
- About 4 KB of text with no
), after a harmlessflag = Trueand a call head (even one in a comment), suppresses directshell=True,curl | shand every other lexical TM finding in a single-window file of any type. - Only a partial ledger event remains.
--fail-on-incompletedefaults 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-356say only "a potential match extending beyond that span" reports the limit. Here it fires with noshell=at all, and after an in-window match.
- About 4 KB of text with no
-
Expected fix:
- At
:273, yieldmatchwhen it is notNone. Skip the candidate (continue) and record truncation only when there is no in-window match. Optionally record it only whenshell\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_exhaustionhook (:4986), which the runner turns intoSTATIC_PARSE_LIMITwithout 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
:2916and:2950to 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.pywith a directshell=Trueand a truncating header keeps TM1; - the comment-only
install.shkeeps TM2; - the long benign option list keeps TM2;
- the dense-header allowance case keeps TM2.
- At
-
-
[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) throughstatic_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).
- The PR sends the PE2 code loop (
-
CI:
- Run 37906385989 on
6b2c8c9a:test-unitreported1 failed, 13140 passed. - The failure is
test_chmod_regex_backtracking.py::TestChmodOptionGroupDoesNotBacktrack::test_very_long_option_run_is_linear. It raised_StaticResourceLimitError: runtime_limitfromstatic_patterns_privilege_escalation.py:1027, with_PatternAllowance(seconds=0.2502516220000004, limit=0.25), on'chmod ' + '-- ' * 30000 + 'x'(90,007 characters). test-unitpasses on main5edc347c.- 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.
- Run 37906385989 on
-
Measured (
regex2026.5.9, Apple silicon, best of 3):Characters regexre6K 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_limitfrom both PE and TManalyzeon 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
rethe way MP2 is exempted.- #678 is linear only for the option-run shape.
- On main's native
re,"chmod " + "-chmod " * ktakes 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, andrespans 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
-chmodshape stays quadratic, but the timed engine still caps it at 0.25 s.
- A mode cannot start with
- Add a span-parity test between the old and new patterns.
- Add a guard test for
"chmod " + "-chmod " * kthat asserts a bounded finish, acceptingruntime_limitwithlimit_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-uniton the live head.
- Make the option group atomic in both rules:
-
-
[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:252and:4182. - The runner catches the error with
path_findings = [](static_runner.py:3891-3892). - Per-window limits go through
_scan_pathinstead, 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_limitat 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_pathkeeps 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.
- Window 2 raises finding 1's parse limit, and
- B2, a 989,553-character valid
- 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.
- When postprocess raises
- Code path:
-
[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 whererefinds nothing.- Why it happens:
- The prefix's lazy
{0,4}?line gap stops at the first line that contains a call head, andend = 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".
- The prefix's lazy
- Measured (transcription) on a
tool.pywithuse_shell = True, thensubprocess.run(a), thensubprocess.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), withshell=fcut exactly at the window end. - The iterator returns span (0, 4120) with name
f, wherere.finditerfinds 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.
- Input:
- Consequence:
- These are Python-only differences from main in both directions. One of them silently flips the default gate to pass.
- Docs
:354-356promisestatic_parse_limitfor 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.
- Give each call head its own 4,096-character span, or record truncation when any call head in the window lacks a
- Why it happens:
-
[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_patternwith the 0.25 s allowance, on every file type. 2aef0f66only removed a dead registry string; that removal changed no behaviour.
- The #658 merge gave
-
Measured (transcription, single-line inputs):
Input head model main reDense prose ("We write the guide and create examples for users, " repeated, ending in a non-hidden ~/config), 8Kcompletes in 0.20 s 0.04 s Same, 10K runtime_limit0.06 s Same, 12.5K runtime_limit0.09 s Same, 50K runtime_limit1.43 s Same, 100K runtime_limit5.6-6.3 s One "create" per about 500 characters, 25K completes in 0.12 s Same, 38K borderline Same, 51K runtime_limit0.08 s Minified JSON MCP tool schema: 100 tools whose descriptions say "Create ... write ...", with /tmp/out/defaults; 23.8Kruntime_limit0.08 s 250K multi-line control completes in 0.05 s - Without a
~/,/home/or/tmp/after the keywords, the search fails fast.
- Without a
-
Consequence:
- The expiry stops
analyzefor that view. Every RA2 rule after this one never runs:nohup/disown/setsid,HKEY_/reg add,plist/launchctland 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
nohupandreg addlines loses both RA2 MEDIUM findings (risk 10 drops to 0). The ledger recordsruntime_limit. - The partial clears MCP
safe_to_installand 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.
- The expiry stops
-
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.
- 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
-
-
[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, readstime.thread_time()(:651and thefinallyblock), checks the budget and creates a newfinditer(..., 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 notimeout=, 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.
-
-
[Non-blocking]
src/skillspector/nodes/analyzers/static_runner.py:474: the\bshortcut 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 reminified 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\bgives 4.3-17.3 ms. - On a 135K corpus with 648
rematches next to 28 kinds of non-ASCII neighbour, all 23 AR rules kept spans identical tore, 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
zlibthreads caused no retries today. - A trip clears
safe_to_installand fails--fail-on-incomplete.
-
Expected fix:
- Treat
\bas(?<!\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.
- Treat
-
-
[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 reportsstatic_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).
- The code raises with no
:258,:337and:355(one 0.25 s allowance "per rule and content window", "the same accumulated matching CPU allowance"):_variable_shell_matchesruns twice per window: in theanalyzepre-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-356and: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.
- Once findings 1-4 are settled, correct
PIC tradeoffs:
- The first limit ends the analyzer for the file (still open).
- What happens:
_scan_pathreturns 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,:3137and:3377), becausedefer_output_limit(:2819-2829) defers onlyOUTPUT_LIMIT.
- This policy predates the PR (main
static_runner.py:1037-1057), and docs:363-365describe 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.
- What happens:
- Fixed 0.25 s limit (still open).
_STATIC_PATTERN_SECONDS = 0.25(static_runner.py:329) is a constant.:641can 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).
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.
- The head is
- Changes since
6b2c8c9a: - CI:
- On
19140a6, run 37934064346 isaction_requiredand has not run. - On
6b2c8c9a, run 37906385989: lint, changes, DCO Check, OpenCode TypeScript Tests and docker-smoke passed.test-unitfailed (finding 2).
- On
- Conflicts:
- Method:
- Baselines come from main's own analyzers and CLI.
- Head behaviour comes from my transcriptions, run with
python -Iinside main5edc347c:_iter_variable_shell_flag_matchesand_variable_shell_matches;_timed_pattern,_PatternAllowance,_iter_pattern_matchesanditer_paragraph_matches.
- Each transcription was diff-checked against the head's lines.
- Also checked and found correct:
SourceLocationIndexline numbers match main.- Variable-shell loops on adversarial input stay bounded: 256K blank lines take 0.047 s, and a true-prefixed
shell=apayload 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
regex2026.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 thatdirect_matchesis timed. It discards the limit that_scan_view_windowsreturns. - 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.
- All timings come from Apple silicon with Python 3.13 and
- 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] |
There was a problem hiding this comment.
[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 | shTM2 at line 5; - a
tool.pyloses a directshell=TrueTM1; - an
install.shwith the call head only in a comment loses TM2; - the content in
:2950loses 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
matchwhen it is notNone. - 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.shwith dense headers trips it and loses TM2. - Update
:2916and:2950to 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 |
There was a problem hiding this comment.
[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, andrespans stay the same. - In
regexthe 90K search drops to about 5 ms. - Add a span-parity test and a bounded-time guard for the
-chmodshape. - Get a green
test-uniton 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() |
There was a problem hiding this comment.
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_limitat 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) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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
\bas(?<!\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. |
There was a problem hiding this comment.
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 reportsstatic_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.
- the bound also raises with no
:258and:337, one 0.25 s allowance per rule and window:_variable_shell_matchesruns 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.
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.