bc1d2a370a
Новый тип шага плана op:"session" {goal, tools, produces} для интерактивного
осмотра (логин/формы/чужой сайт) под планом: внутри сеанса смотреть/кликать по
живым ref свободно, указатель не двигается; сеанс закрывает запись последнего
produces (матч-якорь). Снят дедлок op:"Skill"-как-шаг.
- plan-lock: sessionProduces, actionMatchesStep матчит последний produces,
validatePlanTree валидирует session (produces>=1) и запрещает op:"Skill",
sanitizeSessionTools (предохранитель §3.3: дроп Write/Edit/Bash/floor + warn).
- enforce-supreme-gate decide: ветка указатель-на-сеансе — tools сеанса и
промежуточные produces allow без сдвига, пол применяется (defense-in-depth).
- plan-steps-parse: распознаёт op:"session" (goal/tools/produces, без object/ref),
отвергает op:"Skill" с явным сообщением.
- mentor-verdict: наставник понимает op:"session" — не заворачивает как непонятный шаг.
- сеанс+tools/produces в хеше и подписи плана (подмена ломает печать).
Спека: docs/superpowers/specs/2026-06-18-wall-interactive-session-design.md §3.2-3.3.
+37 тестов, свод 4266 passed / 2 skipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
311 lines
20 KiB
JavaScript
311 lines
20 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';
|
|
import { deserializePointer, nodeAt, normalizeToLeaf, isContainer, MAX_TREE_DEPTH } from './step-pointer.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). R-08: рекурсия в substeps —
|
|
* под-шаги тоже получают criterion_id (запечатан на всех уровнях). */
|
|
function withCriterionIds(steps) {
|
|
return (steps || []).map((s) => {
|
|
const base = { ...s, criterion_id: stepCriterionId(s) };
|
|
if (Array.isArray(s.substeps)) base.substeps = withCriterionIds(s.substeps);
|
|
return base;
|
|
});
|
|
}
|
|
|
|
/** Запечатанный набор criterion_id плана — вход для criteriaFromSealedPlan (Гейт-2, F3/F9). */
|
|
export function sealedCriterionIds(frozenPlan) {
|
|
// V1 (R-08): листья дерева, не только верхний уровень — иначе пропускаем criterion_id под-шагов.
|
|
return treeLeaves((frozenPlan && frozenPlan.steps) || []).map((s) => s && s.criterion_id).filter(Boolean);
|
|
}
|
|
|
|
/** Допустимые режимы судьи в печати (defense-in-depth, SE-2 §4 / sealed-plan §11):
|
|
* null/undefined (нет режима / legacy-печать без поля — на inert печать не ставится),
|
|
* 'shadow' (наблюдение), 'live-block' (энфорсмент). Любое иное (опечатка 'Shadow'/'live_block',
|
|
* 'inert', не-строка) — fail-CLOSE: мнимый режим в печать не попадает (стена whitelist'ит
|
|
* только 'live-block'; источник так же fail-closed, как соседние ворота). */
|
|
export function assertValidJudgeMode(mode) {
|
|
if (mode === undefined || mode === null) return;
|
|
if (mode === 'shadow' || mode === 'live-block') return;
|
|
throw new Error(`invalid judge_mode: ${JSON.stringify(mode)} (allowed: null|'shadow'|'live-block')`);
|
|
}
|
|
|
|
/** Заморозить план: проставить id + версию артефакта + время + подпись-печать.
|
|
* artifactId — на какой опечатанный артефакт опирается план (null, если без артефакта).
|
|
* 5.1: каждый шаг получает детерминированный criterion_id ДО planId/печати → id запечатан. */
|
|
export function freezePlan({ steps, skills = [], artifactId = null, judgeMode = null, delivery = 'internal', key, nowMs }) {
|
|
assertValidJudgeMode(judgeMode);
|
|
const sealedSteps = withCriterionIds(steps);
|
|
const id = planId(sealedSteps);
|
|
// judge_mode входит в ПОДПИСАННУЮ базу (VA-2/SE-2): стена различает shadow- и live-печать,
|
|
// подмена режима ломает подпись. Существующие печати без judge_mode: base без поля → verify ок.
|
|
const base = { plan_id: id, artifact_id: artifactId, judge_mode: judgeMode, skills: Array.isArray(skills) ? skills : [], frozen_at: typeof nowMs === 'number' ? nowMs : Date.now(), steps: sealedSteps };
|
|
// E-S1 Фаза 1: delivery в подписанную базу ТОЛЬКО если не-'internal' — internal-планы
|
|
// (умолчание) остаются байт-идентичны старым печатям (обратная совместимость подписи).
|
|
if (delivery && delivery !== 'internal') base.delivery = delivery;
|
|
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(' '); }
|
|
|
|
/**
|
|
* B+C ч.2 (сеанс осмотра, спека 2026-06-18-wall-interactive-session-design §3.2): нормализовать
|
|
* produces сеанса в непустой массив путей (строка → [строка]; массив → массив без пустых/пробельных).
|
|
* produces — итоговый файл(ы), которые сеанс ОБЯЗАН произвести (≥1). Якорь закрытия — ПОСЛЕДНИЙ
|
|
* из списка (решение владельца Q-B: «несколько можно, последний закрывает сеанс»).
|
|
*/
|
|
export function sessionProduces(step) {
|
|
const p = step && step.produces;
|
|
const arr = Array.isArray(p) ? p : (p != null ? [p] : []);
|
|
return arr.map((x) => String(x ?? '').trim()).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* B+C ч.2 §3.3 (жёсткий предохранитель, единственное место списка запрещённого): tools сеанса
|
|
* НИКОГДА не содержат:
|
|
* — репо-мутаторов (Write/Edit/MultiEdit/NotebookEdit/Bash/PowerShell) — правки репозитория
|
|
* остаются ШАГАМИ плана (вкл. сам produces-файл сеанса — это обычный Write-шаг);
|
|
* — floor-опасного по имени (ssh/cloud-CLI/destructive) — остаётся за полом/escape.
|
|
* Это defense-in-depth поверх default-deny стены (decide не пускает действие не из набора и не
|
|
* query-only; floorDecide рубит floor-опасное при реальном вызове). Запрещённое имя ОТБРАСЫВАЕТСЯ
|
|
* с видимым предупреждением (решение владельца Q2 = «не валить план целиком из-за описки»).
|
|
*/
|
|
export const SESSION_FORBIDDEN_TOOLS = new Set([
|
|
'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell',
|
|
]);
|
|
// Floor-опасные имена-команды, которые наивно могли бы оказаться в tools (§3.3 примеры:
|
|
// install/cloud-CLI/ssh/destructive). Эти НЕ являются настоящими tool-name'ами харнесса —
|
|
// вызвать их как инструмент нельзя, но отбрасываем+предупреждаем явно (зеркало §3.3).
|
|
const SESSION_FORBIDDEN_KEYWORD_RE = /^(?:ssh|scp|sftp|rm|mv|cp|chmod|chown|chgrp|curl|wget|nc|ncat|netcat|socat|eval|kubectl|aws|gcloud|az|docker|terraform|helm|composer|npm|yarn|pnpm|pip|brew|apt|apt-get)$/i;
|
|
|
|
export function sanitizeSessionTools(tools) {
|
|
const list = Array.isArray(tools) ? tools : [];
|
|
const allowed = [];
|
|
const dropped = [];
|
|
for (const raw of list) {
|
|
const name = String(raw ?? '').trim();
|
|
if (!name) continue; // пустые/пробельные/нестроковые — молча отброшены (не инструмент)
|
|
if (SESSION_FORBIDDEN_TOOLS.has(name) || SESSION_FORBIDDEN_KEYWORD_RE.test(name)) dropped.push(name);
|
|
else allowed.push(name);
|
|
}
|
|
return { allowed, dropped };
|
|
}
|
|
|
|
/**
|
|
* Детерминированный матч действия и шага (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;
|
|
// B+C ч.2: сеанс осмотра закрывается записью ПОСЛЕДНЕГО produces-файла (матч-якорь, Q-B).
|
|
// Клики/инструменты сеанса и промежуточные produces сеанс НЕ закрывают (это решает decide,
|
|
// указатель не двигая) — здесь матчится только закрывающий Write по последнему produces.
|
|
if (step.op === 'session') {
|
|
if (String(action.op) !== 'Write') return false;
|
|
const produces = sessionProduces(step);
|
|
if (produces.length === 0) return false; // сеанс без produces — не валиден, джокером быть не может
|
|
let closer, target;
|
|
try { closer = normalize(String(produces[produces.length - 1])); target = normalize(String(action.object)); }
|
|
catch { return false; }
|
|
return !!closer && closer === target;
|
|
}
|
|
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];
|
|
}
|
|
|
|
// ── R-08: дерево листьев / лист по указателю / валидация дерева ──
|
|
|
|
/** Все листья дерева depth-first (V1/V2 вход). Плоский план → сами steps. */
|
|
export function treeLeaves(steps) {
|
|
const out = [];
|
|
const walk = (arr) => {
|
|
for (const s of arr || []) {
|
|
if (s && Array.isArray(s.substeps)) walk(s.substeps);
|
|
else out.push(s);
|
|
}
|
|
};
|
|
walk(steps);
|
|
return out;
|
|
}
|
|
|
|
/** Один лист по сериализованному указателю (целое/массив). Спуск через контейнеры (SE-2):
|
|
* возвращает узел БЕЗ substeps либо null (за концом / битый указатель / контейнер-тупик). */
|
|
export function treeLeafAt(steps, serializedPtr) {
|
|
const p0 = deserializePointer(serializedPtr, steps);
|
|
if (!p0) return null;
|
|
let leafPtr;
|
|
try { leafPtr = normalizeToLeaf(steps, p0); } catch { return null; }
|
|
if (!leafPtr) return null;
|
|
const node = nodeAt(steps, leafPtr);
|
|
if (node == null || isContainer(node)) return null;
|
|
return node;
|
|
}
|
|
|
|
/** Структурная валидация дерева ДО доверия (fail-CLOSED): SE-2 контейнер не несёт op/object/ref;
|
|
* SE-4 непустой substeps + глубина ≤ предела; substeps только массив. */
|
|
export function validatePlanTree(steps) {
|
|
let reason = null;
|
|
const fail = (r) => { if (!reason) reason = r; return false; };
|
|
const check = (arr, depth) => {
|
|
if (depth > MAX_TREE_DEPTH) return fail('превышена глубина дерева плана (fail-CLOSED)');
|
|
if (!Array.isArray(arr)) return fail('шаги плана не массив (fail-CLOSED)');
|
|
for (const s of arr || []) {
|
|
if (!s || typeof s !== 'object') return fail('шаг плана не объект (fail-CLOSED)');
|
|
if ('substeps' in s) {
|
|
if (!Array.isArray(s.substeps) || s.substeps.length === 0) return fail('пустой/невалидный контейнер (SE-4)'); // SE-4
|
|
if (s.op != null || s.object != null || s.ref != null) return fail('контейнер не несёт op/object/ref (SE-2)'); // SE-2
|
|
if (!check(s.substeps, depth + 1)) return false;
|
|
} else {
|
|
// Лист. B+C ч.2 (точка 4): op:'Skill' не может быть шагом плана — навык объявляется в
|
|
// skills-json и вызывается свободно (isPlanDeclaredSkill), указатель не двигая. Это снимает
|
|
// дедлок «op:Skill опечатывается, но указатель не двигает».
|
|
if (String(s.op) === 'Skill') return fail("op:'Skill' не может быть шагом плана — объяви навык в skills-json (он вызывается свободно)");
|
|
// B+C ч.2 (точка 1): сеанс осмотра обязан объявить ≥1 produces — итоговый файл якоря закрытия.
|
|
if (String(s.op) === 'session' && sessionProduces(s).length === 0) return fail('сеанс осмотра без produces — недопустим (нужен ≥1 итоговый файл, он закрывает сеанс)');
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
return check(steps, 1) ? { ok: true } : { ok: false, reason };
|
|
}
|
|
|
|
function planPath(runtimeDir, sessionId) {
|
|
assertSafeSessionId(sessionId); // N3-shared guard формы sessionId (path-traversal)
|
|
const sep = runtimeDir.endsWith('/') ? '' : '/';
|
|
return `${runtimeDir}${sep}frozen-plan-${sessionId}.json`;
|
|
}
|
|
|
|
/** Атомарная запись печати (SE-4/VA-3): temp → rename. Битый/частичный файл не кирпичит
|
|
* стену до пере-печати — финальный путь появляется лишь после полной записи temp. */
|
|
function writeAtomic(path, data, fsImpl) {
|
|
const tmp = `${path}.tmp-${planId([path, data.length])}`;
|
|
fsImpl.writeFileSync(tmp, data);
|
|
fsImpl.renameSync(tmp, path);
|
|
}
|
|
|
|
export function saveFrozenPlan({ plan, sessionId, runtimeDir, fsImpl = fsDefault }) {
|
|
writeAtomic(planPath(runtimeDir, sessionId), JSON.stringify(plan), fsImpl);
|
|
}
|
|
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; }
|
|
}
|
|
|
|
/**
|
|
* Фаза 5 (чистое завершение, спека §6.5): снять печать плана (unlink). После последнего
|
|
* шага стена зовёт это сама → следующее действие в разговорном режиме. Нет файла → no-op
|
|
* (best-effort на ENOENT). path-guard через planPath (assertSafeSessionId).
|
|
*/
|
|
export function removeFrozenPlan({ sessionId, runtimeDir, fsImpl = fsDefault }) {
|
|
const p = planPath(runtimeDir, sessionId);
|
|
try { fsImpl.unlinkSync(p); }
|
|
catch (e) { if (e && e.code === 'ENOENT') return; throw e; }
|
|
}
|
|
|
|
/** Каждое журнальное действие обязано иметь шаг плана; иначе — сирота (пропущен гейт). */
|
|
export function reconcileJournalToPlan(journal, steps, { normalize = pathNormalize } = {}) {
|
|
// V2 (R-08): матчим по листьям дерева — иначе лист-действие не совпадёт с контейнером верхнего
|
|
// уровня → ложные сироты «обход стены». Плоский план → treeLeaves = steps (без регрессии).
|
|
const leaves = treeLeaves(steps);
|
|
const orphans = (journal || []).filter(
|
|
(a) => !leaves.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 }) {
|
|
assertValidJudgeMode(artifact && artifact.judge_mode);
|
|
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 }) {
|
|
// Артефакт персистится атомарно и ДО плана (VA-3) — порядок событий в хуке судьи.
|
|
writeAtomic(artifactPath(runtimeDir, sessionId), JSON.stringify(artifact), fsImpl);
|
|
}
|
|
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 };
|