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