From 9476ac1d16859a60394f5f306c656cff47a20968 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:58:21 -0400 Subject: [PATCH 1/4] fix(disk-hygiene): warn a resumed session that the clean skill's guard is gone The skill-frontmatter belt is not registered after claude -p --resume, so a manual-lane delete approved before the resume ran unprompted. The /disk-hygiene:clean expansion hook now writes a per-session marker, and the existing SessionStart entry tells the model on a resume with that marker to make no delete or overwrite until the user types /disk-hygiene:clean again. The safety model and handoff docs state the belt's lifetime as observed. Closes #9853 Co-Authored-By: Claude Opus 5.5 --- ...fix-disk-hygiene-resume-notice-9853a1c4.md | 7 + plugins/disk-hygiene/README.md | 9 +- plugins/disk-hygiene/hooks/hooks.json | 17 ++- plugins/disk-hygiene/hooks/resume-notice.mjs | 75 ++++++++++ .../disk-hygiene/hooks/resume-notice.test.mjs | 141 ++++++++++++++++++ .../hooks/run-python-hook.test.sh | 13 +- .../skills/clean/reference/safety-model.md | 48 ++++-- .../reference/unsupported-platform-handoff.md | 20 ++- plugins/disk-hygiene/skills/setup/SKILL.md | 4 +- 9 files changed, 309 insertions(+), 25 deletions(-) create mode 100644 .changes/disk-hygiene/fix-disk-hygiene-resume-notice-9853a1c4.md create mode 100644 plugins/disk-hygiene/hooks/resume-notice.mjs create mode 100644 plugins/disk-hygiene/hooks/resume-notice.test.mjs diff --git a/.changes/disk-hygiene/fix-disk-hygiene-resume-notice-9853a1c4.md b/.changes/disk-hygiene/fix-disk-hygiene-resume-notice-9853a1c4.md new file mode 100644 index 0000000000..2bc01fa593 --- /dev/null +++ b/.changes/disk-hygiene/fix-disk-hygiene-resume-notice-9853a1c4.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- **A session resumed after `/disk-hygiene:clean` now hears that the clean skill's guard is gone.** The skill-frontmatter belt (the PowerShell deletion prompts and the deny-by-default Bash lane) is not registered after `claude -p --resume`, so a manual-lane delete approved before the resume ran unprompted. The `/disk-hygiene:clean` expansion hook now writes a per-session marker, and the existing `SessionStart` entry, on `source: resume` with that marker present, tells the model to make no delete or overwrite until the user types `/disk-hygiene:clean` again. It is a notice, not a gate. The safety model, the unsupported-platform handoff, the README and the setup skill now state the belt's lifetime as the process that invoked the skill, with the Windows probe as the verification record ([#9853](https://github.com/melodic-software/claude-code-plugins/issues/9853)). diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index 5340fa7555..12a57abf48 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -140,8 +140,10 @@ from the scopes a repo cannot forge: 2026-10-11; recheck when that section changes which scopes the key is read from), register unconditionally, and fail closed to enabled. -Hook lifetime: the belt keeps denying after a clean run ends, for the rest of the session. Start a -new session to clear it. The plugin-level engine gate fires inside subagents. The +Hook lifetime: the belt keeps denying after a clean run ends, for the rest of the process. Start a +new session to clear it. A resumed session does not get it back: after `--resume` the belt is gone, so +a `SessionStart` notice tells the model to make no delete or overwrite until the user types +`/disk-hygiene:clean` again (#9853). The plugin-level engine gate fires inside subagents. The skill-frontmatter belt does not, and a fanned-out worker's Bash lane is not belt-guarded, so "evidence only" is an instruction to the worker, not an enforced denial. Our probe recorded in [#5565](https://github.com/melodic-software/claude-code-plugins/pull/5565) observed the subagent @@ -176,7 +178,8 @@ either; the failure table below states what happens then. (`skills/clean/scripts/engine_context.py`) runs when `/disk-hygiene:clean` expands and hands the skill the guard's absolute Python and authorized `--data-root`, resolved by the guard's own code, so a run does not open with a deliberately denied call to learn them (#4215). It grants nothing; the -guard still judges every call. +guard still judges every call. The same row records the session id in a marker file, which the resume +notice reads. An installed mod can stop this plugin's `PreToolUse` hooks from running, and can approve a call they blocked, so the guard is only as strong as the mods installed beside it. diff --git a/plugins/disk-hygiene/hooks/hooks.json b/plugins/disk-hygiene/hooks/hooks.json index 34e9f0bc6c..ad9e8b5cdb 100644 --- a/plugins/disk-hygiene/hooks/hooks.json +++ b/plugins/disk-hygiene/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Blocks a destructive disk-hygiene delete that falls outside its authorized roots, detects the guard silently failing to launch, and hands /disk-hygiene:clean the guard's interpreter and data root when it expands.", + "description": "Blocks a destructive disk-hygiene delete that falls outside its authorized roots, detects the guard silently failing to launch, hands /disk-hygiene:clean the guard's interpreter and data root when it expands, and warns a resumed session that the clean skill's guard is no longer registered.", "hooks": { "SessionStart": [ { @@ -10,6 +10,17 @@ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /disk-hygiene:check DISK_HYGIENE_ENABLED; ${PPID:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /disk-hygiene:check DISK_HYGIENE_ENABLED", "timeout": 10, "statusMessage": "Checking that node is on PATH..." + }, + { + "type": "command", + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/hooks/resume-notice.mjs", + "--marker-root", + "${CLAUDE_PLUGIN_DATA}" + ], + "timeout": 10, + "statusMessage": "Checking whether /disk-hygiene:clean ran before this resume..." } ] } @@ -151,6 +162,10 @@ "args": [ "${CLAUDE_PLUGIN_ROOT}/hooks/exec-bash.mjs", "${CLAUDE_PLUGIN_ROOT}/hooks/run-python-hook.sh", + "--marker-root", + "${CLAUDE_PLUGIN_DATA}", + "--launch-marker", + "clean-invoked", "${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/engine_context.py", "--plugin-root", "${CLAUDE_PLUGIN_ROOT}" diff --git a/plugins/disk-hygiene/hooks/resume-notice.mjs b/plugins/disk-hygiene/hooks/resume-notice.mjs new file mode 100644 index 0000000000..c67901b7ea --- /dev/null +++ b/plugins/disk-hygiene/hooks/resume-notice.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// SessionStart notice for a session resumed after /disk-hygiene:clean ran in it (#9853). +// +// The clean skill's frontmatter belt (the PowerShell deletion prompts and the +// deny-by-default Bash lane) is registered in the process that invoked the +// skill. A Windows probe found it absent after `claude -p --resume`, which +// starts a new process on the same session id. The UserPromptExpansion row in +// hooks.json writes `/clean-invoked/.launched` through +// run-python-hook.sh's `--launch-marker`; on `source: resume` this script looks +// for that marker and, when it is there, tells the model to make no deletes or +// overwrites until the user types /disk-hygiene:clean again, which registers +// the belt again. It is a notice, not a gate. +// +// node resume-notice.mjs --marker-root +// +// It always exits 0 and prints nothing unless the source is `resume` and a +// marker exists. Candidates mirror run-python-hook.sh's `_marker_candidates`: +// the marker root, then (off Windows) `${TMPDIR:-/tmp}/disk-hygiene-clean-invoked`. +// On Windows the bash fallback tmp directory is not one node can name, so only +// the marker root counts there, as in exec-bash.mjs's marker check. +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const SUBDIR = 'clean-invoked' +const DATA_PLACEHOLDER = '${CLAUDE_PLUGIN_DATA}' + +export const NOTICE = + 'disk-hygiene: this session was resumed after /disk-hygiene:clean ran in it. ' + + "The clean skill's guard (its PowerShell deletion prompts and deny-by-default Bash lane) " + + 'is registered only in the process that invoked the skill, so it is not active now. ' + + 'Until the user types /disk-hygiene:clean again, make no delete, move to the Recycle Bin, ' + + 'or overwrite of any file through any tool, even one the user approved before the resume. ' + + 'If a cleanup step is pending, ask the user to type /disk-hygiene:clean to re-arm the guard first.' + +export function markerCandidates(session, { markerRoot, env, platform }) { + const safe = String(session).replace(/[^a-zA-Z0-9_-]/g, '_') + if (!safe) return [] + const name = `${safe}.launched` + const out = [] + if (markerRoot && markerRoot !== DATA_PLACEHOLDER) out.push(join(markerRoot, SUBDIR, name)) + if (platform !== 'win32') out.push(join(env.TMPDIR || '/tmp', `disk-hygiene-${SUBDIR}`, name)) + return out +} + +export function decide(payload, ctx) { + if (!payload || payload.source !== 'resume') return null + const session = typeof payload.session_id === 'string' ? payload.session_id : '' + if (!session || /[^\x20-\x7e]/.test(session)) return null + const exists = ctx.exists ?? existsSync + if (!markerCandidates(session, ctx).some((p) => exists(p))) return null + return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: NOTICE } } +} + +function main(argv) { + const at = argv.indexOf('--marker-root') + const markerRoot = at >= 0 ? (argv[at + 1] ?? '') : '' + let payload + try { + payload = JSON.parse(readFileSync(0, 'utf8')) + } catch { + return + } + const out = decide(payload, { markerRoot, env: process.env, platform: process.platform }) + if (out) process.stdout.write(`${JSON.stringify(out)}\n`) +} + +const fold = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p)) +if (process.argv[1] && fold(fileURLToPath(import.meta.url)) === fold(process.argv[1])) { + try { + main(process.argv.slice(2)) + } catch { + // A failed notice must never break session start. + } +} diff --git a/plugins/disk-hygiene/hooks/resume-notice.test.mjs b/plugins/disk-hygiene/hooks/resume-notice.test.mjs new file mode 100644 index 0000000000..d2f93c8117 --- /dev/null +++ b/plugins/disk-hygiene/hooks/resume-notice.test.mjs @@ -0,0 +1,141 @@ +// test-scope: plugins/disk-hygiene/hooks/hooks.json +// Contract tests for the resume notice (#9853): a resumed session hears that the +// clean skill's guard is gone only when /disk-hygiene:clean ran in that session, +// and every other session start stays quiet. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { decide, markerCandidates, NOTICE, SUBDIR } from './resume-notice.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const pluginRoot = dirname(here) +const hooksJson = JSON.parse(readFileSync(join(here, 'hooks.json'), 'utf8')) + +function scratch(t) { + const dir = mkdtempSync(join(tmpdir(), 'dh-resume-notice-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + return dir +} + +function mark(root, session) { + mkdirSync(join(root, SUBDIR), { recursive: true }) + writeFileSync(join(root, SUBDIR, `${session}.launched`), '') +} + +const ctxFor = (markerRoot, extra = {}) => ({ + markerRoot, + env: { TMPDIR: join(markerRoot, 'no-such-tmp') }, + platform: 'linux', + ...extra, +}) + +test('a resume with the clean marker for this session gets the notice', (t) => { + const root = scratch(t) + mark(root, 'sess-1') + const out = decide({ session_id: 'sess-1', source: 'resume' }, ctxFor(root)) + assert.deepEqual(out, { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: NOTICE } }) + assert.match(NOTICE, /\/disk-hygiene:clean again/) +}) + +test('a resume without the marker stays quiet', (t) => { + const root = scratch(t) + assert.equal(decide({ session_id: 'sess-1', source: 'resume' }, ctxFor(root)), null) + mark(root, 'other-session') + assert.equal(decide({ session_id: 'sess-1', source: 'resume' }, ctxFor(root)), null) +}) + +test('startup, clear and fork stay quiet even with the marker', (t) => { + const root = scratch(t) + mark(root, 'sess-1') + for (const source of ['startup', 'clear', 'fork', 'compact', undefined]) { + assert.equal(decide({ session_id: 'sess-1', source }, ctxFor(root)), null, String(source)) + } +}) + +test('a missing or non-ASCII session id stays quiet', (t) => { + const root = scratch(t) + mark(root, '_') + assert.equal(decide({ source: 'resume' }, ctxFor(root)), null) + assert.equal(decide({ session_id: 'é', source: 'resume' }, ctxFor(root)), null) +}) + +test('an unexpanded data placeholder falls back to the tmp candidate off Windows only', () => { + const env = { TMPDIR: '/t' } + assert.deepEqual(markerCandidates('s', { markerRoot: '${CLAUDE_PLUGIN_DATA}', env, platform: 'linux' }), [ + join('/t', `disk-hygiene-${SUBDIR}`, 's.launched'), + ]) + assert.deepEqual(markerCandidates('s', { markerRoot: '${CLAUDE_PLUGIN_DATA}', env, platform: 'win32' }), []) + assert.deepEqual(markerCandidates('a/b', { markerRoot: '/d', env, platform: 'win32' }), [ + join('/d', SUBDIR, 'a_b.launched'), + ]) +}) + +// End to end through the registered rows: the UserPromptExpansion row writes the +// marker through the real launcher, and the SessionStart row reads it. +function registered(event, needle) { + const rows = hooksJson.hooks[event].flatMap((group) => group.hooks) + const row = rows.find((h) => (h.args ?? []).join(' ').includes(needle)) + assert.ok(row, `${event} has a row naming ${needle}`) + return row +} + +function run(row, dataRoot, payload, env = {}) { + const args = row.args.map((a) => + a.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot).replaceAll('${CLAUDE_PLUGIN_DATA}', dataRoot), + ) + return spawnSync(row.command, args, { + input: JSON.stringify(payload), + encoding: 'utf8', + env: { ...process.env, ...env }, + timeout: 30000, + }) +} + +test('the registered rows: the notice fires only after /disk-hygiene:clean expanded in that session', (t) => { + const data = scratch(t) + const tmp = join(data, 'tmp') + mkdirSync(tmp) + const env = { TMPDIR: tmp } + const expansion = registered('UserPromptExpansion', 'engine_context.py') + const resume = registered('SessionStart', 'resume-notice.mjs') + assert.equal(resume.command, 'node') + + const before = run(resume, data, { session_id: 'sess-9', source: 'resume' }, env) + assert.equal(before.status, 0, before.stderr) + assert.equal(before.stdout, '', 'no marker yet: quiet') + + const expanded = run( + expansion, + data, + { session_id: 'sess-9', hook_event_name: 'UserPromptExpansion', command_name: 'disk-hygiene:clean' }, + env, + ) + assert.equal(expanded.status, 0, expanded.stderr) + assert.ok(existsSync(join(data, SUBDIR, 'sess-9.launched')), 'the expansion row wrote the marker') + + const after = run(resume, data, { session_id: 'sess-9', source: 'resume' }, env) + assert.equal(after.status, 0, after.stderr) + assert.equal(JSON.parse(after.stdout).hookSpecificOutput.additionalContext, NOTICE) + + for (const payload of [ + { session_id: 'sess-9', source: 'startup' }, + { session_id: 'sess-other', source: 'resume' }, + ]) { + const quiet = run(resume, data, payload, env) + assert.equal(quiet.status, 0, quiet.stderr) + assert.equal(quiet.stdout, '', JSON.stringify(payload)) + } +}) + +test('a malformed payload exits 0 and prints nothing', () => { + const r = spawnSync('node', [join(here, 'resume-notice.mjs'), '--marker-root', '/nonexistent'], { + input: 'not json', + encoding: 'utf8', + }) + assert.equal(r.status, 0) + assert.equal(r.stdout, '') +}) diff --git a/plugins/disk-hygiene/hooks/run-python-hook.test.sh b/plugins/disk-hygiene/hooks/run-python-hook.test.sh index 822129241e..7c8a97dbfb 100755 --- a/plugins/disk-hygiene/hooks/run-python-hook.test.sh +++ b/plugins/disk-hygiene/hooks/run-python-hook.test.sh @@ -933,9 +933,16 @@ assert_eq "the launcher skips the Stop row before bash with the script's own mar # --- the SessionStart node notice is shell form and needs no node --- # Its behavior, with and without node and under PowerShell, is run by # scripts/node-notice-rows.test.sh; this pins that disk-hygiene keeps it shell form. -notice="$(jq -c '.hooks.SessionStart[].hooks[]' "$HOOKS_JSON")" -assert_eq "one SessionStart row" "1" "$(jq -s 'length' <<<"$notice")" -assert_eq "the SessionStart row is shell form with no args" "true" \ +# The same SessionStart entry also runs the resume notice (#9853), whose +# behavior resume-notice.test.mjs runs; this pins the entry's two handlers. +assert_eq "one SessionStart entry with two handlers" "[2]" \ + "$(jq -c '[.hooks.SessionStart[] | .hooks | length]' "$HOOKS_JSON")" +notice="$(jq -c '.hooks.SessionStart[0].hooks[0]' "$HOOKS_JSON")" +assert_eq "the first SessionStart handler is shell form with no args" "true" \ "$(jq '.type == "command" and (has("args") | not) and (.command | startswith("sh ") and contains("prerequisites.sh\" node-notice /disk-hygiene:check"))' <<<"$notice")" +assert_eq "the second SessionStart handler is the exec-form resume notice" "true" \ + "$(jq '.hooks.SessionStart[0].hooks[1] | .command == "node" and .args == ["${CLAUDE_PLUGIN_ROOT}/hooks/resume-notice.mjs", "--marker-root", "${CLAUDE_PLUGIN_DATA}"]' "$HOOKS_JSON" | tr -d '\r')" +assert_eq "the clean expansion records the session the resume notice reads" "true" \ + "$(jq '[.hooks.UserPromptExpansion[].hooks[].args | .[1:6] == ["${CLAUDE_PLUGIN_ROOT}/hooks/run-python-hook.sh", "--marker-root", "${CLAUDE_PLUGIN_DATA}", "--launch-marker", "clean-invoked"]] == [true]' "$HOOKS_JSON" | tr -d '\r')" pass "all run-python-hook contract checks" diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index c7e1cb06dc..f53d4588c9 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -598,15 +598,40 @@ the two surfaces reach different lanes. The **always-on engine gate** enforces i engine invocation **whether or not the clean skill is active**; it defers (no output) on any command that does not reference the engine, so it does **not** see PowerShell deletion spellings. Those are enforced by the **skill-frontmatter belt** (`powershell_decision`), denied outright in audit-only, for the **rest of -the session after the skill is invoked**. Claude Code registers a skill's frontmatter `PreToolUse` hooks -when the skill is invoked and keeps them registered session-wide; the skills reference states it plainly -("Hooks that Claude Code registers when the skill is invoked and keeps running for the rest of the -session"). There is no harness-level "while the skill is active" window for hooks. The asymmetry +the process that invoked the skill**. Claude Code registers a skill's frontmatter `PreToolUse` hooks +when the skill is invoked; the skills reference says they keep running "for the rest of the session" +and does not mention resume. There is no harness-level "while the skill is active" window for hooks. The asymmetry is easy to misread and is worth naming: a skill's `allowed-tools` and `disallowed-tools` grants DO clear on the user's next message, but its `hooks` do not, so "skill-scoped" is true of the tool grants and false of the belt. Consequences in both directions: the belt keeps enforcing over unrelated later work in -the same session (a later `Remove-Item` is still prompted long after cleanup ended), and it cannot be -retracted by finishing the cleanup. Only the session's end clears it. +the same process (a later `Remove-Item` is still prompted long after cleanup ended), and it cannot be +retracted by finishing the cleanup. + +**The belt does not survive a resume.** `claude -p --resume ` starts a new process on the same +session id, and in that process the belt is not registered: the same PowerShell delete that the belt +prompted (and the headless run denied) inside one process ran unprompted after `--resume`, and the file +was recycled. With the belt gone, the deny-by-default Bash lane and the PowerShell deletion prompts are +gone too; only the always-on engine gate, the permission mode and the auto-mode classifier remain. The +plugin answers this with a notice, not a gate: the `UserPromptExpansion` hook for `/disk-hygiene:clean` +also writes a per-session marker (`clean-invoked/.launched` under the plugin data +directory, through the launcher's `--launch-marker`), and on a `SessionStart` whose `source` is +`resume` the plugin's existing `SessionStart` entry (`hooks/resume-notice.mjs`) finds that marker and +tells the model to make no delete or overwrite until the user types `/disk-hygiene:clean` again, which +registers the belt again. It relies on the model following the notice. A forked session and `/clear` +start a new session id, so the marker does not carry over and no notice fires; whether the belt itself +survives `/clear` or compaction in the same process was not probed. + +- **Verification record.** Claim: the belt holds within one process and is absent after + `claude -p --resume`. Basis: the Windows probe table (runs b, c and e) in + and + [#9853](https://github.com/melodic-software/claude-code-plugins/issues/9853) (Claude Code 2.1.309, + native Windows, headless `--permission-mode auto`, PowerShell tool). Only headless `-p --resume` was + probed; interactive `claude --resume`, `--continue`, `/clear` and compaction were not. Doc pointer: + [Hooks in skills and agents](https://code.claude.com/docs/en/hooks#hooks-in-skills-and-agents) and + [SessionStart decision control](https://code.claude.com/docs/en/hooks#sessionstart-decision-control) + (a `SessionStart` hook can add context but cannot register a hook). As of: 2026-10-25. Recheck + trigger: either section changes how long a skill's hooks stay registered or what a `SessionStart` + hook can return, or a probe of interactive resume. An absent, unreadable, or ambiguous read fails **closed to enabled**: the guard stays active and forces a human prompt before every mutation **it sees**, meaning every Bash engine `apply` and `handoff-apply` and, on PowerShell, only the flagged spellings above, so an unreadable toggle never silently disables @@ -713,8 +738,12 @@ Headless default mode denied the call, `--bg` default mode parked at the permiss `--permission-mode bypassPermissions` and headless auto mode listed the call in `permission_denials` with the target intact, and an interactive `bypassPermissions` session showed the person a prompt and ran the delete only after they approved it. So the guard needs no gate that -does not depend on the permission mode: in each mode its `ask` reaches a person or is denied. Not -probed: the Windows PowerShell tool. **Basis:** the probe table in +does not depend on the permission mode: in each mode its `ask` reaches a person or is denied. On the +Windows PowerShell tool, the belt's `ask` on a manual-lane delete held in one headless auto-mode process +(the call was denied and the target survived) but was absent after `claude -p --resume`, where the +delete ran unprompted ([#5609 probe](https://github.com/melodic-software/claude-code-plugins/issues/5609#issuecomment-6388935329), +Claude Code 2.1.309); the resume limit and the plugin's notice for it are stated under the kill +switch's two surfaces above. **Basis:** the probe table in and (Claude Code 2.1.285, Linux/WSL2, Bash tool, fresh scratch target); @@ -733,7 +762,8 @@ resets bypass mode; closed as not planned 2026-04-20) and not enforced on the PowerShell tool in default mode; closed as not planned 2026-09-21) leave the behavior undocumented. **As of:** 2026-10-22 for the probes; 2026-09-30 for the two doc pages. **Recheck:** a Claude Code changelog entry that names PreToolUse `ask` or `bypassPermissions`, a -change to the "Actions no mode auto-approves" list, or the PowerShell-tool probe tracked in #5609. +change to the "Actions no mode auto-approves" list, or a probe of the PowerShell tool under interactive +resume. A depth-limited scan records every directory it declined to enter in `truncated_paths`. Truncated directories have no captured descendant set, so the preview blocks them (and anything beneath them) diff --git a/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md b/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md index aeab51e3c3..746d68873c 100644 --- a/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md +++ b/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md @@ -213,12 +213,18 @@ bodies through `--body-file ` or `-F `. ## Hook registration outlives the cleanup -This skill's frontmatter hooks stay registered for the **rest of the session** once -`/disk-hygiene:clean` has run +This skill's frontmatter hooks stay registered for the **rest of the process** that ran +`/disk-hygiene:clean` ([Hooks in skills and agents](https://code.claude.com/docs/en/hooks#hooks-in-skills-and-agents), -as of 2026-10-11; recheck when that section changes how long a skill's hooks stay registered). +as of 2026-10-25; recheck when that section changes how long a skill's hooks stay registered). So the deny-by-default Bash lane and the PowerShell deletion prompts keep applying to -unrelated later work in the same session, not only to this cleanup. Say so when a later, -unrelated command is blocked or prompted, rather than treating it as a surprise; the session's own -end is what clears it. Both registration surfaces, the kill switch, and what the belt does and does not -bound: see [`safety-model.md`](safety-model.md). +unrelated later work in the same process, not only to this cleanup. Say so when a later, +unrelated command is blocked or prompted, rather than treating it as a surprise. + +They do **not** survive a resume: after `claude -p --resume` the belt was absent and a PowerShell +delete the user had approved before the resume ran unprompted +([#9853](https://github.com/melodic-software/claude-code-plugins/issues/9853)). Run each approved +manual-lane delete in the same process that ran the skill. When a session resumed after this skill +ran, the plugin's resume notice says so at session start: make no delete or overwrite until the user +types `/disk-hygiene:clean` again. Both registration surfaces, the kill switch, the resume record, and +what the belt does and does not bound: see [`safety-model.md`](safety-model.md). diff --git a/plugins/disk-hygiene/skills/setup/SKILL.md b/plugins/disk-hygiene/skills/setup/SKILL.md index a0a1aaa5ba..7f7500a305 100644 --- a/plugins/disk-hygiene/skills/setup/SKILL.md +++ b/plugins/disk-hygiene/skills/setup/SKILL.md @@ -86,8 +86,8 @@ fails closed like every other guard-relevant unknown in this plugin. 2. **Python floor on `PATH`**. The interpreter used by scanning, validation, the guard, and cleanup. (The guard registers on two surfaces: a plugin-level engine gate that acts only on engine-referencing commands, and the skill-frontmatter belt that - Claude Code keeps armed for the rest of the session after `/disk-hygiene:clean` is - invoked. Both register unconditionally and resolve the kill switch by + Claude Code keeps armed for the rest of the process after `/disk-hygiene:clean` is + invoked, and not after a resume. Both register unconditionally and resolve the kill switch by reading `disk_hygiene_enabled` from managed settings first, then the user `settings.json`.) The required version has one origin: the `MIN_PYTHON` constant in `${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py`, parse it from there (`grep -m1 '^MIN_PYTHON' …`) and probe the interpreter against that value; do not From 9e3dda70bbc6fd8a9a4688f1fccab3fe796e5386 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:38:36 -0400 Subject: [PATCH 2/4] fix(disk-hygiene): mark the resume notice hook executable Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/hooks/resume-notice.mjs | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 plugins/disk-hygiene/hooks/resume-notice.mjs diff --git a/plugins/disk-hygiene/hooks/resume-notice.mjs b/plugins/disk-hygiene/hooks/resume-notice.mjs old mode 100644 new mode 100755 From 1d09a25641707d8684d45455ac0dd98f6f9dfb1e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:10:03 -0400 Subject: [PATCH 3/4] fix(disk-hygiene): reach the resume notice through a linked plugin path Co-Authored-By: Claude Opus 5.5 --- ...fix-disk-hygiene-resume-notice-9853a1c4.md | 2 +- plugins/disk-hygiene/hooks/resume-notice.mjs | 19 ++++++++++++++----- .../disk-hygiene/hooks/resume-notice.test.mjs | 15 ++++++++++++++- .../skills/clean/reference/safety-model.md | 4 +++- .../reference/unsupported-platform-handoff.md | 4 ++-- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.changes/disk-hygiene/fix-disk-hygiene-resume-notice-9853a1c4.md b/.changes/disk-hygiene/fix-disk-hygiene-resume-notice-9853a1c4.md index 2bc01fa593..11ec9e73e7 100644 --- a/.changes/disk-hygiene/fix-disk-hygiene-resume-notice-9853a1c4.md +++ b/.changes/disk-hygiene/fix-disk-hygiene-resume-notice-9853a1c4.md @@ -4,4 +4,4 @@ bump: patch ### Fixed -- **A session resumed after `/disk-hygiene:clean` now hears that the clean skill's guard is gone.** The skill-frontmatter belt (the PowerShell deletion prompts and the deny-by-default Bash lane) is not registered after `claude -p --resume`, so a manual-lane delete approved before the resume ran unprompted. The `/disk-hygiene:clean` expansion hook now writes a per-session marker, and the existing `SessionStart` entry, on `source: resume` with that marker present, tells the model to make no delete or overwrite until the user types `/disk-hygiene:clean` again. It is a notice, not a gate. The safety model, the unsupported-platform handoff, the README and the setup skill now state the belt's lifetime as the process that invoked the skill, with the Windows probe as the verification record ([#9853](https://github.com/melodic-software/claude-code-plugins/issues/9853)). +- **A session resumed after `/disk-hygiene:clean` now hears that the clean skill's guard is gone.** The skill-frontmatter belt (the PowerShell deletion prompts and the deny-by-default Bash lane) is not registered after `claude -p --resume`, so an approved manual-lane delete ran unprompted in the resumed process. The `/disk-hygiene:clean` expansion hook now writes a per-session marker, and the existing `SessionStart` entry, on `source: resume` with that marker present, tells the model to make no delete or overwrite until the user types `/disk-hygiene:clean` again. It is a notice, not a gate. The safety model, the unsupported-platform handoff, the README and the setup skill now state the belt's lifetime as the process that invoked the skill, with the Windows probe as the verification record ([#9853](https://github.com/melodic-software/claude-code-plugins/issues/9853)). diff --git a/plugins/disk-hygiene/hooks/resume-notice.mjs b/plugins/disk-hygiene/hooks/resume-notice.mjs index c67901b7ea..9e0b493644 100755 --- a/plugins/disk-hygiene/hooks/resume-notice.mjs +++ b/plugins/disk-hygiene/hooks/resume-notice.mjs @@ -16,9 +16,10 @@ // It always exits 0 and prints nothing unless the source is `resume` and a // marker exists. Candidates mirror run-python-hook.sh's `_marker_candidates`: // the marker root, then (off Windows) `${TMPDIR:-/tmp}/disk-hygiene-clean-invoked`. -// On Windows the bash fallback tmp directory is not one node can name, so only -// the marker root counts there, as in exec-bash.mjs's marker check. -import { existsSync, readFileSync } from 'node:fs' +// On Windows the launcher's tmp fallback is a Git Bash path this script does +// not resolve, so only the marker root counts there; with +// ${CLAUDE_PLUGIN_DATA} expanded the launcher writes the marker root first. +import { existsSync, readFileSync, realpathSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -65,8 +66,16 @@ function main(argv) { if (out) process.stdout.write(`${JSON.stringify(out)}\n`) } -const fold = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p)) -if (process.argv[1] && fold(fileURLToPath(import.meta.url)) === fold(process.argv[1])) { +// Node realpaths the main entry, so compare real paths: a launch through a +// symlinked or junctioned plugin path must still reach main(). +function realPath(p) { + try { + return realpathSync(p) + } catch { + return resolve(p) + } +} +if (process.argv[1] && realPath(process.argv[1]) === realPath(fileURLToPath(import.meta.url))) { try { main(process.argv.slice(2)) } catch { diff --git a/plugins/disk-hygiene/hooks/resume-notice.test.mjs b/plugins/disk-hygiene/hooks/resume-notice.test.mjs index d2f93c8117..55c3740946 100644 --- a/plugins/disk-hygiene/hooks/resume-notice.test.mjs +++ b/plugins/disk-hygiene/hooks/resume-notice.test.mjs @@ -4,7 +4,7 @@ // and every other session start stays quiet. import { test } from 'node:test' import assert from 'node:assert/strict' -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, existsSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -131,6 +131,19 @@ test('the registered rows: the notice fires only after /disk-hygiene:clean expan } }) +test('a launch through a symlinked plugin path still prints the notice', (t) => { + const data = scratch(t) + mark(data, 'sess-link') + const link = join(data, 'plugin-link') + symlinkSync(pluginRoot, link, 'junction') + const r = spawnSync('node', [join(link, 'hooks', 'resume-notice.mjs'), '--marker-root', data], { + input: JSON.stringify({ session_id: 'sess-link', source: 'resume' }), + encoding: 'utf8', + }) + assert.equal(r.status, 0, r.stderr) + assert.equal(JSON.parse(r.stdout).hookSpecificOutput.additionalContext, NOTICE) +}) + test('a malformed payload exits 0 and prints nothing', () => { const r = spawnSync('node', [join(here, 'resume-notice.mjs'), '--marker-root', '/nonexistent'], { input: 'not json', diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index f53d4588c9..8ce680aecd 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -617,7 +617,9 @@ also writes a per-session marker (`clean-invoked/.launched` under th directory, through the launcher's `--launch-marker`), and on a `SessionStart` whose `source` is `resume` the plugin's existing `SessionStart` entry (`hooks/resume-notice.mjs`) finds that marker and tells the model to make no delete or overwrite until the user types `/disk-hygiene:clean` again, which -registers the belt again. It relies on the model following the notice. A forked session and `/clear` +registers the belt again. It relies on the model following the notice. The marker is written before +the launcher resolves Python, so an expansion blocked for want of an interpreter still leaves it, and a +later resume still warns; that errs toward no deletes. A forked session and `/clear` start a new session id, so the marker does not carry over and no notice fires; whether the belt itself survives `/clear` or compaction in the same process was not probed. diff --git a/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md b/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md index 746d68873c..e01a4b56d5 100644 --- a/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md +++ b/plugins/disk-hygiene/skills/clean/reference/unsupported-platform-handoff.md @@ -221,8 +221,8 @@ So the deny-by-default Bash lane and the PowerShell deletion prompts keep applyi unrelated later work in the same process, not only to this cleanup. Say so when a later, unrelated command is blocked or prompted, rather than treating it as a surprise. -They do **not** survive a resume: after `claude -p --resume` the belt was absent and a PowerShell -delete the user had approved before the resume ran unprompted +They do **not** survive a resume: after `claude -p --resume` the belt was absent and an approved +PowerShell delete ran unprompted in the resumed process ([#9853](https://github.com/melodic-software/claude-code-plugins/issues/9853)). Run each approved manual-lane delete in the same process that ran the skill. When a session resumed after this skill ran, the plugin's resume notice says so at session start: make no delete or overwrite until the user From a91f40c610fbce09e6cf9d155b58b374a93e7566 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:42:45 -0400 Subject: [PATCH 4/4] fix(disk-hygiene): bound the resume notice's stdin and keep skill text to pointers The notice no longer waits for EOF: it stops at the first complete JSON payload, a size cap or a deadline. A sibling .test.sh lets CI's lanes run the Node suite. The safety model's resume text states our decision with a pointer record instead of restating upstream behavior and versions. Co-Authored-By: Claude Opus 5.5 --- plugins/disk-hygiene/hooks/resume-notice.mjs | 59 ++++++++++++++----- .../disk-hygiene/hooks/resume-notice.test.mjs | 19 +++++- .../disk-hygiene/hooks/resume-notice.test.sh | 14 +++++ .../skills/clean/reference/safety-model.md | 50 ++++++++-------- plugins/disk-hygiene/skills/setup/SKILL.md | 6 +- 5 files changed, 103 insertions(+), 45 deletions(-) create mode 100755 plugins/disk-hygiene/hooks/resume-notice.test.sh diff --git a/plugins/disk-hygiene/hooks/resume-notice.mjs b/plugins/disk-hygiene/hooks/resume-notice.mjs index 9e0b493644..be05343f7f 100755 --- a/plugins/disk-hygiene/hooks/resume-notice.mjs +++ b/plugins/disk-hygiene/hooks/resume-notice.mjs @@ -19,7 +19,7 @@ // On Windows the launcher's tmp fallback is a Git Bash path this script does // not resolve, so only the marker root counts there; with // ${CLAUDE_PLUGIN_DATA} expanded the launcher writes the marker root first. -import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { existsSync, realpathSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -53,16 +53,48 @@ export function decide(payload, ctx) { return { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: NOTICE } } } -function main(argv) { +export const MAX_STDIN_BYTES = 1 << 20 +export const STDIN_DEADLINE_MS = 2000 + +// Read the payload without waiting for EOF: stop at the first chunk that +// completes a JSON object, at the size cap, or at the deadline, so a pipe that +// stays open cannot hold session start for the hook's whole timeout. +export function readPayload(stream, { maxBytes = MAX_STDIN_BYTES, deadlineMs = STDIN_DEADLINE_MS } = {}) { + return new Promise((settle) => { + let text = '' + let done = false + const parse = () => { + try { + return JSON.parse(text) + } catch { + return undefined + } + } + const finish = (value) => { + if (done) return + done = true + clearTimeout(timer) + stream.pause() + settle(value) + } + const timer = setTimeout(() => finish(parse()), deadlineMs) + stream.setEncoding('utf8') + stream.on('data', (chunk) => { + text += chunk + if (text.length > maxBytes) return finish(undefined) + const value = parse() + if (value !== undefined) finish(value) + }) + stream.on('end', () => finish(parse())) + stream.on('error', () => finish(undefined)) + }) +} + +async function main(argv) { const at = argv.indexOf('--marker-root') const markerRoot = at >= 0 ? (argv[at + 1] ?? '') : '' - let payload - try { - payload = JSON.parse(readFileSync(0, 'utf8')) - } catch { - return - } - const out = decide(payload, { markerRoot, env: process.env, platform: process.platform }) + const payload = await readPayload(process.stdin) + const out = payload ? decide(payload, { markerRoot, env: process.env, platform: process.platform }) : null if (out) process.stdout.write(`${JSON.stringify(out)}\n`) } @@ -76,9 +108,8 @@ function realPath(p) { } } if (process.argv[1] && realPath(process.argv[1]) === realPath(fileURLToPath(import.meta.url))) { - try { - main(process.argv.slice(2)) - } catch { - // A failed notice must never break session start. - } + // A failed notice must never break session start; exit even if stdin stays open. + main(process.argv.slice(2)) + .catch(() => {}) + .finally(() => process.stdout.write('', () => process.exit(0))) } diff --git a/plugins/disk-hygiene/hooks/resume-notice.test.mjs b/plugins/disk-hygiene/hooks/resume-notice.test.mjs index 55c3740946..f89001e880 100644 --- a/plugins/disk-hygiene/hooks/resume-notice.test.mjs +++ b/plugins/disk-hygiene/hooks/resume-notice.test.mjs @@ -5,7 +5,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, existsSync } from 'node:fs' -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -144,6 +144,23 @@ test('a launch through a symlinked plugin path still prints the notice', (t) => assert.equal(JSON.parse(r.stdout).hookSpecificOutput.additionalContext, NOTICE) }) +test('a payload whose pipe stays open is handled without waiting for EOF', async (t) => { + const data = scratch(t) + mark(data, 'sess-open') + const child = spawn('node', [join(here, 'resume-notice.mjs'), '--marker-root', data], { + stdio: ['pipe', 'pipe', 'pipe'], + }) + t.after(() => child.kill()) + let stdout = '' + child.stdout.setEncoding('utf8').on('data', (c) => (stdout += c)) + child.stdin.write(JSON.stringify({ session_id: 'sess-open', source: 'resume' })) + const started = Date.now() + const code = await new Promise((res) => child.on('exit', res)) + assert.equal(code, 0) + assert.ok(Date.now() - started < 5000, 'exited well inside the 10 s hook timeout') + assert.equal(JSON.parse(stdout).hookSpecificOutput.additionalContext, NOTICE) +}) + test('a malformed payload exits 0 and prints nothing', () => { const r = spawnSync('node', [join(here, 'resume-notice.mjs'), '--marker-root', '/nonexistent'], { input: 'not json', diff --git a/plugins/disk-hygiene/hooks/resume-notice.test.sh b/plugins/disk-hygiene/hooks/resume-notice.test.sh new file mode 100755 index 0000000000..5ec7827627 --- /dev/null +++ b/plugins/disk-hygiene/hooks/resume-notice.test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# test-scope: plugins/disk-hygiene/hooks/hooks.json plugins/disk-hygiene/hooks/run-python-hook.sh +# Discovery wrapper: scripts/run-plugin-tests.sh finds plugins/**/*.test.sh, so +# this hands off to the Node suite. SKIPs (exit 0) when Node is unavailable. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v node >/dev/null 2>&1; then + echo "SKIP: node not installed" >&2 + exit 0 +fi + +exec node --test "$SCRIPT_DIR/resume-notice.test.mjs" diff --git a/plugins/disk-hygiene/skills/clean/reference/safety-model.md b/plugins/disk-hygiene/skills/clean/reference/safety-model.md index 8ce680aecd..ba2dda7e74 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -598,42 +598,38 @@ the two surfaces reach different lanes. The **always-on engine gate** enforces i engine invocation **whether or not the clean skill is active**; it defers (no output) on any command that does not reference the engine, so it does **not** see PowerShell deletion spellings. Those are enforced by the **skill-frontmatter belt** (`powershell_decision`), denied outright in audit-only, for the **rest of -the process that invoked the skill**. Claude Code registers a skill's frontmatter `PreToolUse` hooks -when the skill is invoked; the skills reference says they keep running "for the rest of the session" -and does not mention resume. There is no harness-level "while the skill is active" window for hooks. The asymmetry +the process that invoked the skill**. There is no "while the skill is active" window for the belt. The asymmetry is easy to misread and is worth naming: a skill's `allowed-tools` and `disallowed-tools` grants DO clear on the user's next message, but its `hooks` do not, so "skill-scoped" is true of the tool grants and false of the belt. Consequences in both directions: the belt keeps enforcing over unrelated later work in the same process (a later `Remove-Item` is still prompted long after cleanup ended), and it cannot be retracted by finishing the cleanup. -**The belt does not survive a resume.** `claude -p --resume ` starts a new process on the same -session id, and in that process the belt is not registered: the same PowerShell delete that the belt -prompted (and the headless run denied) inside one process ran unprompted after `--resume`, and the file -was recycled. With the belt gone, the deny-by-default Bash lane and the PowerShell deletion prompts are -gone too; only the always-on engine gate, the permission mode and the auto-mode classifier remain. The -plugin answers this with a notice, not a gate: the `UserPromptExpansion` hook for `/disk-hygiene:clean` -also writes a per-session marker (`clean-invoked/.launched` under the plugin data -directory, through the launcher's `--launch-marker`), and on a `SessionStart` whose `source` is -`resume` the plugin's existing `SessionStart` entry (`hooks/resume-notice.mjs`) finds that marker and +**Treat a resumed process as one without the belt.** Our probe found the belt absent after +`claude -p --resume`, so a resumed session has no deny-by-default Bash lane and no PowerShell deletion +prompts from this plugin; the always-on engine gate, the permission mode and the auto-mode classifier +remain. The plugin answers with a notice, not a gate: the `UserPromptExpansion` hook for +`/disk-hygiene:clean` also writes a per-session marker (`clean-invoked/.launched` under the +plugin data directory, through the launcher's `--launch-marker`), and on a `SessionStart` whose `source` +is `resume` the plugin's existing `SessionStart` entry (`hooks/resume-notice.mjs`) finds that marker and tells the model to make no delete or overwrite until the user types `/disk-hygiene:clean` again, which registers the belt again. It relies on the model following the notice. The marker is written before the launcher resolves Python, so an expansion blocked for want of an interpreter still leaves it, and a -later resume still warns; that errs toward no deletes. A forked session and `/clear` -start a new session id, so the marker does not carry over and no notice fires; whether the belt itself -survives `/clear` or compaction in the same process was not probed. +later resume still warns; that errs toward no deletes. The marker is keyed on the session id, so a +session that starts under a new id gets no notice. -- **Verification record.** Claim: the belt holds within one process and is absent after - `claude -p --resume`. Basis: the Windows probe table (runs b, c and e) in +- **Pointer**: the probe (runs b, c and e) in and - [#9853](https://github.com/melodic-software/claude-code-plugins/issues/9853) (Claude Code 2.1.309, - native Windows, headless `--permission-mode auto`, PowerShell tool). Only headless `-p --resume` was - probed; interactive `claude --resume`, `--continue`, `/clear` and compaction were not. Doc pointer: - [Hooks in skills and agents](https://code.claude.com/docs/en/hooks#hooks-in-skills-and-agents) and - [SessionStart decision control](https://code.claude.com/docs/en/hooks#sessionstart-decision-control) - (a `SessionStart` hook can add context but cannot register a hook). As of: 2026-10-25. Recheck - trigger: either section changes how long a skill's hooks stay registered or what a `SessionStart` - hook can return, or a probe of interactive resume. + [#9853](https://github.com/melodic-software/claude-code-plugins/issues/9853); only headless + `-p --resume` was probed, not interactive resume, `--continue`, `/clear` or compaction. For how long + a skill's hooks stay registered, read + [Hooks in skills and agents](https://code.claude.com/docs/en/hooks#hooks-in-skills-and-agents); for + what a `SessionStart` hook can return, read + [SessionStart decision control](https://code.claude.com/docs/en/hooks#sessionstart-decision-control). +- **As of**: 2026-10-25. +- **Recheck trigger**: either section changes, or a probe of interactive resume, `/clear` or + compaction. + An absent, unreadable, or ambiguous read fails **closed to enabled**: the guard stays active and forces a human prompt before every mutation **it sees**, meaning every Bash engine `apply` and `handoff-apply` and, on PowerShell, only the flagged spellings above, so an unreadable toggle never silently disables @@ -743,8 +739,8 @@ the person a prompt and ran the delete only after they approved it. So the guard does not depend on the permission mode: in each mode its `ask` reaches a person or is denied. On the Windows PowerShell tool, the belt's `ask` on a manual-lane delete held in one headless auto-mode process (the call was denied and the target survived) but was absent after `claude -p --resume`, where the -delete ran unprompted ([#5609 probe](https://github.com/melodic-software/claude-code-plugins/issues/5609#issuecomment-6388935329), -Claude Code 2.1.309); the resume limit and the plugin's notice for it are stated under the kill +delete ran unprompted ([#5609 probe](https://github.com/melodic-software/claude-code-plugins/issues/5609#issuecomment-6388935329)); +the resume limit and the plugin's notice for it are stated under the kill switch's two surfaces above. **Basis:** the probe table in and diff --git a/plugins/disk-hygiene/skills/setup/SKILL.md b/plugins/disk-hygiene/skills/setup/SKILL.md index 7f7500a305..b3ffe9080f 100644 --- a/plugins/disk-hygiene/skills/setup/SKILL.md +++ b/plugins/disk-hygiene/skills/setup/SKILL.md @@ -85,9 +85,9 @@ fails closed like every other guard-relevant unknown in this plugin. `node` as `command`. 2. **Python floor on `PATH`**. The interpreter used by scanning, validation, the guard, and cleanup. (The guard registers on two surfaces: a plugin-level engine gate - that acts only on engine-referencing commands, and the skill-frontmatter belt that - Claude Code keeps armed for the rest of the process after `/disk-hygiene:clean` is - invoked, and not after a resume. Both register unconditionally and resolve the kill switch by + that acts only on engine-referencing commands, and the skill-frontmatter belt, armed from + `/disk-hygiene:clean` until that process ends and treated as absent after a resume (the + clean skill's safety model holds the record). Both register unconditionally and resolve the kill switch by reading `disk_hygiene_enabled` from managed settings first, then the user `settings.json`.) The required version has one origin: the `MIN_PYTHON` constant in `${CLAUDE_PLUGIN_ROOT}/skills/clean/scripts/hygiene.py`, parse it from there (`grep -m1 '^MIN_PYTHON' …`) and probe the interpreter against that value; do not