Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)).
9 changes: 6 additions & 3 deletions plugins/disk-hygiene/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion plugins/disk-hygiene/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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": [
{
Expand All @@ -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..."
}
]
}
Expand Down Expand Up @@ -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}"
Expand Down
115 changes: 115 additions & 0 deletions plugins/disk-hygiene/hooks/resume-notice.mjs
Original file line number Diff line number Diff line change
@@ -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 `<marker-root>/clean-invoked/<session>.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 <dir>
//
// 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)))
}
171 changes: 171 additions & 0 deletions plugins/disk-hygiene/hooks/resume-notice.test.mjs
Original file line number Diff line number Diff line change
@@ -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, '')
})
14 changes: 14 additions & 0 deletions plugins/disk-hygiene/hooks/resume-notice.test.sh
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 10 additions & 3 deletions plugins/disk-hygiene/hooks/run-python-hook.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading