From 5bcf229e4fd643a0aa6bbe895dbfe226e15064d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 18 Jun 2026 19:03:10 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20plan-done=20=D0=B8=20=D0=B0=D1=80=D0=B1?= =?UTF-8?q?=D0=B8=D1=82=D1=80=D0=B0=D0=B6=20gate3=20=E2=80=94=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D0=B5=D1=80=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=20(consent=20forgery=20B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Снятие печати плана (plan-done) и арбитраж gate3 (gate3-arb:accept/continue) — тело-агностичные согласия, обходящие/снимающие стену — теперь открываются ТОЛЬКО терминальным грантом владельца (Поза 1, HOLE-4). supreme-gate: новый параметр terminalGrants в decideMode/runGate, PLAN_FINISH_ACTION проверяется против него (лёгкий escape остаётся на chat-грантах); main грузит loadTerminalGrants. gate3-loop: арбитраж-гранты грузятся через loadTerminalGrants (loader-swap; resolveOwnerArbitration агностична). Ядро стены: 138/138, gate3 44/44, полный свод 4346. Спека §B/§DEC. Co-Authored-By: Claude Opus 4.8 --- ...ery-fix-B4-plandone-gate3-terminal-plan.md | 40 +++++++++++++++++++ tools/enforce-gate3-loop.mjs | 5 ++- tools/enforce-supreme-gate.mjs | 19 +++++---- tools/enforce-supreme-gate.test.mjs | 12 ++++-- 4 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-consent-forgery-fix-B4-plandone-gate3-terminal-plan.md diff --git a/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B4-plandone-gate3-terminal-plan.md b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B4-plandone-gate3-terminal-plan.md new file mode 100644 index 0000000..50e8ccb --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B4-plandone-gate3-terminal-plan.md @@ -0,0 +1,40 @@ +# Consent-forgery fix — Часть B4: plan-done + gate3-arb → терминал Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans / test-driven-development. + +**Goal:** Снятие печати плана (`plan-done`) и арбитраж gate3 (`gate3-arb:accept/continue`) открываются ТОЛЬКО терминальным грантом владельца, не chat floor_escape. Поза 1 для согласий, обходящих/снимающих стену. + +**Architecture (трогает ЯДРО стены — высокий blast-radius):** +- `enforce-supreme-gate`: ввести параметр `terminalGrants` в `decideMode`/`runGate`; проверку `plan-done` (PLAN_FINISH_ACTION) перевести с `escapeGrants` на `terminalGrants`. Лёгкий escape (canonicalAction, строка 396) остаётся на `escapeGrants` (chat ОК). `main()` грузит `loadTerminalGrants(sess)`. +- `enforce-gate3-loop`: `main()` грузит `grants` для `resolveOwnerArbitration` через `loadTerminalGrants` вместо `loadFloorEscapes` (loader-swap; `resolveOwnerArbitration` логику не меняем — она агностична источнику). + +**Спека:** §B/§DEC Поза 1 (HOLE-4 — тело-агностичные plan-done/gate3-arb отнесены в тяжёлый класс). + +**Режим:** ШТАТНЫЙ. Коммит — дисциплина handoff. + +--- + +### Task 1 (DONE): plan-done → terminalGrants (supreme-gate) + +- decideMode/runGate: новый параметр `terminalGrants = []`; PLAN_FINISH_ACTION проверяется против него. +- main(): `let terminalGrants = []` → `loadTerminalGrants(sess)` → передан в runGate. +- Импорт `loadTerminalGrants` в supreme-gate. +- Тесты: finish-грант тесты переведены на `terminalGrants`; добавлен RED-тест «plan-done в escapeGrants (chat) НЕ завершает; только terminalGrants». ✅ 138/138. + +### Task 2 (DONE): gate3-arb → терминал (gate3-loop) + +- main(): `grants: loadTerminalGrants(sess)` вместо `loadFloorEscapes(sess)` (TDD-исключение: loader-wiring; `resolveOwnerArbitration` агностична, покрыта своими тестами). ✅ 44/44. + +### Task 3 (DONE): полный свод + +- `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` → 4346 passed, 2 skipped. Ядро цело. + +--- + +## Осталось (B5/C/GUIDE/нормативка) + +- **B5** — разрушительный bash/powershell (rm-rf/force-push/migrate:fresh) → терминал. В supreme-gate + лёгкий escape (строка 396, `escapeGrantOpen(canonicalAction…, escapeGrants…)`) сейчас принимает + chat-грант на ЛЮБОЕ действие, включая `bash:rm -rf`. Нужен `isHeavyAction(action)`: heavy → проверять + против `terminalGrants`, light → `escapeGrants`. Переиспользовать «ядерный» детектор floor (D1). +- C / GUIDE / нормативка — как в спеке. diff --git a/tools/enforce-gate3-loop.mjs b/tools/enforce-gate3-loop.mjs index a75abe4..edd90c8 100644 --- a/tools/enforce-gate3-loop.mjs +++ b/tools/enforce-gate3-loop.mjs @@ -241,7 +241,7 @@ async function main() { const { resolveJudgeLlmKey } = await import('./judge-gate-config.mjs'); const { callJudgeModel } = await import('./enforce-judge-gate.mjs'); const { requiredLensesFor, runJudge } = await import('./judge-engine.mjs'); - const { loadFloorEscapes, loadConsumed } = await import('./escape-grant.mjs'); + const { loadTerminalGrants, loadConsumed } = await import('./escape-grant.mjs'); try { const event = parseEventJson(await readStdin()); const dir = runtimeDir(); @@ -267,7 +267,8 @@ async function main() { const v = runJudge({ functionName: 'gate3card', requiredLenses, subRunsRequired: [], subRuns: [], llmCall: () => raw, promptArgs }); return { wired: true, decision: v.decision, verdict: v }; }; - const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, callCardJudge, grants: loadFloorEscapes(sess), consumed: loadConsumed(sess), now: Date.now() }); + // Поза 1 (#B4): арбитраж gate3 (accept/continue) — ТЯЖЁЛОЕ → ТОЛЬКО терминальный грант владельца. + const r = await runGate3Stop(event, { runtimeDir: dir, sess, key, judgeKey, loadGreens, loadArtifact, callJudge, callCardJudge, grants: loadTerminalGrants(sess), consumed: loadConsumed(sess), now: Date.now() }); exitDecision({ block: !!r.block, message: r.block ? `[gate3-loop] ${r.message || 'петля открыта — цель не подтверждена'}` : undefined }); } catch { exitDecision({ block: false }); // Stop fail-OPEN: внутренняя ошибка хука НЕ кирпичит конец хода diff --git a/tools/enforce-supreme-gate.mjs b/tools/enforce-supreme-gate.mjs index caa9671..a92545e 100644 --- a/tools/enforce-supreme-gate.mjs +++ b/tools/enforce-supreme-gate.mjs @@ -16,7 +16,7 @@ import { tokenizeBash } from './bash-tokenizer.mjs'; import { signPayload, verifyReceipt, RECEIPT_DOMAINS } from './receipt-sign.mjs'; import { assertSafeSessionId } from './action-journal.mjs'; import { floorDecide } from './floor-decide.mjs'; -import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed } from './escape-grant.mjs'; +import { canonicalAction, escapeGrantOpen, escapeAllowsEvent, loadFloorEscapes, loadConsumed, loadTerminalGrants } from './escape-grant.mjs'; // Фаза 5 Task 5.2 (Вариант А): зарезервированная canonical-метка finish-гранта владельца // «план завершён досрочно». НЕ совпадает ни с одним реальным действием (canonicalAction даёт @@ -389,7 +389,7 @@ export function judgeModeMismatch(planMode, artifactMode) { * реализационный режим, НО бэкстоп: без валидного замороженного артефакта строить нельзя (C-10). */ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, key, - escapeGrants = [], escapeConsumed = [], now = Date.now(), + escapeGrants = [], escapeConsumed = [], terminalGrants = [], now = Date.now(), verifyImpl = verifyFrozenPlan, verifyArtifactImpl = verifyFrozenArtifact, normalize }) { // G-1 (α): сквозной аварийный выход владельца — раньше всех плановых проверок. // allow БЕЗ advanceTo (указатель не двигается; escape — out-of-band, не шаг плана). @@ -400,7 +400,9 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k // (floor_escape с зарезервированной меткой PLAN_FINISH_ACTION — её НЕ порождает ни одно // реальное действие) И есть запечатанный план → план завершается: печать снимается // (runGate зовёт removeFrozenPlan), возврат в разговорный. Не требует валидного артефакта. - if (frozenPlan && escapeGrantOpen(PLAN_FINISH_ACTION, escapeGrants, escapeConsumed, now)) { + // Поза 1 (#B4): снятие печати — ТЯЖЁЛОЕ → ТОЛЬКО терминальный грант владельца (terminalGrants), + // не chat floor_escape (иначе контроллер досрочно снимает стену сочинённой меткой). + if (frozenPlan && escapeGrantOpen(PLAN_FINISH_ACTION, terminalGrants, escapeConsumed, now)) { return { decision: 'allow', mode: 'conversational', finishPlan: true, reason: 'владелец завершил план досрочно (plan-done) — печать снята, возврат в разговор' }; } if (!frozenPlan) { @@ -435,7 +437,7 @@ export function decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr = 0, k * Чистая оркестрация: decideMode → на allow журналирует действие и продвигает шаг. * journal/saveStep инъектируются (в main — реальные Node fs). */ -export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, writeLoopOpen, escapeGrants = [], escapeConsumed = [], now = Date.now() }) { +export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr = null, key, verifyImpl, verifyArtifactImpl, normalize, journal, saveStep, removeFrozenPlan, writeLoopOpen, escapeGrants = [], escapeConsumed = [], terminalGrants = [], now = Date.now() }) { const toolUse = { name: event.tool_name, input: event.tool_input }; const incomingAction = actionOf(toolUse); // §3.4 (десинк fix, ленивое завершение Фазы 5): план был доведён до конца на ПРОШЛОМ действии @@ -456,7 +458,7 @@ export function runGate({ event, frozenPlan, frozenArtifact, stepPtr, tentativeT // решения: commit (= шаг по toPtr → прошлый исполнился) / discard (= повтор шага → прошлый был // заблокирован, не исполнился) / hold / none. Решение принимается по эффективному указателю. const rec = computeReconcile({ frozenPlan, incomingAction, committedPtr: stepPtr, tentativeToPtr, normalize }); - const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr: rec.effPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, now }); + const r = decideMode({ toolUse, frozenPlan, frozenArtifact, stepPtr: rec.effPtr, key, verifyImpl, verifyArtifactImpl, normalize, escapeGrants, escapeConsumed, terminalGrants, now }); // FIX-3: out-of-band аварийный выход владельца (G-1 α) — best-effort пред-запись в журнал // (escape:true), указатель И пометку НЕ трогаем (escape — не шаг плана). Сбой журнала escape // НЕ блокирует (санкционирован владельцем). Помеченная escape-запись снимает будущий @@ -516,7 +518,7 @@ export function panicEscapeDecision(event, escapeGrants = [], escapeConsumed = [ } async function main() { - let event = {}; let escapeGrants = []; let escapeConsumed = []; + let event = {}; let escapeGrants = []; let escapeConsumed = []; let terminalGrants = []; try { event = parseEventJson(await readStdin()); if ((await import('./enforce-hook-helpers.mjs')).standbyActive((event && event.session_id) || 'unknown')) { exitDecision({ block: false }); return; } @@ -528,8 +530,9 @@ async function main() { const sess = resolveSessionId(event); // R-28: из stdin-события, не из env // M7 Фаза 2 (правило 7б): escape-пропуски грузим РАНО — чтобы panic-ветка в catch // имела их, даже если последующий сетап (ключ/план/артефакт/путь) бросит. - escapeGrants = loadFloorEscapes(sess); // G-1 α: read-only floor_escape-пропуски + escapeGrants = loadFloorEscapes(sess); // G-1 α: read-only floor_escape-пропуски (лёгкие escape) escapeConsumed = loadConsumed(sess); // отметки one-shot погашения + terminalGrants = loadTerminalGrants(sess); // Поза 1 (#B4): тяжёлые (plan-done) — только терминал владельца const key = resolveReceiptKey(); const frozenPlan = loadFrozenPlan({ sessionId: sess, runtimeDir }); const frozenArtifact = loadFrozenArtifact({ sessionId: sess, runtimeDir }); @@ -541,7 +544,7 @@ async function main() { const { writeLoopOpen: writeLoopOpenMarker } = await import('./enforce-gate3-loop.mjs'); const { loadTaskId } = await import('./router-task-id.mjs'); const r = runGate({ - event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr, key, escapeGrants, escapeConsumed, + event, frozenPlan, frozenArtifact, stepPtr, tentativeToPtr, key, escapeGrants, escapeConsumed, terminalGrants, journal: (entry) => journalAppend({ payload: entry, key, sessionId: sess, runtimeDir }), saveStep: (n, tentative = null) => fs.writeFileSync(stepPath, JSON.stringify(signStepState(frozenPlan?.plan_id ?? null, n, key, tentative))), // R-19 + F-J removeFrozenPlan: () => removeFrozenPlan({ sessionId: sess, runtimeDir }), // Фаза 5: чистое завершение diff --git a/tools/enforce-supreme-gate.test.mjs b/tools/enforce-supreme-gate.test.mjs index 3b6b26a..a92a2f7 100644 --- a/tools/enforce-supreme-gate.test.mjs +++ b/tools/enforce-supreme-gate.test.mjs @@ -821,13 +821,19 @@ describe('Фаза 5 Task 5.2 — досрочное завершение вла expect(typeof PLAN_FINISH_ACTION).toBe('string'); expect(PLAN_FINISH_ACTION).not.toMatch(/^(write|bash|skill|mcp|powershell):/); }); - it('decideMode: открыт finish-грант + есть план → allow, conversational, finishPlan:true (даже на середине)', () => { + it('decideMode: открыт finish-грант (терминальный) + есть план → allow, conversational, finishPlan:true (даже на середине)', () => { const now = 1000; - const r = decideModeFin({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeGrants: finGrant(now), escapeConsumed: [], now }); + const r = decideModeFin({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeGrants: [], terminalGrants: finGrant(now), escapeConsumed: [], now }); expect(r.decision).toBe('allow'); expect(r.mode).toBe('conversational'); expect(r.finishPlan).toBe(true); }); + it('decideMode: plan-done в escapeGrants (chat) НЕ завершает; только terminalGrants (Поза 1 B4)', () => { + const now = 1000; + const base = { toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeConsumed: [], now }; + expect(decideModeFin({ ...base, escapeGrants: finGrant(now), terminalGrants: [] }).finishPlan).toBeUndefined(); + expect(decideModeFin({ ...base, escapeGrants: [], terminalGrants: finGrant(now) }).finishPlan).toBe(true); + }); it('decideMode: нет finish-гранта → обычный план-режим (finishPlan не выставлен)', () => { const r = decideModeFin({ toolUse: { name: 'Write', input: { file_path: 'tools/foo.mjs' } }, frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, escapeGrants: [], escapeConsumed: [], now: 1000 }); expect(r.finishPlan).toBeUndefined(); @@ -839,7 +845,7 @@ describe('Фаза 5 Task 5.2 — досрочное завершение вла frozenPlan: PLANF, frozenArtifact: ART, stepPtr: 0, key: KFIN, verifyImpl: verifyFin, verifyArtifactImpl: verifyFin, normalize: lowFin, journal: () => true, saveStep: () => {}, removeFrozenPlan: () => { removed++; }, - escapeGrants: finGrant(now), escapeConsumed: [], now, + escapeGrants: [], terminalGrants: finGrant(now), escapeConsumed: [], now, }); expect(r.block).toBe(false); expect(removed).toBe(1);