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.8.3",
"version": "0.9.0",
"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
50 changes: 50 additions & 0 deletions plugins/disk-hygiene/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,56 @@
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.9.0]

### Fixed

- **The `disk_hygiene_enabled` kill switch now enforces on both guard surfaces — closing the
inert-by-default engine gate (#1019).** Through 0.8.3 the plugin-level engine gate (`hooks/hooks.json`)
carried a bare `${user_config.disk_hygiene_enabled}` argument. Because the declared userConfig `default`
is unimplemented upstream (#46477 / #39455 / #39827), an unset-but-defaulted token dropped the whole hook
entry, so on a default install the gate never ran; the skill-frontmatter belt could not receive the value
either (skill hooks get neither the `${user_config.*}` substitution nor `CLAUDE_PLUGIN_OPTION_*`). Audit-only
mode therefore degraded from deny-outright to prompt-gated. Both surfaces now resolve the toggle by
**reading it directly** from user-scope `pluginConfigs` in `settings.json`, so a configured `false` is
denied outright on the Bash engine lane and the PowerShell deletion lane, whether or not the clean skill
is active.

### Changed

- **Kill-switch delivery is a settings read, not a hook argument or environment variable.** The engine gate
drops its `${user_config.*}` argument (fixing the hook-drop) and both surfaces call the new shared
`lib/killswitch_config.py` reader. The user `settings.json` is located **solely** from the
tamper-resistant `${CLAUDE_PLUGIN_ROOT}` both surfaces receive — the guard never falls back to
`CLAUDE_CONFIG_DIR`/`HOME` for it, because those are environment values a repo `.claude/settings.json`
`env` block can inject into hook subprocesses (carrying no provenance). A marker-less `--plugin-dir`
checkout root leaves no trusted user path, so the user scope is skipped and the switch relies on managed
settings, failing closed to enabled otherwise. Since Claude Code 2.1.207 `pluginConfigs` is honored only
from user, managed, and `--settings` scope (project/local ignored), so a hostile repo cannot forge the
value. Every absent, unreadable, or ambiguous read fails **closed to enabled**.
- **Managed (enterprise) settings are honored as the highest-precedence scope.** The reader also reads the
platform managed-settings.json (`/Library/Application Support/ClaudeCode/` on macOS, `/etc/claude-code/`
on Linux/WSL, `C:\Program Files\ClaudeCode\` on Windows — a fixed path, not `%ProgramFiles%`-derived, so a
repo `env` block cannot redirect it); a value configured there overrides the user file, so an organization
can enforce audit-only mode; the sibling `managed-settings.d/` drop-in directory is merged over it
(later files win). The reader also matches only this install's exact `<name>@<marketplace>` key
(derived from `${CLAUDE_PLUGIN_ROOT}`), so another marketplace's `disk-hygiene` entry cannot mask it. The
one residual: a value supplied only through a session `--settings` file (a runtime CLI flag no hook can
observe) is not enforced by the guard.
- **`kill_switch_probe.py` now delegates to the shared reader** (its behavior and single-line JSON output
contract unchanged) so the report-only probe and the guard resolve the switch one way, not two.
- Docs corrected across `clean`/`setup` `SKILL.md`, `reference/safety-model.md`, and `README.md`: the
"engine gate is inert until configured" and "audit-only reaches only the model, not the guard" caveats
are removed; the guard is again the audit-only backstop.

### Design note

- This supersedes the planned SessionStart-hook + state-file delivery ("C′"). Both guard surfaces are the
same script funnelling through one resolve point, so there is nothing to distribute between sessions or
surfaces: a direct read is a smaller trust surface (a settings *read*, no state-file *write*), honors a
mid-session settings change, and needs no session-start timing dependency. Semantics are unchanged from
the locked resolver decision — read user-scope `pluginConfigs`, ignore env, fail closed to enabled.

## [0.8.3]

### Fixed
Expand Down
69 changes: 36 additions & 33 deletions plugins/disk-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,14 @@ at preview. Backups remain the recovery boundary for user data.
shell-free exec form; guarded engine calls must use the same absolute interpreter reported by that
guard, so Bash aliases and functions cannot replace it. 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 configured kill switch and
data-root authority through plugin-hook substitution. **Caveat (verified on Claude Code 2.1.218):**
that gate only registers once `disk_hygiene_enabled` is **explicitly configured**. Upstream never
implemented the declared userConfig `default`, so while the option is unset its
`${user_config.disk_hygiene_enabled}` argument is neither substituted nor exported, and its
presence **drops the whole hook entry** — the gate does not run at all, on either tool. The
skill-scoped belt below carries no such token and is unaffected. 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. Hook-lifetime caveat: docs
engine — deferring everything else instantly — and enforces the kill 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 from user/managed/`--settings`
scope since Claude Code 2.1.207, so a repo cannot forge it), register unconditionally, and fail
closed to enabled — the earlier bare-`${user_config.*}` argument that dropped the engine gate on a
default install is gone (since 0.9.0). Hook-lifetime caveat: docs
scope a skill hook to the component's lifetime, but session-long firing of the belt has been
observed on at least one Claude Code build (producer-reported; see issue #1105) — if unrelated
commands are denied after a clean run ends, start a new session and see that issue. PreToolUse
Expand Down Expand Up @@ -153,30 +152,34 @@ 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. When the value is **explicitly configured `false`**, the plugin-level engine gate receives it
by exec-form substitution and denies engine invocations outright. **Caveat (verified on Claude Code
2.1.218):** this holds only for a configured value — because upstream never implemented the declared
userConfig `default`, an *unset* `disk_hygiene_enabled` is neither substituted nor exported, and its
presence in the gate's args drops the whole hook, so on a default (unconfigured) install the engine
gate does not run at all. The skill self-enforcement (kill-switch probe + skill-content value) is
therefore the primary kill-switch honoring on a default install, not a redundant layer; the
skill-scoped belt cannot receive the value either (skill-frontmatter hooks get neither
`${user_config.*}` substitution nor `CLAUDE_PLUGIN_OPTION_*`) and still forces a human prompt before
every mutation.
- **Trust-surface record (0.7.0):** the plugin-level `hooks/hooks.json` PreToolUse registration is a
NEW trust surface (a hook that launches in every consumer session **once `disk_hygiene_enabled` is
explicitly configured** — see the caveat below), added deliberately for guard-enforced audit-only
mode and data-root authority (#1106 decision, Option E — split registration). Its blast radius is
bounded by design: exec form (no shell), bundled standard-library script only, instant no-output
deferral for any command not referencing the engine, and no new capability beyond what the
skill-scoped deployment already did during active cleanup. Known costs, accepted: one `python3`
launch per Bash/PowerShell call **on a configured install**, and on a machine where `python3`
resolves to the Windows Store alias stub the launch fails on every call (tracked with remediation
detection in #1110). **Caveat (verified on Claude Code 2.1.218):** while `disk_hygiene_enabled` is
unset, the bare `${user_config.*}` argument drops the whole hook, so on a default install this hook
does not register or launch at all — neither the trust surface nor its per-call cost applies until
the option is configured. This entry is the plugin-acceptance review delta for the
change. A direct `hygiene.py` invocation outside that skill does not read the toggle and
mode. Both guard surfaces resolve the toggle by reading `disk_hygiene_enabled` from user-scope
`pluginConfigs` in `settings.json` (not the process environment). A configured `false` denies Bash
engine invocations outright on the always-on engine gate (whether or not the clean skill is active);
PowerShell deletion spellings are denied outright by the skill-scoped belt while `/disk-hygiene:clean`
is active (the always-on gate defers on non-engine commands). The read is honored only from user, managed, and
`--settings` scope (Claude Code 2.1.207+), so a project or local repo `settings.json` cannot flip
it; the user file is located from `${CLAUDE_PLUGIN_ROOT}`, not from repo-redirectable environment, and
the managed (enterprise) file at its fixed system path wins as the highest-precedence scope so an org
can enforce audit-only (the sibling `managed-settings.d/` drop-in directory is merged over it). An absent
or unreadable value fails closed to enabled. The one residual a hook cannot read is a value supplied only
via a session `--settings` file. The skill's own kill-switch probe + skill-content value remain a
defense-in-depth honoring layer over the guard.
- **Trust-surface record (0.7.0; updated 0.9.0):** the plugin-level `hooks/hooks.json` PreToolUse
registration is a NEW trust surface (a hook that launches in every consumer session), added
deliberately for guard-enforced audit-only mode and data-root authority (#1106 decision, Option E —
split registration). Its blast radius is bounded by design: exec form (no shell), bundled
standard-library script only, instant no-output deferral for any command not referencing the engine,
and no new capability beyond what the skill-scoped deployment already did during active cleanup.
Known costs, accepted: one `python3` launch per Bash/PowerShell call, and on a machine where
`python3` resolves to the Windows Store alias stub the launch fails on every call (tracked with
remediation detection in #1110). **0.9.0 delta:** the gate no longer carries a `${user_config.*}`
argument (which, unset, dropped the whole hook and left the gate inert on a default install); it now
registers unconditionally and resolves the kill switch by **reading** the user `settings.json` and the
platform managed-settings.json. The added trust surface is that settings-file *read* — bounded to a
single `pluginConfigs` value, from the user file (located from `${CLAUDE_PLUGIN_ROOT}`) and the
root-owned managed file at its fixed system path, no write. Both are the plugin's own documented CC
config, sanctioned by the acceptance review's operator-home carve-out (criterion 4). This entry is the
plugin-acceptance review delta for the change. 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
Expand Down
4 changes: 1 addition & 3 deletions plugins/disk-hygiene/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@
"--plugin-root",
"${CLAUDE_PLUGIN_ROOT}",
"--authorized-data-root",
"${CLAUDE_PLUGIN_DATA}",
"--disk-hygiene-enabled",
"${user_config.disk_hygiene_enabled}"
"${CLAUDE_PLUGIN_DATA}"
]
}
]
Expand Down
Loading