#!/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-.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; /** * 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 if (RUNTIME_RE.test(String(norm || ''))) { 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.`, }; } 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();