diff --git a/docs/superpowers/plans/2026-06-18-consent-forgery-fix-A-plan.md b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-A-plan.md new file mode 100644 index 0000000..dba3be0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-A-plan.md @@ -0,0 +1,243 @@ +# Consent-forgery fix — Часть A (anti-button, фундамент) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Согласие из ответа AskUserQuestion засчитывается только из свободного ввода владельца; ответ, дословно совпавший с ярлыком контроллерской кнопки, НЕ порождает доверенную запись (`floor_escape` И `approve_git_operation`). + +**Architecture:** Единая anti-button-точка в писателе `enforce-askuser-answer-parser.mjs::processEvent` (единственный писатель доверенных записей в `askuser-decisions-.jsonl`). Чистый предикат `answerMatchesOption` в `askuser-answer-parser.mjs` сравнивает нормализованный ответ с ярлыками опций (`tool_input.questions[].options[].label`). Совпал — запись не пишется. Покрывает оба парсера (`toFloorEscapeRecord`, `toApprovalRecord`) одним guard'ом → закрывает наблюдаемый баг и HOLE-1 (fatal). + +**Tech Stack:** Node ESM, vitest (config `vitest.config.tools.mjs`). Тесты ТОЛЬКО через PowerShell-инструмент: `npx vitest run <файл> --config vitest.config.tools.mjs --no-file-parallelism`. + +**Спека:** [docs/superpowers/specs/2026-06-18-consent-forgery-fix-design.md](../specs/2026-06-18-consent-forgery-fix-design.md) §{#A}, критерии {#CRIT} 1-3. Решение владельца: Поза 1. + +**Режим:** ШТАТНЫЙ (правка машинерии). Пол + verify-before-push остаются. Коммит — по дисциплине handoff (явные пути; `produce-verify-receipt.mjs` через PowerShell; `.git/CB_MSG.txt`; `git commit -F`). + +--- + +## File Structure + +- `tools/askuser-answer-parser.mjs` — добавить чистый предикат `answerMatchesOption(answer, optionLabels)`. Уже содержит `normalizeAnswer` (переиспользуем). +- `tools/askuser-answer-parser.test.mjs` — юнит-тесты предиката. +- `tools/enforce-askuser-answer-parser.mjs` — в `processEvent` собрать ярлыки опций вопроса и пропускать обе доверенные записи при совпадении. +- `tools/enforce-askuser-answer-parser.test.mjs` — интеграционные тесты writer'а (кнопка отвергается, свободный — пишется). + +--- + +### Task 1: Чистый предикат `answerMatchesOption` + +**Files:** +- Modify: `tools/askuser-answer-parser.mjs` (добавить экспорт рядом с `normalizeAnswer`) +- Test: `tools/askuser-answer-parser.test.mjs` + +- [ ] **Step 1: Написать падающий тест** + +В `tools/askuser-answer-parser.test.mjs` добавить (импорт `answerMatchesOption` в существующую строку импорта из `./askuser-answer-parser.mjs`): + +```js +import { answerMatchesOption } from './askuser-answer-parser.mjs'; + +describe('answerMatchesOption — ответ совпал с ярлыком кнопки (anti-button)', () => { + it('дословное совпадение с ярлыком → true (это выбор кнопки)', () => { + expect(answerMatchesOption('FLOOR-ESCAPE: owner-seal:abc', + ['FLOOR-ESCAPE: owner-seal:abc', 'Согласен с замечанием'])).toBe(true); + }); + it('совпадение по нормализации (регистр + лишние пробелы) → true', () => { + expect(answerMatchesOption('floor-escape: OWNER-seal:abc', + ['FLOOR-ESCAPE: owner-seal:abc'])).toBe(true); + }); + it('свободный текст, нет среди ярлыков → false', () => { + expect(answerMatchesOption('FLOOR-ESCAPE: write:c:/x.md', + ['Согласен', 'Держусь контроллера'])).toBe(false); + }); + it('нет опций / не массив → false (свободный ввод)', () => { + expect(answerMatchesOption('что угодно', [])).toBe(false); + expect(answerMatchesOption('что угодно', undefined)).toBe(false); + }); + it('не строка → false', () => { + expect(answerMatchesOption(null, ['x'])).toBe(false); + expect(answerMatchesOption(['a'], ['a'])).toBe(false); + }); + it('ярлык-мусор (null/число) в списке не роняет', () => { + expect(answerMatchesOption('a', [null, 5, 'a'])).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Запустить тест — убедиться, что падает** + +PowerShell: `npx vitest run tools/askuser-answer-parser.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: FAIL — `answerMatchesOption is not a function` / `is not exported`. + +- [ ] **Step 3: Реализовать предикат** + +В `tools/askuser-answer-parser.mjs` сразу ПОСЛЕ функции `normalizeAnswer` (около строки 28) добавить: + +```js +/** + * True если ответ дословно (по нормализации) совпал с ярлыком одной из опций вопроса — + * т.е. это ВЫБОР КНОПКИ. Ярлыки сочиняет контроллер (tool_input.questions[].options[].label), + * а среда может вернуть выбранную кнопку как «ответ» без действия владельца. Поэтому + * доверенную запись (floor_escape / approve_git_operation) из такого ответа НЕ порождаем — + * согласие только из свободного ввода владельца. Прецедент грани — observer-choice-detector + * (exact label match = выбор из опций, иначе свободный Other = владелец рулит сам). + */ +export function answerMatchesOption(answer, optionLabels) { + if (typeof answer !== 'string' || !Array.isArray(optionLabels)) return false; + const a = normalizeAnswer(answer); + if (!a) return false; + return optionLabels.some((l) => typeof l === 'string' && normalizeAnswer(l) === a); +} +``` + +- [ ] **Step 4: Запустить тест — убедиться, что проходит** + +PowerShell: `npx vitest run tools/askuser-answer-parser.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: PASS (включая прежние тесты файла — без регрессии). + +--- + +### Task 2: Guard в writer'е — пропустить обе записи при совпадении с кнопкой + +**Files:** +- Modify: `tools/enforce-askuser-answer-parser.mjs:19` (импорт), `tools/enforce-askuser-answer-parser.mjs:47-54` (цикл по вопросам) +- Test: `tools/enforce-askuser-answer-parser.test.mjs` + +- [ ] **Step 1: Написать падающие тесты** + +В `tools/enforce-askuser-answer-parser.test.mjs` добавить новый describe: + +```js +describe('processEvent — anti-button (HOLE-1 / A): ответ-кнопка не порождает доверенную запись', () => { + it('ответ = ярлык кнопки с FLOOR-ESCAPE → floor_escape НЕ записан', () => { + const dir = tmpRuntimeDir(); + const event = { + session_id: 'sb1', + tool_input: { questions: [{ question: 'q', options: [ + { label: 'FLOOR-ESCAPE: owner-seal:abc' }, { label: 'Согласен с замечанием' }] }] }, + tool_response: { answers: { q: 'FLOOR-ESCAPE: owner-seal:abc' } }, + }; + processEvent(event, { runtimeDir: dir, nowMs: 7 }); + const path = join(dir, 'askuser-decisions-sb1.jsonl'); + expect(existsSync(path)).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }); + + it('ответ = ярлык кнопки с git-командой → approve_git_operation НЕ записан (HOLE-1)', () => { + const dir = tmpRuntimeDir(); + const event = { + session_id: 'sb2', + tool_input: { questions: [{ question: 'q', options: [ + { label: 'git push origin main' }, { label: 'Отмена' }] }] }, + tool_response: { answers: { q: 'git push origin main' } }, + }; + processEvent(event, { runtimeDir: dir, nowMs: 7 }); + const path = join(dir, 'askuser-decisions-sb2.jsonl'); + expect(existsSync(path)).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }); + + it('свободный ответ с FLOOR-ESCAPE (нет среди ярлыков) → floor_escape записан (без регрессии)', () => { + const dir = tmpRuntimeDir(); + const event = { + session_id: 'sb3', + tool_input: { questions: [{ question: 'q', options: [{ label: 'Отмена' }] }] }, + tool_response: { answers: { q: 'да, разрешаю. FLOOR-ESCAPE: write:c:/x.md' } }, + }; + processEvent(event, { runtimeDir: dir, nowMs: 7 }); + const content = readFileSync(join(dir, 'askuser-decisions-sb3.jsonl'), 'utf-8'); + expect(content).toContain('"type":"floor_escape"'); + expect(content).toContain('"action":"write:c:/x.md"'); + rmSync(dir, { recursive: true, force: true }); + }); +}); +``` + +- [ ] **Step 2: Запустить тесты — убедиться, что падают** + +PowerShell: `npx vitest run tools/enforce-askuser-answer-parser.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: FAIL первых двух (запись пишется, файл существует) — guard ещё нет. Третий проходит. + +- [ ] **Step 3: Добавить guard в processEvent** + +В `tools/enforce-askuser-answer-parser.mjs` строку импорта (строка 19) заменить на: + +```js +import { toApprovalRecord, toFloorEscapeRecord, signFloorEscapeRecord, answerMatchesOption } from './askuser-answer-parser.mjs'; +``` + +В цикле `for (const q of questions)` сразу ПОСЛЕ `if (!ans) continue;` (строка 50) вставить: + +```js + // Anti-button (спека §A, HOLE-1): ответ, дословно совпавший с ярлыком контроллерской + // кнопки, доверенную запись НЕ порождает — среда может вернуть кнопку без действия + // владельца. Согласие только из свободного ввода владельца. Покрывает ОБА парсера ниже. + const optionLabels = Array.isArray(q.options) ? q.options.map((o) => o && o.label) : []; + if (answerMatchesOption(ans, optionLabels)) continue; +``` + +- [ ] **Step 4: Запустить тесты — убедиться, что проходят** + +PowerShell: `npx vitest run tools/enforce-askuser-answer-parser.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: PASS все (включая прежние — у них нет `options`, значит трактуются как свободный ввод, записи пишутся как раньше). + +- [ ] **Step 5: Прогнать сводные смежные сюиты — нет регрессии** + +PowerShell: +`npx vitest run tools/askuser-answer-parser.test.mjs tools/enforce-askuser-answer-parser.test.mjs tools/enforce-askuser-answer-parser-floor-escape.test.mjs tools/askuser-answer-parser-floor-escape.test.mjs --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: PASS все. + +--- + +### Task 3: Полный свод + коммит + +**Files:** — + +- [ ] **Step 1: Полный свод tools-сюиты (через PowerShell)** + +PowerShell: `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` +Ожидать: зелёный свод (база 4320 passed, 2 skipped + новые тесты). Если красное — НЕ из-за наших файлов: свериться с pre-existing (memory `feedback-vitest-sentinel-recipe`). + +- [ ] **Step 2: Расписка verify (через PowerShell)** + +PowerShell: `node tools/produce-verify-receipt.mjs` +Ожидать: `signed GREEN` (расписка под staged-diff). Сначала `git add` (Step 3), затем расписка — расписка считает отпечаток staged-diff. + +- [ ] **Step 3: Stage явных путей** + +PowerShell: `git add tools/askuser-answer-parser.mjs tools/askuser-answer-parser.test.mjs tools/enforce-askuser-answer-parser.mjs tools/enforce-askuser-answer-parser.test.mjs docs/superpowers/specs/2026-06-18-consent-forgery-fix-design.md docs/superpowers/plans/2026-06-18-consent-forgery-fix-A-plan.md` +(только наши файлы — чужие `M tools/enforce-*` НЕ трогать). + +- [ ] **Step 4: Сообщение коммита в файл (Write-инструментом)** + +Записать в `.git/CB_MSG.txt`: +``` +fix: anti-button — согласие только из свободного ввода владельца (consent forgery A) + +Ответ AskUserQuestion, дословно совпавший с ярлыком контроллерской кнопки, больше +не порождает доверенную запись (floor_escape И approve_git_operation). Закрывает +наблюдаемую подделку owner-seal и HOLE-1 (git-approval тем же классом). Фундамент — +guard в единственном писателе processEvent + чистый предикат answerMatchesOption. +Спека: specs/2026-06-18-consent-forgery-fix-design.md §A. + +Co-Authored-By: Claude Opus 4.8 +``` + +- [ ] **Step 5: Коммит** + +PowerShell: `git commit -F .git/CB_MSG.txt` +Ожидать: коммит создан. Если пол/гейт заблокировал — СТОП, доложить владельцу (не обходить). + +--- + +## Следующие шаги (отдельный план, после приёмки A) + +- **Часть B** (терминал для тяжёлого + переклассификация heavy-by-разрушительность + `tools/owner-consent.mjs`) — спека §B/§DEC Поза 1. +- **Часть D/KEY** (подпись fail-closed для тяжёлых) — спека §KEY (HOLE-5). +- **Часть C** (анти-диктовка) — требует источник текста контроллера, недоступного парсеру; отдельный дизайн (какой хук видит текст контроллера). +- **GUIDE** (`bags/router-mentor-wall-GUIDE.md`) §«Аварийный выход» — инвертировать рецепт: метку владелец НАБИРАЕТ САМ, не кнопкой. Через claude-md-management/правила-канал. + +## Self-Review + +- **Spec coverage:** §A (оба парсера) → Task 1+2. §CRIT 1 (ярлык+FLOOR-ESCAPE→нет записи) → Task 2 тест 1. §CRIT 2 (ярлык+git→нет записи) → Task 2 тест 2. §CRIT 3 (свободный→записан) → Task 2 тест 3 + прежние. §CRIT 8 (свод зелёный) → Task 3. §B/§KEY/§C — явно вынесены в «Следующие шаги» (не scope этого плана). +- **Placeholder scan:** код во всех шагах полный, команды точные. Нет TBD. +- **Type consistency:** `answerMatchesOption(answer, optionLabels)` — одна сигнатура в Task 1 и Task 2. `normalizeAnswer` переиспользуется (существует). diff --git a/docs/superpowers/specs/2026-06-18-consent-forgery-fix-design.md b/docs/superpowers/specs/2026-06-18-consent-forgery-fix-design.md new file mode 100644 index 0000000..b58a96c --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-consent-forgery-fix-design.md @@ -0,0 +1,248 @@ +# Спека: фикс подделки согласия владельца (consent forgery) — несущая гарантия стены + +**Дата:** 2026-06-18 · **Репозиторий:** claude-brain · **Кодовая фраза:** «роутер-наставник». +**Тип:** machinery fix (enforcement-критический). Правится в ШТАТНОМ (F-K). +**Предшественник:** systematic-debugging Phase 1 (этой сессии) — корень найден и подтверждён. + +## Цель + +Восстановить несущую гарантию стены: артефакт/план помечается «одобрено владельцем» +(owner-seal, escape-грант, благословлённый деплой/коммит) **только если владелец это +осознанно сделал**. Закрыть подделку, при которой контроллер сочиняет ярлык кнопки +AskUserQuestion, среда возвращает его как «ответ» без действия владельца, и система +записывает подписанный грант согласия. + +## Контекст и корень (root cause) {#ROOT} + +**Точка подделки — один писатель, один канал.** Согласие во ВСЕХ точках арбитража +(наставник L1, судья gate1/gate2, gate3, per-action escape, owner-seal) пишется одним +хуком в один файл и читается одними функциями: + +- Единственный писатель: `enforce-askuser-answer-parser.mjs::processEvent` → + `askuser-answer-parser.mjs::toFloorEscapeRecord(answer)` — вынимает метку + `FLOOR-ESCAPE: ` регэкспом **ИЗ ТЕКСТА ОТВЕТА**. +- Канал: `~/.claude/runtime/askuser-decisions-<сессия>.jsonl`. +- Читатели: `escape-grant.mjs::loadFloorEscapes / loadPlanScopedGrants / escapeGrantOpen / + planScopedGrantOpen`; owner-seal — `enforce-judge-gate.mjs::sealTurnProd` → + `escapeGrantOpen(ownerSealAction(hash), …)`. + +**Почему подпись не защищает.** HMAC (`signFloorEscapeRecord`) доказывает, что *отработал +хук с ключом*, а не что *владелец осознанно выбрал*. Подделан именно текст-источник: текст +ответа = ярлык кнопки, который сочиняет контроллер (`tool_input.questions[].options[].label`). + +**Триггер в окружении.** AskUserQuestion может вернуть контроллерскую опцию как «ответ» без +реального действия владельца → парсер подписывает её как грант. + +**Defenses, которые НЕ ловят это сегодня:** `detectOtherSocialEng` тут даже не вызывается +(и ловит лишь «впиши в Other …», а не сочинение ярлыка кнопки); `enforce-runtime-write-deny` +не даёт контроллеру писать канал напрямую — но контроллер пишет его не напрямую, а руками +парсера-хука. + +## Модель доверия {#TM} + +| Актор | Может | Не может | +|---|---|---| +| **Контроллер** (Claude) | сочинять `tool_input.questions[].options[].label`; видеть транскрипт; гейтнутые tool-call'ы | писать `~/.claude/runtime` (deny); читать `~/.claude/runtime` (read-path-deny); держать ключ подписи (keychain, hook-only) | +| **Среда** (хуки) | подписывать гранты ключом; в этом окружении — вернуть выбранную опцию как «ответ» **без** действия владельца | отличить «владелец нажал» от «опция вернулась сама» — НЕ передаётся в событии | +| **Владелец** | набирать свободный текст; работать в своём терминале (контроллер слеп к нему) | — | + +**Вывод о доверии.** Сигнал согласия доверенный ⇔ его содержимое **не мог произвести +контроллер**. Два таких источника: +1. **Свободный текст владельца** (поле «Другое»), которого нет среди ярлыков опций — + при допущении, что среда не фабрикует свободный текст (только выбор из перечисленных опций). +2. **Терминал владельца** — out-of-band, контроллер слеп; несомненный канал. + +**Прецедент в коде** (не выдумано): `observer-choice-detector.mjs::detectAskUserQuestionChoice` +уже различает «дословное совпадение с ярлыком = выбор из опций» против «свободный Other = +владелец рулит сам». Фикс опирается на ту же грань. + +## Класс атаки (что закрываем) {#H} + +Абстрактная классификация (до перечисления дыр): + +1. **Forge-content** — контроллер авторит принятый токен согласия. ← наблюдаемый баг. +2. **Replay** — переиспользовать старый настоящий грант на новое действие/хеш. +3. **Channel-confusion** — выдать кнопку за свободный текст или наоборот. +4. **Harness-fabrication** — среда вернёт текст, который контроллер «посеял», без владельца. +5. **Chokepoint-bypass** — иной писатель/читатель в обход усиленного парсера. +6. **Key-compromise** — контроллер достал ключ подписи (вне scope: pre-existing допущение `resolveReceiptKey`). + +## A. Парсер отвергает согласие-из-кнопки — для ВСЕХ типов доверенных записей {#A} + +> **Уточнено после состязательного аудита 2026-06-18 (HOLE-1, fatal).** Писатель `processEvent` +> порождает в один канал **ДВА** типа доверенных записей разными парсерами: +> `toFloorEscapeRecord` → `floor_escape` И `toApprovalRecord` → `approve_git_operation` +> (`enforce-askuser-answer-parser.mjs:51,53`). Исходная спека усиливала только первый. Это и +> есть «чинить фундамент, не карточки»: контракт ниже общий для писателя, на оба парсера. + +**Контракт (общий, anti-button).** `processEvent` порождает доверенную запись (любого типа: +`floor_escape` ИЛИ `approve_git_operation`) **только если ответ — свободный текст**, т.е. +нормализованный ответ **не совпадает дословно** ни с одним ярлыком опций ЭТОГО вопроса +(`event.tool_input.questions[i].options[].label`). Ответ, совпавший с ярлыком кнопки, доверенную +запись **никогда не порождает**. + +- Реализация: anti-button-фильтр в `processEvent` ПЕРЕД вызовом и `toApprovalRecord`, и + `toFloorEscapeRecord` — единая точка (доступны и `questions[].options`, и сам `ans`). Чистые + парсеры можно оставить как есть; либо принимают `optionLabels` и сами отбрасывают совпадение. +- Поле опций берём из `tool_input.questions[].options[].label` (та же форма, что читают + `observer-choice-detector::detectAskUserQuestionChoice` / `askuser-cosmetic-detector`). +- Сравнение — по нормализации `normalizeAnswer` (lowercase + strip-invisible + collapse-ws), + чтобы кнопка не «проскочила» за счёт регистра/zero-width. +- Edge: вопрос без `options` (чистый свободный ввод) → ответ считается свободным (см. H3 в #AUDIT + — закрывается анти-диктовкой C и/или терминалом B для разрушительных действий, #DEC). +- Edge: multiSelect (ответ-массив) — каждый элемент проверяется против ярлыков; элемент-ярлык + отбрасывается, свободный — проходит. + +**Критерий.** Ответ = ярлык опции с `FLOOR-ESCAPE:` → `floor_escape` НЕ пишется. Ответ = ярлык +опции с git-командой → `approve_git_operation` НЕ пишется (HOLE-1). Свободные ответы обоих видов +(нет среди ярлыков) → пишутся (без регрессии). Тесты RED→GREEN по каждому. + +**Последствие для UX (требует обновления GUIDE, не кода):** прежний рецепт «контроллер кладёт +`FLOOR-ESCAPE:` / git-команду в КНОПКУ» (GUIDE §«Аварийный выход») **инвертируется**: метку/команду +владелец **набирает сам** в поле «Другое»; контроллер в тексте вопроса лишь поясняет, ЧТО набрать +(без диктовки дословной строки — см. C). + +## B. Терминальный канал для тяжёлого согласия {#B} + +> **Переклассифицировано после аудита (HOLE-3/HOLE-4, heavy).** «Тяжёлое» определяется +> **разрушительностью действия, а не префиксом строки.** В тяжёлый класс (требует терминал B, +> не только эвристику C) входят: +> - снятие/обход стены: `plan-done` (`enforce-supreme-gate.mjs::PLAN_FINISH_ACTION`), +> `gate3-arb:accept:*` / `gate3-arb:continue:*` (`enforce-gate3-loop.mjs`); +> - печать одобрения: `owner-seal:`; +> - деплой/коммит: `ops-runbook:`, `commit:`; +> - разрушительный `bash:` / `powershell:` (rm-rf / force-push / migrate:fresh / drop / eval — +> тот же «ядерный» список, что per-command escape в D1). +> +> Прочие лёгкие escape (`write:<путь>`, безопасный `bash:`, `skill:`) — путь A+C достаточен. + +**Контракт.** Для тяжёлых согласий (список выше) — несомненный канал: владелец запускает в СВОЁМ +терминале маленький скрипт `tools/owner-consent.mjs `, который подписывает грант ключом +(`resolveReceiptKey`) и дописывает его в канал. Контроллер подписать не может (ключ ему недоступен +— допущение #TM). Читатели тяжёлых действий принимают грант **только при валидной подписи** +(fail-closed, см. #KEY) — иначе терминальная гарантия испаряется при недоступном keychain (HOLE-5). + +- Скрипт тотально-безопасен: печатает, что подписал; не трогает ничего, кроме канала грантов. +- Канон `` — тот же, что у `escape-grant.mjs::canonicalAction` / `ownerSealAction` + (`owner-seal:` / `ops-runbook:` / `commit:`), чтобы читатели сматчили. +- Грант из терминала неотличим по форме от грантa-из-парсера (тот же `{type:'floor_escape', + action, ts, sig}`) — но порождён действием владельца, не контроллера. + +**Критерий.** Запуск скрипта владельцем пишет валидно-подписанный грант, который открывает +ровно `` (тест на сматч с `escapeGrantOpen` / `ownerSealAction`). + +## C. Усиление анти-диктовки свободного ввода {#C} + +**Контракт.** Когда согласие приходит свободным текстом (путь A), убедиться, что контроллер +не продиктовал дословную строку: + +- `detectOtherSocialEng` вызывается на тексте контроллера последних ходов **перед** принятием + свободного `floor_escape` (сейчас не вызывается вовсе); матч → грант не пишется + видимый warn. +- Расширить паттерны: контроллер, проговоривший дословно `FLOOR-ESCAPE: ` в своём тексте, + ловится (сейчас покрыты лишь «впиши в other»). +- (Опц., сильнейший режим) challenge-нонс: стена генерит нонс в защищённый runtime, владелец + читает его out-of-band (терминал) и вписывает — закрывает H4 полностью. Включается решением #DEC. + +**Критерий.** Контроллер, продиктовавший дословный `FLOOR-ESCAPE: …` в своём тексте, не открывает +грант, даже если строка вернулась свободным ответом. + +## D. Подпись fail-closed для тяжёлых действий {#KEY} + +> **Добавлено после аудита (HOLE-5, heavy).** Сейчас `loadFloorEscapes`/`loadPlanScopedGrants` +> проверяют подпись только при доступном ключе (`escape-grant.mjs:103` — `key ? filter : records`), +> а `loadApprovedGitOps` не проверяет подписи вовсе. В окружении без keychain (реальный сценарий — +> degraded-пути судьи, TLS-задержки) подпись отключается → неподписанный форж принимается. + +**Контракт.** Для **тяжёлых** действий (#B) грант принимается **только при валидной подписи**; +ключ недоступен → грант тяжёлого действия НЕ открывается (fail-closed), читателю возвращается +видимая причина «ключ согласия недоступен — подтвердите в терминале при доступном keychain». +Для **лёгких** действий сохраняется текущее поведение (key-gated, fail-open backstop content-floor) +— осознанный баланс, чтобы недоступность keychain не кирпичила всю сессию. + +**Критерий.** Тяжёлый action + нет ключа → не открыт (тест). Лёгкий action + нет ключа → как сегодня. + +## Точка решения владельца (на приёмке) {#DEC} + +Развилка — насколько строго. Не угадываю, выбираешь ты: + +- **Поза 1 — «а+б» (рекомендую).** Обычные escape (`write:`/`bash:`/`skill:`) — свободный ввод + владельца ОК (A+C). Тяжёлые (`owner-seal`/`ops-runbook`/`commit`) — **обязательно терминал** (B). + Баланс: лёгкое — быстро через чат, несущее — несомненно через терминал. +- **Поза 2 — «только терминал».** ВСЁ согласие — только из терминала (B); AskUser вообще не + порождает грант (A сводится к «никогда»). Надёжнее на класс H4, но больше ручной работы тебе. + +Различие в коде невелико (порог «какие action-префиксы требуют терминал»); спека покрывает обе. + +**РЕШЕНО (владелец, 2026-06-18): Поза 1 — «а+б».** Лёгкие escape (`write:`/безопасный `bash:`/ +`skill:`) — свободный ввод владельца в чате (A+C). Тяжёлые (owner-seal/ops-runbook/commit/ +plan-done/gate3-arb/разрушительный bash) — только терминал владельца (B), подпись fail-closed (#KEY). + +## Состязательный аудит — дыры и ответы {#AUDIT} + +Раунд 1 проведён независимым аудитором 2026-06-18; находки верифицированы по коду (file:line) и +учтены в #A/#B/#KEY выше. Классы атаки — из #H. + +- **HOLE-1 (forge-content, FATAL) — ВЕРИФИЦИРОВАНО, закрыто #A.** Второй парсер writer'а + `toApprovalRecord` пишет `approve_git_operation` из текста ответа без anti-button-проверки + (`enforce-askuser-answer-parser.mjs:51`); читатель `shell-content-rules.mjs::loadApprovedGitOps` + (288-300) принимает БЕЗ подписи; `decide` открывает `git push` на матче (364-366). Контроллер + сочиняет кнопку-команду → среда возвращает → git-гейт открыт. Исходная спека усиливала только + floor_escape. **#A расширен на оба парсера** — это и есть «фундамент». +- **HOLE-3 (harness-fabrication / вопрос без options, heavy) — закрыто #B+#C+#DEC.** Контроллер + ставит вопрос без `options` и сеет свободную строку. A не ловит (нет ярлыков). Закрывают: C + (анти-диктовка) для лёгких; **терминал B (fail-closed подпись)** для разрушительных. Класс + «тяжёлое» переопределён по разрушительности (#B), а не по префиксу. +- **HOLE-4 (тело-агностичные `plan-done`/`gate3-arb`, heavy) — закрыто #B.** Эти метки снимают/ + обходят стену и не привязаны к hash тела → отнесены в тяжёлый класс (терминал B). +- **HOLE-5 (key-absence fail-open, heavy) — закрыто #KEY.** `escape-grant.mjs:103` отключает + проверку подписи без ключа; git-канал не проверяет подпись вовсе. #KEY делает подпись + обязательной (fail-closed) для тяжёлых действий. +- **HOLE-2 (`approve_workflow_script` reader-сирота, light) — НЕ дыра сейчас.** Живого писателя в + коде нет (только читатель `enforce-workflow-gate.mjs:99`) → канал fail-closed (скрипт не + одобрить). Действие: при реализации проверить и либо ретайрить читателя, либо подвести под #A. +- **HOLE-6 (`annotations.notes`, light) — латентно.** `processEvent` floor_escape берёт строго из + `answers`, не из notes → писательского пути нет. Если среда начнёт класть выбор в notes — A + слеп к полю. Зафиксировать в #TM как допущение, пере-аудит при изменении формы события. +- **H-replay — без регрессии.** owner-seal привязан к `ownerSealAction(hash)` тела; per-action — + точный `canonicalAction` + окно 5мин + one-shot consume; plan-scoped — `planScopedGrantOpen`. + Подмена тела ломает сматч. `plan-done`/`gate3-arb` тело-агностичны by design → закрыты #B. +- **H-key-compromise — вне scope.** Pre-existing допущение «контроллер не держит ключ» + (`resolveReceiptKey`, keychain). Падёт допущение — падёт текущая модель; не регрессируем. + +**Полная карта потребителей согласия (доказательство «один усиленный writer покрывает всех»).** +Все читают `~/.claude/runtime/askuser-decisions-.jsonl`; писатель один — `processEvent`: + +| Потребитель | Файл | Тип записи | Усилен | +|---|---|---|---| +| owner-seal (судья) | `enforce-judge-gate.mjs` | floor_escape | #A | +| супрем-гейт / `plan-done` | `enforce-supreme-gate.mjs` | floor_escape | #A+#B | +| пол | `floor-decide.mjs` | floor_escape | #A | +| mcp/egress | `enforce-mcp-classification.mjs` | floor_escape | #A | +| criterion-gate | `enforce-criterion-gate.mjs` | floor_escape | #A | +| verify-gate | `enforce-verify-gate.mjs` | floor_escape | #A | +| gate3-loop арбитраж | `enforce-gate3-loop.mjs` | floor_escape (`gate3-arb:*`) | #A+#B | +| ops-runbook деплой | `blessed-ops.mjs` | floor_escape (`ops-runbook:`) | #A+#B+#KEY | +| commit-канал | `commit-grant.mjs` | floor_escape (`commit:`) | #A+#B+#KEY | +| **git-approval** | **`shell-content-rules.mjs`** | **`approve_git_operation`** | **#A (HOLE-1)+#KEY** | +| workflow-script | `enforce-workflow-gate.mjs` | `approve_workflow_script` | reader-сирота (HOLE-2) | + +## Критерии приёмки {#CRIT} + +1. Ответ-ярлык-кнопки с `FLOOR-ESCAPE:` → `floor_escape` НЕ записан (A). +2. Ответ-ярлык-кнопки с git-командой → `approve_git_operation` НЕ записан (A / HOLE-1). +3. Свободные ответы обоих видов (нет среди ярлыков) → записаны (A, без регрессии лёгких escape/git). +4. Контроллер продиктовал дословную метку → грант не открыт (C). +5. Терминальный скрипт владельца пишет валидно-подписанный грант, открывающий ровно `` (B). +6. Тяжёлое действие (owner-seal/ops-runbook/commit/plan-done/gate3-arb/разрушит. bash) открывается + ТОЛЬКО валидно-подписанным грантом; нет ключа → не открыто (B+KEY). Лёгкое без ключа — как сегодня. +7. owner-seal/ops-runbook/commit в Позе 1 — только терминальный грант, не AskUser-ответ. +8. Полный свод tools-сюиты зелёный (через PowerShell); существующие escape/seal-тесты не сломаны + (где ломаются по смене контракта — переписаны под новый, осознанно). + +```verified-context-json +[ + {"id":"VC-1","kind":"EXTRACTED","ref":"tools/askuser-answer-parser.mjs","anchor":"toFloorEscapeRecord"}, + {"id":"VC-2","kind":"EXTRACTED","ref":"tools/observer-choice-detector.mjs","anchor":"Only an exact label match counts"}, + {"id":"VC-3","kind":"EXTRACTED","ref":"tools/cost-pricing.mjs","anchor":"export const PRICING = Object.freeze("} +] +``` diff --git a/tools/askuser-answer-parser.mjs b/tools/askuser-answer-parser.mjs index 18842a1..addadd0 100644 --- a/tools/askuser-answer-parser.mjs +++ b/tools/askuser-answer-parser.mjs @@ -27,6 +27,21 @@ export function normalizeAnswer(s) { return stripInvisible(s).toLowerCase().split(/\s+/).filter(Boolean).join(' ').trim(); } +/** + * True если ответ дословно (по нормализации) совпал с ярлыком одной из опций вопроса — + * т.е. это ВЫБОР КНОПКИ. Ярлыки сочиняет контроллер (tool_input.questions[].options[].label), + * а среда может вернуть выбранную кнопку как «ответ» без действия владельца. Поэтому + * доверенную запись (floor_escape / approve_git_operation) из такого ответа НЕ порождаем — + * согласие только из свободного ввода владельца. Прецедент грани — observer-choice-detector + * (exact label match = выбор из опций, иначе свободный Other = владелец рулит сам). + */ +export function answerMatchesOption(answer, optionLabels) { + if (typeof answer !== 'string' || !Array.isArray(optionLabels)) return false; + const a = normalizeAnswer(answer); + if (!a) return false; + return optionLabels.some((l) => typeof l === 'string' && normalizeAnswer(l) === a); +} + /** Normalize a shell command for approval comparison (E34): collapse ws, keep case. */ export function normalizeCommand(cmd) { if (typeof cmd !== 'string') return ''; diff --git a/tools/askuser-answer-parser.test.mjs b/tools/askuser-answer-parser.test.mjs index 6d91d90..3d578ec 100644 --- a/tools/askuser-answer-parser.test.mjs +++ b/tools/askuser-answer-parser.test.mjs @@ -22,6 +22,7 @@ import { detectOtherSocialEng, buildApprovalRecord, toApprovalRecord, + answerMatchesOption, } from './askuser-answer-parser.mjs'; import { signApprovalRecord, verifyApprovalRecord } from './askuser-answer-parser.mjs'; @@ -325,3 +326,29 @@ describe('toFloorEscapeRecord — skill-канон (✅O13)', () => { expect(r.action).toBe('write:c:/Моя/Path.md'); }); }); + +describe('answerMatchesOption — ответ совпал с ярлыком кнопки (anti-button)', () => { + it('дословное совпадение с ярлыком → true (это выбор кнопки)', () => { + expect(answerMatchesOption('FLOOR-ESCAPE: owner-seal:abc', + ['FLOOR-ESCAPE: owner-seal:abc', 'Согласен с замечанием'])).toBe(true); + }); + it('совпадение по нормализации (регистр + лишние пробелы) → true', () => { + expect(answerMatchesOption('floor-escape: OWNER-seal:abc', + ['FLOOR-ESCAPE: owner-seal:abc'])).toBe(true); + }); + it('свободный текст, нет среди ярлыков → false', () => { + expect(answerMatchesOption('FLOOR-ESCAPE: write:c:/x.md', + ['Согласен', 'Держусь контроллера'])).toBe(false); + }); + it('нет опций / не массив → false (свободный ввод)', () => { + expect(answerMatchesOption('что угодно', [])).toBe(false); + expect(answerMatchesOption('что угодно', undefined)).toBe(false); + }); + it('не строка → false', () => { + expect(answerMatchesOption(null, ['x'])).toBe(false); + expect(answerMatchesOption(['a'], ['a'])).toBe(false); + }); + it('ярлык-мусор (null/число) в списке не роняет', () => { + expect(answerMatchesOption('a', [null, 5, 'a'])).toBe(true); + }); +}); diff --git a/tools/enforce-askuser-answer-parser.mjs b/tools/enforce-askuser-answer-parser.mjs index d056fdf..74b9988 100644 --- a/tools/enforce-askuser-answer-parser.mjs +++ b/tools/enforce-askuser-answer-parser.mjs @@ -16,7 +16,7 @@ import { appendFileSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; -import { toApprovalRecord, toFloorEscapeRecord, signFloorEscapeRecord } from './askuser-answer-parser.mjs'; +import { toApprovalRecord, toFloorEscapeRecord, signFloorEscapeRecord, answerMatchesOption } from './askuser-answer-parser.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; /** @@ -48,6 +48,11 @@ export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceip if (!q || !q.question) continue; const ans = answers[q.question]; if (!ans) continue; + // Anti-button (спека §A, HOLE-1): ответ, дословно совпавший с ярлыком контроллерской + // кнопки, доверенную запись НЕ порождает — среда может вернуть кнопку без действия + // владельца. Согласие только из свободного ввода владельца. Покрывает ОБА парсера ниже. + const optionLabels = Array.isArray(q.options) ? q.options.map((o) => o && o.label) : []; + if (answerMatchesOption(ans, optionLabels)) continue; const rec = toApprovalRecord(ans, { question: q.question, nowMs }); // M6 FIX-5: подписываем ТОЛЬКО floor_escape (§2.2 — approve_git_operation не трогаем). let esc = toFloorEscapeRecord(ans, { nowMs }); diff --git a/tools/enforce-askuser-answer-parser.test.mjs b/tools/enforce-askuser-answer-parser.test.mjs index 07b8d13..3889225 100644 --- a/tools/enforce-askuser-answer-parser.test.mjs +++ b/tools/enforce-askuser-answer-parser.test.mjs @@ -107,3 +107,47 @@ describe('enforce-askuser-answer-parser floor_escape (M6)', () => { rmSync(dir, { recursive: true, force: true }); }); }); + +describe('processEvent — anti-button (HOLE-1 / A): ответ-кнопка не порождает доверенную запись', () => { + it('ответ = ярлык кнопки с FLOOR-ESCAPE → floor_escape НЕ записан', () => { + const dir = tmpRuntimeDir(); + const event = { + session_id: 'sb1', + tool_input: { questions: [{ question: 'q', options: [ + { label: 'FLOOR-ESCAPE: owner-seal:abc' }, { label: 'Согласен с замечанием' }] }] }, + tool_response: { answers: { q: 'FLOOR-ESCAPE: owner-seal:abc' } }, + }; + processEvent(event, { runtimeDir: dir, nowMs: 7 }); + const path = join(dir, 'askuser-decisions-sb1.jsonl'); + expect(existsSync(path)).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }); + + it('ответ = ярлык кнопки с git-командой → approve_git_operation НЕ записан (HOLE-1)', () => { + const dir = tmpRuntimeDir(); + const event = { + session_id: 'sb2', + tool_input: { questions: [{ question: 'q', options: [ + { label: 'git push origin main' }, { label: 'Отмена' }] }] }, + tool_response: { answers: { q: 'git push origin main' } }, + }; + processEvent(event, { runtimeDir: dir, nowMs: 7 }); + const path = join(dir, 'askuser-decisions-sb2.jsonl'); + expect(existsSync(path)).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }); + + it('свободный ответ с FLOOR-ESCAPE (нет среди ярлыков) → floor_escape записан (без регрессии)', () => { + const dir = tmpRuntimeDir(); + const event = { + session_id: 'sb3', + tool_input: { questions: [{ question: 'q', options: [{ label: 'Отмена' }] }] }, + tool_response: { answers: { q: 'да, разрешаю. FLOOR-ESCAPE: write:c:/x.md' } }, + }; + processEvent(event, { runtimeDir: dir, nowMs: 7 }); + const content = readFileSync(join(dir, 'askuser-decisions-sb3.jsonl'), 'utf-8'); + expect(content).toContain('"type":"floor_escape"'); + expect(content).toContain('"action":"write:c:/x.md"'); + rmSync(dir, { recursive: true, force: true }); + }); +});