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:
@@ -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 и тестами.
|
||||
@@ -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: коммит силами агента
|
||||
|
||||
|
||||
@@ -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') });
|
||||
|
||||
@@ -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();
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user