perf(disk-hygiene): if-gate the PowerShell engine gate on the engine's file name - #3880
Conversation
…s file name Closes #3349. Every PowerShell tool call in every session was still launching the destructive guard to be told it was irrelevant. On a warm interpreter cache that is four execve calls (bash -c, the launcher through its env shebang, bash, the interpreter), no fork, and a 106 KB module import, counted with strace -f; on this Linux host the hook process walls at p50 44 ms against a 2 ms bash -c floor (n = 20 per tool), about 22 spawn-equivalents, the cost class the issue measured as a 2.4 s median on Windows. The Bash entry has carried an if filter on the engine's file name since 0.21.4; the PowerShell entry now carries the same filter for its own tool, so the harness spawns nothing for a PowerShell call that does not name the engine. The 0.21.4 note that a PowerShell filter "must match every subcommand of a compound command" described allow rules, not if. The harness evaluates if through the tool's own permission matcher, and the PowerShell tool's parses the command AST and runs the hook when any statement, pipeline element or nested command matches (Claude Code 2.1.258, preparePermissionMatcher: some over every collected command, case-insensitive glob; an unparsable command runs the hook). A mixed line still reaches the guard and is still denied. No allow/deny decision changes for a call that reaches the guard, and no call the guard would have judged is skipped: the engine-gate mode defers every command that is not _engine_gate_relevant before any deletion spelling is consulted. A/B against a pristine origin/main export over 33 payloads (compound, CR LF, U+2028, BOM, zero-width and near-miss spellings) is identical on exit code, stdout and stderr, and every payload the filter skips is one the pristine gate deferred. The residual the filter cannot see, an engine reached by a link under another name, is the one the Bash lane has accepted since 0.21.4. Tests: the registration-shape test asserts both filters; two new tests assert that a skipped PowerShell call is one the gate defers and that every compound invocation shape is still relevant and still denied; the launcher suite gains a strace census (a warm launch creates no process and execs exactly bash and the interpreter). Each new test was shown to fail under a targeted mutation. README budget accounting and CHANGELOG updated; manifest 0.21.9 -> 0.21.10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…ual class The CHANGELOG and README described the filter's blind spot as "a symlink or hard link under another name." That undersells it: _engine_gate_relevant's marker-free branch identity-checks every word against the bundled engine via os.path.samefile, and its own docstring names Win32 8.3 short names (and trailing-dot/space and ADS-stream spellings) as the same alias class. A "python HYGIEN~1.PY apply" invocation reaches the engine with no literal "hygiene.py" text for the *hygiene.py* permission filter to match, so it is part of the same residual as a link. Reworded both surfaces to name the class (any spelling that reaches the engine without its own file name in the text) with links and 8.3 short names as examples, keeping both surfaces in agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
…ners (#3878) <!-- CURSOR_AGENT_PR_BODY_BEGIN --> No linked issue ## Summary Drop leftover process creations on the hottest Bash paths in this marketplace: the shared hook library every always-on hook sources, the always-on formatter Write/Edit paths (typos, ruff, biome, bash, powershell, go, actionlint, eol, markdown), the always-on desktop-notification Notification path, always-on guardrails verifiers, and CI scanners that used to spawn once per file, per plugin, or per allowlist entry. ## Fix GNU Bash runs command substitution in a subshell even for builtins (Command Substitution, [Bash Reference Manual](https://www.gnu.org/software/bash/manual/html_node/Command-Execution-Environment.html); [Greg's Wiki](https://mywiki.wooledge.org/CommandSubstitution)). Cygwin's `fork` is a non-copy-on-write Win32 `CreateProcess` ([Cygwin User's Guide, Process Creation](https://ftp.cygwin.com/cygwin-ug-net/highlights.html)): "fork will almost certainly always be inefficient under Win32." ### Shared hook library (`lib/hook-utils.sh`, synced to 17 carriers, patch bump) Same `_to` / in-process pattern as #3838, #3732, and #3678: - `hook::json_escape_to` deletes residual C0 bytes with parameter expansion instead of `printf | tr -d` - `hook::emit_channels` writes through `_to` instead of `$(hook::json_escape …)` - Fractional `read -t` landed in bash-4.0-alpha (CHANGES). `hook::read_supports_fractional_timeout` is `BASH_VERSINFO`; no TMPDIR probe file - `hook::notice_once` reads the marker with `read`, creates the directory only when missing, and prunes stale markers once per process - `hook::bash_parse_segments` walks `${cmd:i:1}` instead of `read -N1` from a process substitution, and `$'…'` bodies decode through `ansi_c_decode_to` (`printf -v`) - `hook::repo_root_to` / `hook::repo_relative_path_to` write in this shell so callers skip a leftover capture around git or builtins-only work Isolation `$(source …)` forks are unchanged (#3685). ### typos-format (always-on Write|Edit|NotebookEdit) - Basename via `${FILE##*/}` (plus a backslash trim), not `basename(1)` - `repo_root_to` / `repo_relative_path_to` instead of capture subshells - Directory existence check instead of `$(cd && pwd)` - `command -v typos` is no longer captured; the later exec looks the name up on PATH ### Remaining always-on formatters (ruff, biome, bash, powershell, go, actionlint, eol, markdown) Same leftover class as typos-format, now applied to every always-on formatter that still captured `_to` helpers or spawned `basename` / leftover `cd && pwd`: - `FILE_BASE` is `${FILE##*/}` (and a backslash trim) - `repo_root_to` / `repo_relative_path_to` write in-process - `$(cd && pwd)` canonicalize is an existence check on the path git already answered (ruff, biome, bash-format EditorConfig walk) - Nested `$(normalize_path "$(physical_path …)")` in powershell-format uses the `_to` forms - `command -v ruff|biome|goimports` is no longer captured - markdown-format keeps physical `pwd -P` containment and config discovery; leftover helper-capture and membership dirname on the root-resolution path are gone ### desktop-notification (always-on Notification) - Field extract fuses into `hook::buffer_stdin_to` so completeness and `.notification_type` / `.message` share one jq process - C0 stripping is parameter expansion, not `printf | tr` - `repo_root_to` writes in-process; OSC 9 / BEL use `printf -v`; `terminalSequence` uses `json_escape_jq_to` - `uname` stays so tests can PATH-stub Darwin; git for `repo_root` stays ### guardrails verifiers (always-on PostToolUse / PreToolUse) - `skill-reference-verify`, `stale-path-verify`, and `cli-flag-verify` call `repo_root_to` / `repo_relative_path_to` in-process - `hardcoded-path-check` and `secret-pattern-detection` use `normalize_path_to` instead of leftover `$(hook::normalize_path)` captures - Isolation `$(source …)` forks are unchanged (#3685) ### CI scanners - Orphaned-fixture scan: one `*.test.*` index, cached `evals.json` `files[]`, in-shell ERE escape. Unquoted `\\` matches one backslash (a quoted `'\\'` arm is two chars and leaves `\b` as a word boundary) - Purged-em-dash scan: one `git ls-files -z` with every `:(glob)` pathspec; in-process component-wise attribution so `*` cannot cross `/`. `--list` stdout is byte-identical to origin/main - Cross-plugin source drift: one `find plugins` plus one `sha256sum` of 2+ cluster paths. Discover stdout is byte-identical to origin/main - Discriminating-test-skips / silent-skips: one awk per corpus (`FNR` + `FILENAME`; mawk has no `ENDFILE`) - Hook-exec-form: one jq over every `hooks.json` and one over every `plugin.json` (`input_filename` attributes rows). Unreadable `hooks.json` still fails closed via per-file fallback; unreadable manifests are still skipped Hook-specific leftover-fork work already in flight (#3873, #3872, #3871, #3870, #3869, #3851, #3849, #3779, #3880, #3886) is out of scope here. ## Verification Independent census re-derived spawn counts from `84adf87b` vs `cdb93f61` without inheriting implementer figures. Kernel census `strace -f -e trace=clone,clone3,fork,vfork,execve`; counter over duration; 3 identical trials. **always-on formatters** (this revision vs `84adf87b`): | Hook | clones before | clones after | execve before | execve after | |---|---|---|---|---| | ruff-format no-config skip | 14 | 10 | 4 | 4 | | powershell-format no-settings skip | 23 | 16 | 7 | 7 | | bash-format no-EditorConfig (ShellCheck finding) | 17 | 13 | 6 (1 `basename`) | 5 (0 `basename`) | **guardrails** (this revision): | Hook | clones before | clones after | execve | |---|---|---|---| | skill-reference-verify Write, no skill refs | 18 | 17 | 8 unchanged | | secret-pattern-detection clean Write | 10 | 8 | 4 unchanged | Secret-pattern absolute counts are with `CLAUDE_PLUGIN_ROOT` set (Claude Code always sets it). Without that env the leftover `PLUGIN_ROOT=$(cd … && pwd)` fallback adds one clone on both sides (11→9); the drop of 2 is the same. **CI scanners** (successful execve, exclude ENOENT; earlier commits on this PR): | Gate | origin/main or prior HEAD | HEAD | |---|---|---| | purged-em-dashes `--list` | 478 | 9 | | cross-plugin-source-drift `--check` | 181 | 4 | | discriminating-test-skips | 316 (awk 312) | 5 (awk 1) | | silent-skips | 120 (awk 118) | 4 (awk 2) | | hook-exec-form `--check` | 196 execve, jq 96, tr 96, clones 292 | 7 execve, jq 2, tr 0, clones 9 | `--list` / discover stdout for the two listing gates is byte-identical to origin/main. **Local `scripts/affected-tests.sh --run`:** 153 shell suites passed or were skipped; 14 NOT RUN python/mjs ecosystems (exit 3, expected on this runner). No `FAIL`. Including: `lib/hook-utils.test.sh` PASS=323; bash-format PASS=54; eol-normalizer PASS=54; markdown-format PASS=174; powershell-format PASS=17; cli-flag-verify PASS=92; hardcoded-path-check PASS=118; secret-pattern-detection PASS=86; skill-reference-verify PASS=140; stale-path-verify PASS=108. ruff/biome/go/actionlint behavioral cases skipped here (binaries absent); skip-path and source pins still ran. `session-event-log.test.sh` PASS=53 isolated under the fan-out. **CI on `cdb93f61`:** lint, hook-utils, test-linux (0–3), test-windows, changes, ci-status, and managed-files-guard all succeeded. https://github.com/melodic-software/claude-code-plugins/actions/runs/34066676378 https://github.com/melodic-software/claude-code-plugins/actions/runs/34066676488 ## Related Refs #3838, #3732, #3678, #1979, #3488, #2891. Same leftover-fork class as open PRs #3849 / #3851 / #3869 / #3873 / #3872 / #3871 / #3870 / #3779 / #3880 / #3886 (those stay hook-specific). N/A for a dedicated issue. <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-fdfdc962-be1b-4c9c-9833-3aec57852330?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-fdfdc962-be1b-4c9c-9833-3aec57852330&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
|
Merge-ordering note for the merge lane, from the review of this PR. Open PR #3783 (branch Recording it here rather than in the body because the body edit was interrupted; the substance is unchanged. Generated by Claude Code |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 5m 10s —— View job Code review —
|
|
Claude finished @kyle-sexton's task in 2m 44s —— View job
|
|
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: 5eaba367b6
ℹ️ 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 has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…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>
Keep the PowerShell engine-gate if-filters and preserve guarding when the script path is in a variable. The matcher evaluates collected command nodes, so `$script = '.../hygiene.py'; python $script` missed PowerShell(*hygiene.py*). Sibling filters PowerShell(*python*$*) and PowerShell(*& $*) keep those invocations on the guard. Bump disk-hygiene to 0.23.1 above main's 0.23.0. Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
… test PowerShell now has several matchers (literal path plus variable-based invocations), so the watchdog ceiling test cannot assume exactly two PreToolUse registrations. Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
GNU grep '\\b' word boundaries fail the shell-portability gate. strace
syscall lines already carry a literal '(' after the name.
Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
…ply path already has (#3919) Closes #3859 ## Summary The apply path already processes nested entries bottom-up, so removing a directory and the now-empty directory that contained it happens in one pass. `handoff-verify` lacked those semantics, so a container emptied by removing its last child was not recognized as newly removable in the same round. ## Fix - Share the apply lane's `removal_sort_key` via `emptied_container_order`. One decreasing-depth pass names the inventoried directories the settled (`clear`/`gone`) removals empty. Each is then run through the same categorical checks and reported under `emptied_containers`. Verification still mutates nothing. The approved paths' `clear`/`not_clear` counters (and the CLI exit code) are unchanged. - Compare only the surplus (live children the snapshot did not record) when checking a container. Apply refuses to `rmdir` only when `scandir` still finds an occupant; missing inventoried children are the verify-one-delete-one sequence progressing, not drift. A replaced inventoried child fails its own approved-path verdict and stays out of the settled set. Rebased onto origin/main (not onto #3880). Version is `0.23.2`, stacked above main `0.23.0` so #3880 can keep `0.23.1`. `hooks.json` is untouched; `test_hygiene.py` edits stay in `HandoffVerifyTests` (plus a ruff wrap in `HygieneTests`) so #3880's PowerShell matcher additions in `GuardTests` can still merge. ## Verification - `python3 -m unittest` `HandoffVerifyTests`: 37 tests, including the one-round cascade, verify-one-delete-one, apply/verify agreement, and 60-level termination - `plugins/disk-hygiene/skills/clean/scripts/hygiene.test.sh`: 368 tests OK - `scripts/run-ruff.sh check` and `format --check` on the changed Python - all four `check-changelog-parity.sh` modes vs origin/main - `scripts/affected-tests.sh --run` (the one `block-hook-bypass` FAIL is the suite's symlink case when the worktree itself lives under `/tmp`, reproduced on origin/main, not this change) - mutation: restoring exact child-set equality kills `test_container_survives_the_verify_one_delete_one_sequence`; making `emptied_container_order` never qualify kills the cascade and apply agreement tests ## Remaining work None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Closes #3349
Summary
Every PowerShell tool call in every session was still launching the disk-hygiene destructive guard to be told it was irrelevant. The Bash entry in
hooks/hooks.jsonhas carried"if": "Bash(*hygiene.py*)"since 0.21.4; the PowerShell entry carried no filter, on the 0.21.4 rationale that "a PowerShell filter must match every subcommand of a compound command and would skip this kill-switch guard silently on a mixed line".Where the cost actually goes (measured, not assumed): on a warm interpreter cache the hook spends 0 process forks and 4
execvecalls (bash -c, the launcher through its#!/usr/bin/env bashshebang, bash, python3) plus a 106 KB module import, counted withstrace -f -e trace=clone,clone3,fork,vfork,execveon the real dispatched command string; the one creation is the guard's own watchdog thread. Cold path addschmod,mvand a probe interpreter (6 creations, 7execve). On this Linux host the hook process walls at p50 44 ms (n = 20 per tool, min 43, max 58) against abash -c :floor of 2 ms, about 22 spawn-equivalents. That is a spawn-chain-and-interpreter-start cost, not anything the guard computes, which is why the issue's suggested fixes 1 and 2 (lazy regex table, cachedMIN_PYTHON) would not have bought it back: fix 2 already landed in 0.21.0, and regex compilation is microseconds. The only fix that removes the cost is not spawning.The 0.21.4 rationale carried allow-rule semantics over to
if, where they do not apply. Verified in the installed Claude Code 2.1.258 binary: the hookifevaluator resolves the rule through the tool's ownpreparePermissionMatcher, and the PowerShell tool's parses the command AST, collects every statement's commands plus nested commands, and returnssome(...), a case-insensitive whitespace-normalised glob match against eachname argstext; an unparsable command runs the hook. The hooks reference documents"if": "PowerShell(Remove-Item *)"as the PowerShell spelling. SoGet-Date; python hygiene.pystill runs the hook.Fix
hooks/hooks.json: the PowerShell entry carries"if": "PowerShell(*hygiene.py*)", the same content as the Bash entry for its own tool. No change to any Python; the guard is byte-identical.test_hygiene.py:test_engine_gate_is_registered_once_per_toolnow asserts both filters (it previously asserted the PowerShell entry had none, with the stale rationale in its docstring). Two new tests:test_powershell_if_filter_skips_only_calls_the_gate_would_defer(a skipped PowerShell call, includingRemove-Item,.NET Delete, zero-width and split spellings, is one_engine_gate_relevantrejects, so the gate deferred it before any deletion spelling was consulted) andtest_powershell_if_filter_admits_every_engine_invocation_shape(;,|,&&, newline, CR LF, U+2028, call operator, nestedpwsh -Command, tab and upper-case forms are all admitted by a reference of the 2.1.258 matcher, all relevant, and all still denied bypowershell_decision).run-python-hook.test.sh: a kernel-level spawn census (strace -f, skipped where strace is absent, ptrace is refused, or python3 is a shim script): a warm launch creates no process and execs exactly bash and the interpreter.[0.21.10]; manifest 0.21.9 -> 0.21.10.Why this is behaviour-identical for every call that reaches the guard, and lossless for every call that no longer does:
_decidein engine-gate mode returns before any PowerShell mutation regex when_engine_gate_relevantis false, and relevance requires the engine's file name as a token or a separator-bearing word that is the same file as the bundled engine. The second case (a symlink or hard link under another name) is the residual the filter cannot see; the Bash lane has accepted it since 0.21.4 and it is stated in the CHANGELOG, along with a comment naming the engine, which the AST-based matcher assigns to no command.Verification
git archive origin/mainexport, running both trees through the registered command string (bash -c, payload on stdin): 33 payloads (benign, deletion spellings, engine invocations in every compound form, CR LF, U+2028, BOM, zero-width, near-misses liketest_hygiene.pyandhygiene.pyc, three Bash lane controls), identical on exit code, stdout and stderr, and every payload the predicate skips is one the pristine gate deferred (rc 0, no output).iffails the shape test ('PowerShell(*hygiene.py*)' != None); reintroducing a$(dirname ...)fork in the launcher fails the census (creations 0 became 1); removingcasefold()from_carries_markerfails the admitted test on the upper-case case; widening it tostartswith("hygiene")fails the skipped test twice.scripts/affected-tests.sh --run: 330 suites selected by the transitive basename closure; every shell suite passes exceptplugins/claude-ops/skills/plugins/scripts/cache-content-check.test.sh, whose two "process budget" failures reproduce identically on the cleanorigin/mainexport (pre-existing, not this change). The 13 NOT RUN suites from other ecosystems were run from their own lanes:test_hygiene340 OK,test_guard_launch_monitor23 OK,test_hook_telemetry3 OK, the nine other Python suites OK, the.mjssuite 1/1.scripts/run-ruff.sh check plugins/disk-hygiene) clean; ShellCheck and shfmt clean on the extended suite; markdownlint clean on README and CHANGELOG;check-purged-em-dashes.shclean; ai-slop detector 0 findings on both prose files;check-changelog-parity.shgreen in--check,--check-order,--check-bump origin/mainand--check-preserved origin/main.execve, because the harness never spawns the command; a call that names it pays the unchanged 4execve/ 0 forks and is judged unchanged.execvefor the admitted path is unchanged, which is the evidence this is latency removed by not launching, not work removed from the guard.Acceptance criteria from the triage brief that are not met, stated plainly:
Related
.claude/rules/hook-budget.mdanddocs/conventions/hook-budget/README.md(rule 1: measured share stated in the plugin README).🤖 Generated with Claude Code
https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob
Generated by Claude Code