feat: терминальный грант владельца — примитив (consent forgery B1)

Несомненный канал согласия для тяжёлого (Поза 1): floor_escape с origin:owner-terminal,
подписанный ключом keychain. Скрипт владельца tools/owner-consent.mjs строит+подписывает+
пишет грант (запускает владелец; контроллер не может — нет ключа + floor режет запись runtime).
Читатель loadTerminalGrants принимает ТОЛЬКО origin-помеченные валидно-подписанные гранты,
ключ обязателен (fail-closed, #KEY). Нулевой радиус: живые потребители НЕ тронуты —
подключение (owner-seal/ops-runbook/commit/plan-done/gate3/bash) отдельными заходами B2+.
Спека: specs/2026-06-18-consent-forgery-fix-design.md §B/§KEY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 18:02:28 +03:00
parent cdc32b1a1d
commit dec0ed502a
5 changed files with 412 additions and 0 deletions
@@ -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 <sessionId> <action>
* Контроллер запустить с пользой НЕ может: его Bash не имеет доступа к keychain (ключ null →
* грант не подписан → loadTerminalGrants отвергает) и не пишет ~/.claude/runtime (floor).
* Канон <action> — как escape-grant::canonicalAction / ownerSealAction (owner-seal:<hash> и т.п.).
*/
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 <sessionId> <action>');
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 и тестами.
+22
View File
@@ -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: коммит силами агента
+28
View File
@@ -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') });
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* owner-consent — терминал ВЛАДЕЛЬЦА подписывает ТЕРМИНАЛЬНЫЙ грант согласия (Поза 1, Часть B).
* Запуск владельцем: node tools/owner-consent.mjs <sessionId> <action>
* Контроллер запустить с пользой НЕ может: его Bash не имеет доступа к keychain (ключ null →
* грант не подписан → loadTerminalGrants отвергает) и не пишет ~/.claude/runtime (floor).
* Канон <action> — как escape-grant::canonicalAction / ownerSealAction (owner-seal:<hash> и т.п.).
*/
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 <sessionId> <action>');
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();
+41
View File
@@ -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([]);
});
});