66 lines
2.8 KiB
JavaScript
66 lines
2.8 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;
|
|
|
|
/**
|
|
* 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();
|