Skip to content

fix(guardrails): PowerShell guard differential + budget/flag-keyed refusals (#4682) - #4715

Merged
cursor[bot] merged 7 commits into
mainfrom
cursor/4682-ps-guard-differential-37e9
Sep 28, 2026
Merged

cursor[bot] merged 7 commits into
mainfrom
cursor/4682-ps-guard-differential-37e9

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Closes #4682

Summary

Adds check-guardrails-ps-differential.sh (613-command corpus) proving branch never allows what base refuses. Lands scoped fixes: sink-attempt budget exhaustion refuses (no allow token); flag-keyed here-string comment refusals for convention/noncanonical commit guards.

guardrails → 0.37.5 (serialize after #4684 / 0.37.4).

Notes

Unfiled bypass remains on both main and branch (git reset --hard + nested here-string shape) — out of this issue's scope; see implementer note.

Test plan

  • Differential self-test 21 cases; full differential ~3m (per implementer)
Open in Web Open in Cursor 

cursoragent and others added 3 commits September 27, 2026 22:45
)

scripts/check-guardrails-ps-differential.sh <base-ref> extracts the base
ref's plugins/guardrails with git archive and runs a corpus of PowerShell
commands, as stdin payloads, through both trees: each of the five
blocking consumers directly, the run-guards.sh Bash row with each tree's
own hooks.json argv, and block-dangerous-git under all 31 non-empty
ps-unparsable-* token subsets for commands the base refuses token-free.
It exits 1 on any cell where the base exits 2 and the working tree does
not, and prints the cell table.

Every cell pins CLAUDE_PLUGIN_ROOT to its own tree and clears inherited
plugin options, so an operator's environment cannot hand both arms one
library or decide a cell. The corpus is 582 commands harvested from the
guard suites (--harvest) plus 31 hand-written ones.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
… budget (#4682)

block-dangerous-git exited 0 once _ps_sink_attempts passed 4, with the
remainder of the command never read. Under all five ps-unparsable-*
tokens a command with five sink triggers and git reset --hard passed,
and so did a granted shape whose blanking makes no progress, such as
$a=& 'git reset --hard' under ps-unparsable-dynamic-invocation alone.

The loop now re-classifies after the fifth blank and refuses at the top
of the next round (form powershell-unparsable-budget-exhausted), and no
allow token clears it. Token-free behavior is unchanged.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
…ag (#4682)

block-convention-violation and block-noncanonical-commit refused a
commented here-string opener only when PS_SINK_TRIGGER was
herestring-comment-char, which the classifier records only when no
other trigger fired. A commit whose opener carried a # behind a { or an
iex reported that other trigger and both guards deferred. They now test
PS_HERESTRING_OPENER_COMMENT_CHAR, a strict superset that only moves
exits from 0 to 2.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

PR body contract — issue linkage

This PR body does not yet satisfy the issue-linkage contract:

  • Missing a "## Fix" section. State the concrete change and how it addresses the problem.
  • Missing a "## Verification" section. Record concrete evidence the change works (commands, gates, output).
  • Missing a "## Related" section. List related PRs, ADRs, or decision-log entries this PR does not close.

Edit the body and this comment updates itself on the next run.

cursoragent and others added 2 commits September 27, 2026 23:55
Excuse the jq regex class and the fixture sed -i, and mark the differential test executable.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Re-bump guardrails to 0.38.2 after 0.38.1 landed.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot marked this pull request as ready for review September 27, 2026 23:58
cursoragent and others added 2 commits September 28, 2026 00:10
Re-bump guardrails to 0.38.3 after 0.38.2 landed.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
# Conflicts:
#	plugins/guardrails/CHANGELOG.md

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot merged commit 048a17b into main Sep 28, 2026
19 checks passed
@cursor
cursor Bot deleted the cursor/4682-ps-guard-differential-37e9 branch September 28, 2026 00:34
kyle-sexton added a commit that referenced this pull request Sep 30, 2026
…5340)

No related issue: audit follow-up for the guardrails plugin. Every issue
it touches is already closed and several are being reopened by hand, so
no closing keyword is used.

## Summary

An audit of the 499 PRs an unattended agent opened between 2026-09-27
and 2026-09-29 found guardrails defects in six groups: guard gaps that
let a destructive command through, tests that no longer test what they
claim, a non-atomic cache, an undeclared Node requirement, README and
CHANGELOG statements that were wrong, and an unratified performance
claim in PLAN.md. This PR fixes what does not need the owner, and leaves
the owner-reserved questions alone (see Related). Guardrails goes 0.41.9
to 0.42.0.

## Fix

- `block-windows-drive-tmp`: a drive-root `\tmp` is refused on a
usertemp host; `curl`/`wget` short-flag clusters, `--output-dir` and an
option after a bare `-O` are judged; the usertemp mount fallback needs
`usertemp` on the `/tmp` mount's own line. The suite feeds commands on
stdin, so the 9 `/usr/bin` writer cases that were skipped now run.
- `block-root-delete-target` (PowerShell): a delete after LF, CRLF or a
bare CR, or inside a scriptblock, grouping or `$( )`, is judged;
`$env:NAME\subpath` no longer refused as a bare variable (`$env:TEMP`,
`$env:TEMP\`, `$env:TEMP\*` still are).
- PowerShell classifier: one untrusted-reduction flag instead of two, CR
scan skipped when the command has no CR, sink-budget tests rebuilt so
they exercise budget exhaustion.
- `stale-path-verify` and `skill-reference-verify`: a partly written
cache is a miss and is rebuilt; no extra process on the hook path.
- `wsl` regression rows added to the `block-hook-bypass`,
`block-noncanonical-commit` and `block-convention-violation` suites.
- Node on PATH is declared in the README, checked by `/guardrails:setup
check`, and listed in `prerequisites.json`; setup's apply text follows
the reconfiguration convention.
- README: `wsl` since-version, six re-parsing guards, PowerShell
no-token over-blocks, plugin-scoped GitHub MCP matcher, contributor
to-do and tracker asides removed. PLAN.md: the "no further process can
be removed" floor is scoped to the PostToolUse cold path and the goal is
marked a draft.
- CHANGELOG: five released entries corrected in place (0.41.7, 0.40.0,
0.38.11, 0.38.1, 0.37.3), no heading removed. They are named in the new
0.42.0 entry.
- From other groups' requests (extending the 0.42.0 entry, no second
bump):
- README: the exec-form dispatcher rows are dated to 0.41.3 (they said
0.41.0), and each fire is stated as two processes, node and then the
bash it spawns, with the candidate order left to the header of
`hooks/exec-bash.mjs` (hook-launcher).
- `/guardrails:setup check` probes the bash `hooks/exec-bash.mjs`
resolves, not the Bash tool's own bash (hook-launcher).
- PLAN.md says its census rows and floor line were measured under shell
form and not re-measured under exec form, and that no hook or skill
reads it (hook-launcher).
- `exec-bash.test.sh` accepts the first bash on `PATH` (a Homebrew bash)
as well as `/bin/bash` and `/usr/bin/bash`, and the plugin's resolver
test gains the PATH-first cases from `lib/exec-bash.resolver.test.mjs`
(hook-launcher).

## Verification

- `git merge origin/main`: two conflicts, the plugin version and the
CHANGELOG head, resolved by keeping 0.42.0 above the 0.41.9 entry that
#5309 added on main.
- `scripts/check-changelog-parity.sh --check --check-order`: pass.
`--check-preserved origin/main`: all 225 headings preserved.
- `scripts/validate-plugins.sh`: all manifests and the catalog validate.
- `scripts/affected-tests.sh --run --jobs 8`: every guardrails suite
passes (block-windows-drive-tmp, block-root-delete-target,
block-dangerous-git, block-hook-bypass, block-noncanonical-commit,
run-guards, stale-path-verify, skill-reference-verify, setup and the
rest). Three suites fail on this host for missing tools, unrelated to
the change: `scripts/check-html-assets.test.sh` and
`scripts/check-script-contract.test.sh` (htmlhint not installed),
`scripts/hook-census.test.sh` (strace not installed). 25 Node and Python
suites are selected but belong to other lanes.
- `scripts/check-guardrails-ps-differential.sh origin/main`: 613
commands, 0 cells where main refuses and this branch does not. The
scratch under-block differentials for block-windows-drive-tmp (6648
cells, 0 lost) and block-root-delete-target (15823 payloads; the 31
cells that differ are `$env:NAME\subpath` spellings Bash also allows,
plus one backtick-before-CR-CR-LF read as PowerShell reads it) are
recorded in the task notes.
- Baseline differential runs at the two earlier commits and at main: 0
cells lost in all three. Substitution-cap cost on WSL2 Linux: 2000
unquoted `$(:)` took a median 6038 ms; the same 2000 inside single
quotes or a heredoc body took 140 ms, level with a 137 ms control with
no substitution text. No Windows measurement was taken.
- No Windows host and no strace here: Windows behavior is proven by the
test-windows lane after the ready flip, and the spawn ceilings by CI's
ratchet step.
- Cross-group pass, on head `c3111b8d6` after merging origin/main
`0589e13f4` (clean):
- `exec-bash.test.sh` and `exec-bash.resolver.test.sh` pass. With a bash
symlink first on `PATH` outside `/bin` and `/usr/bin`, the old pin fails
(`FAIL: bash was not a real path`) and the new one passes.
- `check-changelog-parity.sh --check`, `--check-order`,
`--check-preserved origin/main` (226 headings) and `--check-bump
origin/main` pass.
- `validate-plugins.sh`, `sync-exec-bash.sh --check` (21 copies) and
`check-skill.sh` on the setup skill (PASS, 0 errors) pass. So do
`coverage-manifest.test.sh` (17 checks), `check-skill-portability.sh`
and `check-shell-portability.sh`. markdownlint, typos and shellcheck are
clean on the changed files.
- `scripts/affected-tests.sh --run --jobs 8` against origin/main: 287
selected suites pass. The same three as before fail on this host for
missing tools: `check-html-assets.test.sh` and
`check-script-contract.test.sh` (htmlhint), and `hook-census.test.sh`
(strace).
- PR #4715's disclosed "`git reset --hard` + nested here-string" bypass:
12 PowerShell nested here-string payloads carrying `git reset --hard`
exit 2 from `block-dangerous-git.sh` on this branch and on main, so no
issue was filed. One Bash probe exits 0 on both: a `-c` operand built
from a command substitution, which is the README's declared `$(…)`
residual.
- Ready pass, on head `600ec5b3c` after merging origin/main `a82943beb`
(clean; main's 7 new commits touch no guardrails path):
- Security review of the PR diff: no findings. Every guard change adds
refusals or matches the Bash lane. Probes still refuse `$env:TEMP\..`,
`${env:TEMP}\.`, a stray `)` or `}` before a delete, a delete after a
keyword block, unclosed levels, and a delete after a JSON `\r` or
`\u000d`. A raw control byte in the payload is invalid JSON, so the CR
re-read keeps the command as first read.
- `check-changelog-parity.sh` (`--check`, `--check-order`,
`--check-preserved origin/main`, `--check-bump origin/main`),
`validate-plugins.sh`, `sync-exec-bash.sh --check`, shellcheck,
markdownlint and typos on the changed files: pass. `exec-bash.test.sh`
and the node resolver test pass.
- `scripts/affected-tests.sh --run --jobs 8 origin/main`: the same three
suites fail for missing tools (htmlhint, strace).
`audit-coverage.test.sh` and `cant-fail-scan.test.sh` also failed in the
8-job run; both pass run alone on this head and on a snapshot of
`a82943beb`, and this PR does not touch their paths.

## Related

Audit: `.work/audit/REPORT.md` (local, not committed). Refs #3683 #3686
#3951 #4118 #4236 #4242 #4247 #4251 #4261 #4390 #4516 #4527 #4528 #4651
#4678 #4679 #4681 #4682 #4683 #4685.

Left for the owner, not implemented here:
- #4390: reopened with a decision packet on the cache design, plus an
addendum on PLAN.md's Done-when (ratify the ceilings, k × S, or
re-measure on Windows first) and `async` keep-or-reverse.
- #4684: reopened with a decision packet on the substitution cap against
the hook-precision rule.
- #5341: a new decision issue for block-root-delete-target scope
(launcher grammar, second PowerShell parser).
- #4118, #4681 and #4683: reopened with ratify-or-reverse packets,
because #4766, #4755 and #5036 took decisions reserved for the owner and
were merged by `app/cursor`. #3683 gets the same packet for #4845's
host-skip call.
- #4679: a packet on whether hook-observability condition 3 admits a
once-per-(session, agent) latch, or the admission is recorded as an
owner-approved exception.
- #3951: a packet on whether to build the heredoc-aware fix now in its
own PR, since the owner's 2026-09-28 comment closing #4811 said more
guard logic is not justified now.

Operator-only steps stay open on #3683, #4236, #4261, #4527, #4678,
#4679 and #4684 (Windows-host measurements and runs, and one interactive
render check).

Found and declared, not fixed: `env -f x rm -rf /` exits 0; PowerShell
`$r = Remove-Item ...`, comma-list and here-string forms remain gaps in
block-root-delete-target. An unmeasured Windows `RUN_GUARDS_PROFILE`
cost stays on #4236.

Cross-group requests:
- claude-ops: `audit_skill_visibility.test.sh` skips its whole
`--installed` contract on any Git Bash host (apply `host_path` to the
`--installed` argument or narrow the probe; correct the CHANGELOG
guess).
- claude-config: `unhobble/SKILL.md` (~L382) and
`unhobble/evals/evals.json` restate the block-hook-bypass exit-code
claim with no verification record.
- planning: `planning/skills/setup/SKILL.md` (~L158) has an unwrapped
line in the apply bullet.
- conventions: `docs/conventions/hook-observability/README.md` condition
3 needs to say whether a once-per-(session, agent) latch counts as a
state transition.
- scripts: add the shapes from the PowerShell differential findings to
`scripts/guardrails-ps-differential-corpus.jsonl`.
- hook-launcher: #5309 (0.41.9) landed the PATH lookup and the visible
notice for an unresolvable bash in `exec-bash.mjs`. Still open: a
missing node needs a launcher design that does not depend on node
(#3708). The README wording and the PLAN.md census note are now in this
PR.
- scripts: the 12 nested here-string `git reset --hard` payloads above
are candidates for the same corpus.

Cross-group requests received, checked against origin/main `0589e13f4`:
- hook-launcher:
- The README exec-form version and two-process shape, the PLAN.md
annotation, the relaxed launcher test pin, the PATH-first resolver cases
and the setup bash probe: applied (see Fix).
- Node declaration: already done here (README Requirements, setup check
item 3, `prerequisites.json`).
- Five owner decisions: #4390 already had its packet. #3683 was already
reopened and now has a ratify-or-reverse packet. #4118, #4681 and #4683
are now reopened with packets. #3686 belongs to hook-launcher.
- ci:
  - #4527 already carries the per-suite questions.
- The `\tmp\x` downgrade follow-up is not filed, because bca5f57 in
this PR restores the block assertion: the helper no longer downgrades a
backslash-led `\tmp`, and the quoted spellings expect 2.
- #3683: #5316's windows-2025 run shows `cloud-bootstrap-plugins`
PASS=79 with no probe skip, so its probe needs no rework. The results,
and #4845's causes marked as hypotheses, are on #3683.
- tracker:
  - #3951 not implemented: owner decision (packet above).
- #4235 not delivered by this PR; it needs its own dispatch. The owner's
comment closing #4817 asks for "a design scoped to #4235", which belongs
in its own PR: its `ps-command.sh` changes overlap this PR's classifier
edits and need the token-subset differential. The tracker posts the
scope comments.
  - #4678: reopened with Windows operator steps (not run here).
- #4679's render confirmation: already an open item with the exact
steps.
- The #4715 bypass: probed, not reproducible (see Verification). Its
payloads go to the scripts group rather than this PR, because the corpus
lives in `scripts/`.
- conventions:
- The setup apply bullet already carries main's scope-matching caveat 2.
- #4679: the condition-3 packet is posted. The wording goes to
conventions after the owner answers.
- source-control: commented on #4247 with #5317's widened matcher.
- performance: PLAN.md is already relabelled and #4390 already carries
both labels. The Done-when and `async` questions are posted as an
addendum.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EugXnFddtpHcY5gTuyEirB

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(guardrails): add a PowerShell guard differential; refuse at the budget cap

2 participants