Skip to content

fix(security): make the shell sensitive-path gate linear and bounded - #8277

Closed
bolichen97 wants to merge 1 commit into
kirodotdev:mainfrom
bolichen97:fix/sensitive-regex-quadratic
Closed

fix(security): make the shell sensitive-path gate linear and bounded#8277
bolichen97 wants to merge 1 commit into
kirodotdev:mainfrom
bolichen97:fix/sensitive-regex-quadratic

Conversation

@bolichen97

@bolichen97 bolichen97 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Problem / Motivation

An hourly cron whose agent emitted a ~9 KB bash command full of https:// URLs took a user's gateway down every hour. The crash dump's main thread sat in security.py inside is_sensitive_bash_command for 25 s and the loop-stall watchdog hard-exited the process. The gate is synchronous on the event loop under that watchdog, so its worst case is the gateway's worst case — and it was quadratic in the command. The user's own benchmark of the installed gate: 1.2 KB 0.6 s, 2.3 KB 2.3 s, 4.6 KB 9.1 s, 9.1 KB 35.8 s.

Why it matters

Any surface that streams a permission request (cron, Slack, dashboard side panel, workflows) and any caller of hooks.on_tool_call runs this gate inline. A long command from the model is enough to kill the gateway, every in-flight turn with it, and — for a cron — to do it again on the next run. #7941 removed the eleven .* token anchors, which cut the constant ~20x, but the growth was still quadratic on every shape measured (10 KB 0.3 s, 40 KB 5 s, pattern tier alone).

What changed (motivation → approach → change)

Three constructs were each quadratic on their own and their costs multiply, so each was measured and fixed separately (harness in a subprocess under timeout; pattern tier alone, 10 KB / 40 KB, before → after):

construct shape before after fix
redirect alternative `.*[<> ]\s*` any input 0.30 s / 4.8 s 5 ms / 19 ms
UNC anchor \\\\[^\s'"]+ + generalized separator one UNC token with \X\.. hops 0.06 s / 0.8 s 11 ms / 43 ms UNC takes a plain separator: its greedy run already absorbs every character a no-op chain contains, so the languages are equal, and each backtrack step becomes a constant-time literal check
verb-anchored verb.*<path>, interp … open( … <path> verb-dense line 12 ms / 130 ms 4 ms / 18 ms no regex spelling of "a verb earlier on this line" is linear; . never crosses a newline, so per line it decomposes into "earliest verb end" + "path search from there" (_verb_anchored_sensitive_hit, two linear searches, same language). _sensitive_pattern_hit runs both halves and is the only entry to the pattern tier

Fixing the first alone leaves the UNC shape quadratic (0.8 s at 40 KB); fixing the second alone changes nothing visible because the first dominates; fixing both leaves the verb term (×3.5 per doubling). All three are needed.

Whole gate at the crash size (10–12 KB) on every adversarial shape tried — doubled-separator paths, URL-dense JSON, UNC chains, verb-dense lines, ~-runs, 24 000-backslash runs: 20–80 ms, against 15–36 s on the shipped build. With the ceiling lifted for measurement, 20 → 40 → 80 KB scales ×2 per doubling on every shape.

On top of that MAX_SCANNABLE_COMMAND_CHARS (20 KiB) is a hard ceiling in the gate itself: a longer command is refused with a reason, never scanned partially and never let through unscanned — a denied long command is recoverable, a stalled gateway and an unscanned command are not. llm_helpers._MAX_SCANNABLE_TOOL_INPUT_CHARS already refused at that size; it now aliases the same constant so the two tiers cannot drift. Two later passes (_ENV_CRED_PATTERNS, the normalizer) remain O(k·n) in verb tokens and are bounded by the ceiling (≤ 60 ms at 20 KB); they were not on the crash path and are left as is.

Zero verdict change. A 381 474-command generated differential corpus (verbs × preceding characters × paths × terminators, script-open shapes, multi-line, UNC and %VAR% spellings, plus every golden from the existing tests) produced no difference against origin/main and against the tree before #7941.

Spec: docs/system-specs/modules/security.md gains the bounded-cost invariant.

Tests

test/test_security_gate_liveness.py:

  • verdict pins for the cases where each rewritten construct was the only matching branch (paths preceded by a non-anchor character; open( after the interpreter; UNC with hops), plus negatives (path before the verb, verb on another line, open( before the interpreter)
  • the pattern tier is the union of both halves
  • source guards on the three spellings
  • ceiling: refused above, scanned at, tiers agree
  • both trigger paths at the crash size (Pass 1b double-separator 10 KB, URL payload 12 KB) and doubling-ratio linearity for the backslash run (Pass 1) and one shape per construct

Seven mutants each turn a test red (drop the ceiling; drop the verb half; accept a path before the verb; .* back on the redirect; generalized separator back on UNC; no line split; open( before the interpreter). The two spelling mutants are also caught by the timing pins alone with the source guard deselected.

test/test_security_regex_linearity.py::test_long_nonshell_line_does_not_blow_up is resized under the new ceiling (it fed 22.5 KB and asserted "allowed"; that is now refused by design).

Manual verification

Reproduced the field curve on the shipped build (0.6.0.10 internal / pre-#7941): 10.7 KB double-separator command 14.8 s. Measured re.search GIL behaviour separately (a worker's 16–23 s search leaves the main thread ticking on 3.10/3.11/3.12) — relevant to the companion offload PR, not to this one.

Related Issues

Companion PRs: #8278 (title-tier off-loop scan in _resolve_permission) and #8279 (cron loop-stall attribution + breaker + doctor). Each is independently mergeable.

Pattern harvest

Rule candidate: review-prompt
Pattern: a leading .* (or a greedy run followed by a starred group) inside an alternation evaluated with re.search on agent-supplied text — redundant for existence, quadratic in the subject; measure each .*-bearing branch alone, because their costs multiply and fixing one hides the rest.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

A cron whose agent emitted a ~9 KB bash command full of https:// URLs
took the gateway down every hour: is_sensitive_bash_command ran for 25+
seconds on the event loop and the loop-stall watchdog hard-exited the
process. The gate is synchronous under that watchdog, so its worst case
is the gateway's worst case, and it was quadratic in the command.

Three constructs were each quadratic on their own, and their costs
multiply, so each is measured and fixed separately (10 KB / 40 KB,
pattern tier alone, before -> after):

* The redirect alternative `.*[<>|]\s*<path>`: a `.*` tried at every
  offset of every line whatever the content, the one term whose cost did
  not depend on the input looking like a path. 0.30 s / 4.8 s -> 5 ms /
  19 ms. Dropped to `[<>|]\s*<path>`, redundant under re.search exactly
  like the token anchors kirodotdev#7941 already fixed.
* The UNC anchor `\\\\[^\s'"]+` followed by the generalized separator:
  the greedy run backtracks one character at a time and re-walks the
  whole `\X\..` chain from every separator it lands on. 0.06 s / 0.8 s on
  one UNC token -> 11 ms / 43 ms. It now takes a plain separator, which
  matches the same strings: the run already absorbs every character a
  no-op chain contains.
* The verb-anchored branch `verb.*<path>` (and `interp ... open( ...
  <path>`): re-walks the line from every verb occurrence. 12 ms / 130 ms
  on a verb-dense line -> 4 ms / 18 ms. No regex spelling of "a verb
  earlier on this line" is linear, so it is a Python walk now:
  `.*` never crosses a newline, so per line the statement is "earliest
  verb end" + "path search from there", two linear searches accepting
  exactly the old language. `_sensitive_pattern_hit` runs both halves.

Whole gate at the crash size (10-12 KB, every adversarial shape tried:
doubled-separator paths, URL-dense JSON, UNC chains, verb-dense lines,
24 000-backslash runs): 20-80 ms, against 15-36 s on the shipped build.

On top of that, MAX_SCANNABLE_COMMAND_CHARS (20 KiB) is a hard ceiling:
a longer command is refused with a reason, never scanned or skipped. The
tool_input tier in llm_helpers already refused at that size; it now
aliases the same constant so the two cannot drift.

Zero verdict change: a 381 474-command generated differential corpus
(verbs x preceding characters x paths x terminators, script-open shapes,
multi-line, UNC and %VAR% spellings) produced no difference against
origin/main and against the tree before kirodotdev#7941. The tests pin the cases
where each rewritten construct was the ONLY matching branch. Seven
mutants (drop the cap, drop the verb half, accept a path before the
verb, `.*` back on the redirect, gsep back on UNC, no line split,
`open(` before the interpreter) each turn a test red; the two spelling
mutants are also caught by the timing pins alone.
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Superseded by #8282, which carries this change together with its two companions as one PR.

@bolichen97 bolichen97 closed this Sep 3, 2026
@github-actions github-actions Bot added the fork Pull request from a fork (external contributor) label Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant