diff --git a/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B1-terminal-primitive-plan.md b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B1-terminal-primitive-plan.md new file mode 100644 index 0000000..6e4f696 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-B1-terminal-primitive-plan.md @@ -0,0 +1,276 @@ +# Consent-forgery fix — Часть B1: примитив терминального гранта Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans / test-driven-development. Steps — checkbox (`- [ ]`). + +**Goal:** Несомненный «терминальный грант» владельца: запись `floor_escape` с `origin:'owner-terminal'`, подписанная ключом keychain, которую может создать ТОЛЬКО процесс с доступом к keychain (терминал владельца) — контроллер не может (нет ключа + floor не даёт писать runtime). Плюс fail-closed читатель, принимающий лишь origin-помеченные валидно-подписанные гранты. + +**Architecture:** Подпись `signFloorEscapeRecord` уже покрывает всё тело записи (домен FLOOR_ESCAPE) → добавление `origin` автоматически под подписью. Новый скрипт `tools/owner-consent.mjs` строит+подписывает+пишет грант (запускает владелец). Новый чистый читатель `loadTerminalGrants` в `escape-grant.mjs` фильтрует по `origin` + валидной подписи (ключ ОБЯЗАТЕЛЕН — fail-closed, #KEY). **Нулевой радиус: живые потребители НЕ трогаются — подключение отдельными заходами B2+.** + +**Tech Stack:** Node ESM, vitest (`vitest.config.tools.mjs`). Тесты ТОЛЬКО через PowerShell: `npx vitest run <файл> --config vitest.config.tools.mjs --no-file-parallelism`. + +**Спека:** [specs/2026-06-18-consent-forgery-fix-design.md](../specs/2026-06-18-consent-forgery-fix-design.md) §B/§KEY. Решение: Поза 1. + +**Режим:** ШТАТНЫЙ. Коммит — дисциплина handoff (явные пути; receipt; `.git/CB_MSG.txt`; `git commit -F`). + +**Доверие (проверено по коду этой сессии):** `~/.claude/runtime` в `DEFAULT_PROTECTED_PATTERNS`+`READ_DENY_PATTERNS` (shell-content-rules.mjs:25,80) → Bash/PowerShell контроллера не пишет/не читает канал. Ключ — только keychain (receipt-key-config.mjs), env убран → процесс без keychain подписать не может. Контроллер, запустив `owner-consent.mjs`, ключа не получит → грант не подписан → читатель отвергнет. + +--- + +## File Structure + +- `tools/escape-grant.mjs` — добавить `OWNER_TERMINAL_ORIGIN` + `loadTerminalGrants(...)` (рядом с `loadFloorEscapes`, переиспользует приватный `readFloorEscapeRecordsAt` + `verifyFloorEscapeRecord`). +- `tools/escape-grant.test.mjs` — тесты читателя. +- `tools/owner-consent.mjs` — НОВЫЙ скрипт владельца: `buildTerminalGrant` + `writeTerminalGrant` + CLI `main`. +- `tools/owner-consent.test.mjs` — НОВЫЙ тест скрипта. + +--- + +### Task 1: Читатель `loadTerminalGrants` (fail-closed по origin+подписи) + +**Files:** +- Modify: `tools/escape-grant.mjs` (добавить после `loadFloorEscapes`, ~строка 107) +- Test: `tools/escape-grant.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +Добавить в `tools/escape-grant.test.mjs` (импорт `loadTerminalGrants, OWNER_TERMINAL_ORIGIN` из `./escape-grant.mjs`; `signFloorEscapeRecord` из `./askuser-answer-parser.mjs`): + +```js +import { loadTerminalGrants, OWNER_TERMINAL_ORIGIN } from './escape-grant.mjs'; +import { signFloorEscapeRecord } from './askuser-answer-parser.mjs'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +describe('loadTerminalGrants — только origin:owner-terminal + валидная подпись (fail-closed)', () => { + const KEY = 'tg-key'; + const mkdir = () => mkdtempSync(join(tmpdir(), 'tg-')); + const writeRecs = (dir, sess, recs) => + writeFileSync(join(dir, `askuser-decisions-${sess}.jsonl`), recs.map((r) => JSON.stringify(r)).join('\n') + '\n'); + + it('подписанный origin-грант с ключом → принят', () => { + const dir = mkdir(); + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }, KEY); + writeRecs(dir, 's1', [rec]); + const grants = loadTerminalGrants('s1', 100, { keyImpl: () => KEY, runtimeDir: dir }); + expect(grants).toEqual([{ action: 'owner-seal:abc', ts: 100 }]); + rmSync(dir, { recursive: true, force: true }); + }); + + it('chat-грант без origin → отвергнут (даже подписанный)', () => { + const dir = mkdir(); + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', ts: 100 }, KEY); + writeRecs(dir, 's2', [rec]); + expect(loadTerminalGrants('s2', 100, { keyImpl: () => KEY, runtimeDir: dir })).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }); + + it('origin-грант неподписанный → отвергнут (форж контроллера)', () => { + const dir = mkdir(); + writeRecs(dir, 's3', [{ type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }]); + expect(loadTerminalGrants('s3', 100, { keyImpl: () => KEY, runtimeDir: dir })).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }); + + it('нет ключа → [] (fail-closed #KEY, тяжёлый грант без проверки подписи не принимаем)', () => { + const dir = mkdir(); + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }, KEY); + writeRecs(dir, 's4', [rec]); + expect(loadTerminalGrants('s4', 100, { keyImpl: () => null, runtimeDir: dir })).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }); + + it('future-ts отброшен (нижняя граница)', () => { + const dir = mkdir(); + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'commit:xyz', origin: OWNER_TERMINAL_ORIGIN, ts: 5000 }, KEY); + writeRecs(dir, 's5', [rec]); + expect(loadTerminalGrants('s5', 100, { keyImpl: () => KEY, runtimeDir: dir })).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +PowerShell: `npx vitest run tools/escape-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: FAIL — `loadTerminalGrants is not a function` / `OWNER_TERMINAL_ORIGIN` undefined. + +- [ ] **Step 3: Реализовать читатель** + +В `tools/escape-grant.mjs` сразу ПОСЛЕ `loadFloorEscapes` (после строки 107) добавить: + +```js +export const OWNER_TERMINAL_ORIGIN = 'owner-terminal'; // Часть B: метка терминального гранта владельца + +/** + * Терминальные гранты владельца (Поза 1, #B): floor_escape с origin:'owner-terminal', + * подписанные ключом keychain. Принимаем ТОЛЬКО при валидной подписи — ключ ОБЯЗАТЕЛЕН + * (fail-closed, #KEY: тяжёлый грант без проверяемой подписи не доверяем). origin под подписью + * (verifyFloorEscapeRecord покрывает всё тело) → контроллер не добавит origin к chat-гранту. + * Окно не режем здесь — вызыватель применяет своё (escapeGrantOpen 5мин / planScopedGrantOpen план). + */ +export function loadTerminalGrants(sessionId, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) { + const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir).filter((r) => r.origin === OWNER_TERMINAL_ORIGIN); + if (records.length === 0) return []; + let key = null; try { key = keyImpl(); } catch { key = null; } + if (!key) return []; // fail-closed: нет ключа → терминальный грант не проверить → не принимаем + return records + .filter((r) => verifyFloorEscapeRecord(r, key)) + .map((r) => ({ action: r.action, ts: typeof r.ts === 'number' ? r.ts : 0 })) + .filter((g) => now - g.ts >= 0); // нижняя граница (не future-ts); верхней нет (окно — у вызывателя) +} +``` + +- [ ] **Step 4: Запустить — убедиться, что проходит** + +PowerShell: `npx vitest run tools/escape-grant.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: PASS (включая прежние тесты файла). + +--- + +### Task 2: Скрипт владельца `tools/owner-consent.mjs` + +**Files:** +- Create: `tools/owner-consent.mjs` +- Test: `tools/owner-consent.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +Создать `tools/owner-consent.test.mjs`: + +```js +import { describe, it, expect } from 'vitest'; +import { buildTerminalGrant, writeTerminalGrant } from './owner-consent.mjs'; +import { OWNER_TERMINAL_ORIGIN, loadTerminalGrants } from './escape-grant.mjs'; +import { verifyFloorEscapeRecord } from './askuser-answer-parser.mjs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const KEY = 'oc-key'; + +describe('owner-consent — терминальный грант владельца', () => { + it('buildTerminalGrant ставит origin и тип', () => { + expect(buildTerminalGrant('owner-seal:abc', 7)).toEqual({ + type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 7 }); + }); + + it('writeTerminalGrant пишет подписанный грант, который читает loadTerminalGrants', () => { + const dir = mkdtempSync(join(tmpdir(), 'oc-')); + const r = writeTerminalGrant({ sessionId: 's1', action: 'owner-seal:abc', nowMs: 100, key: KEY, runtimeDir: dir }); + expect(r.signed).toBe(true); + const raw = JSON.parse(readFileSync(join(dir, 'askuser-decisions-s1.jsonl'), 'utf-8').trim()); + expect(raw.origin).toBe(OWNER_TERMINAL_ORIGIN); + expect(verifyFloorEscapeRecord(raw, KEY)).toBe(true); + expect(loadTerminalGrants('s1', 100, { keyImpl: () => KEY, runtimeDir: dir })) + .toEqual([{ action: 'owner-seal:abc', ts: 100 }]); + rmSync(dir, { recursive: true, force: true }); + }); + + it('без ключа грант не подписан → loadTerminalGrants его отвергает', () => { + const dir = mkdtempSync(join(tmpdir(), 'oc-')); + const r = writeTerminalGrant({ sessionId: 's2', action: 'commit:xyz', nowMs: 100, key: null, runtimeDir: dir }); + expect(r.signed).toBe(false); + expect(loadTerminalGrants('s2', 100, { keyImpl: () => KEY, runtimeDir: dir })).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }); +}); +``` + +- [ ] **Step 2: Запустить — убедиться, что падает** + +PowerShell: `npx vitest run tools/owner-consent.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: FAIL — модуль `./owner-consent.mjs` не существует. + +- [ ] **Step 3: Реализовать скрипт** + +Создать `tools/owner-consent.mjs`: + +```js +#!/usr/bin/env node +/** + * owner-consent — терминал ВЛАДЕЛЬЦА подписывает ТЕРМИНАЛЬНЫЙ грант согласия (Поза 1, Часть B). + * Запуск владельцем: node tools/owner-consent.mjs + * Контроллер запустить с пользой НЕ может: его Bash не имеет доступа к keychain (ключ null → + * грант не подписан → loadTerminalGrants отвергает) и не пишет ~/.claude/runtime (floor). + * Канон — как escape-grant::canonicalAction / ownerSealAction (owner-seal: и т.п.). + */ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { signFloorEscapeRecord } from './askuser-answer-parser.mjs'; +import { resolveReceiptKey } from './receipt-key-config.mjs'; +import { OWNER_TERMINAL_ORIGIN } from './escape-grant.mjs'; + +export function buildTerminalGrant(action, nowMs = Date.now()) { + return { type: 'floor_escape', action: String(action || ''), origin: OWNER_TERMINAL_ORIGIN, ts: nowMs }; +} + +export function writeTerminalGrant({ sessionId, action, nowMs = Date.now(), key, runtimeDir, fsImpl = { appendFileSync, mkdirSync } }) { + const rec = signFloorEscapeRecord(buildTerminalGrant(action, nowMs), key); + const dir = runtimeDir || join(homedir(), '.claude', 'runtime'); + const path = join(dir, `askuser-decisions-${sessionId || 'unknown'}.jsonl`); + try { fsImpl.mkdirSync(dirname(path), { recursive: true }); } catch { /* ignore */ } + fsImpl.appendFileSync(path, JSON.stringify(rec) + '\n'); + return { path, signed: !!(rec && rec.sig), action: rec.action }; +} + +function main() { + const argv = process.argv.slice(2); + const sessionId = argv[0]; + const action = argv.slice(1).join(' '); + if (!sessionId || !action) { + console.error('usage: node tools/owner-consent.mjs '); + process.exit(2); + } + const key = resolveReceiptKey(); + if (!key) console.error('[owner-consent] ВНИМАНИЕ: нет ключа keychain (router-mentor-receipts) — грант НЕ подписан, тяжёлый потребитель его отвергнет.'); + const r = writeTerminalGrant({ sessionId, action, key }); + console.log(`[owner-consent] ${r.signed ? 'signed' : 'UNSIGNED'} terminal grant: ${r.action} -> ${r.path}`); +} + +import { fileURLToPath } from 'node:url'; +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); +``` + +- [ ] **Step 4: Запустить — убедиться, что проходит** + +PowerShell: `npx vitest run tools/owner-consent.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: PASS. + +--- + +### Task 3: Полный свод + коммит + +- [ ] **Step 1: Полный свод** + +PowerShell: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: зелёный (4329 + новые тесты). Красное не из-за наших файлов → сверить pre-existing. + +- [ ] **Step 2: Stage явных путей** + +PowerShell: `git add tools/escape-grant.mjs tools/escape-grant.test.mjs tools/owner-consent.mjs tools/owner-consent.test.mjs docs/superpowers/plans/2026-06-18-consent-forgery-fix-B1-terminal-primitive-plan.md` + +- [ ] **Step 3: Расписка** + +PowerShell: `node tools/produce-verify-receipt.mjs` → `signed GREEN`. + +- [ ] **Step 4: Сообщение в `.git/CB_MSG.txt` (Write-инструментом), затем коммит** + +PowerShell: `git commit -F .git/CB_MSG.txt`. Блок гейта → СТОП, доложить владельцу. + +--- + +## Следующие шаги (B2+, отдельными заходами — живые потребители) + +- **B2:** owner-seal (sealTurnProd) → `loadTerminalGrants` вместо `loadFloorEscapes` (терминал-only печать «одобрено»). +- **B3:** ops-runbook (blessed-ops) + commit (commit-grant) → терминальный plan-scoped грант. +- **B4:** plan-done / gate3-arb → терминал (снятие/обход стены). +- **B5:** разрушительный bash/powershell per-command escape → терминал (переиспользовать «ядерный» детектор D1). +- **C / GUIDE / нормативка** — как в спеке. + +## Self-Review + +- **Spec coverage:** §B (терминальный канал) → Task 1+2. §KEY (fail-closed подпись) → Task 1 тест «нет ключа → []». §CRIT 5 (терминальный скрипт пишет валидно-подписанный грант) → Task 2. Подключение тяжёлых потребителей (§CRIT 6/7) → B2+ (явно вынесено). +- **Placeholder scan:** код полный, команды точные. +- **Type consistency:** `OWNER_TERMINAL_ORIGIN`, `loadTerminalGrants(sessionId,now,opts)`, `buildTerminalGrant(action,nowMs)`, `writeTerminalGrant({...})` — согласованы между Task 1/2 и тестами. diff --git a/tools/escape-grant.mjs b/tools/escape-grant.mjs index 36f8511..93eb4f8 100644 --- a/tools/escape-grant.mjs +++ b/tools/escape-grant.mjs @@ -106,6 +106,28 @@ export function loadFloorEscapes(sessionId, now = Date.now(), { keyImpl = resolv .filter((g) => now - g.ts <= FLOOR_ESCAPE_WINDOW_MS); } +export const OWNER_TERMINAL_ORIGIN = 'owner-terminal'; // Часть B: метка терминального гранта владельца + +/** + * Терминальные гранты владельца (Поза 1, #B): floor_escape с origin:'owner-terminal', + * подписанные ключом keychain. Принимаем ТОЛЬКО при валидной подписи — ключ ОБЯЗАТЕЛЕН + * (fail-closed, #KEY: тяжёлый грант без проверяемой подписи не доверяем). origin под подписью + * (verifyFloorEscapeRecord покрывает всё тело) → контроллер не добавит origin к chat-гранту, а + * свой подписать не может (нет ключа) и не запишет канал (floor). Окно не режем здесь — + * вызыватель применяет своё (escapeGrantOpen 5мин / planScopedGrantOpen окно=план). + * keyImpl/fsImpl/runtimeDir инъектируемы для тестов. + */ +export function loadTerminalGrants(sessionId, now = Date.now(), { keyImpl = resolveReceiptKey, fsImpl = fsDefault, runtimeDir } = {}) { + const records = readFloorEscapeRecordsAt(sessionId, fsImpl, runtimeDir).filter((r) => r.origin === OWNER_TERMINAL_ORIGIN); + if (records.length === 0) return []; + let key = null; try { key = keyImpl(); } catch { key = null; } + if (!key) return []; // fail-closed: нет ключа → терминальный грант не проверить → не принимаем + return records + .filter((r) => verifyFloorEscapeRecord(r, key)) + .map((r) => ({ action: r.action, ts: typeof r.ts === 'number' ? r.ts : 0 })) + .filter((g) => now - g.ts >= 0); // нижняя граница (не future-ts); верхней нет (окно — у вызывателя) +} + export const OPS_RUNBOOK_PREFIX = 'ops-runbook:'; // D1: благословлённый деплой export const COMMIT_GRANT_PREFIX = 'commit:'; // D2: коммит силами агента diff --git a/tools/escape-grant.test.mjs b/tools/escape-grant.test.mjs index 0ac11a7..5bf72dd 100644 --- a/tools/escape-grant.test.mjs +++ b/tools/escape-grant.test.mjs @@ -2,6 +2,34 @@ import { describe, it, expect } from 'vitest'; import { canonicalAction, escapeGrantOpen, FLOOR_ESCAPE_WINDOW_MS } from './escape-grant.mjs'; import { loadOpsRunbookGrants, opsRunbookGrantOpen, OPS_RUNBOOK_PREFIX } from './escape-grant.mjs'; import { loadCommitGrants, commitGrantOpen, COMMIT_GRANT_PREFIX } from './escape-grant.mjs'; +import { loadTerminalGrants, OWNER_TERMINAL_ORIGIN } from './escape-grant.mjs'; +import { signFloorEscapeRecord } from './askuser-answer-parser.mjs'; + +describe('loadTerminalGrants — origin:owner-terminal + валидная подпись (fail-closed, #B/#KEY)', () => { + const KEY = 'tg-key'; + const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') }); + it('подписанный origin-грант с ключом → принят', () => { + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }, KEY); + expect(loadTerminalGrants('s', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })) + .toEqual([{ action: 'owner-seal:abc', ts: 100 }]); + }); + it('chat-грант без origin → отвергнут (даже подписанный)', () => { + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', ts: 100 }, KEY); + expect(loadTerminalGrants('s', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]); + }); + it('origin-грант неподписанный → отвергнут (форж контроллера)', () => { + const rec = { type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }; + expect(loadTerminalGrants('s', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]); + }); + it('нет ключа → [] (fail-closed #KEY)', () => { + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 100 }, KEY); + expect(loadTerminalGrants('s', 100, { keyImpl: () => null, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]); + }); + it('future-ts отброшен (нижняя граница)', () => { + const rec = signFloorEscapeRecord({ type: 'floor_escape', action: 'commit:xyz', origin: OWNER_TERMINAL_ORIGIN, ts: 5000 }, KEY); + expect(loadTerminalGrants('s', 100, { keyImpl: () => KEY, fsImpl: mkFs([rec]), runtimeDir: '/rt' })).toEqual([]); + }); +}); describe('commit грант (D2 — окно = существование плана)', () => { const mkFs = (records) => ({ existsSync: () => true, readFileSync: () => records.map((r) => JSON.stringify(r)).join('\n') }); diff --git a/tools/owner-consent.mjs b/tools/owner-consent.mjs new file mode 100644 index 0000000..6b80784 --- /dev/null +++ b/tools/owner-consent.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * owner-consent — терминал ВЛАДЕЛЬЦА подписывает ТЕРМИНАЛЬНЫЙ грант согласия (Поза 1, Часть B). + * Запуск владельцем: node tools/owner-consent.mjs + * Контроллер запустить с пользой НЕ может: его Bash не имеет доступа к keychain (ключ null → + * грант не подписан → loadTerminalGrants отвергает) и не пишет ~/.claude/runtime (floor). + * Канон — как escape-grant::canonicalAction / ownerSealAction (owner-seal: и т.п.). + */ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { signFloorEscapeRecord } from './askuser-answer-parser.mjs'; +import { resolveReceiptKey } from './receipt-key-config.mjs'; +import { OWNER_TERMINAL_ORIGIN } from './escape-grant.mjs'; + +export function buildTerminalGrant(action, nowMs = Date.now()) { + return { type: 'floor_escape', action: String(action || ''), origin: OWNER_TERMINAL_ORIGIN, ts: nowMs }; +} + +export function writeTerminalGrant({ sessionId, action, nowMs = Date.now(), key, runtimeDir, fsImpl = { appendFileSync, mkdirSync } }) { + const rec = signFloorEscapeRecord(buildTerminalGrant(action, nowMs), key); + const dir = runtimeDir || join(homedir(), '.claude', 'runtime'); + const path = join(dir, `askuser-decisions-${sessionId || 'unknown'}.jsonl`); + try { fsImpl.mkdirSync(dirname(path), { recursive: true }); } catch { /* ignore */ } + fsImpl.appendFileSync(path, JSON.stringify(rec) + '\n'); + return { path, signed: !!(rec && rec.sig), action: rec.action }; +} + +function main() { + const argv = process.argv.slice(2); + const sessionId = argv[0]; + const action = argv.slice(1).join(' '); + if (!sessionId || !action) { + console.error('usage: node tools/owner-consent.mjs '); + process.exit(2); + } + const key = resolveReceiptKey(); + if (!key) console.error('[owner-consent] ВНИМАНИЕ: нет ключа keychain (router-mentor-receipts) — грант НЕ подписан, тяжёлый потребитель его отвергнет.'); + const r = writeTerminalGrant({ sessionId, action, key }); + console.log(`[owner-consent] ${r.signed ? 'signed' : 'UNSIGNED'} terminal grant: ${r.action} -> ${r.path}`); +} + +import { fileURLToPath } from 'node:url'; +const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; +if (isCli) main(); diff --git a/tools/owner-consent.test.mjs b/tools/owner-consent.test.mjs new file mode 100644 index 0000000..cb09ccb --- /dev/null +++ b/tools/owner-consent.test.mjs @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { buildTerminalGrant, writeTerminalGrant } from './owner-consent.mjs'; +import { OWNER_TERMINAL_ORIGIN, loadTerminalGrants } from './escape-grant.mjs'; +import { verifyFloorEscapeRecord } from './askuser-answer-parser.mjs'; + +const KEY = 'oc-key'; + +// in-memory fs (как escape-grant.test): пишем в Map, читаем обратно +function memFs() { + const s = new Map(); const norm = (p) => String(p).replace(/\\/g, '/'); + return { s, norm, + appendFileSync: (p, d) => { const n = norm(p); s.set(n, (s.get(n) || '') + d); }, + mkdirSync: () => {}, + existsSync: (p) => s.has(norm(p)), + readFileSync: (p) => s.get(norm(p)) || '' }; +} + +describe('owner-consent — терминальный грант владельца (Часть B)', () => { + it('buildTerminalGrant ставит origin и тип', () => { + expect(buildTerminalGrant('owner-seal:abc', 7)).toEqual({ + type: 'floor_escape', action: 'owner-seal:abc', origin: OWNER_TERMINAL_ORIGIN, ts: 7 }); + }); + + it('writeTerminalGrant пишет подписанный грант, который читает loadTerminalGrants', () => { + const fs = memFs(); + const r = writeTerminalGrant({ sessionId: 's1', action: 'owner-seal:abc', nowMs: 100, key: KEY, runtimeDir: '/rt', fsImpl: fs }); + expect(r.signed).toBe(true); + const raw = JSON.parse((fs.s.get('/rt/askuser-decisions-s1.jsonl') || '').trim()); + expect(raw.origin).toBe(OWNER_TERMINAL_ORIGIN); + expect(verifyFloorEscapeRecord(raw, KEY)).toBe(true); + expect(loadTerminalGrants('s1', 100, { keyImpl: () => KEY, fsImpl: fs, runtimeDir: '/rt' })) + .toEqual([{ action: 'owner-seal:abc', ts: 100 }]); + }); + + it('без ключа грант не подписан → loadTerminalGrants его отвергает', () => { + const fs = memFs(); + const r = writeTerminalGrant({ sessionId: 's2', action: 'commit:xyz', nowMs: 100, key: null, runtimeDir: '/rt', fsImpl: fs }); + expect(r.signed).toBe(false); + expect(loadTerminalGrants('s2', 100, { keyImpl: () => KEY, fsImpl: fs, runtimeDir: '/rt' })).toEqual([]); + }); +});