feat: plan-done и арбитраж gate3 — только терминальный грант (consent forgery B4)

Снятие печати плана (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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 19:03:10 +03:00
parent 29287d73c9
commit 5bcf229e4f
4 changed files with 63 additions and 13 deletions
@@ -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 / нормативка — как в спеке.
+3 -2
View File
@@ -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: внутренняя ошибка хука НЕ кирпичит конец хода
+11 -8
View File
@@ -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: чистое завершение
+9 -3
View File
@@ -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);