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:
+40
@@ -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 / нормативка — как в спеке.
|
||||
@@ -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: внутренняя ошибка хука НЕ кирпичит конец хода
|
||||
|
||||
@@ -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: чистое завершение
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user