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..11ec9e73e7 --- /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 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/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 100755 index 0000000000..be05343f7f --- /dev/null +++ b/plugins/disk-hygiene/hooks/resume-notice.mjs @@ -0,0 +1,115 @@ +#!/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 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, realpathSync } 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 } } +} + +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] ?? '') : '' + 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`) +} + +// 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))) { + // 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 new file mode 100644 index 0000000000..f89001e880 --- /dev/null +++ b/plugins/disk-hygiene/hooks/resume-notice.test.mjs @@ -0,0 +1,171 @@ +// 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, symlinkSync, writeFileSync, existsSync } from 'node:fs' +import { spawn, 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 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 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', + encoding: 'utf8', + }) + assert.equal(r.status, 0) + assert.equal(r.stdout, '') +}) 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/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..ba2dda7e74 100644 --- a/plugins/disk-hygiene/skills/clean/reference/safety-model.md +++ b/plugins/disk-hygiene/skills/clean/reference/safety-model.md @@ -598,15 +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 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**. 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 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. + +**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. The marker is keyed on the session id, so a +session that starts under a new id gets no notice. + +- **Pointer**: the probe (runs b, c and e) in + and + [#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 @@ -713,8 +736,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)); +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 +760,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..e01a4b56d5 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 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 +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..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 session after `/disk-hygiene:clean` is - invoked. 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