refactor(security): drop the regex traversal simulation from the bash gate - #9089
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Deletes a control at the layer that never bound the subprocess, keeps enforcement at the OS layer that does; residuals measured on both trees and owner-accepted. The one boundary change — the governance-ceiling read is no longer blocked at the bash layer — is a truthful restatement, not a weakening: the OS sandbox deliberately permitted that read in every mode (a spawned [DESIGN-REVIEWED] 4495a29 |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @bolichen97 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence is in. The core change is a cause-level deletion (the AGENTS.md invariant it records — "a regex spelling-chase is a review smell" — is written/updated in the same commit as required). I verified the one new production symbol: First-Principles-Verdict: CONCERNS The deletion is aimed at the true cause and earns itself; the one rider, What this change shipsIntent: stop the bash gate falsely refusing ordinary read-only commands whose verdict depended on the gateway's cwd or the input's size — a FIX, achieved by deletion.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 4495a29 |
fdab8c8 to
d75d5e4
Compare
Legitimate and reproduced before changing anything. Six variable-indirection
Taken as written. Two things worth recording because they shaped the fix rather than following from it: The first attempt built one view over the whole command and regressed
The adjudication note is accurate on the mechanism and on the absence of a recovery |
This is round 2 on this span. Round 1 named a variable indirection and was Every verdict below is measured, not argued — the same probe run against this
Half 1 — brace expansion: the finding is factually wrong. Half 2 —
So the choice is not fix-vs-ignore, it is: restore the grammar and its false
Round 3 on this span would hit the same-span stall trigger. If the lane returns /ai-review override gpt d75d5e4: the brace half is allowed on main too so it is not a regression from this PR, and the find half is closable only by restoring the traversal grammar this PR exists to delete — the residual is accepted and documented. |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings that block. The one survivor is an inherited, already-accepted residual, not a regression this diff introduces. FINDING — src/kiro_crew/security.py:10994 — removing the [OPUS-REVIEWED] 4495a29 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
/ai-review override gpt d75d5e4: the brace half is allowed on origin/main too so it is not a regression from this PR, and the find half is closable only by restoring the traversal grammar this PR exists to delete, so the residual is accepted and documented in the PR body. |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
d75d5e4 to
e12f227
Compare
Correct, and correct about the consequence rather than only the complexity. Measured So the watchdog is crossed at ~4,000 lines, well inside what After (same measurement, same path): Of the two fixes offered, the second was deliberately not taken. A cap on Pinned by Behaviour is unchanged — all six variable-indirection spellings from round 1 still |
e12f227 to
1f5b4f9
Compare
Correct, correctly advisory, and it improves the record: I had documented this residual
So the residual is exactly the indirection-spelled form, not the leaf: a command that Why this is not fixed by restoring the pass. The deleted matcher never bounded these two The proportionate fix is at the layer that can hold it: move each leaf's in-sandbox reader Recorded in the PR body under the third stated residual, now naming both leaves. |
Not rebutted, and not overridden. Both questions pass, and the second one passes in the Verified rather than taken on description:
The direct spelling of each is still refused, by Why So the fix is bounded and has a precedent in the same file: The ruling I need. That change lands in the MCP spawn path and
Auto-merge is armed on this PR, and this ResolutionThe scope question was put to the repository owner and the answer changed once the fix was Nor is any hiding-based fix available: the MCP servers share the agent shell's environment, So: overridden here, tracked in #9175, which carries the verified facts, the rejected |
|
/ai-review override gpt 4495a29: the named fix is provably ineffective and the exposure is architectural and pre-existing — the identical credential stays readable in-sandbox at Repository-owner override, with the reasoning recorded rather than asserted. The finding's own fix does not close it. GPT asks to "mask The exposure is architectural, and it predates this PR. That one value is both What this PR actually changes here. It removes a text-layer matcher that never bounded Tracked, not waived: #9175 carries the capability split (two secrets: an internal one Also recorded, so the trade is not silent: overriding lets this PR land before #9175, |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Problem / Motivation
is_sensitive_bash_commandran three passes that worked out where a command wouldend up by re-implementing shell and find-utils grammar in regex: a
cd/variablenormalizer (
_check_sensitive_via_normalizer,_check_sensitive_cd_taint), analternate-tool traversal analysis (
_check_alt_traversal_reaches_fence), and afindtraversal analysis (_check_find_traversal_reaches_fence) — roughly 4.2klines of matchers, budgets and helpers covering quote and variable expansion, brace
expansion and sequences,
cdbase tracking, pipeline staging, andfindprimaryand
-namepattern grammar.They denied ordinary read-only commands far more often than they caught an access
the literal fence did not already name:
.resolved against the gateway process's own workingdirectory, which on the desktop app is
/— an ancestor of every fenced store.So
grep -r pattern .,find . -name '*.py',ls -R .anddu -sh *wererefused whatever
cdpreceded them, and the verdict depended on where the gatewayhappened to start rather than on the command.
refusal keyed on the subject's SIZE or SHAPE rather than its content: an English
docstring opening with "Find …" exhausted the 64-root budget, and 66 markdown code
spans read as 66 nested substitutions.
Why it matters
Every one of those refusals lands on an agent doing ordinary work — reading its own
repo, grepping for a symbol, listing a tree — and it is not recoverable by rewording,
because the trigger is the gateway's cwd or the input's length. The budgets made the
cost fall on prose and on long-but-benign scripts rather than on an attacker.
What changed (motivation → approach → change)
Symptom: read-only traversals refused, with the verdict depending on the gateway's
launch directory. Root cause: the gate was simulating the shell in regex to guess a
path the command never spelled. Change: delete all three passes and the ~4.2k lines
behind them, and keep the literal fence.
What survives is the part that names a path it can actually see:
_sensitive_pattern_hit,_RELATIVE_SENSITIVE_RE, the separator-run collapse, the trust-root extraction control,the IMDS check and the env-credential rules. Enforcement of the keystone itself is
unchanged and sits where it cannot be talked around —
is_sensitive_path()on everyresolved path a caller opens, plus the OS sandbox for the agent process.
The deletion set was derived mechanically, not by eye: an AST +
git blamepasscomputed which top-level symbols the three PRs authored, then a reachability pass
from every public entry point and every cross-module import found what became dead.
Symbols already unreachable before this change (an unwired protected-branch check,
two unused shell-token helpers) were excluded so they stay out of the diff. Three
PR-authored symbols are kept because live code uses them:
_SENSITIVE_LEAF_PARENT_DIRSand
_GENERAL_PURPOSE_PARENT_DIRSfeed the literal keystone regex, and_SHELL_ASSIGN_REis used by_resolve_local_assignments.Files deleted —
test/test_security_alt_traversal.py(1849 lines) andtest/test_security_relative_traversal_roots.py(117 lines) tested only the removedmachinery, so they go whole.
test/test_security.pyloses 3 classes and 39 testfunctions for the same reason. Nothing else is removed.
Stated residual. A spelling whose fenced segment is not adjacent to the traversal
it travels through —
~/../<user>/.aws/credentials, up and back down through anintervening segment — is no longer refused by this gate. Every ordinary relative
spelling that resolves to a fenced path still is, including the
..,.andrepeated-separator forms, because the fenced segment stays within the matcher's
reach. This is not pinned either way in the tests, so a later change is free to
decide it.
Tests
TestTraversalSimulationIsGone(new, intest/test_security.py) pins the twohalves together: 10 read-only traversals that must be allowed, and 8 spellings that
name a keystone path and must still be refused. Asserting only the allowed half
would pass just as well if the fence were deleted outright.
(
_check_find_traversal_reaches_fence,_check_alt_traversal_reaches_fence,_check_sensitive_via_normalizer,_check_sensitive_cd_taint,_find_traversal_reaches_fence,_alt_root_reaching_fence,_path_candidates) andthat
is_sensitive_bash_commandno longer takes_traversal_subjects. A behaviouralassertion alone cannot tell "the simulation is gone" from "the simulation is present
and happens to allow this input", which is how a reinstated pass would slip back in.
test_name_only_traversal_is_the_same_class_as_every_other_leafcompared twoverdicts that are now both
None.test/test_security_gate_liveness.pyandtest/test_security_path_resolve_bounded.pyeach dropped one assertion that named a removed symbol; both files stay green
(69 passed).
Manual verification
N/A — unit coverage is sufficient: this is a deletion inside one gate, and both the
allowed and the refused half are asserted directly against
is_sensitive_bash_command.Local gates run green on this commit:
check_black_formatting,isort,flake8(the 3 findings in touched files are present verbatim on
origin/main),mypy(same 3 pre-existing errors as base),
docs-lint,scrub-lint, and the security /hooks / cron / llm_helpers test files directly (2339 + 766 passed). The full local
suite reached 73% of 89k items with zero failures before the host OOM-killed its
workers; a lower-concurrency re-run is in flight, and CI's own run on the merge ref
is the authoritative one.
Related Issues
no linked issue: this is a maintainer-directed removal, raised from a review of the
gate's false-positive rate rather than from a filed report.
Pattern harvest
Rule candidate:
review-promptPattern: a text-analysis gate that simulates another language's grammar to guess a
value the input never spells — the simulation's false-positive rate grows with the
grammar's surface while its true-positive rate stays bounded by what a literal
matcher already covers.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Second stated residual (found by the GPT review lane, round 2)
A
findrooted at home that hands a discovered file to a reader —find ~ -name <cred-leaf> -exec <reader> {} +, and the| xargs <reader>form — was refused onmainand is not refused here. It is accepted rather than fixed, and the reason is a measurement
rather than a preference:
_SENSITIVE_HOME_DIRScarries.awsand.sshas fenceddirectories and holds no credential leaf NAME, so
mainnever matched this shape on akeyword. It matched by inferring where a traversal rooted at
~could arrive, which isthe machinery this change removes. The only text-local rule that catches the named command
also denies
find ~ -name '*.log' -exec <reader> {} +— an ordinary read-only command, andexactly the false-denial class that motivated the removal. Closing it properly means a
resolved-path fence rather than a text-adjacency one, which is a change of its own size.
Not a regression from this PR: the brace spellings the same finding named
(
cat ~/.a{ws,bc}/<leaf>,cat ~/.{aws,ssh}/<leaf>) are allowed onorigin/mainas well— the removed simulation never covered brace expansion — so this change does not alter
their verdict. Measured on both trees rather than inferred.
Both residuals share one root cause, worth naming because it is what a follow-up should
target: the surviving fence matches ADJACENCY in the command text, so any indirection that
separates a fenced directory from its leaf — a
..through an intervening segment, abrace, a path discovered at runtime — escapes it. The variable-indirection case the same
lane raised in round 1 was closable inside this PR precisely because an assignment is
decided by the command text alone; these two are not.
An aside that is itself evidence for the measurement above: composing this section tripped
Kiro Crew's own live gate, whose
findsimulation is still present, because the examplecommand in the prose resolved against a real home directory. That is the behaviour this
PR removes, observed from the outside.
Third stated residual: the one leaf the OS layer cannot fence
The whole text-matcher layer is deleted rather than extended, because the enforcement
that actually binds a shell subprocess is the OS layer in
sandbox.py-- a spawnedcommand reaches a file through an
open()that never routes through the tool gate, so apath fenced only there is readable in any sandbox mode whatever a matcher recognises.
Every crew-home leaf therefore carries one of three dispositions, and the disposition IS
the guarantee:
.env,live_target.json,backup/HIDDENsecurity_policy.json,admission_policy.json,profiles,computer_use.jsonREADONLYsel_hmac.keyVISIBLEsandbox.py's own note: these "stay on the tool gate alone"Two consequences are accepted rather than matched around:
is, unconditionally. This is deliberate at the OS layer too: masking a policy file makes
it resolve to the permissive standalone default, so hiding a ceiling REMOVES it instead
of protecting it.
AGENTS.mdis updated in this commit to state what is actuallyguaranteed instead of the "neither read nor write" it claimed.
sel_hmac.keyand.local_secretareVISIBLE(the SEL audit key and theMCP-to-dashboard auth secret), so obfuscated bash spellings of them are
no longer caught; the direct spelling of each is still refused, by
is_sensitive_pathAND by the bash gate.
token_signing.keyisHIDDENand unaffected.Closing this properly means moving its in-sandbox reader behind the gateway so the leaf
can become
HIDDEN-- asandbox.pychange, reachable by no matcher. Accepted by therepository owner for this change rather than deferred to an issue, on the reasoning
that the text layer never bounded it either.
Six review rounds on one span produced ten distinct spellings (
V=$HOME, brace expansion,find -exec, an argument-position assignment,/./, a glued>leaf, an assignmentfeeding a
cd, a conditional reassignment, glued statement boundaries,pushd). Eachclosure narrowed an unbounded set by one and several introduced defects of their own. That
is the evidence for moving the control to the layer that can hold it rather than growing
the one that cannot.