#!/usr/bin/env node /** * guard-block-log — журнал блоков обороны М1–М6 для доски «кто на посту» (D-3). * Логгер пишут машинные хуки при РЕШЁННОМ блоке (best-effort, fail-quiet, Node fs — * как logVerdictLine/logViolation). Читатели сканируют все session-файлы runtime для * глобальной доски (board-генератор не имеет одного session_id). Достоверность журнала — * при зарегистрированном поле-страже runtime (Фаза 8); до флипа данных нет (0/0). */ import fsDefault from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { canonicalAction } from './escape-grant.mjs'; function defaultRuntimeDir() { return join(homedir(), '.claude', 'runtime'); } /** Чистая запись блока. */ export function buildGuardBlockEntry({ machine, action, reason, now }) { return { ts: now, machine: String(machine ?? ''), action: String(action ?? ''), reason: String(reason ?? '') }; } /** Best-effort: записать блок машины. action — из canonicalAction(event); sess — из event.session_id. * НИКОГДА не бросает (вызывается в block-ветке хука; сбой логгирования не влияет на блок). */ export function logGuardBlock(event, machine, reason, { fsImpl = fsDefault, runtimeDir = defaultRuntimeDir(), now = Date.now() } = {}) { try { const action = canonicalAction(event && event.tool_name, (event && event.tool_input) || {}); const sess = (event && event.session_id) || 'unknown'; const entry = buildGuardBlockEntry({ machine, action, reason, now }); fsImpl.mkdirSync(runtimeDir, { recursive: true }); fsImpl.appendFileSync(join(runtimeDir, `guard-blocks-${sess}.jsonl`), JSON.stringify(entry) + '\n'); } catch { /* fail-quiet */ } } function scanSessionFiles(fsImpl, runtimeDir, prefix) { let names = []; try { names = fsImpl.readdirSync(runtimeDir).filter((f) => f.startsWith(prefix) && f.endsWith('.jsonl')); } catch { return []; } const out = []; for (const name of names) { let raw; try { raw = fsImpl.readFileSync(join(runtimeDir, name), 'utf8'); } catch { continue; } for (const line of String(raw).split(/\r?\n/)) { const t = line.trim(); if (!t) continue; let r; try { r = JSON.parse(t); } catch { continue; } out.push(r); } } return out; } function windowSortCap(recs, { now, windowMs, limit }) { return recs .filter((r) => r && typeof r.ts === 'number' && now - r.ts >= 0 && now - r.ts <= windowMs) .sort((a, b) => b.ts - a.ts) .slice(0, limit) .map((r) => ({ ...r, ts: new Date(r.ts).toISOString() })); } /** Недавние блоки машин для доски. */ export function loadRecentBlocks({ fsImpl = fsDefault, runtimeDir = defaultRuntimeDir(), now = Date.now(), windowMs = 86400000, limit = 10 } = {}) { const recs = scanSessionFiles(fsImpl, runtimeDir, 'guard-blocks-') .map((r) => ({ ts: r.ts, machine: r.machine, action: r.action, reason: r.reason })); return windowSortCap(recs, { now, windowMs, limit }); } /** Недавние escape владельца (floor_escape) для доски. */ export function loadRecentEscapes({ fsImpl = fsDefault, runtimeDir = defaultRuntimeDir(), now = Date.now(), windowMs = 86400000, limit = 10 } = {}) { const recs = scanSessionFiles(fsImpl, runtimeDir, 'askuser-decisions-') .filter((r) => r && r.type === 'floor_escape' && typeof r.action === 'string') .map((r) => ({ ts: typeof r.ts === 'number' ? r.ts : 0, machine: 'escape', action: r.action, reason: 'escape владельца' })); return windowSortCap(recs, { now, windowMs, limit }); }