Files
brain/tools/enforce-runtime-write-deny.mjs

80 lines
3.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 RUNTIME_RE = /(^|\/)\.claude\/runtime(\/|$)/i;
// P10-a (router-mentor): любой инструмент, несущий путь записи в одном из этих
// полей, проверяется — не только 4 именованных Write-tool'а (ловит MCP-писатели).
// B4 (2026-06-05): расширено — MCP-писатели несут путь под разными именами полей.
const PATH_FIELDS = ['file_path', 'notebook_path', 'path', 'target_file', 'filename', 'destination', 'dest', 'output_path', 'uri'];
/** Извлечь предполагаемый путь записи из tool_input по известным полям. '' если нет. */
export function extractPath(toolInput) {
if (!toolInput || typeof toolInput !== 'object') return '';
for (const f of PATH_FIELDS) {
if (typeof toolInput[f] === 'string' && toolInput[f]) return toolInput[f];
}
return '';
}
/**
* Pure decision по пути (toolName-агностично, P10-a). Блок ТОЛЬКО при подтверждённом
* совпадении с ~/.claude/runtime. Нормализация бросила → fail-open.
* @param {object} p
* @param {string} p.filePath
* @param {Function} [p.normalizeImpl] - injectable normalizer (default: resolving pathNormalize)
* @returns {{block:boolean, reason?:string}}
*/
export function decide({ filePath, normalizeImpl = pathNormalize }) {
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 (receipts / journal head). Hooks write it via Node fs, not file-writing tools.`,
};
}
return { block: false };
}
/** Decision из полного события: извлекает путь из любого инструмента (P10-a). */
export function decideFromEvent(event) {
return decide({ filePath: extractPath(event && event.tool_input) });
}
async function main() {
try {
const event = parseEventJson(await readStdin());
const r = decideFromEvent(event);
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();