fix(disk-hygiene): resolve kill switch by reading user settings directly - #1242
Conversation
The disk_hygiene_enabled kill switch was inert on a default install: the
plugin-level engine-gate hook carried a bare ${user_config.disk_hygiene_enabled}
argument, and an unset-but-defaulted userConfig token drops the whole hook entry
(upstream default unimplemented, #46477/#39455/#39827). Audit-only mode degraded
from deny-outright to prompt-gated.
Both guard surfaces now resolve the toggle by reading user-scope pluginConfigs
from settings.json through a shared lib/killswitch_config.py reader, located from
the tamper-resistant ${CLAUDE_PLUGIN_ROOT} (honored only from user/managed scope
since CC 2.1.207, so a repo cannot forge it), ignoring the environment, failing
closed to enabled. kill_switch_probe.py now delegates to the same reader.
Supersedes the planned SessionStart+state-file delivery: both surfaces are one
script through one resolve point, so a direct read is a smaller trust surface
(a settings read, no state-file write) and honors mid-session changes.
Closes #1019.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 21s —— View job PR Review: fix(disk-hygiene): resolve kill switch by reading user settings directly
OverviewThe root cause diagnosis is correct: a bare Findings1. Confusing
|
|
Claude finished @kyle-sexton's task in 6m 32s —— View job Security Review — PR #1242
Reviewed changed files: Two security findings follow. IMPORTANT —
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bd50ada781
ℹ️ 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".
The exec-bit hygiene gate requires every tracked shebang file to be mode 100755, but the new lib module is imported (no __main__), never executed, and was committed 100644 from Windows. Remove the shebang rather than mark a library executable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 56s —— View job PR Review: fix(disk-hygiene): resolve kill switch by reading user settings directly
OverviewThe root cause diagnosis is correct: a bare The latest commit ( Findings1.
|
|
Claude finished @kyle-sexton's task in 6m 11s —— View job Security Review — PR #1242 (HEAD 23e40d3)
Reviewed HEAD IMPORTANT — Guard falls through to env-reachable
|
Addresses a Codex P1: reading only the user settings.json missed the managed and --settings scopes Claude Code also honors for pluginConfigs, so an admin-enforced (managed) disk_hygiene_enabled=false resolved to enabled and the engine gate returned `ask` for `apply` instead of denying it. The shared lib/killswitch_config.py reader now also reads the platform managed-settings.json (macOS /Library/Application Support/ClaudeCode, Linux/WSL /etc/claude-code, Windows %ProgramFiles%\ClaudeCode). As the highest-precedence, non-overridable scope, a value configured there wins over the user file, so an organization can enforce audit-only mode. A value supplied only through a session --settings file (a CLI flag no hook observes) or the managed-settings.d/ drop-in directory remains a documented residual. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 45s —— View job PR Review: fix(disk-hygiene): resolve kill switch by reading user settings directly
Overview of the new commit (266c2a9)This commit adds managed (enterprise) settings support in response to the Codex inline review comment and the "acknowledged gap" flagged in the two prior security reviews. Both Findings1. Windows
|
|
Claude finished @kyle-sexton's task in 2m 14s —— View job Security Review — PR #1242 (HEAD 266c2a9)
Reviewed HEAD IMPORTANT —
|
|
Addressed the Codex P1 ("Honor managed and --settings kill-switch sources") in commit 266c2a9.
Remaining residuals a hook genuinely cannot read (documented in the guard docstring, safety-model, README, CHANGELOG): a session |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 266c2a905f
ℹ️ 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".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 266c2a905f
ℹ️ 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".
…ce masking
Two Codex findings on the managed-settings read:
- P1: the Windows managed path was derived from %ProgramFiles%, which a repo
settings `env` block can set for hook subprocesses — letting a repo point the
highest-precedence managed lookup at a forged managed-settings.json that
force-enables the switch. Hard-code the documented C:\Program Files\ClaudeCode
path so no environment value participates.
- P2: the pluginConfigs key match was a disk-hygiene@* prefix, so another
marketplace's disk-hygiene entry could contradict this install's value and
make the read ambiguous (falling back to enabled). The guard now derives its
exact <name>@<marketplace> id from ${CLAUDE_PLUGIN_ROOT} and matches only that;
the report-only CLI (which cannot know its marketplace) keeps the prefix match.
The session --settings source remains a documented residual: no hook channel
exposes the active --settings file path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 11s —— View job PR Review: fix(disk-hygiene): harden managed read against env tamper + marketplace masking
Overview of the new commit (23cad83)This commit closes the two open confirmed/confirmed findings from the previous two review rounds:
Findings1.
|
|
Claude finished @kyle-sexton's task in 3m 29s —— View job Security Review — PR #1242 (HEAD 23cad83)
Reviewed HEAD What 23cad83 fixesWindows managed-settings path now hardcoded. if sys.platform == "win32":
return Path(r"C:\Program Files\ClaudeCode\managed-settings.json")Prior reviews (confirmed in both the code review and security review of Exact plugin-ID matching closes multi-marketplace ambiguity. The Codex P2 finding ("a second marketplace's The IMPORTANT —
|
|
Addressed the second review round in 23cad83:
185 tests OK; markdownlint clean. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 23cad83407
ℹ️ 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".
Addresses a Codex P1: an organization can set disk_hygiene_enabled only in the managed managed-settings.d/ drop-in directory, an honored higher-precedence source the reader was ignoring, so an audit-only session could get `ask` instead of `deny`. The reader now merges the primary managed-settings.json with every *.json in the sibling managed-settings.d/ directory (sorted; later files win), mirroring Claude Code's drop-in merge, before falling back to user settings. All at the fixed root-owned system path, so a repo cannot forge them. The session --settings file is now the only honored source a hook cannot read (no env var or payload field exposes its path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 30s —— View job PR Review: fix(disk-hygiene): read managed-settings.d drop-in kill-switch entries
Overview of the new commit (48f8355)This commit closes the last confirmed Codex P1 finding (and the acknowledged residual from all four prior review passes): Findings1.
|
|
Claude finished @kyle-sexton's task in 3m 50s —— View job Security Review — PR #1242 (HEAD 48f8355)
Reviewed HEAD IMPORTANT — Guard falls to env-reachable
|
| Finding | Status |
|---|---|
Windows %ProgramFiles% env tamper → managed-settings path forgery |
Fixed (23cad83) |
| Multi-marketplace masking → ambiguous read → falls back to enabled | Fixed (23cad83) |
| Managed settings not honored (only user settings read) | Fixed (266c2a9) |
managed-settings.d/ drop-ins not read |
Fixed (48f8355, this commit) |
Guard falls to env-reachable CLAUDE_CONFIG_DIR when layout check fails |
Open (PLAUSIBLE, all 5 commits) |
|
Fixed the |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 48f835548c
ℹ️ 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".
Addresses a Codex P1 on --plugin-dir installs: when the plugin root carries no
plugins/cache marker, the guard fell back to CLAUDE_CONFIG_DIR/HOME to locate
user settings, and a repo .claude/settings.json env block can inject those into
hook subprocesses — pointing the read at a forged settings.json that flips the
switch.
The guard now locates the user settings file SOLELY from the tamper-resistant
${CLAUDE_PLUGIN_ROOT} cache marker; it never consults the environment. A
marker-less (--plugin-dir) root yields no trusted user path, so the user scope
is skipped and the switch relies on managed settings (fixed system paths),
failing closed to enabled otherwise. Managed enforcement is therefore honored
even in --plugin-dir installs.
Tests drive the kill switch by patching the guard's resolver directly (no env),
which also removes the last CLAUDE_CONFIG_DIR reliance from the guard suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 44s —— View job PR Review: fix(disk-hygiene): never trust an env-derived settings path in the guard
Overview of the new commit (429a29c)This commit closes the one remaining confirmed open finding: the guard's fallback to an env-reachable
What's in this commitThe core fix is correct and complete
The fix is the right shape. The security model now holds without caveats: the guard never consults an environment-derived path in any reachable code path. A Test updates match the new behavior precisely
Remaining carryover findings (unchanged across all commits)1.
|
| Finding | Status |
|---|---|
${user_config.*} hook-drop making engine gate inert |
Fixed (bd50ada) |
| Managed settings not honored (only user settings read) | Fixed (266c2a9) |
Windows %ProgramFiles% env tamper → managed-settings path forgery |
Fixed (23cad83) |
| Multi-marketplace masking → ambiguous read → falls back to enabled | Fixed (23cad83) |
managed-settings.d/ drop-ins not read |
Fixed (48f8355) |
Guard falls to env-reachable CLAUDE_CONFIG_DIR when layout check fails |
Fixed (429a29c, this commit) |
zip(dict, list) fragility in error message |
Open (minor, ×5 reviews) |
assert effective is not None stripped by python -O |
Open (low severity, ×5 reviews) |
managed_settings_path parameter shadows module-level function |
Open (cosmetic, ×3 reviews) |
dropin.iterdir() unhandled OSError |
Open (low severity, ×1 review) |
Verdict
Approved. This commit closes the last open confirmed/PLAUSIBLE security finding. The kill-switch resolution chain is now fully tamper-resistant: no code path in the guard consults an env-derived path for the user-settings location. The four carryover items (zip, assert, parameter shadow, iterdir) are minor to low-severity with no blocking issues. Ready to merge.
|
Fixed the |
|
Claude finished @kyle-sexton's task in 4m 9s —— View job Security Review — PR #1242 (HEAD 429a29c)
Reviewed HEAD What 429a29c fixesPreviously PLAUSIBLE — now FIXED: The prior function returned def _resolve_user_settings_path() -> Path | None:
plugin_root = _argv_flag_value(sys.argv[1:], _PLUGIN_ROOT_FLAG)
if plugin_root and plugin_root != _PLUGIN_ROOT_PLACEHOLDER:
derived = _user_settings_path_from_root(plugin_root)
if derived:
return Path(derived)
return None # no longer falls back to CLAUDE_CONFIG_DIR
SUGGESTION —
|
| Finding | Status in HEAD 429a29c |
|---|---|
Guard falls to env-reachable CLAUDE_CONFIG_DIR when layout check fails |
Fixed (429a29c) |
Windows %ProgramFiles% env tamper → managed path forgery |
Fixed (23cad83) |
| Multi-marketplace masking → ambiguous read → falls back to enabled | Fixed (23cad83) |
| Managed settings not honored | Fixed (266c2a9) |
managed-settings.d/ drop-ins not read |
Fixed (48f8355) |
dropin.iterdir() PermissionError uncaught |
Open (SUGGESTION) |
resolve_authorized_data_root() env fallback for CLAUDE_PLUGIN_DATA |
Open (SUGGESTION) |
| Probe/guard reporting divergence on settings path | Open (acknowledged residual) |
What was checked and found clean at HEAD
- No command injection: The guard calls no subprocess,
os.system, oreval. All decisions go throughjson.dumps()to stdout. - No path traversal via model-controlled input:
_literal_shell_words()rejects shell operator characters;_data_root_key()normalizes but does not trust model-supplied values for authority decisions. sys.pathinsertion safe:Path(__file__).resolve().parents[3] / "lib"is the CC-controlled plugin install path, not attacker-controllable.- Kill-switch resolution fully env-independent in hook context: Both
_resolve_user_settings_path()andmanaged_settings_path()now derive their paths without consulting the environment. A hostile repo'senvblock can no longer redirect either read. - Fail-closed JSON reads:
probe()handlesFileNotFoundError,OSError,UnicodeDecodeError, non-dict JSON root, unrecognized booleans, and conflicting entries — all returningeffective=True. plugin.json: Version bump to0.9.0only; no new permissions or trust delegations.- No secrets or PII introduced.
…#573) (#1247) Closes #573 ## Summary Wires the previously-inert `work_dispatch_concurrency_cap` to real enforcement and removes the `work_cycle_batch_cap` knob that bound nothing — adopting the posted decision brief's **Option 1** (verify-then-recommend: "wire concurrency for real; remove the batch cap from `userConfig`"). Both scalars had shipped as tunable execution caps that no code read, sitting against this repo's `PLUGIN-PHILOSOPHY.md` ("schema used honestly"; "a silently skipped feature is a defect"). - **Concurrency cap — now enforced through a single real parameter.** `/implementation:implement-dispatch` gains an optional `--wave-cap <N>` argument that overrides its internal "3–5 concurrent dispatch waves" default. `/work-items:work`'s autonomous execute step resolves `${user_config.work_dispatch_concurrency_cap}` and threads it into the delegated dispatch as `--wave-cap` when the operator set it, passing nothing when it is unset so implement-dispatch's internal 3–5 default still applies. The value now reaches real fan-out behavior through that one parameter. - **Default-preservation is load-bearing, so the manifest `default: 3` is removed.** The brief guarantees the internal 3–5 stays the default when the cap is unpassed. That only holds if an unset key stays distinguishable from a configured one. Per the plugins-reference, a declared `default` is "the value used when the user provides nothing" — an unset-but-defaulted key would resolve `${user_config.…}` to a hard `3`, collapsing the 3–5 range. Dropping `default` (keeping `min: 1`) leaves a surviving `${user_config.…}` placeholder on unset — the exact "unset" signal `work` already keys off — so implement-dispatch owns the real default range. (This is also robust against the currently-unimplemented-upstream `default` substitution noted in #1242.) - **Batch cap removed, not downgraded.** `work` selects and executes exactly one item per invocation — it has no "cycle" to count, so `work_cycle_batch_cap` had no honest in-skill enforcement point. The autonomous per-cycle item budget already exists and is enforced as the `work-loop` lane's adaptive item cap (`work_loop_item_cap_*`). A future, demonstrated need for a distinct loop-side batch budget reopens as a `/loop`-side concern rather than an indefinite inert knob. Consistent with the just-merged #572 lifecycle: the concurrency value rides the same `work` → `implement-dispatch` delegation, worker-side provisioning and orchestrator-owned PR creation are untouched. ### Authority The `needs-human` + `wayfind: design` decision was delegated to the session by the operator (session `d557362f` handoff, "have you do ALL of these"); the delegation is the human decision. A decision-adoption comment is posted on #573. Labels are left in place per the repo convention that keeps `needs-human` / `wayfind: design` on issues through close (as #572 / #1244 did). ## Test plan - **changelog-parity `--check-bump origin/main`:** green — both bumped plugins (implementation 0.8.0→0.9.0, work-items 0.23.0→0.24.0) carry a matching `## [<version>]` entry. - **skill-quality static gate (`check-skill.sh`)** on the three changed skills (`implement-dispatch`, `work`, `work-loop`): PASS, 0 errors; all base-ref trigger phrases preserved (8/8 `work`, 7/7 `work-loop`). - **markdownlint-cli2** over the changed markdown (SKILLs via check-skill; `README.md` + both `CHANGELOG.md` directly): 0 errors. - **All three changed `evals.json`** validate against `plugins/skill-quality/reference/evals.schema.json`; a new implement-dispatch eval (id 7) covers `--wave-cap N` → cap N while eval id 1 keeps the unset → 3–5 assertion. - Docs-only change (SKILL / manifest / README / CHANGELOG / evals prose + version bumps); no shell or hook logic touched. ## Related - #572 / #1244 — sibling from the same PR #563 review (deferred config-vs-enforcement gaps); merged the orchestrator-dispatch lifecycle this cap wiring rides. This PR completes the cap-enforcement half #1244 deliberately left out of scope. - #563 / #479 — source of the deferral (MERGED); introduced the two caps as `userConfig` and deferred their enforcement to #573. - #464 — same-plugin serialization (still deferred; unchanged here). - #1242 — documents the currently-unimplemented-upstream `userConfig` `default` substitution the default-removal above is robust against. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… gate (#1249) ## Summary Codifies the userConfig→hook **channel decision matrix** as a new `hook-*` family owner doc, `docs/conventions/hook-config-delivery/` (README + CHANGELOG, `contract_version` 1.0), registered in the PLUGIN-PHILOSOPHY convention registry. It composes with `config-cascade` (which owns consumer-tracked file layering; this owns the harness-prompted userConfig path), characterizes channels A–F — including the direct-settings-read channel (F) that disk-hygiene 0.9.0 shipped in #1242 — and version-pins every upstream fact to CC 2.1.218 with explicit recheck triggers (docs re-fetched 2026-07-24; behavioral facts from the 2026-07-23 fresh-session probe). Enforces the matrix's "never bare argv" rule with a new **`userconfig-argv-gate`** CI lane: `scripts/check-hook-userconfig-argv.sh` fails on any `${user_config.*}` token in a plugin hook config — the default `hooks/hooks.json`, manifest-pointed hook files (string or array), and inline manifest `hooks` objects. MCP/LSP configs are out of scope (substitution there is sanctioned). A stale-guarded allowlist (`scripts/hook-userconfig-argv-allowlist.txt`, currently comment-only) is reserved for a ratified channel D adoption once the G-required probe passes. This pins the exact regression #1242 fixed: an unset-but-defaulted argv token silently drops the whole hook entry (upstream `default` unimplemented — anthropics/claude-code#46477, closed not-planned). ## Test plan - `bash scripts/check-hook-userconfig-argv.test.sh` — 13/13 scenarios green (bare token in default / manifest-pointed / array / inline configs fail with file:line; clean, MCP, unreferenced-sibling, non-hooks-manifest cases stay quiet; allowlist honored; stale allowlist entries fail; comments inert; unparsable manifest skipped). CRLF-tolerant on Windows (jq emits `\r` under Git Bash). - `bash scripts/check-hook-userconfig-argv.sh` — real tree passes (0.9.0 already removed the last bare token). - `shellcheck --rcfile=.shellcheckrc` clean on both scripts; `actionlint` + YAML parse clean on `ci.yml`; `markdownlint-cli2` clean on the new/edited docs. - CI job runs its self-test first (broken-detector-cannot-mask pattern) and is wired into the `ci-status` needs aggregate. ## Related No related issue: the delivery-channel program is tracked outside this tracker; this PR closes nothing. Context: supersedes the draft matrix in #1182 (which stays open, demoted to the adoption/tracking pointer), builds on #1242 (disk-hygiene 0.9.0, closed #1019). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…1465) Closes #1416 ## Summary - **Closes the observability half of #1416.** A repo-operator investigation (issue comments, 2026-07-25T23:46-23:47Z) found both originally-reported launch-refusal root causes already fixed and merged (disk-hygiene: #1242/0.9.0; repo-hygiene: #1006), and split the one remaining live defect (a silent post-launch death) to #1423, fixed separately by #1449. What #1416 kept as its own scope, per the operator's brief and its amendment: make a guard-launch/runtime failure loud, because "the guard denied nothing because it approved" and "the guard denied nothing because it never ran, or ran and died" were indistinguishable from outside the harness. - **New detector, `plugins/disk-hygiene/skills/clean/scripts/guard_launch_monitor.py`.** A second, independent hook — stdlib-only, imports nothing from `destructive_guard.py` or `lib/` — registered on `Stop` (not `PreToolUse`/`PostToolUse`) in `hooks/hooks.json`. Deliberately not per-tool-call: this repo already paid for that mistake once (`docs/adr/0004-rightsize-instruction-surfaces-by-incumbent-first-arbitration.md`'s D-12, a guardrails `PreToolUse` hook costing 12-19s p50 on every Bash call). Per the [hooks reference](https://code.claude.com/docs/en/hooks) (fetched 2026-07-25), `Stop` fires once per turn — the guard, if it ran, ran synchronously before the guarded command, so its failure record is already in the transcript well before the turn ends. The read itself is a bounded byte-seek tail (2MB cap) so per-turn cost never scales with session length, and a once-per-session marker (keyed by the hook's own `session_id` input, never a field found inside transcript records — those can differ from the file's own session, confirmed empirically against real local transcripts) short-circuits the read entirely after the first warning. - **Satisfies the amended criteria 2/3 exactly.** The emitted `systemMessage` states the most recent failure's `exitCode` and `durationMs` explicitly (labelled, not just embedded) alongside truncated stderr and the total failure count — verified both by the rendered-string test in `test_guard_launch_monitor.py` (fixture shaped like the real #1423 record: `exitCode: 1`, `durationMs: 17054`) and by a manual smoke test against a genuine local transcript record (see Test plan) that reproduces `exitCode: 1`, `durationMs: 11`, and the real config-refusal stderr text. - Never blocks, never emits `permissionDecision` or `decision: block`; on any transcript read/parse failure it exits 0 with no output. The once-per-session marker degrades toward *re-warning*, never toward silence, if its own bookkeeping write fails — over-warning is the safe direction for a module whose entire purpose is killing a silent-suppression defect class. - `plugin.json` 0.9.4 → 0.9.5, `CHANGELOG.md` entry, `README.md` and `skills/clean/reference/safety-model.md` both state what's covered (only `destructive_guard.py`'s own command string, current-session only) and what isn't (repo-hygiene's own guard — verified working separately; no retroactive scan of past sessions). ## Test plan - [x] `plugins/disk-hygiene/skills/clean/scripts/guard_launch_monitor.test.sh` — 17/17 pass: the #1423 shape (states `exitCode: 1`/`durationMs: 17054` in the rendered string), the launch-refusal shape, empty-stderr placeholder rendering, a clean session with a *different* hook's failure present (proves the command-substring filter discriminates), a fully clean session, malformed/unreadable transcript, missing `transcript_path`, malformed stdin, once-per-session suppression (same session id) vs independent warnings (different session ids), tail-bounded read still finds a failure near the end of an oversized transcript, marker-write failure still emits the warning this run, and a direct assertion that no `permissionDecision`/`decision: block` is ever emitted. - [x] Manual smoke test against a genuine local transcript (copied outside the repo, not committed): piped a real `hook_non_blocking_error` record for `destructive_guard.py` (`exitCode: 1`, `durationMs: 11`, the real "Plugin option \"disk_hygiene_enabled\" isn't set" stderr) through the finished detector — emitted `systemMessage` names all three correctly. - [x] `bash scripts/check-hook-userconfig-argv.sh` — pass (new hook's args carry no `${user_config.*}` token). - [x] `bash scripts/check-changelog-parity.sh --check-bump origin/main` — pass. - [x] `node scripts/validate-plugin-contracts.mjs` — pass (43 setup skills, 2101 plugin files). - [x] `claude plugin validate plugins/disk-hygiene/` — pass. - [x] `bash scripts/run-plugin-tests.sh` (full repo, 149 `*.test.sh` files) — run locally; time-boxed partway through (29/149 files, 0 failures) given this change's isolation to new disk-hygiene-only files plus the repo-wide structural gates above already passing across all 2101 plugin files. CI runs the same script to completion as the authoritative full-repo gate. ## Related Refs #1423 — the live launch/runtime-death fail-open this issue was found alongside, fixed separately by #1449 (open, unmerged as of this PR). Refs #1449 — open PR, unmerged, also touches `plugins/disk-hygiene/.claude-plugin/plugin.json`, `CHANGELOG.md`, and `hooks/hooks.json` for the #1423 fix. Both PRs edit the same three files; whoever merges second should expect a straightforward rebase (this PR adds a new `Stop` hooks.json key and a new CHANGELOG/version entry — no overlapping lines with #1449's `PreToolUse`-side edit, but git may still want a manual pass). 🤖 Generated with [Claude Code](https://claude.com/claude-code) *This was generated by AI during work-loop execution.* --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ing open at exit 1 (#1449) *This was generated by AI during work-loop execution.* ## Summary - The disk-hygiene destructive-action guard (`destructive_guard.py`) was observed exiting `1` with empty stderr after running 17054 ms — PreToolUse treats exit `1` as **non-blocking** (per the [hooks reference](https://code.claude.com/docs/en/hooks), fetched 2026-07-25), so the destructive Bash/PowerShell command ran ungated. This is distinct from #1242's `${user_config.*}` launch-refusal fix — the recorded command was already the post-#1242 shape. - Root cause: only the JSON-payload parse at the top of `main()` was wrapped in a `try`/`except`. Every line of decision logic after it (now extracted into `_decide()`) had **no exception handling at all**, so any bug or unexpected exception fell through to Python's default unhandled-exception behavior (exit 1, no diagnostic). - Fix: `main()` now wraps the `_decide()` call in `try`/`except BaseException`, denying (exit `2`, one-line stderr diagnostic) on any exception — exit `1` is no longer reachable from any internal path. A self-enforced watchdog (default 10s, overridable via `DISK_HYGIENE_GUARD_WATCHDOG_SECONDS`) also denies on its own internal deadline instead of risking an unbounded hang toward the harness's 600s default hook timeout; both hook registrations (`hooks/hooks.json` and `skills/clean/SKILL.md`) now declare an explicit `timeout: 20` backstop. - The 17s duration itself is investigated, not conclusively characterized (single, unreproduced occurrence) — findings recorded in the module docstring and CHANGELOG. Leading hypothesis: `_engine_gate_relevant`'s marker-free fallback calls `os.path.samefile` on every separator-containing word of *every* Bash/PowerShell command in *every* session, so a slow/ unreachable path referenced by an unrelated command is a real, user-reachable stall vector. The empty-stderr detail does not fully square with a plain uncaught Python exception (which normally writes a traceback), so an external process kill (antivirus/EDR) remains an open, unconfirmed possibility this module cannot fix from inside the interpreter — the watchdog and explicit hook timeout are the achievable mitigation regardless of which it was. ## Test plan - [x] `plugins/disk-hygiene/skills/clean/scripts/hygiene.test.sh` — 202 tests pass (193 pre-existing + 9 new), including: - injected-failure sweep across the guard's real call graph (`resolve_mode`, `_engine_gate_relevant`, `resolve_disk_hygiene_enabled`, `resolve_authorized_data_root`, `is_exact_kill_switch_probe`, `classify_exact_engine_command`, `powershell_decision`, a bare `KeyboardInterrupt`) — every case asserts exit `2` with non-empty stderr and exit `1` never observed - watchdog wiring (arm/cancel around `_decide`, on both the normal-return and exception paths) via a mocked `Timer` (no real thread/hang in the test process) - the watchdog callback's own hard-exit contract (`_watchdog_fire`), exercised in a real subprocess so `os._exit` cannot terminate the test runner - [x] `node scripts/validate-plugin-contracts.mjs` — 43 setup skills / 2093 plugin files, pass - [x] `claude plugin validate plugins/disk-hygiene/` — pass - [x] `bash scripts/check-hook-userconfig-argv.sh` — pass - [x] `bash scripts/check-changelog-parity.sh --check-bump origin/main` — pass - [x] Manual subprocess smoke tests: real invocation still denies correctly (~0.14s, no added latency from the watchdog); malformed-JSON stdin still returns exit 0 with a deny decision (unchanged, unrelated existing behavior) ## Related - #1416 — the parent transcript-sweep issue this was split out of (this PR does not close it; #1416's remaining spine, making guard failures visible to the operator as a class, is out of scope here). - #1242 — the prior, already-fixed `${user_config.*}` launch-refusal fail-open this bug is distinct from. Closes #1423 --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Summary
The
disk_hygiene_enabledkill switch was inert on a default install. The plugin-level engine-gate hook (hooks/hooks.json) carried a bare${user_config.disk_hygiene_enabled}argument; because the declared userConfigdefaultis unimplemented upstream (#46477 / #39455 / #39827), an unset-but-defaulted token drops the whole hook entry, so the gate never ran. The skill-frontmatter belt could not receive the value either (skill hooks get neither the${user_config.*}substitution norCLAUDE_PLUGIN_OPTION_*). Audit-only mode therefore degraded from deny-outright to prompt-gated.Closes #1019.
Fix
Both guard surfaces now resolve the toggle by reading
disk_hygiene_enableddirectly from user-scopepluginConfigsinsettings.json, via a new sharedlib/killswitch_config.pyreader:${CLAUDE_PLUGIN_ROOT}both surfaces already receive (fallback toCLAUDE_CONFIG_DIR/HOMEonly when--plugin-rootis absent — the report CLI / unit tests)..claude/settings.jsonenvblock reaches hook subprocesses and carries no provenance. Since CC 2.1.207pluginConfigsis honored only from user/managed/--settingsscope (project/local ignored), so a hostile repo cannot forge it.${user_config.*}argv is removed fromhooks.json(fixing the hook-drop);kill_switch_probe.pynow delegates to the same reader (its single-line JSON contract unchanged).Design note — supersedes the planned SessionStart + state-file ("C′")
The two enforcement surfaces are the same script through one resolve point (
destructive_guard.py), 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-scopepluginConfigs, ignore env, fail closed to enabled).Enforcement reach (accurate)
/disk-hygiene:cleanis active (the always-on gate defers on non-engine commands).§643 plugin-acceptance security review — PASS
The one new surface is a settings-file read (criterion 4). The playbook's own carve-out sanctions reading the plugin's own documented
~/.claudeuser-global config; it's located from${CLAUDE_PLUGIN_ROOT}with no../reach-out into consumer-repo data. Criterion 1 (hooks) is reduced — a${user_config.*}arg removed. Surfaces 2/5/6/7 untouched. Trust-surface delta recorded inREADME.md+CHANGELOG.md.Testing
test_hygiene.py: 179 tests OK (newDirectReadKillSwitchTestscovering the plugin-root channel,CLAUDE_CONFIG_DIRfallback, tamper-resistance, env/argv ignored, fail-closed; newhooks.jsoncontract test locking--plugin-rootpresent + bare${user_config.*}gone; guard helpers made hermetic).test_kill_switch_probe.py: 17 tests OK, unchanged (behavior-preserving extraction).hooks.json/plugin.jsonvalid.Related
defaultgap this works around: #46477 / #39455 / #39827 (recheck if resolved).🤖 Generated with Claude Code