fix(security): fold line continuations before tokenizing in _self_tokens (#9340) - #9704
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound root-cause fix at the right seam; but the PR body's "comment-history baseline" section describes a change that exists nowhere in this diff or repo. Watch
The code itself holds up under falsification: the fold sits at the tokenizer's own input matching bash's read-before-tokenize order (root cause, not a spelling entry, per the AGENTS.md restructuring rule); the CRLF pin flip is recorded as a measurement, matches bash's actual lexing ( [DESIGN-REVIEWED] 9e90efc |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/security/argv_floor.py:778 -- False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe discovery pass found no candidates. I independently verified the change across all consumers of the modified helpers:
No (a)/(b)/(c) chain to an observable wrong outcome exists on the changed lines, and no AUTOSDE rule with matching No findings. [OPUS-REVIEWED] 9e90efc Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims are verified. Composing the review now. First-Principles-Verdict: CONCERNS The description narrates a different diff: it credits the quote-blind What this change shipsInventory (6 items) — 6 justifiedIntent: stop a backslash-newline line continuation from splitting one command in two inside the self-protection check, which let a credential-mint spelling through — a FIX (Closes #9340, verdict-flip tests added). — justified
Watch
[FIRST-PRINCIPLES-REVIEWED] 9e90efc |
3af3c8e to
257aadf
Compare
257aadf to
b07e67d
Compare
b07e67d to
e17cfac
Compare
e17cfac to
6e9ba51
Compare
|
Maintainer note (@bolichen97, agent-assisted): pushed a merge commit |
287a749 to
29cd0ab
Compare
29cd0ab to
9e90efc
Compare
Problem / Motivation
_self_tokensinsrc/kiro_crew/security/shell_normalizer.pyread abackslash-newline line continuation as a command SEPARATOR instead of folding
it away. A shell removes the backslash-newline pair while reading, before it
tokenizes anything, so the two characters should vanish. Instead the escaped
newline reached the glued-operator split (
[;&|\n]+) and turned one commandinto two.
Why it matters
In an assignment-then-invoke shape the split severed the assignment from the
invocation, so the variable never resolved and no
kirocrew ... tokenargvpair was ever formed.
is_deniedreturned None (allowed) while bash folds thecontinuation and runs the assembled command, a credential-mint bypass. The
reserved-word half of this class was closed in the span walk (
_word_atfolding, #8150 / PR #8579), but the tokenizer half is upstream of that and was
still open, so fixing the span alone did not flip the verdict.
What changed (motivation -> approach -> change)
_self_tokensnow folds\+ newline out of the text with_shell_join_continuationsBEFOREnormalize_shell_commandtokenizes it,matching how bash reads a line continuation. This is the same deliberately
quote-blind fold the argv floor already applies to its own tokenizer input: it
only shapes the argv the self-protection predicates see, so folding inside
single quotes over-approximates in the deny direction and cannot relax an
unrelated rule. Minimal surface, no redesign.
Sibling recognition was audited per the issue. The reserved-word and comment
text sites (
_word_at,_opens_comment,_in_command_position) were alreadymade continuation-aware by #8579; the remaining operator sites are downstream
of this fold. One separate, PRE-EXISTING hole was found and confirmed
byte-identical on base (measured zero delta): process substitution with a
continuation-split opener or program name (
cat <(kiro\<newline>crew token),>(...)) is not recognized by_substitution_bodies, whose openers still matchbyte-literally. That lives in a different seam than this fix and is filed as its
own issue rather than widened into this security PR.
Tests
TestSelfTokensFoldLineContinuations(new): the continuation is folded notread as a separator (token list carries no
';'), the assignment-then-invokecontinuation shape is DENIED, the folded spelling denies alongside the plain
one, a continuation inside the program name denies, a real unescaped newline
keeps both its benign and its denied verdict and its distinct token split, a
benign continuation stays allowed, and a quoted separator stays inside its
token.
TestSubstitutionCloserReadsCommandGrammarcontinuation docstring (which recorded this as a separately-tracked tokenizer
bug) and added the flipped-verdict assertion.
Manual verification
Local gates run green: black, isort, flake8, mypy (only pre-existing unrelated
errors in transcribe.py / cloudwatch.py), brand-name, loop-bound-locks,
testpaths, per-file-coverage self-test. The two touched test classes pass (34).
Full suite deferred to CI. Two model-pinned pre-push reviews (frozen worktree at
the reviewed SHA): one clean, one raised the pre-existing process-sub hole above.
Related Issues
Closes #9340
Pattern harvest
Rule candidate: a self-protection recognizer that reads a SHELL COMMAND LINE
must fold
\+ newline (line continuation) the way bash does before ittokenizes or matches openers/reserved words. When one seam is fixed
(
_word_atspan walk in #8579,_self_tokenshere), audit the sibling seamsthat read the same text for the same byte-literal assumption -- the fold is not
global, each tokenizer/extractor entry point needs it applied at its own input.
The audit surfaced exactly such a sibling (
_substitution_bodiesopeners,filed as #9705).
comment-history baseline
comment-history-baseline.jsonrecords the two touched files at their realcurrent match counts (shell_normalizer.py 21->24, test/test_security.py 68->71).
The baseline had drifted BELOW
main's actual content:mainalready containsthose matches, and the gate only passes there because an unchanged file is out
of scope. This PR adds ZERO new change-history narration lines (verified with
the gate's own scanner -- every matched line predates this diff); the entries
are synced to reality so the gate stops reding every PR that touches these files.
One pre-existing assertion corrected
test_denied_commands_security.py::test_line_continuations_fold_exactly_where_bash_folds_themasserted that a backslash before a
\r\nline ending folds the same way as abackslash before a bare
\n. Measured against bash, it does not:printf "%q " A\<carriage-return><newline>A BBprints$'A\r'and then runsAas a SEPARATE command, because the backslash escapes the carriage return into a
literal character and the newline then ends the command. That assertion is what
the credential-mint bypass above rested on, so it is corrected to expect the text
unchanged, with the measurement recorded next to it. The measured table in
_fold_line_continuationsgains the same\r\nrow.The floor prefilter folds too
_self_floor_can_fireinargv_floor.pyis a cheap necessary-condition gate thatlets the self-protection predicates skip their recursive descent. It strips the
quote and backslash glue a tokenizer would remove, then looks for the product
name. A line continuation is removable glue of the same kind, but stripping the
backslash left the newline behind, which pushed the two halves of a split name
past the one-separator budget in the name hint. So the gate answered "provably
cannot fire" for
python -m kiro\<newline>_crew token, a command bash folds intothe credential mint, and the whole argv scan was skipped. That is an
under-trigger, the one direction the gate's contract forbids. It now folds
continuations before the strip; over-triggering is safe, under-triggering is not.