feat(hooks): deny git checkout/restore that would discard uncommitted work - #1380
Conversation
… work hooks/gitapex_check_bash_safety.py's classify() extracts every path a git checkout/git restore invocation could discard, purely from token shape (no live I/O): the -- anchored form, git checkout . or a multi-positional checkout with no --, and a case-sensitive git restore flag walk (--staged/--worktree tracked separately so -S/-s are never conflated). A dynamic path token that cannot be soundly resolved to a literal denies outright, as does a segment where the classifier cannot tell which working tree is at risk (-C/--git-dir/--work-tree, a GIT_DIR=/GIT_WORK_TREE=/GIT_INDEX_FILE= assignment, or an earlier cd in the same command). hooks/check-bash-safety.sh adds a new wrapper step, structured like the existing git-push/provenance-scan step: when checkout_restore_paths is non-empty, it reads .cwd from the PreToolUse payload itself (not CLAUDE_PROJECT_DIR, which does not track the session's own cd calls) and denies if `git diff --quiet` reports any of the paths dirty against HEAD (or the empty-tree hash on an unborn HEAD). Closes the near-miss in #1128 repair 4, where `git checkout -- PATH` silently discarded an uncommitted refactor with no warning. Refs #1128. Fixes #1375.
…tore Covers the new git-checkout/restore wrapper step in check-bash-safety.sh against real scratch git repos: a dirty target denies from both the repo root and a subdirectory, a clean target and ordinary branch switches allow, restoring with only the staged flag stays allowed regardless of working-tree dirtiness, a missing or non-repo cwd denies, the unborn-HEAD empty-tree fallback behaves correctly in both directions, a tree-location flag denies through the classifier before any live git call runs, and a real merge conflict names a remedy that actually works mid-conflict (unlike a plain stash, which fails while unresolved). Refs #1375.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1380 +/- ##
==========================================
+ Coverage 99.60% 99.61% +0.01%
==========================================
Files 145 145
Lines 23666 24700 +1034
Branches 2833 3001 +168
==========================================
+ Hits 23572 24606 +1034
Misses 94 94 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…paths codecov flagged 7 uncovered lines on PR #1380 (97.31% patch coverage, target 99.55%): the --no-worktree branch, the bare and fused forms of recurse-submodules, the plain boolean-flag vocabulary in git restore's flag walk, and the checkout/restore subcommand scan's own normal loop exit when only global flags precede the end of a segment with no subcommand following. Adds a targeted test for each, bringing hooks/gitapex_check_bash_safety.py to 100% line and branch coverage. Refs #1375.
Independent adversarial review of PR #1380 found two real defects in the new git checkout/restore detection surface, both live-confirmed before being fixed here. Critical: a genuinely-unset, unquoted decoy token between "git" and "checkout"/"restore" (e.g. git $NEVERSET checkout -- file.py) made _find_git_checkout_restore treat that git occurrence as ambiguous and give up, so checkout_restore_paths came back empty and the command was silently allowed -- a near-zero-effort bypass of the entire feature, reproduced live via a real bash proxy confirming the decoy word-splits away to nothing and the command genuinely runs as plain "git checkout -- file.py". Fixed by reusing _token_is_all_unassigned_refs, the same primitive the existing git-push detector already relies on for the identical position, to skip a token that unambiguously vanishes at real bash runtime instead of giving up on it. Medium: shlex's own default whitespace set includes a bare newline, so tokenize() never produced a distinct newline token, making segment_tokens' own newline-boundary handling dead code. Every prior rule was unaffected since none of them depend on a segment actually ending where a line break falls, but the new checkout/restore path extraction consumes every token to the end of its segment, so an ordinary two-line script with a checkout on one line and something unrelated with a dynamic token on the next line had that second line's own token swept in and spuriously denied. Fixed by moving the newline into shlex's own punctuation_chars instead, so it tokenizes as its own operator token; a newline inside a quoted string still stays fused into its own token as before. Both are pinned with regression tests, and hooks/gitapex_check_bash_safety.py stays at 100% line and branch coverage. Refs #1375.
…e positives A second, independent round of adversarial review of PR #1380 found the round-1 vanishing-decoy fix does not cover every shape that vanishes. _token_is_all_unassigned_refs deliberately excludes any default-clause reference on the reasoning that a default clause always supplies real substitute text -- true when that text is non-empty, but not when it is literally empty. ${NEVERSET:-}, ${NEVERSET-}, ${NEVERSET:+x}, and ${NEVERSET+x} (NEVERSET never assigned) all confirmed live via a real bash proxy to word-split away to nothing exactly like a bare $NEVERSET, reopening the same near-zero-effort bypass the first fix closed, just spelled differently. Rather than widen the shared, already many-times-revised _token_is_all_unassigned_refs primitive (used by several existing rules, with a long history of narrow fixes and reverted over-generalizations), this adds a small, local helper scoped to the checkout/restore detector that recognizes exactly these two clause shapes, delegating the real "does NAME itself vanish" question back to the existing primitive on a synthesized plain reference rather than re-deriving it. The same review round also found two low-severity false positives in git restore's own flag walk: a literal -- (valid git syntax disambiguating a pathspec, the same role it already plays for git checkout) and the fused --source=VALUE/--conflict=VALUE forms were both denied outright as unrecognized flags. Both are now recognized. All three are pinned with regression tests, including a property test exercising the new helper directly per the repository's own detection- logic property-coverage gate, and hooks/gitapex_check_bash_safety.py stays at 100% line and branch coverage. Refs #1375.
Self-review after the round-2 fix found one more shape of the identical
bug: bash's assign-default parameter expansion, ${NAME:=} and ${NAME=}
with empty text, also word-splits away to nothing when NAME is
unassigned (confirmed live via a real bash proxy), the same as the
already-fixed ${NAME:-}/${NAME-} default-clause forms, but the helper
only matched the "-" operator, not "=".
Widens the same local regex to accept both operators. Also adds an
explicit no-false-positive pin for ${NAME:?}/${NAME?} (the error-message
clause): unlike every other clause here, real bash terminates the whole
command with an error when NAME is unset for that one, so it is correctly
left unrecognized rather than treated as vanishing -- there is no real
invocation for a missed detection to miss.
Refs #1375.
Three rounds of adversarial review progressively closed every ordinary, honest-accident-shaped bash idiom for a decoy token between "git" and "checkout"/"restore" that vanishes at real bash runtime: a bare reference, and the default/assign-default/alt-value clause forms. Bash's other parameter-expansion operators that also evaluate to the empty string on an unset variable -- substring, prefix/suffix removal, pattern substitution, case modification -- are not honest-accident-shaped the way those are, matching this file's own established convention for exotic non-literal indirection (issue #1375's own Non-goals section). Pins this explicitly as a disclosed, tested residual rather than leaving it silently uncovered, following the same KNOWN_BYPASS_COMMANDS convention this file already uses for two other disclosed gaps: the decoy is correctly treated as ambiguous, not silently misread as a real checkout/restore invocation, so this is the same disclosed-residual shape the dynamic-subcommand Non-goal already carries, not a distinct or worse failure mode. Refs #1375.
A fresh, independent adversarial review of this PR's current head found
a real, live-verified bypass: an ordinary backslash-newline line
continuation before a checkout/restore path (a common style for
wrapping a long git command) tokenized to a path with a literal leading
newline baked in ('\nfile.py' instead of 'file.py'). The live git diff
wrapper check then ran against that nonexistent path, found it clean,
and silently allowed a real, dirty-file checkout through.
Root cause: shlex's own posix-mode escape handling does not implement
POSIX shell's backslash-newline line-joining rule -- it leaves a stray
newline character embedded in the token (unquoted) or leaves both
characters untouched (double-quoted), instead of removing the pair
entirely the way real bash does.
Fixed with a narrow, quote-aware preprocessing pass, _strip_line_
continuations, run before shlex ever sees the command: it tracks only
single/double/unquoted state and removes exactly a backslash-newline
pair when not inside single quotes, passing every other character
through unchanged so shlex's own existing escape resolution still
applies to everything else exactly as before. Verified live against
real bash across unquoted, double-quoted, and single-quoted contexts,
including the escaped-backslash-then-newline edge case where the second
backslash must not be treated as a fresh escape-introducer.
Also disclosed a second, non-blocking finding from the same review: a
bare checkout/restore restores the working tree from the index, not
HEAD, so a staged-with-no-further-edit path is a genuine no-op that this
check still denies as if it would discard something. Confirmed
directionally safe (over-denial only, never a missed real discard) and
pinned with a regression test and an inline comment rather than
special-cased, since the existing deny message's remedies already
resolve it as a false alarm.
Refs #1375.
A fresh, independent adversarial review of this PR's current head found
a second real, live-verified bypass: `-b`/`-B`/`--orphan` is git's own
branch-creation/reset mode for checkout, mutually exclusive with every
pathspec-checkout mode, but `-b`/`-B` take the immediately following
token as their own new-branch-NAME value, which does not start with
`-`. Sub-case (b)'s dash-prefix positional filter swept that value (and
a start-point after it) into checkout_restore_paths as if they were
file paths, so `git checkout -f -b newbranch other` reported
('newbranch', 'other') -- neither the real at-risk file.
Live-verified end-to-end in a scratch repo: dirtied a tracked file with
no relation to either token, ran the real command, and confirmed the
change was silently discarded while the wrapper's live git diff check
against the two wrong (nonexistent) paths found "clean" and allowed the
command through -- a false safety claim, not merely an unchecked case.
Fixed by checking for -b/-B/--orphan first in _git_checkout_paths,
before any sub-case dispatch, and folding it into the same honest,
no-claim Non-goal the bare `git checkout SOMENAME` case already
carries: empty checkout_restore_paths, matching the risk profile this
exact command already had before this feature existed (git's own
built-in switch-protection, minus whatever -f already bypasses), rather
than a confident, wrong claim that specific paths were checked and
clean. Soundly extracting the real at-risk set for a branch
creation/reset would need to reproduce git's own internal "would this
overwrite any dirty tracked file" logic, out of a pure classifier's
reach -- the same reasoning that already accepts the bare-SOMENAME case
as a Non-goal.
Updated one pre-existing test whose own fixture command happened to be
exactly this shape (`git checkout -b newbranch master`, used to pin the
unrelated newline-segment-boundary fix) to use plain positionals
instead, preserving its original intent without colliding with this
fix.
Refs #1375.
…n rule
A fresh, independent adversarial review of this PR's current head found
a third real, live-verified bypass: _git_restore_paths already hard-
denies --pathspec-from-file/--pathspec-file-nul ("paths come from a file
this classifier cannot inspect"), but _git_checkout_paths -- despite
real git accepting the identical flag pair on checkout, not just
restore -- never recognized it at all. A single positional after it
(the control file's own name) fell through to the bare-SOMENAME
Non-goal, which is the wrong treatment here: that Non-goal is honest for
an ambiguous ref/path, but --pathspec-from-file's value names a FILE
CONTAINING the real pathspecs, which is exactly the opaque-path threat
this whole feature exists to close, not a case to silently wave through.
Live-verified end-to-end: with a tracked file listed in the control file
dirtied, the wrapper allowed `git checkout --pathspec-from-file
files.txt` unconditionally (no check performed at all), and the real
command silently discarded the change.
Fixed by adding the same explicit deny _git_restore_paths already
carries for this flag pair, checked right after the round-4 branch-
creation-flag fold and before any positional-based sub-case dispatch.
Refs #1375.
Resolves the single real conflict in hooks/gitapex_check_bash_safety.py: issue #1350 (merged to main via PR #1383 while this branch was in review) independently implemented its own _strip_line_continuations for a related newline-swallowing bug, plus a new _strip_comments fix. Both branches added a same-named function for the same underlying bash line-continuation gap. Reconciled to one shared implementation rather than two divergent copies: kept _strip_comments (main's, unrelated and additive), and for _strip_line_continuations kept this branch's double-quote-aware quote tracking over main's single-quote-only version, after finding main's simpler version has a real bug -- an apostrophe inside an already-open double-quoted string (e.g. "don't ... \<newline>...") is wrongly read as a fresh single-quote opener, since it never tracks double-quote state at all, which then suppresses continuation-removal for the rest of that string. Live-verified: `printf '[%s]' "don't strip \` + newline + `this"` must produce one joined argument; main's algorithm would leave the continuation unstripped once it saw the apostrophe. Merged docstrings to preserve both sides' live-verified reasoning (round-3/#1375's own bypass finding, #1350's dual fix and the _strip_comments interaction, and this reconciliation's own double-quote finding) rather than dropping either narrative. Re-ran the full hooks/, checkout/restore, oracle-pins, and sibling gitapex_check_task_bash_safety.py suites after resolving (835 passed), plus ruff/mypy/xenon/property-coverage-gate/ssot-schema, all clean, 100% coverage maintained on the merged file.
Merge conflict resolution (main -> this branch, commit b03b059)
File in conflict: What conflicted: both branches independently added a same-named Resolution approach:
Verification after resolving: ruff, mypy, xenon, the property-coverage gate, and the ssot-schema scan all clean; Known follow-up, out of this PR's scope: the sibling module Generated by Claude Code |
A fresh, independent adversarial review of this PR's merge-conflict resolution (round 6) found a real bug in _strip_comments's own boundary tracking, present since issue #1350's already-merged version but with no observable consequence there (main had no checkout/restore feature for a leaked comment token to reach) -- this merge is what first makes it security-relevant. _strip_comments unconditionally cleared its own word-boundary flag after consuming any backslash-escaped pair, including a genuine \<newline> line continuation. A continuation deletes to nothing once _strip_line_continuations runs afterward, so the character right after it should see whatever boundary status held immediately before the backslash -- not a forced non-boundary. The bug meant a "#" comment sitting on a continued line was never recognized as a comment. Live-verified end-to-end: `git checkout -- clean.py \` + newline + `# TODO revisit auth.py later` swept "auth.py" (a name that only appears in the comment text) into checkout_restore_paths as a phantom candidate, and the wrapper denied an entirely safe checkout with a message pointing at a file the command never referenced. Over-denial only, never a missed real discard, but a confusing false positive for a completely ordinary bash idiom (an inline comment on a wrapped command). Fixed by not clearing the boundary flag specifically when the escaped character is a newline, in both the unquoted and double-quoted backslash-handling branches (the double-quoted one has no currently observable effect, since exiting a quote already forces the flag false regardless, but is fixed too for consistency with the documented invariant and to avoid a future refactor silently making it live). Refs #1375.
A fresh, independent adversarial review of this PR's current head
(round 7) found a critical, full-classifier-bypass bug, not limited to
checkout/restore: _strip_comments treated everything inside an open
double-quoted string as opaque literal text, with no comment
recognition at all. Correct for genuine literal content ("a#b" really
is one literal word in real bash), but wrong for a $(...) embedded
inside that double-quoted string -- real bash recursively re-enters
ordinary, comment-aware command parsing for a substitution's own
content regardless of what quote encloses the $( that opened it, so a
"#" inside it does start a real comment (confirmed live: a ")" inside
a #-comment inside "$(...)" does not end the substitution).
Left unstripped, that comment's own embedded ")" survived into shlex's
dequoted token, where _find_fused_command_substitution's paren-depth
counter (comment- and quote-blind by design) mistook it for the
substitution's real closing paren, silently truncating everything
after that point -- including a genuine, undisguised git checkout on
the next physical line -- from all classification, not merely the
checkout/restore rule.
Live-verified, real, silent data loss: `x="$(echo hi #comment with
paren ) here` + newline + `git checkout -- dirty.py)"` ran the
embedded checkout for real and discarded an uncommitted change, while
classify() reported deny=False with an EMPTY checkout_restore_paths --
a confident, wrong "nothing to see here" rather than an honest
non-goal.
Fixed by teaching _strip_comments's double-quote handling to recognize
a nested $(...) and delegate to a new recursive helper,
_consume_command_substitution_content, that processes the
substitution's own content the way real bash does: comments live
again, and any further nested '/"/$( gets its own independent,
mutually-recursive handling (via a second new helper,
_consume_double_quoted_content, also now used for ordinary top-level
double-quoted strings). The analogous decoy built from a literal ")"
inside a nested QUOTED span (rather than a comment) needed no fix and
was checked live: any balanced quoted span containing a literal ")"
necessarily leaves an odd, unbalanced quote count in text naively
truncated partway through it, which already trips tokenize()'s own
TokenizeError fail-closed path -- only a comment can hide an
unbalanced ")" without requiring an unbalanced quote in the truncated
prefix, which is why this fix is scoped to comment-handling rather
than a general rewrite of the paren-depth counter.
Refs #1375.
A fresh, independent adversarial review of this PR's current head (round 8), stress-testing the round-7 comment/substitution-nesting fix, found a distinct, more fundamental bug: Python's shlex module (which tokenize() relies on) tracks double-quote state as one flat, whole-command toggle, with no concept of bash's own recursive quote-context reset inside a $(...) command substitution. A double-quoted span nested inside a $(...) that is itself nested inside an outer double-quoted string desynchronizes shlex's own quote parity from real bash's actual parse, while keeping the total double-quote count even across the whole command -- so tokenize()'s own TokenizeError fail-closed path never fires, unlike the structurally-safe, always-unbalanced quote-decoy shape the round-7 fix's own docstring already documents. Live-verified real, silent data loss: x="$(echo "y)" && git checkout -- dirty.py)" genuinely discards a dirty tracked file when actually executed, while classify() reports deny=False with "git"/"checkout" never appearing as their own separate tokens at all -- fused into what shlex mis-reads as inert quoted content. This is a property of shlex itself, not any rule built on top of it: every existing rule (checkout/restore, git push, pip install, gh api) shares this exposure equally, and it reproduces on commits predating this PR's own round-7/round-8 fixes -- confirmed not introduced by issue #1375's own checkout/restore feature. A genuine fix needs a command-substitution-aware recursive tokenizer replacing shlex's own single-pass, non-recursive quote state machine, not a narrow patch -- disproportionate scope for issue #1375's own checkout/restore feature. Tracked as its own dedicated issue, #1404, rather than fixed here. Disclosed in the module's own header docstring and pinned as a tested, current-behavior regression in KNOWN_BYPASS_COMMANDS (shlex-nested-double-quote-inside-command-substitution-full-bypass), matching this module's own established convention for a disclosed, accepted residual rather than leaving it silently uncovered. Refs #1375.
…s scope-isolating
A fresh, independent adversarial review of this PR's current head
(round 34) found two more sibling gaps in the same scope-isolation area
rounds 31-33 already closed for `(...)` subshells, pipe stages,
background jobs, `local`/`declare`/`typeset` declarations, and process
substitution.
`coproc { ... }` (bash's own coprocess syntax) forks its body to run
asynchronously in a subshell connected by a pipe, exactly like `cmd &`,
with no non-isolating usage at all -- but the pre-round-34 isolation
check had no concept of `coproc` whatsoever. Independently reproduced
live via `classify()` and real bash execution with stand-in `uv`/`gh`
binaries on PATH: `TOOL=uv; VERB=harmless; VERB=$(echo install);
coproc { VERB=safe; }; wait; $TOOL $VERB foo` resolved to `deny=False`
even though real bash genuinely runs `uv install foo` (captured argv:
"install foo"); the `_rule_gh_api_write` counterpart reproduces
identically.
Bash's `$"..."` locale-translated-string syntax fuses the `$` prefix
onto the dequoted string content -- this classifier's own tokenizer
turns `$"local"` into the single token `$local`, never a bare `local`,
so the exact-membership check the round-32/33 fixes relied on
(`"local" in seg`) never matched it, even though `$"local"` genuinely
invokes the `local` builtin in real bash when used in command-starting
position (confirmed live: `f() { $"local" VERB=safe; }; f` leaves the
caller's own `$VERB` untouched, exactly like a bare `local VERB=safe`
would). `TOOL=uv; VERB=harmless; VERB=$(echo install); f() { $"local"
VERB=safe; }; f; $TOOL $VERB foo` resolved to `deny=False` even though
real bash genuinely runs `uv install foo`. Confirmed through the real
wrapper: all four new scenarios (two constructs x two consumers) now
deny with exit 2 where they previously allowed with exit 0.
Closed by extracting a shared `_SCOPE_LOCALIZING_KEYWORDS` set (now
`local`, `declare`, `typeset`, `coproc`) and a new `_seg_has_a_scope_
localizing_keyword` helper that checks each token BOTH as-is and with a
single leading `$` stripped, catching the `$"..."`-fused form for all
four keywords uniformly -- `coproc` is treated identically to `local`
(always isolating, no ambiguous non-isolating usage exists in real
bash).
Regression tests added at every established layer: unit tests for
`_seg_has_a_scope_localizing_keyword`/`_segment_tokens_with_scope_
isolation`'s new `coproc`/`$"..."`-fused handling directly, a `@given`
Hypothesis property test exercising the new helper by name (issue
#1178's own coverage requirement), unit and `classify()`-level tests
for both consumer functions' new isolation classes plus false-positive
controls (an unrelated coproc, an unrelated plain `$local` variable
reference in a different segment, a real top-level clear after a
harmless coproc), and wrapper-level end-to-end pins against real
stand-in `uv`/`gh` binaries (hooks/test_gitapex_check_bash_safety.py,
tests/test_gitapex_check_bash_safety_properties.py). Full gate suite
green: ruff check/format, mypy, xenon (CI's own whole-codebase
invocation), the detection-logic property-coverage gate (run against
the uncommitted working tree, no HEAD ref), and no coverage regression
on hooks/gitapex_check_bash_safety.py (99%, identical pre-existing gap
in two unrelated functions, confirmed unchanged against the
pre-round-34 baseline).
Refs #1375.
…' into claude/pr-1375-merge-prep-ru0cn6
…cope-isolating
A fresh, independent adversarial review of this PR's current head
(round 35) found a new sibling gap in the same scope-isolation area
rounds 31-34 already closed for `(...)` subshells, pipe stages,
background jobs, `local`/`declare`/`typeset`/`coproc` declarations, and
process substitution.
None of `{`, `}`, `while`, `do`, `done`, `until`, `for`, `select`, `if`,
`then`, `fi` were recognized at all -- but a `{...}` brace group or a
`while`/`until`/`for`/`select`/`if` compound command, backgrounded or
piped AS A WHOLE, forks exactly like a subshell: a fake "the value is
safe now" reassignment written inside one can never actually reach the
parent shell's own copy of the name. Independently reproduced live via
classify() and real bash execution with stand-in uv/gh binaries on
PATH: a backgrounded brace group around a static reassignment resolved
to deny=False even though real bash genuinely runs the earlier, still-
dynamic value's own dangerous command (captured argv confirmed against
a stand-in uv binary); the piped-brace-group, piped-while-loop,
backgrounded-if, backgrounded-for, doubly-nested-piped-group, and
pipe-receiving-group's-later-statement shapes all reproduce
identically, as does the _rule_gh_api_write counterpart.
Two negative controls confirm a correct fix cannot simply treat every
brace/while/until/for/select/if as unconditionally isolating (the same
false-positive trade-off already navigated for parenthesized subshells
in round 31/32, and deliberately not extended to arithmetic ((...))):
a bare compound-command group with no trailing background or pipe
boundary genuinely leaks to the parent in real bash and must stay
allowed.
A third live-verified concern surfaced during this round's own design
review, not by the reviewing subagent: an initial stack-based matching
design that scanned every token position (not just position 0) for a
close keyword desynced against a literal, non-syntactic argument (a
bare "fi" as a plain command argument) sitting inside the same group's
own segment -- it popped the real group's own stack entry against that
spurious match, leaving the REAL closing brace unmatched and the
assignment wrongly left unisolated, reopening the very bypass this
change exists to close. Closed by requiring a close keyword to sit at
position 0 of its own raw segment (grammar-accurate: bash always
requires a closing brace/done/fi to start a fresh command), while
still counting every leading, contiguous run of open keywords in a
segment (bash places no such requirement between an outer and an
immediately-nested opener) -- confirmed live post-fix that the literal-
argument distractor case still correctly denies.
Closed via a new open/close keyword-set pair and a bracket-matching
helper that marks every raw segment inside a group isolated exactly
when the group's own outer boundary is a pipe stage, is backgrounded,
or is itself the receiving side of a pipe -- wired into the existing
scope-isolation segmentation alongside the subshell/pipe/background/
local checks already there.
Regression tests added at every established layer: unit tests for the
new bracket-matching function directly (including the nested-open,
pipe-receiving-later-statement, and literal-close-keyword-argument
safety cases), a Hypothesis property test exercising it by name (issue
#1178's own coverage requirement), unit and classify()-level tests for
both consumer functions' new isolation classes plus false-positive
controls (an unrelated backgrounded group, a real top-level clear
after a harmless one, three bare-group negative controls), and
wrapper-level end-to-end pins against real stand-in uv/gh binaries.
Full gate suite green: ruff check/format, mypy, the detection-logic
property-coverage gate (run against the uncommitted working tree, no
HEAD ref), and no coverage regression on the classifier file (99%,
identical pre-existing gap in two unrelated functions, confirmed
unchanged against the pre-round-35 baseline). The one xenon complexity
finding surfaced by a whole-codebase run is confirmed byte-identical
to origin/main and outside this diff entirely -- a pre-existing
condition, not introduced by this round.
Refs #1375.
…corruption A fresh, independent adversarial review of this PR's current head (round 36) found case/esac was entirely absent from the scope-isolation machinery rounds 31-35 built for its sibling compound commands, and found a second, distinct bug in how its own syntax interacts with the pre-existing (...)-subshell depth tracking. Finding 1: case...esac is bash's remaining compound-command form -- it forks as one unit when the whole statement is piped or backgrounded, exactly like the brace-group/while/until/for/select/if forms round 35 already closed -- but neither "case" nor "esac" were ever added to the group-isolation keyword sets. Independently reproduced live via classify() and real bash execution with stand-in uv/gh binaries on PATH: a backgrounded case around a static reassignment resolved to deny=False even though real bash genuinely runs the earlier, still- dynamic value's own dangerous command; the piped form and the _rule_gh_api_write counterpart both reproduce identically. A bare case...esac with no trailing background or pipe boundary genuinely leaks to the parent in real bash and stays correctly allowed post-fix. Closed by adding "case"/"esac" to the existing keyword sets -- both already satisfy the position-0/leading-open-run detection every other keyword pair relies on, so no change to the bracket-matching logic itself was needed. Finding 2: a case statement's own pattern arm ends with a bare ")" (e.g. "1) ...", "a|b) ...") that is lexically indistinguishable, at the token level, from a subshell-closing ")" -- but unlike every other paired delimiter this classifier tracks, a case pattern's closing paren has no corresponding open token at all. The pre-fix code decremented the tracked (...)-nesting depth unconditionally on every bare ")", so a case nested inside a genuinely enclosing subshell prematurely "closed" that subshell's own tracked depth on the case's first pattern arm, long before the real closing paren was reached -- reproducing with no background or pipe operator involved at all, purely through ordinary subshell nesting. Confirmed live that the outer subshell genuinely still isolates the assignment in real bash. Closed by tracking, per currently-open case block (a stack, since case statements nest), whether the next bare ")" is that block's own pattern terminator rather than a real subshell close: armed once, right after that case's own first "in" keyword; disarmed the instant a ")" is consumed as a pattern terminator (still creating a segment boundary, but leaving depth unchanged); re-armed on the next double-semicolon while the block stays open; and popped off the stack at "esac". A companion safety concern was checked during this round's own design review: a per-block flag ensures only the case's own first "in" arms the pattern-close expectation, so a LATER, unrelated "in" lexically inside an arm's body (from a nested for-loop or select-loop) is never mistaken for the case's own -- confirmed live that a real subshell genuinely enclosing such a nested loop stays correctly isolated, not desynced by the inner loop's own "in" keyword. Regression tests added at every established layer: unit tests for both the depth-tracking fix and the group-isolation fix directly (including the nested-real-subshell-inside-an-arm and nested-for-in-desync safety cases), a Hypothesis property test exercising the group-isolation function by name (issue #1178's own coverage requirement), unit and classify()-level tests for both consumer functions' new isolation classes plus false-positive controls (a bare case, an unrelated backgrounded case, a real top-level clear after a harmless one, a genuine subshell nested inside a case arm that must still leak normally after its own pattern close), and wrapper-level end-to-end pins against real stand-in uv/gh binaries. Full gate suite green: ruff check/format, mypy, the detection-logic property-coverage gate (run against the uncommitted working tree, no HEAD ref), and no coverage regression on the classifier file (99%, identical pre-existing gap in two unrelated functions, confirmed unchanged against the pre-round-36 baseline). Refs #1375.
…ng depth A fresh, independent adversarial review of this PR's current head (round 37) found a false-positive bug introduced by the round-36 fix for case/esac scope isolation. Bash's case syntax allows an OPTIONAL leading paren decorator on a pattern arm -- "(1) cmd ;;", "(1|2) cmd ;;" -- common, POSIX/ksh- compatible style, no shopt needed. That opening paren is lexically identical to a real subshell opener, so it hit the ordinary subshell- open handling and unconditionally incremented the tracked nesting depth. The round-36 fix only consulted its own case-pattern-tracking state on the CLOSING paren, never the opening one, so the same closing paren that correctly recognized itself as the arm's own pattern terminator (and correctly skipped decrementing) left the decorator's own phantom increment permanently unbalanced. Tracked depth stayed inflated by one for the rest of the token stream, which downstream means every segment after such a case block was wrongly treated as scope-isolated -- so a perfectly ordinary, genuinely top-level static reassignment after the case statement could never clear an earlier poisoning. Independently reproduced live via classify() and real bash execution with stand-in uv/gh binaries on PATH: a decorated case arm followed by a genuine top-level clearing reassignment resolved to deny=True even though real bash genuinely runs the harmless, cleared command; the alternation-pattern form and the gh api write-detector counterpart both reproduce identically. Traced that this bug can only ever inflate tracked depth relative to real bash's own true nesting, never deflate it, so it is confirmed a false positive (over-denial), not a bypass -- unlike every round-30-36 finding in this same area. Closed by tracking, per currently-open case block, whether its current pattern arm has consumed any real token yet since being armed. A bare opening paren is now treated as the harmless decorator, with its depth increment suppressed, only when it is the very first token of the current arm; every token consumed while a block is armed marks the arm as started immediately afterward, except the arm/re-arm setup tokens themselves. This deliberately leaves a rare, opt-in bash extglob pattern's own internal parens unaffected -- a narrower, disclosed residual (an over-denial on a feature requiring an explicit shopt, never a bypass) rather than a fully general case-pattern parser. Regression tests added at every established layer: unit tests for the depth-tracking fix directly (including a check that the fix does not disable the existing group-isolation mechanism for a genuinely backgrounded decorated case), unit and classify()-level tests for both consumer functions' new behavior plus the alternation-pattern and gh-api-write forms, and wrapper-level end-to-end pins against real stand-in uv/gh binaries (both the newly-allowed and the still-denied shapes). Full gate suite green: ruff check/format, mypy, the detection-logic property-coverage gate (already satisfied by existing coverage on the touched function), and no coverage regression on the classifier file (99%, identical pre-existing gap in two unrelated functions, confirmed unchanged against the pre-round-37 baseline). Refs #1375.
…racking A fresh, independent adversarial review of this PR's current head (round 38) found a genuine security bypass in the round-36/37 case- tracking state machine, reopening the class of defect those rounds closed. Bash only treats case and esac as reserved words in command-starting position. The word between case and in -- the statement's own subject -- is an ordinary word position, so a literal esac (or case) there is valid, unremarkable bash (case esac in a) true ;; esac genuinely switches on the literal string "esac"). The case-tracking state machine matched purely on token text with no restriction that the token actually occupy real bash's own keyword position, so a literal esac subject immediately popped the tracking stack before the real in keyword was even reached. The pattern's own genuinely-terminating close paren then fell through to the ordinary subshell-close handling and wrongly decremented a REAL enclosing subshell's own tracked depth, deflating it relative to real bash's true nesting -- the opposite direction from round 37's over-denial, and therefore a genuine bypass. Independently reproduced live via classify() and real bash execution with stand-in uv/gh binaries on PATH: a case statement using the literal word "esac" as its own subject, genuinely nested inside a real enclosing subshell, resolved to deny=False even though real bash genuinely keeps the later reassignment isolated inside that subshell; the gh api write-detector counterpart and the case-as-its-own-subject variant both reproduce identically. Closed by requiring case and esac to additionally sit at position 0 of the current in-progress raw segment before either is recognized as live case-tracking syntax -- the exact same position-0 discipline already applied to the compound-command group-isolation mechanism's own open/close keyword matching, for the identical reason: real bash's grammar guarantees case and esac only ever start a fresh command, so a token seen after other tokens have already accumulated in the current segment can safely be treated as ordinary literal text. The in keyword is deliberately not given the same position-0 gate, since the real syntactic in normally is not segment-position-0 itself (it shares a segment with the case's own subject word) -- independently verified live that a literal in used as a case's own subject still resolves correctly through the existing once-only consumption guard, needing no separate fix. Regression tests added at every established layer: unit tests for the position-0 fix directly (both the esac-as-subject and case-as-subject shapes), a unit test at the poisoning-tracking level, classify()-level tests for both consumer functions plus the gh-api-write counterpart, and wrapper-level end-to-end pins against real stand-in uv/gh binaries. Full gate suite green: ruff check/format, mypy, the detection-logic property-coverage gate (already satisfied by existing coverage on the touched function), and no coverage regression on the classifier file (99%, identical pre-existing gap in two unrelated functions, confirmed unchanged against the pre-round-38 baseline). Refs #1375.
… bypass A fresh, independent adversarial review of this PR's current head (round 39), stress-testing rounds 30-38's own scope-isolation reassignment-clearing story, found a third instance of the same shlex-quote-information-loss class already disclosed as issue #1404 (nested double-quote state) and issue #1412 (redirect-operator-shaped filenames). _raw_segments_with_boundaries (the (...)-subshell depth tracker underlying _names_reassigned_from_a_static_value/_names_cleared_by_a_ later_static_reassignment, and by extension _rule_gh_api_write/B1a/B1b) recognizes a real subshell open/close purely by a token's TEXT -- tokenize()'s own shlex dequotes every token first, so a QUOTED literal "(" or ")" argument tokenizes to the identical bare string as a genuine, unquoted operator, with no way to recover which one the source actually was. Two distinct manifestations confirmed live: a quoted close paren inside a genuinely enclosing subshell prematurely decrements tracked depth, wrongly letting a still-isolated reassignment clear an earlier poisoning -- real bash genuinely keeps the reassignment isolated, but classify() allows the command outright; and a quoted open paren with no matching close inflates depth for the rest of the command with nothing to ever balance it, wrongly denying an ordinary, harmless, genuinely top-level clearing reassignment that follows. The first is a genuine security bypass; the second is a false positive -- both confirmed via classify() against the live module source and via real bash execution with stand-in uv/gh binaries on PATH, and reproducing identically for the gh api write-detector counterpart. More severe in reach than the two already-disclosed residuals, since the mechanism it defeats is the one every round-30-38 finding exists to protect. Deliberately not attempted here, for the identical reason issue #1412 already gives: a narrow patch confined to this one function risks reintroducing a worse, far more common false-positive class, and a genuinely sound fix needs tokenize() itself to preserve per-token quote/escape provenance -- the same tokenizer-level architectural change issues #1404/#1412 already require, ideally landed once for all three rather than three independently-drifting patches. Tracked as its own dedicated issue, matching this module's own established convention for the prior two instances of this class: #1502 Disclosed in the module's own header docstring and pinned as tested, current-behavior regressions: the bypass direction in KNOWN_BYPASS_COMMANDS (quoted-paren-inside-a-subshell-clears-a- poisoning-bypass, plus the gh-api-write counterpart), and the false-positive direction as a disclosed over-denial residual test, mirroring the existing arithmetic-double-paren-content precedent. Refs #1375.
A fresh, independent adversarial review of this PR's current head (round 40), branching away from the rounds 19-39 reassignment-scope- isolation story into checkout/restore path resolution itself, found a fourth class of checkout/restore protection gap: `_git_checkout_paths` only special-cases `-b`/`-B`/`--orphan` (`_CHECKOUT_BRANCH_CREATION_ FLAGS`) as genuinely ref-vs-path-ambiguous, folding a single trailing positional after one of them into the bare-SOMENAME Non-goal. But `--ours`/`--theirs`/`-2`/`-3` (git's own conflict-resolution side flags) do not share that ambiguity -- real git flatly refuses to combine them with branch switching (live-verified against real git 2.43.0: `git checkout --ours otherbranch`, with `otherbranch` a real ref, reports `fatal: '--ours/--theirs' cannot be used with switching branches`) -- so a single remaining positional after one of them is unambiguously a path, never a ref. The pre-fix code fell through to the Non-goal anyway, so `git checkout --ours realfile.py` (and `--theirs`/`-2`/`-3`) resolved to an empty `checkout_restore_paths`, skipping the wrapper's own live dirty-file check entirely -- live- verified that each of the four flags genuinely discards dirty tracked- file content in real git while classify() reported nothing to check. Also live-verified the negative control: `--conflict=<style>` does NOT share this property -- `git checkout --conflict=merge otherbranch` still genuinely switches branches with a real ref -- so it correctly stays in the Non-goal, unchanged by this fix. Adds `_CHECKOUT_CONFLICT_SIDE_FLAGS` and resolves the single positional as a path when one of these flags is present, threading it through `_resolve_path_tokens` the same as every other resolved checkout path. Regression-tested at every layer: `@given`-parametrized unit tests over all four flags plus the `--conflict=` negative control directly against `_git_checkout_paths`, `classify()`-level end-to-end tests confirming `checkout_restore_paths` is populated (and the negative control stays empty), and a real-scratch-repo wrapper-level pin running the shipped `hooks/check-bash-safety.sh` against a genuinely dirty tracked file, confirming denial for all four flags and continued allowal for `--conflict=`. Refs #1375.
…redoc blindness A fresh, independent adversarial review of this PR's current head (round 41, the operator's own designated FINAL review round for this PR), deliberately auditing areas outside the heavily-reviewed scope-isolation and checkout-path-resolution stories, found one genuine, previously- undisclosed security bypass and one genuine, previously-undisclosed false-positive class. ## Fix: branch-creation-flag and pathspec-from-file scans ignored -- `_git_checkout_paths`'s `-b`/`-B`/`--orphan` and `--pathspec-from-file`/ `--pathspec-file-nul` checks scanned the ENTIRE `tokens_after` list -- including every token after a literal `--` -- before the `--` boundary was ever honored. Real git guarantees every token after `--` is a pathspec, never a flag, but the pre-fix code did not respect that: a tracked file literally named `-b` (or `-B`/`--orphan`/`--pathspec-from- file`), referenced after `--` (`git checkout -- -b`, an ordinary, unambiguous path reference), made the branch-creation-flag check fire on that positional's own name, folding the WHOLE invocation into the bare-SOMENAME Non-goal and silently skipping the wrapper's own live dirty-file check for `-b` and every other path listed alongside it. Live-verified end to end through the actual shipped `hooks/check-bash-safety.sh` wrapper (not just `classify()` in isolation): with a tracked file literally named `-b` genuinely dirty, no branch-creation flag actually present, the wrapper allowed `git checkout -- -b` outright (exit 0, no live check performed), and running it for real silently discarded the dirty content. Confirmed fixed: the wrapper now correctly denies (exit 2), naming `-b` as the at-risk path. The `--pathspec-from-file` counterpart shared the identical boundary flaw but denies rather than allows (a file positionally named `--pathspec-from-file` after `--` triggered an over-denial, the safe direction, not a bypass) -- fixed in the same change for correctness. Fixed by restricting both checks to the tokens strictly before the first `--`, matching every other sub-case's own established treatment of that boundary. Regression-tested at every layer: a `@given`-parametrized unit test over all four branch-creation-flag shapes plus a companion multi- path test, a `classify()`-level end-to-end test, and a real-scratch-repo wrapper-level pin running the shipped script against a genuinely dirty tracked file literally named `-b`. ## Disclosure: heredoc bodies tokenized as live command text The same review also found that `tokenize()` has no here-document (`<<DELIM`) or here-string (`<<<`) awareness at all -- a heredoc body's own text is tokenized as if it were live command source, so a denied phrase sitting in pure heredoc DATA triggers a denial bash never actually executes. Live-verified: `cat <<EOF\npip install foo\nEOF` denies naming the pip+install verb sequence though real bash only prints the text; a heredoc merely describing a checkout command in prose populates this PR's own new `checkout_restore_paths` surface too. A DIFFERENT mechanism from the already-disclosed #1404/#1412/#1502 shlex quote-information-loss class (a missing lexical construct, not quote-provenance loss on an otherwise-correctly-segmented stream), and safe-direction only (over-denial, never a bypass). Deliberately NOT attempted as a fix here, matching this module's own established convention for a broad, cross-rule tokenizer limitation whose edge cases (an unterminated heredoc, multiple heredocs on one line, `<<-`'s own tab-stripping, quoted-vs-unquoted `DELIM`) deserve their own dedicated verification pass. Tracked as its own dedicated issue, #1520, disclosed in the module's own header docstring and pinned as a tested, current-behavior disclosed-residual regression at every layer. Refs #1375.
Summary
Adds a new
checkout_restore_pathsdetection surface tohooks/gitapex_check_bash_safety.py'sclassify()and a new livegit diffwrapper step tohooks/check-bash-safety.sh, so agit checkout/git restoreinvocation that would silently discard uncommitted work is denied instead of run unchecked.Dedup: this PR implements issue #1375, which already recorded its own dedup search (
repo:tvna/gitapex git checkout discard uncommitted guard/repo:tvna/gitapex checkout restore PreToolUse deny, 4 unrelated hits, no duplicate proposing this mechanism) before this PR existed; no additional PR-level dedup search performed.Additional hardening found during Step 8 independent review (issue #1375)
This PR's own iterative Step 8 independent-review loop (
drafting-a-pr-to-mergeskill) found and fixed substantially more than the V1 feature described above
while reviewing this diff -- every fix below is committed to this same branch,
each with its own regression tests and detailed docstring citing the live
reproduction. This section summarizes that story for reviewers; see the
per-commit messages and the cited functions' own docstrings in
hooks/gitapex_check_bash_safety.pyfor full detail on any individual round.Early rounds: checkout/restore-specific hardening
Closed a series of narrower bypasses and false positives specific to the new
checkout_restore_pathsdetection surface itself: line-continuation handling,-b/-B/--orphan's own value wrongly treated as a path,--pathspec-from-fileparity with
restore, a whole-moduleshlexnested-quote bypass (disclosed,not fully closed -- see Residuals below), redirect-clause exclusion and
strict-vs-lenient redirect-fd handling,
cd/pushd/popdrecognized ascwd-relocating (including a dynamic form), and outer-scope threading into
$(...)command-substitution recursion.The main story: an order-blind / scope-blind reassignment-collapse bug class
A sustained series of independent-review rounds found progressively different
shapes of the same underlying gap: this classifier's static token-scan
resolves a bash variable's value using a heuristic that can ignore bash's real
sequential execution order or its real scoping rules, trusting a stale, wrong,
or scope-inappropriate value. This affected not just checkout/restore but two
other pre-existing consumers sharing the same resolution machinery:
_rule_gh_api_write(thegh apiwrite-method/field detector) and the B1a/B1brules (dynamically-constructed watched-tool/verb detection).
earlier read stayed trusted at its stale value -- closed via a "sticky bias"
mechanism, then widened to
cd/pushd/popdtokens, fused/history-widenedindirect references, and (critically) to the two gh-api-write/B1a-B1b
consumers, which originally had no such protection at all.
NAME+=value(compound append),read/readarray/mapfile/printf -v, andNAME[i]=value(array-elementassignment) were each, in turn, found completely invisible to the
assignment-tracking regexes -- defeating checkout/restore and, separately,
the gh-api-write/B1a-B1b consumers.
one specific known-unresolvable case (see
graphql-mutation-keyword- variable-concatenationbelow) was first too broad (excluding an entireclass of genuine reassignment from protection) and, once narrowed, itself
needed a "clear on a later static reassignment" refinement so a name
restored to a trustworthy value did not stay falsely poisoned forever.
${!NAME}indirect-reference scan (sufficient for one code path) was reused for a second path
where it was NOT sufficient, defeating detection through one extra layer of
indirection.
found genuine security bypasses (not merely over-denials) in how the
"clear on a later static reassignment" logic accounted for bash's own
scoping rules -- a fake "the value is safe now" reassignment written inside
a
(...)subshell, a|pipeline stage, a backgrounded (cmd &) job, alocal/declare/typesetfunction-scoped declaration, or a<(...)/>(...)process substitution can never actually reach the parent shell'sown copy of the name in real bash -- but the pre-fix code trusted each of
these exactly like an ordinary top-level reassignment, letting an earlier,
genuine poisoning be wiped out. Each was independently reproduced live via
classify()and real bash execution against stand-inuv/ghbinaries before being closed, generalizing into a single
_segment_tokens_with_scope_isolationmechanism built on_raw_segments_with_boundaries.A companion fix (
((EXPR))arithmetic-command syntax vs. a deliberately-spaced, genuinely double-nested
( (cmd) )subshell) was investigated and its"obvious" resolution deliberately rejected after live verification proved it
would itself reopen a bypass -- see Residuals below.
Round 40: a fourth checkout/restore protection gap
Round 40 deliberately branched away from the scope-isolation story into
checkout/restore path resolution itself once that area showed signs of
exhaustion, and found a fourth class of protection gap:
_git_checkout_pathstreated
--ours/--theirs/-2/-3(git's own conflict-resolution sideflags) the same as the genuinely ref-ambiguous
-b/-B/--orphan, folding asingle trailing positional after any of them into the bare-SOMENAME Non-goal
and never surfacing it to the wrapper's live dirty-file check. But real git
flatly refuses to combine a conflict side flag with branch switching
(live-verified against real git 2.43.0), so that positional is unambiguously a
path --
git checkout --ours realfile.py(and--theirs/-2/-3) silentlydiscarded a genuinely dirty tracked file while
classify()reported nothingto check. Fixed by recognizing these four flags and resolving the remaining
positional as a path; the
--conflict=<style>flag was independentlylive-verified NOT to share this property (a real ref after it still switches
branches) and correctly stays in the Non-goal, unchanged.
Round 41 (final review): a boundary bug and a disclosed heredoc-blindness residual
Round 41 was designated this PR's own FINAL independent-review round (an
explicit operator decision, made after 40 rounds -- see "Independent review
verdict" below for the full disclosure of what that means for this round's
own fix). Deliberately auditing areas outside the heavily-reviewed
scope-isolation and checkout-path-resolution stories, it found one genuine
security bypass and one genuine, previously-undisclosed false-positive class.
Fixed:
_git_checkout_paths's-b/-B/--orphanand--pathspec-from-file/--pathspec-file-nulchecks scanned the ENTIRE tokenlist, including every token after a literal
--, before the--boundarywas ever honored. Real git guarantees every token after
--is a pathspec,never a flag -- but a tracked file literally named
-b(or-B/--orphan/--pathspec-from-file), referenced after--(git checkout -- -b, anordinary, unambiguous path reference), folded the WHOLE invocation into the
bare-SOMENAME Non-goal, silently skipping the wrapper's own live dirty-file
check. Live-verified end to end through the actual shipped wrapper: with a
tracked file literally named
-bgenuinely dirty, the wrapper allowedgit checkout -- -boutright, and running it for real silently discardedthe dirty content. Fixed by restricting both checks to the tokens strictly
before the first
--.Disclosed, not fixed:
tokenize()has no here-document (<<DELIM)or here-string (
<<<) awareness at all -- a heredoc body's own text istokenized as if it were live command source, so a denied phrase sitting in
pure heredoc DATA triggers a denial bash never actually executes (safe
direction, never a bypass). A DIFFERENT mechanism from the #1404/#1412/#1502
shlex quote-information-loss class (a missing lexical construct, not
quote-provenance loss) -- tracked as its own dedicated issue,
#1520, matching this module's own
established disclosure convention.
Disclosed, accepted residuals (not fixed, deliberately)
((EXPR))arithmetic vs. double-subshell ambiguity: this classifier'sown tokenizer discards the presence/absence of a space between adjacent
parens that real bash's lexer uses to disambiguate
((VERB=1))(arithmetic,runs in the current shell) from
( (VERB=1) )(a genuine double-nestedsubshell). Treating the ambiguous pair as depth-neutral was proven live to
reopen a bypass (a double-subshell can hold an arbitrary, fully-isolated
string value), so
((...))-shaped content stays conservatively treated asisolated -- an accepted over-denial on genuinely harmless arithmetic usage,
not a bypass.
graphql-mutation-keyword-variable-concatenation: agh api graphqlmutation built from purely-dynamic variable concatenation with no earlier
static value is genuinely unresolvable by this classifier; the existing
_substitute_var_refs_candidates-based resolution already treats it as "noevidence of a write" rather than poisoning it, to avoid a documented
false-positive regression. Tracked in
KNOWN_BYPASS_COMMANDS.printf -v$NAMEfused form: the separate-tokenprintf -v NAMEform isrecognized; the fused
printf -vNAME(no space) form is not.$(...)/array-literal spans: a poisonedname's status does not thread into a nested recursive
$(...)/array-literalspan the same way it does at the top level.
shlexnested-quote bypass: disclosed early in this PR'sown review (see
docs(hooks): disclose a critical, whole-module shlex nested-quote bypass); a pre-existing limitation of the underlyingtokenizer, out of this PR's own scope to fix.
same shlex quote-information-loss class as the item above, found live
during round 39 while stress-testing rounds 30-38's own scope-isolation
story --
_raw_segments_with_boundariesrecognizes a real subshellopen/close purely by a token's TEXT, so a QUOTED
"("/")"argumenttokenizes identically to a genuine, unquoted operator. Two shapes
confirmed live: a quoted
)inside a real subshell prematurelydecrements tracked depth (a bypass, letting an isolated reassignment
wrongly clear a poisoning), and a quoted
(with no matching closeinflates depth for the rest of the command (a false positive, wrongly
denying an ordinary top-level clearing reassignment). Deliberately NOT
attempted here, for the identical reason issue CRITICAL: a quoted redirect-operator-shaped filename defeats checkout/restore path extraction #1412 already gives (a
narrow patch risks reintroducing a worse false-positive class; a sound
fix needs
tokenize()itself to preserve per-token quote/escapeprovenance). Tracked as its own dedicated issue,
CRITICAL: quoted parenthesis characters defeat subshell-depth tracking in the scope-isolation reassignment-clearing mechanism #1502.
41 (this PR's own final review round) -- see "Round 41" above. Tracked as
its own dedicated issue, hooks/gitapex_check_bash_safety.py: here-document bodies are tokenized as live command text, causing false-positive denials #1520.
tracked separately, out of this PR's scope.
Independent review verdict
Step 8's independent-review loop ran 41 rounds against this PR (issue
#1375), each dispatching a fresh adversarial reviewer against the current
diff informed of every prior round's findings so it would not re-report
them. Rounds 30-38 formed an unusually dense run in the scope-isolation
area (8 of 9 rounds found genuine defects); round 39 found a third instance
of the disclosed shlex-quote-information-loss class (issue #1502) rather
than a fixable defect; round 40 found and fixed a fourth checkout/restore
protection gap (conflict-side flags). By explicit operator decision, made
after this sustained run of rounds, round 41 was designated this PR's
own final independent-review round rather than continuing the loop
indefinitely: it found and fixed one more genuine security bypass (the
--boundary bug, see "Round 41" above) and disclosed one moregenuine, safe-direction false-positive class (heredoc-body-blindness,
issue #1520) -- both independently re-verified against
classify()andreal bash/git by this session directly, not merely trusted from the
reviewing subagent's own report.
Disclosed scope limitation of this verdict: round 41's own fix (the
--boundary fix above) was regression-tested at every layer this PR'sown established discipline requires (a
@given-parametrized unit test,a
classify()-level end-to-end test, and a real-scratch-repo wrapper-level pin against a genuinely dirty tracked file), and the full existing
suite (1026 tests) stays green with no regression -- but, because round 41
was designated final, that fix itself was NOT put through a FURTHER fresh
adversarial review round the way every earlier round's own fix was before
this PR reached CLEAN. This is a deliberate, disclosed, operator-approved
scope boundary on this verdict, not a silent gap: current test count for
the classifier's own suites is 1026 passed
(
hooks/test_gitapex_check_bash_safety.py+tests/test_gitapex_check_bash_safety_properties.py), 99% coverage onhooks/gitapex_check_bash_safety.pywith an unchanged, pre-existing2-function gap; ruff/mypy and the detection-logic property-coverage gate
all green as of the verified commit above.
Facts
hooks/gitapex_check_bash_safety.pyhad zero handling forcheckout/restorebefore this change (grepped the full file case-insensitively; confirmed again on this diff's base).Verdictwas a 3-fieldNamedTuple(deny, reason, is_git_push); this PR adds a 4th field,checkout_restore_paths: tuple[str, ...] = (), defaulted so every pre-existingVerdict(...)call site needed no change.git diff --quiet HEAD -- PATHexits 1 when dirty and 0 for both a nonexistent path and an unresolved literal string (the fail-open shape this design avoids by denying an unresolvable dynamic path token in the classifier itself);git checkout .silently discards an uncommitted change;git check-ref-format --branch ./--branch ..both fail as invalid branch names;git checkout no-such-ref no-such-file(two unresolvable positionals, no--) reports a pathspec error for both._is_git_push_segmentlowercases every literal token before comparison (confirmed athooks/gitapex_check_bash_safety.py:2407pre-diff) -- reusing that shape verbatim forgit restore's flag walk would collapse-S/-s, so the new restore flag walk is deliberately case-sensitive instead._rule_command_substitution_content/_rule_array_literal_contentalready threadis_git_pushoutward through every recursive span (issue fix(hooks): close pipe/variable-indirection bypass class in check-bash-safety.sh deny logic (Stage 1) #1326's own fifteenth/nineteenth-round fixes); this PR widens both to also threadcheckout_restore_pathsthe same way, confirmed with a dedicated regression test for each (x=$(git checkout -- f.py),A=(git checkout -- f.py); "${A[@]}").${paths[@]}-shaped array-subscript path was resolved to its own unexpanded literal text instead of being recognized as unresolved, and afor f in $(...); do git checkout -- "$f"; done-shaped loop was invisible to detection because the subcommand scan was anchored toseg[0].Assumptions
${paths[@]}bug was found by directly exercisingclassify()against the issue's own example commands during implementation, not inferred from the design doc alone -- the design doc had already predicted the underlying_VAR_REF_FULL_RElimitation but the specific silent-passthrough failure mode was confirmed live in this session.for-loop detection gap was found the same way:_find_git_checkout_restoreoriginally anchored itsgitscan toseg[0], which a literaldotoken (bash'sfor/do/doneare not shell control operators, sosegment_tokensnever splits on them) defeats. Fixed by scanning for a literalgittoken at any position in the segment, mirroring_is_git_push_segment's own existing scan.git commit -m "$(cat <<'EOF' ... EOF)"(used for this branch's own first commit message) recursively re-tokenizes the heredoc body as if it were executable shell source (a pre-existing, disclosed limitation of_rule_command_substitution_content-- it does not understand heredoc syntax at all -- now formally disclosed and tracked as issue hooks/gitapex_check_bash_safety.py: here-document bodies are tokenized as live command text, causing false-positive denials #1520, see "Round 41" above) -- a commit message describing this feature in prose can trip the new gate on its own words. Worked around here by usinggit commit -Ffor the second commit instead of fixing the underlying heredoc-blindness, which is out of this issue's own scope.Acceptance Criteria Map
Restated from issue #1375's own ACM, row by row, with each Proof method's actual result appended.
.cwdfrom the payload for the live checkhooks/test_gitapex_check_bash_safety.py::test_checkout_denied_when_target_has_uncommitted_changes/::test_checkout_denied_from_a_subdirectory_when_target_has_uncommitted_changes, run end-to-end through the real shipped script against a scratch repo -- PASStest_classify_denies_a_loop_fed_dynamic_checkout_path(the issue's ownfor f in $(...); do git checkout -- "$f"; doneexample) andtest_resolve_path_tokens_denies_an_unresolvable_dynamic_token-- PASS; this exact case was a real bug found and fixed during implementation (_find_git_checkout_restorewas originallyseg[0]-anchored)git checkout .and multi-positionalgit checkout(no--) are coveredtest_git_checkout_paths_treats_a_single_dot_or_dotdot_positional_as_a_path,test_git_checkout_paths_extracts_two_or_more_positionals_with_no_double_dash,test_checkout_dot_denied_when_a_tracked_file_is_dirty(end-to-end) -- PASSgit restore --stagedis never denied;-s PATHand--staged --worktree PATHare always denied when dirtytest_git_restore_paths_empty_when_staged_without_worktree,test_git_restore_paths_checked_when_staged_and_worktree_both_present,test_git_restore_paths_checked_for_source_short_flag_not_conflated_with_staged,test_restore_staged_allowed_even_when_worktree_is_dirty(end-to-end) -- PASSgit restore -h; a materially different git version's flag set is not re-verified here)--pathspec-from-fileand any unrecognized flag in a restore segment deny rather than under-extracting pathstest_git_restore_paths_denies_pathspec_from_file,test_git_restore_paths_denies_an_unrecognized_flag-- PASS$(...)-wrapped and array-literal-wrapped checkout/restore threadcheckout_restore_pathsoutwardis_git_push's own OR-ingtest_classify_threads_checkout_restore_paths_through_command_substitution,test_classify_threads_checkout_restore_paths_through_array_literal-- PASSgit stashgit checkout -m -- PATHas an alternativetest_checkout_denied_in_a_real_merge_conflict_names_the_conflict_remedy-- a REAL merge conflict built in a scratch repo, not simulated -- PASSHEADdoes not resolve (viagit rev-parse --verify -q HEAD, not error-message parsing)test_checkout_allowed_on_unborn_head_with_no_conflicting_content/test_checkout_denied_on_unborn_head_when_staged-- PASS@givenproperty tests intests/test_gitapex_check_bash_safety_properties.py.github/scripts/gitapex_gate_detection_logic_property_coverage.pyrun directly against this diff -- PASS:OK: 1 in-scope file(s) graded, 0 inline waiver(s) honoured.targetentries + refreshedruletext in.gitapex/ssot.json'sbash-cli-write-and-install-guardtests/test_gitapex_scan_ssot_schema.py(91 tests) -- PASS;gitapex_scan_ssot_schema.pyrun directly -- PASS:No ssot.json drift found._rule_*behavior is unchanged_rule_*function modifiedhooks/test_gitapex_check_bash_safety.py+tests/test_gitapex_check_bash_safety_properties.py+tests/test_gitapex_check_bash_safety_differential.py(real-bash-oracle fuzzer) suites -- PASS, 0 regressionsRisk / blast radius
This hook already fails closed for jq/python3-missing/malformed-payload cases; this PR adds new deny paths to the same fail-closed family, all additive (no existing
_rule_*function's behavior is modified -- confirmed by the full pre-existing suite staying green). Blast radius is everyBashtool call whose command containsgit checkout/git restorein this repository's own Claude Code sessions (and any other environment that installs this same hook). A false deny blocks a legitimate checkout/restore; the remedy is always named in the deny message (stash, resolve-and-add, orgit checkout -m --).Rollback
Revert this PR's commits (
git revert). No schema/data migration;.gitapex/ssot.json's newtargetrows andruletext revert cleanly along with the code.Verification
Manual end-to-end validation against real scratch git repos (not just pytest) during development: the near-miss's own exact command from both the repo root and a subdirectory; a real merge conflict; an unborn-HEAD fresh repo in both a clean and a staged-dirty state -- all matched the ACM's own expected result before being converted into permanent regression tests.
Checklist
.gitapex/ssot.json's registry entry)skills/*/SKILL.md, adocs/superpowers/specs/*.mddesign doc, a security-relevant skill, or a deterministic checker script (skills/*/scripts/*.py,evals/scripts/*.py,.github/scripts/*.py), a## Skill audit evidencesection discloses the required verdicts/waivers (see.github/scripts/gitapex_gate_skill_audit_disclosure.py) -- noskills/*/scripts/*.py/evals/scripts/*.py/.github/scripts/*.pytouched, but this diff DOES register as a changed deterministic gate byhooks/check-*.shnaming and.gitapex/ssot.jsonregistration, so## Skill audit evidencebelow is filled in for that reasonevals/*/split.md, that entry discloses a Transfer check line -- N/A, noevals/*/split.mdtouchedskills/*/SKILL.md's Stop-boundary bullets or named dispatch branches,evals//tasks/*.yamlgained at least as many new fixtures -- N/A, noSKILL.mdtouchedSkill audit evidence
git diff --name-status main...HEAD -- .gitapex/ssot.json hooks/check-bash-safety.sh hooks/gitapex_check_bash_safety.py | uv run --frozen python3 .github/scripts/gitapex_detect_changed_gate_scripts.pynames all three of.gitapex/ssot.json,hooks/check-bash-safety.sh,hooks/gitapex_check_bash_safety.pyas changed deterministic-gate paths requiring disclosure (registration in.gitapex/ssot.jsonand thehooks/check-*.shnaming rule).checker-script-adversarial-reviewdoes not apply (none ofskills/*/scripts/*.py,evals/scripts/*.py,.github/scripts/*.pyare touched).skills/evaluating-deterministic-gate-quality/references/dimensions.md's deterministic-shape checks (1-6): the new deny path uses the exact same dual-channeldeny()(JSONhookSpecificOutput.permissionDecision+ non-zero exit) as every existing deny in this hook, not a silently-downgraded warn (dimension 1/2); the livegit diff --quietcheck re-validates the actual, specific condition being gated (real working-tree dirtiness), not a proxy (dimension 3); a bundled test suite ships beside the new logic in both the unit-level property file and an end-to-end scratch-repo suite (dimension 4); every path fed to the shell wrapper is base64-decoded, never interpolated raw (dimension 5); the wrapper's livegitcalls carry no unbounded loop or missing timeout beyond what the existing git-push step already accepts (dimension 6, unchanged shape).${paths[@]}unexpanded-literal passthrough and thefor-loopseg[0]-anchoring gap) were found by deliberately trying to defeat the new detection logic with the issue's own adversarially-reviewed example commands, before this PR was opened, and both are now permanent regression tests (test_resolve_path_tokens_denies_an_array_subscript_token,test_classify_denies_an_array_subscript_fed_checkout_path,test_classify_denies_a_loop_fed_dynamic_checkout_path,test_find_git_checkout_restore_finds_git_at_any_segment_position).Related Issue
Refs #1128. Closes #1375.