fix(repo-hygiene): close the three HIGH clean-skill audit findings - #3887
Conversation
|
Claude finished @kyle-sexton's task in 5m 56s —— View job Code review complete
|
|
Claude finished @kyle-sexton's task in 3m 9s —— View job Security review complete
No security issues found. This PR is net security-positive: it closes a guard-registration gap (the Specifically checked and found sound:
No CRITICAL/IMPORTANT/SUGGESTION findings to report. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: aa1b6d8881
ℹ️ 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".
|
Last security-reviewed head: |
|
Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
The `clean` skill's destructive guard registered against `Bash` alone while its own patterns carried a PowerShell spelling (recursive `Remove-Item`), so that pattern was unreachable and on a PowerShell host the guard was absent rather than partial. Register `Bash|PowerShell`, the spelling every other guard in this marketplace uses; a PowerShell tool call carries its command in the same `.tool_input.command` field, so the guard script itself needed no change. Both audit scripts capped the pull-request lookup at 200 and suppressed its errors. That lookup is the only mechanism that detects a squash merge, so a short or missing map did not soften a verdict, it inverted one: a branch whose work had landed lost its evidence and reported as unmerged. `git-branch-audit.sh` and `git-stash-audit.sh` now share one `clean_pr_map` helper that raises the cap, detects truncation by equality against the requested limit (`gh pr list` has no unlimited sentinel and rejects `--limit 0`), and reports `PRCount:`, `PRDataTruncated:`, or `PRDataUnavailable:`. Both sites are fixed; fixing one would have left half the bug. Step 0 resolved a repository root with a bare `git rev-parse --show-toplevel` on a path documented as reachable from anywhere, ending the run outside a repository. The tiers that need a root now resolve it themselves, guarded. The `allowed-tools` frontmatter claimed the six mutating scripts stay behind the destructive guard. They do not: the guard matches command shapes and matches none of them, nor `git branch -D`, nor `git push --delete`. The claim is corrected rather than the guard extended; its best-effort posture is deliberate and whether coverage should grow is a separate human-gated decision (#3852). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…uard Registering the guard on the PowerShell tool left it with no acknowledgement path there: the only documented ack, the Bash prefix `CLEAN_GUARD_ACK=1 <command>`, is not something PowerShell can execute, so a confirmed Remove-Item -Recurse, git reset --hard, or git stash drop was blocked with no way through short of the kill switch. The guard now accepts `$env:CLEAN_GUARD_ACK=1; <command>` on the PowerShell tool. The ack is a deny-to-allow widening, so each spelling counts only on its own tool and only as the leading statement with the literal value 1. The test asserts the accepted spelling against a live pwsh when one is on PATH, and a bypass corpus (comment, quoted string, after the command, later pipeline segment, falsey or other value, wrong tool) stays blocked. Section 4.3 of SKILL.md now tells the reader to act on the stash audit's PR-map status line the way section 4.2 does for the branch audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…sh ref
The destructive guard's ack dispatch treated every tool_name other than
PowerShell as Bash, so a third shell tool added later would have inherited the
Bash prefix `CLEAN_GUARD_ACK=1 <cmd>` as an unblock nobody granted it. Nothing
was open today: a bare destructive command already exited 2 under every
tool_name, and pwsh cannot execute the Bash prefix at all. The dispatch is now
default-deny on tool_name, Bash gets the prefix spelling, PowerShell gets the
$env: spelling, anything else and a missing tool_name get no ack path, and the
block reason for those says so instead of naming a spelling the tool cannot
run. Blocking behavior is byte-identical for every tool.
The documented PowerShell stash drop used a bare `stash@{0}`, which pwsh reads
as splatting syntax: git reported "unknown switch `e'" and the stash count did
not change. Single-quoted it drops correctly. Fixed in SKILL.md and in the test
corpus; the Bash lane needs no quoting and is untouched.
Tests extended with 42 tool-gating cases (Foo, powershell, Powershell,
"PowerShell ", " Bash", bash, Bash2, "123", "null", empty string, JSON null,
JSON 123, and an absent key) asserting no ack path and unchanged blocking, plus
live pwsh execution of the quoted and unquoted stash selectors against a real
stash fixture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
House style bans em dashes in prose written here. Three helper-signature comments added by the previous commit used them; reworded, no behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…p-file create Remove #3852 from the clean skill body (tracking stays in the changelog) and treat failure to create or write the PR map file as PRDataUnavailable so an invalid TMPDIR cannot report a complete map with no rows. Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
aa1b6d8 to
eb9b992
Compare
The lint lane rejects `unparseable` in the branch-audit tests and the git-branch-cleanup output-contract line. Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
The manifest description already names Bash and PowerShell; regenerate the README options block so the plugin-options docs check matches. Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
…y deletion batch (#3900) Closes #3853 ## Summary Makes recovery from a wrong branch deletion mechanical. `git-branch-audit.sh` now reports every branch's tip commit as a structured `Tip:` field and writes all tips to a durable capture file; a new `git-branch-delete.sh` becomes the git tier's only sanctioned deletion path and refuses to delete anything unless that capture exists, covers every branch in the batch, and still matches each branch's live tip. The value is in the ordering: the tip is recorded, pinned, and ledgered before the destructive step, the destructive step itself is an atomic compare-and-delete on the recorded tip, and the test restores deleted branches from the capture alone after a `gc --prune=now`. ## Fix **Where the capture lives and why it survives.** `<git-common-dir>/repo-hygiene/branch-tips/<utc-stamp>-<pid>.tsv`, i.e. the main checkout's `.git/`, even when the audit ran in a linked worktree (`--capture-file PATH` overrides). `.git/` is untouched by `tree` resets and by linked-worktree removal, and is findable by someone who knows only the repository. The path convention is documented in `context/git-branch-cleanup.md` (§4.2 and the new §4.8) so recovery needs no memory of how the run was invoked. **What is captured.** Per branch: name, tip SHA, tier, PR state, upstream, ahead, behind, commits not on `origin/<default>`, and a UTC timestamp; header lines name the repo, its common dir, the default branch, and the restore command. Rows are written for every branch regardless of verdict, since a verdict can be wrong in either direction. Rows are recognised by shape on both sides (nine tab-separated columns, a commit id in the second, the run's stamp in the last), never by a leading `#`: `#` is a legal first character of a branch name, and a comment heuristic made such a branch count one row short at the seal, failing the whole audit with `TipCaptureError: short write` and leaving no branch in that repository deletable. **Enforcement, and what happens when capture fails (fail closed).** - Audit side: the capture is written to a `.part` file, created exclusively (`noclobber`) so a `<stamp>-<pid>` collision across PID namespaces sharing a mount, or an interrupted run, is refused rather than interleaved into one file (the foreign `.part` is left alone). It is renamed into place only after a shape-based row count on disk equals the rows produced. Any failure (unresolvable common dir, unwritable directory, existing `.part`, short write, failed rename) prints `TipCaptureError: <why>` and no `TipCapture:` path. A branch whose tip cannot be resolved gets `Tip: unresolved` and no row. - Delete side: `git-branch-delete.sh --capture PATH BRANCH...` runs a batch-wide precondition before the first deletion: capture present, readable, correct header, taken in this repository (compared by common dir), every branch has a row, every captured tip equals the live tip, a SAFE-by-ancestry tip is actually merged into `origin/<default>` (the check `git branch -d` used to make, now made before anything is written), and no branch is current/default/protected-pattern/worktree-checked-out; REVIEW needs `--force-review`, PROTECTED and WORKTREE are never deletable. Any refusal prints `Refused: <branch> (<what is missing and how to produce it>)`, `Summary: planned=0 refused=N deleted=0`, exit 3, and nothing is deleted, including the branches that would have passed. **Per-branch vs per-batch, and partial failure.** Preconditions are per batch (all or nothing). In `--apply`, each branch is then processed in order: re-verify the live tip, `git update-ref refs/repo-hygiene/deleted/<branch> <tip>` (the pin: `git-prune.sh --apply` runs `gc` right after deletion and `gc` prunes unreachable objects past `gc.pruneExpire`, so a SHA in a file alone is not a recoverable capture), append to the ledger (`<capture>.deleted.tsv`, beside the capture's real file with symlinks resolved), and only then delete with `git update-ref -d refs/heads/<branch> <tip>`. That delete is a compare-and-delete inside git's ref lock: it refuses (`cannot lock ref ... is at X but expected Y`) when the tip is no longer the captured one. The per-branch re-check closes the window between the batch check and the pin; the conditional delete closes the window between the pin and the delete, which a plain `git branch -D` left open (a tip moved there was deleted and its commit lost to the next prune). A failure in any step before the delete prints `Aborted: <branch> (<why>)` and stops: branches already deleted keep their pin and ledger row, the failing branch and everything after it are untouched, `Summary:` counts `deleted/failed/aborted/untouched`, exit 1. A delete refused because the tip moved also aborts: the branch stays at its new tip, its pin stays (harmless, and it records the tip the audit saw), and the ledger gains a `# not deleted:` note. Safe delete for SAFE by ancestry; force delete for SAFE with a merged PR (squash), LIKELY-SAFE, and forced REVIEW; the delete mechanism is the same atomic form in every mode. Every `Deleted:` line and the closing `Restore:` line carry `git branch <branch> <tip>`. **Ledger path choice.** The ledger follows the capture's real location rather than refusing a capture outside the sink, because `--capture-file` to a writable location is the documented fallback when the sink itself is unwritable; refusing it would remove the only recovery path from a `TipCaptureError`. The pin, not the ledger, is what protects the tip. **Docs.** SKILL.md §4.2 states the precondition; `context/git-branch-cleanup.md` §4.7 routes deletion through the script, documents the atomic delete, the ledger and the pins, and adds §4.8 (restore steps); `context/action-router.md` names the script in the `git` row. The delete script is deliberately not in `allowed-tools` (mutating; the pairing test's allowlist is unchanged and passes). The session guard does not match `git branch -D` (that is #3852), so "only through the script" is enforced by the script's preconditions plus the skill text, not by a hook. **Interaction with sibling #3854 (not implemented here).** A lossy-but-deletable verdict block would sit at the confirmation gate, immediately before `git-branch-delete.sh --apply`; the delete script's tier gate (`SAFE`/`LIKELY-SAFE` free, `REVIEW` behind `--force-review`) is where a new tier would need to be admitted. The two changes touch adjacent but distinct places. **Version.** `0.10.35`. Main is `0.10.33`; open PR #3887 (`claude/3850-repo-hygiene`) takes `0.10.34`. Re-checked against current main and every open head (61 refs) immediately before the latest push: only this branch carries `0.10.35`. `git merge-tree` against #3887 shows textual conflicts in four files: `CHANGELOG.md` (adjacent new entries), `plugin.json` (version line), `git-branch-audit.sh` (header comment block, and the setup block after `CURRENT_BRANCH` where #3887 adds its `clean_pr_map` block and this PR adds the capture setup), and `git-branch-cleanup.md` (the output-contract line). All are additive on both sides. Merge against current main is clean. ## Verification Ran in this container (Linux, git 2.43, ShellCheck 0.11.0, shfmt 3.12.0): - `git-branch-delete.test.sh` (new, 106 cases) and `git-branch-audit.test.sh` (extended, +25 cases): pass. The delete suite demonstrates (a) a dry-run plans and deletes nothing and an apply deletes four branches, one named `#42-hash`, with ledger rows and pins; (b) no `--capture`, unreadable capture, bad header, foreign-repo capture, missing row, moved tip, forged SAFE row on an unmerged branch, mixed batch with one REVIEW branch, protected/current/worktree: each exits 3 with every branch still present, including the deletable siblings; backup-ref failure mid-batch aborts with the blocked branch and the following one untouched; an unwritable ledger aborts before anything is deleted; a symlinked capture puts the ledger beside the real file; (c) after `gc --prune=now`, each deleted branch is recreated with `git branch <name> <tip>` using only the name-to-tip lookup in the capture file, and its content is intact; a control shows the same prune destroys an unpinned tip; (d) both tip-move windows, opened deterministically: a FIFO at the ledger path parks the script, with no reader present, at the ledger append that sits after the pin and before the delete, and the pin appearing is the signal to move the tip. Window B moves the parked branch's own tip (between pin and delete): the delete refuses, the branch stays at the moved tip, the pin records the captured tip, the ledger carries the `# not deleted:` note, and the moved commit survives `reflog expire` plus `gc --prune=now`. Window A parks on a first branch and moves the second branch's tip (after the batch check, before its re-check): the first branch is deleted, the second aborts at the re-check with no pin and no ledger row. - Mutation battery on a scratch copy (each mutation applied alone, suites re-run): remove the apply-time re-check: 3 failures, all window A (stale pin written, ledger row written, wrong abort step); revert the delete to `git branch -D`: 7 failures, all window B, including the branch gone and the moved commit pruned, which reproduces the loss the review demonstrated; restore the `grep -c -v '^#'` seal and the comment-skipping parser: every capture case in both suites fails with `TipCaptureError`, which is the bricking the review reported. Earlier battery on the same tree (drop tip-moved refusal 6, drop the pin 9, drop missing-row refusal 1, skip refused branches 17, ignore ledger write failure 1, audit stops printing `Tip:` 3, audit hides `TipCaptureError:` 2, audit writes no rows 9) unchanged. No surviving mutation. - `scripts/affected-tests.sh --run` in four shards, foreground: shards 0/4, 2/4, 3/4 exit 3 (all shell suites passed; the NOT RUN entries are other ecosystems). Shard 1/4 exit 1 solely from `plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh` (2/24 process-budget cases), reproduced identically on a clean `git archive origin/main` export, so pre-existing. The audit suite ran in shard 1, the delete suite in shard 2, `allowed-tools-pairing.test.sh` in shard 1; all passed. - `shellcheck -x` and `shfmt -d` clean on the four changed shell files. `check-purged-em-dashes.sh --check` clean; no em dashes in added lines (byte check on the diff). - `check-changelog-parity.sh`: `--check`, `--check-order`, `--check-bump origin/main`, `--check-preserved origin/main` all exit 0. - Exec bit: `git-branch-delete.test.sh` was committed `100644` and failed the CI `lint` job's exec-bit gate on `0e701107a`; it is `100755` from `f6f87d91e` on (`git ls-tree` confirmed). CI on the current head (`ae18b8433`): `lint`, `test-linux (0..3)`, `hook-utils`, `changes`, `managed-files-guard`, GitGuardian and `ci-status` all success; `test-windows` and the review lanes skipped (draft). ## Related - Refs #3346 (parent, gap G1 first half) - Refs #3854 (sibling: lossy-but-deletable verdict; not implemented here, interaction noted above) - Refs #3852 (guard coverage for `git branch -D`; not implemented here) - #3887 (open, same plugin, takes `0.10.34`; expect the textual conflicts listed above) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
…ing them when no telemetry sink is set (#3913) Closes #3862 ## Summary Guard decisions left the process only through `HOOK_TELEMETRY_SINK`, which is inert unless an environment variable names an executable. On an ordinary install every decision was discarded as it was made, so "why was this denied", "has it denied this all along", and "did the guard run at all" had no evidence to answer from. New `plugins/disk-hygiene/lib/guard_decision_log.py` appends one JSON object per line to `<CLAUDE_PLUGIN_DATA>/guard-decisions/decisions.jsonl`, default on, no configuration. `destructive_guard.py` records every branch that reaches a verdict; `guard_launch_monitor.py` records the did-not-run state the guard structurally cannot write about itself. Version taken: **0.23.0**. Verified free against current `main` (`6db96637d`, 0.21.9) and against the head of every open PR at the moment of opening: #3880 claims 0.21.10, #3783 claims 0.22.0, and #3851/#3887/#3900/#3779 leave the manifest at 0.21.9. 0.23.0 is strictly greater than all of them, so it cannot collide under any merge order; the 0.22.x gap is what `--check-order` explicitly reads as correctly ordered. ## Fix **Where the record lives, and why it survives.** Under the plugin's own persistent data root, resolved by the guard's existing `resolve_authorized_data_root()` (the `--authorized-data-root` / `--plugin-root` / `CLAUDE_PLUGIN_DATA` ladder) beside the run records already kept there. Because this plugin is the one that deletes things, the filename was checked against the engine's own discovery hints in `reference/baseline-policy.json`: `decisions.jsonl` and `decisions.previous.jsonl` match none of the 14 bundled name globs (`*.tmp`, `tmp-*`, `tmp_*`, `scratch*`, `*.lock`, `__pycache__`, `*.partial`, `*.crdownload`, `*.tmp.*`, `.claude.json.tmp.*`, `temp_git_*`, `.pulumi-write-test-*`, `.DS_Store`, `Thumbs.db`), so the plugin's own hints never nominate its audit trail. Appending directly rather than writing a temp file and renaming is deliberate for the same reason: an atomic-write staging name would land on `*.tmp.*`, which is a bundled hint. **What is recorded.** `schema_version`, `timestamp` (UTC, milliseconds), `hook`, `decision`, `rule`, `tool`, `mode`, `command`, `reason`. `decision` is `allow` / `ask` / `deny` / `none` (ran, issued no `permissionDecision`) / `not-run`. `rule` names the branch that fired, so `kill-switch-disabled-apply` is distinguishable from `not-exact-engine-command`: two different answers to "why". `command` is the input that drove it and `reason` is the exact text the host was given, so the record and the host cannot disagree. Both are clipped to 400 characters, which keeps it a record of the decision rather than a copy of the payload and keeps every line short enough that concurrent hook processes appending to the same file do not interleave. **Bounded, enforced.** The live file rotates to `decisions.previous.jsonl` at 1 MiB via `os.replace`, so the record occupies at most about 2 MiB forever with no operator pruning. The bound is checked from the offset the append already returns (`handle.tell()` in append mode), so enforcing it costs no extra syscall. **Cost.** Measured with `strace -f -e trace=clone,clone3,fork,vfork,execve,openat,write` against a detached worktree of `origin/main` at `6db96637d`, five invocations per arm plus a warm-path detail run: | Path | Before | After | | --- | --- | --- | | defer (a Bash command not naming the engine, the always-on branch) | 1 `execve`, 1 `clone3` | 1 `execve`, 1 `clone3`, 0 record syscalls | | decision (deny), warm data root | 1 `execve`, 1 `clone3` | 1 `execve`, 1 `clone3`, 1 `openat` + 1 `write` | | decision (deny), first write of an install | 1 `execve`, 1 `clone3` | plus 1 failed `openat` and 1 `mkdir` | The single `clone3` is `CLONE_THREAD`, the existing watchdog thread, not a process. **The process and exec census is unchanged on every path.** The plugin-level defer branch, which is what this always-on hook takes for work unrelated to disk-hygiene, writes nothing at all and is byte-for-byte the path it was. Wall clock over 40 invocations per arm, alternated twice, moved inside run-to-run noise on this host (52 to 58 ms both before and after, the sign of the difference changing between repetitions), which is why the syscall census rather than a duration is the figure cited. **Failure behavior: the verdict never changes.** Two boundaries, both load-bearing. `guard_decision_log.record` returns a bool and catches `BaseException` around the whole write. `_record_decision` in the guard wraps its own call, because the data root and mode are resolved in the argument list, outside `record`'s protection, and one of its call sites is `main`'s own `except BaseException` handler, where a raise would reach the interpreter's default handler: exit 1, which PreToolUse treats as non-blocking, so the command the guard just denied would run. Every record call is made after the verdict has been emitted, and its result is discarded. **What is deliberately not recorded.** The plugin-level defer (hot path, and not a decision anyone reconstructs later). The watchdog expiry path: that callback runs while the main thread is presumed wedged inside a filesystem call and stays syscall-free for exactly that reason, so a write there could hang on the same filesystem. Both are stated in the README rather than left implicit. **Adjacency, stayed out of.** #3861 (the guard's fail-open when no interpreter resolves) is `needs-human`. This change touches the guard's decision branches but not interpreter resolution, and adds no new fail-open path; the `not-run` record makes the fail-open class more visible after the fact without adjudicating it. **Escape hatch.** `DISK_HYGIENE_GUARD_DECISION_LOG` set to `0` / `off` / `false` / `no` turns the record off. Opt-out, not opt-in: any other value, including an absent one, records. ## Verification All runs local and in the foreground; draft CI is not cited as test evidence. - `bash scripts/affected-tests.sh --run --shard N/4`: shards 0, 1, 2 exit **3** (success, with `NOT RUN` non-shell ecosystems), 0 `FAIL` lines each. Shard 3 exits 1 with exactly three `FAIL` lines, all from `plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh` (`process budget: the trace probe actually counted something`, `process budget: a one-install report costs at most 26 process creations`). **Reproduced unchanged on a clean detached worktree of `origin/main` at `6db96637d`**: same suite, same 2 cases, exit 1. Pre-existing, not this change. Every changed file maps to at least one suite; the three docs/manifest files resolve through the recorded no-suite allowlist. - Direct suites: `hygiene.test.sh` RC=0 (350 cases), `guard_launch_monitor.test.sh` RC=0 (28 cases), `run-python-hook.test.sh` RC=0, `test_guard_decision_log.py` + `test_hook_telemetry.py` RC=0 (15 new cases). - All four parity modes green: `--check`, `--check-order`, `--check-bump origin/main`, `--check-preserved origin/main`. - `scripts/run-ruff.sh check plugins/disk-hygiene`: all checks passed. `format --check`: my four touched/new Python files are clean. Five files remain unformatted in this plugin (`killswitch_config.py`, `hygiene.py`, `guard_launch_monitor.py:137`, `test_hygiene.py:2373`, `test_kill_switch_probe.py:96`); all five are identically unformatted on `origin/main`, so none is introduced here. - Gates run clean: `check-purged-em-dashes.sh`, `check-drive-root-litter.sh`, `check-silent-skips.sh`, `check-discriminating-test-skips.sh`, `check-fixture-git-isolation.sh`, `check-hook-exec-form.sh`, `check-killswitch-hoist.sh`, all RC=0. New test file committed `100755` (verified with `git ls-tree`), the new library `100644` matching its sibling `hook_telemetry.py`. - No shell files changed, so shellcheck and shfmt have nothing to say about this diff. `lib/hook-utils.sh` untouched. **Mutation proof (the new assertions discriminate).** Nine mutations applied one group at a time, each reverted: | Mutation | Caught by | | --- | --- | | rotation call disabled | 3 lib cases (`rotates_at_the_bound`, `discards_only_the_generation_before_last`, `a_failing_rotation...`) | | `_clip` returns text unchanged | `long_command_and_reason_are_truncated` | | `enabled()` hardcoded True | 5 lib subtests + `the_record_can_be_turned_off_without_changing_a_verdict` | | deny rule string collapsed onto the kill-switch rule | `denied_engine_command_is_recorded_with_its_rule_and_input` | | a record added on the defer path | `engine_gate_defer_records_nothing` | | `_record_decision`'s try/except removed | `a_broken_decision_record_never_changes_a_verdict` (record raises), `record_decision_swallows_a_failure_in_data_root_resolution`, and the **pre-existing** `every_call_graph_function_failure_denies_at_exit_2_never_1` for both `resolve_mode` and `resolve_authorized_data_root` | | `_record_not_run` call removed from the monitor | 3 monitor cases | The write-failure proof is `test_a_broken_decision_record_never_changes_a_verdict`, which drives all five verdict shapes (`allow`, `ask`, `deny`-by-authority, `deny`-by-kill-switch, and the no-output defer) three times: unsabotaged, against a data root whose parent is a regular file (a real filesystem `OSError` on both the append and the `mkdir` behind it), and with `record` raising `RuntimeError`. All three runs produce the identical verdict list, and the unwritable root is asserted to still not exist afterwards. `test_a_write_failure_leaves_the_deny_exit_status_untouched` pins the same thing end to end through `main`. **Hermeticity fix included.** `run_guard_engine_gate` previously passed `SCRIPT_DIR / "data-root"` as the authorized data root. With records being written, that would have littered the checkout on every test run, so it now takes a per-test temp path, and `GuardTests.setUp` pops an inherited `CLAUDE_PLUGIN_DATA` so a developer's real plugin data directory is never written to by the suite. ## Related - Closes #3862; parent #3347 finding F12. - Sibling #3861 (guard fail-open when no interpreter resolves) is `needs-human` and deliberately untouched; the `not-run` record is what makes that class visible after the fact. - Sibling finding on a false denial: `rule` plus `command` plus `reason` is what answers it from the record instead of by reproduction. - Hook budget convention: `docs/conventions/hook-budget/README.md`, `.claude/rules/hook-budget.md`. Measured share stated in the plugin README's trust-surface record. - Version contention checked against open PRs #3783 (0.22.0) and #3880 (0.21.10). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob --- _Generated by [Claude Code](https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>

Closes #3850
Summary
Four small, independently reversible fixes to
repo-hygiene:clean, bundled because the operating-model contract on #3346 says PR 1 lands them together. Each was a case of the skill promising or implying a safety property the code did not deliver: a guard registered where it could never fire, a pull-request lookup that discarded evidence without saying so, a repository-root resolution that ended the run, and a frontmatter comment naming a gate that does not cover what it claimed.A follow-up commit (
400845c3) answers the fresh-context review: the guard now has an acknowledgement path on the PowerShell tool, section 4.3 consumes the stash audit's PR-map status line, and the shell-portability note below is corrected. A second follow-up (04bbe657) hardens the ack dispatch to default-deny and quotes the PowerShell stash selector, both described under 1c below.Fix
1. The destructive guard never saw PowerShell. The frontmatter hook registered
matcher: "Bash"whiledestructive-guard.shhas always carried a PowerShell spelling (Remove-Item[[:space:]].*-Recurse). ABash-only matcher can never hand the guard a PowerShell tool call, so that pattern was unreachable and on a host whose primary shell is PowerShell the guard was absent, not merely partial. Nowmatcher: "Bash|PowerShell". The spelling was verified against the local marketplace rather than assumed: it is whatplugins/guardrails/hooks/hooks.json,plugins/disk-hygiene/skills/clean/SKILL.md,scripts/check-hook-exec-form.test.shanddocs/conventions/hook-budget/README.mdall use. A PowerShell tool call carries its command in the same.tool_input.commandfield Bash uses (plugins/guardrails/hooks/block-dangerous-git.shreads exactly that).1b. The guard accepts a PowerShell-valid acknowledgement (review follow-up). Registering the guard on the PowerShell tool exposed a gap: the only documented ack, the Bash prefix
CLEAN_GUARD_ACK=1 <command>, is not something PowerShell can execute (pwsh -NoProfile -Command 'CLEAN_GUARD_ACK=1 Write-Host hi'exits 1, "term 'CLEAN_GUARD_ACK=1' is not recognized"), and the PowerShell-valid$env:CLEAN_GUARD_ACK=1; Remove-Item -Recurse -Force objstill exited 2 from the guard. So a confirmedRemove-Item -Recurse,git reset --hard, orgit stash dropon the PowerShell tool had no ack path short of the kill switch, and the test case "ack prefix works on the PowerShell tool too" asserted a spelling PowerShell cannot run. The guard now readstool_nameand accepts, on the PowerShell tool only,$env:CLEAN_GUARD_ACK=1; <command>: optional leading whitespace, the$env:CLEAN_GUARD_ACKassignment with the literal value1(bare or quoted), then the;that ends the statement, and nothing else before it. The Bash prefix stays Bash-only. Each spelling is a real assignment only in its own shell (Bash treats$env:CLEAN_GUARD_ACK=1;as a failed command lookup and runs the rest anyway), so the guard accepts it only there. The block reason names the spelling for the tool that was blocked.SKILL.mddocuments both spellings in the guard paragraph and in the stash-drop instruction.1c. Tool gating hardened to default-deny, and the PowerShell stash selector quoted (second review follow-up,
04bbe657). Two bounded changes, neither of which closes a live bypass.Tool gating. The ack dispatch was written as "PowerShell gets the
$env:spelling, everything else gets the Bash prefix", sotool_nameabsent,"",null,123,powershell,Powershell,"PowerShell "andFooall took the Bash path and the Bash prefix counted as an ack there. Nothing was open: a bare destructive command exited 2 under every one of those, and PowerShell cannot execute the Bash prefix anyway. The problem was future-facing, a third shell tool added later would silently inherit an ack path nobody chose to give it. The dispatch is now default-deny ontool_name: the Bash prefix only when the tool is exactlyBash, the$env:spelling only when it is exactlyPowerShell, and any other or missingtool_namegets no ack path at all. Blocking is byte-identical for every tool; the block reason for an ungated tool now says there is no acknowledgement path instead of naming a spelling that tool cannot honour. The test corpus gains 42 gating cases (Foo,powershell,Powershell,"PowerShell "," Bash",bash,Bash2, string"123", string"null", empty string, JSONnull, JSON123, and an absenttool_namekey), each asserting that neither ack spelling unblocks, that a bare destructive command still exits 2, and that a benign command still exits 0. Mutating the dispatch back to the old "not PowerShell" form kills 15 of the new assertions, and leaves the blocking assertions green, which is the correct signature for a grant removal rather than a bypass fix.Stash selector quoting. The documented PowerShell drop and the matching test payload used a bare
git stash drop stash@{0}. Underpwshthat is splatting syntax, git receives a mangled argument and reportsunknown switch `e', and the stash count does not change, so the documented example did not do what it said. Verified live: unquoted exits 129 with two stashes still present,git stash drop 'stash@{0}'exits 0 and drops one. The ack itself always propagated correctly; only the selector needed quoting, and only on the PowerShell lane. Fixed inSKILL.md§4.3 and in the test corpus, with the livepwshrun now part of the suite (guarded bycommand -v pwsh, skipping cleanly without it). The Bash lane needs no quoting and is untouched.context/git-branch-cleanup.mdcarries no stash example, and a sweep forstash@across the plugin found no other PowerShell-context occurrence.Out of scope and unchanged, per the review: the ack regex itself (a 61-payload attack pass found no bypass) and the guard's destructive-command coverage (#3852, which also records the unmatched
git stash drop;).2. The pull-request map was silently truncated, at both sites.
git-branch-audit.shandgit-stash-audit.sheach rangh pr list --state all --limit 200with errors suppressed on both the lookup and its parsing. That lookup is the documented mitigation for squash merges, whichgit branch --mergedstructurally cannot see, so a short or missing map did not soften a verdict, it inverted one: a branch whose work had landed lost the only evidence that it landed and reported as unmerged or needing review. Worse, an unauthenticated or missingghproduced an empty map indistinguishable from a repository with no pull requests.Both sites now share one
clean_pr_maphelper inscripts/lib/clean-common.shthat:--limit(per the parent's recorded correction,gh pr listhas no unlimited sentinel and rejects--limit 0), overridable withCLEAN_PR_LIST_LIMIT;PRDataTruncated:;PRCount: <n>for a usable map, soPRCount: 0is a real answer;PRDataUnavailable: <why>whenghorjqis missing, the call fails, or the output is not a PR array, and emits no count in that case, so a failed lookup cannot be read as an empty repository.Fixing only the branch-audit site (the one the audit reported) would have left half the bug, so the stash-audit site is fixed with it. Section 4.3 of
SKILL.mdnow tells the reader to read the status line first and surface it in the confirmation gate, worded the way section 4.2 already did for the branch audit (review follow-up).3. An unguarded repository-root resolution aborted the menu path. Step 0 ran a bare
REPO_ROOT=$(git rev-parse --show-toplevel)on a path the skill documents as reachable from anywhere; outside a repository that exits 128 and takes the run down with it. Step 0 now resolves no root at all, and the tiers that need one (§4 git, §6 tree) resolve it themselves with2>/dev/nulland stop that tier on an empty result. The bundled scripts already self-resolve and printError: not a git repository.4. The frontmatter claimed guard coverage that does not exist. The
allowed-toolscomment said the six mutating scripts "stay behind the PreToolUse destructive guard and the permission flow". They do not: the guard matches destructive command shapes and matches none of those scripts, norgit branch -D, norgit push --delete. Withholding the grant, so the permission flow and the confirmation gate apply, is the actual mechanism. The claim is corrected, the guard is not extended. The guard's header honestly declares it a best-effort net rather than a security boundary with accepted gaps; that posture is preserved and pointed at #3852, which owns the threat-model decision. The same overstatement inallowed-tools-pairing.test.sh's header comment and inplugin.json'suserConfigdescription is corrected too.Manifest bumped to
0.10.34with a matching CHANGELOG entry. Re-verified free at04bbe657:origin/mainis atbf24e0e5carrying0.10.33, and no other open pull-request head claims0.10.34(all 28 other open heads were checked; the highest is0.10.33).Verification
Every acceptance criterion is met. Stated per criterion, with the commands that show it:
destructive-guard.test.shasserts both halves: the registered matcher parsed out ofSKILL.mdreachesPowerShell, and threeRemove-Item -Recurse -Forcespellings driven through atool_name: "PowerShell"payload exit 2. Asserting only the verdict would have passed with the bug fully intact, since the guard script was never the broken part.$env:CLEAN_GUARD_ACK=1;spellings (bare,"1",'1', spaced) exit 0 on the PowerShell tool. Whenpwshis on PATH the test executes the accepted spelling for real and asserts the value reaches the following command (ack=1), and asserts the Bash prefix exits 1 under pwsh.Foo,powershell,Powershell,"PowerShell "," Bash",bash,Bash2,"123","null", the empty string, JSONnull, JSON123, and an absent key: neither ack spelling unblocks, a bare destructive command still exits 2, a benign one still exits 0, and the block reason for an ungated tool contains no ack spelling at all.ForEach-Object { ... }), as the second statement, value0,'',$null,10, no;separator, `pwshagainst a two-stash fixture, now in the suite:git stash drop stash@{0}exits 129 withunknown switch `e'and both stashes still present;git stash drop 'stash@{0}'exits 0 and one stash is gone.destructive-guard.test.shis untouched and still passes; 131/131 green.clean_pr_map. Tests in both audit suites setCLEAN_PR_LIST_LIMIT=1to reach the boundary without a 100000-row fixture and assertPRDataTruncated:.PRCount: 0with noPRDataUnavailable:for an empty-arraygh, andPRDataUnavailable:with noPRCount:for aghthat exits 1 and for one that exits 0 with non-array output.git rev-parse --show-toplevelexits 128 there, whileresolve-clean-action.shprintsAction: menuand exits 0. The aborting call is gone from that path.allowed-tools-pairing.test.shgains two assertions: one bans the specific overstatement, one requires any mention of the guard inallowed-toolsto state its real scope, so a reworded overstatement cannot slip past.affected-tests.sh --runselects and passes the mapped suitesRevert-sensitivity checked, not assumed. Each new contract was proven to fail when its fix is undone:
matcherto"Bash"→FAIL: [23] hook registration reaches the PowerShell tool — expected PowerShell in: - matcher: "Bash";SKILL.mdtoHEAD→ all three newallowed-tools-pairingassertions fail, includingunguarded '$(git rev-parse --show-toplevel)' ends the run outside a repo: skills/clean/SKILL.md:122;1to2(5 failures, the five accepted spellings), the start anchor dropped from the PowerShell regex (5 failures, the comment, quoted-string, and second-statement bypasses go green), the tool check removed so the PowerShell spelling counts on every tool (4 failures), and the guard as committed at7d382d31before the first follow-up (7 failures, including the replaced case that used to assert the Bash prefix on PowerShell);04bbe657dispatch back to "PowerShell gets$env:, everything else gets the Bash prefix" →15/131 checks failed, every one of them a new gating assertion (13 "Bash ack prefix grants nothing" cases plus the two block-reason assertions). The "bare destructive still blocked" and "benign still allowed" cases stay green under that mutation, which is the expected signature: the old dispatch handed out an ack nobody chose, it did not fail open.Commands run, in the foreground, with actual results:
bash scripts/affected-tests.sh --run --shard <i>/4for legs 0 through 3, run one after another (the unsharded run exceeds the tool ceiling; the legs are the same selection partitioned four ways). Legs 0, 2 and 3 exit 3 (success, with 14NOT RUNnon-shell suites delegated to their own lanes). Leg 1 exits 1 on a single pre-existing failure in a file this PR does not touch:plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh, 2 of 24 process-budget cases, and the same 2 fail identically on a cleangit archive origin/mainexport atbf24e0e5(measured -1 — the pid-stamped PS4 did not reach the traced shell).destructive-guard.test.shruns in leg 1 and passes.scripts/check-shell-portability.test.shandplugins/desktop-notification/hooks/desktop-notification.test.sh, both intermittent pre-existing failures noted in earlier runs, passed in this one.bash plugins/repo-hygiene/skills/clean/scripts/destructive-guard.test.sh→All 131 checks passed.(was 76 before this follow-up), including the six livepwshcases.bash plugins/repo-hygiene/skills/clean/scripts/git-branch-audit.test.sh→OK: git-branch-audit.sh tests passed(29 cases).bash plugins/repo-hygiene/skills/clean/scripts/git-stash-audit.test.sh→OK: git-stash-audit.sh tests passed(19 cases).bash plugins/repo-hygiene/scripts/allowed-tools-pairing.test.sh→All allowed-tools pairing checks passed.bash scripts/check-changelog-parity.sh --check,--check-order,--check-bump origin/main,--check-preserved origin/main→ all four exit 0 at04bbe657.bash scripts/check-purged-em-dashes.sh→ no em dashes.shellcheck -xandshfmt -dover all changed shell files → clean, exit 0.Related
--limit 0correction used above.git branch -Dandgit push --delete. Deliberately out of scope here: extending the guard would pre-empt that threat-model decision. (Noted while testing, for that issue:git stash drop;with the;directly afterdropis not matched by the current pattern, which requires whitespace or end of line there; pre-existing, untouched here.)CHANGELOG.mdand.claude-plugin/plugin.json; this PR touches only its own entry and bumps to0.10.34.plugins/claude-ops/skills/plugins/scripts/cache-content-check.test.shfails on a cleanorigin/mainexport;plugins/desktop-notification/hooks/desktop-notification.test.shandscripts/check-shell-portability.test.shfail intermittently for causes not yet identified and should get their own issues.🤖 Generated with Claude Code
https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Generated by Claude Code