From 493f45b16252229ae495953fdb83381a741e5cfb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 21:33:05 +0000 Subject: [PATCH 1/4] perf(disk-hygiene): if-gate the PowerShell engine gate on the engine'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 Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob --- .../disk-hygiene/.claude-plugin/plugin.json | 2 +- plugins/disk-hygiene/CHANGELOG.md | 31 +++++ plugins/disk-hygiene/README.md | 32 +++-- plugins/disk-hygiene/hooks/hooks.json | 1 + .../hooks/run-python-hook.test.sh | 43 +++++++ .../skills/clean/scripts/test_hygiene.py | 111 ++++++++++++++++-- 6 files changed, 202 insertions(+), 18 deletions(-) diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 7d58bf0fe0..aca37c4d05 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "disk-hygiene", - "version": "0.21.9", + "version": "0.21.10", "description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.", "author": { "name": "Melodic Software", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index ca1c85f5f5..a63050eac0 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,37 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.21.10] + +### Changed + +- **The PowerShell engine-gate entry carries an `if` filter, `PowerShell(*hygiene.py*)`, + matching the Bash entry's.** Every PowerShell tool call in every session was still launching + the 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) and a 106 KB module + import, counted with `strace -f`. The harness now evaluates the filter before spawning + anything, so a PowerShell call that does not name the engine costs this plugin no process. 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 (verified in Claude Code 2.1.258's + `preparePermissionMatcher`: `some` over every collected command, case-insensitive glob; an + unparsable command runs the hook). A mixed line such as `Get-Date; python hygiene.py` therefore + still reaches the guard and is still denied on the PowerShell lane, as are the `|`, `&&`, + newline, CR LF and U+2028 forms. No allow/deny decision changes for a call that reaches the + guard, and no call the guard would have judged is skipped: the gate defers every command that + is not `_engine_gate_relevant` before any deletion spelling is consulted, and relevance needs + the engine's file name in the text or a path that is the same file as the bundled engine. That + second case, a symlink or hard link under another name, is the residual the filter cannot see, + and the Bash lane has accepted it since 0.21.4; text the PowerShell parser assigns to no + command (a comment naming the engine) is likewise invisible to the filter where the guard would + have failed closed on it. `test_engine_gate_is_registered_once_per_tool` now asserts both + filters; two new tests assert that a PowerShell call the filter skips is one the gate defers, + and that every compound invocation shape the filter admits is still relevant and still denied. + The launcher's contract suite gains a kernel-level spawn census (`strace -f`, skipped where + unavailable): a warm launch creates no process and execs exactly bash and the interpreter. The + README's hook-budget accounting records the before and after census. (#3349) + ## [0.21.9] ### Changed diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index d47d462121..d980659f50 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -92,15 +92,7 @@ enforces nothing. The launcher resolves Python itself instead (#1504). The guard registers on two surfaces: a plugin-level **engine gate** (`hooks/hooks.json`) that acts only on commands referencing the engine, deferring everything else instantly, and enforces the kill -switch and data-root authority (since **0.21.4** the gate is registered once per tool: the `Bash` -entry carries the `if` filter `Bash(*hygiene.py*)`, a superset of the gate's own relevance check, -so a Bash command that does not name the engine no longer spawns the Python interpreter to be -deferred, except that a command containing `$()`, a backtick or `$VAR` still spawns it, because -the filter cannot see what the substitution expands to; the `PowerShell` entry carries no `if`, -because an `if` filter is scoped to the tool it names, so a Bash filter would leave every -PowerShell call unguarded, and a PowerShell filter must match every subcommand of a compound -command, which would skip this kill-switch guard silently on a mixed line, so every PowerShell -call still pays the interpreter start); and the skill-scoped **belt** inside the `clean` skill's context, +switch and data-root authority; and the skill-scoped **belt** inside the `clean` skill's context, which adds the deny-by-default Bash and deletion-spelling PowerShell discipline during active cleanup work. Both surfaces resolve the kill switch by reading `disk_hygiene_enabled` from user-scope `pluginConfigs` in `settings.json` (located from `${CLAUDE_PLUGIN_ROOT}`, honored only @@ -258,6 +250,28 @@ measurements below carry the conditions they were taken under. pairs, measured p50 5446 → 1418 ms and p95 16991 → 7874 ms; those absolute values are specific to that contention and are not comparable to the ≈ 190–300 ms figures above, which were taken on a quiet host. Re-measure per the convention's method on a quiet host before citing a new share. + **Superseded in 0.21.10 for every shell call that does not name the engine.** The gate is + registered once per tool, and each entry carries the `if` filter for its own tool over the + engine's file name, `Bash(*hygiene.py*)` (since 0.21.4) and `PowerShell(*hygiene.py*)`, because + an `if` filter is scoped to the tool it names and one `Bash(...)` filter under a `Bash|PowerShell` + matcher left every PowerShell call unguarded. The harness evaluates the filter through the + tool's own permission matcher before it spawns anything, so a Bash or PowerShell call that does + not name the engine now costs this plugin zero processes and zero `execve` calls. Before, on a + warm interpreter cache, every PowerShell call paid four `execve` calls (`bash -c`, the launcher + through its `env` shebang, bash, the interpreter), no fork, and a 106 KB module import, to be + told it was irrelevant; measured with `strace -f` on Linux, where the hook process walled at + p50 44 ms against a `bash -c :` floor of 2 ms (n = 20 per tool), about 22 spawn-equivalents, + the cost class the issue measured as a 2.4 s median on Windows. A call that names the engine + pays that chain unchanged and is judged unchanged. What each filter still cannot see: for Bash, + a command containing `$()`, a backtick or `$VAR` spawns the guard anyway, because the filter + cannot see what the substitution expands to; for PowerShell, the matcher parses the command and + runs the hook when any statement, pipeline element or nested command matches, so a mixed line + such as `Get-Date; python hygiene.py` still reaches the guard (the every-subcommand rule applies + to allow decisions, not to `if`). Neither filter sees an engine reached without its file name in + the command text, a symlink or hard link under another name; the gate's relevance check could + catch that case by file identity, and the residual is accepted on both lanes, as it has been on + the Bash lane since 0.21.4, because the engine's own preview and approval-token containment + still answers for it. On a machine where no Python 3 interpreter resolves at all the gate fails open on every call, the `Stop` detector emits a `systemMessage` for that case, so the blind spot is visible rather than silent (#1110, #1504). **0.9.0 delta:** the gate no longer carries a `${user_config.*}` diff --git a/plugins/disk-hygiene/hooks/hooks.json b/plugins/disk-hygiene/hooks/hooks.json index 1972c1b506..fa953bb71b 100644 --- a/plugins/disk-hygiene/hooks/hooks.json +++ b/plugins/disk-hygiene/hooks/hooks.json @@ -21,6 +21,7 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", + "if": "PowerShell(*hygiene.py*)", "shell": "bash", "timeout": 60, "statusMessage": "Checking the disk-hygiene delete against its authorized roots..." diff --git a/plugins/disk-hygiene/hooks/run-python-hook.test.sh b/plugins/disk-hygiene/hooks/run-python-hook.test.sh index 8ff0975ce7..cb40fe4948 100755 --- a/plugins/disk-hygiene/hooks/run-python-hook.test.sh +++ b/plugins/disk-hygiene/hooks/run-python-hook.test.sh @@ -413,4 +413,47 @@ else echo "SKIP: runnable python3 not available for happy-path probe" >&2 fi +# --- a warm launch creates no process before it execs the interpreter --- +# +# Both engine-gate entries carry an `if` filter, so this launcher now runs only +# for a shell call that names the engine; what such a call pays is the spawn +# chain itself, and a warm launch must add nothing to it. The census is taken at +# the kernel (`strace -f`), not through a PATH shim or `set -x`: a fork that +# never execs (a `$(...)` substitution, a pipeline) is invisible to both, and a +# fork is the unit Windows charges for. Expected on a warm cache: zero +# clone/fork calls, and exactly two execve calls, bash running the launcher and +# the resolved interpreter running the target. Skipped where strace is absent, +# where ptrace is refused, or where `python3` is itself a script (a version +# manager shim), since that shim's own spawns are not this launcher's. +if ! command -v strace >/dev/null 2>&1; then + echo "SKIP: strace not available for the spawn census" >&2 +elif [[ -z "$REAL_PYTHON" ]] || [[ "$(head -c 2 "$REAL_PYTHON" 2>/dev/null)" == "#!" ]]; then + echo "SKIP: python3 is absent or is a shim script; spawn census not exercised" >&2 +elif ! strace -qq -e trace=execve -o /dev/null true >/dev/null 2>&1; then + echo "SKIP: strace cannot trace on this host; spawn census not exercised" >&2 +else + CENSUS_HOME="$PROBE_DIR/census-home" + CENSUS_LOG="$PROBE_DIR/census.strace" + mkdir -p "$CENSUS_HOME" + # Cold launch: resolves and writes the cache record under the census HOME. + HOME="$CENSUS_HOME" bash "$FIXTURE_ROOT/hooks/run-python-hook.sh" \ + "$FIXTURE_TARGET" "$FIXTURE_MARKER" >/dev/null 2>&1 || true + rm -f "$FIXTURE_MARKER" + HOME="$CENSUS_HOME" strace -f -qq -e trace=clone,clone3,fork,vfork,execve \ + -o "$CENSUS_LOG" bash "$FIXTURE_ROOT/hooks/run-python-hook.sh" \ + "$FIXTURE_TARGET" "$FIXTURE_MARKER" >/dev/null 2>&1 || true + assert_eq "the traced warm launch still runs the target" \ + "ran" "$([[ -e "$FIXTURE_MARKER" ]] && printf 'ran' || printf 'skipped')" + # A creation is a clone/fork line that returned a child id; the `unfinished` + # half of a split line is excluded so a creation is counted once. + census_creations="$(grep -E '\b(clone3?|v?fork)\b' "$CENSUS_LOG" | + grep -v unfinished | grep -cE '= [1-9][0-9]*$' || true)" + census_execs="$(grep -E '^[0-9]+ +execve\(' "$CENSUS_LOG" | + grep -cE '\) = 0$' || true)" + assert_eq "a warm launch creates no process before exec (strace census)" \ + "0" "$census_creations" + assert_eq "a warm launch execs exactly bash and the interpreter (strace census)" \ + "2" "$census_execs" +fi + pass "all run-python-hook contract checks" diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index 18d0dd45a4..a1f0ffa547 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -6349,8 +6349,8 @@ def _engine_gate_hook_args(cls) -> list[str]: for hook in entry.get("hooks", []) if any("destructive_guard.py" in token for token in cls._hook_argv(hook)) ] - # One registration per tool (``Bash`` with an ``if`` filter, ``PowerShell`` - # without one), both carrying the same guard argv. + # One registration per tool, each carrying its own tool-scoped ``if`` + # filter and the same guard argv. assert len(commands) == 2, commands argvs = { tuple(cls._guard_argv_from_hook(hook, "destructive_guard.py")) @@ -6359,15 +6359,25 @@ def _engine_gate_hook_args(cls) -> list[str]: assert len(argvs) == 1, argvs return list(argvs.pop()) + # The rule content both engine-gate ``if`` filters carry: the engine's file + # name, so the harness spawns the guard only for a call that names it. + _ENGINE_GATE_FILTER_CONTENT = "*hygiene.py*" + def test_engine_gate_is_registered_once_per_tool(self) -> None: """Lock the per-tool registration shape of the plugin-level engine gate. An ``if`` filter is scoped to the tool it names: under a single ``Bash|PowerShell`` matcher, ``Bash(...)`` filtered every PowerShell call - out of this kill-switch guard. The ``Bash`` entry keeps the filter; the - ``PowerShell`` entry carries none, because a PowerShell filter must match - every subcommand of a compound command and would skip the guard silently - on a mixed line. + out of this kill-switch guard. Each tool therefore has its own entry, and + each entry carries the filter for its own tool over the same content. + The PowerShell filter is safe on a compound line because 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, the PowerShell tool's ``preparePermissionMatcher``: ``some`` + over every collected command, case-insensitive glob; an unparsable + command runs the hook). The every-subcommand rule belongs to allow + decisions, not to ``if``. """ hooks_path = SCRIPT_DIR.parents[2] / "hooks" / "hooks.json" config = json.loads(hooks_path.read_text(encoding="utf-8")) @@ -6378,8 +6388,93 @@ def test_engine_gate_is_registered_once_per_tool(self) -> None: if any("destructive_guard.py" in token for token in self._hook_argv(hook)) } self.assertEqual({"Bash", "PowerShell"}, set(by_matcher)) - self.assertTrue(by_matcher["Bash"].get("if", "").startswith("Bash(")) - self.assertNotIn("if", by_matcher["PowerShell"]) + for tool, hook in by_matcher.items(): + with self.subTest(tool=tool): + self.assertEqual( + f"{tool}({self._ENGINE_GATE_FILTER_CONTENT})", hook.get("if") + ) + + _IF_STATEMENT_SPLIT = re.compile(r"\r?\n|\u2028|\u2029|;|\|\||&&|\|") + + @classmethod + def _powershell_if_admits(cls, command: str) -> bool: + """Reference of the harness's PowerShell ``if`` for ``*hygiene.py*``. + + Mirrors Claude Code 2.1.258: the command is split into statements and + pipeline elements, each element's text is whitespace-normalised, and the + rule glob (``^.*hygiene\\.py.*$``, case-insensitive) is tried against + each; any match runs the hook. The real evaluator walks the PowerShell + AST, so text the parser assigns to no command (a comment) is invisible + to it where this reference still sees it; the commands the tests below + feed it are chosen so the two agree. + """ + rule = re.compile(r"(?s)^.*hygiene\.py.*$", re.IGNORECASE) + return any( + rule.match(re.sub(r"[ \t]+", " ", part.strip())) is not None + for part in cls._IF_STATEMENT_SPLIT.split(command) + ) + + def test_powershell_if_filter_skips_only_calls_the_gate_would_defer( + self, + ) -> None: + """A PowerShell call the ``if`` filter skips is one the gate deferred anyway. + + The plugin-level gate acts only on ``_engine_gate_relevant`` commands + and defers everything else with no output BEFORE any deletion spelling + is consulted, so a filter that admits every relevant command changes no + decision. Deletion spellings that never name the engine are the cases + that look like a loss and are not: the engine gate never judged them + (the skill-scoped belt does, and it carries no filter). The zero-width + and split spellings are the marker broken in ways the guard's own token + split also does not read as the engine. + """ + skipped = [ + "Get-ChildItem -Force", + "git status --short", + "Remove-Item -Recurse -Force C:\\temp\\build", + "rm -rf ./node_modules", + "Get-Date; Remove-Item .\\x.log", + "[System.IO.File]::Delete('C:\\temp\\a.txt')", + "python hygiene\u200b.py --scan", + "python hygiene .py", + ] + for command in skipped: + with self.subTest(command=command): + self.assertFalse(self._powershell_if_admits(command)) + self.assertFalse(guard._engine_gate_relevant(command, "PowerShell")) + + def test_powershell_if_filter_admits_every_engine_invocation_shape( + self, + ) -> None: + """A PowerShell call that names the engine still reaches the guard and denies. + + Mixed lines are the case the earlier unfiltered registration feared: a + statement separator, a pipeline, a PowerShell 7 chain operator, a + newline, a CR LF pair, U+2028, the call operator, a nested + ``pwsh -Command`` payload, run-together whitespace and an upper-case + spelling all keep the marker inside a statement the harness matches, + and each still denies on the PowerShell lane. + """ + admitted = [ + "python hygiene.py --scan", + "PYTHON HYGIENE.PY --scan", + "Get-Date; python hygiene.py --scan", + "python hygiene.py --scan | Out-Null", + "Get-Date && python hygiene.py --scan", + "Get-Date\r\npython hygiene.py --scan", + "Get-Date\npython hygiene.py --scan", + "python hygiene.py\u2028Get-Date", + "& python .\\hygiene.py --scan", + 'pwsh -Command "python hygiene.py --scan"', + "python hygiene.py\t--scan", + ] + for command in admitted: + with self.subTest(command=command): + self.assertTrue(self._powershell_if_admits(command)) + self.assertTrue(guard._engine_gate_relevant(command, "PowerShell")) + verdict = guard.powershell_decision(command, True) + self.assertIsNotNone(verdict) + self.assertEqual("deny", verdict[0]) def test_engine_gate_hook_resolves_kill_switch_from_plugin_root_not_user_config( self, From 5eaba367b60a78384327944395632bd9ab545673 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 22:08:52 +0000 Subject: [PATCH 2/4] fix(disk-hygiene): broaden the stated engine-gate residual to its actual 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 Claude-Session: https://claude.ai/code/session_01ViPsHkL3ng9xWt2GjEQJob --- plugins/disk-hygiene/CHANGELOG.md | 9 +++++---- plugins/disk-hygiene/README.md | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index a63050eac0..5540f41624 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -24,10 +24,11 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol guard, and no call the guard would have judged is skipped: the gate defers every command that is not `_engine_gate_relevant` before any deletion spelling is consulted, and relevance needs the engine's file name in the text or a path that is the same file as the bundled engine. That - second case, a symlink or hard link under another name, is the residual the filter cannot see, - and the Bash lane has accepted it since 0.21.4; text the PowerShell parser assigns to no - command (a comment naming the engine) is likewise invisible to the filter where the guard would - have failed closed on it. `test_engine_gate_is_registered_once_per_tool` now asserts both + second case, any spelling that reaches the engine without its own file name in the text (a + symlink or hard link under another name, a Win32 8.3 short name), is the residual the filter + cannot see, and the Bash lane has accepted it since 0.21.4; text the PowerShell parser assigns + to no command (a comment naming the engine) is likewise invisible to the filter where the guard + would have failed closed on it. `test_engine_gate_is_registered_once_per_tool` now asserts both filters; two new tests assert that a PowerShell call the filter skips is one the gate defers, and that every compound invocation shape the filter admits is still relevant and still denied. The launcher's contract suite gains a kernel-level spawn census (`strace -f`, skipped where diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index d980659f50..0eb889ea2e 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -267,11 +267,11 @@ measurements below carry the conditions they were taken under. cannot see what the substitution expands to; for PowerShell, the matcher parses the command and runs the hook when any statement, pipeline element or nested command matches, so a mixed line such as `Get-Date; python hygiene.py` still reaches the guard (the every-subcommand rule applies - to allow decisions, not to `if`). Neither filter sees an engine reached without its file name in - the command text, a symlink or hard link under another name; the gate's relevance check could - catch that case by file identity, and the residual is accepted on both lanes, as it has been on - the Bash lane since 0.21.4, because the engine's own preview and approval-token containment - still answers for it. + to allow decisions, not to `if`). Neither filter sees an engine reached without its own file + name in the command text, any spelling such as a symlink or hard link under another name or a + Win32 8.3 short name; the gate's relevance check could catch that case by file identity, and the + residual is accepted on both lanes, as it has been on the Bash lane since 0.21.4, because the + engine's own preview and approval-token containment still answers for it. On a machine where no Python 3 interpreter resolves at all the gate fails open on every call, the `Stop` detector emits a `systemMessage` for that case, so the blind spot is visible rather than silent (#1110, #1504). **0.9.0 delta:** the gate no longer carries a `${user_config.*}` From bccbbe533c352ba98afbb88ff13460721c4d4a61 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 15:25:07 +0000 Subject: [PATCH 3/4] fix(disk-hygiene): count every guard registration in the timeout seam 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 --- plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index 75960e7d7d..d03b3df2ed 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -7936,8 +7936,10 @@ def test_declared_hook_timeouts_match_the_watchdog_ceiling(self) -> None: for hook in entry.get("hooks", []) if any("destructive_guard.py" in token for token in self._hook_argv(hook)) ] - # One registration per tool, both declaring the same timeout. - self.assertEqual(2, len(declared), declared) + # Bash has one matcher; PowerShell has several (literal engine path + # plus variable-based invocations). Every registration that launches + # the guard must declare the same timeout the watchdog clamp uses. + self.assertGreaterEqual(len(declared), 2, declared) self.assertEqual({guard._DECLARED_HOOK_TIMEOUT_SECONDS}, set(declared)) skill_text = (SCRIPT_DIR.parent / "SKILL.md").read_text(encoding="utf-8") From 973da03611e858d4b599012b5732d5fee0e79efa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 16:00:46 +0000 Subject: [PATCH 4/4] fix(disk-hygiene): use POSIX ERE for the strace spawn census GNU grep '\\b' word boundaries fail the shell-portability gate. strace syscall lines already carry a literal '(' after the name. Co-authored-by: ksextonmelodic --- plugins/disk-hygiene/hooks/run-python-hook.test.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/disk-hygiene/hooks/run-python-hook.test.sh b/plugins/disk-hygiene/hooks/run-python-hook.test.sh index cb40fe4948..b9d0c77158 100755 --- a/plugins/disk-hygiene/hooks/run-python-hook.test.sh +++ b/plugins/disk-hygiene/hooks/run-python-hook.test.sh @@ -445,8 +445,10 @@ else assert_eq "the traced warm launch still runs the target" \ "ran" "$([[ -e "$FIXTURE_MARKER" ]] && printf 'ran' || printf 'skipped')" # A creation is a clone/fork line that returned a child id; the `unfinished` - # half of a split line is excluded so a creation is counted once. - census_creations="$(grep -E '\b(clone3?|v?fork)\b' "$CENSUS_LOG" | + # half of a split line is excluded so a creation is counted once. The pattern + # is POSIX ERE: strace writes `clone(`, `clone3(`, `fork(`, `vfork(`; a GNU + # `\b` word boundary is not needed and is not portable. + census_creations="$(grep -E '(clone3?|v?fork)\(' "$CENSUS_LOG" | grep -v unfinished | grep -cE '= [1-9][0-9]*$' || true)" census_execs="$(grep -E '^[0-9]+ +execve\(' "$CENSUS_LOG" | grep -cE '\) = 0$' || true)"