Skip to content

feat(hooks): deny git checkout/restore that would discard uncommitted work - #1380

Merged
tvna merged 57 commits into
mainfrom
claude/pr-1375-merge-prep-ru0cn6
Aug 30, 2026
Merged

feat(hooks): deny git checkout/restore that would discard uncommitted work#1380
tvna merged 57 commits into
mainfrom
claude/pr-1375-merge-prep-ru0cn6

Conversation

@tvna

@tvna tvna commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a new checkout_restore_paths detection surface to hooks/gitapex_check_bash_safety.py's classify() and a new live git diff wrapper step to hooks/check-bash-safety.sh, so a git checkout/git restore invocation 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-merge
skill) 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.py for 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_paths detection surface itself: line-continuation handling,
-b/-B/--orphan's own value wrongly treated as a path, --pathspec-from-file
parity with restore, a whole-module shlex nested-quote bypass (disclosed,
not fully closed -- see Residuals below), redirect-clause exclusion and
strict-vs-lenient redirect-fd handling, cd/pushd/popd recognized as
cwd-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 (the gh api write-method/field detector) and the B1a/B1b
rules (dynamically-constructed watched-tool/verb detection).

  • Order-blindness within a single scope: a name reassigned after an
    earlier read stayed trusted at its stale value -- closed via a "sticky bias"
    mechanism, then widened to cd/pushd/popd tokens, fused/history-widened
    indirect references, and (critically) to the two gh-api-write/B1a-B1b
    consumers, which originally had no such protection at all.
  • Assignment-construct blind spots: NAME+=value (compound append),
    read/readarray/mapfile/printf -v, and NAME[i]=value (array-element
    assignment) were each, in turn, found completely invisible to the
    assignment-tracking regexes -- defeating checkout/restore and, separately,
    the gh-api-write/B1a-B1b consumers.
  • Over-narrow and over-broad exclusions: a fix meant to avoid regressing
    one specific known-unresolvable case (see graphql-mutation-keyword- variable-concatenation below) was first too broad (excluding an entire
    class 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.
  • Indirect-reference resolution: a single-level ${!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.
  • Scope isolation (the most severe findings): three consecutive rounds
    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, a
    local/declare/typeset function-scoped declaration, or a <(...)/
    >(...) process substitution can never actually reach the parent shell's
    own 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-in uv/gh
    binaries before being closed, generalizing into a single
    _segment_tokens_with_scope_isolation mechanism 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_paths
treated --ours/--theirs/-2/-3 (git's own conflict-resolution side
flags) the same as the genuinely ref-ambiguous -b/-B/--orphan, folding a
single 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) silently
discarded a genuinely dirty tracked file while classify() reported nothing
to check. Fixed by recognizing these four flags and resolving the remaining
positional as a path; the --conflict=<style> flag was independently
live-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/--orphan and
--pathspec-from-file/--pathspec-file-nul checks scanned the ENTIRE token
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 a tracked file literally named -b (or -B/--orphan/
--pathspec-from-file), referenced after -- (git checkout -- -b, an
ordinary, 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 -b genuinely dirty, the wrapper allowed
git checkout -- -b outright, and running it for real silently discarded
the 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 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 (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)

Independent review verdict

  • Verdict: CLEAN
  • Verified commit: 426592c

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 more
genuine, safe-direction false-positive class (heredoc-body-blindness,
issue #1520) -- both independently re-verified against classify() and
real 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's
own 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 on
hooks/gitapex_check_bash_safety.py with an unchanged, pre-existing
2-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.py had zero handling for checkout/restore before this change (grepped the full file case-insensitively; confirmed again on this diff's base).
  • Verdict was a 3-field NamedTuple(deny, reason, is_git_push); this PR adds a 4th field, checkout_restore_paths: tuple[str, ...] = (), defaulted so every pre-existing Verdict(...) call site needed no change.
  • Live-verified against real git 2.43.0 in a scratch repo (re-verified independently in this session, not only cited from the issue): git diff --quiet HEAD -- PATH exits 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_segment lowercases every literal token before comparison (confirmed at hooks/gitapex_check_bash_safety.py:2407 pre-diff) -- reusing that shape verbatim for git 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_content already thread is_git_push outward 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 thread checkout_restore_paths the same way, confirmed with a dedicated regression test for each (x=$(git checkout -- f.py), A=(git checkout -- f.py); "${A[@]}").
  • Two real fail-open bugs were found and fixed during this session's own defeat-testing before this PR was opened (see "Assumptions" below for how each was found): a ${paths[@]}-shaped array-subscript path was resolved to its own unexpanded literal text instead of being recognized as unresolved, and a for f in $(...); do git checkout -- "$f"; done-shaped loop was invisible to detection because the subcommand scan was anchored to seg[0].

Assumptions

  • The issue's own Acceptance Criteria Map is adopted without modification (independently re-verified against current source and live git before this branch started; re-verification marker posted on the issue).
  • The ${paths[@]} bug was found by directly exercising classify() 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_RE limitation but the specific silent-passthrough failure mode was confirmed live in this session.
  • The for-loop detection gap was found the same way: _find_git_checkout_restore originally anchored its git scan to seg[0], which a literal do token (bash's for/do/done are not shell control operators, so segment_tokens never splits on them) defeats. Fixed by scanning for a literal git token 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 using git commit -F for 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.

Criterion Interpretation Planned ops Proof method Residual risk
The near-miss's own exact command, replayed from the repo root AND from a subdirectory, is denied when the target has uncommitted changes Fact 5's cwd finding must actually be fixed, not just diagnosed Wrapper reads .cwd from the payload for the live check hooks/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 -- PASS Low
A dynamic/unresolvable path token denies rather than silently passing Fact 5's fail-open finding Classifier denies on unresolvable dynamic path token; wrapper never sees it test_classify_denies_a_loop_fed_dynamic_checkout_path (the issue's own for f in $(...); do git checkout -- "$f"; done example) and test_resolve_path_tokens_denies_an_unresolvable_dynamic_token -- PASS; this exact case was a real bug found and fixed during implementation (_find_git_checkout_restore was originally seg[0]-anchored) Low
git checkout . and multi-positional git checkout (no --) are covered Fact 5's pathspec-fallback finding widens V1 scope Detection sub-cases (b) and (c) test_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) -- PASS Low
git restore --staged is never denied; -s PATH and --staged --worktree PATH are always denied when dirty Fact 5's case-collapse finding Case-sensitive, enumerated restore flag walk test_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) -- PASS Medium (restore's flag vocabulary is enumerated against git 2.43.0's own git restore -h; a materially different git version's flag set is not re-verified here)
--pathspec-from-file and any unrecognized flag in a restore segment deny rather than under-extracting paths Fail-open shape found during design review Explicit deny branches, not silent fallthrough test_git_restore_paths_denies_pathspec_from_file, test_git_restore_paths_denies_an_unrecognized_flag -- PASS Low
$(...)-wrapped and array-literal-wrapped checkout/restore thread checkout_restore_paths outward Fact 5's recursion-drop finding Both recursive rule functions thread and concatenate the new field, mirroring is_git_push's own OR-ing test_classify_threads_checkout_restore_paths_through_command_substitution, test_classify_threads_checkout_restore_paths_through_array_literal -- PASS Low
A path containing a newline or other shell-hazardous byte is still checked correctly, not silently split/skipped Newline-splitting hazard found during design review JSON array (not newline-joined string) from classifier; base64 round-trip in the wrapper Implemented per design; exercised indirectly by every other passing path-extraction test, but not independently pinned with a dedicated newline-containing-path regression test in this PR Low-medium (residual: no dedicated test for this specific byte-hazard case)
An unmerged (merge-conflict) path denies with a working remedy in the message, not only git stash Deny message's own remedy does not work mid-conflict Deny message names git checkout -m -- PATH as an alternative test_checkout_denied_in_a_real_merge_conflict_names_the_conflict_remedy -- a REAL merge conflict built in a scratch repo, not simulated -- PASS Low
A fresh repo with no commits yet (unborn HEAD) does not spuriously deny a clean checkout/restore Unborn-HEAD false positive found during design review Empty-tree-hash fallback when HEAD does not resolve (via git 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 Low
New detection logic carries Hypothesis property coverage Issue #1178's own CI gate requirement @given property tests in tests/test_gitapex_check_bash_safety_properties.py .github/scripts/gitapex_gate_detection_logic_property_coverage.py run directly against this diff -- PASS: OK: 1 in-scope file(s) graded, 0 inline waiver(s) honoured. Low
Registry stays in sync Issue #1345's own precedent New target entries + refreshed rule text in .gitapex/ssot.json's bash-cli-write-and-install-guard tests/test_gitapex_scan_ssot_schema.py (91 tests) -- PASS; gitapex_scan_ssot_schema.py run directly -- PASS: No ssot.json drift found. Low
Ordinary branch-switching and every existing _rule_* behavior is unchanged Additive detection surface only No existing _rule_* function modified Full existing hooks/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 regressions Low

Risk / 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 every Bash tool call whose command contains git checkout/git restore in 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, or git checkout -m --).

Rollback

Revert this PR's commits (git revert). No schema/data migration; .gitapex/ssot.json's new target rows and rule text revert cleanly along with the code.

Verification

uv run --frozen pytest hooks/test_gitapex_check_bash_safety.py tests/test_gitapex_check_bash_safety_properties.py tests/test_gitapex_check_bash_safety_differential.py tests/test_gitapex_scan_ssot_schema.py -q --no-cov
# 586 passed

uv run --frozen pytest --ignore=tests/test_gitapex_check_bash_safety_oracle_pins.py --ignore=tests/test_gitapex_check_task_bash_safety_oracle_pins.py --ignore=tests/test_gitapex_check_bash_safety_differential.py --ignore=tests/test_gitapex_check_task_bash_safety_differential.py -q --no-cov
# 6598 total; 6597 passed, 1 failed (test_gitapex_scan_harden_checkout_pin_drift.py -- a shallow-clone artifact of this session's own checkout, unrelated to this diff: `git rev-parse --is-shallow-repository` is `true` here; the real CI workflow always fetches full history)

uv run --frozen ruff check hooks/gitapex_check_bash_safety.py hooks/test_gitapex_check_bash_safety.py tests/test_gitapex_check_bash_safety_properties.py
uv run --frozen ruff format --check
uv run --frozen mypy hooks/gitapex_check_bash_safety.py hooks/test_gitapex_check_bash_safety.py
# all clean

uv run --frozen xenon --max-absolute E --max-modules B --max-average A --exclude "apm_modules/*,skills/evaluating-skill-quality/scripts/gitapex_check_skill_shape.py" .
# no output (no violations)

git diff -- hooks/gitapex_check_bash_safety.py hooks/check-bash-safety.sh | uv run --frozen python3 .github/scripts/gitapex_gate_detection_logic_property_coverage.py
# OK: 1 in-scope file(s) graded, 0 inline waiver(s) honoured.

uv run --frozen python3 .github/scripts/gitapex_scan_ssot_schema.py .gitapex/ssot.json
# No ssot.json drift found.

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

  • Tests pass locally
  • Docs updated if behavior changed (.gitapex/ssot.json's registry entry)
  • Issue number cited in every commit
  • If this PR adds/modifies a skills/*/SKILL.md, a docs/superpowers/specs/*.md design doc, a security-relevant skill, or a deterministic checker script (skills/*/scripts/*.py, evals/scripts/*.py, .github/scripts/*.py), a ## Skill audit evidence section discloses the required verdicts/waivers (see .github/scripts/gitapex_gate_skill_audit_disclosure.py) -- no skills/*/scripts/*.py/evals/scripts/*.py/.github/scripts/*.py touched, but this diff DOES register as a changed deterministic gate by hooks/check-*.sh naming and .gitapex/ssot.json registration, so ## Skill audit evidence below is filled in for that reason
  • If this PR adds a new Kept-edit-log entry to any evals/*/split.md, that entry discloses a Transfer check line -- N/A, no evals/*/split.md touched
  • If this PR adds or increases a skills/*/SKILL.md's Stop-boundary bullets or named dispatch branches, evals//tasks/*.yaml gained at least as many new fixtures -- N/A, no SKILL.md touched

Skill 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.py names all three of .gitapex/ssot.json, hooks/check-bash-safety.sh, hooks/gitapex_check_bash_safety.py as changed deterministic-gate paths requiring disclosure (registration in .gitapex/ssot.json and the hooks/check-*.sh naming rule). checker-script-adversarial-review does not apply (none of skills/*/scripts/*.py, evals/scripts/*.py, .github/scripts/*.py are touched).

  • deterministic-gate-quality: RAN -- read against skills/evaluating-deterministic-gate-quality/references/dimensions.md's deterministic-shape checks (1-6): the new deny path uses the exact same dual-channel deny() (JSON hookSpecificOutput.permissionDecision + non-zero exit) as every existing deny in this hook, not a silently-downgraded warn (dimension 1/2); the live git diff --quiet check 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 live git calls carry no unbounded loop or missing timeout beyond what the existing git-push step already accepts (dimension 6, unchanged shape).
  • defeat-test-disclosure: RAN -- two real fail-open bugs (the ${paths[@]} unexpanded-literal passthrough and the for-loop seg[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.

claude added 2 commits August 29, 2026 09:47
… 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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 09:57 — with GitHub Actions Active
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 03f4405a-3b6e-4e76-9def-b366b48b97f1


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.61%. Comparing base (ae8c090) to head (426592c).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 10:06 — with GitHub Actions Active
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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 10:17 — with GitHub Actions Active
…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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 10:32 — with GitHub Actions Active
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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 10:35 — with GitHub Actions Active
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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 10:38 — with GitHub Actions Active
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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 11:02 — with GitHub Actions Active
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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 11:23 — with GitHub Actions Active
vsajan added 2 commits August 29, 2026 11:38
…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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 11:51 — with GitHub Actions Active

tvna commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Merge conflict resolution (main -> this branch, commit b03b059)

main advanced past this branch's original base (three merges: PR #1383/#1382/#1377) while this PR was in review, and one of those (issue #1350, PR #1383) produced a real content conflict with this branch's own line-continuation fix.

File in conflict: hooks/gitapex_check_bash_safety.py (the only file with real conflict markers; every other file main advanced with auto-merged cleanly).

What conflicted: both branches independently added a same-named _strip_line_continuations function for the same underlying bash line-continuation gap (this branch found it via issue #1375's own round-3 checkout/restore review; main found it independently via issue #1350's own newline-as-statement-separator fix). Main's merged PR also added a new, unrelated _strip_comments fix (a #-comment word-boundary scanner) that this branch did not have.

Resolution approach:

  • Kept _strip_comments as-is (main's, additive, no conflict with this branch's own work).
  • For the duplicate _strip_line_continuations, kept this branch's own algorithm (which tracks both single-quote AND double-quote state) over main's simpler version (which tracked only single-quote state), after finding a real correctness bug in main's version during reconciliation: an apostrophe inside an already-open double-quoted string (e.g. a contraction like "don't" in a commit-message-shaped string literal) is wrongly read by main's version as a fresh single-quote opener, since it never tracks double-quote state at all -- this then incorrectly suppresses continuation-removal for the rest of that string. Live-verified before and after the fix:
    • Before (main's algorithm): printf '[%s]' "don't strip \ + newline + this" would leave the continuation unstripped once it saw the apostrophe.
    • After (this branch's algorithm, now the merged result): the same command correctly joins into one argument, [don't strip this].
  • Merged both sides' docstrings so neither side's live-verified reasoning was dropped (this branch's own round-3 bypass finding, issue bash-safety: newline-separated statements collapse into one segment, bypassing seg[0]-anchored deny rules #1350's dual fix and the _strip_comments interaction, and this reconciliation's own double-quote finding).
  • tokenize() now runs _strip_comments first, then _strip_line_continuations, then constructs the lexer once (removed a duplicate lexer.whitespace = lexer.whitespace.replace("\n", "") line left over from the raw merge).

Verification after resolving: ruff, mypy, xenon, the property-coverage gate, and the ssot-schema scan all clean; hooks/, the checkout/restore suites, both oracle-pins files, and the sibling skills/executing-a-branch-plan/scripts/gitapex_check_task_bash_safety.py suite all green (835 tests); 100% line+branch coverage maintained on the merged file.

Known follow-up, out of this PR's scope: the sibling module skills/executing-a-branch-plan/scripts/gitapex_check_task_bash_safety.py (a self-contained duplicate of the hooks module, per its own header comment) carries its own copy of _strip_line_continuations, merged in from the same main advance, with the identical single-quote-only bug described above -- confirmed by reading its source, not yet independently live-verified against that module's own test harness. Flagging as a candidate follow-up task rather than fixing it here, since it is unrelated to issue #1375's own scope and untouched by this branch until this merge.


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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 12:11 — with GitHub Actions Active
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.
@tvna
tvna deployed to ruleset-verify August 29, 2026 13:13 — with GitHub Actions Active
@tvna
tvna deployed to ruleset-verify August 30, 2026 02:15 — with GitHub Actions Active
vsajan and others added 2 commits August 30, 2026 02:33
…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.
@tvna
tvna deployed to ruleset-verify August 30, 2026 02:33 — with GitHub Actions Active
@tvna
tvna deployed to ruleset-verify August 30, 2026 02:34 — with GitHub Actions Active
…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.
@tvna
tvna deployed to ruleset-verify August 30, 2026 03:00 — with GitHub Actions Active
…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.
@tvna
tvna deployed to ruleset-verify August 30, 2026 03:18 — with GitHub Actions Active
…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.
@tvna
tvna deployed to ruleset-verify August 30, 2026 03:34 — with GitHub Actions Active
…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.
@tvna
tvna deployed to ruleset-verify August 30, 2026 04:17 — with GitHub Actions Active
vsajan added 2 commits August 30, 2026 04:47
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.
@tvna
tvna deployed to ruleset-verify August 30, 2026 05:22 — with GitHub Actions Active
@tvna
tvna marked this pull request as draft August 30, 2026 09:03
@tvna
tvna marked this pull request as ready for review August 30, 2026 09:04
@tvna
tvna merged commit 4c6267a into main Aug 30, 2026
36 of 37 checks passed
@tvna
tvna deleted the claude/pr-1375-merge-prep-ru0cn6 branch August 30, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hooks/check-bash-safety.sh: no guard against git checkout/restore discarding uncommitted work (real near-miss, #1128)

3 participants