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>

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