fix(disk-hygiene): enforce disk_hygiene_enabled kill switch on PowerShell lane + via hook argv#968
Conversation
…hell lane + via hook argv
The disk_hygiene_enabled=false kill switch (audit-only mode) did not prevent
deletions, for two independent reasons; both are fixed.
B2 - the enabled flag never gated the PowerShell lane. main() routed PowerShell
calls to powershell_decision and returned before the enabled gate was computed,
so flagged deletion spellings (Remove-Item/rm/del/::Delete/recycle-bin) returned
ask - and could be approved - with execution disabled. The gate is now resolved
before the tool-name branch and threaded into powershell_decision, which denies
flagged deletions in audit-only mode and only prompts when execution is enabled.
D3 - the kill switch was inert under the env-injection failure. The guard read
CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLED from the hook process env and defaulted
to enabled when absent, but the runtime does not inject plugin env vars into a
skill-frontmatter hook's environment, so a configured false was silently
overridden to enabled. The clean skill's hook now passes the configured value as
a runtime-substituted --disk-hygiene-enabled ${user_config.disk_hygiene_enabled}
argument (exec-form args substitute ${user_config.*} where env injection does
not), and the guard reads the kill switch from that argument, honoring the env
var only as a fallback. Absent any channel the guard still fails safe to enabled.
Mirrors the --authorized-data-root argv mechanism.
Version 0.4.3 -> 0.4.4; CHANGELOG entry added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 13s —— View job PR #968 Review —
|
| Test | What it verifies |
|---|---|
test_powershell_deletion_spellings_denied_in_audit_only_mode |
B2: all 5 PS spellings → deny when disabled (via env fallback) |
test_kill_switch_blocks_every_lane_via_argv_without_env |
B2+D3 acceptance: false via argv, env UNSET → both PS and Bash lanes deny |
test_kill_switch_enabled_via_argv_without_env_still_gates |
Confirms argv true → ask, not a hardcoded default |
test_resolve_disk_hygiene_enabled_precedence |
Full precedence matrix: argv-over-env, case/whitespace, placeholder fallback, no-channel |
test_argv_flag_value_parses_both_arg_spellings |
Both --flag value and --flag=value forms, absent-flag → None |
test_skill_hook_passes_disk_hygiene_enabled_flag_matching_constant |
Seam lock: frontmatter args array matches guard constants |
The pre-existing test_powershell_deletion_spellings_force_final_prompt (enabled → ask) remains meaningful as a regression guard.
Observations (non-blocking)
1. run_guard_powershell_disabled implicitly relies on test-runner argv
test_hygiene.py line ~1322 — This helper sets the env var but does not mock sys.argv. resolve_disk_hygiene_enabled() reads sys.argv[1:] first; since the test runner's argv won't contain --disk-hygiene-enabled, from_argv is None and the env var correctly drives the result. This works, but it's a subtle implicit assumption. The parallel run_guard_enabled_argv helper (which does mock argv) could serve as a model if run_guard_powershell_disabled ever needs updating.
2. Seam test parses SKILL.md frontmatter as JSON — single-line dependency
test_hygiene.py line ~1573 — The seam test extracts args_line[args_line.index("["):args_line.rindex("]")+1] and parses it as JSON. This correctly handles the current single-line args: [...] format, but would silently fail (raise ValueError on index()) if the array is ever reformatted across multiple YAML lines. A low-priority brittleness — the test failing loudly if the format changes is actually acceptable behaviour for a seam lock, so this is a style note rather than a defect.
3. Placeholder exact-string comparison
destructive_guard.py line 168 — The unsubstituted-placeholder guard compares against "${user_config.disk_hygiene_enabled}" exactly. If the harness ever passes the literal in a different form (different whitespace, etc.), the check would miss it and interpret the placeholder as True (since "${...}".lower() != "false" is True). This is the same assumption as the --authorized-data-root precedent and is well-tested by the precedence test, but it is a documented caveat worth keeping in mind if the harness substitution behavior ever changes.
Security
- No new attack surface. The argv channel is model-unforgeable by the same argument as
--authorized-data-root(exec-form hook, harness-controlled substitution). - Fail-safe default preserved: unconfigured →
enabled=True→ guard active, every mutation still gated behind the human prompt. - The kill switch value is now honored on all four lanes: Bash scan/preview (always allow), Bash apply (ask/deny), PowerShell mutation spellings (ask/deny), PowerShell .NET Delete (ask/deny).
Docs / Changelog
SKILL.md, safety-model.md, and CHANGELOG.md are consistent with the code changes. The 0.4.4 version bump in plugin.json is correct for a security fix. The CHANGELOG entry is clear and complete.
|
Claude finished @kyle-sexton's task in 5m 17s —— View job Security Review — PR #968
Scope: Result: No critical or important security vulnerabilities found. The two fixes are correctly implemented and maintain the pre-existing fail-safe defaults. Two suggestion-level observations follow. SUGGESTION — Confidence: HighOnly the literal string
return from_argv.strip().lower() != "false"
return os.environ.get(_DISK_HYGIENE_ENABLED_ENV, "true").strip().lower() != "false"Any value that is not exactly Security direction is correct: unexpected values fail safe to SUGGESTION — Confidence: Medium
These helpers patch The new Notes on the core fixesB2 (PowerShell lane never gated): D3 (env-injection failure): Fail-safe chain:
All four paths are covered by the new tests. The acceptance test |
Post-green review pass (work-2 / T5, 2026-07-22T08:24:56Z) — classificationCI green (functional gates +
No inline threads to resolve. Flipping to ready; handing to babysit. Merge remains human/merge-lane. |
|
Claude finished @kyle-sexton's task in 2m 22s —— View job PR #968 Review —
|
| Input | Result |
|---|---|
argv "false" (any case/whitespace) |
enabled=False |
| argv is the literal unsubstituted placeholder | falls back to env |
| argv is empty string | falls back to env (falsy guard) |
| No channel | defaults to "true" → enabled=True (fail-safe, guard active) |
The --flag value and --flag=value spellings are both handled at lines 129–133. The absent-flag case (returns None, then if from_argv is falsy → env fallback) is also correct.
SKILL.md frontmatter at line 13 passes --disk-hygiene-enabled ${user_config.disk_hygiene_enabled} in exec-form args — this is substituted as a single OS-level argv token with no shell parsing, mirroring the --authorized-data-root precedent from #742.
Prior suggestion classifications
Both suggestions from the automated security review pass are correctly classified:
"Only literal false disables" — Non-issue. The disk_hygiene_enabled userConfig is typed "boolean" in plugin.json line 21, so the harness can only emit the canonical string "false" or "true". Non-boolean spellings ("0", "no") are not a reachable input and the fail-safe direction (unexpected → enabled=True, guard stays active) is correct. Classification valid.
"Test helpers don't mock sys.argv" — Deferred to #970. run_guard_disabled and run_guard_powershell_disabled rely on the test runner's argv not containing --disk-hygiene-enabled, which holds for all standard runners. The new run_guard_enabled_argv helper (which patches both sys.argv and os.environ) demonstrates the robust pattern and already covers the B2+D3 acceptance tests. Deferral is reasonable.
Test coverage
The six new tests accurately target the exact failure scenarios:
| Test | What it verifies |
|---|---|
test_powershell_deletion_spellings_denied_in_audit_only_mode |
B2: all 5 PS spellings → deny when kill switch is off via env fallback |
test_kill_switch_blocks_every_lane_via_argv_without_env |
B2+D3 acceptance: false via argv only, env var UNSET → both PS and Bash lanes deny |
test_kill_switch_enabled_via_argv_without_env_still_gates |
argv true without env → ask, confirming argv value drives the decision |
test_resolve_disk_hygiene_enabled_precedence |
Full precedence matrix including case/whitespace, placeholder, empty, no-channel |
test_argv_flag_value_parses_both_arg_spellings |
Both --flag value and --flag=value spellings; absent → None |
test_skill_hook_passes_disk_hygiene_enabled_flag_matching_constant |
Seam lock between frontmatter args and guard constant; prevents silent rename regression |
The pre-existing test_powershell_deletion_spellings_force_final_prompt (enabled → ask) still passing means the enabled path is demonstrably unchanged.
Security
No new attack surface:
- Exec-form hook substitution prevents argv injection through the config value.
- Fail-safe default preserved: unconfigured →
enabled=True→ every mutation still behind the final human prompt. - Kill switch now enforced across all four lanes: Bash scan/preview (allow), Bash apply (ask/deny), PS mutation spellings (ask/deny), PS
.NETDelete (ask/deny).
|
Claude finished @kyle-sexton's task in 2m 27s —— View job Security Review — PR #968
Scope: Result: No CRITICAL or IMPORTANT security vulnerabilities found. The two fixes are correctly implemented, the fail-safe default is preserved, and the argv-injection surface is not exploitable. Two SUGGESTION-level observations below. Core fix analysisB2 — PowerShell kill switch gate. D3 — argv channel.
Argv injection surface. The hook is exec-form (no shell), so
SUGGESTION — Confidence: HighAudit-only mode (
if tool_name == "PowerShell":
verdict = powershell_decision(command, enabled)
if verdict:
print(json.dumps(decision(*verdict)))
return 0When This is pre-existing behavior, not introduced by this PR, and is documented in SUGGESTION — Confidence: MediumOnly the literal string
return from_argv.strip().lower() != "false"
return os.environ.get(_DISK_HYGIENE_ENABLED_ENV, "true").strip().lower() != "false"Values |
…ill-switch argv seam (#971) ## Summary `resolve_disk_hygiene_enabled()` reads `--disk-hygiene-enabled` from `sys.argv[1:]` before the environment fallback (introduced in #382). Three helpers in `plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py` — `run_guard`, `run_guard_disabled`, and `run_guard_powershell_disabled` — patched `os.environ` to drive the kill switch but left `sys.argv` unpatched. If a test runner's real invocation argv happened to carry `--disk-hygiene-enabled <value>`, it would silently override the env-var mock and a `run_guard_powershell_disabled`-based test would observe `ask` where `deny` is expected. This is test-isolation fragility, not a live failure: standard `unittest`/`pytest` invocations never produce such argv, so CI is unaffected today. The fix seals the latent seam. ## Change Each of the three helpers now patches `guard.sys.argv` to a clean, flag-free argv (`[str(SCRIPT_DIR / "destructive_guard.py")]`) alongside its existing `os.environ` mock — the exact pattern the newer `run_guard_enabled_argv` helper already established. With no argv flag present, `resolve_disk_hygiene_enabled()` falls through to the environment mock, keeping the env var the sole channel under test. No production logic is touched; the underlying #968 fix is correct and security-reviewed clean. Version bumped `0.4.4 → 0.4.5` (patch — pure test isolation, no behavior change) with a matching `CHANGELOG.md` entry. ## Verification - `python -m unittest -v test_hygiene.py` — 81 passed, 4 skipped (platform gates). - `ruff check` on the touched file — All checks passed. - `markdownlint-cli2` on `CHANGELOG.md` — 0 errors. - `check-changelog-parity.sh --check` and `--check-bump origin/main` — pass. - `scripts/validate-plugins.sh` — manifests + catalog validated. ## Note (out of scope, follow-up candidate) `run_guard_powershell` (the enabled PowerShell helper) carries the identical env-only seam but is not named in #970, so it is deliberately left untouched here to keep this PR atomic to the issue's stated scope. Worth a follow-up to seal for full consistency. ## Related - #968 — the security-review SUGGESTION this PR resolves. - #382 — the argv-reading precedence in `resolve_disk_hygiene_enabled()` that this test change seals against. Closes #970 --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
The
disk_hygiene_enabled=falsekill switch ("audit-only mode") did not actually prevent deletions, for two independent reasons. Both are fixed here.ask(and could be approved) with execution disabled.falsewas silently overridden to enabled.Fix
B2 — gate the PowerShell lane (
skills/clean/scripts/destructive_guard.py).main()routedtool_name == "PowerShell"topowershell_decision()and returned before theenabledgate was computed (that computation was only reached on the Bash branch). The gate is now resolved once, before the tool-name branch, and threaded intopowershell_decision(command, enabled). A flagged mutation spelling (Remove-Item/rm/del/::Delete/recycle-bin) resolves against the kill switch via new helper_powershell_mutation_verdict:askwhen enabled (preserves the manual-handoff prompt),denyin audit-only mode. Engine (hygiene.py) calls stay hard-denied regardless.D3 — deliver the value through the hook argv (
skills/clean/SKILL.md+destructive_guard.py), mirroring the--authorized-data-root ${CLAUDE_PLUGIN_DATA}mechanism landed in #742 / #376. The frontmatter exec-form hook now passes--disk-hygiene-enabled ${user_config.disk_hygiene_enabled}in itsargsarray. Newresolve_disk_hygiene_enabled()reads the kill switch from that argv (generic_argv_flag_valueextracted from_argv_authorized_data_root), treats a literal/unsubstituted placeholder or empty value as absent, and honorsCLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLEDonly as a fallback. Absent every channel, the guard fails safe toenabled=true(guard active, every mutation still gated behind the final human prompt) — the pre-existing fail-safe default is preserved; the change is that a user who setfalsenow has that value honored.Per the plugins reference,
${user_config.KEY}is rejected only in shell-form fields; the sanctioned channel for exec-form hooks isargs(or the env var) — exactly our hook shape.Docs updated to match:
SKILL.mdaudit-only note,reference/safety-model.mdPowerShell paragraph (previously stated deletions are alwaysask),CHANGELOG.md, andplugin.json0.4.3 → 0.4.4.Verification
python -m unittest test_hygiene— 77 passed, 4 skipped (Linux-only platform gates). New tests:test_powershell_deletion_spellings_denied_in_audit_only_mode— the five mutation spellings +::Deletereturndenywhen disabled. The pre-existingtest_powershell_deletion_spellings_force_final_prompt(enabled →ask) still passes, proving the enabled path is unchanged.test_kill_switch_blocks_every_lane_via_argv_without_env— the centerpiece: a configuredfalsereaching the guard only via argv, with the env var UNSET (the exact env-injection failure) denies both a PowerShellRemove-Itemand a Bashapply.test_kill_switch_enabled_via_argv_without_env_still_gatesconfirms an argvtrue(env unset) yieldsask, proving the argv value — not a hardcoded default — drives the decision.test_resolve_disk_hygiene_enabled_precedence(argv-over-env both directions, case/whitespace-tolerantfalse, placeholder/empty → env fallback, no-channel → fail-safe enabled),test_argv_flag_value_parses_both_arg_spellings, andtest_skill_hook_passes_disk_hygiene_enabled_flag_matching_constant(locks the frontmatter-args ↔ guard-constant seam).Linters:
ruff check— All checks passed;markdownlint-cli2on the 3 changed markdown files — 0 errors;check-changelog-parity.sh --checkand--check-bump origin/main— pass (0.4.4entry present). (ruff formatis not the repo's gate —origin/mainis notruff format-clean either, so it is not run to avoid unrelated churn.)Caveat (inherited, unverifiable without the live harness): the
!= "false"comparison assumes a boolean userConfig serializes to the string"false". This is the same assumption the pre-existing env-var gate already made (CLAUDE_PLUGIN_OPTION_...was parsed identically), so it is no new regression; and live-harness substitution of${user_config.disk_hygiene_enabled}into exec-formargsrests on the plugins-reference guarantee (same substitution pass as${CLAUDE_PLUGIN_DATA}, already proven to resolve in this exact position) and cannot be exercised without the running harness.Closes #382
Related
--authorized-data-rootargv precedent this builds on.🤖 Generated with Claude Code