Files
brain/tools/guard-block-log.mjs
T

71 lines
3.8 KiB
JavaScript

#!/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 });
}