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 <noreply@anthropic.com>
This commit is contained in:
@@ -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-<sess>.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 <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
- [ ] **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` переиспользуется (существует).
|
||||
@@ -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: <action>` регэкспом **ИЗ ТЕКСТА ОТВЕТА**.
|
||||
- Канал: `~/.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:<hash>`;
|
||||
> - деплой/коммит: `ops-runbook:<hash>`, `commit:<hash>`;
|
||||
> - разрушительный `bash:` / `powershell:` (rm-rf / force-push / migrate:fresh / drop / eval —
|
||||
> тот же «ядерный» список, что per-command escape в D1).
|
||||
>
|
||||
> Прочие лёгкие escape (`write:<путь>`, безопасный `bash:`, `skill:`) — путь A+C достаточен.
|
||||
|
||||
**Контракт.** Для тяжёлых согласий (список выше) — несомненный канал: владелец запускает в СВОЁМ
|
||||
терминале маленький скрипт `tools/owner-consent.mjs <action>`, который подписывает грант ключом
|
||||
(`resolveReceiptKey`) и дописывает его в канал. Контроллер подписать не может (ключ ему недоступен
|
||||
— допущение #TM). Читатели тяжёлых действий принимают грант **только при валидной подписи**
|
||||
(fail-closed, см. #KEY) — иначе терминальная гарантия испаряется при недоступном keychain (HOLE-5).
|
||||
|
||||
- Скрипт тотально-безопасен: печатает, что подписал; не трогает ничего, кроме канала грантов.
|
||||
- Канон `<action>` — тот же, что у `escape-grant.mjs::canonicalAction` / `ownerSealAction`
|
||||
(`owner-seal:<hash>` / `ops-runbook:<hash>` / `commit:<hash>`), чтобы читатели сматчили.
|
||||
- Грант из терминала неотличим по форме от грантa-из-парсера (тот же `{type:'floor_escape',
|
||||
action, ts, sig}`) — но порождён действием владельца, не контроллера.
|
||||
|
||||
**Критерий.** Запуск скрипта владельцем пишет валидно-подписанный грант, который открывает
|
||||
ровно `<action>` (тест на сматч с `escapeGrantOpen` / `ownerSealAction`).
|
||||
|
||||
## C. Усиление анти-диктовки свободного ввода {#C}
|
||||
|
||||
**Контракт.** Когда согласие приходит свободным текстом (путь A), убедиться, что контроллер
|
||||
не продиктовал дословную строку:
|
||||
|
||||
- `detectOtherSocialEng` вызывается на тексте контроллера последних ходов **перед** принятием
|
||||
свободного `floor_escape` (сейчас не вызывается вовсе); матч → грант не пишется + видимый warn.
|
||||
- Расширить паттерны: контроллер, проговоривший дословно `FLOOR-ESCAPE: <action>` в своём тексте,
|
||||
ловится (сейчас покрыты лишь «впиши в 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-<sess>.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. Терминальный скрипт владельца пишет валидно-подписанный грант, открывающий ровно `<action>` (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("}
|
||||
]
|
||||
```
|
||||
@@ -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 '';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user