Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
bc34ff3
feat(hooks): deny git checkout/restore that would discard uncommitted…
claude Aug 29, 2026
32c5e45
test(hooks): add end-to-end wrapper regression tests for checkout/res…
claude Aug 29, 2026
a5f221b
test(hooks): close remaining patch-coverage gaps on checkout/restore …
claude Aug 29, 2026
65f4836
fix(hooks): close a vanishing-decoy bypass and a newline false-positive
claude Aug 29, 2026
ffb6321
fix(hooks): close an empty-default-clause bypass and two restore fals…
claude Aug 29, 2026
f48a7fa
fix(hooks): close the assign-default variant of the same vanishing decoy
claude Aug 29, 2026
c9be5b3
docs(hooks): pin the remaining exotic-parameter-expansion residual
claude Aug 29, 2026
6a7a1dd
fix(hooks): close a live checkout/restore bypass via line continuation
claude Aug 29, 2026
aed21be
fix(hooks): stop treating -b/-B/--orphan's own value as a checkout path
vsajan Aug 29, 2026
86335c3
fix(hooks): deny checkout --pathspec-from-file, matching restore's ow…
vsajan Aug 29, 2026
b03b059
Merge branch 'main' into claude/pr-1375-merge-prep-ru0cn6
vsajan Aug 29, 2026
dfebaa2
fix(hooks): keep a line-continued comment recognized after the merge
vsajan Aug 29, 2026
69c15c9
fix(hooks): recognize a comment inside a $(...) nested in double quotes
vsajan Aug 29, 2026
3c710d5
docs(hooks): disclose a critical, whole-module shlex nested-quote bypass
vsajan Aug 29, 2026
e4138ce
fix(hooks): recognize pushd/popd alongside cd as cwd-relocating
vsajan Aug 29, 2026
7fe95a2
fix(hooks): recognize a dynamic cd/pushd/popd as cwd-relocating too
vsajan Aug 29, 2026
9f6393e
fix(hooks): narrow dynamic-cd detection to actually resolvable cd/pus…
vsajan Aug 29, 2026
f542d88
fix(hooks): fail closed when a resolved cd-relocator candidate is sti…
vsajan Aug 29, 2026
2914165
fix(hooks): check the first surviving segment word, not seg[0] itself
vsajan Aug 29, 2026
d109eba
fix(hooks): recognize a redirect clause and a dynamic git token
vsajan Aug 29, 2026
929f42c
fix(hooks): exclude a redirect clause from extracted checkout/restore…
vsajan Aug 29, 2026
c08dfde
fix(hooks): split strict vs lenient redirect-fd handling by call site
vsajan Aug 29, 2026
656f685
docs(hooks): disclose two round-17 redirect-handling residuals
vsajan Aug 29, 2026
edc3168
fix(hooks): thread outer scope into command-substitution recursion
vsajan Aug 29, 2026
d9e3fd8
fix(hooks): recognize a reassigned-after-use dynamic git token
vsajan Aug 29, 2026
a017034
fix(hooks): apply the reassignment bias fix to cd/pushd/popd too
vsajan Aug 29, 2026
8306e6d
fix(hooks): close two more reassignment-ambiguity bypasses
vsajan Aug 29, 2026
d14f75c
fix(hooks): close the reassignment-ambiguity bypass on two HARD-DENY …
vsajan Aug 29, 2026
556013c
fix(hooks): widen checkout/restore path history to fused references
vsajan Aug 29, 2026
3794c13
Merge branch 'main' into claude/pr-1375-merge-prep-ru0cn6
tvna Aug 29, 2026
16b6fe4
fix(hooks): close two more checkout/restore reassignment-ambiguity bugs
vsajan Aug 29, 2026
a9f9978
fix(hooks): close a compound-append-assignment blind spot in checkout…
vsajan Aug 29, 2026
50f2b64
fix(hooks): close read/readarray/mapfile/printf-v/array-element reass…
vsajan Aug 29, 2026
8749f2b
Merge branch 'main' into claude/pr-1375-merge-prep-ru0cn6
tvna Aug 29, 2026
da4a370
Merge remote-tracking branch 'origin/claude/pr-1375-merge-prep-ru0cn6…
vsajan Aug 29, 2026
0ac32f8
fix(hooks): protect gh-api-write/B1a-B1b against a static-then-dynami…
vsajan Aug 30, 2026
9054c4a
Merge branch 'main' into claude/pr-1375-merge-prep-ru0cn6
tvna Aug 30, 2026
0df566b
Merge remote-tracking branch 'origin/claude/pr-1375-merge-prep-ru0cn6…
vsajan Aug 30, 2026
412ad48
fix(hooks): clear a static-value poisoning once a later static reassi…
vsajan Aug 30, 2026
bf3818e
fix(hooks): resolve indirect ${!NAME} references two levels deep for …
vsajan Aug 30, 2026
0794ec6
fix(hooks): clear gh-api-write/B1a-B1b poisoning after any later stat…
vsajan Aug 30, 2026
86751c9
fix(hooks): never let a subshell-scoped static reassignment clear gh-…
vsajan Aug 30, 2026
82f279e
fix(hooks): generalize scope-isolation to pipe stages, background job…
vsajan Aug 30, 2026
5002c79
fix(hooks): recognize process substitution and declare/typeset as sco…
vsajan Aug 30, 2026
2e56a97
Merge branch 'main' into claude/pr-1375-merge-prep-ru0cn6
tvna Aug 30, 2026
102bb27
Merge remote-tracking branch 'origin/claude/pr-1375-merge-prep-ru0cn6…
vsajan Aug 30, 2026
92da11c
fix(hooks): recognize coproc and $"..."-fused local/declare/typeset a…
vsajan Aug 30, 2026
ccf7ed4
Merge branch 'main' into claude/pr-1375-merge-prep-ru0cn6
tvna Aug 30, 2026
9638b5b
Merge remote-tracking branch 'origin/claude/pr-1375-merge-prep-ru0cn6…
vsajan Aug 30, 2026
ac1e8b3
fix(hooks): recognize piped/backgrounded compound-command groups as s…
vsajan Aug 30, 2026
3583a70
fix(hooks): recognize case/esac as scope-isolating and fix its depth …
vsajan Aug 30, 2026
429a2c0
fix(hooks): stop a case pattern's optional leading paren from inflati…
vsajan Aug 30, 2026
f2bf8c4
fix(hooks): stop a case statement's own subject word from desyncing t…
vsajan Aug 30, 2026
aeff40a
docs(hooks): disclose a critical quoted-paren subshell-depth-tracking…
vsajan Aug 30, 2026
9f6025e
fix(hooks): recognize conflict side flags as unambiguous checkout paths
vsajan Aug 30, 2026
7091ed9
Merge remote-tracking branch 'origin/main' into claude/pr-1375-merge-…
vsajan Aug 30, 2026
426592c
fix(hooks): honor the -- boundary in checkout flag scans; disclose he…
vsajan Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .gitapex/ssot.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"id": "bash-cli-write-and-install-guard",
"kind": "script",
"script": "hooks/check-bash-safety.sh",
"rule": "Delegates classification to hooks/gitapex_check_bash_safety.py, a token-based classifier (Python stdlib shlex, POSIX mode) that matches against bash's own dequoted, operator-segmented token stream -- closing the quote-splitting, ${IFS}/$IFS substitution, and variable/array/positional-parameter indirection bypass classes a prior raw-text regex substring scan was live-confirmed vulnerable to (issue #1326). Denies a Bash call matching a package/plugin-install verb (uv add/uv remove and apm install/apm uninstall stay allowed as declarative, visibly-mutating commands); denies gh issue/gh pr write subcommands and gh api writes (POST/PUT/PATCH/DELETE, a field flag, or 'mutation' in gh api graphql); on git push (including an obfuscated/indirected one that still resolves to git push), runs skills/outward-artifact-preflight/scripts/gitapex_scan_provenance.py against the outgoing commit range and warns (never blocks) if it flags something. Fails closed (denies) rather than allowing the call through when jq or python3 is missing from PATH, the payload or tool_input is not a JSON object, tool_name is present but not a string, tool_input.command is present but not a string, the classifier exits non-zero, or the classifier's own output is not a JSON object with a recognized decision. Disclosed residual (not closed by this classifier): verb-token-splitting that never places the tool/verb name as its own literal token anywhere, e.g. string-slice reconstruction (cmd=uvinstall; eval \"${cmd:0:2} ${cmd:2}\") or array-literal-assignment indirection (A=(uv); V=(install); \"${A[@]}\" \"${V[@]}\") -- see hooks/gitapex_check_bash_safety.py's own module docstring.",
"rule": "Delegates classification to hooks/gitapex_check_bash_safety.py, a token-based classifier (Python stdlib shlex, POSIX mode) that matches against bash's own dequoted, operator-segmented token stream -- closing the quote-splitting, ${IFS}/$IFS substitution, and variable/array/positional-parameter indirection bypass classes a prior raw-text regex substring scan was live-confirmed vulnerable to (issue #1326). Denies a Bash call matching a package/plugin-install verb (uv add/uv remove and apm install/apm uninstall stay allowed as declarative, visibly-mutating commands); denies gh issue/gh pr write subcommands and gh api writes (POST/PUT/PATCH/DELETE, a field flag, or 'mutation' in gh api graphql); on git push (including an obfuscated/indirected one that still resolves to git push), runs skills/outward-artifact-preflight/scripts/gitapex_scan_provenance.py against the outgoing commit range and warns (never blocks) if it flags something. Since issue #1375: the classifier also extracts every path a git checkout/git restore invocation could discard (git checkout -- PATH, git checkout ./.., git checkout with 2+ positionals and no --, or git restore PATH/--staged --worktree PATH, a case-sensitive enumerated restore flag vocabulary), denying outright (no live I/O) when a path token is dynamic and cannot be soundly resolved to a literal, when an otherwise-risky shape yields zero paths (e.g. bare 'git checkout --'), when a restore segment carries --pathspec-from-file/--pathspec-file-nul or an unrecognized flag, or when the classifier cannot soundly determine which working tree is at risk (a -C/--git-dir/--work-tree flag, a GIT_DIR=/GIT_WORK_TREE=/GIT_INDEX_FILE= assignment, or an earlier cd/pushd/popd -- literal, or dynamic and resolvable to one via a reassignment-biased reading -- in the same command); when paths are extracted, hooks/check-bash-safety.sh reads .cwd from the PreToolUse payload itself (not $CLAUDE_PROJECT_DIR) and denies if `git -C \"$cwd\" diff --quiet <HEAD-or-empty-tree> -- \"$path\"` reports any of them dirty. Fails closed (denies) rather than allowing the call through when jq or python3 is missing from PATH, the payload or tool_input is not a JSON object, tool_name is present but not a string, tool_input.command is present but not a string, the classifier exits non-zero, the classifier's own output is not a JSON object with a recognized decision, .cwd is missing/not a git working tree, or a checkout/restore path's own live git-diff check cannot be verified. Disclosed residual (not closed by this classifier): verb-token-splitting that never places the tool/verb name as its own literal token anywhere, e.g. string-slice reconstruction (cmd=uvinstall; eval \"${cmd:0:2} ${cmd:2}\") or array-literal-assignment indirection (A=(uv); V=(install); \"${A[@]}\" \"${V[@]}\") -- see hooks/gitapex_check_bash_safety.py's own module docstring. A bare 'git checkout SOMENAME' (single positional, not '.'/'..', no --) is a deliberate Non-goal: disambiguating a branch/ref name from a path needs a live ref-existence lookup this pure classifier does not perform.",
"planes": ["pretooluse"],
"local_exclusion": "PreToolUse-only: grades a Claude Code tool-call JSON payload arriving on stdin, which has no working-tree equivalent to reconstruct ahead of a push.",
"trigger": "PreToolUse matcher Bash (hooks/hooks.json)",
Expand All @@ -101,7 +101,10 @@
{"kind": "bash-pattern", "ref": "gh api -X|--method POST|PUT|PATCH|DELETE"},
{"kind": "bash-pattern", "ref": "gh api -f|-F|--field|--raw-field"},
{"kind": "bash-pattern", "ref": "gh api graphql ... mutation"},
{"kind": "bash-pattern", "ref": "git push"}
{"kind": "bash-pattern", "ref": "git push"},
{"kind": "bash-pattern", "ref": "git checkout -- PATH..."},
{"kind": "bash-pattern", "ref": "git checkout ."},
{"kind": "bash-pattern", "ref": "git restore [FLAGS] PATH..."}
]
},
{
Expand Down
78 changes: 78 additions & 0 deletions hooks/check-bash-safety.sh
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,82 @@ if [ "$is_git_push" = "true" ]; then
fi
fi

# --- Finding 5: git checkout/restore gated on a live git-diff check (issue #1375) ---
# `git checkout -- PATH` / `git restore PATH` / `git checkout .` can discard
# uncommitted work on a tracked path with no warning at all. gitapex_check_bash_safety.py's
# own classifier already extracted every candidate path such an invocation
# could discard (its own "git checkout/restore path extraction" section),
# soundly and with no live git call of its own. Unlike Finding 4's own
# advisory provenance scan (surfaces candidates, does not decide -- warn,
# not deny), "does git diff report a difference at this path" is a binary,
# deterministic fact about repo state with no judgment-call axis, so a hit
# here denies.
checkout_restore_paths_count=$(printf '%s' "$classifier_output" | jq -r '.checkout_restore_paths | length // 0')
if [ "$checkout_restore_paths_count" -gt 0 ]; then
# Read `.cwd` from the ORIGINAL tool-call payload, not
# `${CLAUDE_PROJECT_DIR:-$(pwd)}` the way Finding 4 above does -- a push
# is not cwd-relative, but a `git diff -- PATH` pathspec check is, and
# `.cwd` is Claude Code's own record of the Bash tool call's actual
# working directory (updated on every `cd` the session runs), not this
# hook runner's own. Replaying the near-miss's own exact command from a
# subdirectory must resolve the pathspec against the SAME tree bash
# itself would use.
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty')
if [ -z "$cwd" ]; then
deny "Blocked by hooks/check-bash-safety.sh: this git checkout/restore command needs the PreToolUse payload's own .cwd field to check the right working tree against, but it was missing or empty. Failing closed."
fi
if ! git -C "$cwd" rev-parse --show-toplevel >/dev/null 2>&1; then
deny "Blocked by hooks/check-bash-safety.sh: '$cwd' is not inside a git working tree -- cannot verify this git checkout/restore command is safe. Failing closed."
fi
# A fresh repo with no commits yet has no HEAD to diff against
# (`git diff --quiet HEAD -- PATH` fails with "fatal: bad revision
# 'HEAD'", confirmed live, exit 128, not the "differs" exit 1) --
# compare against git's own well-known empty-tree object instead, so a
# genuinely clean fresh repo is not denied outright.
if git -C "$cwd" rev-parse --verify -q HEAD >/dev/null 2>&1; then
diff_base="HEAD"
else
diff_base="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
fi
# Disclosed, accepted residual (round-3 independent review, issue #1375):
# a bare `git checkout -- PATH` / `git restore PATH` restores the
# WORKING TREE from the INDEX, not from HEAD -- so a path that was
# `git add`-ed with no further unstaged edit (worktree == index, but
# index != HEAD) is a genuine no-op checkout/restore, yet this check
# diffs against HEAD/the empty tree and denies it as if it would
# discard something. Confirmed live: stage a change with no further
# edit, then `git checkout -- PATH` changes nothing on disk, but this
# diff_base comparison still reports a difference. Deliberately left
# as-is rather than special-cased per flag combination (`--staged`
# alone already skips this check entirely below, since unstaging alone
# never discards file content) -- the failure direction is safe
# (over-denial only, matching every other explicit-source variant this
# check already treats the same conservative way; it never under-denies
# a real discard), and the existing deny message's `git checkout -m --`
# / `git add` remedies still resolve it as a false alarm the caller can
# work around.
# Fed via process substitution (`< <(...)`), not a pipe
# (`... | while read`) -- bash runs a pipe's right-hand side in a
# subshell, where `deny`'s own `exit 2` would only exit that subshell,
# letting this script fall through to its own `exit 0` past the loop
# instead of actually denying. Process substitution keeps the loop in
# THIS shell, so `exit 2` inside it really does exit the whole script.
while IFS= read -r encoded_path; do
[ -z "$encoded_path" ] && continue
# Each line is one base64-encoded path (issue #1375: a genuine JSON
# array from the classifier, base64-encoded here too) -- a path
# containing a newline or other shell-hazardous byte would otherwise
# split across `read` calls or corrupt this loop's own field
# splitting; base64 has neither.
path=$(printf '%s' "$encoded_path" | base64 -d)
diff_exit=0
git -C "$cwd" diff --quiet "$diff_base" -- "$path" || diff_exit=$?
if [ "$diff_exit" -eq 1 ]; then
deny "Blocked by hooks/check-bash-safety.sh: this git checkout/restore command would discard uncommitted changes at '$path'. Stash first (git stash push -- '$path') if this is not resolving a merge conflict; if it is, resolve and git add the path, or use git checkout -m -- '$path' to regenerate conflict markers instead of discarding them."
elif [ "$diff_exit" -ne 0 ]; then
deny "Blocked by hooks/check-bash-safety.sh: could not verify whether '$path' has uncommitted changes (git diff exited $diff_exit). Failing closed."
fi
done < <(printf '%s' "$classifier_output" | jq -r '.checkout_restore_paths[] | @base64')
fi

exit 0
Loading
Loading