fix(security): hand the push arity scan raw words, not cut ones - #8712
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Fixes the semantic merge conflict at its root: one shared shell state machine replaces the divergent private copies that let two green PRs compose into a red, permissive main. Both conflicting commits are confirmed in the base ancestry, the diff's mechanism matches the description exactly (raw words handed to the arity scan, one redirection model, quote-aware splits), and the extra bypasses closed along the way are the same defect class in the same seam, all tightening. The new [DESIGN-REVIEWED] 5084a94 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All checks are done. Composing the review. First-Principles-Verdict: CONCERNS Every item removes a traced defect at cause level, but two riders are undeclared and the change orphans What this change shipsIntent: unbreak main's six
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 5084a94 |
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: |
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: |
a8c53d0 to
f9fcef7
Compare
f9fcef7 to
b0f0c1a
Compare
b0f0c1a to
7cb3d43
Compare
eaa8a45 (#7808) landed unrebased over d402acd (#7356). Both rewrote the same git-publish argument tokenizer, in incompatible directions, with no textual conflict -- so main went red on nine push-gate tests on every platform, not just Windows. #7808's option-arity and shell-syntax model is defined over RAW words: it splits each word at its own unquoted operators, models redirection arity itself, truncates at a word-initial '#', and classifies extglob and glob shapes. #7356 had meanwhile taught the outer parse to cut a word at its glued operators and to step over redirections with a second, coarser reading of the same grammar. The outer parse therefore pre-chewed the words the inner model reads, and every shape whose identity lives in the operator lost it: 'origin>' read as a plain remote, '@(main)' as the ambiguous ref '@', a lone '&' as an empty token, and '<<-' left its heredoc delimiter behind as a phantom refspec. Four shapes came back with NO tag at all -- an allow -- and 'git push origin & git push origin2 main' lost its bare row. Reconciled so each decision lives in one place: * _git_push_args returns the RAW spelling of the words it keeps, and gets its redirection arity from _push_token_redirection instead of reading the grammar a second time. That alone fixes '<<-', whose '-' the local reading took for an attached target. * Process substitution is a WORD, not a redirection: a token opening with '<(' / '>(' is returned rather than skipped, so '-o <(echo)' keeps its value and cannot shift the positional split onto the remote. The word- position ungate moves out of _AMBIGUOUS_EXPANSION_RE, which fired on the whole segment and so also denied 'git push origin my-feature > >(tee log.txt)' -- an ordinary feature push whose output is teed, and one #7356 explicitly pinned as allowed. * _dequote_token no longer cuts at operators. _cut_at_operator stays where an operator can hide a PROGRAM name (the git anchor); for an ARGUMENT it destroyed the evidence the scan reads. 'mainline)>log' still resolves to protected 'mainline' via the piece scan. * Segment and word splitting are quote/escape aware. Splitting inside quotes truncated a word mid-quote, and the fragment then arrived with the shell state open -- which the fragment rule reads as a splice across the boundary. That denied legal refnames ("feature|x", 'a;b') and made a wrapper's quoted payload yield a bare 'git' token, so the OUTER line parsed as a push and its fragmented ref denied ordinary work. A backslash-newline still splits and still keeps its trailing escape: it is the one separator that VANISHES, and that escape is the signal the ungated sentinel is drawn from. * A word whose operator tail is only subshell punctuation keeps its exact identity, so a refname pushed inside '( ... )' is read precisely. The word is still scanned as a refspec candidate. Option-vs-positional classification lives in ONE function every path shares. The subshell-punctuation branch above originally appended its recovered word to the positional list directly, skipping the classifier -- so '(git push --repo=origin -f)' filed '-f' as the only refspec, it matched no protected name, and the segment came back with NO tags: a force push to a possibly-protected current branch, admitted by adding one parenthesis, while the same spelling without parens is correctly bare. '(git push -f)' also reported the remote-only row instead of bare, the wrong-identity hazard rounds 10, 13 and 14 of #7808 each turned into a bypass. A punctuation strip changes WHERE a word came from, never WHAT it is, so the glued-redirection branch routes through the same classifier rather than relying on its own flag-shaped-prefix guard. The bare-operator fallback still appends its pieces directly, deliberately: it has already marked the split untrusted, so both no-refspec rows fire and every positional is only a refspec CANDIDATE -- a flag landing there can add a tag, never remove one. Shell quoting likewise has ONE state machine. _iter_shell_chars owns it and five consumers drive it: the operator/fragment read, the segment split, the word split, the operator cut, and the substitution-span helper _matching_close_paren. Each used to carry its own copy, and they did not agree, which cost three more bypasses: * the process-substitution BOUNDARY counted parens with str.count, so in "git push origin feature > >(echo '(' ) main" the QUOTED '(' inflated the depth, the real ')' only returned it to 1, and 'main' was swallowed into the substitution -- a protected-branch push ALLOWED; * the WORD SPLIT had no ANSI-C awareness while the boundary walk did, so in "git push origin feature > >(echo $'a\\'b') main" the escaped quote read as a real closer, the next quote reopened, and 'main' fused into one unterminated word the walk could no longer rescue; * the nested-payload EXTRACTOR walked parens on its own, and there a quote-unaware span does not merely mis-size the body -- it loses the nested command outright. "git push origin my-feature > >(X=')' git push origin main)" extracted the body "X='", so the publish of a protected branch inside the substitution was never scanned and is_denied returned None for a command bash executes. Extraction and boundary now read the SAME span, which is the only arrangement in which they cannot disagree. The rule is explicit: a PROVEN-complete substitution is a word, an UNPROVABLE one is ambiguous. When the words run out with the substitution still open ('>(echo main'), or a quote is still open at the end, the push parse returns None and the segment takes the unparseable branch's non-opt-out-able sentinel; for the extractor the fail-closed reading is the whole remainder, since scanning text that is not really in the body can only add findings. _protected_name_in_substitution carried the same private walk and is repointed too. That corrects an earlier audit note on it: its truncation was recorded as over-deny-only, but stopping at a quoted ')' returns "", so a product name hidden after one was never seen -- an UNDER-deny for the self-protection rule. The remaining paren walkers are deliberate, and each fails safe: _substitution_depth_delta closes an argv EARLY on a quoted ')', so the rest is scanned as its own command (over-scan); _operand_span_end spans to the LAST closer by documented design because quoting is already stripped before it runs; _output_redirect_scan can only over-long a target span; _split_shell_segments serves the cd-tracking pass and is documented as not interchangeable; the _find_brace_* and _find_pattern_operand walkers parse find-command grammar, not shell payloads; _has_top_level_alternation and _split_deny_frags parse deny-rule REGEX text. Round 2's subshell-punctuation branch is a character-membership test over the operator tail rather than a depth count, and a quoted paren needs a quote character there, which is not in "()". Nothing is weakened. Every protective assertion holds, and two readings become MORE precise: '(git push origin main)' now reports the protected-branch row instead of the unparseable-fallback sentinel (the same row its unparenthesised spelling gets -- a spelling-specific row would itself be an escape hatch), and a quoted whitespace-spanning option value no longer needs the fallback because it is no longer torn in two. Verified: 127 passed in test/test_push_branch_gate.py, and 2,792 passed / 1 skipped across test_push_branch_gate.py, test_security.py, test_denied_commands_security.py, test_denied_commands_api.py, test_security_posture.py and test_security_regex_linearity.py. Every new assertion was proven red-first against the previous commit: each exploit spelling publishes a protected branch with no tag at all or is allowed outright, an unclosed substitution silently swallows the rest of the segment, the word split disagrees with the boundary walk about where an ANSI-C quote ends, and a name after a quoted ')' escapes the self-protection scan. Fixes the main-wide push-gate breakage blocking every open PR's shard.
7cb3d43 to
5084a94
Compare
buluoray
left a comment
There was a problem hiding this comment.
Approving — this unblocks every open PR in the repo, and the diagnosis holds up against the evidence.
Verified independently before approving
- The breakage is real and repo-wide:
Backend Tests (3.12, 3)is 9 failed / 21943 passed on an
unrelated frontend PR whose merged tree'stest/andsrc/kiro_crew/hashes are byte-identical to
origin/main's (that PR contains zero Python) — so the failures belong to main, exactly as this
description says. Six intest_push_branch_gate.py::TestUnrecognisedOptionsReadProtectively, three in
test_security.py::TestGitPublishSubshellGluing. - The named cause matches the history:
166ff5acc(#8197) committed 06:53:36Z andeaa8a45bb(#7808)
committed 07:03:28Z, both landing on the same tokenizer with no textual conflict. main is red from
07:03:28Z onward. - This PR's green is not a stale green: its
Backend Tests (3.12, 3)ran 10:51:53Z → 11:15:40Z,
after the second commit landed. (By contrast #8672's shard 3 started 06:47:04Z, beforeeaa8a45bb,
so its green predates the breakage and proves nothing.) - It does not buy green by relaxing tests — the decisive check for a "make main green" fix.
test/test_push_branch_gate.pyadds 108 assert lines and removes 1, alongsidesecurity.py
+540/−200. Every lane is green: 57 pass, 8 skipping, 0 fail; GPT 5.6 and Opus 4.8 report no blocking
findings and Design Review passes.
One note, not a request to change anything
First Principles' CONCERNS is a stale-declaration point, not a defect: its own list judges all ten
shipped items justified, and says items 8–9 (_substitution_bodies becoming quote-aware,
_protected_name_in_substitution reading past a quoted )) "earn their place; only the declaration is
stale." Worth a one-line edit to the touched-surfaces sentence at some point, since those two sit on
is_denied's substitution path rather than the push matcher — but that is documentation, and holding a
repo-wide unblock for it would be the wrong trade.
Merging so the open PRs can go green again.
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).
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).
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).
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, 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. 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).
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), 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. 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).
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. 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).
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).
…8719) 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).
Root cause
eaa8a45bb(#7808, model publish option arity so the floor tag holds) landed unrebased overd402acdf8(#7356, close four enforcement bypasses in the bash command gates). Both rewrote the samegit pushargument tokenizer, in incompatible directions, with no textual conflict — so main went red the moment the second one merged.This is not a Windows-only failure. The Windows shard was simply the first lane to finish; the six
test_push_branch_gate.pyfailures reproduce on Linux with a plainpytest test/test_push_branch_gate.py, and #8697's LinuxBackend Tests (3.12, 3)job showed the identical six. Linux shards 2–4 on main's own run33953620339never completed, which is why the first read looked platform-specific.The mechanism
#7808's option-arity and shell-syntax model is defined over RAW words. It splits each word at its own unquoted operators (
_push_token_shell_read), models redirection arity itself (_push_token_redirection), truncates at a word-initial#, and classifies extglob/glob shapes.#7356 had meanwhile taught the outer parse (
_git_push_args) to cut a word at its glued operators and to step over redirections with a second, coarser reading of the same grammar. The outer parse therefore pre-chewed the words the inner model reads, and every shape whose identity lives in the operator lost it:git push origin>/dev/nullorigingit push origin @(main)@git push origin & echo x&is a command boundary → baregit push origin <<- EOFEOFas a refspec<<-consumes its delimitergit push origin> loggit push --force>/dev/null origin feature-xFour of those returned no tag at all — an allow — and
git push origin & git push origin2 mainlost its bare row. In the other direction, #7808's new whole-segment[<>]\(alternative deniedgit push origin my-feature > >(tee log.txt), an ordinary feature push whose output is teed, which #7356 explicitly pinned as allowed.The fix — one decision, one place
_git_push_argsreturns the RAW spelling of the words it keeps, and takes its redirection arity from_push_token_redirectioninstead of reading the grammar a second time. That alone fixes<<-, whose-the local reading took for an attached target.<(/>(is returned rather than skipped, so-o <(echo)keeps its value and cannot shift the positional split onto the remote. The word-position ungate moves out of_AMBIGUOUS_EXPANSION_RE(which fired on the whole segment) into the scan that sees the tokens surviving redirection removal — so a process substitution the shell removes keeps the precise reading, and one that survives as an argv word is still ungated._dequote_tokenno longer cuts at operators._cut_at_operatorstays where an operator can hide a program name (thegitanchor); for an argument it destroyed the evidence the scan reads.mainline)>logstill resolves to protectedmainlinethrough the piece scan._split_push_command_segments,_split_shell_words). Splitting inside quotes truncated a word mid-quote, and the fragment then arrived with the shell state open — which the fragment rule reads as a splice across the boundary. That denied legal refnames ('feature|x','a;b') and made a wrapper's quoted payload yield a baregittoken, so the outer line parsed as a push and its fragmented ref denied ordinary work (bash -c '(cd /tmp && git push origin my-feature)'). A backslash-newline still splits and still keeps its trailing escape: it is the one separator that vanishes, and that escape is the signal the ungated sentinel is drawn from.( ... )is read precisely. The word is still scanned as a refspec candidate, so a protected name inside the parens is caught exactly as it is without them.Nothing is weakened
Every protective assertion holds. Two readings become more precise, and both are pinned in the metacharacter inventory:
(git push origin main)now reports theprotected-branch-namerow instead of the unparseable-fallback sentinel. The test asserts it equals the row its unparenthesised spelling gets — a spelling-specific row would itself be an escape hatch — and it is still a denial.--push-option='ci skip' origin main→protected-branch-name).What was tested
test/test_push_branch_gate.py— 109 passed (103 pre-existing + 6 new).test_push_branch_gate.py,test_security.py,test_denied_commands_security.py,test_denied_commands_api.py— the two suites that pin fix(security): close four enforcement bypasses in the bash command gates #7356's and fix(security): model publish option arity so the floor tag holds #7808's opposing constraints, so both PRs' intents are held simultaneously.test_security_posture.pyandtest_security_regex_linearity.pyincluded in the same run (repo-wide drift and ReDoS guards, required because this adds functions undersrc/kiro_crew).mypy --platform linux src/kiro_crew— Success: no issues found in 1293 source files.black --checkis clean ontest/test_push_branch_gate.pyand on every line this PR adds tosecurity.py; that file is listed in.github/black-baseline.txt, so it is exempt from the gate and was deliberately not wholesale-reformatted (doing so adds ~594 lines of unrelated churn to an urgent fix).New test class
TestTokenizerContractBetweenTheLayerspins the seam in both directions — what the argument scan is handed and by whom — so the two layers cannot silently drift apart again.Why no screenshot: backend-only change. The touched surfaces are the
git pushdeny-gate matcher insrc/kiro_crew/security.py(_git_push_args,_dequote_token,_push_segment_targets_protected,_git_publish_floor_tags) and its unit tests. No dashboard, renderer, CLI output or user-visible UI is involved.Pattern harvest
Rule candidate: When two PRs edit the same parser and the second merges without rebasing onto the first, a semantic merge conflict lands with no textual conflict — before triaging a red as platform-specific, reproduce it locally on the other platform and check whether that platform's shards actually COMPLETED, because an incomplete shard reads as green in the lane.
Rule candidate: A test whose comment explains WHY a value is expected is the place to detect a layer-contract change — when a rationale ("no clean
gittoken survives", "fragments poison the split") has become false because another PR fixed that layer, the fix is to re-derive the expected value and say which layer now accounts for it, not to restore the old behaviour.Not generalizable: The specific arity table entries (
<<-consuming its delimiter,<(/>(being words rather than redirections,>&-keeping its self-contained reading) are bash grammar facts particular to this matcher and carry no lesson beyond it.