Repository navigation
fix(security): scope the scrub hook by the live shell directory, not the session root - #1914
Merged
Merged
Conversation
potiuk
enabled auto-merge (squash)
October 30, 2026 15:57
…the session root The Bash tool event that Claude Code hands a mod carries no working directory, so register() fell back to $.session.root() and flagged the scope as a fallback. The root is only where the session started; with the fallback flag set, the tracker exemption is skipped, so in a session started in the tracker checkout a plain `git commit -F msg.txt` for the tracker's own config was blocked whenever the message used embargo terms. git has no repository flag, so the `--repo` hint in the block message offered no way through. The mod API also exposes $.session.cwd(): the session's current directory, which Claude Code moves when a Bash call changes directory (and which is the calling agent's own directory for a subagent). register() now reads it before falling back to the root. The hook then scopes a bare command by the directory the shell is really in: a commit in the tracker checkout takes the tracker exemption, and one made after an earlier call moved the shell into an upstream worktree is scanned. A cd on the same line is still followed as before. An explicit event cwd still wins. The session-root fallback and its rule are unchanged for harnesses where the current directory is unavailable. Its block message no longer suggests `--repo` for a git command, which has no such flag. Generated-by: Claude Opus 5
… redirect The scrub hook already treats GIT_DIR / GIT_WORK_TREE leading the git command itself (`GIT_DIR=x git commit`) as a repository redirect that the cwd-based tracker exemption cannot cover. The same variables set by an earlier segment of the line slipped past it: with a verified tracker cwd, `export GIT_DIR=../upstream/.git && git commit -m ...` took the tracker exemption although the commit lands in the other repository. A git public-surface subcommand now loses the cwd-based exemptions (tracker and known non-upstream remote) and falls through to the term scan when an earlier segment on the same line sets or exports a GIT_* variable through export, declare, typeset, readonly or local (including `export GIT_DIR` of an earlier assignment), or through a bare `GIT_*=...` segment with no command after it. Any GIT_* name counts, not only the repository-selection ones: the list grows with git releases and a tracker commit has no reason to set any of them earlier on the same line. An assignment scoped to another command (`GIT_DIR=x ls`) does not persist and is not flagged. `set -a` style auto-export is out of scope. Claude Code does not carry variables across Bash calls, so the same line is the only place this can happen. Generated-by: Claude Opus 5
…it or gh The GIT_* rule only recognised plain declaration builtins and git commands, so `builtin export GIT_DIR=...`, `GIT_DIR+=...`, an `eval` or `source`, or `export GH_REPO=...` before a bare `gh` command still let the later command take the directory-based exemption while acting on another repository. Fail closed instead: once an earlier segment on the line mentions any GIT_* or GH_* variable, runs an environment-opaque builtin (eval, source, ., set, read, mapfile, printf -v) after any builtin / command / control-word prefix, or declares variables with expansions, later git and gh commands lose the directory-based exemption and go through the term scan. A tracker commit has no reason to do any of this on the same line. Generated-by: Claude Opus 5
…mand Each pass over the deny-list found another way to change the environment or the repository before a later git or gh command: control words (`if source ...; then`), `GIT_COMMON_DIR=...` leading the git command itself, `git remote set-url` / `git config`, `gh repo set-default`, untracked `pushd` / `popd`, function and alias definitions, `PATH=...`. Replace it with an allow-list. An earlier segment keeps a later git or gh command's directory-based exemption only when its command is `git` with add / status / diff / log / show, `gh <noun>` with view / list / status / diff, `cd` (tracked by the loop), or one of echo, printf (without -v), test, [, true, false, :, pwd, ls, cat, with no leading variable assignment; brace-group braces are ignored. Any leading assignment on the git or gh command itself also removes its exemption. Generated-by: Claude Opus 5
An allow-listed command could still change repository state before a later git or gh command ran, after remote inspection had read the old state: through an output redirection (`printf ... > .git/config`), a command or process substitution (`echo $(git remote set-url ...)`), or `git diff --output=.git/config`. And an own-environment git command (`GIT_COMMON_DIR=... git tag`, `env ... git tag`, also after a prefix such as `then`) passed the first-word assignment check. An earlier segment with `>`, a backtick, `$(` or `<(` now counts as a redirect for later git and gh commands, as does an allow-listed git command with an `--output` / `-o` option. parseSubcommand records whether it unwrapped a leading assignment or an `env` wrapper, after any prefix, and such a git or gh command gets no directory-based exemption. Generated-by: Claude Opus 5
…m the allow-list A session started with GH_REPO, GIT_DIR or another repository-selecting variable in its environment repoints every bare git or gh command, but the directory-based exemption only looked at the directory. register() now reads those variables (literal $.env.get names, as the mod API requires) and any of them removes the exemption for the whole line. `printf -vNAME` assigns a variable (bash accepts the option glued to the name), so printf can change the environment and leaves the allow-list; echo covers the harmless case. Generated-by: Claude Opus 5
…rect
The directory tracking follows every `cd`, but `||` runs what follows
only on failure, so `cd /upstream || cd /tracker; gh ...` left the
tracked directory at the tracker while the shell stayed upstream. And
an allow-listed echo could assign through `${GH_REPO:=...}`.
A segment followed by `||`, or containing any `$` expansion, now counts
as a redirect for later git and gh commands.
Generated-by: Claude Opus 5
A `cd` after `&&` or `||` runs only conditionally (`false && cd /tracker; gh ...`), but the directory tracking followed it anyway, so a later command could take the tracker exemption from a directory the shell never entered. Such a `cd` now counts as a redirect for later git and gh commands; a leading or `;`-separated `cd` is still tracked. Generated-by: Claude Opus 5
Checking only the separator right before a `cd` missed a conditional
group: in `false && { true; cd /tracker; }`, the `cd` follows `;` inside
the braces but still runs only if `false` succeeds. Once an `&&` or `||`
has appeared on the line, every later `cd` now counts as a redirect for
later git and gh commands.
Generated-by: Claude Opus 5
A `cd` inside a compound command runs only conditionally. The allow-list already treats a segment starting with `if`, `while`, `for`, `case` or another compound keyword as a redirect for every later command, so these lines go through the term scan; pin that with tests. Generated-by: Claude Opus 5
…d cd A command substitution inside the git or gh command itself (`gh issue create -t "$(git remote set-url ...)"`) runs before the command resolves its repository, but only earlier segments were checked. Such a command now gets no directory-based exemption and goes through the dynamic-input check. Only a plain `cd <dir>` is tracked: `cd a b` fails and leaves the shell where it was, and options or `cd -` move it somewhere the tracking does not model, so any other cd form counts as a redirect. Generated-by: Claude Opus 5
…command
The in-command check caught `$(`, backticks and `<(` but not a plain
`$` expansion (`"${GH_REPO:=...}"` assigns before gh resolves its
repository) or `>(`. Use the same set as the earlier-segment check:
any `$`, backtick, `<(` or `>(` removes the directory-based exemption.
Generated-by: Claude Opus 5
With CDPATH inherited, `cd name` searches the CDPATH entries before the current directory, so the tracked directory can name a tracker while the shell lands in an upstream checkout. A relative `cd` that CDPATH can redirect (not `/...` or `./...`) now counts as a redirect, and the hook reads CDPATH from the session environment. `bash -c '...'` and `eval '...'` invoking git or gh can cd or set GIT_DIR inside the string, but their command type did not match the step-5 redirect check, so the tracker exemption skipped the dynamic-input block. They now get no directory-based exemption. Generated-by: Claude Opus 5
Bash skips the CDPATH search only for `/...`, `.`, `..`, `./...` and `../...`; a name such as `.hidden` is still searched, so it counts as a CDPATH-redirectable cd too. Tests also pin that an earlier `CDPATH=...` or `export CDPATH=...` on the line already removes the exemption through the allow-list. Generated-by: Claude Opus 5
potiuk
force-pushed
the
fix/scrub-hook-live-cwd
branch
from
October 30, 2026 17:11
6ce188d to
91bbe4e
Compare
1 task done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
tool.callevent gives a mod no working directory.register()therefore fell back to$.session.root(), which is only the directory the session started in, and set the fallback flag. Under that flag the tracker exemption is skipped, so in a session started in the tracker checkout, a plaingit commit -F msg.txtfor the tracker's own config was blocked whenever the message used embargo terms. The block message's--repohint did not help, because git commit/tag/merge have no such flag.$.session.cwd(), the session's current directory. Claude Code updates it when a Bash call changes directory, and for a subagent it is that agent's own directory.register()now reads it first, after an explicit eventcwd, and before the root. A bare command is then scoped by where the shell really is. A commit in the tracker checkout is exempt. A commit made after an earlier call moved the shell into an upstream worktree is scanned. Acdon the same line is followed as before.--repofor a git command.gitwithadd/status/diff/log/show,gh <noun>withview/list/status/diff,cd(already tracked), or one ofecho,test,[,true,false,:,pwd,ls,cat, with no leading variable assignment, no output redirection, no$expansion, no command or process substitution, not followed by||, and (for git) no--output/-o; brace-group braces are ignored. Anything else —export GIT_DIR=...in any spelling,export GH_REPO=...,eval,source, control words, function or alias definitions,PATH=...,git remote set-url,git config,gh repo set-default, untrackedpushd/popd— sends the later command through the term scan. A leading assignment or anenvwrapper on the git or gh command itself, after any prefix, does the same (GIT_COMMON_DIR=... git tag; the parser recognised onlyGIT_DIR/GIT_WORK_TREEthere). A deny-list was tried first and each review pass found another bypass. Claude Code does not carry variables across Bash calls, so only the same line matters — except variables the session itself inherited: if Claude was started withGH_REPO,GH_HOSTor a repository-selectingGIT_*variable set,register()reads it and every bare git / gh command loses the directory-based exemption.cdtracking: a$expansion, backtick,<(or>(inside the git / gh command, acdthat runs only conditionally (after&&/||), anycdother than a plaincd <dir>(cd -,cd -P, extra arguments), a relativecdthat an inheritedCDPATHcan redirect, andbash -c '...'/eval '...'invoking git or gh all remove the directory-based exemption.Type of change
.claude/skills/<name>/) — eval fixtures updated belowtools/<system>/*.md)tools/*/withpyproject.toml)docs/,README.md,CONTRIBUTING.md)projects/_template/)prek, workflows, validators)plugins/magpie-security/hooks/public-surface-scrub.ts) and its testsTest plan
prek run --all-filespasses (ranprek run --fileson the two changed files: all hooks pass, includingcheck-claude-mods-localandpytest (workspace))uv run pytest/ruff check/mypypassestools/dev/check-claude-mods.sh --localwith Claude Code 2.1.312.claude plugin validate --strictpasses with the new$.session.cwd()call, andpublic-surface-scrub.test.tshas 158 pass / 0 fail.register()tests, with the session root always the tracker checkout:session.cwdis the tracker: a baregit commit -Fpasses.session.cwdis an upstream worktree, as after acdin an earlier call: the commit is blocked.cdinto upstream on the same line: blocked.cwdtakes precedence oversession.cwd.gh pr createwith a verified tracker cwd is exempt.--repo.session.cwdhas no answer, the hook still falls back to the root.session.cwd()read removed, the two tests for the tracker case fail.GIT_*declaration form (export,declare -x,typeset -x,readonly,local, bare,builtin/command export,+=, scopedGIT_DIR=x ls),eval,source,.,set -a,printf -v,export "$V",export FOO=1,if source ...; then, a leadingGIT_COMMON_DIR=on the git command,git remote set-url,git config,pushd/popd, a function definition,PATH=...,env GIT_COMMON_DIR=... git tag,then GIT_COMMON_DIR=... git tag,>> .git/config,echo $(...), backticks,<(...),git diff --output=...; and before a baregh:export GH_REPO,GH_HOST=, a leadingGH_REPO=,git remote set-url,gh repo set-default,printf ... > .git/config,printf -vGH_REPO ...,cd /upstream || cd /tracker;,echo "${GH_REPO:=...}";; and any bare git / gh command when the session environment carriesGH_REPOorGIT_DIR.echo ready && git commit,git add file && git commit,git status && git commit, and a bare commit; the existing{ cd ../tracker; gh issue view 1; }; git committest still passes.cdinsideif/while/for/caseor after&&/||,cd -,cd -P,cd dir extra,$(...)/ backticks /${GH_REPO:=...}/>(...)inside the git or gh command,cd childandcd .hiddenunder an inheritedCDPATH(cd ./childand absolute paths stay exempt),CDPATH=.../export CDPATH=...earlier on the line, andbash -c/sh -c/evalinvoking git or gh.GIT_*export still passes (the rule removes the exemption; it does not block by itself).RFC-AI-0004 compliance
Linked issues
Refs #1676
Notes for reviewers (optional)
$.session.cwd()comes from: in Claude Code 2.1.312, thesession.cwdcall is answered from the call context's cwd, else from the session's current cwd, and only then from the original cwd. That current cwd is updated frompwd -Pafter each Bash command (setCwd, telemetrytengu_shell_set_cwd).session.rootanswers the original cwd. Claude Code's own built-in mods read both, and resolve relative paths againstsession.cwd. There is no public.d.tsfor the mod API, so this comes from the shipped engine, not from documentation.cdmade in an earlier Bash call, norGIT_DIR/GIT_WORK_TREEset by an earlierexport. The latter is now covered for every directory by the allow-list above.envwrappers,printf -v,${VAR:=...},cdafter&&or||, redirections into.git/config,git remote set-url,gh repo set-default, inheritedGH_REPO, inheritedCDPATH,bash -c/eval). The hook still parses shell text statically, so treat the exemption as best-effort; when in doubt it now blocks, and the blocked path stays the term scan, not a hard refusal.🤖 Generated with Claude Code