53407a77cd
Closes the TDD-gate cross-actor gap: when a subagent (spawned by a Task in the controller's current turn) writes the failing test and confirms RED, the controller's subsequent production edit was falsely blocked because the gate only scanned the controller's own turn. Net strengthening, no discipline weakened. - Part 1 (enforce-runtime-write-deny): block the Write tool from any ~/.claude/projects/**/*.jsonl (session/subagent transcripts). Memory *.md there stays writable (never matches .jsonl$). Resolving normalizer defeats ./.. evasion. This makes the agent-<id>.jsonl that Part 2 trusts unforgeable (it was the last ungated write channel; Bash/PowerShell/Read gates already covered it). - Part 2 (enforce-tdd-gate): decide() also credits a subagent's matching test edit + RED via a new subagentEntriesList. turnTaskAgentIds() reads the hex agentId from the harness-written Task tool_result (the controller cannot forge its own tool_result; hex-only match blocks "agentId: ../../x" path traversal). subagentTranscriptPaths() derives <dir>/<controller-session>/subagents/agent-<id>.jsonl. main() reads them best-effort (missing/unreadable -> no extra credit = stricter). No new weakening: a delegated subagent doing real TDD is legitimate; the only forgery vector (overwriting the agent jsonl) is closed by Part 1. Existing controller-turn behaviour is preserved (empty subagent list == old logic). OWNER (settings.json, Claude can't edit it): enforce-tdd-gate is already a registered PreToolUse hook -> Part 2 goes live on merge. enforce-runtime-write-deny must be registered on PreToolUse(Edit|Write|MultiEdit|NotebookEdit) for Part 1 to be live. TDD: RED -> GREEN per behavior. tools-vitest 2027 passed / 2 skipped. Backlog item C (=Z); plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
3.5 KiB
JavaScript
78 lines
3.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* enforce-runtime-write-deny — PreToolUse(Edit|Write|MultiEdit|NotebookEdit).
|
|
*
|
|
* Blocks the Write/Edit TOOL from writing under ~/.claude/runtime/** — closing a
|
|
* pre-existing self-write hole on the v4 git-approval anchor
|
|
* (askuser-decisions-<sess>.jsonl) and any future runtime side-channel. The
|
|
* Bash/PowerShell gates already protect this path for shell commands; the Write
|
|
* tool was the remaining ungated channel. Standalone — independent of
|
|
* safe-baseline (router-gate v4 §"Side-channel write-protection", item 1b / C3).
|
|
*
|
|
* Uses the resolving `pathNormalize` (path.resolve + realpath) so `.`/`..`
|
|
* segments cannot evade the match (V2-2) — `~/.claude/./runtime/x` collapses to
|
|
* the real path before the regex test. Legitimate hooks write there via Node
|
|
* `fs`, not the Claude Write tool, so they are unaffected.
|
|
*
|
|
* Posture: blocks ONLY on a confirmed runtime match; if normalization throws
|
|
* (cannot determine the path) it fails-OPEN, never bricking the session. This
|
|
* deliberately trades a tiny residual (a malformed path that throws) for
|
|
* not self-locking the controller out of all edits on an unexpected error.
|
|
*/
|
|
import { pathNormalize } from './path-normalization.mjs';
|
|
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
|
|
|
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
const RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;
|
|
// Transcript protection (Z Part 1): any *.jsonl under ~/.claude/projects/** is a
|
|
// session/subagent transcript. The tdd-gate credits a subagent's RED from its
|
|
// agent-<id>.jsonl, so these must be unforgeable by the Write tool. Memory files
|
|
// there are *.md and never match `.jsonl$`, so memory writes stay allowed.
|
|
const TRANSCRIPT_RE = /(^|\/)\.claude\/projects\/.*\.jsonl$/i;
|
|
|
|
/**
|
|
* Pure decision.
|
|
* @param {object} p
|
|
* @param {string} p.toolName
|
|
* @param {string} p.filePath
|
|
* @param {Function} [p.normalizeImpl] - injectable normalizer (default: resolving pathNormalize)
|
|
* @returns {{block:boolean, reason?:string}}
|
|
*/
|
|
export function decide({ toolName, filePath, normalizeImpl = pathNormalize }) {
|
|
if (!WRITE_TOOLS.has(toolName)) return { block: false };
|
|
const fp = String(filePath || '');
|
|
if (!fp) return { block: false };
|
|
let norm;
|
|
try { norm = normalizeImpl(fp); } catch { return { block: false }; } // cannot determine → fail-open
|
|
const normStr = String(norm || '');
|
|
if (RUNTIME_RE.test(normStr)) {
|
|
return {
|
|
block: true,
|
|
reason: `Write to «${norm}» denied — ~/.claude/runtime is a protected side-channel (git-approval anchor). Hooks write it via Node fs, not the Write tool.`,
|
|
};
|
|
}
|
|
if (TRANSCRIPT_RE.test(normStr)) {
|
|
return {
|
|
block: true,
|
|
reason: `Write to «${norm}» denied — ~/.claude/projects/**/*.jsonl are session/subagent transcripts (tamper-protected; the tdd-gate trusts them). The harness writes transcripts, never the Write tool. Memory *.md there stays writable.`,
|
|
};
|
|
}
|
|
return { block: false };
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const event = parseEventJson(await readStdin());
|
|
const r = decide({
|
|
toolName: event.tool_name,
|
|
filePath: (event.tool_input && (event.tool_input.file_path || event.tool_input.notebook_path)) || '',
|
|
});
|
|
exitDecision({ block: r.block, message: r.reason });
|
|
} catch {
|
|
exitDecision({ block: false }); // fail-quiet
|
|
}
|
|
}
|
|
|
|
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-runtime-write-deny.mjs');
|
|
if (isCli) main();
|