Files
portal/tools/enforce-runtime-write-deny.mjs
T
Дмитрий 53407a77cd feat(router-gate): tdd-gate credits delegated (subagent) TDD + transcript write-deny
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>
2026-05-31 18:18:44 +03:00

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();