Files
brain/tools/action-journal.mjs
T

121 lines
6.3 KiB
JavaScript

#!/usr/bin/env node
/**
* action-journal — Журнал S1 (несущий №1): append-only хеш-цепочка действий.
* chain_hash = sha256(prev_hash + canonicalJson(payload)). Правка записи задним
* числом ломает последующие хеши; голова цепи HMAC-подписана (оптовую перезапись
* нельзя без ключа). Идея цепи — из audit_chain_hash() (db/schema.sql:75).
* Лог-до-действия и «нет записи → нет действия» — ENFORCEMENT Машины 2 (верховный
* хук читает журнал); здесь — примитив записи + tamper-evidence + подпись головы.
*/
import { createHash } from 'node:crypto';
import fsDefault from 'node:fs';
import { canonicalJson, signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs';
export const GENESIS_HASH = '0'.repeat(64);
/** chain_hash = sha256(prev_hash + canonicalJson(payload)). */
export function computeEntryHash(prevHash, payload) {
return createHash('sha256').update(String(prevHash) + canonicalJson(payload)).digest('hex');
}
/**
* Добавить запись к цепочке (чисто; персист — отдельно в journalAppend).
* @returns {{entries: object[], headSig: string|null}}
*/
export function appendEntry(entries, payload, { key, nowMs }) {
const prev = entries.length ? entries[entries.length - 1] : null;
const prevHash = prev ? prev.chain_hash : GENESIS_HASH;
const seq = (prev ? prev.seq : 0) + 1;
const ts = typeof nowMs === 'number' ? nowMs : Date.now();
const entry = {
seq,
ts,
payload,
prev_hash: prevHash,
// B2 (2026-06-05): seq+ts входят в хеш — подмена метаданных задним числом ломает цепь.
chain_hash: computeEntryHash(prevHash, { seq, ts, payload }),
};
const next = [...entries, entry];
const headSig = signHead(entry.seq, entry.chain_hash, key);
return { entries: next, headSig };
}
/** Подпись головы: HMAC над {seq, head_hash} (verifyReceipt-совместимая форма). */
export function signHead(seq, headHash, key) {
return signPayload({ seq, head_hash: headHash }, key, RECEIPT_DOMAINS.JOURNAL_HEAD);
}
/**
* Проверка цепочки: (1) каждый chain_hash пересчитывается из prev_hash+payload;
* (2) prev_hash каждой записи = chain_hash предыдущей; (3) подпись головы валидна.
* @returns {{ok: boolean, brokenAt: number|null}} brokenAt = seq первой битой записи (или null).
*/
export function verifyChain(entries, headSig, { key }) {
if (!Array.isArray(entries) || entries.length === 0) return { ok: false, brokenAt: null };
let prevHash = GENESIS_HASH;
for (const e of entries) {
// N2: структурно-битая запись (null / не-объект / массив) — fail-closed value,
// не исключение (внешняя порча .jsonl строкой `null` доходит сюда из loadJournal).
if (!e || typeof e !== 'object' || Array.isArray(e)) return { ok: false, brokenAt: null };
if (e.prev_hash !== prevHash) return { ok: false, brokenAt: e.seq ?? null };
if (computeEntryHash(prevHash, { seq: e.seq, ts: e.ts, payload: e.payload }) !== e.chain_hash) return { ok: false, brokenAt: e.seq ?? null };
prevHash = e.chain_hash;
}
const head = entries[entries.length - 1];
const headOk = verifyReceipt({ seq: head.seq, head_hash: head.chain_hash, sig: headSig }, key, RECEIPT_DOMAINS.JOURNAL_HEAD);
if (!headOk) return { ok: false, brokenAt: head.seq };
return { ok: true, brokenAt: null };
}
// N3 (2026-06-07 расширено до общего guard, аудит M1-M4): sessionId вклеивается в путь
// файла без проверки формы `..`/`/`/`\` → path traversal (запись/чтение вне каталога).
// Единый экспортируемый guard для ВСЕХ sibling-строителей пути машин (action-journal/
// judge-subrun-journal/plan-lock/enforce-supreme-gate) — закрывает класс разом, не точечно.
// throw (fail-closed; в supreme-gate ловится внешним try/catch → block). Источник sessionId —
// stdin-событие harness (R-28, UUID-подобный), guard — защита-в-глубину против дрейфа.
const SESSION_ID_RE = /^[A-Za-z0-9_-]+$/;
export function assertSafeSessionId(sessionId) {
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) {
throw new Error('invalid sessionId (path-injection guard)');
}
return sessionId;
}
function paths(runtimeDir, sessionId) {
assertSafeSessionId(sessionId);
const sep = runtimeDir.endsWith('/') ? '' : '/';
return {
jsonl: `${runtimeDir}${sep}action-journal-${sessionId}.jsonl`,
head: `${runtimeDir}${sep}action-journal-${sessionId}.head`,
};
}
/** Прочитать цепочку из JSONL + подпись головы. Нет файла → пусто. */
export function loadJournal({ sessionId, runtimeDir, fsImpl = fsDefault }) {
const { jsonl, head } = paths(runtimeDir, sessionId);
let entries = [];
try {
const raw = fsImpl.readFileSync(jsonl, 'utf8');
entries = String(raw).split('\n').filter(Boolean).map((l) => JSON.parse(l));
} catch (e) {
if (e && e.code === 'ENOENT') return { entries: [], headSig: null };
throw e;
}
let headSig = null;
try { headSig = String(fsImpl.readFileSync(head, 'utf8')).trim() || null; } catch { headSig = null; }
return { entries, headSig };
}
/**
* Добавить действие в журнал сессии (персист через Node fs — НЕ Write-tool).
* @returns {{entry: object, headSig: string|null}}
*/
export function journalAppend({ payload, key, sessionId, runtimeDir, nowMs, fsImpl = fsDefault }) {
const { entries } = loadJournal({ sessionId, runtimeDir, fsImpl });
const { entries: next, headSig } = appendEntry(entries, payload, { key, nowMs });
const entry = next[next.length - 1];
const { jsonl, head } = paths(runtimeDir, sessionId);
fsImpl.appendFileSync(jsonl, JSON.stringify(entry) + '\n');
fsImpl.writeFileSync(head, String(headSig ?? ''));
return { entry, headSig };
}