fix(guardrails): block an exported MSYS path-conversion suppressor - #2878
Conversation
An exported MSYS_NO_PATHCONV / MSYS2_ARG_CONV_EXCL switches off MSYS argv rewriting for every later command in the same command string, so a later path argument reaches a Windows-native program unconverted and git resolves its leading / against the current drive. That recreated D:\d a third time. Adds a PreToolUse guard keyed on the exported form (0.32% firing across 14,234 real Bash commands) rather than on a path shape (45.7% firing, and a false negative on the actual incident). Also corrects the drive-root-litter detector's remediation text, which told the reader to delete a phantom tree that can contain a live registered worktree. Refs #2870 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp
…on-mangling workaround Rule 5 in windows-path-emit, plus the conditional <rev>:<path> mangling that sends authors reaching for the suppressor in the first place. Wires the new guard into every guardrails doc surface: guard table, kill-switch table, generated options table, catalog, telemetry registry, schema and example. Refs #2870 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
|
Claude finished @kyle-sexton's task in 1m 19s —— View job Security review
Scope reviewed: all 15 changed files ( Findings: none. Notes from the review, for context (not findings):
No security-relevant findings in this PR. |
|
Last security-reviewed head: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6e3faaaa2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Claude finished @kyle-sexton's task in 3m 56s —— View job Claude's review
SummaryReviewed both commits ( One correctness finding, posted inline on The I traced this by hand (the sandbox here blocks executing arbitrary bash scripts, only specific git read commands are permitted), but the ERE logic is unambiguous: the alternation Everything else — the host gate, the fail-closed posture (NUL byte, oversized command, missing jq), the telemetry wiring, and the doc/count bookkeeping across 8+ surfaces — checked out. |
|
Claude has reviewed this PR 5 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
The machine-specific-paths hygiene gate rejects a literal D:/repos/... even inside an illustrative message or a test fixture command. Replaced with <repo-root> placeholders; no behavior change, and the suite still asserts the same 50 cases. Refs #2870 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp
|
Claude finished @kyle-sexton's task in 1m 7s —— View job Security review
Skip gate hit — no new security-relevant surface. The last security review covered head
None of this touches the guard's matching logic ( No security-relevant surface to review on this update — no findings. |
|
Claude finished @kyle-sexton's task in 1m 54s —— View job Code review (
|
Adversarial review found an undeclared false negative: a per-command prefix scopes to one PROCESS, so when that process is an interpreter, the suppressor leaks into every command inside the script. Verified behaviorally -- `MSYS_NO_PATHCONV=1 bash -c 'git ... /d/a; git ... /d/b'` leaves both unconverted, where the same prefix on git directly converts the second. Closing it cost zero additional false positives on the same 14,234-command corpus: the one match was already blocked by the export rule. A prefix on a non-shell command word -- the safe idiom, 193 corpus uses -- stays allowed. Suite 50 -> 64 assertions. Refs #2870 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp
|
Claude finished @kyle-sexton's task in 4m 24s —— View job Code review (
|
|
Claude finished @kyle-sexton's task in 2m 3s —— View job Security review
Scope reviewed: the diff since the last full security pass (head
Findings: none. No injection path, no trust-boundary change, no permission-widening, no Note, not a security finding (out of scope for this lane — flagged already by the code-review lane and by Codex's review): |
The first draft expressed the child-shell rule as one ERE placing an unbounded [^[:space:]]* next to a quantified (NAME=value[[:space:]]+)* group. On a real corpus command -- MSYS_NO_PATHCONV=1 git show 'origin/main:.claude/settings.json' -- that backtracked catastrophically and hung the matcher, and it also wrongly matched the "sh" inside "show". A hook that hangs is worse than one that misses: PreToolUse has a timeout, and a guard that burns the budget gets switched off. The token walk is O(tokens) with no backtracking, and judges the command word's basename, so show, refresh, and a settings.json path are not shells. Two regression pins added: the exact corpus string, and a command whose name merely ends in sh. Suite 64 -> 66 assertions. Refs #2870 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp
|
Claude finished @kyle-sexton's task in 5m 7s —— View job Security review
Scope reviewed: the diff since the last full security pass (head What changed: Findings: none that meet this lane's scope (no untrusted-input/trust-boundary crossing, no injection path, no permission-widening — this hook still only pattern-matches Note, not a security finding (out of scope for this lane, consistent with how the prior |
|
Claude finished @kyle-sexton's task in 5m 43s —— View job Code review (
|
…nner ShellCheck's optional add-default-case check (SC2249) flags the case statement inside leaks_into_child_shell for having no *) branch. The branch added here is an explicit no-op, and that is a deliberate behavioral choice rather than lint appeasement: this case statement is a scanner step in the linear token walk, not the guard's allow/deny verdict. A token that does not begin a suppressor prefix simply means the walk continues to the next token, so silently falling through preserves the function's existing semantics exactly. Exiting with an error here would be wrong — ordinary tokens such as command words and arguments hit this branch on every scan. No behavior changes; the hook's 66 tests pass unchanged. Fixes the sole remaining hygiene finding on PR #2878 (issue #2870). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwdkpWf6bptu3AqTMoeg2H
|
Claude finished @kyle-sexton's task in 1m 18s —— View job Code review (
|
|
Claude finished @kyle-sexton's task in 43s —— View job Security review
Skip gate hit — no new security-relevant surface. The last full security pass on this PR covered head plugins/guardrails/hooks/block-exported-msys-pathconv.sh | 4 ++++
1 file changed, 4 insertions(+)specifically a no-op No security-relevant surface to review on this update — no findings. |
…atcher This commit resolves the five unresolved review threads on PR #2878, each of which identified a way the exported-suppressor matcher either over-fired on quoted prose or under-fired on valid export spellings. Under-firing (threads on export option forms and declare flag clusters): is_exported_suppressor now recognizes `export --`, and it matches `declare`/`typeset` by capturing the whole flag region and checking it for the export flag afterward, so `-rx`, `-gx`, `-xg`, and separately given `-x -g` flags all match regardless of order or combination. The literal single-flag `-x` spelling was a false negative one flag away from the tested form. `declare -r`, `declare -p`, and `export -n` stay allowed because none of them exports. Under-firing (the path-qualified launcher threads, one current and one outdated duplicate): leaks_into_child_shell now normalizes every candidate token to its basename, with quoting and a `.exe` suffix stripped, before deciding whether it is a launcher or a shell. That closes the `/usr/bin/env bash -c` gap, and the launcher set now also covers `command`, `builtin`, `exec`, `nohup`, and option flags such as `env -i`, which err fail-closed rather than fail-open. Over-firing (the quoted-text thread): the matcher now requires the export keyword to sit at command position, after a separator or a newline, UNLESS the command also names a shell word, in which case quoted text can be handed to that shell and execute, so any occurrence stays a match. Commit messages, echo arguments, and grep patterns that quote the forbidden spelling are now allowed, while `bash -c 'export MSYS_NO_PATHCONV=1; ...'` and `eval "export ..."` remain blocked. Two residual false positives are accepted deliberately and pinned by test: an export spelling inside a heredoc body, and prose quoting the export alongside a shell word in the same command string. Distinguishing those requires real shell parsing, and this guard errs fail-closed. The header's measured corpus numbers were taken with the old matcher; the corpus itself is not in the repository, so the numbers were not re-measured. Directionally, the command-position rule only removes firings on prose, and the broadened export spellings only add firings on commands that genuinely export. The test suite grows from 66 to 117 assertions, and every new behavior above is pinned by at least one of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwdkpWf6bptu3AqTMoeg2H
|
Claude finished @kyle-sexton's task in 2m 40s —— View job Security review
Scope reviewed: the diff since the last full security pass (head What changed: the commit resolves all five open review threads on this PR (the Codex P1 quoted-prose over-firing finding, the Codex P2 Findings: none that meet this lane's scope. This is still a pure Two notes, not findings (consistent with how this lane has scoped every prior round on this PR — completeness/false-negative gaps in this self-protective, non-privileged guard are the code-review lane's territory, not a trust-boundary or injection issue, since nothing here processes attacker-controlled input across a privilege boundary):
No injection path, no permission-widening, no |
…gnized A fresh-context verification pass on PR #2878 found a regression the review-finding commit introduced: contains_shell_word and the token walk strip only a LEADING quote from each token, so a fully quoted shell word such as 'bash' or "C:\Program Files\Git\bin\bash.exe" normalized to bash' or bash.exe" and matched nothing. Without a recognized shell word the matcher fell back to command-position mode, and an export inside the quoted -c argument was no longer at command position, so commands like & "C:\Program Files\Git\bin\bash.exe" -c "export MSYS_NO_PATHCONV=1; git status" went from blocked to allowed. The PowerShell call-operator form is the one that matters in practice: the Git Bash install path contains a space, so quoting it is mandatory, and this guard runs on the PowerShell surface precisely to catch bash invoked from it. Both normalization sites now strip a trailing quote alongside the leading one, which also closes the pre-existing miss on a prefix targeting a quoted shell path. Three new tests pin the quoted bare word, the quoted absolute path, and the PowerShell call-operator form. The suite grows from 117 to 124 assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwdkpWf6bptu3AqTMoeg2H
|
Claude finished @kyle-sexton's task in 1m 15s —— View job Security review
Scope reviewed: the diff since the last full security pass (head What changed: Findings: none. This is a pure detection-tightening fix to a Three new tests ( No injection path, no permission-widening, no trust-boundary change. |
… walk Verification on PR #2878 surfaced a pre-existing asymmetry in leaks_into_child_shell: quote normalization applied to the launcher and command-word side of the walk but not to the assignment side, so the first word of a quoted child command string arrived as "MSYS_NO_PATHCONV=1 and failed the MSYS_NO_PATHCONV=* pattern. As a result, bash -c "MSYS_NO_PATHCONV=1 bash -c 'git worktree add ...'" was allowed even though the inner prefix leaks into the inner shell exactly the way this guard describes. The gap predates this PR's review commits, but a guard whose argument is measured precision should close a known false negative rather than declare it. Quote stripping now happens once at the top of the loop, for every token, before either the assignment check or the command-word check. The nested-quoted-prefix shape is pinned by a new test, and the header's declared residual false positives now also cover prose that quotes a suppressor-prefix-on-shell spelling alongside a shell word, including a shell name at the very end of a quoted string, which quote normalization reads as the bare word. The suite grows from 124 to 127 assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwdkpWf6bptu3AqTMoeg2H
|
Claude finished @kyle-sexton's task in 1m 20s —— View job Security review
Scope reviewed: the diff since the last full security pass (head What changed: in Findings: none. This is a strict tightening of an already-reviewed One new test ( No injection path, no permission-widening, no trust-boundary change. |
The hygiene job's comment-hygiene scan rejects pull-request references in code comments, and six comments added during this PR's review rounds cited the PR by number. Those comments now describe the findings in timeless terms instead, which is what a future reader needs anyway: the provenance lives in the pull request and the commit messages, not in the code. The shell-portability gate flagged the backslashes in the new PowerShell test fixture's Windows path as GNU-only regex word-boundary escapes. They are path separators in literal test data that never reaches grep or sed, so the fixture now carries the gate's documented portability-ok annotation with that reason. This commit follows a merge of origin/main into the branch, which the stale-base-overlap gate required because docs/CATALOG.md had changed on both sides since the merge base. The merge itself was clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwdkpWf6bptu3AqTMoeg2H
|
Claude finished @kyle-sexton's task in 45s —— View job Security review
Skip gate hit — no new security-relevant surface. The last full security pass on this PR covered head
No injection path, no permission-widening, no trust-boundary change, and no |
…8.31 PR #2876 landed first and published guardrails 0.28.31, so this branch's 0.29.0 bump now sits on top of that version instead of on 0.28.30: the manifest stays at 0.29.0, and the 0.29.0 changelog entry sits above the 0.28.31 entry rather than in place of it. While re-resolving, the 0.29.0 entry was brought up to date with the matcher's final reviewed shape: the broadened export spellings, the command-position rule for prose, the basename-normalized launcher walk, and the declared residual false positives, none of which the original entry could have described because they came out of this PR's own review rounds. Changelog parity checks (--check, --check-bump against origin/main, --check-order) pass locally, as do the hook's 127 tests and shellcheck. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwdkpWf6bptu3AqTMoeg2H
|
Claude finished @kyle-sexton's task in 4m 11s —— View job Security review
Scope reviewed: the diff since the last full security pass (head The inherited main-side content ( Findings: none that meet this lane's scope (no injection path, no Note, not a security finding, but worth surfacing loudly — the manual conflict re-resolution in this merge commit dropped the new guard's manifest entry entirely.
Practically, this doesn't create an exploitable gap: No injection path, no permission-widening, no new |
The first merge resolution took main's entire plugin manifest and put only the version back, which silently dropped this PR's two manifest changes: the fourteen-guard description that names the new guard, and the block_exported_msys_pathconv_enabled kill-switch option. The catalog and plugin-options gates caught the damage, because the committed generated docs described a guard the manifest no longer declared. This merge brings in main through 4e6249a and resolves the guardrails collision correctly: the manifest is the PR side in full, whose only divergence from main is the version line, re-resolved to 0.29.0 on top of the 0.28.33 that main has since published; the changelog keeps the 0.29.0 entry above main's 0.28.33, 0.28.32, and 0.28.31 entries. Verified locally: generate-catalog --check, generate-cheatsheet --check, sync-plugin-options-docs --check, validate-plugins, changelog parity (--check, --check-bump vs origin/main, --check-order), the hook's 127 tests, and shellcheck on both hook files all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwdkpWf6bptu3AqTMoeg2H
|
Claude finished @kyle-sexton's task in 1m 16s —— View job Security review
Skip gate hit — no new security-relevant surface. The last full security pass on this PR covered head Restricting to this PR's actual scope (
No guard logic, test file, telemetry schema, or No security-relevant surface to review on this update — no findings. |
Summary
D:\dwas recreated for the third time on 2026-08-16 at 16:46:30 EDT, this time by an ordinary agent lane creating a worktree. #2841 shipped the convention, the helper and the detector for this class; none of them is a preventive control, and the detector is structurally incapable of firing before the damage.The trigger turns out not to be the path form. The creating lane ran
export MSYS_NO_PATHCONV=1earlier in the same command string — to work around MSYS mangling an unrelated<rev>:<path>argument — and that export silently unconverted agit worktree add /d/worktrees/...seven segments later. The same lane had already run the identical path argument successfully. A guard keyed on the/[a-z]/path shape has a false negative on the real incident, which is the same false-green shape this body of work exists to remove.This PR adds a guard keyed on the environment instead, and fixes a shipped defect in the detector's remediation advice that the same incident exposed.
Fix
1. New
PreToolUseguard:plugins/guardrails/hooks/block-exported-msys-pathconv.sh(Bash|PowerShell, Windows hosts only, default on, kill switchblock_exported_msys_pathconv_enabled).Blocks
export MSYS_NO_PATHCONV/export MSYS2_ARG_CONV_EXCLand thedeclare -x/typeset -xspellings. Leaves the per-command prefix form and bare assignments alone. A cheap substring pre-filter runs before any parsing, so a command that never names either variable exits immediately.2.
scripts/check-drive-root-litter.shremediation text. Its footer told the reader to "remove the phantom tree once you have confirmed it holds nothing else." The first real hit on this machine,D:\d\worktrees\ccp-measure2, is a live registered git worktree (.git→gitdir: D:/repos/.../.git/worktrees/ccp-measure2, present ingit worktree list). Deleting it as advised strands the registry entry and leavesgit worktree pruneas the only cleanup — on a repository currently carrying 69 registered worktrees across live lanes. The text now sends the reader togit worktree remove --forcefirst.git grep worktreeover the script previously returned nothing.3.
docs/conventions/windows-path-emit/README.mdgains rule 5 (never export a suppressor) and a section on the conditional colon-argument mangling that sends authors reaching for one.Doc surfaces wired: guard table, kill-switch table, generated options table,
docs/CATALOG.md, telemetry schema + example + producer registry,skills/setup/SKILL.md, count-bearing prose, CHANGELOG, version0.28.30→0.29.0.Why not extend
block-windows-drive-tmp.shReuse-or-replace applies, and the answer is an openly-scoped sibling rather than a silent second way. That guard is a path-shape and write-target matcher scoped to the literal component
tmp, with tmp-specific allowances (/var/tmp,%TEMP%). This one is an environment-variable matcher with no path component at all. Neither would fire on the other's cases. Folding two disjoint matchers and two exclusion sets into one hook makes both harder to reason about and risks regressing a guard that works. Both headers now carry a reciprocal cross-reference naming the split.Why not
scripts/check-shell-portability.shAn authoring-time lint class for this idiom is genuinely complementary and worth doing — but a parallel lane owns that dispatcher right now (#2840). Routed as a follow-up in #2870 rather than colliding.
Verification
Mechanism, by execution. No artifact was created at any real drive root;
substmapped throwaway virtual drives onto scratch subdirectories, all removed with removal verified by listing.D:\dwas never modified, moved or removed, andgit worktree prunewas never run.The exact incident command shape, PowerShell, throwaway repo on
substdriveP::created and registered
P:\d\worktrees\ccp-reproA. Drive-dependence, same argument from two drives:An independent agent, given no sight of the above reasoning, re-derived the same result from scratch on
X:/Y:and isolated the responsible layer — the result flips with argv conversion alone, sogit.exeresolves/y/...against the current drive and Git Bash normally masks it.The predicate, four ways in one measurement.
git rev-parse --sq-quoteprints exactly whatgit.exereceived and creates nothing:Measured precision (ADR-0003). Corpus: 16,919
tool_input.commandstrings from 701 local transcript JSONL files (388 MB), split by tool, plus 710 lines of PowerShell console history./[a-z]/(rejected)git worktree add(rejected)Numbers are not a regex approximation: every command in the corpus was replayed through the built hook binary, with
OSTYPE=msys, as a real PreToolUse payload on stdin.The 46 span four distinct lanes (
ccp-measure2,ccp-silent-revert-calib,ccp-silent-revert-fixture,ccp-verify-2843) and two repositories, including the lane family that produced this incident. They are unseeded — the reproductions run for this work used the safe prefix form and are among the 193 correctly left alone — so this is ADR-0003's measured-precision path, not its seeded-defect exemption.Precision claim, stated exactly. All 46 are genuine instances of the anti-pattern: the exported form always has unbounded blast radius over the rest of the command string, and the remedy is one keystroke. What the measurement cannot claim is a per-fire count of "would have produced litter" — that depends on which later command consumed a path, which is not statically decidable. Two honesty notes: the corpus is one machine's, the deployment surface but not a fleet; and it contains this investigation, which is why no count keyed on a bare mention of the variables is used.
Declared coverage gaps, in the hook header rather than hidden: a suppressor exported by a script the command invokes;
set -aplus a bare assignment; an expansion-built value; and any spawner outside the two tool surfaces (CI runners,subprocess), which no PreToolUse hook can see.Test plan
New
plugins/guardrails/hooks/block-exported-msys-pathconv.test.sh— PASS=50 FAIL=0. Auto-discovered byscripts/run-plugin-tests.sh; noci.ymlchange, socheck-lane-coverage.shis unaffected. Covers: the POSIX host gate; the real #2870 incident shape;export,declare -x,typeset -x, chained-&&, both-variables and leading-unrelated-assignment forms; the safe per-command-prefix and bare-assignment forms; six mention-not-setting cases (grep, commit prose,gh issue createtitle,unset, a similarly-named variable,echo); an ordinarygit worktree addwith an MSYS path, asserting the design decision not to match a path shape; the PowerShell surface; fail-closed over-length and payload cases; and the kill switch.Everything else run locally against the branch:
scripts/check-drive-root-litter.test.shplugins/guardrails/hooks/block-windows-drive-tmp.test.shrequire-jq-posture.test.sh(auto-enrolls the new hook)require-jq-notice-isolation.test.shscripts/check-silent-skips.shscripts/check-hook-exec-form.shscripts/check-hook-userconfig-argv.shshellcheck -x(repo.shellcheckrc) on all changed shellscripts/check-shell-portability.sh origin/maincheck-changelog-parity.sh--check/--check-bump/--check-orderscripts/validate-plugins.shsync-plugin-options-docs.py --check/generate-catalog.mjs --check/generate-cheatsheet.mjs --checkmarkdownlint-cli2on every changed markdownBoth new files are mode
100755.Related
block-windows-drive-tmp.sh, the sibling guardworktree-add-containment-gate.sh, the second guard that did not firedocs/adr/0003-verification-guards-earn-default-on-by-measured-precision.mdReview-round matcher changes (added after the five review threads)
The three review-round commits (
6f462b3b8,a8462fe82,acbb881f0) changed the matcher's firing envelope in both directions, and one widening is easy to miss from the commit messages alone:echoarguments, and grep patterns quotingexport MSYS_NO_PATHCONV=1are allowed.git commit -m "do not export MSYS_NO_PATHCONV=1 in bash"now blocks (the trailingbash"reads as the wordbash), while the same sentence not ending in the shell name —"...in bash, do not export MSYS_NO_PATHCONV=1"— stays allowed. This is inside the header's declared residual false-positive class, errs fail-closed with an instructive message, and is the accepted cost of closing a true-leak false negative: a fully quoted shell word ('bash', or"C:\Program Files\Git\bin\bash.exe"behind PowerShell's&) previously evaded the shell-word check entirely.bash -c "MSYS_NO_PATHCONV=1 bash -c '...'"— where the suppressor prefix is the first word of a quoted child command string — now blocks.All declared residuals are documented in the hook header and pinned by tests (127 assertions, up from 66 at review time).