2026-05-31 05:57:59 +03:00
#!/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 ;
2026-06-04 04:04:25 +03:00
// P10-a (router-mentor): любой инструмент, несущий путь записи в одном из этих
// полей, проверяется — не только 4 именованных Write-tool'а (ловит MCP-писатели).
2026-06-05 03:42:40 +03:00
// B4 (2026-06-05): расширено — MCP-писатели несут путь под разными именами полей.
const PATH _FIELDS = [ 'file_path' , 'notebook_path' , 'path' , 'target_file' , 'filename' , 'destination' , 'dest' , 'output_path' , 'uri' ] ;
2026-06-04 04:04:25 +03:00
/** Извлечь предполагаемый путь записи из 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 '' ;
}
2026-05-31 05:57:59 +03:00
/**
2026-06-04 04:04:25 +03:00
* Pure decision по пути (toolName-агностично, P10-a). Блок ТОЛЬКО при подтверждённом
* совпадении с ~/.claude/runtime. Нормализация бросила → fail-open.
2026-05-31 05:57:59 +03:00
* @param {object} p
* @param {string} p.filePath
* @param {Function} [p.normalizeImpl] - injectable normalizer (default: resolving pathNormalize)
* @returns {{block:boolean, reason?:string}}
*/
2026-06-04 04:04:25 +03:00
export function decide ( { filePath , normalizeImpl = pathNormalize } ) {
2026-05-31 05:57:59 +03:00
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 ,
2026-06-04 04:04:25 +03:00
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. ` ,
2026-05-31 05:57:59 +03:00
} ;
}
return { block : false } ;
}
2026-06-04 04:04:25 +03:00
/** Decision из полного события: извлекает путь из любого инструмента (P10-a). */
export function decideFromEvent ( event ) {
return decide ( { filePath : extractPath ( event && event . tool _input ) } ) ;
}
2026-05-31 05:57:59 +03:00
async function main ( ) {
try {
const event = parseEventJson ( await readStdin ( ) ) ;
2026-06-04 04:04:25 +03:00
const r = decideFromEvent ( event ) ;
2026-05-31 05:57:59 +03:00
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 ( ) ;