fix(disk-hygiene): stop guard hook fail-open on skill-hook data-root token - #1014
Conversation
…token
The clean skill's PreToolUse guard hook passed --authorized-data-root
${CLAUDE_PLUGIN_DATA} in its args, but Claude Code refuses to launch a
skill-frontmatter hook that references ${CLAUDE_PLUGIN_DATA} (plugin-only;
only ${CLAUDE_PLUGIN_ROOT} is available to skill hooks) and treats the
failed launch as a non-blocking error. The destructive-action guard
therefore never ran: rm -rf, engine apply, and the PowerShell deletion
belt were all ungated.
The hook now passes only --plugin-root ${CLAUDE_PLUGIN_ROOT} so it always
launches; destructive_guard.py derives the authorized data root from the
plugin root via Claude Code's documented persistent-data-directory layout
(<plugins>/data/<id>, id = sanitized <name>@<marketplace>). Every failure
mode is fail-closed: an unrecognized layout yields no authority, so
--data-root engine calls are denied while the guard stays fully active.
The direct --authorized-data-root and CLAUDE_PLUGIN_DATA env channels
remain accepted as fallbacks.
Known platform gap: the disk_hygiene_enabled kill switch can no longer
reach a skill-frontmatter guard (${user_config.*} is not substituted for
skill hooks and CLAUDE_PLUGIN_OPTION_* is not injected), so audit-only
mode degrades from deny-outright to human-prompt-gated; documented in the
safety model and CHANGELOG.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
|
Claude finished @kyle-sexton's task in 4m 49s —— View job Security Review — PR #1014
|
|
Claude finished @kyle-sexton's task in 3m 32s —— View job PR #1014 Code Review
SummaryThis PR correctly fixes a fail-open in the Overall this is a well-structured security fix with correct fail-closed behavior throughout. One testability fragility and a couple of minor notes below. FindingsMedium — Test isolation gap:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 834f98b73b
ℹ️ 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".
|
🔒 merge-drive lane claiming this PR for on-branch fix work this cycle (amendment-round: 16, worker tier). Will fix clear branch-owned CI/bot findings, defer non-DEFER-FORBIDDEN items with reply+resolve, push, then re-check the merge gate. |
The install root is the version leaf (<plugins>/cache/<marketplace>/<name>/ <version>), so the guard's derivation must not assume CLAUDE_PLUGIN_ROOT is the <name> directory. Anchor on the <plugins>/cache marker instead of a fixed parent depth: the two segments after cache are the marketplace and name, data is cache's sibling, and any version leaf is ignored. Handles the directly-linked local install (no version leaf) too. The prior fixed-depth check returned None on every real install (fail-closed, but the mechanism was dead), and its tests used a fabricated version-less layout that hid it; tests now use the real versioned shape and the dev shape. Docs corrected to the real cache/<marketplace>/<name>/<version> shape and to stop claiming state "provably" lands via derivation; note that whether a skill hook also receives CLAUDE_PLUGIN_DATA in env is unconfirmed, so the derivation is written to stand on its own either way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
The machine-specific-paths CI lane flags a fabricated /home/<user> path. Switch the derivation fixtures to the neutral /x/plugins/... form the sibling test already uses; the derivation only needs the plugins/cache structure, not a home-rooted path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
Reconcile disk-hygiene conflicts from concurrent main advances (#1010, #1012, #1028, #1031) that landed while this branch was open. - plugin.json / CHANGELOG: restack this branch's fix onto main's version line, 0.4.8 -> 0.6.3 (next patch above main's landed 0.6.2); preserve every landed CHANGELOG entry (0.6.2 / 0.6.1 / 0.6.0 / 0.5.0) with this branch's entry on top. - clean/SKILL.md: compose #983's corrected kill-switch framing (a skill-scoped hook receives neither ${user_config.*} nor CLAUDE_PLUGIN_OPTION_*, so the guard cannot enforce audit-only) with #1012's deterministic kill_switch_probe read, dropping main's stale "guard enforces via --disk-hygiene-enabled" claim that #983's hook-arg change (only --plugin-root) invalidated. - clean/reference/safety-model.md: keep #983's --data-root derivation-from- plugin-root rewrite and append #1010's --confirmed-large-scan grammar note. destructive_guard.py and test_hygiene.py auto-merged (data-root derivation + --confirmed-large-scan + kill_switch_probe allowlist + MIN_PYTHON floor); full suite 98 passed, 4 platform-skipped.
|
Claude finished @kyle-sexton's task in 4m 15s —— View job Security Review — PR #1014
SummaryThe core fix is correct and addresses a real fail-open: replacing FindingsIMPORTANT —
|
|
Claude finished @kyle-sexton's task in 3m 31s —— View job PR #1014 Review
Codex P1: Versioned cache path — ResolvedThe Codex review was based on commit parts = Path(plugin_root).parts
for index in range(1, len(parts)):
if not (
parts[index].casefold() == _PLUGIN_CACHE_DIRNAME
and parts[index - 1].casefold() == _PLUGINS_DIRNAME
):
continue
marketplace, name = parts[index + 1], parts[index + 2]For a versioned install at Still-open: Relative path not rejected in
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f832eee3c8
ℹ️ 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".
…itation A `claude --plugin-dir <checkout>` session has no <plugins>/cache/<marketplace> structure and no marketplace-keyed data id, so the guard cannot derive an authorized data root and relies solely on the CLAUDE_PLUGIN_DATA environment variable (which the plugins reference documents as exported to hook processes). Where a build does not export it to a skill hook, the engine lane is fail-closed in that dev-only mode while the destructive-action guard stays active — a deliberate safe-over-convenient tradeoff, not a security gap. Records this in the safety model in response to review feedback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
|
Claude finished @kyle-sexton's task in 2m 18s —— View job PR #1014 Review
Status after latest commit (
|
|
Claude finished @kyle-sexton's task in 5m 2s —— View job Security Review — PR #1014
SummaryThe latest commit ( IMPORTANT —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d8b32b8c8c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Two live docs still said the skill-scoped guard enforces the disk_hygiene_enabled
kill switch, which stopped holding once the guard's hook stopped receiving the
toggle (a skill-frontmatter hook gets neither the ${user_config.*} substitution
nor CLAUDE_PLUGIN_OPTION_*). The README security-review bullet and the
kill_switch_probe.py docstring now state the degraded-mode reality documented in
the safety model: audit-only is model-enforced (the clean skill self-enforces via
the probe), the guard cannot deny on the toggle but still gates every mutation
behind a human prompt, and a direct hygiene.py call answers only to the engine's
own preview/approval gate. README points at the safety model for the detail.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
|
Claude finished @kyle-sexton's task in 3m 12s —— View job PR #1014 Review
Status after HEAD (
|
|
Claude finished @kyle-sexton's task in 3m 32s —— View job Security Review — PR #1014
SummaryHEAD is IMPORTANT —
|
…tion over #1014) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw
…ket-denying non-OS drives (#1063) > [!NOTE] > Supersedes #1026 — same fully reviewed, CI-green content rebuilt as a single commit on current > main (version reconciled to 0.6.4 over #1014's 0.6.3). See #1026 for the complete review history; > all its review threads were resolved there. ## Summary `disk-hygiene`'s `clean` skill rejected **any** filesystem/volume root as an audit target purely structurally, with no reasoning about the volume's purpose. This wrongly blocked a legitimate non-OS volume such as a Windows **Dev Drive** (`D:\`, ReFS, no OS content) with no path to override, interrogate, or explain. Root classification is now **reasoned**: a genuine OS-managed root stays denied; a non-OS volume root becomes a valid target that composes with the large-target scan gate. Fixes #984 ## Root cause (corrected from the issue's premise) The issue cited `hard_protection()` (the `path.parent == path` branch) as the blanket rejection. Empirically, that branch is **dead code for drive roots** in the live call graph — candidates are validated non-root descendants of the target, so it never fires on a drive root. The real user-facing block on Windows was the **mount-point check** in `main()` scan: every Windows drive letter is `os.path.ismount() == True`, so `D:\` was rejected as a "mount point" *before* the root check the issue points at (verified live: `os.path.ismount('D:/') == True`; scanning `D:\` returned `{"error": "mount points are not valid audit targets"}`). Fixing only the cited line would have left the bug unfixed. `preview()` applied the same checks in a different order, a latent inconsistency. ## Fix - **Reasoned OS-managed classification** (`is_os_managed_target`): a root is denied when it is *within* an OS-managed root (full `system_roots()` set) or *holds an existing* OS-install marker (`os_drive_markers`). The OS-install markers deliberately **exclude the per-volume metadata every Windows volume carries** (`System Volume Information`, `$Recycle.Bin`) — a Dev Drive has those too, so counting them would misclassify every drive root as OS-managed. `WINDOWS_VOLUME_SYSTEM_NAMES` is split into `WINDOWS_OS_DRIVE_MARKERS` ∪ `WINDOWS_PER_VOLUME_METADATA`; the union is unchanged, so per-entry protection of those folders on every drive is preserved. - **Mount rejection scoped to non-root mounts** (`mounted and not is_volume_root(target)`): a volume-root target (inherently a mount point) falls through to the reasoned root logic; nested and bind mounts stay hard-blocked — the real cross-boundary danger. - **Scan and preview unified** to one `unverified → OS-managed → non-root-mount` target-check order, removing the latent ordering inconsistency. - **Composes with the large-target gate (#985 / #1010), reusing its vocabulary — no parallel gate.** `large_scan_reasons` now names a non-OS volume root a known-large root (reason `non-os-volume-root`), so an unbounded whole-volume walk returns `large-target-confirmation-required` (exit 5) unless bounded with `--max-depth` or confirmed with `--confirmed-large-scan`. No `destructive_guard` change is needed — #1010 already permits that flag. - `hard_protection`'s (dead-for-drive-roots) branch is made reasoned rather than blanket, for faithfulness, and locked with a direct unit test. Deletion safety is unchanged: per-entry OS-managed / mount / VCS / identity protections, the preview, and per-tier approval all still gate any removal. The design **degrades safely** — if OS-drive markers were somehow absent on a real OS drive, the result is confirmation-required plus full downstream gating, never a silent dangerous delete. A stray non-OS `D:\Windows` folder classifies the volume as OS-managed (conservative false-positive deny), which is acceptable. ## Test evidence - `test_hygiene.py`: **101 tests pass** (added classification, `os_drive_markers` metadata-exclusion, `hard_protection` reasoning, non-root-mount rejection, OS-managed deny in scan+preview, and non-OS-volume-root large-target-gate tests; existing `system_roots` protection test stays green). - `ruff check` clean; `python -m py_compile` clean. - Repo gates green: `check-changelog-parity --check-bump`, `check-changed-skills` (skill-quality static contract — 0 errors), `validate-plugins` (manifests + catalog), `check-orphaned-fixtures`. - **Live verification on the audited machine**: `C:\` → denied (`OS-managed roots are not valid audit targets`); `D:\` (Dev Drive) with no bound → `large-target-confirmation-required` (reason `non-os-volume-root`); `D:\ --max-depth 1 --confirmed-large-scan` → `scan-complete`. ## Independent review Reviewed by a fresh-context reviewer (rationale withheld, adversarial). Verdict: **classification core sound** — every branch hand-traced, the pathlib root-identity edge verified empirically, no path where an OS drive slips through as non-OS or a Dev Drive is wrongly denied, and no deletion-safety regression. No code changes required. Three low-confidence advisories, noted for transparency (no action taken): 1. `hard_protection`'s volume-root branch is unreachable on the live call path (candidates are validated non-root descendants) — kept as defense-in-depth and locked by a direct unit test. 2. `system_roots()` and `os_drive_markers()` each call `windows_drive_roots()`, so a scan makes a couple of redundant `GetLogicalDrives` syscalls per invocation — negligible, not hot-path. 3. Known limitation: a stray `Recovery` (or other OS-install-marker-named) folder on a non-boot drive would classify that volume as OS-managed. This fails safe (over-restrictive deny, never an unsafe allow) and is the conservative side to err on. ## Fresh-docs note Per the repo's fresh-docs mandate: this change is Python engine logic that reuses existing status vocabulary and adds no new plugin component type, manifest field, hook, or skill surface — no official Claude Code plugin-schema page is load-bearing here, so none is cited. The only external behavior reused (`--confirmed-large-scan`, `large-target-confirmation-required`) is this plugin's own #1010 contract, referenced directly. ## Related - #983, #985, #986 — sibling issues from the same disk-hygiene audit, addressed in parallel PRs. - #985 (PR #1010) — the companion large-target scan gate; this PR builds on its `large_scan_reasons` / `--confirmed-large-scan` mechanism rather than adding a parallel gate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
The belt bullet asserted a remedy ("moving it to the shared launcher") that
#2568 does not commit to — the #1014 constraint on skill-hook substitution
argues against assuming the wired-hook shape transfers. A safety doc should
name what is tracked, matching the wording already used in test_hygiene.py.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W
…d destructive guard (#2570) ## Problem `disk-hygiene`'s `PreToolUse` destructive-operation guard **has not been running at all** on Windows hosts whose `PATH` resolves `bash` to the WSL relay. This is a dead safety guard, not log noise: `destructive_guard.py` never executes, so destructive `Bash`/`PowerShell` commands proceed ungated while the operator sees only `PreToolUse:Bash hook error` lines. Both registrations in `hooks/hooks.json` used **exec form**: ```json { "type": "command", "command": "bash", "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/run-python-hook.sh", "..."], "timeout": 60 } ``` ### Reproduction ```console $ where.exe bash # from PowerShell, i.e. the real Windows PATH C:\Windows\System32\bash.exe C:\Users\<user>\AppData\Local\Microsoft\WindowsApps\bash.exe ``` Git Bash's directory is not on the Windows `PATH` at all, so the WSL relay wins. Invoking the launcher through it: ```console $ printf '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \ | /c/Windows/System32/bash.exe plugins/disk-hygiene/hooks/run-python-hook.sh \ plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py --mode engine-gate <3>WSL (31 - Relay) ERROR: CreateProcessCommon:818: execvpe(/bin/bash) failed: No such file or directory EXIT=1 ``` The only installed WSL distro is `docker-desktop`, which has no `/bin/bash`. ### Why it is silent Per the [hooks reference](https://code.claude.com/docs/en/hooks): > **Exec form** runs when `args` is present. Claude Code resolves `command` as an executable on `PATH` and spawns it directly with `args` as the argument vector, with no shell involved. … No shell tokenization happens on any platform. > On Windows, exec form requires `command` to resolve to a real executable such as a `.exe`. And a hook that cannot start is **non-blocking**: > A hook that can't start lands in the same non-blocking bucket. … For most hook events, the action proceeds. When you set up a policy hook, watch for this notice on its first run: a mistyped path in `settings.json` leaves the gate silently disabled. So "guard never ran" and "guard approved" are indistinguishable from outside. ### This is a regression, and the repo already knew the rule `0.17.6` moved both registrations onto `"command": "bash"` + `args` to fix Python resolution (#1504) — reintroducing the exact launch failure #1006 had already fixed on the skill-frontmatter surface. #1416 was closed `COMPLETED` while the guard stayed dead; it is **reopened** by this PR. `plugins/claude-config/skills/audit/reference/audit-checklist.md` Category D already carries this as an `error` row naming this precise failure — and `disk-hygiene` shipped it anyway. A checklist a human reads is not a gate; see #2569. ## Fix Shell form, which Claude Code routes through **its own Git Bash** on Windows rather than a `PATH` lookup: > **Shell form** runs when `args` is absent. The `command` string is passed to a shell: `sh -c` on macOS and Linux, Git Bash on Windows, or PowerShell when Git Bash isn't installed. Set the `shell` field to choose explicitly. ```json { "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}\"", "shell": "bash", "timeout": 60 } ``` The #1504 Python-resolution behaviour is untouched — only the launch mechanism moves. **Design notes** - **Every placeholder is double-quoted**, per the page's "In shell form, wrap each placeholder in double quotes." Verified for **both** hooks that the resulting argv is byte-identical to the exec-form vector, against a `CLAUDE_PLUGIN_ROOT` *and* a `CLAUDE_PLUGIN_DATA` containing spaces and backslashes. The Stop hook was checked explicitly: `guard_launch_monitor.py` is what emits the `systemMessage` when no interpreter resolves, so a mangled `--data-root` there would have broken the detector that makes a dead guard visible. - **`"shell": "bash"` is declared explicitly**, unlike sibling `guardrails` which omits it. Shell form otherwise falls back to PowerShell on a Windows host with no Git Bash detected, which cannot run a `.sh`. This is the spelling `claude-config`'s Category D row names as the fix, and the one #1006 used. It is ignored when `args` is set, so it is meaningful only in shell form. - **Exec form with a real binary was not viable here.** The launcher is a `.sh`, so it needs bash — and there is no portable spelling of Git Bash's `bash.exe` to put in `command`. That is circular, which is why the page's general "prefer exec form for path placeholders" preference (a quoting-safety point) yields to the Windows platform constraint. - No environment workaround. `CLAUDE_CODE_GIT_BASH_PATH` and `PATH` reordering are explicitly **not** the fix; shell form is resolved by Claude Code, so `PATH` order neither causes nor fixes this. ## Tests fixed — both encoded the bug as the contract This is why the defect survived two fix attempts: - `hooks/run-python-hook.test.sh` asserted `.command == "bash"` and read `.args[0]`. It now asserts the **portability property**: launcher named in `command`, `args` absent, `shell: bash`, and every `${CLAUDE_PLUGIN_*}` placeholder double-quoted. Verified it **fails** against the old exec-form JSON. - `skills/clean/scripts/test_hygiene.py` selected guard hooks with `if hook.get("args") and …`, which matches nothing once a hook moves to shell form — every assertion built on it would have gone **vacuously green**. Replaced with a form-agnostic `_hook_argv()` (`shlex.split` for shell form, `[command, *args]` for exec form) plus `_guard_argv_from_hook()`. ## Audit — full list of instances Audited **every tracked JSON file** repo-wide via a recursive walk for `type: command` + `args` — which covers the 18 `hooks/hooks.json` files, any manifest-pointed hook config, and **inline `hooks` objects declared in a `plugin.json`** — plus **every** SKILL.md and agent-definition YAML frontmatter `hooks:` block across all 77 plugins. | # | Location | Shape | Status | | --- | --- | --- | --- | | 1 | `plugins/disk-hygiene/hooks/hooks.json` — `PreToolUse` | exec form, `"command": "bash"` | **Fixed here** (proven dead) | | 2 | `plugins/disk-hygiene/hooks/hooks.json` — `Stop` | exec form, `"command": "bash"` | **Fixed here** (proven dead) | | 3 | `plugins/disk-hygiene/skills/clean/SKILL.md` frontmatter | exec form, `"command": "python3"` | **Deferred → #2568** (latent) | No other plugin in the repo has this defect — `disk-hygiene` is the only one using exec form at all. Every other hook, including all of `guardrails`, is already quoted shell form. **Why #3 is deferred rather than fixed here:** it is *latent*, not live — on the reporting host `python3` resolves to a real binary, so that guard currently works. It would fail only where `python3` is the zero-length `WindowsApps` alias stub. Converting a **currently-working** safety guard on a premise not verifiable in CI risks killing a live guard, which is the exact harm this PR fixes. #2568 carries the full analysis and the #1014 constraint (skill hooks receive only `${CLAUDE_PLUGIN_ROOT}`; `--authorized-data-root` must not be reintroduced). ## Verification - `run-python-hook.test.sh` — **13/13 PASS**; confirmed it fails against the pre-fix `hooks.json`. - Full `disk-hygiene` suite (5 files, 250 + 23 tests) — **no new failures**. Three telemetry-sink timing failures reproduce locally on Windows but are confirmed **pre-existing on unmodified `origin/main`** via a detached baseline worktree, and the `hygiene` lane is green on Linux CI. - `setup` shipped no `evals/evals.json`, which the skill-quality gate requires once its SKILL.md changes; six cases were added and pass both the schema and `check-evals-quality.sh`. - `shellcheck -x` (repo `.shellcheckrc`) clean; `typos`, `markdownlint-cli2`, `check-hook-userconfig-argv.sh`, `check-shell-portability.sh`, and `check-changelog-parity.sh` (`--check`, `--check-bump`, `--check-order`) all green. - Live corroboration: `guardrails`' quoted shell-form hooks fired and blocked a command during this session on the same machine — the pattern is demonstrably working here. - Version bumped `0.17.7` → `0.17.8` with a CHANGELOG entry framing it honestly as a regression. ## Docs corrected Both stated the now-disproved premise, and one was actively harmful: - `README.md` claimed the wired hooks "register as `bash` … so `bash` must resolve on `PATH`". - `skills/setup/SKILL.md` preflight item 1 told operators to **reorder their Windows `PATH`** so Git Bash precedes stub paths — a check that passes while the guard is dead, prescribing the workaround rather than the fix. It now verifies the registration shape and explicitly says `PATH` ordering is not the cause. - `hooks/run-python-hook.sh`'s header comment asserted the launcher "is registered as `bash` (available in Git Bash on Windows)" — the exact false assumption. Rewritten to document shell form and warn against reverting to exec form. Closes #1416 ## Related - #2568 — the remaining skill-scoped `python3` instance, split out of this audit - #2569 — proposed repo-wide CI gate for this defect class, modelled on `check-hook-userconfig-argv.sh` - #1504 — the 0.17.6 Python-resolution fix that introduced this regression - #1006 — the original shell-form fix for this defect class - #1014 — established the skill-hook substitution allowlist - #1346 — earlier report of the same silently-unenforced guard 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
#2572) ## Problem `plugins/disk-hygiene/skills/clean/SKILL.md`'s frontmatter belt was the **third and last** exec-form hook registration, deliberately left unconverted by #2570 (row 3 of that PR's audit table): ```yaml - type: command command: "python3" args: ["${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/destructive_guard.py", "--plugin-root", "${CLAUDE_PLUGIN_ROOT}"] timeout: 60 ``` Per the [hooks reference](https://code.claude.com/docs/en/hooks), exec form (`args` present) resolves `command` as an executable on `PATH` with no shell, and "On Windows, exec form requires `command` to resolve to a real executable such as a `.exe`." On stock Windows `python3` resolves to `%LOCALAPPDATA%\Microsoft\WindowsApps\python3.exe` — a **zero-length App Execution Alias stub**, not a real executable. The spawn fails, a failed hook launch is non-blocking, and the belt silently enforces nothing. This registration has named a bare interpreter since #215, predating `hooks/run-python-hook.sh` (#1504) entirely — the launcher that exists precisely to reject that stub. **This one is latent, not dead.** It works wherever `python3` is a real interpreter, which is the reporting host. So the risk here is the inverse of #2570's: breaking a *working* safety guard. Everything below is scoped to proving that cannot happen. ## Fix Shell form through the shared launcher — the shape #2570 established, no third variant: ```yaml - type: command command: '"${CLAUDE_PLUGIN_ROOT}"/hooks/run-python-hook.sh "${CLAUDE_PLUGIN_ROOT}"/skills/clean/scripts/destructive_guard.py --plugin-root "${CLAUDE_PLUGIN_ROOT}"' shell: bash timeout: 60 ``` **On the YAML spelling.** This is the *same value* `hooks.json` carries; only the file-level escape differs. JSON needs `\"` escapes, YAML expresses it as a single-quoted scalar wrapping literal double quotes. It is not a third shape — `_hook_argv()` tokenizes both identically. **Shell form is honored on the skill-frontmatter surface — verified, not assumed.** #1014 found this surface more restricted than documented, so this was checked rather than inferred: `repo-hygiene`'s `skills/clean/SKILL.md` has shipped a shell-form frontmatter hook with `shell: bash` since #1006 (`a83d1748`, titled "shell-form hook launch"), and still carries it on `main` today. A live, working, same-repo precedent on the exact surface. **The #1014 constraint is respected.** Verified in code, not from a summary: `destructive_guard.py:686-687` resolves `--plugin-root`, rejecting the literal unexpanded placeholder, and derives the data root from it via `_plugin_data_root_from_root`; `resolve_authorized_data_root`'s own docstring records that "a plugin `hooks.json` hook can [supply `${CLAUDE_PLUGIN_DATA}`]; a skill hook cannot." The new command string therefore substitutes **only** `${CLAUDE_PLUGIN_ROOT}` — no `${CLAUDE_PLUGIN_DATA}`, no `${user_config.*}`, and `--authorized-data-root` is **not** reintroduced. This is asserted by an existing test that was made form-agnostic rather than left form-specific. ## Argv equivalence — the load-bearing evidence **Claim, scoped precisely:** the argv **`destructive_guard.py` itself receives** is byte-identical before and after. `argv[0]` necessarily changes — from the interpreter name `python3` to the launcher path — because replacing interpreter resolution with the launcher *is* the fix. Verified two ways. First with Python's `shlex` (the tokenizer `test_hygiene.py` uses), then against **a real bash**, because a claim about how a shell tokenizes should be proven by a shell. The launcher path is swapped for an argv dumper so the vector it would receive is directly observable: ```console root = C:\Program Files\Claude Code\plug in root shell sees: "C:\Program Files\Claude Code\plug in root"/hooks/run-python-hook.sh "C:\Program Files\Claude Code\plug in root"/skills/clean/scripts/destructive_guard.py --plugin-root "C:\Program Files\Claude Code\plug in root" argv built by bash (launcher swapped for an argv dumper): C:\Program Files\Claude Code\plug in root/skills/clean/scripts/destructive_guard.py --plugin-root C:\Program Files\Claude Code\plug in root root = D:\a b\c\d argv built by bash (launcher swapped for an argv dumper): D:\a b\c\d/skills/clean/scripts/destructive_guard.py --plugin-root D:\a b\c\d ``` Exactly three tokens after the launcher, matching the exec-form `args` array element for element, for a POSIX root and for two Windows roots containing **both spaces and backslashes**. These are the same three roots the added test asserts against. **Why it holds, and why the quoting is required rather than stylistic:** inside POSIX double quotes a backslash is literal (it escapes only `$`, `` ` ``, `"`, `\`, newline), and whitespace does not split. Remove the double quotes and `C:\Program Files\Claude Code\plug in root` splits into four argv entries and the backslashes get eaten — the guard would receive a truncated `--plugin-root` and lose its data-root authority. That is exactly why #2570's "double-quote every placeholder" rule is a correctness requirement. ## Closest reproducible proxy for "the guard still blocks" The belt is skill-scoped, so it fires only inside `/disk-hygiene:clean` and cannot be exercised from an ordinary session — I am **not** claiming a live in-session verification of the belt itself. Instead, the exact new command string was substituted the way Claude Code substitutes it and driven end to end: ```console $ printf '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/example"}}' \ | bash -c '"<root>"/hooks/run-python-hook.sh "<root>"/skills/clean/scripts/destructive_guard.py --plugin-root "<root>"' {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Disk-hygiene fails closed: Bash is restricted to exact bundled scan, preview, handoff-verify, and apply invocations of ... hygiene.py ..."}} ``` The belt launches through the new string and emits a `deny`. Note for anyone re-running this: the belt is **deny-by-default** during active cleanup (it allows only the exact bundled engine invocations), so a benign payload such as `echo hi` denies too. Both denying is the documented behavior of this surface, not over-blocking introduced here. Composed with the argv equivalence above, the argument is stronger than a single live observation: the guard receives a byte-identical vector, the existing 252-test suite proves the guard's decisions *given* that vector, and `test_skill_hook_launcher_resolves_a_supported_interpreter` proves the launcher resolves and execs end to end. No behavior change is reachable — only the launch mechanism moved. ## What this does NOT fix — stated so it is not overclaimed `run-python-hook.sh` **exits 0 silently in guard mode** when no interpreter resolves anywhere on its ladder (its own test asserts this). So this closes *"the belt cannot start against the alias stub"*. It does **not** make the belt fail-closed on a host with no Python at all — that residual is unchanged, and is documented as such in `safety-model.md` and the README. ## Tests — the third bug-as-contract site, and its fix #2568 named one form-specific helper. There were **three** sites, all of which encoded the launch form they were supposed to be checking: | Site | Old shape | Post-conversion behavior | | --- | --- | --- | | `_skill_hook_command_and_args()` | `next(… "args:" in line)` | **raises `StopIteration`** | | same helper's `command` read | `.split(":",1)[1].strip().strip('"')` | silently mis-parses a single-quoted YAML scalar into a string `shlex` then tokenizes wrongly | | `test_skill_hook_interpreter_is_python3_and_resolves` | `assertEqual("python3", interpreter)` | asserts the defect | The helper is replaced by `_skill_hook()`, which parses the frontmatter into a **hook mapping** and feeds it to the *existing* form-agnostic `_hook_argv()` / `_guard_argv_from_hook()` added by #2570 — so both surfaces are now asserted through one reading path, in either form. A `_yaml_flow_scalar()` helper decodes the three one-line YAML scalar styles rather than hand-stripping quotes (the suite is stdlib-only; PyYAML is not a dependency). The interpreter test now **exercises the launcher's real resolution ladder** instead of restating it, so the two cannot drift. **Added — and proven discriminating (requirement 5):** - `test_skill_hook_registers_in_portable_shell_form` — the same four portability properties `run-python-hook.test.sh` asserts for `hooks.json` (launcher named in `command`, `args` absent, `shell: bash`, every `${CLAUDE_PLUGIN_*}` double-quoted). That suite is jq-based and cannot read YAML frontmatter, which is why this surface is asserted in `test_hygiene.py`. **Verified to FAIL against the pre-change frontmatter** (`git stash` of SKILL.md only): ``` FAIL: test_skill_hook_registers_in_portable_shell_form AssertionError: 'hooks/run-python-hook.sh' not found in 'python3' : skill hook must launch through the shared launcher: 'python3' ``` Note the substitution-allowlist test passes in **both** forms, so it is explicitly *not* the discriminator — its docstring now says so. - `test_skill_hook_argv_matches_the_exec_form_vector_it_replaced` — encodes the equivalence proof above over three roots (POSIX, and two Windows roots with spaces + backslashes). This one passes in both forms *by design*: it is the equivalence evidence, not the regression gate. ## Docs corrected (requirement 6) Every surface that described this hook's form: - `README.md` — "Claude Code launches the skill-scoped guard in shell-free **exec form**"; "the **wired** hooks resolve Python through `run-python-hook.sh`" (x2, now all three). - `skills/clean/reference/safety-model.md` — "(the clean skill's frontmatter hook, **still exec form**)"; "The skill-scoped belt is a separate surface and **still launches in exec form via `python3`** (#2568)" (sentence's purpose is gone); the "Hook launch form" section extended from two hooks to three; "since #1504 **both wired hooks** launch through the shared launcher". - `skills/setup/SKILL.md` — "the skill-scoped belt through the **literal name `python3`**"; preflight item 1's scope; item 2's "The `clean` guard hook runs the literal command `python3`". - `hooks/run-python-hook.sh` header — "Launch disk-hygiene **wired** hooks"; "**hooks.json** invokes this file in SHELL FORM". - `skills/setup/scripts/python3_alias_probe.py` docstring + operator message, and `test_python3_alias_probe.py` docstring — both grounded the check on "the guard is launched as `python3`". **Also changed — fallout of the conversion, caught in review (thread from `chatgpt-codex-connector`, P2):** `/disk-hygiene:setup check` step 2 verdicts the interpreter **ladder**, not `python3` alone. My first pass kept `store-alias-stub` → FAIL and merely re-grounded the rationale. That was wrong, and the review found the concrete counter-case: a host with real Python installed **without "Add to PATH" but with the `py` launcher** has a stubbed `python3`, a working `py -3`, and a guard that now launches on every call — and would have been reported broken and told to reinstall Python. Before this PR that FAIL was correct (the belt really did launch as bare `python3`); the conversion is what made it a false positive, so fixing it belongs here. Step 2 now treats the alias probe as **diagnostic input** — it still exists to classify the first rung *without executing it*, since a bare `python3 --version` pops the Store — resolves the ladder in the launcher's own order skipping stubs, and checks the selected interpreter against the parsed `MIN_PYTHON`. This is strictly more accurate in both directions, not a safety downgrade: - **FAIL** on an **exhausted** ladder or a below-floor interpreter — the real fail-open. Both still FAIL under a disabled toggle, for the reason already in the file. - **WARN** when the ladder resolves a supported interpreter but `python3` is the stub. Guards launch; the residual is only that a bare `python3` typed by hand still opens the Store. The probe's own return values are unchanged (27/27 setup tests still green) — only the verdict mapping and the message wording moved. **Not changed, deliberately:** - **`safety-model.md`'s "`${CLAUDE_PLUGIN_ROOT}` — the only substitution a skill-frontmatter hook receives"** — still true, still the #1014 constraint, and now load-bearing for this shape. - **`scripts/check-hook-userconfig-argv.sh`** — audited for the vacuous-green risk #2570 found. It scopes to hook *config JSON* only (`hooks/hooks.json`, manifest-pointed configs, inline manifest `hooks`), so SKILL.md frontmatter was never in scope and this change cannot make it silently pass. A repo-wide gate covering the frontmatter surface is what **#2569** proposes; not built here. ## Verification - **`test_hygiene.py` — 252 tests**, 1 failure. `check-changed-skills.sh` reports 2 script-test failures for `clean`. All three are the **pre-existing Windows telemetry-sink timing failures** #2570 baselined — confirmed identical on **unmodified `origin/main`** via a detached baseline worktree (`250 tests, same single failure` in `test_hygiene.py`; `23 tests, same 2 failures` in `guard_launch_monitor.test.sh`). Count moves 250 → 252 (two added, one renamed). The `hygiene` lane is green on Linux CI. - `hooks/run-python-hook.test.sh` — **13/13 PASS**. - `skills/setup/scripts` — 27/27 PASS. - `markdownlint-cli2` (5 files) — 0 errors. `typos` — clean. `shellcheck -x` — clean. `ruff check` — all checks passed (`ruff format` drift in these files is pre-existing on `main` and is not a CI gate). - `check-changelog-parity.sh --check`, `--check-bump origin/main`, `--check-order` — all green. - `check-hook-userconfig-argv.sh`, `check-shell-portability.sh`, `check-skill-portability.sh`, `check-silent-skips.sh`, `check-discriminating-test-skips.sh`, `check-manifest-duplicate-keys.py` — all green. - All **5** `disk-hygiene` test entry scripts run individually: `run-python-hook.test.sh`, `kill_switch_probe.test.sh`, `python3_alias_probe.test.sh` PASS; `guard_launch_monitor.test.sh` and `hygiene.test.sh` carry only the three baselined failures above. (I started `scripts/run-plugin-tests.sh` for the whole repo but it exceeded its window and was stopped, so I am **not** claiming a full-repo local run — CI covers that lane.) - `machine-specific-paths` initially failed on the first push: the argv fixtures used `C:\Users\<name>\…` and `/home/user/…` roots. A correct catch — the properties under test are spaces, backslashes, and a drive-letter shape, none of which need a home-directory spelling. Fixtures moved to `/opt/claude/plugins/disk-hygiene`, `C:\Program Files\Claude Code\plug in root`, and `D:\a b\c\d`, and the evidence above was re-run against those exact roots. - Version bumped `0.17.8` → `0.17.9` with a CHANGELOG entry. - No `lefthook` config exists in this repo (no `lefthook.yml` / `.lefthook.yml`), so there is no such gate to run here. Closes #2568 ## Related - #2570 — converted the two wired hooks; deliberately deferred this instance as row 3 of its audit - #1416 — the wired-hook instance of this defect class - #1014 — established the skill-hook substitution allowlist this change stays inside - #1006 — the original shell-form fix, and the live precedent for shell form on this surface - #1504 — introduced `run-python-hook.sh`, which this belt now finally routes through - #2569 — proposed repo-wide CI gate for this defect class 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Aac8xjCjMxFsXGHCXKHY4W --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

Problem
The
cleanskill's PreToolUse security guard silently never ran — a live fail-open. Theskill-frontmatter hook (
skills/clean/SKILL.md) passed--authorized-data-root ${CLAUDE_PLUGIN_DATA}in its args, but Claude Code refuses to launch a skill-scoped hook that references
${CLAUDE_PLUGIN_DATA}(it is plugin-only; only${CLAUDE_PLUGIN_ROOT}is available to skillhooks) and treats the failed launch as a non-blocking error. So
rm -rf, engineapply, and thePowerShell deletion belt were all ungated.
This recurs the same fail-open shape prior fixes addressed, through a new vector (hook-launch
failure via an unsupported substitution token). The 0.4.4 premise — "inline placeholder substitution
resolves in exec-form hook
argswhere environment injection does not" — does not hold for${CLAUDE_PLUGIN_DATA}in a skill-scoped hook.Fix
--plugin-root ${CLAUDE_PLUGIN_ROOT}— the sole substitution a skillhook receives — so it always launches and the destructive-action guard runs again.
destructive_guard.pyderives the authorized data root from the plugin root. The real installroot is version-specific —
<plugins>/cache/<marketplace>/<name>/<version>(a directly-linkedlocal install omits the
<version>leaf) — so the guard anchors on the<plugins>/cachemarker(not a fixed depth): the two segments after
cacheare the marketplace and name, data is thesibling
<plugins>/data/<id>, and<id>is the sanitized<name>@<marketplace>. Verified againstthe live install (
cache/melodic-software/disk-hygiene/0.4.6→data/disk-hygiene-melodic-software).--data-rootengine calls are denied while the destructive-action guard stays fully active. The direct
--authorized-data-rootflag and theCLAUDE_PLUGIN_DATAenvironment variable remain accepted asadditional/fallback channels for hosts that can supply them.
reference/safety-model.md) to describe the derivation insteadof the now-false
${CLAUDE_PLUGIN_DATA}-hook-argument narrative — overlaps the doc-accuracy concernin disk-hygiene: doc corrections — Windows mislabeled 'full', false CLAUDE_PLUGIN_DATA premise repeated, PowerShell-lane gaps #386/D2.
Regression test for the launch path
Adds
test_skill_hook_args_launch_with_only_skill_available_substitutions, which asserts every${...}token in the declared hook command/args is within the skill-hook allowlist(
${CLAUDE_PLUGIN_ROOT}) — the two tests that previously asserted the buggy${CLAUDE_PLUGIN_DATA}/
${user_config.*}tokens are gone. Also adds end-to-end coverage of the--plugin-root→data-root derivation through the guard, plus derivation unit tests. (Static assertion; that the hook
launches is only fully verifiable in a live Claude Code session.)
Known limitation (platform gap)
The
disk_hygiene_enabledkill switch can no longer reach a skill-frontmatter guard. Its onlychannels are the
--disk-hygiene-enabledargv flag (which needs the${user_config.*}substitutionskill hooks do not receive) and the
CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLEDenv var (which theruntime does not inject into skill hooks). The guard therefore defaults to enabled and cannot honor a
configured
falseby denying outright; it still forces a human prompt before every mutation(fail-safe), and the skill body's substituted value lets the model self-enforce audit-only. This
never functioned on 0.4.6 either (the hook did not launch at all), so it is a documented gap, not a
regression. Fully honoring the kill switch in the guard needs a delivery channel skill hooks do not
yet have (a plugin-scoped hook/MCP server that can carry the value, or Claude Code adding
${user_config.*}substitution for skill hooks). Tracked as #1019.Open question (does not block this fix)
Whether a skill-frontmatter hook process additionally receives
CLAUDE_PLUGIN_DATAin itsenvironment is unconfirmed on current Claude Code (plugins-reference lists it as an available
path variable; #376 found it absent from a skill hook's env on 2.1.215). If present, the guard's
CLAUDE_PLUGIN_DATAenv fallback carries the data root and the plugin-root derivation is a redundantbelt; if absent, the derivation is load-bearing. The derivation is written to be correct either way,
so this fact changes only which channel does the work, not correctness.
Verification
python -m unittest test_hygiene— 84 pass, 4 platform-skips.ruff checkclean;scripts/validate-plugins.sh,scripts/check-changelog-parity.sh --check-bumppass. Version bumped 0.4.7 → 0.4.8 with a matching CHANGELOG entry.
Sources
${CLAUDE_PLUGIN_ROOT}availability,${CLAUDE_PLUGIN_DATA}plugin-only,data-dir layout: https://code.claude.com/docs/en/plugins-reference (Persistent data directory),
https://code.claude.com/docs/en/hooks (Hooks in skills and agents),
https://code.claude.com/docs/en/skills
Fixes #983
Related
python— fails to run (fail-open risk) on python3-only hosts #380, disk-hygiene: doc corrections — Windows mislabeled 'full', false CLAUDE_PLUGIN_DATA premise repeated, PowerShell-lane gaps #386 — prior disk-hygiene guard fail-open fixes and the doc-accuracy issue; disk-hygiene: guard hook fails to launch on skill-frontmatter hooks referencing ${CLAUDE_PLUGIN_DATA} — live fail-open on 0.4.6 #983 is thelatest fail-open vector and disk-hygiene: skill-frontmatter guard reads CLAUDE_PLUGIN_DATA from env but never receives it -> engine lane fails closed on all platforms #376's fix did not hold. Cross-linked, not closed here.
disk_hygiene_enabledkill switch cannot reach askill-frontmatter guard; needs a platform or plugin-scoped delivery channel. Not closed here.
🤖 Generated with Claude Code
https://claude.ai/code/session_01SFq1q99cNeZjKhDzHv2BQw