fix(guardrails): stop blocking a PowerShell call of a constant target - #1974
Conversation
`ps::might_invoke_git`'s call-target branch matched any quote character after `&` or `.`, so `& "C:\tools\publish.ps1"` — the ordinary PowerShell script-invocation idiom, carrying no `git` token and a compile-time constant path — routed to the fail-closed sink and was refused by a *git* guard. Both quote styles and the dot-source form were affected, and since the predicate is shared, the same command false-blocked twice: once from block-dangerous-git and once from block-no-verify. The branch now matches only a genuinely computed target: a bare variable or subexpression (`& $tool`, `& (…)`), or a double-quoted string that interpolates (`& "$tool"`, `& "C:\tools\$ver\x.exe"`). Per PowerShell about_Quoting_Rules a `$`-free double-quoted string and any single-quoted string are compile-time constants, so such a target is statically decidable as non-git. No fail-open: the literal-git probe runs quote-intact, so `& 'git' …`, `& "git" …`, and `& "C:\Git\cmd\git.exe" …` are still caught by name, and every interpolated form still blocks. Regression cases for both directions are checked in on both guards' suites. Also in this change: - The sink's block messages named constructs that were not present and omitted the one that was. `ps::classify_git_command` now records which of the four sink triggers fired in `PS_SINK_TRIGGER`, and each message prints remediation specific to it — "remove the unparsable construct" is unactionable when the trigger was a launcher or a computed target. - Both fail-closed messages now name their kill switch, which the sibling too-long and alias-cap messages already did. - Telemetry carries the trigger instead of collapsing all four shapes into one `powershell-unparsable` token that hid the false-positive rate. - The git and python-write lanes now share one call-target predicate rather than two drifted regexes; the git lane's blanket quote match was the drift that caused this bug. The python lane is unchanged. Verified: 858 tests pass across the five affected suites (block-dangerous-git 329, block-hook-bypass 211, block-noncanonical-commit 172, block-no-verify 115, block-convention-violation 31); shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
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 4m 20s —— View job Claude finished the code review
SummarySolid, well-documented fix. I traced the new predicates ( I could not execute the test suites or Findings🟡 Suggestion — This PR's stated goal is "the git and python-write lanes share one call-target predicate instead of two drifted regexes" (new its own comment even says "Mirrors 🟡 Nit —
Test coverageThe new regression cases in both |
|
Claude finished @kyle-sexton's task in 3m 17s —— View job Security review complete
No security issues found. This PR narrows a false-positive-prone regex in a defensive guardrail hook (bash), and I traced the control flow closely for a fail-open regression (the class of bug this exact file has been bitten by before — see the What I checked:
I wasn't able to execute the shell test suites myself in this sandbox (no approval for running the |
|
Claude has reviewed this PR 3 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f0394e9b8f
ℹ️ 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".
Two sites in ps-command.sh, both surfaced by the gate scanning the whole changed file: - The new dynamic-invocation trigger message embedded a Windows path example whose `\s` tripped the GNU-construct heuristic. The example was incidental; the message now states the rule (a constant quoted path is decidable; only an interpolating target reaches this branch) without a literal path, which is clearer anyway. - `ps::write_bypass`'s `$null`-discard exclusion uses `\>` to escape a literal `>` inside a bash `[[ =~ ]]` ERE. That is not GNU grep's `\>` word-boundary and no external grep/sed is involved, so it carries a `portability-ok:` excuse rather than a rewrite. Pre-existing line; the gate only saw it because this PR touches the file. Verified: `scripts/check-shell-portability.sh origin/main` reports no unexcused constructs, shellcheck clean, repro battery still 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 19s —— View job Security review complete
No security issues found. This PR narrows a false-positive-prone regex in a defensive guardrail hook (bash) that fail-closes on PowerShell git commands it cannot parse with confidence. I traced the change specifically for a fail-open regression — a genuinely computed/dangerous call target slipping past the sink — since that's the failure mode that would matter here (the file's own header already frames the threat model as accidental-destruction friction, not a boundary against deliberate evasion, so I focused on whether the narrowing itself is sound rather than on disclosure). What I checked:
Two non-security remediation-wording issues were already flagged by Codex's review (the |
|
Claude finished @kyle-sexton's task in 2m 23s —— View job Claude finished the code review
Summary
Codex's two findings — verified against HEAD, both still openI traced both by hand through the current 1.
The bolded claim is false for this exact, tested case, and the reworded text (from 2. Here-string close message names the wrong terminator for a Everything else from the first review round stands unchangedThe I could not execute the shell test suites or |
…very command that reaches them Two review findings on this PR, both message-only — the detection is unchanged and correct in each case. The unbalanced-here-string line always named the '@ terminator, but PowerShell pairs @" with "@. An operator whose @" body was flagged and who followed the advice literally got a command that was still unbalanced and still blocked. ps::blank_herestrings now records the hanging opener's quote in PS_HERESTRING_QUOTE, and the line names the matching terminator (naming both when no opener was recorded). The dynamic-invocation line told the operator to "invoke the target by its literal name" and claimed a constant quoted path is not blocked. The invocation FORM is what routes a command to that branch, so `& 'git' reset --hard` names its program literally and is blocked anyway — the advice described the form they had already used. It now says to drop the iex / & / . and write the program as a plain command word, which applies to every shape that reaches the branch. Both defects survived because every PowerShell sink case asserted only the exit code. block-no-verify.test.sh now captures stderr and pins the terminator selection and the drop-the-operator advice. Suites: block-no-verify 120/120, block-dangerous-git 329/329. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 16s —— View job Claude finished the code review
Summary
1. Here-string terminator now matches the actual opener. 2. Dynamic-invocation message no longer tells the operator to do what they already did. The old text asserted "a constant quoted path is decidable and is not blocked," which was false for Test coverage matches the fix. The new I checked whether No regressions found in the diff. The two non-blocking suggestions from the first review round ( I could not execute the |
|
Claude finished @kyle-sexton's task in 1m 44s —— View job Security review complete
No security issues found. This review covers the new commit since the last security pass, What changed in What I verified:
Combined with the two prior security-review passes on this PR (predicate-narrowing fail-open analysis, telemetry/injection review), I have no outstanding security concerns on the current HEAD. |
…ed --sessions (#1981) Closes #1980 ## Summary Two ways `retro`'s chain-scoped path produced a wrong answer with no error signal: a comma-joined `--sessions` list resolved to nothing, and a chain walk that terminated early was indistinguishable from a genuinely short chain. ## Fix **`--sessions` comma splitting.** The option is declared `nargs="+"`, so `--sessions a,b,c` was consumed as one literal token that matched no transcript, and the run reported `0 with transcript` for a chain whose transcripts all existed. Tokens are split on `,` after parsing — a session id never contains one, so the split cannot change the meaning of a correctly space-separated invocation. Empty fragments (`a,,b`, a trailing comma) are dropped rather than passed on as an id that cannot exist; a value resolving to no ids at all reaches the existing usage error (exit 2). **`chain_coverage`.** Multi-session output gains `requested` / `found` / `available` / `ratio`. `available` counts the transcripts present in the base directory — the per-project transcript directory — which is the denominator the `previous_handoff` walk structurally cannot see. It is coverage evidence for a reader, not a filter: some sibling transcripts will belong to other work, which is exactly why the skill surfaces the ratio rather than the parser widening the chain. The same ratio also rides in the human-readable `summary`, so it is visible without reading the structured field. An unreadable base directory degrades `available` to `null` instead of failing the parse. **Skill contract.** `retro`'s SKILL.md and `context/session.md` now require stating the discovery basis, and forbid presenting a low-coverage chain retrospective silently: below a ratio of ~0.5, name `found` and `available` and offer `--sessions` with the ids enumerated. ## Verification - `plugins/session-flow/skills/retro/scripts/test_parse_transcript.py` — 35 passed (was 30). New cases: comma-joined list resolves the same list as the space-separated form and keeps its order (first id = current session); mixed separators with empty fragments; a `--sessions ,` value that yields no ids exits 2; coverage reported as 2-of-5 with `ratio` 0.4 and the ratio present in `summary`; full coverage reports `ratio` 1.0. - `plugins/session-flow/skills/retro/scripts/parse-transcript.test.sh` — passes. - `ruff check` and `ruff format --check` — clean. - `scripts/check-changed-skills.sh origin/main` — `retro` PASS, 0 errors. - `scripts/check-changelog-parity.sh --check-bump origin/main` and `--check-order`, and `markdownlint-cli2` on the three touched markdown files — clean. **Fresh-docs mandate**: no WebFetch was required and none was performed. This changes a script's own CLI behavior, its JSON output shape, and skill prose — no plugin manifest field beyond the `version` bump, no hook contract, no documented harness behavior. ## Related - Refs #1979 and #1974 — other fixes draining the same audit inbox. No file overlap with either: `session-flow` carries no copy of the shared hook library, so it is untouched by the 16-plugin version bump in #1979. - The same audit report's third finding (a subagent completion-signal contract in `orchestrate`) overlaps an audit item resolved separately and is deliberately out of scope here. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-reduction Resolves the version-line collisions this branch's 16-plugin lib bump has with three PRs that landed on main first (#1974 guardrails, #1981 session-flow, #1983 claude-ops). - guardrails: main released 0.19.1 (#1974's PowerShell sink fixes), so the shared-lib entry re-heads as 0.19.2. - claude-ops: main released 0.27.2 (#1983's $HOME spelling fix, which also escaped the manifest's em-dashes), so the shared-lib entry re-heads as 0.27.3 and the manifest keeps main's escaped description. - Both changelogs keep BOTH entries, each under its own version. Gates: sync-hook-utils.sh --check (all 16 copies match) and --check-bump origin/main (lib changed and every carrying plugin bumped) both pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
) Closes #1978 ## Summary Every hook in this marketplace sources `lib/hook-utils.sh`, and buffering the hook payload spawned three external processes to do work bash can do in-process. On Windows Git Bash, where process creation is `fork()` emulation, each spawn costs roughly 140 ms — paid on every tool call, in all 16 plugins that carry the library. ## Fix - **`hook::resolve_read_slice`**: the `awk` float division becomes fixed-point shell arithmetic, printing the same three-decimal form `read -t` is given. `printf -v`, not `$( )`, because a command substitution forks the shell even for a builtin — the fork is the cost being removed. - **`hook::buffer_stdin`**: `printf | tr -d '\r'` becomes `${input//$'\r'/}`, and the post-loop `jq -e .` validity probe is skipped when `hook::json_complete` already parsed the identical CR-stripped buffer with jq inside the read loop. `json_complete` returns non-zero both for an incomplete buffer and for absent/broken jq, so the flag is set only on its success path and the jq-absent fail-open is untouched. - **New `hook::jq_fields`**: extracts several fields from one payload in a single jq process, for hooks that read two or three fields from the same envelope and currently pay a fork plus an exec for each. It uses `// ""` rather than `// empty` so an absent field keeps its slot instead of silently shifting every later index onto the wrong filter, reads NUL-separated values through a process substitution (command substitution strips NUL), and strips CR **after** the read — the Windows jq build writes stdout in text mode and expands every LF it emits to CRLF, so a value cleaned inside jq arrives dirty anyway. No hook call sites change in this PR. The plugins that read a second field already gate it behind an early exit or a telemetry probe, so converting them would add work on the common path; the batch helper's win is in the guardrails git guards, which read `.tool_input.command` and `.tool_name` unconditionally — and those files are in flight in #1974. The helper ships now because the lib sync gate makes every library change cost a version bump in all 16 carrying plugins; adding it later would pay that a second time. ## Verification **Measured, quiet box, 15 alternating pairs** of the same `block-dangerous-git` invocation against each library version (alternating so machine-load drift hits both arms equally): | lib | mean | min | max | | --- | --- | --- | --- | | `main` | 1672 ms | 1316 ms | 2443 ms | | this branch | 1401 ms | 1120 ms | 1760 ms | ~270 ms per invocation, and the slow tail shrinks with the mean. That is less than the 3 × 140 ms the spawn-count model predicts; the measured number is the one to trust. **Gates run locally:** - `lib/hook-utils.test.sh` — new coverage for the slice format (including the fallbacks a non-numeric bound and a `0.000` quotient must take) and for `hook::jq_fields` (multi-line and CR-carrying values, absent-field slot retention, unparsable payload, no-filter call, non-string values). The two `buffer_stdin` timing assertions that fail intermittently here fail the same way on `main` (1–3 failures per run on both sides) — they are wall-clock-ceiling tests on a loaded Windows box, the same class as the ceilings tracked for `block-noncanonical-commit.test.sh`. - `plugins/guardrails/hooks/block-dangerous-git.test.sh` — the black-box hook contract suite, run serially (never concurrently: its wall-clock assertions fail spuriously under parallelism). - `scripts/sync-hook-utils.sh --check` — all 16 plugin copies match. - `scripts/sync-hook-utils.sh --check-bump origin/main` — every carrying plugin bumped. - `scripts/check-changelog-parity.sh --check-bump origin/main` and `--check-order`. - `scripts/check-shell-portability.sh --paths`, `shellcheck -x`, `shfmt -d -i 2`, `markdownlint-cli2`, `scripts/check-manifest-duplicate-keys.py`. **Fresh-docs mandate**: no WebFetch was required for this change and none was performed. The edit is internal implementation of a shell library — it touches no hook contract surface, no manifest field beyond the mechanical `version` bumps the sync gate itself demands, and no documented harness behavior. The 16 touched manifests are version lines only. ## Related - Refs #1974 — carries the guardrails PowerShell false-positive fix and touches `plugins/guardrails/CHANGELOG.md` and `plugin.json`. Both PRs bump guardrails to `0.19.1`, so whichever merges second needs a one-line rebase onto `0.19.2`. - Refs #1975 — adds the Windows CI job for `lib/hook-utils.test.sh`; it is the coverage that would have caught a Windows-only regression in this file. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #1973
Summary
ps::might_invoke_git's call-target branch matched any quote character after&or., so& "C:\tools\publish.ps1"— the ordinary PowerShell script-invocation idiom, carrying nogittoken and a compile-time constant path — routed to the fail-closed sink and was refused by a git guard. Both quote styles and the dot-source form were affected, and because the predicate is shared, the same command false-blocked twice: once fromblock-dangerous-git, once fromblock-no-verify.The branch now matches only a genuinely computed target: a bare variable or subexpression (
& $tool,& (…)), or a double-quoted string that interpolates (& "$tool",& "C:\tools\$ver\x.exe").Per PowerShell
about_Quoting_Rules, a$-free double-quoted string and any single-quoted string are compile-time constants, so such a target is statically decidable as non-git.No fail-open
This is the risk that mattered, so it is covered in both directions:
& 'git' …,& "git" …, and& "C:\Git\cmd\git.exe" …are still caught by name.& "$tool" commit.Regression cases for both directions are checked in on both guards' suites (13 new cases).
Also in this change
--%/ subexpression / script-block / here-string regardless of which of the four sink triggers actually fired, so "remove the unparsable construct" was unactionable when the trigger was a launcher or a computed target.ps::classify_git_commandnow records the trigger inPS_SINK_TRIGGERand each message prints remediation specific to it.powershell-unparsabletoken that hid the false-positive rate.One implementation note: the shared operator prefix is spelled out in each predicate rather than concatenated in from a variable. Mixing an unquoted variable with adjacent literal regex text in a
[[ =~ ]]pattern is version-sensitive, and a predicate that quietly stops matching fails OPEN. The two named functions are the seam that prevents drift.Verification
858 tests pass across the five affected suites, shellcheck clean:
block-dangerous-gitblock-hook-bypassblock-noncanonical-commitblock-no-verifyblock-convention-violationBefore the fix, all five constant-target shapes exited 2 on a live repro battery; after, all exit 0 while every must-block shape still exits 2.
guardrails0.19.0 → 0.19.1.Related
20260807-182002-guardrails-block-dangerous-git-ps-call-op-false-positive, produced by aplugin-quality:auditrun on a consuming project.& $tool reset --hard(bare variable, no other construct) is allowed and is the documented A2b residual, not part of this false-positive class — nothing routes it to the sink.