All notable changes to the disk-hygiene plugin are documented here. Format follows
Keep a Changelog; this plugin uses semantic versioning.
- The four disk-hygiene Python test wrappers parse the MIN_PYTHON floor, build the floor probe and pick an interpreter through one sourced test-wrapper library instead of four inline copies. Skip messages, argv and exit codes are unchanged.
- Settle destructive-guard verdicts through one helper, share the handle-contest and accepted-path overlap checks and the scan-complete payload in the clean engine, inline the monitor tail read and lowercase the launcher path in bash (behavior unchanged).
- setup skill: python3_alias_probe.py reports a stat failure through one call that varies only the detail text, and the kill-switch probe suite writes its toggle settings through one helper. Output byte-identical.
- killswitch_config.py builds its kill-switch probe reports through two shared wrappers and two sentence constants instead of nine hand-repeated strings; hook_telemetry.py's absolute-sink predicate returns its comparison directly; test_guard_decision_log.py shares its owner-only permission assertion. No behavior change.
- Cite the marketplace
docs/doctrine files by their lower-kebab names (docs/plugin-philosophy.md,docs/migration-playbook.md, and siblings); the files were renamed and the old uppercase paths no longer resolve.
-
Options reference drops its em dashes. The generated How-to-set-these block is rewritten by
scripts/sync-plugin-options-docs.py, which is the fix site: its output is regenerated, never hand-edited. The block no longer needs the ignore marker that exempted it from the repository's em-dash gate, so that marker is gone as well. -
Every markdown surface in the plugin passes
/ai-slop:audit. Em dashes in the plugin's own prose, including the clean skill's safety model, are rewritten as a comma, a period, a colon where a definition or list follows, or a restructured sentence. No safety rule, protected path, or deletion precondition changed: every "denied", "fails closed", "never", "only", flag name, and verdict token keeps its meaning. -
Reflexive
load-bearingandseambecome the concrete thing each stood for, including "the gluing defect" and "the identical unsealed input channel" in the released entries that describe them. -
The plugin's markdown is declared in
scripts/em-dash-purged-paths.txt, so the gate defends it from here on. The README's generated options block keeps its existing ignore marker: that prose belongs to the generator, not the output. -
Changelog, in-place wording corrections to released entries: the same rewrite was applied inside
[0.21.0],[0.20.35],[0.20.31],[0.20.19],[0.20.18],[0.20.15],[0.20.14],[0.20.13],[0.20.11],[0.20.10],[0.20.8],[0.20.7],[0.20.6],[0.20.5],[0.20.4],[0.20.3],[0.20.1],[0.18.0],[0.17.11],[0.17.9],[0.17.8],[0.17.6],[0.17.1],[0.17.0],[0.16.0],[0.15.0],[0.13.0],[0.12.0],[0.11.0],[0.10.2],[0.10.1],[0.10.0],[0.9.7],[0.9.6],[0.9.5],[0.9.4],[0.9.3],[0.9.2],[0.9.1],[0.9.0],[0.8.3],[0.8.2],[0.8.1],[0.8.0],[0.7.3],[0.7.2],[0.7.1],[0.7.0],[0.6.5],[0.6.4],[0.6.3],[0.6.2],[0.6.1],[0.6.0],[0.4.7],[0.4.6],[0.4.5],[0.4.4],[0.4.3], and[0.4.1]. Wording only; every entry's facts are unchanged.
- The engine's invocation grammar is declared once and consumed by both the parser and the guard. The set of subcommands and tiers lived twice: the argument parser built one copy and the destructive guard hand-wrote another to validate against. They could disagree, and only a reader comparing the two would notice. The grammar is now stated in one module that the parser is built from and the guard validates against, so a subcommand cannot exist in one and be missing from the other.
- Bash denial text names the guard that issued it (#3348). The always-on engine
gate and the session belt previously shared one
_bash_denial_guidancebody, so a session that never invoked/disk-hygiene:cleanwas told its Bash lane was restricted to the skill's scan/preview/handoff-verify/apply shapes. Each mode now explains its own scope: the engine gate says this invocation is gated and the rest of the Bash lane is unaffected; the belt says the skill was invoked, that the belt persists until the session ends, and names recovery as a new session). Skill frontmatter hooks stay registered for the rest of the session and plugin hooks run inside subagents, so the belt is not a subagent-escape hatch. Allow/deny decisions and the classifier allow-list disclosure are unchanged.
- Hook prompt text: drop the undated sibling-guard verification claim from the guard-launch-monitor warning (prompt-audit follow-up F2)
- Dated the four harness-version claims in the clean skill and its safety model with a basis, a verification date, and a recheck trigger (prompt-audit follow-up F6)
- Give the guard-launch-monitor telemetry-sink tests a sink Windows can execute, so the two cases exercise dispatch instead of timing out (prompt-audit follow-up F10)
- Hold the os-managed root-children preview case to the same platform-gate mock its siblings use, and compare the stash duplicate at its resolved spelling, so both pass off Linux (prompt-audit follow-up F10)
handoff-verifynames emptied containers in the same round. After the approved-path verdicts, it reuses the apply lane's bottom-upremoval_sort_keyto decide which inventoried directories the settled removals (clearorgone) leave empty, then runs each through the same categorical checks and reports them underemptied_containers(deepest first) withremovable_emptied_containerscounting theclearones. Verification still mutates nothing. The approved paths'clear/not_clearcounters, and the CLI exit code they drive, are unchanged. A cascade that used to cost a second full scan for one empty directory is now named completely in one round. Refs #3859.
- A later re-verify after a verify-one-delete-one step is not drift. Apply refuses to
rmdira directory only whenscandirstill finds an occupant. The container check therefore compares only the surplus (live children the snapshot did not record). Missing inventoried children are the settled removals progressing; a replaced inventoried child fails its own approved-path verdict and stays out of the settled set, so it cannot empty the container. - Approved paths are revalidated after container probes. Container walks and handle
checks can outlast a concurrent same-name replacement of an approved path, and the
container surplus-name check does not see that replacement. When any emptied container is
named,
handoff-verifyreruns the approved-path checks and recomputes containers from the post-revalidation settled set, so a staleclearis not emitted. - Windows handle probes skip descendants that are already gone. After verify-one-delete-one,
expected_pathsstill names settled missing children;CreateFileW(..., OPEN_EXISTING)on those returns ERROR_FILE_NOT_FOUND and would make the emptied containerhandle-state-unverified. The container probe now walks the live descendant set.
- The PowerShell engine-gate entry carries
iffilters, matching the Bash entry'sBash(*hygiene.py*). Every PowerShell tool call in every session was still launching the guard to be told it was irrelevant: on a warm interpreter cache that is fourexecvecalls (bash -c, the launcher through itsenvshebang, bash, the interpreter) and a 106 KB module import, counted withstrace -f. The harness now evaluates the filters before spawning anything, so a PowerShell call that does not name the engine and does not invoke an interpreter or call-operator through a variable costs this plugin no process. The 0.21.4 note that a PowerShell filter "must match every subcommand of a compound command" described allow rules, notif: the harness evaluatesifthrough the tool's own permission matcher, and the PowerShell tool's parses the command AST and runs the hook when any statement, pipeline element or nested command matches (verified in Claude Code 2.1.258'spreparePermissionMatcher:someover every collected command, case-insensitive glob; an unparsable command runs the hook). A mixed line such asGet-Date; python hygiene.pytherefore still reaches the guard and is still denied on the PowerShell lane, as are the|,&&, newline, CR LF and U+2028 forms. The assignment$script = '.../hygiene.py'; python $script scanis the shape a singlePowerShell(*hygiene.py*)filter misses: the matcher evaluates collected command nodes, so the literal path lives in the assignment and is not part of the laterpython $scriptcommand. Sibling filtersPowerShell(*python*$*)andPowerShell(*& $*)keep that invocation, and the call-operator form& $script, on the guard. No allow/deny decision changes for a call that reaches the guard. Residuals the filters still cannot see: an engine reached without its file name in any command node and without an interpreter or call-operator variable (a symlink or hard link under another name, a Win32 8.3 short name), which the Bash lane has accepted since 0.21.4; text the PowerShell parser assigns to no command (a comment naming the engine). The launcher's contract suite gains a kernel-level spawn census (strace -f, skipped where unavailable). The README's hook-budget accounting records the before and after census.
- Guard decisions are recorded locally, with no configuration.
lib/guard_decision_log.pyappends one JSON object per line to<plugin data root>/guard-decisions/decisions.jsonlevery time the guard reaches a verdict. Each line carries the timestamp, the decision, the rule that fired, the tool, the registration mode, the command that drove it, and the reason the host was given, so a denial explains itself from the record alone rather than by reproducing it. Before this, decisions left the process only throughHOOK_TELEMETRY_SINK, which is inert unless an environment variable names an executable, so on an ordinary install every decision was discarded as it was made. - Command text is omitted on the catch-all arms, and remaining text is secret-scrubbed. A
PowerShell
nonerecord (belt mode, no flagged spelling) and a Bash deny-by-default (not-exact-engine-command) persistcommand_charsrather than the command. Othercommand/reasonfields are shape-scrubbed (tokens, bearer headers,SECRET/KEY/TOKEN/PASSWORDassignments including$env:...) before the 400-character clip. - Owner-only files. The log directory is
0700and the live file0600, reapplied on every write so a leftover world-readable file is tightened. - The record distinguishes allowed, denied, and did-not-run.
guard_launch_monitor.pywrites the third state when it detects ahook_non_blocking_errorfor the guard: a hook that never launched cannot record its own absence, so the detector that already finds it now leaves the evidence behind. A PowerShell call the guard adjudicated without issuing a decision is recorded asnone, which is not the same event as an absent record. - Bounded by rotation, not by advice. The live file is retired to
decisions.previous.jsonlonce it reaches 1 MiB, so the record occupies at most about 2 MiB forever with no operator pruning. The bound is checked from the offset the append already returns, so it costs no extra syscall. Command and reason fields are clipped to 400 characters, which keeps a record a record of the decision rather than a copy of the payload, and keeps every line short enough that concurrent hook processes appending to the same file do not interleave. - Set
DISK_HYGIENE_GUARD_DECISION_LOGto0,off,false, ornoto turn the record off. It is opt-out, not opt-in: any other value, including an absent one, records.
- The existing
HOOK_TELEMETRY_SINKchannel is untouched. A configured sink receives exactly the envelopes it received before, with the same fields. - Measured cost on the hook path, per the hook-budget convention: the always-on defer branch (a
Bash command that does not name the engine) writes nothing and is unchanged. On a branch that
does reach a decision the process and exec census is identical to before, one
execveand one thread clone, and the record costs oneopenatplus onewriteplus onechmodon a warm data root, plus one failedopenat, onemkdir, and onechmodon the first write of an install. See the README's trust-surface record for the numbers and the method.
- clean:
scan --quietomitschildren_rollupfrom stdout and shortens the closing note, keepingstatus,target,snapshot, the three coverage terms,empty_directory_count, both byte totals,truncated_paths,errors,policy_sourcesandos_autoclean. The snapshot file carries the rollup in full in both modes, so the flag drops a duplicate rather than data. Default output is unchanged: a caller already parsing the rollup off stdout keeps it. On a real--max-depth 1home-directory scan the payload fell from 7,247 to 893 bytes (88 %); on a 200-child target, from 67,262 to 5,052 (93 %), because the rollup is the only part that grows with the frontier. - clean: the Bash guard admits
--quietas a third valueless scan flag, at most one per invocation and with no trailing value, so the flag is reachable without widening the grammar. No pipe, redirect, or shell-operator allowance is added; that rejection is unchanged. - clean:
--root-childrenscans get their own quiet note, and reportempty_directory_counton stdout like an ordinary scan does. Root-children mode's default note carries a coverage qualification nothing else on stdout encodes: the volume root and every skipped OS-owned/hidden/system/reparse entry were never walked, so the inventory is partial by construction andchildren_rollupcovers the selected children only. The skipped entries are recorded asroot_children_skippedin the snapshot alone andtruncated_pathsdoes not stand in for them, so quieting that sentence away would drop a fact rather than a duplicate. The quiet root-children note keeps the coverage sentence and drops only the rollup prose, and the field set--quietdocuments now holds in both scan modes rather than in ordinary scans alone.
hygiene.py's home-match docstring writes its case-variant example with a<user>placeholder, and the tests that need a real home value compose it (Path("/Users") / "alice",Path("/home") / "missing") instead of spelling one literal. The values the tests compare are byte-identical to before, so every assertion proves what it did. Thecleaneval prompt and the launch-monitor transcript fixture take the same placeholder treatment. The org machine-specific-path detector reads a literal user-home or checkout path as a leaked machine path wherever it appears, including inside a test.
- clean: the frontmatter hook comments, the
--executesentence, the Dev Drive sentence, and the Gotchas opener state the current rule without issue numbers or a diff against an earlier wording.reference/safety-model.mdstates the Windows and macOS decline, the flagged-spelling set, the kill-switch delivery, the hook launch form, the guard failure detector, theuser_configprohibition, the scratchpad rule, and the roll-up guarantee as present-tense rules with no issue numbers, plugin version pins, dates, or measured figures. One issue number is dropped fromreference/unsupported-platform-handoff.md. Upstreamanthropics/claude-codeissue pointers stay. - setup: two issue-number parentheticals removed; the guard's kill-switch scope now says
managed settings are read first, then the user file, matching
lib/killswitch_config.py. - Applied from the 2026-09 prompt-audit against Claude Fable 5.1 (docs/specs/prompt-audit-skills-2026-09.md).
- Both PreToolUse rows carry a
statusMessage. The Stop row already had one, so a silent destructive-guard failure was legible while the guard itself ran unnamed: the spinner said nothing for up to 60 s on a Bash call that could be blocked. Now "Checking the disk-hygiene delete against its authorized roots..." on both the Bash and PowerShell rows. (#3719) hooks/hooks.jsoncarries a top-leveldescription. One line naming what this plugin's hook set does, on a field the hooks reference documents as optional and every hook set here omitted. (#3719)
hook_telemetry.pydropstelemetry_enabled(), which had no caller. The search was repo-wide rather than plugin-local: the only live spelling is the bashhook::telemetry_enabledelsewhere, the telemetry contract doc never names the Python function, no test touches it, andemit()already self-guards on an unset sink. The guard imports the module but never calls it. Nothing else in this group moved, because the remaining files are allow/deny and fail-closed surface that the sweep's safety boundary keeps out of scope. The pinned ruff wrapper is clean and the telemetry suite ran 3 of 3.
- Test-harness tidyings from the repo-wide sweep. The launcher's contract
suite drops a fake-interpreter stub that was written and made executable but
referenced nowhere, and folds its two remaining stub writes into the loop form
the same file already uses; its engine-read contract block trades history
narration for present-tense rationale, keeping both halves. The guard's
main()loses a comment paragraph that narrated the comment's own two earlier revisions, with every mechanism fact it carried still stated in the paragraphs either side of it. One test flattens a dict lookup fed by a nested conditional key into a plainif/elif/else. No allow/deny list, scope check, dry-run gate, delete-safety predicate, symlink handling or kill-switch path was touched, and the cleanup engine and hook shim are not in the diff at all. The guard change is proven inert at the compiler level: identical AST and all 91 code objects equal across bytecode, constants, names and flags. The platform rewrite was checked over 211,111 inputs with zero mismatches. Suites: 338, 3, 23 and 166 Python tests plus the three shell suites, all green.
- The engine gate carries an
iffilter,Bash(*hygiene.py*). The gate only ever judges a command that carries the engine's file name (_engine_gate_relevant), so the filter is a superset of its own relevance check; every other Bash call no longer pays a Python interpreter start to be told it is irrelevant. - The engine gate is registered once per tool. An
iffilter is scoped to the tool it names, so the singleBash|PowerShellentry carrying aBash(...)filter never launched the gate for a PowerShell call. TheBashentry keeps the filter; a separatePowerShellentry carries none, because PowerShell filtering must match every subcommand of a compound command and would skip this kill-switch guard silently on a mixed line. Every PowerShell call therefore still pays the interpreter start, as it did before 0.21.4.
- Options reference cites the plugin-reconfiguration convention. The generated How-to-set-these block no longer restates the 2.1.240 verified-version record.
- setup: cite the plugin-reconfiguration convention for the native
/plugin configure/ headless--configpath instead of restating the verified-version record inline.
- Manual-handoff docs name the hook-issued
ask, not a guaranteed human prompt. The PowerShell deletion lane returnspermissionDecision: "ask". Official PreToolUse docs say that value prompts the user to confirm and, since v2.1.211, forces the prompt even in auto mode, but an explicitpermissions.askrule is what those pages treat as forcing a prompt inautoandbypassPermissions(dontAskauto-denies instead). The engine-apply step uses that same hookask, so SKILL.md and README no longer call it a guaranteed final prompt. The safety model and unsupported-platform handoff now say so.
- The guard's watchdog no longer blocks commands it never judged. The
internal deadline is wall-clock, so it measured contention as readily as it
measured a stall, and this hook fires on every
Bash/PowerShellcall in every session. Under concurrent load an ordinary read-only command could cross the 10s deadline while the guard was still inside_engine_gate_relevant's marker-free fallback, and the blanketexit 2then BLOCKED it, observed about six times in one session, each succeeding on an identical retry. At expiry the guard now distinguishes "could not decide" from "decided deny", and the downgrade is available in exactly one situation:engine-gatemode with a provably marker-free command, where the completed verdict would knowably have been the instant plugin-level defer, soaskis strictly more protective than the outcome the guard would have reached (a defer emits no decision at all and lets the command run) and strictly less blocking than theexit 2it replaces. Everything else keeps the pre-change deny:beltmode always denies, as do a command carrying the engine marker and a stall before the payload parses. Belt is the default the skill-frontmatter registration runs under, since it passes no--modeandresolve_mode()falls back to it, and there Bash is deny-by-default with_engine_gate_relevantnever consulted, so a marker-freerm -rfwould have been denied rather than deferred. Every path still delivers a decision, so the killed-hook fail-open ADR 0004 documents stays closed. Decision emission is now serialized and latched to one object, because two threads writing stdout would splice malformed JSON, which PreToolUse reads as no decision at all.
- The hook launcher resolves its interpreter once instead of on every tool
call. It re-derived the interpreter every invocation: a
sedread of the engine forMIN_PYTHON, a whole extra Python spawned only to evaluate a version predicate, adirname, and on thepybranch a third spawn. Process creation is the dominant cost on Windows and the term that explodes under concurrent load. Measured spawn census for one warm invocation, counted through aPATHshim: 4 spawns to 1. The remaining one is the guard itself. Interleaved A/B over 24 alternating pairs on a Windows host: p50 5446ms to 1418ms, p95 16991ms to 7874ms, median paired ratio 3.71x. Resolution logic is unchanged and still runs on a cache miss; the floor is now recovered inside the candidate interpreter, keepinghygiene.MIN_PYTHONthe single origin. The resolved interpreter is cached under$HOME/.cache/disk-hygiene, keyed on the launcher's own (version-pinned) directory and invalidated byPATHchange, loss of executability, a zero-length App Execution Alias stub, an interpreter newer than the record, or TTL expiry. Any miss or corrupt record re-resolves; a cache failure can never produce "no interpreter", which is the guard's silent fail-open.
lib/test_hook_telemetry.pyno longer races the fire-and-forget sink. The sink's>redirect creates its output file empty beforecatwrites it, so the two sink tests' existence-polls could read an empty file on a fast host and fail onjson.loads("")(about three runs in four on a fast Linux container). Both tests now wait for non-empty, parseable content with a five-second deadline, retrying on a truncated mid-write read as well, mirroring the_wait_for_filediscipline the sibling suites already use. Productionhook_telemetry.pyis untouched; the suite is deterministic over repeated runs (12/12).
- Behavior-preserving simplification sweep (batch-simplify). The setup skill's
test_python3_alias_probe.pyreplacesmake_file's hand-rolledopen(path, "wb")context manager (with a dead zero-size guard) with the exact stdlib equivalentpath.write_bytes(b"\0" * size). Byte-identical file contents and permissions verified empirically for every size the suite uses; refutation-verified; 10/10 tests pass.
setup: normalized the probe-don't-recite directive and repaired residual grammar defects. The directive had fractured under the same per-plugin de-slop campaign;docs/PLUGIN-PHILOSOPHY.mdnow owns the rule under aruntime-groundedclause, and the eighteen sites that campaign fractured carry one wording. Twenty setup skills assert the rule; the other two,context-guardandrate-limit-guard, state it about their own scripts in their own words and are left for a separate pass, so the fleet is not yet down to a single form. Whole-repo extract-ssot sweep.
- Authoring-doctrine pass over
README.md,skills/clean/reference/unsupported-platform-handoff.md. Fixed pointers and cross-references that did not resolve; sentences that parsed two ways. Every edit was verified against the file by an agent that did not propose it. Prose only; no behavior, contract, or trigger phrase changed.
safety-model.mdnamed one valueless scan flag where the guard strips two. The engine grammar section claimed--confirmed-large-scanwas "the one valueless scan flag", whiledestructive_guard.pystrips at most one each of--confirmed-large-scanand--root-children. The same document already documents--root-childrenin its root-children section, so it contradicted itself on a safety surface. The prose now matches the parser. Coupling pass, apply lane; no code change.
- Comment-residue cleanup (
/code-tidying:audit-comment-residue). History narration, plan/session references, and stale back-references in code comments rewritten as present-tense rationale or removed. Comment-only, no behavior change.
- Behavior-preserving simplification sweep, wave 11 (batch-simplify). hygiene.py:
reclaimable_local_bytesuses the same walrus-filtersum(...)idiompreviewalready uses (callee pure, one evaluation per entry, empty input still int 0), andentry_is_empty_directorymerges two branches with byte-identical bodies into oneisinstance(inventory, (dict, set)); the repo's ruff-format hook reflowed two token-identical regions in the same file. test_python3_alias_probe.py moves a backslash-continuedwithto the parenthesized form. Adversarially refutation-verified; suites 317 + 10 green; guard, kill-switch, and destructive-boundary code untouched.
- Comment triage pass (
/code-tidying:dissolve-comments). In the clean skill's engine, the Win32 magic literals inwindows_handle_statebecame named module constants (FILE_FLAG_BACKUP_SEMANTICS,FILE_ATTRIBUTE_NORMAL,OPEN_EXISTING), keeping only the share-mode rationale as prose. No behavior change; test suite green before and after.
-
Long reference files carry a
## Contentsindex. 1 reference file in this plugin gained one.The predicate is
audit-progressive-disclosure's own: a reference file over 300 lines with no table of contents, which both official sources agree on by that length. Scope came from the detector's tier classification rather than a line count, soSKILL.mdfiles are excluded by construction: they are invocation tier, not the on-demand reference tier the rule names. Files with fewer than five H2s were held out, because a three-row index on a long file earns nothing and the doctrine offers a grep recipe instead. Purely additive, with anchors generated from each file's own headings and verified to resolve. Docs-hygiene sweep, L2-progressive-disclosure.
- The README no longer pins a section of the clean skill's safety model by heading anchor. The
standalone-checkout handoff cited
skills/clean/reference/safety-model.md#standalone-git-checkout-evidence; that anchor no longer exists, and the link now points at the file. Heading anchors stay private even for an intra-plugin cite, because renaming a heading is exactly the refactor the encapsulation contract protects (ADR 0018). Docs-hygiene sweep, L4-encapsulation.
## Requirements and platform supportis a requirements section again. It had grown to 81 lines of design rationale and regression history under a heading that promises what you need in order to run this. Registration mechanics, the two guard surfaces, the kill-switch read, the Stop detector, the hook-lifetime caveat, and the Windowspython3stub gotcha moved into a new## How the guard is registered. Six version deltas this file already carries were dropped rather than moved. Docs-hygiene sweep, L8-write-for-humans.## Plugin-acceptance security reviewis now## Security posture. The heading named the review that was conducted rather than the property a reader wants, and the section now opens by stating the posture. Every fact, measurement, date, and host detail survives. Docs-hygiene sweep, L8-write-for-humans.- The generated options block sits under
## Configuration. It was under## Sources, which means citations in every other plugin README here. The generated table itself is unchanged; a## Configurationheading was added above it. Docs-hygiene sweep, L8-write-for-humans.
cleansplit against the progressive-disclosure audit. The unsupported-platform handoff, reachable only when--executeis requested on Windows or macOS and a human has approved an exact path list, moved toreference/unsupported-platform-handoff.md. That condition is now the pointer's when-to-read clause, so the body carries the rule and the spoke carries the procedure. No content was dropped.
- Behavior-preserving simplification pass (repo-wide batch-simplify). Removed the dead
import shutilfromskills/clean/scripts/destructive_guard.py(ruff F401; only comment mentions remained) and corrected the stale comment above_READONLY_SUPPORTING_BASH_HEADSto the code's actual contract (bare names denied outright; absolute trusted-path heads only); retargeted the oneguard.shutilmock intest_hygiene.pyto the same module object; removed two provably unreachable fixtures fromlib/test_hook_telemetry.py; hoisted three method-localimport osstatements intest_guard_launch_monitor.py. AST comparison confirms exactly these four semantic deltas plus formatting. Suites green (317 + 23 + 3 + 17); no guard predicate touched.
- Repo-wide
/ai-slop:audit fixpass (#3359). The "denied outright, not merely prompted" construction inclean/reference/safety-model.mdrestated positively as "an outright deny with no prompt fallback", which also drops a dash pair.
- Instruction-surface de-slop (#2891, disk-hygiene cluster). Rewrote this plugin's
README.mdand everySKILL.mdto drop em dashes under the repo's zero-tolerance house policy, using/ai-slop:audit fixsemantics: periods or commas, or a restructured sentence, never parentheses, en dashes, or a spaced hyphen as a stand-in. Meaning stays; only the mark and the sentence break change. The generated options block is ignore-fenced becausescripts/sync-plugin-options-docs.pystill emits em dashes from its shared template.
- setup: normalized restated setup-contract prose (preamble, probe-ladder opening, never-writes boundary, and/or headless-reconfigure recipe as present) to the canonical fleet wording, keeping the operable text inline with a provenance-only citation (whole-repo extract-ssot batch, #2698).
setupskill: the headless reconfiguration route no longer prescribesclaude plugin uninstall+ reinstall. That instruction rested on an unversioned claim thatclaude plugin install --configis ignored once a plugin is installed, and following it dropped the plugin's whole storedpluginConfigsentry, resetting every declared option to its manifest default. On Claude Code 2.1.240 a plainclaude plugin install … --configagainst an already-installed plugin printsalready installedand still writes the value, so that is now the documented route, stamped with the CLI version it was verified against (#3111).applyalso now separates the write from its effect: the stored value changes immediately, but the running session's hooks keep theCLAUDE_PLUGIN_OPTION_*they were handed at session start, so verification means rerunningcheckin a FRESH session. A same-session rerun reports the old value, which is not a failed write. It never asserts an unobserved change.- Docs: the generated options block's headless route no longer implies
--configapplies only at install time, and now carries the CLI version its claim was verified against (#3111). The block also now separates the write from its effect: the value is stored immediately, but hooks are handed theirCLAUDE_PLUGIN_OPTION_*at session start, so a check run in the same session still reports the old value and that is not a failed write. Two upstream links that pointed at empty backward-compatibility anchors on the settings page were repointed at the headings that hold the content.
clean: both single-repository routes name the Skill tool (#3002). "Use/repo-hygiene:clean" became "Invoke/repo-hygiene:cleanvia the Skill tool", and the worktree-checkout bullet's "hand off to/source-control:worktree status/cleanup" became "hand off by invoking … via the Skill tool" (that target isdisable-model-invocation: false, so the invocation-reach invariant permits it). Wording only. The scope boundary is unchanged. Follows the invocation-mode rubric's cross-skill phrasing rule, now unconditional after the fleet sweep.
- The
disk_hygiene_enabledrow in the README's options table used a curly apostrophe in "clean skill's". It was the only genuine punctuation residue found across the marketplace's 1214 tracked markdown files, so it is repaired rather than exempted.
destructive_guard.pydrops three docstrings that restated their function names (comment-only; full hygiene suite green).
test_hygiene.pyno longer lets a fixture's git identity land in the caller's repository (#2840). The module clearsGIT_DIR,GIT_WORK_TREE,GIT_INDEX_FILE,GIT_COMMON_DIR,GIT_PREFIX,GIT_OBJECT_DIRECTORYandGIT_CONFIGfromos.environat import.git -C <fixture>is a readability guard, not an isolation guarantee:-Cchanges directory, while an exported absoluteGIT_DIRoverrides repository discovery, sogit config's default--localscope resolves to the caller's gitdir and the fixture identity is written there instead, leaving the fixture with no.gitand silently re-authoring the caller's next commit. That is the incident behind #2827.GIT_CONFIGis a second leak path rather than another spelling of the first: it replaces the file thegit configsubcommand reads and writes, so an identity write follows it past-C, past a clearedGIT_DIR, and past the working directory. Test-only change; no shipped skill, hook or engine behavior is affected.
- A per-immediate-child roll-up in every scan payload (#2851). The skill tells operators to open
a large target with
--max-depth 1and reason over its immediate children, but the engine emitted only a flat per-entry list plus target-level totals, so the view the workflow prescribes existed in no engine output, and the triage that surfaced this was run off a hand-built PowerShell table.children_rollupnow carries one row per immediate child withname,kind,walked,logical_bytes,reclaimable_local_bytes,size_qualifiers,entry_count,newest_mtime_ns, andunwalked_reasons; the row is present for every child the run covered, whatever that child's coverage, so a gap is visible per child rather than only intruncated_paths.scan-completeechoes the block alongside the snapshot. - The roll-up's bytes carry the same qualifier channel every other byte surface here has (#2851).
logical_bytesis a LOGICAL total, so a cloud placeholder's remote size, a hard link's shared object, and a sparse file's unallocated extent all inflate it above what deleting the child would return. This is the blockSKILL.mdtells the operator to lead the report with. Each row therefore also carriessize_qualifiers(the union observed in the subtree) andreclaimable_local_bytes(unqualified files only), mirroringtarget_reclaimable_local_bytes. Both follow the same walked/null discipline as the other aggregates. Found by an adversarial fresh-context verifier, which measured a walked row reportinglogical_bytes: 10000000for a sparse child in a payload whosetarget_reclaimable_local_byteswas 0. unhinted_entries, the third coverage term (#2851).scan-completealready reportedentriesandhinted_entries; it now also reportsentriesminushinted_entries, every inventoried entry no hint judged, so a run with 7 hinted entries out of 40,247 reads as 0.017 % hint coverage rather than as seven findings. It counts every INVENTORIED entry, not only fully walked ones, which is a deliberate narrowing of the request's "walked entries carrying no hint": counted that way the three terms partition the inventory exactly, soentriesis alwayshinted_entriesplusunhinted_entriesand neither number needs a caveat to add up.SKILL.md§3 documents both the roll-up block and the rate.
- Under
--max-depth 1a non-empty child's totals arenull, not numbers, and this is deliberate (#2851). The request asked for a recursive byte total, entry count, and newest mtime per immediate child, populated under--max-depth 1. Those two constraints cannot both hold: a recursive total for a non-empty child is only knowable by walking that child, and walking it is precisely what the depth bound exists to prevent. Measured on a 740-path fixture, the bounded pass opens 8 directories, stats 41 paths, and inventories 8 entries with the roll-up, byte-identical to the same pass without it, while the unbounded walk costs 20 opens, 4,427 stats, and 740 entries. The roll-up therefore reads only what the walk already recorded and reports the honest subset: exact numbers for loose files, empty children, and anything genuinely walked;walked: falsewith all three aggregatesnulland a named cause (depth-cut) for everything else. A partial subtree sum is never presented as a total andnullnever degrades to0, because0remains the genuine "this child is empty" answer that the zero-byte-residue work depends on.
- Single, early-terminating read of the engine per hook launch (issue 2853). The always-on
Bash|PowerShelllauncher used to run two separate full-filesedpasses over the ~3,500-line engine, neither stopping at the match, to recoverMIN_PYTHONfrom near the top of the file. It now runs onesedwhose address-blockqterminates the read at theMIN_PYTHONline.hygiene.MIN_PYTHONremains the floor's single origin (PR 1028), andtest_hygiene.py'sVersionFloorTestsshape/count lock still passes;hooks/run-python-hook.test.shnow asserts behaviorally (via a recordingsedshim plus an argv replay against a two-floor fixture) that a launch reads the engine exactly once and that the read stops at the first match instead of scanning to EOF. - README states the hook's measured always-on share (issue 2853). The trust-surface record's
launch-count sentence is replaced with a measured figure per the hook-budget convention's method
(2026-08-16, Windows 11 + Git Bash): ≈ 190–300 ms per shell tool call for the engine-gate hook
across batches (92 single runs), ≈ 320–410 ms parallel wall for the two-registration set measured
concurrently (
&+wait, 60 pairs), of which the engine read accounts for ≈ 24 ms (≈ 13%) in the single-read form, down from ≈ 38 ms (≈ 19%) in the two-pass form.hooks.json's"timeout": 60is unchanged.
- Behavior-preserving simplifications from the repository-wide batch-simplify pass: duplicated helpers folded, dead code and redundant constructs removed, no functional change. Every group was verified by a fresh-context verifier agent against the plugin's own test suite.
- Flag the ordinary send-an-item-to-the-Recycle-Bin spelling (#2850). The
Shell.Applicationrule shipped for #2595 required the literal bin folder id,NameSpace(10)/NameSpace(0xa), so$sh.NameSpace('<parent folder>').ParseName('victim').InvokeVerb('delete'), which addresses the item through its parent folder and never names the bin, returned no verdict and raised no prompt. That shape is now keyed on the delete VERB rather than on the folder id, and returnsask(ordenyin audit-only mode) like every other recognized deletion spelling. The suffixedInvokeVerbExspelling is covered by the same token, which a word boundary closed afterInvokeVerbhad excluded. - What the rule deliberately still does not catch (#2850).
MoveHereinto an ordinary (non-bin) folder is a MOVE, not a deletion, and keeps deferring; so doCopyHereinto an ordinary folder, non-delete verbs such asInvokeVerb('open'), the omitted default verb, and an opaque verb argument (InvokeVerb($verb)). The delete-verb set is enumerated, not identity-checked, and the pattern set now says so. A COM shell verb is named by the item's own verb collection, so completeness is not implied. The test note claimingMove-Itemis the catch-all for these COM spellings is corrected:_POWERSHELL_MUTATION_WORDSmatches neitherMoveHerenorInvokeVerb.
- Restore the last two files #2635 never got back (#2590). #2635 changed seven files; the
stale-base squash in #2639 deleted them, #2714 restored four and #2803 restored four, and the
overlap left
README.mdandskills/clean/evals/evals.jsonunrestored onmain, a partial recovery the silent-revert canary cannot detect, because the deleting commit is already a recorded incident. The README's overview, approval-contract bullet, and deletion-report paragraph lead with tidiness again, and eval 12 (empty-directories-remain-first-class-tidiness-findings) is back alongside eval 1's provenance-first expectation. - Three of the restored surfaces are corrected rather than restored verbatim (#2590). Eval 1's
expectation is byte-identical to #2635; the other three are not, and each deviation is
deliberate. (a) The README approval bullet: #2635's own hunk was malformed and would have
re-introduced a duplicated, truncated bullet; the replacement follows
SKILL.md§5, which is the authority on what the approval table names. (b) The README deletion-report paragraph keeps the locked / changed / protected / needs-elevation / unverified enumeration that #2635's wording would have collapsed to "skips". (c) Eval 12's prompt is retargeted through the documented--root-childrenselection: as #2635 wrote it the prompt scannedC:\directly, which the engine has always refused, so the eval's expected output was reachable only by bypassing the confirmation gate.
- Re-land the engine half of tidiness-first reporting (#2590). #2635 shipped
empty_directory_count, planprovenance/riskvalidation, and preview/apply tidiness fields; a later stale-base squash deleted the engine while #2714 restored only the skill prose. Snapshot,validate_plan,preview, andapplyagain keep zero-byte directories first-class and require provenance/risk on every candidate.
- Windows read-only Bash allowlist was inert: 0 commands accepted (#2774). Two
compounding defects: (A1) MSYS path reinterpretation ran after
Path.is_absolute(), which is False for POSIX-style heads on Windows-native Python, so/usr/bin/lsnever reached the Git-root mapping; (A2)_readonly_supporting_basenamedid not strip.exe/.EXE, so real Git-for-Windows binaries never matched the allowlist. Move the MSYS branch ahead of the absolute gate and strip Windows executable extensions. - Engine-gate hard-
allowleak for allowlisted inspection commands (#2774). In--mode engine-gate, an allowlisted command that also names the engine path emittedpermissionDecision: "allow", bypassing the user's prompt in every consumer session. Downgrade that path toask; belt mode still hard-allows. - Exclude user-writable
%LOCALAPPDATA%\\Programs\\Gitfrom NT trust roots (#2774) and reword the trust-root comments so they no longer claim independence from environment-derived input.
- CI: add a focused
disk-hygiene-guard-windowslane (windows-2025, GuardTests only) so NT trust/MSYS/basename branches cannot regress silently on Linux-only CI.
- Re-landed the belt's read-only allowlist and session-honest docstring, and anchored
its trust check (#2618, #2691). PR #2641 merged from a tree predating PR #2639 and its
squash merge reverted #2639 wholesale; CI stayed green because the revert removed the
tests with the code.
resolve_mode()again documents that a skill-frontmatterPreToolUsehook stays armed for the rest of the session rather than only while cleanup is the active work, and the read-only supporting Bash allowlist (ls,test,stat,du,pwd,basename,dirname,find,file,[) is restored as absolute paths under trusted system directories.findis gated by the full GNU/BSD side-effect primary set, everything else still deny-by-default. - Recycle Bin deletion spellings are recognized on the PowerShell belt (#2595).
Microsoft.VisualBasic.FileIO.FileSystem::DeleteFile/DeleteDirectoryandShell.ApplicationNameSpace(10)/NameSpace(0xa)withMoveHere/InvokeVerbnow prompt likeRemove-Item. The skill's own manual-handoff lane recommends Recycle Bin removal, so these were the one deletion route the belt never saw.
- Trusted-binary matching is anchored to a resolved installation root, not a path
substring (#2618).
_TRUSTED_READONLY_BIN_SUBSTRINGS_NTmatched fragments such as/git/usr/bin/and/windows/system32/anywhere in a path, so a repository-controlledD:/anyrepo/git/usr/bin/findwas trusted and a planted binary carrying an allowlisted basename was hard-allowed, bypassing even the user's own permission prompt. Trust now derives from independently located Git installation roots (ProgramFiles,ProgramFiles(x86),LocalAppData\\Programs) and from%SystemRoot%, compared as an anchored case-insensitive path prefix, never from aPATH-selectedgit.exe. [must clear the same executable-identity check as every other head (#2618). The[ ... ]branch returned before the trusted-binary check, so[was trusted on name alone, the shell-function-shadowing exposure the guard itself cites to deny barepython/python3. It is now allowed only as an absolute trusted/usr/bin/[ ... ]form.- Bare allowlisted heads are denied (#2618).
shutil.which("ls")finds the system binary while Bash still executes an exportedBASH_FUNC_ls%%first; only absolute paths under trusted system directories (MSYS/usr/bin/...mapped through the known Git root on Windows) can hard-allow.
- Re-landed tidiness-first reporting and corrected the belt's documented posture (#2590,
#2618). PR #2639 and then #2641 squash-merged from stale bases and silently reverted the
prior markdown fixes (#2691). Reports are again ordered by tier and evidence strength, never
by byte size, with empty directories as first-class findings;
provenanceandriskreturn to the plan schema; preview and apply lead with tidiness. The skill and safety-model docs now state that skill-frontmatterPreToolUsehooks stay armed for the rest of the session, drop the false exec-form claim (shell form since 0.17.9 / #2568), name the long-path Recycle Bin hard-stop, declare relocation out of scope, and thin Gotchas harness duplication intosafety-model.mdpointers. Guard-code recovery remains in the sibling lane.
- PowerShell
>>append redirection is flagged like>(#2675)._POWERSHELL_OUTPUT_REDIRECTmatched neither character of a>>pair, because(?![=>&])rejects the first>and the lookbehind rejects the second, so<cmd> >> append.txtwrote a file with no prompt while the same command with>prompted. Append is matched explicitly (>>,2>>,*>>) without widening that lookahead, which the stream-merge exclusion from #2627 and the$null-discard exclusion from #2671 both depend on.>> $nullstays silent, a discard rather than a file write, and requires a real token terminator after$nullso punctuation continuations like>>$null/out.txtstay flagged.
- PowerShell
$nulldiscards (2>$null,*>$null,>$null) are no longer flagged as file-overwriting redirection (#2615). The stream-merge exclusion released in 0.17.11 closed only the>&form; the character after>in a discard is$, so every2>$null, PowerShell's/dev/nulland the standard way to silence a noisy read-only command, kept prompting._POWERSHELL_OUTPUT_REDIRECTnow also excludes a>whose target is$null, spelled as guardrails'ps::write_bypassspells the same exclusion and matched case-insensitively (PowerShell variable names are). Only horizontal whitespace is skipped between>and$null, and$nullitself must be followed by a real token terminator (whitespace,;,|,),}, or end-of-string), so punctuation continuations like>$null/out.txtor2>$null\evil.ps1stay flagged as file writes. Real redirection still prompts:2>out.txt,> out.txt,1>file,'data' > file, a non-$nullvariable target (2>$nullish), and a command that discards one stream while redirecting another (... 2>$null > out.txt).
- The documented argument surface names the root-children flags again (#2588). Resolving the
conflict in #2641 inserted a fresh "Arguments and boundaries" opening paragraph above the existing
one instead of merging into it, orphaning that paragraph's continuation. The section was left with
two overlapping sentences, and the authoritative first one silently dropped
--root-childrenand--root-child <name>, reintroducing exactly the wrong-argument-surface defect #2589 was filed for, against the feature #2636 had just shipped. The two sentences are merged back into one carrying every flag, and the skill'sargument-hintnow lists the root-children flags it had never carried. Documentation only: the engine has accepted both flags since #2636 and its behavior is unchanged.
- Empty directories at
--max-depthare inventoried as size 0 (#2618). A depth cut used to mark every boundary directory truncated even when it had no children. One first-child probe (no recursion, fail-closed on unreadable) now records empty boundaries as walked with size 0 and keeps them out of the truncated set; directories with children and unreadable directories keep the previous not-walked marking. VCS and protection cuts are unchanged. Emptiness does not answer those refusals.
- A strictly evidence-gated manual path for provably redundant standalone Git checkouts
(#2596).
handoff-verifyaccepts an optional--vcs-evidencefile and remains read-only. Without that option, and in preview/apply unconditionally, VCS metadata and tracked content stay categorically protected. With it, the verifier relaxes only the Git-specific blockers and.gitscan boundary after all four gates pass live: porcelain status is empty (including untracked, gitignored-but-present, and submodule dirtiness via--ignored=matching); every local branch tip plus a detachedHEADis confirmed by exact SHA throughgh apiagainst the checkout's configuredgithub.comremote; every stash SHA appears in an independent declared checkout's stash list (or there are no stashes); and the checkout is bound to the existing exact-path operator-approval file. The live.gitrepository set must exactly match the evidence map, every common Git directory must remain inside the approved checkout (rejecting linked worktrees), and stash copies must sit outside every approved deletion path with a--git-common-dirdistinct from the candidate's Git store (so a linked worktree of the same repository cannot count as a backup). Any missing tool, unsupported provider, dirty tree, unconfirmed head, unmatched stash, malformed output, timeout, or set/boundary mismatch retains the categorical reasons and returnscontested; every non-Git protection remains untouched. The Bash guard admits only the exact read-only--vcs-evidence <file>handoff shape.
- Read-only supporting Bash allowlist verifies executable identity (#2591). Bare
names (
ls,find, …) are allowed only whenshutil.whichresolves into a trusted system directory (/bin,/usr/bin, and siblings; Windows System32 / Git usr\bin when applicable). Absolute paths under those directories are allowed; relative path-qualified forms and PATH-shadowed binaries outside trusted prefixes fail closed (same rationale as denying barepython/python3). Symlinks are realpath'd so a trusted-prefix link into an untrusted tree is denied.
- Empty-directory counting is linear in inventory size (#2590). Snapshot finalization precomputes parent paths once instead of scanning the full inventory per directory, so the tidiness metric stays tractable near the 250,000-entry limit.
- Scan-error directories are not counted as empty (#2590). When
os.scandirfails, the directory is recorded as not-walked (unknown) and excluded fromempty_directory_count, so a coverage gap is not reported as empty residue.
- Reporting is tidiness-first; reclaimable bytes are secondary (#2590). Skill guidance,
scan/preview/apply report fields, and the approval table now lead with provenance, what an
entry is, why it is removable, and risk. Empty directories stay first-class and rankable via
snapshot
empty_directory_count, previewempty_directory/empty_directories, and applypaths_removed/empty_directories_removed. Byte totals remain available but no longer frame the run. Plan candidates requireprovenanceandriskalongside the existing evidence fields.
- Root-children mode for OS-managed volume roots (#2588). Targeting
C:\or/without a new flag still fails closed, and nothing walks an OS-managed root as a whole. With--root-childrenthe engine enumerates that root's immediate entries only, hard-excludes OS-owned / hidden / system / reparse / mount / protected-shell-folder / non-directory names (preferring more exclusions when ambiguous), and returnsroot-children-selection-requireduntil the operator names one or more admitted directories via repeatable--root-child <name>. Selected children are audited into one snapshot and one report; the volume root's own files and every skipped entry are never inventoried. Documented inskills/clean/SKILL.mdArguments, the confirmation gate, the scan template, andreference/safety-model.md. The skill-scoped guard accepts the new scan flags.
- Linux OS-provisioned root directories are excluded from root-children admission (#2588). The
Linux allowlist now matches the Windows/macOS posture for conventional OS roots (
home,root,tmp,opt,srv,media,mnt, and the existingbin/boot/… set), so/tmpand/homeare not reported as admitted audit targets. - Root-child selection preserves exact basenames on case-sensitive hosts (#2588). On Linux,
--root-child Cacheresolves only toCache, not a case-folded sibling such ascache, and the scan filter keeps that exact name. Windows and macOS remain case-insensitive.
- PowerShell stream merges (
2>&1,*>&1) are no longer flagged as file-overwriting redirection (#2615)._POWERSHELL_OUTPUT_REDIRECTmatched the>inside2>&1because its lookaround only excluded adjacent<,>, and=. In PowerShell>&is only ever a stream merge and never designates a file, so ordinary diagnostic commands that capture combined output were prompting as mutations, approval-fatigue noise that blunts real deletion prompts, especially once the belt stays armed for the rest of the session (#2591). The detector now also excludes a following&;2>out.txtand'data' > filestill prompt.
- Document the real clean-skill argument surface (#2589). The Arguments
section omitted
--max-depthand--confirmed-large-scan(and related flags the engine accepts), which undercut the bounded-first large-target workflow the skill body requires.
- The skill-scoped guard launches in shell form too, closing the last exec-form instance
(#2568).
skills/clean/SKILL.md's frontmatter belt was the third and final registration left on exec form after #1416:"command": "python3"plusargs, unchanged since #215 and so predatinghooks/run-python-hook.shentirely. Exec form is a barePATHlookup, and on stock Windowspython3resolves to the zero-lengthWindowsApps\python3.exeApp Execution Alias stub, which is not a real executable: the belt could not launch there at all, and a failed hook launch is non-blocking, so it silently enforced nothing. Unlike the wired hooks this instance was latent, not dead. It works whereverpython3is a real interpreter, so the conversion was held to argv equivalence rather than merely to launching: the vectordestructive_guard.pyreceives is byte-identical before and after, verified against roots containing spaces and backslashes, with only argv[0] changing from the interpreter name to the launcher path. The command string substitutes only${CLAUDE_PLUGIN_ROOT}, the sole token Claude Code provides to a skill-frontmatter hook;--authorized-data-root${CLAUDE_PLUGIN_DATA}is deliberately not reintroduced, because that token is unavailable on this surface and causes launch refusal (#1014). Residual, unchanged: the launcher exits 0 silently in guard mode when no interpreter resolves anywhere on its ladder, so this closes "cannot start against the alias stub", not "fails closed with no Python". - The skill-hook tests no longer encode the launch form they were meant to check. Three tests
in
skills/clean/scripts/test_hygiene.pyread the frontmatter form-specifically: one required anargs:line (and would have raised on shell form), one hand-stripped quotes off thecommand:line, and one asserted the literalpython3as the interpreter. That is the same bug-as-contract shape that let the wired guard ship dead twice. The frontmatter is now read into a hook mapping and fed to the existing form-agnostic_hook_argv()helper, so both surfaces are asserted through one path; the interpreter test now exercisesrun-python-hook.sh's real resolution ladder instead of restating it. Two tests were added: one asserting the four portability properties for this surface (launcher named incommand, noargs,shell: bash, every placeholder double-quoted), verified to fail against the pre-change frontmatter, and one asserting the argv equivalence above. - Docs that described this hook's form are corrected.
README.md,skills/setup/SKILL.md,skills/clean/reference/safety-model.md,hooks/run-python-hook.sh's header, and thepython3_alias_probe.pydocstring and operator message all stated that the belt launches as the literalpython3, several of them as the rationale for a check. The reason is now grounded on the launcher rather than on a hook form that no longer exists.
/disk-hygiene:setup checkverdicts the interpreter LADDER, notpython3alone. Direct fallout of the conversion above: while the belt namedpython3in exec form, a stubbed first rung genuinely was a guard-launch failure, so step 2 mappedstore-alias-stubstraight to FAIL. Now that every surface routes throughhooks/run-python-hook.sh, that mapping reports a healthy install as broken. A host with real Python installed without "Add to PATH" but with thepylauncher has a stubbedpython3, a workingpy -3, and a guard that launches on every call, yet would have been told to reinstall Python. Step 2 now treats the alias probe as diagnostic input, resolves the ladder in the launcher's own order (skipping stubs), and checks the selected interpreter against the parsedMIN_PYTHON. FAIL is unchanged in substance where it matters, an exhausted ladder or a below-floor interpreter both still FAIL under a disabled toggle, and a stubbedpython3beside a workingpython/py -3becomes a WARN naming the real residual: a barepython3typed by hand still opens the Microsoft Store. The probe's own return values are unchanged; only the mapping to a verdict and the message wording moved.
- Wired hooks launch in shell form, restoring the destructive guard on Windows (#1416).
0.17.6moved bothhooks/hooks.jsonregistrations onto"command": "bash"+argsto resolve a real Python 3 interpreter (#1504), and in doing so reintroduced the exact launch failure #1006 had already fixed for the skill-frontmatter hook. Exec form (argspresent) is a barePATHlookup, and on Windowsbashresolves to the WSL relaySystem32\bash.exebefore Git Bash:execvpe(/bin/bash) failed: No such file or directory. A hook that fails to launch is a non-blocking error, sodestructive_guard.pynever ran and the PreToolUse gate silently enforced nothing on every such host. Both registrations now namerun-python-hook.shdirectly with"shell": "bash"and noargs, which Claude Code routes through Git Bash instead of aPATHlookup. Every${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA}placeholder is double-quoted, so the argv is byte-identical to the exec-form vector across paths containing spaces. The #1504 Python-resolution behaviour is unchanged. Only the launch mechanism moves.hooks/run-python-hook.test.shpreviously asserted.command == "bash", encoding the defect as the contract; it now asserts the portability property (launcher named incommand, noargs,shell: bash, every placeholder quoted). - Security records now assess the shell-form launch instead of asserting the old exec form.
The README trust-surface record and
skills/clean/reference/safety-model.mdstill bounded the plugin-level hook by "exec form (no shell)", a safety claim the same change disproved, so the plugin's own security assessment reasoned from a false premise. Both now state what shell form does and does not guarantee: the command string is a fixed literal in the plugin's ownhooks.jsonwith no model-, repo-, or session-supplied interpolation, whose only substituted values are Claude Code's own double-quoted${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA}placeholders, verified byte-identical to the exec-form argv for roots containing spaces and backslashes, while noting that those placeholders are substituted textually before bash parses the result, so the quoting bounds whitespace and backslashes rather than every shell metacharacter. The invariant is now maintained byhooks/run-python-hook.test.shand the form-agnostictest_hygiene.pyhook helpers rather than being structural (repo-wide gate: #2569).skills/clean/SKILL.mdsplit its single launch bullet per surface: the wired gate resolves Python throughrun-python-hook.sh, while the skill-scoped belt is the one still exec-form on a barepython3(#2568).safety-model.mddropped a stale claim that theStopdetector shares the guard'spython3lookup and leaves that vector unreported, which #1504 already closed.
- Docs: actionable
/plugin configureguidance now uses the marketplace-qualified form (<plugin>@<marketplace>; generated option blocks use@<marketplace>) perdocs/extensibility-contract-smoke-tests.mdTest E (#1360). Targetless references to the flow stay unqualified.
- Wired hooks launch through a bash Python resolver (#1504). Both
hooks/hooks.jsonregistrations now invokehooks/run-python-hook.sh, which resolves a real Python 3 interpreter (rejecting the zero-length WindowsAppspython3alias stub) before exec'ing the guard or the Stop detector. When no interpreter resolves, the guard still fails open (exit 0) and the detector emits asystemMessageon stdout, so the blind spot the detector exists to surface is visible even when barepython3cannot run.
- HOOK_TELEMETRY_SINK envelopes on both wired hooks (#1505). The fleet's first
native Python telemetry emitter (
lib/hook_telemetry.py) mirrorshook::emit_telemetryfor the stdlib-onlydestructive_guard.pyPreToolUse guard and theguard_launch_monitor.pyStop detector. Meaningful outcomes emitok/blocked/error; pure inapplicability short-circuits stay silent.
- Guard-launch detector scans transcript head plus tail (#1514). Guard failures that fall outside the 2 MB tail window are no longer lost when a later turn appends a large record.
- PowerShell guard now surfaces move/rename/overwrite spellings (#387).
Move-Item,Rename-Item,Set-Content,Out-File,New-Item -Force, output redirection, andFormat-Volume/Clear-Diskjoin the existing deletion-spellingaskbar on the PowerShell lane.
_discard_streamno longer re-closes the fd it just repaired. When stderr's fd was closed outright,os.open(os.devnull)could return that same fd number;dup2was then a no-op and the unconditionalclose(null_fd)left fd 2 closed again, defeating the null-device redirect. The guard now skips closingnull_fdwhen it is the target fd. Covered by a unit test that closes fd 2 before calling_discard_stream.
-
Upstream doc stamps re-verified against the live pages (2026-08-10). Each dated claim below was re-checked against the complete raw markdown source of the page it cites (
https://code.claude.com/docs/en/<page>.md), not a summarized fetch, and each was confirmed by a verbatim quote before its stamp was refreshed. No claim changed; only the verification dates moved.skills/clean/scripts/destructive_guard.py: the quoted hooks-reference sentence on exit code 1 being a non-blocking error, andexit 2being the policy-enforcement code, is still present verbatim.
- The bare
/<skill>alias for this plugin's skills. TheirSKILL.mdfiles no longer declare a frontmattername. The field is optional and defaults to the directory name, so declaring it only restated the path while registering a second, unnamespaced command, which the slash-command picker then echoed back as/plugin:skill (skill). Invoke a skill by its namespaced command; the command itself is unchanged.
Version note:
0.14.0is never published. This entry claimed it while open, and 0.15.0 shipped first with a note pointing here; taking the next free number on merge is what keeps both entries truthful about the order they actually landed in.
-
Byte accounting can say "unknown" and "not reclaimable local bytes" (#1806). Finding 2 of the live audit: a truncated subtree emitted
logical_size: 0, byte-identical to a genuinely empty directory, while hard-linked names each contributed their fullst_sizeto every total. PR #1818 landed thesize_qualifiers/file_attributesmechanism for cloud placeholders and deliberately left aggregates alone so this change could own the rest.Every entry now records
nlinkandallocated_size(cheapst_blocks * 512on POSIX; null on Windows where that field is absent). A truncated directory, whether by max-depth, protected name, or VCS boundary, carrieslogical_size: nullandnot-walkedrather than pretending to be empty. Files withst_nlink > 1carryhardlinked; sparse files carrysparsewhen the platform exposes the signal. Snapshot, preview, apply, and the scan-complete summary each reportreclaimable_local_bytes(ortarget_reclaimable_local_bytes/reclaimable_local_bytes_removed) as a figure distinct from the walked logical roll-up: every qualified entry is excluded, so a report can no longer honestly claim gigabytes removed when the observed free-space delta is ~0 because the bytes were shared, remote, or never inventoried.
Findings 3 (a summarize surface), 4 (Stop-detector marker amortisation), 6 (probe path
provenance vs the guard's trusted settings channel), and 7 (run-state retention / snapshot path
containment) stay out of this PR. Each needs a design or coupled-grammar call rather than a
mechanical completion of the byte-qualification vertical slice. Findings 1 and 5 already shipped in
0.13.0.
-
A confirmation question is unanswerable when its acceptance bar names something the question never showed. The gate applied one bar, "an affirmative answer naming exactly the tier and path list just shown", to every question the skill asks, including the no-target prompt and §1's large-scan confirmation. Neither has presented a tier or a path list, so no reply a human could give satisfied the stated bar, and the two questions the gate exists to protect were the only ones it could actually be cleared for. The question surface rule and the answer floor (the user's own answer, this session, never inferred, stop on rejection) stay common to all four questions; what an answer must name is now stated per question: a directory for target selection, the target plus a deliberate unbounded walk for scan scope, the exact tier and path list for removal and the manual handoff. §1's and §6's cross-references now name their row instead of asserting the deletion bar applies unchanged, and the gate states the obligation that generated the defect: ask each question so it shows what its row requires the answer to name.
-
The confirmation gate fell back to an inline question only when
AskUserQuestionwas absent. Permission modedontAsk"auto-denies tools unless pre-approved …AskUserQuestion… denied even if you've allowed them" (permissions, fetched 2026-08-08), which leaves the tool visible in the pool while every call fails; only a bare-name deny rule "removes the tool from Claude's context entirely". Absence and denial are therefore distinct states, and keying the fallback on absence let adontAsksession pick a tool it cannot use and leave the destructive confirmation gate unsatisfied rather than asking inline. The fallback now triggers on absent, denied, or otherwise unusable, including a denial discovered only by calling it, so a state neither named case anticipates still routes to the inline question. -
The
python3alias probe could not be reached on a machine whose only alternate interpreter cannot run it.setupstep 1(b) classifies thepython3resolution with a bundled inspect-only probe launched through some other interpreter, and routed to the PowerShell equivalent only when no such interpreter existed at all. A real-but-incompatible launcher is not absent, so the check had no path to a verdict and could classify neither the Store stub nor its own remediation. Python 3.6, which the same section already names as an interpreter that rejectsfrom __future__ import annotations, and a legacypython2.x are both such launchers. The PowerShell fallback now also covers a chosen interpreter that emits no verdict.
Version note:
0.11.0is claimed by #1804 (PR #1818) and0.12.0by #1805 (PR #1819), both open against this manifest. This entry takes the next number so the three do not collide; merged in issue order the changelog reads contiguously.
-
Hint matching is case-insensitive, on every platform (#1806).
has_protected_name()casefolds andmatching_hints()did not, so on Windows and macOS, where both spellings name the same file, protection was case-robust while discovery was not. Measured against the shipped baseline before the fix:Thumbs.db,tmp-build, andscratch.mdeach matched a hint whilethumbs.db,TMP-build, andScratch.mdmatched nothing.Every glob the engine evaluates now goes through one
glob_matches()helper, covering hints, consumer protection globs, and the protection re-checks in the preview, verify, and apply lanes, so discovery and protection cannot disagree about what a name is. The protection-side globs move deliberately rather than by accident, and casefolding is the safe direction for both roles: a protection glob that matches more can only keep more, and a hint that matches more can only surface more for triage, since hints are discovery signals and never cleanup verdicts. The helper usesfnmatchcaseon casefolded operands rather thanfnmatch, whose folding follows the host platform. A matcher whose verdict changes with where the scan runs is not a matcher a protection can rest on. -
Atomic-write staging remnants are hinted as a class, not as one producer's filename (#1806).
*.tmprequires.tmpas a suffix and.claude.json.tmp.*encodes one producer's exact prefix. Neither matches.tmpas an infix before a pid and random suffix, the standard write-temp-then-rename shape, while the producer-specific hint's ownreasonclaimed to cover the class. A scan of one sibling plugin's state directory returnedhinted_entries: 0across 63 entries, 61 of which were remnants of exactly that shape; they surfaced only because a subagent read the directory positionally.A new
atomic-write-staging-remnanthint (*.tmp.*, ceilingmedium) covers the class..rate-limits.json.tmp.<pid>.<random>andsettings.json.tmp.4now hint where they previously matched nothing. The producer-specific hint still fires alongside it, since it carries a narrower reason and a class hint does not replace that. -
The Bash denial text no longer under-reports the allow-list (#1806). The documented bootstrap path is to submit a wrong shape so the denial teaches the grammar, and it enumerated four engine subcommands while omitting the read-only kill-switch probe that
_decideallows before the classifier ever runs. A consumer learning the allow-list from the denial never learned the probe is permitted, and the probe is the step that lets the model state the kill-switch value honestly instead of assuming the default. The denial now also discloses the bundled engine's own path, which is the only route left when a rendered body's${CLAUDE_PLUGIN_ROOT}arrives unexpanded and the exact-path identity check denies every guess.The enumeration and the grammar are now one list:
classify_exact_engine_commandrejects any subcommand outside_ALLOWED_ENGINE_SUBCOMMANDSbefore its own dispatch, and both bundled script paths come from one accessor each, so the message cannot teach a grammar the classifier does not implement.
Version note:
0.11.0is claimed by the cloud-placeholder fix (#1804, PR #1818), which is open against the same manifest. This entry takes the next number so the two do not collide; merge #1818 first and this changelog reads contiguously.
-
The engine gate's "provably a different file" escape no longer covers this plugin's own stale engines (#1805). #1640 and #1611 fixed over-gating: a word naming an existing file that is not the bundled engine defers, so a consumer's own
tools/hygiene.pyis not mistaken for this engine. Claude Code keeps a replaced version's directory on disk after an update, so that same escape also covered every previous version of this engine sitting beside the current one, each a genuinely different file, each deletion-capable, and each answering to nothing but its own containment once the always-on gate defers.The consequence is a kill-switch bypass, not an unbounded-delete bypass: with
disk_hygiene_enabled: falsethe plugin-level gate is the only guard whenever the clean skill is not the active work, and it deferred. The stale engine's own preview, approval-token, and platform blockers still applied. Versions at or below 0.8.1 predate settings-based kill-switch enforcement entirely.Measured on the audit host: 17 cached version directories,
0.3.0through0.10.2, 16 carrying an intact engine (0.9.4's is absent). Against the installed 0.10.2 guard, all 15 non-current engines resolve, are notsamefilewith the bundled one, and the plugin-level gate deferred on anapply --executeinvocation of every one of them. The documented "about two weeks" retention bound does not hold in practice:0.3.0is still present, so the window is unbounded.The gate now refuses the escape to any path resolving inside
<plugins>/cache/<marketplace>/<name>. That prefix is derived from the guard module's own__file__, not from argv or the environment, so nothing outside the process can redirect it, the same reasoning that keeps the kill-switch read offCLAUDE_CONFIG_DIR. A--plugin-dircheckout carries no such prefix and the narrowing is inert there, which is correct: a checkout has no cached siblings, and narrowing on it would gate a contributor's work on their own tree.This does not relax or re-break #1640 and #1611. A consumer's own engine-named script outside the cache still defers, on both the plain and the operator-carrying shapes; verified before and after against an identical synthetic cache layout, where the stale sibling flips from
deferstoGATESwhile the consumer tool staysdefersand the bundled engine staysGATES.Residual: a copied engine, one carried outside the cache tree, is still outside the prefix, as it is outside every identity check the gate makes. That is the copy-evasion class the gate has always accepted, and the engine's own preview/approval-token containment remains the authority.
-
Cloud-sync placeholders are now hard-protected instead of being the most attractive target in a home audit (#1804). The engine's only structural defense against cloud-sync content was
is_linkish(), which treats a Windows reparse point as protected. The dominant OneDrive dehydrated-placeholder class carries no reparse bit when read throughos.lstat, so the whole subtree was walked and every placeholder was recorded as an ordinary file withprotected_reasons: []. Itslogical_sizeis the remote byte count while local occupancy is roughly zero, so the tree also looked like the largest reclaimable win on the volume. Deleting a placeholder propagates the delete to the provider, which for a tenant sync root is the organisation's only copy.Measured on the audit host before the fix: 1,101 files walked, 872 dehydrated placeholders totalling 13,770,936,008 bytes, 0 of 872 flagged by
is_linkish(). After the fix the same tree reports 842 entries carryingcloud-placeholder(the remaining 30 sit under subtrees an existing name protection already truncates), and the only entries left unprotected are the 229 genuinely local, hydrated files.hard_protection()now contributes acloud-placeholderreason from the file attributes it already reads, so the protection reaches every lane at once:scan,preview,handoff-verify, andapply's pre-removal recheck all consult that one predicate. It is deliberately independent of the reparse test rather than folded into it: this is precisely the class a reparse test cannot see. Both flags are derived from a singlelstatper ancestor, so the walk's stat load is unchanged.FILE_ATTRIBUTE_RECALL_ON_OPENis deliberately excluded from the predicate, against the obvious reading of the attribute names. Its value,0x00040000, is the same number asFILE_ATTRIBUTE_EA, and Microsoft documentsRECALL_ON_OPENas appearing "only in directory enumeration classes" while every attribute read here comes fromlstat(File Attribute Constants). Read throughlstatthe bit therefore means "has extended attributes": a sweep of two non-cloud trees on the audit host found 1,552 fully-local files carrying it, including .NET build output and temporary.nodefiles. Including it would have protected exactly the artifacts this engine exists to reclaim. With the bit excluded, the same 412,270-entry sweep flags zero false positives while the tenant sync root still flags correctly. -
A tenant cloud-sync root is protected by name, not only by its contents (#1804). Attribute protection covers placeholder files, but measurement showed the containing directories carry no cloud attribute at all, since 99 subdirectories under the tenant root all read plain
0x10. A fully hydrated sync root therefore has no protected descendant, and deleting it still destroys the cloud copy. Name protection was exact-match and shipped the literalOneDriveonly, soOneDrive - <Organization>, the documented shape of a OneDrive for Business sync root, whose tenant portion varies per installation, matched nothing.The baseline now carries a
protected_name_globslist, matched casefolded throughfnmatchcaseso the verdict does not depend on the host platform's case rules. Consumers could not have closed this themselves:protected_exact_namesis not overlay-extensible, and an overlay'sadditional_protected_path_globsare matched relative to the scan target, so a standing policy protects such a root only when the target happens to be its parent. Protection that must hold for every target has to ship in the baseline.The list is deliberately short, because a protected name applies at every depth: a protected directory is never traversed, and it reports
logical_size: 0, which is byte-identical to a genuinely empty directory. Over-protection is therefore not free. It silently under-reports. Shipped: the globOneDrive - *(measured on the audit host, and the documented shape of a OneDrive for Business sync root), the globDropbox (*)and the exact nameDropbox(Dropbox documents bothDropbox (Personal)andDropbox (<business name>)as folder names), and the exact nameiCloud Drive.Two candidates from the report were rejected after checking them.
Boxis a common enough directory name in source trees that protecting it at every depth would make ordinary directories untraversable and silently zero-sized.Google Driveis a legacy Backup-and-Sync name: current Google Drive for desktop streams to a virtual drive letter (G:by default on Windows, Drive for desktop settings), not to a folder under the user profile.Only the OneDrive class was measured.
iCloud DriveandDropboxwere confirmed unprotected by name on the audit host, but their file attributes were never sampled, so they are protected on name alone and their placeholder behaviour remains unverified.iCloud Drivecarries the space and is the folder name Apple documents directly under the Windows user profile.Effect on the reported scenario: in a depth-1 scan of the user home,
OneDrive - <Organization>moves fromprotected_reasons: []tobaseline-protected-name, and the same path is now rejected outright as an audit target rather than silently scanned.
- Per-entry
size_qualifiersandfile_attributesin the snapshot (#1804). A recorded byte count carried no way to say "these are not local bytes", so a placeholder's remote size was indistinguishable from reclaimable content. Every entry now records the attribute wordlstatalready returned plus asize_qualifierslist, and a cloud placeholder is qualified whether or not it is protected. Protection stops the deletion, and the qualifier stops the misreading. This is an additive per-entry trace only; no aggregate's definition changes here.
- The clean skill's positional-triage rule reads an entry's own
protected_reasonsrather than testing membership ofprotected_exact_names. Protection now also comes from name patterns and from live filesystem state, and a rule naming a single policy field walks straight past a sync root whose name embeds a tenant.
-
The engine gate no longer denies commands naming a DIFFERENT file whose name ends in
hygiene.py(#1611)._engine_gate_relevantdecided marker relevance with a bare substring test over the command string, sotest_hygiene.py, this plugin's own test suite, read as an engine invocation. In any consumer session with the plugin enabled, that denied the natural commands for working on it:python3 -m unittest -v .../test_hygiene.pyandruff check .../test_hygiene.pywere both refused, on the Bash tool and on PowerShell. The literal-parse path was already correct, since it basename-matched (Path(word).name == _ENGINE_MARKER) and deferred, so only the operator-carrying path misfired, which is why the failure looked arbitrary: the same command gated or deferred depending on whether it contained a&&. Relevance now uses that same basename equality everywhere, via one_carries_markerhelper, so the two paths agree on what "is the engine" means.This is a precision change, not a relaxation. A basename test is only as good as the tokens it reads, so the narrowing is paid for by deriving those tokens as maximal runs of path-legal characters (
[^A-Za-z0-9._\-/\\:]+as the delimiter). Enumerating shell syntax instead would be a losing game, because an assignment glues the filename with=, a list with:, and a metacharacter with;, and missing any one of them silently un-gates a real invocation. Inverting the question is total:hygiene.pyis spelled entirely from the kept characters, so splitting on everything else can only expose the engine filename, never hide it.engine=hygiene.py && python3 "$engine" apply,FOO=1 BAR=hygiene.py python3 "$BAR" apply,foo;hygiene.py,$(hygiene.py scan), andtrue|hygiene.pyall still gate.Identity still outranks the filename for the newly-deferred name. Because
test_hygiene.pyno longer carries the marker, it routes to the marker-free branch, whose job is to catch a LINK to the engine under another name, and that branch scanned only whitespace tokens, so an operator glued to the path (/tmp/test_hygiene.py;echo done) left...;echoattached,samefileresolved nothing, and a link to the real engine deferred. The path-legal tokens are scanned there too now, which can only ever gate more. A link to the engine named like the suite gates beside;,|, and&&, and now also gates with no operator at all, where it deferred before this release.A relative marker path in an operator-carrying command is now treated as unknowable rather than provable. The "provably a DIFFERENT file" escape resolves a token against the guard's working directory, but that branch is reached precisely because the command carries an operator, and an operator can be a
cd. From a directory holding an unrelatedhygiene.py,cd <plugin-scripts>;./hygiene.py scanlet the escape "prove" a different file and defer while the shell ran the bundled engine. The escape now requires an absolute path. This also closes two pre-existing fail-opens of the same shape (cd <plugin-scripts> && ./hygiene.py applyand its bare-name spelling), which deferred before this release. Behavior change worth noting: a consumer invoking its ownhygiene.pyby RELATIVE path inside an operator-carrying command (python3 ./hygiene.py --help && echo ok) now gates where it previously deferred. That is the fail-closed direction and it is deliberate. The guard cannot know which directory that path is relative to; an absolute path still defers.Two further shapes are handled where the token alone is not enough. A Bash line continuation is removed before tokenizing, because the shell eats
\+ newline while reading the line and otherwise it stays welded to the filename ashygiene.py\, hiding a multi-line invocation of the real engine. And the basename is taken by splitting on both separators rather than withPath().name, which is platform-flavoured:PureWindowsPath("/x/hygiene.py\")yieldshygiene.pywhilePurePosixPathkeeps the backslash, so aPath-based predicate would gate on Windows and fail open on Linux.Copy-evasion coverage is untouched because it never routed through the marker: a link to the engine under any other name gates by
os.path.samefileidentity, and a byte copy remains the accepted residual the function's docstring already names. -
The engine gate no longer denies a consumer's own
hygiene.pybecause of how its parent directory is spelled (#1640). Detection wants aggressive splitting and resolution wants whole paths, and one token list was serving both. The "provably a DIFFERENT file" escape requires an ABSOLUTE path, but it read the path-legal fragments, so any character outside that class split a consumer's absolute path and left the fragment carrying the filename relative, unprovable, and denied:python3 /tmp/consumer+tools/hygiene.py --help && echo donegated a file that has nothing to do with this plugin.~is what makes this ordinary rather than exotic. A Windows 8.3 short-name segment (C:\Users\<user>~1\...) puts unpunctuated paths under ordinary temp directories into the same population. Resolution now reads the whole shell word containing the token, with quoted spans kept intact so a path with spaces resolves too, while detection keeps the fine tokens exactly as they were.The widening cannot travel: a token is paired with its enclosing word by SPAN, never by substring containment, so a marker token is only ever proved a different file by its OWN word. Containment-based pairing would let
python3 /abs/consumer/hygiene.py --help && python3 hygiene.py applyborrow the first word's absolute path to "prove" its bare second invocation different, and defer while the real engine ran. Residual, deliberately left: an operator with no surrounding whitespace (python3 /tmp/c+x/hygiene.py&&echo done) still gates, because the whole word is then/tmp/c+x/hygiene.py&&echo, which resolves to nothing. That is the fail-closed direction, and widening the tokenizer to chase it would re-open the gluing defect this release exists to close. -
A filename spelling the FILESYSTEM resolves to the engine no longer bypasses the gate. Win32 discards trailing dots and spaces from a filename and resolves
::$DATAto the main data stream, socd <plugin-scripts> && python hygiene.py. applyopened and ran the kill-switched engine while no token's basename was the marker, so the guard deferred. 8.3 short names are a third spelling of the same kind. The fix asks the filesystem instead of listing spellings: a relative word is identity-checked against the ENGINE'S OWN directory, which is precisely the directory such a command mustcdinto for the alias to run. That closes trailing dots, trailing spaces, NTFS stream suffixes, and short names in one move, where enumerating them closes one per review round. The name predicate is unchanged and stays platform-independent; identity is what carries this.Verified as a differential against the pre-change guard over 89 command shapes: engine invocations, wrappers, assignments, concatenations, substitutions, pipes, backticks, redirects, line continuations, post-
cdrelative paths, linked aliases, filename aliases, punctuated and short-name consumer paths, proof-borrowing shapes, mentions, and near-miss names. Every shape holds its prior verdict except the intended ones: four filename-alias shapes and one path-list assignment move toward GATING, and fourteen consumer-path shapes move toward deferral. The suite gained four regression tests, including a mechanical check that the span-located token partition is identical to the split partition it mirrors, so a future refactor cannot quietly change what detection reads.
- Safety model now documents the live agent scratchpad hazard (#1637).
machine-health's newclaude-temp-rootcheck routes its findings here, which makes a Claude Code temp root a named target for this skill. Its hazard is not the session running the clean but a concurrently running other session, whose scratchpad is an active working directory with no marker separating it from an abandoned one, and directory age cannot separate them, since a long-running session's scratchpad is old and live at once. The new "Live agent scratchpads" section records that no new machinery is needed: live-handle proof, live re-discovery of VCS markers, identity-and-descendant equality since snapshot, and immediate verdict expiry already hold the line structurally rather than by heuristic. It also states the two consequences plainly: a Windows temp root is a manual-lane job because the engine returnsexecution-platform-unsupportedthere, and a temp root is a low-confidence target however large it looks, because the tier follows what can be proven quiescent rather than what would be reclaimed. No behavior change.
clean's approval points now state an invariant plus a conditional surface, instead of namingAskUserQuestionas the only way to confirm (#1724). All three named that tool: the §1 large-scan confirmation, the §5 removal approval, and the §6 unsupported-platform handoff. It is not always in the pool: permission modedontAskdenies it unconditionally, a bare-namepermissions.denyrule removes it from Claude's context entirely, and adisallowed-toolsentry removes it from the pool while the skill is active, each leaving the text naming something absent. A new Confirmation gate section owns both halves once: the bar (the user's own affirmative answer, in this interactive session, naming exactly the tier and path list just shown; no prior general request,--execute, "clean everything", approval of another tier, or silence; never self-supplied or inferred; stop on rejection) and the surface (AskUserQuestionpreferred because its answer cannot be fabricated, an inline numbered question when it is absent). The three sites now point at it rather than restating it. The bar is unchanged, and this plugin's model-independent floor is untouched. The skill-scoped hook still blocks ad-hoc deletion and still forces a final permission prompt for the exact engineapply, and the approval token still binds an apply to the previewed plan.
-
A broken stdout turned the guard's deny into a fail-open (#1524), the seventh of the class #1449 closed and the one none of its in-process tests could see.
_decide's decisionprintonly buffers, so a closed stdout pipe raises nowhere insidemain. The failure surfaces at interpreter shutdown, and CPython reports that by replacing the exit status with120. Measured on the merged code: adenydecision with stdout wired to a pipe whose reader is closed exited120, which PreToolUse treats as non-blocking, so the destructive command runs even though the guard decided to deny it. The module tail now flushes stdout itself, catching an undeliverable decision while there is still a decision to make. It denies at exit2with a diagnostic, because a decision the host never received is not a decision, then flushes stderr best-effort andos._exits the resolved code, so a shutdown flush can no longer rewrite it. Reuses_write_diagnostic's null-device fallback, extracted as_discard_stream. Covered by a real-subprocessGuardTestscase against a genuinely closed stdout pipe (the in-process helpers never reach interpreter shutdown, so they cannot reproduce it).Also closes #1526's independently-reported trigger (fd 2 closed outright, not merely broken) as a structural side effect, without touching
_discard_streamitself. #1526 exists because_discard_stream's null-device repair can self-undo: whenos.open(os.devnull, ...)happens to return the very fd being repaired (POSIX allocates the lowest free descriptor, so a closed fd 2 is reused rather than a fresh one),os.dup2is a no-op and the followingclosere-closes it, and the pre-#1524 tail (raise SystemExit(main())) then hits that closed fd during the interpreter's own shutdown flush and gets rewritten to120the same way. Every exit path now ends inos._exitinstead, which never runs that shutdown flush, so nothing downstream depends on_discard_streamhaving actually repaired the fd. The latent self-undo bug it describes is still present in_discard_stream, but can no longer surface as a rewritten exit code. Covered by a second real-subprocess case that closes fd 2 outright and forces the internal-error deny path; 213 tests pass.
-
The destructive-action guard could fail open on exit 1 with no diagnostic (#1423), distinct from #1242. #1416's transcript sweep turned up one recorded occurrence: the plugin-level
hooks/hooks.jsondestructive_guard.py --mode engine-gatehook launched successfully, ran for 17054 ms, then exited1with empty stderr, on the post-#1242 command shape, not the${user_config.*}launch-refusal bug #1242 already fixed. Per the hooks reference (fetched 2026-07-25), PreToolUse treats exit1as non-blocking and proceeds with the tool call, and only exit2blocks, so the guard itself never issued a deny, ask, or allow: it simply stopped, and the destructive command ran ungated. The defect: only the top-of-mainJSON-payload parse was wrapped in atry/except; every line of decision logic after it (now extracted into_decide) had no exception handling at all, so any bug or unexpected exception in that path fell through to Python's default unhandled-exception behavior: exit 1, silently.mainnow wraps the entire_decidecall in atry/except BaseException, so any exception the guard's own code raises past the payload parse denies (exit2, one-line diagnostic on stderr naming the exception type and message) instead of falling open; exit1is no longer reachable from any internal path in the guard (test_hygiene.pyGuardTestsnow injects a failure into_decidedirectly, into every function_decidecalls in its real belt- and engine-gate-mode call graph, namelyresolve_mode,_engine_gate_relevant,resolve_disk_hygiene_enabled,resolve_authorized_data_root,is_exact_kill_switch_probe,classify_exact_engine_command, andpowershell_decision, and into a bareKeyboardInterrupt, asserting exit2with non-empty stderr and exit1never observed, in every case).The 17-second duration investigated, not characterized (single unreproduced occurrence). Every filesystem call already reachable from this module (
Path.resolve(strict=True),os.path.samefile,Path.stat/read_text) already caughtOSErrorat its own call site, so a stall ending inOSErrorwould not by itself explain an uncaught exception, narrowing the field without settling it. The strongest identified candidate for the stall itself, not for the exit-1 bug:_engine_gate_relevant's marker-free fallback callsos.path.samefileon every separator-containing word of every Bash/PowerShell command in every session (not only disk-hygiene commands) while resolving the plugin-level engine gate. An unreachable or slow-to-stat path referenced by an ordinary, unrelated command is a real, user-reachable way to block this hook for seconds. What argues against a plain uncaught exception as the full story: empty stderr is not what Python's default unhandled-exception handler produces (it writes a traceback), which leaves an external process kill (antivirus/EDR scanningpython3, a transient OS resource issue) as an open, unconfirmed possibility this module cannot fix from inside the interpreter, since a truly externally killed process cannot run Python code to change its own exit behavior. What IS fixed regardless of which of these it turns out to have been: the guard now self-enforces an internal watchdog deadline (DISK_HYGIENE_GUARD_WATCHDOG_SECONDS, default 10s, comfortably above every legitimate invocation, which completes in milliseconds, and below the one observed 17054 ms occurrence) that denies (exit2, diagnostic on stderr) on a background timer if_decidehas not returned by the deadline, instead of risking an unbounded hang toward the harness's own (600s-default) hook timeout. Both guard registrations (hooks/hooks.json's plugin-level engine gate andskills/clean/SKILL.md's skill-scoped belt) now also declare an explicittimeout: 60as a harness-level backstop, well below the previous implicit 600s default, in case the internal watchdog itself is ever prevented from running. 60 is the same proven valueguardrailsraised its own blocking PreToolUse guards to (plugins/guardrails/CHANGELOG.md[0.15.1]: 10-40x headroom over every real duration sample measured, well short of the 600s platform default), not the 20s this plugin started at. -
Four residual fail-open paths in the new watchdog itself, all reported in review. (1) Arming the watchdog sat outside the exit-2 boundary it protects: under OS thread or memory exhaustion
threading.Timer(...)/.start()raisesRuntimeError: can't start new thread, which reached the interpreter's default handler: exit1, non-blocking, destructive command proceeds. Failing to arm the guard's own deadline is exactly when the guard must deny, so construction and startup now run inside the protected boundary and fail closed at exit2. (2)_watchdog_secondsvalidated itsDISK_HYGIENE_GUARD_WATCHDOG_SECONDSoverride with a bare> 0test, whichinf(and1e400, which parses toinf) passes;threading.Timer(inf, ...)then acceptsstart()and dies in the timer thread withOverflowError: timestamp out of range for platform time_t, silently disarming the watchdog while the guard looks armed, and because it raises off the main thread, the exit-2 boundary never sees it. Non-finite overrides now fall back to the default like every other invalid value. (3) The watchdog was armed afterjson.load(sys.stdin), so a stall in the stdin read itself ran with no deadline armed at all, so the declared hooktimeoutwould fire first and the harness would cancel the hook with nopermissionDecision, the exact non-blocking fail-open #1423 exists to close. One such stall is a Windows Win32-pipe late EOF, where the OS delivers the complete JSON payload but delays the EOF signal, the same classguardrailsbounds in its bash hook fleet viahook::buffer_stdin,plugins/guardrails/CHANGELOG.md[0.8.0]. The watchdog now arms as the first action insidemain's fail-closed boundary, before the stdin read. (4) A valid but large override inverted the two deadline layers: the watchdog is the primary mechanism and the declared hooktimeoutis the backstop, which only holds while the watchdog fires first, soDISK_HYGIENE_GUARD_WATCHDOG_SECONDS=600meant the harness killed the process instead, and a killed PreToolUse hook yields nopermissionDecision, so the command proceeds unguarded. Overrides are now clamped to_WATCHDOG_MAX_SECONDS(the declared 60s hook timeout less 10s of headroom the watchdog structurally cannot cover: interpreter startup beforemainruns, plus teardown after the timer fires). The guard cannot read its own hooktimeout, because a PreToolUse payload does not carry it, so that value is duplicated in code and pinned to both registrations bytest_declared_hook_timeouts_match_the_watchdog_ceiling, which fails the suite if either drifts. All four paths are covered by newGuardTestscases, including a real-subprocess test with stdin opened as a pipe that is never written to or closed (a stalled read on the host running the suite, not only a Windows Win32-pipe late EOF, reproducing the general "blocked in read" shape without needing that platform specifically); 209 tests pass. -
A fifth fail-open path: the deny diagnostic could preempt the deny itself. Both fail-closed exits write a one-line explanation to stderr first, and both wrote it with a bare
print. If the hook host has closed or lost the stderr pipe, thatprintraisesBrokenPipeErrorfrom inside the very handler about to deny. The exception escapes beforereturn 2inmainoros._exit(2)in_watchdog_fireruns, and the process exits with a status PreToolUse treats as non-blocking, so the destructive command proceeds ungated. On the timer thread it is worse: an exception there never reachesmain's exit-2 boundary at all. Both sites now route through_write_diagnostic, which makes the write best-effort and, on a failed write, points fd 2 at the null device so the interpreter's own shutdown flush of a still-buffered stderr cannot raise either (that failure exits 120, likewise non-blocking). The deny is carried by the exit code, and losing the message is acceptable where losing the deny is not. Covered by two newGuardTestscases, one per exit site; 211 tests pass. -
GuardTestsno longer reads an operator's own watchdog override as the default. The deadline is overridable by environment variable, and the guard's own timeout diagnostic tells operators to export it, so a value already exported in the shell running the suite leaked into every assertion about the DEFAULT deadline and failed it against a correct implementation. The class'ssetUpnow strips that variable for the whole class; the cases that exercise an override still set it explicitly.
- A silent
destructive_guard.pylaunch/runtime failure is now surfaced instead of looking identical to an approval (#1416). A repo-operator investigation of the original #1416 report found both cited launch-refusal root causes already fixed and merged (#1242/0.9.0 here, repo-hygiene's own guard by #1006); what remained live was that "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. A new detector,skills/clean/scripts/guard_launch_monitor.py, registers as a second, independent hook inhooks/hooks.json, onStoprather thanPreToolUse/PostToolUseto avoid repeating the per-tool-call cost class documented indocs/adr/0004-rightsize-instruction-surfaces-by-incumbent-first-arbitration.md's D-12, and scans the session transcript's tail forhook_non_blocking_errorrecords namingdestructive_guard.py. On a match it emits onesystemMessageper session (never a block, never apermissionDecision) naming the guard, the failure count, and the most recent failure's exit code, duration, and truncated stderr. It is a separate, stdlib-only process, deliberately not wired through the guard's own code, since a guard that cannot launch cannot report that it did not launch, and it fails silently closed on any read/parse error so it can never itself become the reason a turn is blocked. It covers onlydestructive_guard.py's own command string: repo-hygiene's guard is out of scope (verified working separately), there is no retroactive scan of prior sessions, and, because both hooks are wired with the same literalpython3command, the interpreter-resolution fail-open documented in the README (the WindowsApps alias stub, or a missing/brokenpython3) takes the detector down with the guard, so that one vector stays unreported until the detector gets a launcher independent of the guarded interpreter (#1504). The bounded tail read discards its first line only when the retained window actually starts mid-record: whensize - _MAX_TAIL_BYTESlands exactly on a record's first byte, an unconditional discard threw away a whole record, which can be the session's only guard failure, silencing the very report the detector exists to make. The once-per-session marker is written only after the warning has actually left the process (printthenflush, then mark): marking first meant a closed pipe or a kill between the two silenced every laterStopin the session while the broad never-fail-loudly handler exited quietly, reinstating the silence the detector exists to break.
- Headless reconfigure recipe now preserves install scope (#1406). The
claude plugin uninstall→claude plugin install ... --configrecipe inskills/setup/SKILL.mddefaulted both halves to-s user. When this plugin is installed atprojectorlocalscope, that silently uninstalled a separate user-scope record while the effective project/local install kept loading, and the reinstall landed at a scope that does not load. Both commands now carry-s <scope>, sourced from whatclaude plugin listreports for this plugin, the same fix already applied tosession-flowandrate-limit-guardin #1393.
setup checknow detects the Windows Storepython3alias stub that fails the guard open (#1110). Thecleandestructive-action guard hook launches the literal commandpython3. On stock Windows that name resolves to a zero-lengthWindowsApps\python3.exeApp Execution Alias, a reparse stub that opens the Microsoft Store instead of running an interpreter, so the guard process never starts. A PreToolUse hook blocks a tool call only by emitting exit code 2 or adenydecision; a guard that never runs emits neither, so Claude Code lets the destructive Bash/PowerShell command proceed ungated. This recurs the 0.6.3 fail-open shape (hook launch failure treated as non-blocking) through a new vector: the guard's launch name resolving to the Store stub rather than a real interpreter.setup checknow runs a bundled inspect-only probe (skills/setup/scripts/python3_alias_probe.py, covered bytest_python3_alias_probe.py) that classifies thepython3resolution, where zero length under aWindowsAppspath component is the stub, without executing it, and orders the check so nothing (including the version-floor probe itself) executes the bare namepython3until that verdict isok: the probe is launched via an already-proven interpreter (py -3,python, or an absolute path), with a direct PowerShell inspection of the resolved path as the interpreter-less fallback. The check fails closed on every verdict exceptok: the stub and an unreadable-identity (indeterminate) verdict both FAIL with remediation (disable the App execution alias, or put real Python ahead of WindowsApps onPATH), and an absentpython3folds into the floor's missing-interpreter FAIL. The disabled-toggle FAIL→INFO downgrade is exempted for every step-1 failure: audit-only mode is enforced by the guard, both guard surfaces launch the literal namepython3, and a guard that never runs can neither read nor enforce the configuredfalse. So every non-okverdict (store-alias-stub,indeterminate,not-found) stays fatal; so does a nominallyokresolution whose version probe then fails to launch at all, such as a corrupt or zero-length binary outsideWindowsApps, a broken shim, or a permission error; and so does an interpreter that starts but reports a version below the floor, because launching the version probe proves only that something executes, not that it can run the guard's own source (Python 3.6, for example, rejects the guard'sfrom __future__ import annotationsand exits without a deny, which PreToolUse treats as non-blocking). The suite's test wrapper applies the same inspection to its own interpreter candidates before executing them. The README requirements section documents the vector.
- The anchored target root is validated by object identity, so benign directory churn no longer
aborts an approved run (#384). Preview and apply held the target root to full stat identity
(
st_mtime_nsandst_sizeincluded), but a directory's mtime and size flip whenever any direct child is added or removed. Human approval sits between scan and apply, so any unrelated write into a live target, commonly a home or an active project root, flipped the root's mtime and aborted the run with "anchored target changed since the snapshot," forcing a full rescan. Both sites now use the stable device/inode/type identity that directory candidates andhandoff-verifyalready use; a replaced root still refuses. The check was never wrong-deleting, only over-refusing.
resolve_snapshot_targetno longer takesstrict_root_stat. With one root-identity standard across preview, apply, andhandoff-verify, the parameter that selected between them is gone and the single refusal reads "target root was replaced since the snapshot."
- The PowerShell lane's documented coverage now names what it does not flag (#386).
reference/safety-model.mdand the clean skill's PowerShell gotcha described the lane as gating "known deletion spellings" without stating that destructive non-deletion spellings reach the tool with no guard verdict, audit-only mode included:Move-Item,Rename-Item, overwriting writers (Set-Content/Out-File/>/New-Item -Force), andFormat-Volume/Clear-Disk. The gap is now disclosed where the security model is stated, naming the consumer's permission policy as its only backstop. The manual handoff's per-path approval covers the paths selected for removal, so it does not reach what these spellings collaterally destroy. Docs only; the guard's behavior is unchanged and closing the gap is tracked in #387.
- The
disk_hygiene_enabledkill 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 userConfigdefaultis 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 norCLAUDE_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-scopepluginConfigsinsettings.json, so a configuredfalseis denied outright on the Bash engine lane and the PowerShell deletion lane, whether or not the clean skill is active.
- 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 sharedlib/killswitch_config.pyreader. The usersettings.jsonis located solely from the tamper-resistant${CLAUDE_PLUGIN_ROOT}both surfaces receive. The guard never falls back toCLAUDE_CONFIG_DIR/HOMEfor it, because those are environment values a repo.claude/settings.jsonenvblock can inject into hook subprocesses (carrying no provenance). A marker-less--plugin-dircheckout 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.207pluginConfigsis honored only from user, managed, and--settingsscope (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 repoenvblock cannot redirect it); a value configured there overrides the user file, so an organization can enforce audit-only mode; the siblingmanaged-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'sdisk-hygieneentry cannot mask it. The one residual: a value supplied only through a session--settingsfile (a runtime CLI flag no hook can observe) is not enforced by the guard. kill_switch_probe.pynow 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/setupSKILL.md,reference/safety-model.md, andREADME.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.
- 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.
- RETRACTS 0.8.2's PowerShell claim, which was wrong (#1195). 0.8.2 documented that "PreToolUse guards
do not intercept PowerShell-tool commands" and scoped the PowerShell lane behind a preview caveat. A
fresh-session controlled test falsified that: a
Bash|PowerShellPreToolUse matcher does fire for the PowerShell tool on 2.1.218, the payloadtool_nameis literallyPowerShell, and a liveSet-Contentthrough that tool was blocked. There is no harness firing divergence and no preview limitation involved. 0.8.2's caveat overstated an un-isolated inference and is removed. - The real defect, now documented accurately: the plugin-level engine gate is inert whenever
disk_hygiene_enabledis unconfigured.hooks/hooks.jsonpasses a bare${user_config.disk_hygiene_enabled}; upstream never implemented the declared userConfigdefault, so an unset-but-defaulted token is neither substituted nor exported asCLAUDE_PLUGIN_OPTION_*and its presence drops the entire hook entry (proven: token-carrying hooks vanish while token-free controls fire, and return once the key is configured). So the gate has never run for any consumer who never set the key, on Bash and PowerShell alike, which is the real shape of the reported "PowerShell bypass". The skill-scoped belt carries no such token and is unaffected. Every doc that claimed the gate "fires in every session" or that audit-only mode is "guard-enforced" corrected: thecleanandsetupSKILL.mdfiles,reference/safety-model.md, and the consumerREADME.md. The code fix (a delivery channel that does not depend on the unimplementeddefault) is tracked separately. Recheck when the upstream gap closes (#46477 / #39455 / #39827).
- Docs no longer promise PowerShell-tool deletion protection that does not fire on current builds
(inbox
173656).skills/clean/SKILL.mdandreference/safety-model.mdasserted the PowerShell guard belt "turns deletion spellings into a final human permission prompt" and that a configureddisk_hygiene_enabled=falseblocks the PowerShell lane. On Claude Code 2.1.218 (Windows, reproduced) aBash|PowerShellPreToolUse hook fires for the Bash tool but does not intercept PowerShell-tool commands. The PowerShell tool is a documented preview feature (tools-reference) and PreToolUse interception of it is not a listed preview limitation, so the belt and the kill switch's reach into the manual PowerShell lane are inert there. The claims are now scoped as the guard's intended design with an explicit version-pinned preview caveat + recheck trigger; on Windows the protections that actually hold are the manual lane's per-pathhandoff-verifyapproval and the consumer's baseline permission policy. Observed effect only: the mechanism (matcher firing vs Windows payload delivery vstool_name) is not yet isolated (recheck by adding a loggingPreToolUsematcher: "PowerShell"hook in a fresh session and confirming it fires for a PowerShell-tool command); the upstream docs-vs-behavior divergence is held for a report once isolated.
--executenow gates every deletion lane, including the manual handoff (#1113, F7). A deliberate semantic unification, not a restatement: the flag previously read as "offer the gated ENGINE lane", which can never apply on Windows/macOS, leaving the manual lane's gate ambiguous, and consumer sessions read it both ways (one proceeded to manual deletion without--execute). The clean skill now states the unified contract in one sentence at the argument definition and requires--executein the manual-handoff precondition, for lane symmetry.
- Doc corrections from the 0.6.4 consumer audit (#1113, F9/F10). Safety-model trust boundaries
now name standing-policy
additional_hints[].reasonprose as untrusted claims requiring independent evidence (additive-only design means hints cannot authorize, but the prose reached triage reasoning unlabeled). Setup SKILL.md and the README now saypreviewreportsexecution-platform-unsupportedas a per-candidate blocker rather than "returns" it (it was never a top-level status), and the README states once that the Recycle-Bin / Trash naming is a model-layer distinction only. The engine treats Windows and macOS identically. F10(c)'s restructure-the-hub suggestion is DECLINED with evidence: the repo's.markdownlint-cli2.jsoncsets"MD013": false(no line-length rule, since the complaint came from an out-of-repo lint run) and the skill-quality gate passes the hub at its current length.
hygiene.py handoff-verify: deterministic revalidation for the manual lane (#1109). New read-only subcommand: takes the snapshot plus the human-approved exact path list ({"version": 1, "paths": [...]}, same containment rules as plan candidates) and reruns the engine's identity/reparse/protection/descendant/VCS/handle checks per path against live state, emitting one machine-readable verdict each,clear/drifted/gone/contested, and never deleting anything. Platform execution blockers deliberately do not apply (the subcommand exists exactly where apply is unsupported); every unverifiable condition fails closed intocontested. Exit 0 all-clear, exit 3 otherwise. The target-root gate reuses preview's checks but tolerates the root directory's own metadata churn (stable device/inode/type identity instead of full stat identity, because deleting an approved root-level item changes the root's mtime, and the manual lane deletes one item at a time with a re-verify between items); a replaced root still refuses. The clean skill's manual-handoff lane now writeshandoff-paths.json, runs handoff-verify immediately before deletion, and acts only on verdict-clearpaths, bringing snapshot binding to Windows/macOS without adding an engine deletion lane (captures most of the declined F12 value; #1116's affirmation records this as the intended alternative). The Bash guard admits the exacthandoff-verify --snapshot <s> --paths <p> [--data-root <d>]shape as a read-only invocation, including in audit-only mode (kill switch keeps blocking every deletion lane; verification is reporting). Safety model documents the verdict vocabulary and the emission-time-only validity ofclear.
- Test coverage for the least-observable engine paths (#1114). Test-only release, with no engine
behavior change. The paths a consumer can least verify live now have direct tests with mocked OS
surfaces, exercised identically on both CI lanes regardless of host platform:
windows_handle_stateCreateFileW error-code mapping (32/33 → open, 5/1314 → needs_elevation, unknown codes fail closed as unverified; handle closed on success; directory probes use backup semantics),posix_handle_statelsof parsing (missing lsof, diagnostics on stderr, unexpected exit codes, and timeouts all fail closed; directory vs file command shapes),windows_storage_sense_stateregistry reads (set/zero/missing values, missing key),_decode_mountinfo_pathoctal decoding (escapes, non-octal and truncated sequences left verbatim), and the non-Git VCS marker branch (nested, enclosing, and casefolded markers all flagvcs-state-unverified). No latent engine bugs surfaced while writing them.
- PowerShell lane narrows the engine deny from substring to invocation classification (#1112).
The lane denied ANY command containing the substring
hygiene.py, blocking commands that merely NAME the script (live-observed, F6) while a renamed copy evaded it anyway. The engine check now uses the same invocation classifier as the plugin-level gate (bundled-file identity + launcher rules): bare-name and consumer-file mentions defer. Deliberately NOT deferred: a command whose argument IS the bundled engine, even under a read-verb spelling (Get-Content <engine>), because PowerShell aliases and profile functions shadow cmdlet names, so a verb name proves nothing about what executes (review finding); the deny message points at non-shell file tools for reading the engine source.
- PowerShell mutation guard covers instance-method
.Delete(), and robocopy mirror/purge/move (#1111). The .NET-delete pattern required::beforedelete, so$item.Delete()executed with no guard flag (live-observed in the 0.6.4 consumer audit, F4); it now also matches.Delete(.robocopywith/MIR,/PURGE,/MOV, or/MOVE(mass deletion via mirroring) now raises the final ask prompt and is denied in audit-only mode; plainrobocopy /Ecopies stay untouched. The truncation family (Set-Content,Out-File,New-Item -Force) is DECLINED with reason: those spellings are ordinary file-writing work, and an ask-tier belt that fires on every write during a cleanup session trades too much friction for a raised bar the engine's own containment already backs. Design stays raised-bar-not-fail-closed.
- Split guard registration: the plugin-level engine gate delivers the kill switch and data-root
authority (#1105, #1106, #1107). The destructive guard now registers on two surfaces. A NEW
plugin-level
hooks/hooks.jsonPreToolUse hook runsdestructive_guard.py --mode engine-gatewith${user_config.disk_hygiene_enabled}and${CLAUDE_PLUGIN_DATA}substituted in exec form (both channels docs-verified), so a configuredfalse(audit-only mode) is guard-enforced against engine invocations in every session, and--data-rootauthority no longer depends on reconstruction from the plugin root. In engine-gate mode the guard defers instantly with no output for any command that does not reference the engine, so unrelated work is never taxed. The skill-scoped belt (deny-by-default Bash + deletion-spelling PowerShell discipline) is unchanged and remains scoped to active cleanup. The gate acts on parsed engine INVOCATIONS, not mentions:git diff -- hygiene.py,rg hygiene.py, orecho hygiene.pydefer, a word resolving to a DIFFERENT existing file namedhygiene.py(a consumer's own tool) defers, and interpreter options before the script (python3 -B) cannot slip the gate; unparsable marker-carrying commands fail closed into the gate (review finding on the implementation PR). GuardTests now exercise the exact channel set the shipped plugin-level registration receives (run_guard_engine_gategrid), closing the tests-prove-undelivered-channels gap. Trust-surface delta recorded in the README's plugin-acceptance security review section. Docs record the observed-vs-documented hook-lifetime discrepancy (session-long belt firing, producer-reported, with #1105 tracking the interactive repro) and that PreToolUse hooks fire inside subagents. The maintainer's re-affirmation of the Windows-engine-execution decline (#1116) is recorded in the safety model with its reversal trigger.
- Manual-handoff lane: container-wide deletions now require immediate pre-execution
re-enumeration (#1108). An approval for a container-wide operation (
Clear-RecycleBin, emptying the Trash) was bound to a prose item list that could go stale between approval and execution. Items landing in the container after approval would be destroyed under an approval that predated their existence (a live near-miss in the 0.6.4 consumer audit, F2). The clean skill's unsupported-platform handoff now forbids container-wide deletion commands outright, since review showed even immediate re-enumeration leaves an approval-to-execution window against a live container, and satisfies "empty the container" by per-item deletion under the lane's per-path revalidation, so unenumerated arrivals survive. Also documents that Recycle Bin / Trash reversibility is conditional: bin size caps, policy-disabled bins, or non-NTFS/network volumes can silently make removal permanent.Clear-RecycleBinadded to the PowerShell guard's mutation words, and module-qualified deletion cmdlets (Module\Remove-Item,Module\Clear-Content,Module\Clear-RecycleBin) now match a companion pattern the word boundary's lookbehind previously rejected (review findings on the same PR; the guard word is defense-in-depth for attempted container ops, which the manual lane now forbids). The broader F4 spelling additions (.Delete(, robocopy purge flags) remain tracked in #1111. Engine-side changed-since-scan gotcha now cross-references the manual lane's re-enumeration rule (closes #1108's third acceptance criterion in both directions).
- A non-OS volume root (e.g. a Windows Dev Drive) is no longer blanket-rejected (#984). A
whole-volume root was refused purely structurally, on Windows by the mount-point gate (every drive
letter is
os.path.ismountTrue), backed by aparent == rootfilesystem-root check, with no reasoning about the volume's purpose, blocking a legitimate non-OS volume. Root classification is now reasoned: an OS-managed root (the OS drive holding an existing Windows install /Program Files/ProgramData, or/holding/bin,/etc, …) is still denied, while a non-OS volume root, a drive root carrying only the per-volume metadata every volume has (System Volume Information,$Recycle.Bin) and no OS-install marker, is now a valid target. The target-level mount rejection is scoped to non-root mount points, so nested and bind mounts stay hard-blocked; per-entry mount/OS-managed/VCS/identity protections and the preview + per-tier approval gate are unchanged. Scan and preview share one unverified → OS-managed → non-root-mount target-check ordering. A now-valid non-OS volume root composes with the large-target scan gate (0.5.0): it is a known-large root (large_scan_reasonsreasonnon-os-volume-root), so an unbounded whole-volume walk returnslarge-target-confirmation-requiredunless bounded with--max-depthor confirmed with--confirmed-large-scan.
- The destructive-action guard was failing open on the bundled
cleanskill. 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 andrm -rf, engineapply, 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.pyderives 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-rootengine calls are denied while the destructive-action guard stays fully active. A direct--authorized-data-rootand theCLAUDE_PLUGIN_DATAenvironment variable remain accepted as additional/fallback channels for hosts that can supply them. - Known limitation (platform gap): the
disk_hygiene_enabledkill switch can no longer reach the guard on a skill-frontmatter hook. Its only channels are the--disk-hygiene-enabledargv flag (which needs the${user_config.*}substitution skill hooks do not receive) and theCLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLEDenvironment variable (which the runtime does not inject into skill hooks). The guard therefore defaults to enabled and cannot honor a configuredfalseby 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)
- The hook now passes only
- Windows platform posture no longer reads as if the engine deletes there.
setup check's platform-posture step said "Windows (full,lstatreparse + Win32, never UAC)", but "full" described only the audit lane.clean's preview returnsexecution-platform-unsupportedon Windows and removal is a manual Recycle-Bin handoff. The posture line (and the README's Windows bullet) now keeps the lanes visibly separate: full audit; engine execution unsupported; manual, per-path Recycle-Bin handoff after explicit approval. macOS gains the matching manual Trash note. - "Skill-scoped guard" wording now says what the scope means. The
destructive_guard.pyPreToolUse hook is registered in thecleanskill's frontmatter and fires only within that skill's context; setup's own probes and any directhygiene.pyinvocation rely on the engine's built-in containment, not the hook. One clause insetup checkstep 1 and the README requirements bullet now states this instead of implying always-on protection. - The security review's Configuration bullet no longer claims "no
userConfig". That claim has been stale since 0.3.0 introduced thedisk_hygiene_enabledtoggle; the bullet now describes the actual surface (one non-sensitive boolean that can only narrow the destructive surface) with the review conclusion unchanged.
- The Python version floor now has one origin. The "3.11+" floor was hand-maintained in at
least five places:
hygiene.py's runtime check (the real enforcement), both.test.shwrappers, both SKILL.md files, and the README. Meanwhile the setup skill told itself to "probe what they actually require, don't recite this file"; a future bump would drift the copies silently. The floor is now the module-levelMIN_PYTHONconstant inhygiene.py: the runtime check and its error message derive from it, a regression test locks the constant's greppable line shape and proves enforcement uses it, both test wrappers parse it instead of restating the number (failing loudly if the parse breaks),setup checkstep 1 derives the probed floor from the constant, and the remaining prose mentions are annotated as pointers or convenience copies of that origin.
- Deterministic kill-switch probe (
skills/setup/scripts/kill_switch_probe.py): a report-only, stdlib-only read of the configureddisk_hygiene_enabledvalue frompluginConfigs[<plugin-id>].optionsin the usersettings.json(CLAUDE_CONFIG_DIR-aware). It emits one JSON line with theeffectiveboolean, itssource(configured/default/indeterminate), adegradedflag, and the matched entries. The guard's Bash allowlist now permits exactly the argument-free bundled probe invocation (any argument, barepython, or a different path stays denied).
setup checkno longer reports the kill switch from an unexpanded body token. Step 4 previously emitted${user_config.disk_hygiene_enabled}in the skill body with the rule "unexpanded or empty means defaulttrue", so a configuredfalse(audit-only mode) whose token failed to expand was misreported as enabled, a false-negative on the safety-critical setting the check exists to verify. Current plugin docs state non-sensitive${user_config.*}values substitute in skill content, but a live run observed the token unexpanded, so body-token expansion cannot be relied on for a safety report.checknow reports the probe's deterministic result with provenance, degrades honestly ("could not read the configured toggle; assuming defaulttrue") when no definitive read is possible, and treats the body token as at most a cross-check whose contradiction is reported rather than silently resolved. Thecleanskill's audit-only instruction likewise stops treating an unexpanded token as "unset = enabled" and resolves the toggle through the same probe; enforcement remains with the guard's runtime-substituted--disk-hygiene-enabledhook argument (0.4.4).
- Engine-level large-target scan gate. A
scanwhose target resolves to the user home directory now returnslarge-target-confirmation-required(after a cheap top-level probe, no full walk) unless it carries--max-depthor the new--confirmed-large-scanflag, backing the former prompt-only--max-depth 1convention with a deterministic backstop so a forgotten bound cannot become an accidental unbounded whole-home walk. The Bash guard accepts the valueless--confirmed-large-scanin the exact scan shape.
- The
cleanskill now hands off git worktree checkouts to/source-control:worktree. An audit of a repos root containing worktree checkouts (e.g. under.worktrees/) inventories each checkout and protects its tracked content and.gitmetadata, but the skill named no next step for the worktree lifecycle it does not own. The boundary list (and the README relationship list) now point at/source-control:worktree status/cleanup(if installed), run from the checkout's own main repository, since those actions manage the current repository's worktrees and take no target, extending the existing managed-state → named-handoff pattern. Discoverability only; no engine or safety behavior change. (#986)
- Test isolation only, with no runtime behavior change. The
run_guard_powershellhelper intest_hygiene.py, the enabled-PowerShell sibling of the three helpers sealed in 0.4.5, carried the identical unsealed input channel: it mockedos.environto drive the kill switch but leftsys.argvunpatched, so an ambient--disk-hygiene-enabledflag in the real test-runner invocation could override the env-var mock the test intends to exercise. It now patchesguard.sys.argvto a clean, flag-free argv alongside its existing environment mock, matching the pattern the other fourrun_guard*helpers use, so the environment variable stays the sole channel under test. This completes the sealing left out of 0.4.5 for scope; standardunittest/pytestinvocations never produced such argv, so it seals latent fragility rather than a live failure.
- Test isolation only, with no runtime behavior change. The
run_guard,run_guard_disabled, andrun_guard_powershell_disabledhelpers intest_hygiene.pymockedos.environto exercise the kill switch but leftsys.argvunpatched. Since the guard reads--disk-hygiene-enabledfromsys.argv[1:]before the environment fallback, a test runner whose real invocation argv happened to carry that flag could override the env-var mock and flip an expecteddenytoask. Each helper now patchesguard.sys.argvto a clean, flag-free argv alongside its existing environment mock, matching the pattern therun_guard_enabled_argvhelper already established, so the environment variable stays the sole channel under test. Standardunittest/pytestinvocations never produced such argv, so this seals latent fragility rather than a live failure.
- The
disk_hygiene_enabledkill switch now actually blocks deletions in audit-only mode. Settingdisk_hygiene_enabled=false(audit-only mode) failed to prevent deletions in two independent ways, both fixed here.- The PowerShell lane never consulted the kill switch:
destructive_guard.pyroutedPowerShellcalls topowershell_decisionand returned before the enabled gate was computed, so flagged deletion spellings (Remove-Item,rm,del,::Delete, recycle-bin calls) still returnedask, and could be approved, even with execution disabled. The enabled gate is now resolved before the tool-name branch and threaded intopowershell_decision, which denies flagged deletions in audit-only mode and only prompts (ask) when execution is enabled. - The kill switch was inert under the env-injection failure: the guard read
CLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLEDfrom the hook process environment and defaulted to enabled when absent, but the runtime does not inject plugin env vars into a skill-frontmatter hook's environment, so a configuredfalsewas silently overridden to enabled. Thecleanskill's hook now passes the configured value as a runtime-substituted--disk-hygiene-enabled ${user_config.disk_hygiene_enabled}argument, since inline placeholder substitution resolves in exec-form hookargswhere environment injection does not, and the guard reads the kill switch from that argument, honoring the environment variable only as a fallback. When no channel supplies a value the guard still fails safe to enabled (guard active, every mutation gated behind the final human prompt). This mirrors the--authorized-data-rootargv mechanism.
- The PowerShell lane never consulted the kill switch:
- The
cleanskill's destructive-safety guard now launches via a resolvablepython3. The PreToolUse hook ran in exec form via the unqualified interpreterpython, which stock macOS and many Linux distros do not ship (onlypython3). Because Claude Code treats a failed hook launch as a non-blocking error, an unresolvablepythonfails the guard open, sorm -rf, engineapply, and other destructive shapes stop being intercepted on the very POSIX hosts the safety model relies on, and a legacypython2.x resolving first would crash the guard on modern syntax. The hook now namespython3. A new regression test (test_skill_hook_interpreter_is_python3_and_resolves) locks the config atpython3and probes that a runnablepython3reports a 3.11+ interpreter. Enforcement remains bounded by resolution: on a host without a resolvablepython3the launch still fails open on the manual PowerShell deletion lane (engineapplyis already unsupported on Windows/macOS), so the per-path human approval that lane already requires and the consumer's baseline permission policy stay the backstop, and/disk-hygiene:setup checkreports interpreter resolution. (#380)
- The
cleanskill's step 2 now defines "suspicious" for home-directory targets. A prior fix covered thetmp_*hint-glob gap but left two findings open: an unhinted agent-session status file has no shared name shape to glob, and SKILL.md never said what "suspicious" meant for an unhinted entry. Both are the same gap: the scan snapshot already records every walked entry with a possibly-emptyhintslist, so the data was always there, just never triaged. Step 2 now instructs the model to treat any loose root-level entry at a user-home target that is not inprotected_exact_namesand does not match a recognizable app/config convention as suspicious, closing the triage gap without inventing a fabricated baseline-policy.json glob for a naming pattern the evidence doesn't support. (#287)
- The skill-frontmatter guard now receives its authorized data root.
destructive_guard.pyread the authoritative data root only from theCLAUDE_PLUGIN_DATAenvironment variable, which the runtime does not inject into a skill-frontmatter hook's process environment. As a result--data-rootnever validated and thescan/preview/applyengine lane failed closed on every guarded invocation, on all platforms. Thecleanskill's hook now passes the root as a runtime-substituted--authorized-data-root ${CLAUDE_PLUGIN_DATA}argument, since inline placeholder substitution resolves in hook arguments where environment injection does not, and the guard reads its authority from that argument, honoringCLAUDE_PLUGIN_DATAonly as a fallback. The security property is unchanged: the authority is a runtime-substituted value the model cannot forge, validated against the model-supplied--data-root. The unsubstituted-placeholder fallback matches only the exact${CLAUDE_PLUGIN_DATA}token, so a real data-root path that merely contains the${sequence is preserved as the authority instead of being discarded.
/disk-hygiene:setupskill on the uniform contract (fleet conformance wave, dim 8).checkreads the clean skill's bundled scripts as the source of truth and probes Python 3.11+, conditional Git, the current OS family's documented lane (Linuxlsofand macOS audit-only reported as INFO), and the effectivedisk_hygiene_enabledtoggle.applyis guidance-only with no write path; toggle guidance states--config's fresh-install-only semantics. A disabled toggle downgrades prerequisite FAILs to INFO.
Fixes driven by a live Windows user-profile audit where the engine was unusable through its sanctioned lane and the guard's protections did not cover the platform's primary shell.
--data-rooton scan, preview, and apply. The Bash guard validates the value against theCLAUDE_PLUGIN_DATAits own hook process received (the runtime exports it to hook processes but not to shell tool subprocesses, so the engine could previously never find its generated-state root through the guarded lane) and discloses the authorized value in denial guidance alongside the interpreter path. Absent hook authority the flag fails closed; the environment variable remains honored as a fallback.--max-depthbounded scans. Directories at the cutoff are recorded intruncated_paths, reported as coverage gaps, and blocked from plans by a newtruncated-not-inventoriedpreview blocker. This makes a profile-root audit possible: the previous all-or-nothing walk exceeded the 250k entry cap on any real home directory before reaching a single loose file.- PowerShell guard lane (matcher now
Bash|PowerShell): engine invocations from PowerShell are hard-denied (Bash stays the only engine lane), and known deletion spellings / .NET Delete calls surface a final human permission prompt instead of executing silently. Read-only support work passes through untouched. - Documented unsupported-platform manual handoff: on Windows/macOS, after the same exact-list human approval as the engine lane, removal proceeds manually with per-path revalidation and reversible (Recycle Bin / Trash) deletion preferred.
- Scan progress heartbeat to stderr every 25k entries; the entry-cap error now suggests
--max-depth. os_autocleanadvisory is computed before the walk and included in scan failure payloads, so a capped profile scan still surfaces the Storage Sense / systemd-tmpfiles recommendation.- Baseline hints for
tmp_*(medium ceiling) andscratch*(low ceiling) artifacts.
- Generated-state error messages name the
--data-root/CLAUDE_PLUGIN_DATApair instead of the environment variable alone. - Execution kill switch migrated to native
userConfig(the fleet-wide kill-switch doctrine ruling): thedisk_hygiene_enabledboolean (defaulttrue) now gates the clean skill's execution tiers, read by the skill-scoped guard through the nativeCLAUDE_PLUGIN_OPTION_DISK_HYGIENE_ENABLEDhook-process mirror. Configure with/plugin configure disk-hygieneorclaude plugin install --config. - BREAKING: the
HOOK_DISK_HYGIENE_ENABLEDenvironment variable is retired and no longer read. Zero-config behavior is unchanged (execution allowed).