fix(security): fail closed on unreadable process-substitution bodies - #8719
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound fix shape (allowlist over an unbounded denylist), but four closed security bypasses ship with zero pinned regression tests. Watch
Suggestions
[DESIGN-REVIEWED] 399e773 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence is in. The change is a security fix to the git-push gate in First-Principles-Verdict: CONCERNS The allowlist is the right cause-level cure, but it guards one of two boundary-proof sites, and its reserved-word list re-spells What this change shipsIntent: stop bash spellings the push gate cannot read from smuggling a protected-branch push past it — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 399e773 |
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.py:6897 -- The prose-enumerated allowlist lacks the round-trip assertion required by False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
72d7aa5 to
74af3e3
Compare
74af3e3 to
04a8db3
Compare
04a8db3 to
964d5f2
Compare
964d5f2 to
1dd64e7
Compare
1dd64e7 to
b9a69ac
Compare
Follow-up to #8712 (c791f0f), which reconciled #7356 with #7808 and turned main green. Two allow-direction gaps survive it, both found by the GPT 5.6 review lane over eight rounds on #8719 and each verified with a read-only probe against main: * The ANSI-C decision in _iter_shell_chars used a raw `text[i-1] == "$"` lookback, so an ESCAPED dollar (`\$'foo\'`) opened `$'...'`, kept the quote open across a `;`, and hid the publish behind it -- with the segment split and the program anchor now reading this state, a false "still open" is an allow-direction error, not the over-flag the docstring claimed. `$'` now opens ANSI-C only after a literal, unpaired dollar (an odd run; `$$` is the PID parameter). * The process-substitution boundary walk proves the closer for QUOTING only. Three constructs outside quoting moved it or hid the program while the count still balanced: a word-initial `#` comments out the `)` after it (`> >(cat >/dev/null # fake )` + newline + `) main` pushed main), a `case` pattern's `)` is syntax (`> >(case x in x) git push;; esac)` ran the nested bare push), and a globbed program word (`> >(/usr/bin/g?t push origin main)`) closed cleanly but resolves to `git` only at run time, so the payload walk that judges the skipped body saw no push. Modelling bash constructs one at a time is unbounded, so the boundary is refused as a class: a body word the walk cannot read -- a word-initial unquoted `#`, an unquoted reserved word, an unquoted glob or expansion character (`$'` excepted, the walk models it) -- returns None and the segment takes the non-opt-out-able sentinel. #8712's proven quote-aware walk stays exactly as merged; the rule is layered in front of it, so every shape #8712 pins (quoted parens, ANSI-C escapes, `> >(tee log.txt)`, `2> >(cmd)`) keeps its precise reading. No test assertion was changed. Refs #8695, #8706 (both resolved by #8712).
c96bddf to
d9f06b3
Compare
Follow-up to #8712 (c791f0f), which reconciled #7356 with #7808 and turned main green. Two allow-direction gaps survive it, both found by the GPT 5.6 review lane over eight rounds on #8719 and each verified with a read-only probe against main: * The ANSI-C decision in _iter_shell_chars used a raw `text[i-1] == "$"` lookback, so an ESCAPED dollar (`\$'foo\'`) opened `$'...'`, kept the quote open across a `;`, and hid the publish behind it -- with the segment split and the program anchor now reading this state, a false "still open" is an allow-direction error, not the over-flag the docstring claimed. `$'` now opens ANSI-C only after a literal, unpaired dollar (an odd run; `$$` is the PID parameter). * The process-substitution boundary walk proves the closer for QUOTING only. Three constructs outside quoting moved it or hid the program while the count still balanced: a word-initial `#` comments out the `)` after it (`> >(cat >/dev/null # fake )` + newline + `) main` pushed main), a `case` pattern's `)` is syntax (`> >(case x in x) git push;; esac)` ran the nested bare push), and a globbed program word (`> >(/usr/bin/g?t push origin main)`) closed cleanly but resolves to `git` only at run time, so the payload walk that judges the skipped body saw no push. Modelling bash constructs one at a time is unbounded, so the boundary is refused as a class: a body word the walk cannot read -- a word-initial unquoted `#`, an unquoted reserved word, an unquoted glob or expansion character (`$'` excepted, the walk models it), or a nested unquoted `(` (an extglob program `/usr/bin/@(git)` balances the count and resolves only at run time) -- returns None and the segment takes the non-opt-out-able sentinel. #8712's proven quote-aware walk stays exactly as merged; the rule is layered in front of it, so every shape #8712 pins (quoted parens, ANSI-C escapes, `> >(tee log.txt)`, `2> >(cmd)`) keeps its precise reading. No test assertion was changed. Refs #8695, #8706 (both resolved by #8712).
d9f06b3 to
a5feb39
Compare
Follow-up to #8712 (c791f0f), which reconciled #7356 with #7808 and turned main green. Two allow-direction gaps survive it, both found by the GPT 5.6 review lane over eight rounds on #8719 and each verified with a read-only probe against main: * The ANSI-C decision in _iter_shell_chars used a raw `text[i-1] == "$"` lookback, so an ESCAPED dollar (`\$'foo\'`) opened `$'...'`, kept the quote open across a `;`, and hid the publish behind it -- with the segment split and the program anchor now reading this state, a false "still open" is an allow-direction error, not the over-flag the docstring claimed. `$'` now opens ANSI-C only after a literal, unpaired dollar (an odd run; `$$` is the PID parameter). * The process-substitution boundary walk proves the closer for QUOTING only. Three constructs outside quoting moved it or hid the program while the count still balanced: a word-initial `#` comments out the `)` after it (`> >(cat >/dev/null # fake )` + newline + `) main` pushed main), a `case` pattern's `)` is syntax (`> >(case x in x) git push;; esac)` ran the nested bare push), and a globbed program word (`> >(/usr/bin/g?t push origin main)`) closed cleanly but resolves to `git` only at run time, so the payload walk that judges the skipped body saw no push. Modelling bash constructs one at a time is unbounded, so the boundary is refused as a class: a body word the walk cannot read -- a word-initial unquoted `#`, an unquoted reserved word, an unquoted glob or expansion character (`$'` excepted, the walk models it), a nested unquoted `(` (an extglob program `/usr/bin/@(git)` balances the count and resolves only at run time), or an unquoted control operator / `#` inside the word (`>(cat&# fake )` opens a comment after the `&`, and to bash `#` is word-initial after an operator) -- returns None and the segment takes the non-opt-out-able sentinel. #8712's proven quote-aware walk stays exactly as merged; the rule is layered in front of it, so every shape #8712 pins (quoted parens, ANSI-C escapes, `> >(tee log.txt)`, `2> >(cmd)`) keeps its precise reading. No test assertion was changed. Refs #8695, #8706 (both resolved by #8712).
a5feb39 to
bbde828
Compare
Follow-up to #8712 (c791f0f), which reconciled #7356 with #7808 and turned main green. Two allow-direction gaps survive it, both found by the GPT 5.6 review lane over eight rounds on #8719 and each verified with a read-only probe against main: * The ANSI-C decision in _iter_shell_chars used a raw `text[i-1] == "$"` lookback, so an ESCAPED dollar (`\$'foo\'`) opened `$'...'`, kept the quote open across a `;`, and hid the publish behind it -- with the segment split and the program anchor now reading this state, a false "still open" is an allow-direction error, not the over-flag the docstring claimed. `$'` now opens ANSI-C only after a literal, unpaired dollar (an odd run; `$$` is the PID parameter). * The process-substitution boundary walk proves the closer for QUOTING only. Constructs outside quoting moved it or hid the program while the count still balanced: a word-initial `#` comments out the `)` after it (`> >(cat >/dev/null # fake )` + newline + `) main` pushed main; so does `>(cat&# fake )`, since `#` is word-initial after an operator), a `case` pattern's `)` is syntax (`> >(case x in x) git push;; esac)` ran the nested bare push), and a globbed or extglob program word (`> >(/usr/bin/g?t push origin main)`, `>(/usr/bin/@(git) ...)`) closed cleanly but resolves to `git` only at run time, so the payload walk that judges the skipped body saw no push. Enumerating the offending metacharacters grew by one per review round, so the rule is an ALLOWLIST: an unquoted body word may consist only of letters, digits and `/ - _ . = :` (the alphabet of an ordinary program invocation) plus the quote delimiters the walk owns, the `$` of an ANSI-C `$'...'`, and a closing `)`; any other unquoted character, an unquoted backslash escape (`\g\i\t` reaches the program as `git` while no scanner word spells it), an unescaped `$` or backtick inside DOUBLE quotes (`"$GIT" push origin main` expands), or an unquoted reserved word, makes the body opaque and `_git_push_args` returns None so the segment takes the non-opt-out-able sentinel. The same fail-closed answer applies to a redirection whose ATTACHED target opens a paren (`--push-option 2>(cat >/dev/null # fake )`): skipping it as a self-contained redirection left the body's remainder to be read as argv, where a `#` truncated the real refspecs. Known over-block: a redirection or other metacharacter inside the body (`> >(cat >/dev/null)`) is ungated rather than allowed. #8712's proven quote-aware walk stays exactly as merged; the rule is layered in front of it, so every shape #8712 pins (quoted parens, ANSI-C escapes, `> >(tee log.txt)`, `2> >(cmd)`) keeps its precise reading. No test assertion was changed. Refs #8695, #8706 (both resolved by #8712).
Summary
Follow-up to #8712 (
c791f0f1d), which reconciled #7356 with #7808 and turnedmaingreen on the nine push-gate assertions. This PR keeps #8712's structure exactly as merged (_iter_shell_chars,_shell_quote_walk, the proven quote-aware process-substitution boundary) and closes the two allow-direction gaps that survive it, both found by the GPT 5.6 review lane over eight rounds on this PR's earlier revision and each re-verified with a read-only probe againstorigin/mainatc791f0f1d.Refs #8695, #8706 (both resolved by #8712 -- this PR does not re-fix them).
What still escapes on
main(probe:is_denied(...)returnedNone)echo \$'foo\'; git push origin main; eval 'git push origin feature'_iter_shell_charsopens ANSI-C on a rawtext[i-1] == "$"lookback, so the ESCAPED\$opens$'...', the quote stays open across;, the segment never splits, thegitword is "quoted" and the outer line defers to the eval payload (a feature push)git push origin feature > >(cat >/dev/null # fake )⏎) main)balances the count,mainon the next line is never scannedgit push origin feature-x > >(case x in x) git push;; esac)casepattern's)is syntax, not the closer; the nested bare push runsgit push origin feature > >(/usr/bin/g?t push origin main)(alsogi[t], extglob/usr/bin/@(git))gitat run timeFix (implementation only,
src/kiro_crew/security.py, +102/-5)$'opens an ANSI-C string only after a literal, unpaired$(an odd run;$$is the PID parameter,\$is data). The docstring's "only ever over-flags" claim is retired: since fix(security): hand the push arity scan raw words, not cut ones #8712 the segment split and the program anchor read this state in the allow direction, so a false "still open" hides a separator or agitword._process_substitution_word_is_opaqueadmits an unquoted body word only if every unquoted character is in_PROCESS_SUBSTITUTION_SAFE_CHARS-- letters, digits,/ - _ . = :(the alphabet of an ordinary program invocation) -- plus the quote delimiters the walk owns, the$of an ANSI-C$'...', and the closing). Anything else (glob* ? [, extglob / nested(,#,& ; |,~,!, braces,$/backtick expansions, and any unquoted backslash escape such as\g\i\t) or an unquoted bash reserved word (case,if,for,while,function,{,[[, ...), or an unescaped$/ backtick inside double quotes ("$GIT" push origin mainexpands at run time) makes the body opaque:_git_push_argsreturnsNoneand the segment takes the non-opt-out-able sentinel. A redirection whose attached target opens a paren (--push-option 2>(cat >/dev/null # fake )) gets the same answer -- skipping it as a self-contained redirection left the body's remainder to be read as argv, where a#truncated the real refspecs. Rationale: the earlier denylist grew by one shell metacharacter per review round (*?[→ extglob(→#→& ; |), and an enumeration of what bash can do with a character is never finished -- so the rule names what IS readable instead. Known over-block: a redirection or other metacharacter inside the body (> >(cat >/dev/null)) is ungated rather than allowed.Everything #8712 pins keeps its precise reading (probed):
> >(echo '(' ) mainand> >(echo $'a\'b') mainstill recover["origin", "feature", "main"]and reportprotected-branch-name;> >(tee log.txt)/> >(tee 'log.txt')stay allowed;2> >(cmd)still yields["--force", "origin"]; unterminated bodies still fail closed;(git push origin main)keeps #8712's precise row. No test assertion was changed or removed.Differences from #8712
text[i-1] == "$"(escaped\$'misread)$onlyPattern harvest
Rule candidate: review-prompt -- when a security parser "proves" a boundary, ask what grammar the proof covers and enumerate the bash constructs outside it (comments, reserved words, globs, expansions); a proof over quoting alone is not a proof over bash. The eight GPT rounds on this PR were exactly that enumeration.
Not generalizable as semgrep: the defect is a semantic gap between a state machine's coverage and the shell's grammar, not a syntactic anti-pattern.
Verification
Static gates only on this desk (CI is the test oracle):
mypy src/kiro_crew/security.pyclean;scripts/check_black_formatting.pypasses (new hunks black-clean). Read-only probes: 4 escaping shapes above + the earlier 4 GPT shapes all denied; #8712's pinned shapes unchanged.