69e20099db
Второй аудит машин 1-4 другим объективом (sharp-edges: устойчивость к неправильному применению / мягкие умолчания / совпадение по пустоте-подстроке). Криптоядра здоровы (подтверждено). 8 реальных дыр закрыты по TDD: M3: - coverage-machine F-1: покрытие считалось по двусторонней ПОДСТРОКЕ — produces "a" покрывал запрос "audit-rls-policy" (ложное «всё покрыто»). Новый tokensCover: точное равенство ИЛИ подмножество слов по границам. coveringSkill + coverageRegistry. - router-engine F-8: confidence не проверялся на диапазон — 5/Infinity проходили как «уверен» (обход воздержания 5.2), -3 как принуд. abstain. validateTrace: [0,1] finite. - round-control C: пустой roundKey="" активировал managed-режим (!= null) → все сессии делили один счётчик-бакет. Теперь managed требует непустую строку. - router-learning-queue G: повторное approve уже-решённого id повторно клало запись в фонд (дубль). applyApprovalBatch: переводит только status==='pending'. M2: - plan-lock F5: шаг с пустым object был джокером (object:'' матчил действие, чей путь не извлёкся → object''). actionMatchesStep: пустой object шага не матчит ничего. M4 (инертна; чистые fail-closed правки кода, корректны и при включении): - judge-slop-counter H: битый/null вердикт в списке ронял счёт (v.missing на null). Теперь не крашит, считается халтурой (безопасная сторона). - judge-engine J: consensusDecision на пустом/битом списке дрейфовал к GO. Теперь GO только если есть голоса И каждый чистый GO; иначе NO-GO (fail-closed для hard-risk). - judge-orchestrator K: finalGate снимал вето пола на любой falsy floorBlocked (undefined от упавшей проверки = fail-open). Теперь снять может только явный false. Регрессия tools-only 2555 passed + 2 skip (+15 TDD-тестов, 0 регрессий). Осознанно НЕ менялось (без призраков): - M1 receipt-sign domain default '' / разделитель пробел — backward-compat контракт (тест 18-19), инъективен на enum-доменах без пробелов. - M1 action-journal атомарность записи головы + битая .jsonl строка — fail-closed (битьё → verifyChain ok:false → стена блокирует); чистого behavioral-теста нет. - M3 round-control requiredSkills=[] — контракт вызывающего (пустой = не требуется). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
6.8 KiB
JavaScript
126 lines
6.8 KiB
JavaScript
#!/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');
|
|
}
|
|
|
|
/** Заморозить план: проставить id + версию артефакта + время + подпись-печать.
|
|
* artifactId — на какой опечатанный артефакт опирается план (null, если без артефакта). */
|
|
export function freezePlan({ steps, artifactId = null, key, nowMs }) {
|
|
const id = planId(steps);
|
|
const base = { plan_id: id, artifact_id: artifactId, frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps };
|
|
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 };
|