#!/usr/bin/env node /** * plan-lock — Замок плана (несущий №2, P15-f): заморозка одобренного плана * хеш-печатью (HMAC из Машины 1). «В плане» — фикция без печати: правка шагов * меняет sig, что видно владельцу. Детерминированный матч «действие ↔ шаг». */ import { createHash } from 'node:crypto'; import fsDefault from 'node:fs'; import { canonicalJson, signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs'; import { pathNormalize } from './path-normalization.mjs'; import { assertSafeSessionId } from './action-journal.mjs'; /** Стабильный id плана = sha256 канонизированных шагов. */ export function planId(steps) { return createHash('sha256').update(canonicalJson(steps)).digest('hex'); } /** * Детерминированный criterion_id шага (Машина 5 Пакет 5, 5.1, Δ5): sha256 канонизированного * СОДЕРЖИМОГО шага БЕЗ самого criterion_id (идемпотентность на повторной заморозке). Чистая * функция от публичного содержания → id = ЦЕЛОСТНОСТЬ, не подлинность (контроллер пересчитает; * подлинность даёт подпись подписанта на зелёном прогоне, Δ5). Ценность здесь: id привязан * к смыслу шага (подмена object/op меняет id) и запечатывается подписью плана (нельзя выдумать * id на демо-этапе мимо печати — sealedCriterionIds = вход для criteriaFromSealedPlan). */ export function stepCriterionId(step) { const { criterion_id: _drop, ...rest } = step || {}; return createHash('sha256').update(canonicalJson(rest)).digest('hex'); } /** Обогатить шаги детерминированным criterion_id ДО печати (5.1). */ function withCriterionIds(steps) { return (steps || []).map((s) => ({ ...s, criterion_id: stepCriterionId(s) })); } /** Запечатанный набор criterion_id плана — вход для criteriaFromSealedPlan (Гейт-2, F3/F9). */ export function sealedCriterionIds(frozenPlan) { return ((frozenPlan && frozenPlan.steps) || []).map((s) => s && s.criterion_id).filter(Boolean); } /** Заморозить план: проставить id + версию артефакта + время + подпись-печать. * artifactId — на какой опечатанный артефакт опирается план (null, если без артефакта). * 5.1: каждый шаг получает детерминированный criterion_id ДО planId/печати → id запечатан. */ export function freezePlan({ steps, artifactId = null, key, nowMs }) { const sealedSteps = withCriterionIds(steps); const id = planId(sealedSteps); const base = { plan_id: id, artifact_id: artifactId, frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps }; return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_PLAN) }; } /** Печать цела? (правка шагов/времени/id → false; без ключа/подписи → false). */ export function verifyFrozenPlan(plan, key) { if (!plan || typeof plan !== 'object') return false; return verifyReceipt(plan, key, RECEIPT_DOMAINS.FROZEN_PLAN); } /** Нормализация команды Bash: схлопнуть пробелы, trim. */ function normCommand(c) { return String(c || '').split(/\s+/).filter(Boolean).join(' '); } /** * Детерминированный матч действия и шага (P15-a + P15-e): op И object обязаны * совпасть. Файловые object — через normalize (default pathNormalize); Bash — через * normCommand. Никакого LLM, никаких зашитых списков. */ export function actionMatchesStep(step, action, { normalize = pathNormalize } = {}) { if (!step || !action) return false; if (String(step.op) !== String(action.op)) return false; if (step.op === 'Bash') { const stepCmd = normCommand(step.object); if (!stepCmd) return false; // F5 (аудит M1-M4): пустой шаг-команда не матчит ничего return stepCmd === normCommand(action.object); } // F5: пустой файловый object шага = брак (не валидный шаг плана) → джокером быть не может. // Иначе шаг {op:'Write', object:''} совпал бы с любым Write, чей путь не извлёкся (object ''). if (!String(step.object ?? '').trim()) return false; let a, b; try { a = normalize(String(step.object)); b = normalize(String(action.object)); } catch { return false; } return a === b; } /** Шаг по указателю (или null за концом). */ export function nextStep(steps, ptr) { if (!Array.isArray(steps) || ptr < 0 || ptr >= steps.length) return null; return steps[ptr]; } function planPath(runtimeDir, sessionId) { assertSafeSessionId(sessionId); // N3-shared guard формы sessionId (path-traversal) const sep = runtimeDir.endsWith('/') ? '' : '/'; return `${runtimeDir}${sep}frozen-plan-${sessionId}.json`; } export function saveFrozenPlan({ plan, sessionId, runtimeDir, fsImpl = fsDefault }) { fsImpl.writeFileSync(planPath(runtimeDir, sessionId), JSON.stringify(plan)); } export function loadFrozenPlan({ sessionId, runtimeDir, fsImpl = fsDefault }) { try { return JSON.parse(fsImpl.readFileSync(planPath(runtimeDir, sessionId), 'utf8')); } catch (e) { if (e && e.code === 'ENOENT') return null; throw e; } } /** Каждое журнальное действие обязано иметь шаг плана; иначе — сирота (пропущен гейт). */ export function reconcileJournalToPlan(journal, steps, { normalize = pathNormalize } = {}) { const orphans = (journal || []).filter( (a) => !(steps || []).some((s) => actionMatchesStep(s, a, { normalize })) ); return { ok: orphans.length === 0, orphans }; } /** Стабильный id артефакта = sha256 канонизированного содержания. */ export function artifactId(artifact) { return createHash('sha256').update(canonicalJson(artifact)).digest('hex'); } /** Заморозить артефакт разговорной фазы (вторая печать). */ export function freezeArtifact({ artifact, key, nowMs }) { const base = { ...artifact, artifact_id: artifactId(artifact), frozen_at: typeof nowMs === 'number' ? nowMs : Date.now() }; return { ...base, sig: signPayload(base, key, RECEIPT_DOMAINS.FROZEN_ARTIFACT) }; } /** Печать артефакта цела? */ export function verifyFrozenArtifact(artifact, key) { if (!artifact || typeof artifact !== 'object') return false; return verifyReceipt(artifact, key, RECEIPT_DOMAINS.FROZEN_ARTIFACT); } // Персист артефакта (как у плана, Task 3) — нужен рантайму: main() грузит его, // стена сверяет версию (artifact_id) и резолвит ссылки шагов (закрытая дверь). function artifactPath(runtimeDir, sessionId) { assertSafeSessionId(sessionId); // N3-shared guard формы sessionId (path-traversal) const sep = runtimeDir.endsWith('/') ? '' : '/'; return `${runtimeDir}${sep}frozen-artifact-${sessionId}.json`; } export function saveFrozenArtifact({ artifact, sessionId, runtimeDir, fsImpl = fsDefault }) { fsImpl.writeFileSync(artifactPath(runtimeDir, sessionId), JSON.stringify(artifact)); } export function loadFrozenArtifact({ sessionId, runtimeDir, fsImpl = fsDefault }) { try { return JSON.parse(fsImpl.readFileSync(artifactPath(runtimeDir, sessionId), 'utf8')); } catch (e) { if (e && e.code === 'ENOENT') return null; throw e; } } /** * Закрытая дверь (C-5): если у шага есть ссылка ref на решение артефакта, * она ОБЯЗАНА резолвиться в опечатанном артефакте. Нет ref → требование снято * (простой шаг). Есть ref, но раздела нет → false (стена блокирует). */ export function refResolves(step, frozenArtifact) { if (!step || !step.ref) return true; const sections = (frozenArtifact && frozenArtifact.sections) || {}; return Object.prototype.hasOwnProperty.call(sections, step.ref); } export const _internals = { fsDefault };