Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/disk-hygiene/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "disk-hygiene",
"version": "0.6.2",
"version": "0.6.3",
"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, changed entries, and live-handle uncertainty fail closed.",
"author": {
"name": "Melodic Software",
Expand Down
32 changes: 32 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,38 @@
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.6.3]

### Fixed

- **The destructive-action guard was failing open on the bundled `clean` skill.** The
skill-frontmatter PreToolUse hook 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 skill hooks) and treats the failed
launch as a non-blocking error — so the guard silently never ran and `rm -rf`, engine `apply`, and
the PowerShell deletion belt were all ungated. This recurs the fail-open shape earlier fixes
addressed through a new vector (hook launch failure via an unsupported substitution token); the
0.4.4 premise that "inline placeholder substitution resolves in exec-form hook args" does not hold
for `${CLAUDE_PLUGIN_DATA}` in a skill-scoped hook.
- The hook now passes only `--plugin-root ${CLAUDE_PLUGIN_ROOT}` — the sole substitution a skill
hook receives — so it always launches. `destructive_guard.py` derives the authorized data root
from the plugin root using Claude Code's documented persistent-data-directory layout
(`<plugins>/data/<id>`, `<id>` = the sanitized `<name>@<marketplace>`). Every failure mode is
fail-closed: an unrecognized layout yields no authority, so `--data-root` engine calls are denied
while the destructive-action guard stays fully active. A direct `--authorized-data-root` and the
`CLAUDE_PLUGIN_DATA` environment variable remain accepted as additional/fallback channels for
hosts that can supply them.
- **Known limitation (platform gap):** the `disk_hygiene_enabled` kill switch can no longer reach
the guard on a skill-frontmatter hook. Its only channels are the `--disk-hygiene-enabled` argv
flag (which needs the `${user_config.*}` substitution skill hooks do not receive) and the
`CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLED` environment variable (which the runtime does not
inject into skill hooks). The guard therefore defaults to enabled and cannot honor a configured
`false` by denying outright; it still forces a human prompt before every mutation, 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 rather than a regression.
Delivering the kill switch to a skill-scoped guard needs a channel skill hooks do not yet have.
(#983)

## [0.6.2]

### Fixed
Expand Down
12 changes: 8 additions & 4 deletions plugins/disk-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,14 @@ hand-cleaning the zone.
- **MCP / external trust:** no MCP server, agent, dependency, or third-party service is shipped.
- **Configuration:** one non-sensitive `userConfig` boolean (`disk_hygiene_enabled`, default
`true`) gating the execution tiers — setting it `false` puts `/disk-hygiene:clean` in audit-only
mode (enforced by the skill-scoped guard, which denies every deletion lane there; a direct
`hygiene.py` invocation outside that skill does not read the toggle and answers only to the
engine's own preview/approval-token gate). The toggle can only narrow the destructive surface,
never widen it. No credentials. Policy comes from an explicit invocation
mode. That mode is enforced by the skill, which resolves the toggle through the bundled kill-switch
probe and self-enforces; the skill-scoped guard cannot independently enforce it, because a
skill-frontmatter hook reaches the guard with neither the `${user_config.*}` substitution nor the
`CLAUDE_PLUGIN_OPTION_*` environment variable, though the guard still forces a human prompt before
every mutation. A direct `hygiene.py` invocation outside that skill does not read the toggle and
answers only to the engine's own preview/approval-token gate. The toggle can only narrow the
destructive surface, never widen it (see [the safety model](skills/clean/reference/safety-model.md)
for the degraded-mode detail). No credentials. Policy comes from an explicit invocation
argument or standing `disk-hygiene.json` files under `~/.claude/` and the consumer project's
`.claude/`. All policy input is pattern-only and additive: it can add protections and discovery
hints or disable hints, and cannot weaken hard guards or authorize removal, so ambient config
Expand Down
27 changes: 15 additions & 12 deletions plugins/disk-hygiene/skills/clean/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ hooks:
hooks:
- type: command
command: "python3"
args: ["${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/destructive_guard.py", "--authorized-data-root", "${CLAUDE_PLUGIN_DATA}", "--disk-hygiene-enabled", "${user_config.disk_hygiene_enabled}"]
args: ["${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/destructive_guard.py", "--plugin-root", "${CLAUDE_PLUGIN_ROOT}"]
---

# Disk hygiene
Expand Down Expand Up @@ -42,14 +42,14 @@ directory, symlink, or Windows reparse point.
running the bundled probe (the guard allows exactly this argument-free shape):
`"<hook-python>" "${CLAUDE_PLUGIN_ROOT}/skills/setup/scripts/kill_switch_probe.py"` and honor
the `effective` value it reports; on `degraded: true` proceed as enabled but say the configured
value could not be read. In this audit-only mode the guard denies every
deletion lane, including the flagged PowerShell mutation spellings, not only the Bash engine
apply. The kill-switch value reaches the guard as a runtime-substituted hook argument
(`--disk-hygiene-enabled ${user_config.disk_hygiene_enabled}`), so a configured `false` is
honored even where the runtime does not inject the `CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLED`
environment variable; the environment variable is only a fallback. The hook
runs in shell-free exec form and reports its absolute Python interpreter and the authorized
`--data-root` value in denial guidance. Use that exact interpreter path as `<hook-python>` for
value could not be read. Honoring that value is your responsibility: a skill-frontmatter hook
receives neither the `${user_config.*}` substitution nor the
`CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLED` environment variable, so the guard cannot independently
enforce audit-only mode — it stays active and still forces a human prompt before every mutation,
but a configured `false` reaches only you, not the guard. Do not treat the guard as the kill
Comment thread
kyle-sexton marked this conversation as resolved.
switch's backstop here. The hook runs in shell-free exec form and reports its absolute Python
interpreter and the authorized `--data-root` value in denial guidance. Use that exact interpreter
path as `<hook-python>` for
every engine call; bare `python`/`python3` is rejected because Bash aliases and functions can
replace them. If either value is not known yet, submit the otherwise exact scan shape once with
bare `python`: the guard must deny it and report both, after which retry the scan with the
Expand All @@ -71,9 +71,12 @@ stay there, never in the target or `${CLAUDE_PLUGIN_ROOT}`. Run:
[--max-depth <N>] [--confirmed-large-scan]
```

The guard validates `--data-root` against the authorized data root it receives as a
runtime-substituted hook argument (`${CLAUDE_PLUGIN_DATA}`), so generated state provably lands in the
plugin data directory even when the shell environment lacks `CLAUDE_PLUGIN_DATA`.
The guard validates `--data-root` against the plugin data directory it derives from
`${CLAUDE_PLUGIN_ROOT}` (passed to the guard as `--plugin-root`, the only substitution a
skill-frontmatter hook receives), confining generated state to the plugin data directory even when
the guard's own environment lacks `CLAUDE_PLUGIN_DATA`. If the guard cannot recognize the install
layout it derives no authority and denies `--data-root` engine calls rather than trusting a guessed
path, so re-run reporting a denial is a coverage gap, not a clean result.

For a large root (a home directory, anything whose recursive walk could exceed the engine's entry
cap), start with a bounded pass: add `--max-depth 1` to inventory the target's loose files and
Expand Down
62 changes: 48 additions & 14 deletions plugins/disk-hygiene/skills/clean/reference/safety-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,58 @@ The skill-scoped Bash guard accepts only complete literal words in the three dec
shapes. It rejects every Bash expansion family, glob/word-splitting input, redirection, operator,
escape, and compound-command form before validating arguments. Canonical script-path comparison uses
the host platform's path case rules; POSIX path identity is never case-folded. A `--data-root` value
is accepted only when it matches the authorized data root the guard receives as a runtime-substituted
hook argument (`${CLAUDE_PLUGIN_DATA}`) — the shell environment is never trusted for it (an env var is
honored only as a fallback), and absent that authority the flag fails closed. `--max-depth` accepts
only a bare positive-integer literal. `--confirmed-large-scan` is the one valueless scan flag; the
guard permits at most one and rejects any trailing value, so the scan grammar stays exact.
is accepted only when it matches the plugin data directory the guard derives from
`${CLAUDE_PLUGIN_ROOT}` — the only substitution a skill-frontmatter hook receives, passed to the
guard as `--plugin-root` and mapped to `<plugins>/data/<id>` per the documented
[persistent-data-directory](https://code.claude.com/docs/en/plugins-reference#persistent-data-directory)
layout. A host that can substitute `${CLAUDE_PLUGIN_DATA}` itself may instead pass it directly as
`--authorized-data-root`, and the `CLAUDE_PLUGIN_DATA` environment variable is honored last; absent
every channel the flag fails closed. `--max-depth` accepts only a bare positive-integer literal.
`--confirmed-large-scan` is the one valueless scan flag; the guard permits at most one and rejects
any trailing value, so the scan grammar stays exact.

Deriving the data root from `${CLAUDE_PLUGIN_ROOT}` couples to the one undocumented part of that
layout — the `cache/<marketplace>/<name>/<version>` shape of the installation root (the install root
is the version leaf; a directly-linked local install omits it). The guard anchors on the
`<plugins>/cache` marker rather than a fixed depth, taking the marketplace and name from the two
segments after `cache` and reading `data` as `cache`'s sibling, so a version leaf does not shift the
result. That coupling is acceptable only because its sole failure mode is fail-closed: an
unrecognized layout yields no authority, so `--data-root` engine calls are denied while the
destructive-action guard stays fully active. The plugins reference documents all three path
variables (`CLAUDE_PLUGIN_ROOT`/`CLAUDE_PLUGIN_DATA`/`CLAUDE_PROJECT_DIR`) as exported to hook
processes as environment variables, so the guard's `CLAUDE_PLUGIN_DATA` env fallback should carry the
authority wherever the runtime honors that for skill hooks — the derivation is then a redundant belt.
An earlier Claude Code build was observed not to export it to a skill hook, which is why both
channels exist.

A `claude --plugin-dir <checkout>` development session is the one shape with no derivable authority: a
bare checkout has no `<plugins>/cache/<marketplace>` structure and no stable marketplace-keyed data
`<id>`, so the marker walk finds nothing. That dev workflow relies solely on the `CLAUDE_PLUGIN_DATA`
environment variable; where a Claude Code build does not export it to a skill hook, the engine lane is
fail-closed there (every `--data-root` invocation denied) while the destructive-action guard itself
stays fully active. This is a deliberate safe-over-convenient tradeoff for a development-only mode,
not a security gap — a local developer sets `CLAUDE_PLUGIN_DATA` or exercises the engine lane through
a real marketplace install.

The same guard also covers the PowerShell tool with the inverse tradeoff: PowerShell stays open for
read-only support work, while engine invocations are hard-denied (Bash is the only engine lane) and
known deletion spellings and .NET Delete calls resolve against the `disk_hygiene_enabled` kill
switch — the same bar as the engine apply lane. When execution is enabled they are downgraded to a
final human permission prompt; in audit-only mode (`disk_hygiene_enabled` is `false`) they are
denied outright, so the kill switch blocks deletions on the PowerShell lane too and not only the
Bash engine apply. The kill-switch value reaches the guard as a runtime-substituted hook argument
(`--disk-hygiene-enabled ${user_config.disk_hygiene_enabled}`), so a configured `false` holds even
where the runtime does not inject `CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLED` into the hook
environment (the environment variable is only a fallback). That lane is a raised bar, not
fail-closed: an unknown mutation spelling passes it, so the engine's own containment, revalidation,
and platform gates remain the deletion authority.
switch. When the guard sees execution enabled they are downgraded to a final human permission prompt;
when it sees a configured `false` (audit-only mode) they are denied outright, so the kill switch would
block deletions on the PowerShell lane too and not only the Bash engine apply.

That kill-switch enforcement is, however, only as reachable as the value is. The guard reads it from a
`--disk-hygiene-enabled` argv flag or the `CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLED` environment
variable, but a skill-frontmatter hook receives neither — Claude Code substitutes only
`${CLAUDE_PLUGIN_ROOT}` into a skill hook's args and does not inject `CLAUDE_PLUGIN_OPTION_*` into its
environment. So in the bundled skill deployment the guard defaults to enabled and cannot honor a
configured `false` by denying; it still forces a human prompt before every mutation, and the model
itself reads the substituted `disk_hygiene_enabled` value from the skill content and self-enforces
audit-only. Enforcing the kill switch in the guard needs a delivery channel skill hooks do not yet
have (a plugin-scoped hook or MCP server that can carry the value, or Claude Code adding
`${user_config.*}` substitution for skill hooks). Even when the switch is reachable, the PowerShell
lane is a raised bar, not fail-closed: an unknown mutation spelling passes it, so the engine's own
containment, revalidation, and platform gates remain the deletion authority.

A depth-limited scan records every directory it declined to enter in `truncated_paths`. Truncated
directories have no captured descendant set, so the preview blocks them (and anything beneath them)
Expand Down
Loading