fix(security): stop re-asking a command-level guard per payload - #8967
Conversation
…yload The deny walk asked a command-level question once per extracted payload. Three of the guards in _data_consumer_exempt read only `tokens`, which the caller binds once outside the payload loop, and one of them sweeps the whole argv with a regex -- so N payloads cost N x len(tokens). Recovering each payload's token positions with an enumerate scan swept the argv a second time. Both values are now computed once per command and passed in. The verdict cannot change: each hoisted guard is a pure function of `tokens` and each one REFUSES the exemption, so hoisting alters how often the same answer is computed, never what it is. Callers that ask about a single token pass nothing and keep the old self-computing path. Commands with no nested payload -- the common case -- do neither piece of work. Measured at the size the issue reports (18,000 payloads, 222,894-byte command): 209.19s -> 2.55s for the spaced spelling, 10.22s -> 2.33s for the glued one. Growth is now linear rather than quadratic: doubling ratios over 4k -> 8k -> 16k -> 32k payloads are 1.89 / 2.04 / 2.06 (fitted exponent 1.04), against 1.99 / 3.41 / 3.79 / 3.87 before (fitted exponent 1.95). The argv sweep count falls from 1830 / 7260 / 28920 at 30 / 60 / 120 payloads (ratio ~3.98) to 61 / 121 / 241 (ratio ~1.99). Deny set proven unchanged: 79 commands x 3 rule tiers produce byte-identical verdicts before and after, 50 of them denials. Closes #8595
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A measured quadratic on attacker-influenced input in the deny gate, fixed at its actual root by a semantics-preserving loop-invariant hoist, with verdict identity and cost shape both pinned structurally. [DESIGN-REVIEWED] e56fd3e |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe refactor is behavior-preserving. The three command-level guards moved into No findings. [OPUS-REVIEWED] e56fd3e Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All verification is done. The fix targets the payload loop in First-Principles-Verdict: CONCERNS The reported quadratic is fixed at its cause, but the same recompute-per-iteration pattern survives, counted, at three sibling call sites the description frames as untouched by design. What this change shipsIntent: keep the deny gate able to decide a payload-stuffed command in seconds so the security decision completes instead of being watchdog-killed. FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] e56fd3e |
|
Conceding the First Principles finding on the wording, and filing the substance rather than widening this PR. Putting it in a comment because the description is otherwise accurate and editing it would re-roll three clean review verdicts on this head. The wording was understated and the reviewer is right. The description says "callers that ask about a single token pass nothing and keep the old self-computing path". Each call is single-token; each caller loops. Verified by reading all three enclosing loops at main d4c2cbf:
It is real and reachable, and it is not this PR's. Measured both columns, because a claim that something is or is not this change's effect is a differential one:
Sweeps multiply by exactly 4.00 per doubling, and are identical on both trees -- this diff moves that axis by zero, in either direction. Roughly 2,200 trigger tokens (~33KB) crosses a 25s watchdog. Measured on a clean-main worktree with this branch's changes absent. Filed as #8972 with that evidence, the in-module precedent at security.py:6362 ("Once per FRAME, not once per token: this is the loop whose per-token scan made the floor quadratic"), and the tradeoff the reviewer named: the guards are reached only after a narrow trigger predicate matches, so an unconditional per-frame hoist would charge every command reaching those floors one argv sweep it currently skips. That is a judgement about the common case and wants its own measurement, which is why it is a separate change rather than three more edited security-critical call sites here. The optional |
bolichen97
left a comment
There was a problem hiding this comment.
Approved after an independent verdict-identity check rather than from the description.
Not a cache, and nothing payload-specific is skipped. The three guards moved into _data_consumer_command_disqualified read only tokens — I read each body and none touches index or token. The genuinely per-payload guards (index <= 0, _CONTROL_OPERATOR_RE.search(token)) stay inside the loop and are still charged per payload. tokens is bound once at _deny_segment_views (tokens = _shell_tokens(source)) and is never rebound or mutated anywhere in the for payload body, so the hoisted boolean and token_positions are computed from the same object every call then reads. token_positions.get(payload, []) reproduces the enumerate comprehension exactly, including the empty result for a synthesized payload that is not a token — which still fails closed by descending.
Failure direction is safe. A hoisted True refuses the exemption and the payload is descended into (over-block). The bypass direction needs the hoisted value to be False where a per-payload evaluation would be True, which a pure function of an unchanged input cannot produce. The three untouched call sites pass no keyword and take the None self-compute branch, so they are byte-identical to main.
Verdict identity, measured not argued. 970 commands — every launcher x body pair, all six data-consumer programs with and without a piped evaluator, every documented refusal route, the #8595 repro at 3/12/40 payloads with a real denial planted at three positions each, nested -c, glued herestring, env -S, sed e-flag, line continuations, and the quote/empty-element re-spellings — run under the merge base 8aef8fe3f and under e56fd3edc in separate detached worktrees with each module's own path asserted. Byte-identical output, 326 denials / 644 allows so it is not a degenerate comparison, and the diff provably reddens when a single verdict is flipped in either direction.
The fix is real. Same harness, spaced repro: main 0.437 / 1.264 / 4.671s at 500 / 1k / 2k payloads (ratios 2.89, 3.70); this branch 0.171 / 0.226 / 0.465s (ratios 1.32, 2.06). New memory is one dict bounded by len(tokens), so it adds no amplification axis of its own.
AGENTS.md security invariants untouched: no rule records, no restated rule count, no sensitive-path matcher change, no gate relocation. First Principles CONCERNS is advisory and about the pattern surviving at three sibling call sites, which is correctly filed separately rather than widening this diff.
Problem / Motivation
is_deniedis quadratic in the number of nested shell payloads a commandcarries, so a large-but-ordinary-looking command makes the deny gate run for
minutes.
Measured on current main, spaced repro from #8595 (
bash -c a0pay -c a1pay ...,18,000 pairs, a 222,894-byte command):
is_deniedon mainDoubling the payload count multiplies the time by ~3.9, not ~2: ratios
1.99 / 3.41 / 3.79 / 3.87 across 250 -> 500 -> 1k -> 2k -> 4k payloads, a
fitted growth exponent of 1.95. The reporter measured ~293s at 18k on their
own workstation; this is the same curve on a different machine.
The extractor is not the bottleneck and never was:
_nested_shell_payloadsislinear on the same inputs (ratios 1.92 / 2.02 / 2.03 / 2.02, 0.166s at 18k).
Why it matters
The gate is what decides whether a command may run, and a command string is
attacker-influenced input. Extrapolating the measured fit, ~6,300 payloads
(about 74KB of command text) is enough to cross a 25s-class watchdog on this
host. When the watchdog fires first, the outcome is not a slow decision but an
interrupted one, so this is availability of the deny path rather than mere
latency.
It is reachable on main today through the spaced spelling, and the glued
spelling reaches the same code path since #8491 merged (glued: 10.22s at 18k).
What changed (motivation -> approach -> change)
Symptom -> curve. Timed the real
is_deniedover 250 to 32,000 payloadsrather than trusting the word "super-linear". It is quadratic, not exponential,
which decides what kind of fix is needed.
Curve -> root cause, and this is where the issue's own diagnosis is wrong.
#8595 attributes the cost to "N payloads x M rules x payload length". That
product is linear in N, because each payload is about ten bytes, so it cannot
produce an exponent of 1.95. Profiling at 2,000 payloads (6.18s total) locates
the real term:
_deny_pattern_matches, the per-payload x per-rule scan that all threesuggested mitigations aim at: 282,282 calls, 0.477s, 7.7% of the time.
_data_consumer_exempt, called once per payload: 4.912s, 79%. Inside it,any(_SCRIPT_EXECUTES_RE.search(tok) for tok in tokens)runs 8,004,000iterations, which is exactly 2000 x 4002 = payloads x len(tokens).
_data_consumer_exempthas three guards that read onlytokens, andtokensis bound once by the caller, outside the payload loop, as is
programs. So acommand-level question was being re-answered once per payload. Recovering each
payload's token positions with
[i for i, tok in enumerate(tokens) if tok == payload]walked the same argv a second time, once per payload.Root cause -> change. Both values are now computed once per command and
passed into the loop:
_data_consumer_command_disqualified,and
_data_consumer_exempttakes an optionalcommand_disqualified;token_positionsindex built once.Neither can change a verdict. Each hoisted guard is a pure function of
tokensand each one refuses the exemption, so hoisting alters how often the same
answer is computed, never what it is.
token_positions.get(payload, [])returnsthe same ascending index list the comprehension produced. Callers that ask about
a single token pass nothing and keep the old self-computing path, so the three
call sites this PR does not touch are unchanged. A command carrying no nested
payload skips both, so ordinary commands pay nothing new.
Deviation from the issue's suggested directions, stated up front. #8595
proposes dedup, a cap with fail-closed, or precompiled/aggregated rules, and two
operators routed the issue to
needs-investigationon the grounds that choosingbetween them is a contract decision. That reading of those three options is
correct, and it is why none of them is implemented here: they all target the
7.7% term. Specifically, dedup cannot help this shape at all, since
a0paytoa17999payare already distinct; and a cap or a rule-scan rewrite would eachpick a policy where
_AltWorkBudget's own docstring records three revisionsfailing open. Hoisting a loop-invariant needs no cap, no dedup, no budget
threaded through the extraction sites and no contract choice. If maintainers
still want a cap as defence-in-depth for other amplification axes, that is
separable from this fix and unaffected by it.
Both halves are load-bearing, measured rather than assumed. Reverting only
the position index, keeping the guard hoist, leaves the walk quadratic: 1.37s /
4.30s / 15.56s at 4k / 8k / 16k payloads, ratios 3.15 and 3.62. So this is one
defect with two expressions at one call site, not a fix plus a tidy-up.
Tests
Cost is pinned structurally, never on elapsed time. A wall-clock bound would
claim a performance budget for every other pass in the gate and would flake on a
slower runner, so what the tests assert is the bounded quantity.
test_guard_is_charged_once_however_many_payloads-- the command-level guardis charged the same number of times at 30, 60 and 120 payloads.
test_argv_sweep_is_linear_in_the_argv_not_quadratic_in_payloads-- the_SCRIPT_EXECUTES_REargv sweep count stays within 3x the argv length.Measured before: 1,830 / 7,260 / 28,920 sweeps at 30 / 60 / 120 payloads
(ratio ~3.98). After: 61 / 121 / 241 (ratio ~1.99).
test_argv_is_walked_per_command_not_per_payload-- argv elements consumedstay within 40x the argv length. Before: 2,984 / 9,554 / 33,494 / 124,574 at
30 / 60 / 120 / 240 payloads (ratio ~3.72). After: 1,154 / 2,294 / 4,574 /
9,134 (ratio ~2.00).
test_every_way_the_exemption_is_refused_still_refusesandtest_the_ordinary_data_consumer_is_still_exempt-- every documented route bywhich the exemption is refused, and the ordinary exempt case, in both
directions, so the change can neither widen nor narrow the exemption.
test_precomputed_and_self_computed_guards_agree-- theNonebranch used bythe untouched callers gives the same answer as the hoisted value, token by
token.
Before and after,
is_denied, measured not extrapolated:Growth is now linear. Doubling ratios over 4k -> 8k -> 16k -> 32k payloads are
1.89 / 2.04 / 2.06, a fitted exponent of 1.04, against 1.95 before. At
32,000 payloads (a 404,894-byte command) the gate takes 4.51s.
Suite:
pytest -n0 test/test_denied_commands_security.py-> 695 passed.flake8,isort --check-only,mypy src/kiro_crew/(1302 files) and the blackgate all clean.
Five mutations, each hand-applied and asserted to have applied, each reddening a
different observable with a different assertion:
sweeps become 1,891 against a bound of 183.
enumerateposition scan -> 2,984 elements against a bound of2,440.
echo <name> <verb> | shisallowed when it must be denied.
echo <name> <verb>is denied when itmust be allowed.
Nonebranch stops self-computing -> the agreement test reportsself-computed=True passed-in=False, and a refusal case flips to allowed.Mutations 3 and 4 fail in opposite directions on the same guard, which is what
rules out a test that reddens indiscriminately.
Manual verification
Deny-set identity was proven by running the same matrix under main and under
this branch and diffing the results, rather than arguing it from the diff: 79
commands x 3 rule tiers, byte-identical verdicts, 50 of the 79 denials. The
matrix covers destructive and credential and publish shapes, nested payloads
behind six different launchers, every documented refusal route for the
data-consumer exemption, ordinary allowed commands, and the #8595 repro shape
with a real denial embedded at several positions. Controls: the comparison
detects a deliberate one-character change, and the two outputs are non-empty
(22,528 bytes each), so an empty-vs-empty comparison cannot pass for agreement.
All measurements were taken in a detached worktree at a pinned main with none of
this branch's code present, with the imported module's path asserted, so the
before numbers are attributable to main alone.
Related Issues
Closes #8595
Context, not changed here: #8197 and #8491 are the glued-payload extraction work
this issue was found alongside; #8491 is merged, and the 2x2 in #8595 correctly
attributes the cost to main rather than to that PR. #8338 and #8282 concern cost
in command length, a different axis that this change does not address.
Pattern harvest
Rule candidate: review-prompt
Pattern: a predicate that reads only a loop-invariant collection, evaluated
inside a loop over that same collection, turns an O(n) helper into O(n^2) at the
call site. The tell here was a guard taking both an index and the whole argv,
where two of its three checks never used the index.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)