dec0ed502a
Несомненный канал согласия для тяжёлого (Поза 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>
46 lines
2.5 KiB
JavaScript
46 lines
2.5 KiB
JavaScript
#!/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();
|