From 0d31e62248b0f8940a00b6c7c2f7da10bf22a5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 18 Jun 2026 19:54:25 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D0=BD=D1=82=D0=B8-=D0=B4=D0=B8?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=D0=BA=D0=B0=20=D1=81=D0=B2=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20floor=5Fescape=20(consen?= =?UTF-8?q?t=20forgery=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Свободный floor_escape (лёгкий escape в чате) НЕ доверяется, если контроллер в своём тексте текущего хода инструктировал владельца вписать метку. processEvent читает текст контроллера из транскрипта хода (readTranscript + lastAssistantText, переиспользован), один раз на событие зовёт detectOtherSocialEng (раньше был мёртвой проводкой — не вызывался нигде); flagged → floor_escape не пишется. Паттерны detectOtherSocialEng усилены императивной диктовкой метки FLOOR-ESCAPE (глагол рядом; простое упоминание концепта не флагуется). fail-open observability — сбой чтения транскрипта → как раньше. Применяется к floor_escape (git-approval закрыт #A). Завершает кодовую часть эпика (A + B1-B5 + C). Спека §C. Свод 4350 зелёный. Co-Authored-By: Claude Opus 4.8 --- ...onsent-forgery-fix-C-antidictation-plan.md | 174 ++++++++++++++++++ .../2026-06-18-consent-forgery-fix-design.md | 18 +- tools/askuser-answer-parser.mjs | 4 + tools/askuser-answer-parser.test.mjs | 9 + tools/enforce-askuser-answer-parser.mjs | 16 +- tools/enforce-askuser-answer-parser.test.mjs | 20 ++ 6 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-18-consent-forgery-fix-C-antidictation-plan.md diff --git a/docs/superpowers/plans/2026-06-18-consent-forgery-fix-C-antidictation-plan.md b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-C-antidictation-plan.md new file mode 100644 index 0000000..754bd14 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-consent-forgery-fix-C-antidictation-plan.md @@ -0,0 +1,174 @@ +# Consent-forgery fix — Часть C: анти-диктовка свободного ввода Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans / test-driven-development. + +**Goal:** Свободный `floor_escape` (лёгкий escape в чате) НЕ доверяется, если контроллер в своём тексте текущего хода инструктировал владельца вписать метку (`detectOtherSocialEng`). Закрывает блатантную диктовку. + +**Architecture:** Источник текста контроллера — транскрипт текущего хода (`readTranscript(event.transcript_path)` → `lastTurnControllerText`, текст assistant-сообщений хода; хук читает транскрипт штатно, как observer). В `processEvent` ОДИН раз на событие: `detectOtherSocialEng(controllerText)` → flagged → свободный `floor_escape` не пишется. Применяется ТОЛЬКО к `floor_escape` (git-approval закрыт anti-button #A). `processEvent` fail-open — ошибка чтения транскрипта → как сегодня. `controllerText` инъектируем для тестов. + +**Tech Stack:** Node ESM, vitest. Тесты — через PowerShell. + +**Спека:** §C. Опирается на A (anti-button в том же `processEvent`). + +**Режим:** ШТАТНЫЙ. Урок [[feedback-edit-active-hook-code-atomically]]: правку `processEvent` (активный PostToolUse-хук) делать согласованно — импорты и использование вместе, чтобы модуль не был «полусломан». + +--- + +### Task 1: `lastTurnControllerText` (текст контроллера текущего хода) + +**Files:** +- Modify: `tools/enforce-hook-helpers.mjs` (новый export рядом с `lastUserPromptText`) +- Test: `tools/enforce-hook-helpers.test.mjs` (или новый файл, если нет) + +- [ ] **Step 1: RED — тест** + +```js +import { lastTurnControllerText } from './enforce-hook-helpers.mjs'; +describe('lastTurnControllerText — текст ассистента текущего хода', () => { + it('возвращает assistant-текст текущего хода, исключая прошлые ходы', () => { + const entries = [ + { message: { role: 'user', content: 'старый' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'старый ответ' }] } }, + { message: { role: 'user', content: 'текущий вопрос' } }, + { message: { role: 'assistant', content: [{ type: 'text', text: 'впиши в Other метку' }] } }, + ]; + const t = lastTurnControllerText(entries); + expect(t).toContain('впиши в Other'); + expect(t).not.toContain('старый ответ'); + }); + it('пусто / не массив → пустая строка', () => { + expect(lastTurnControllerText(null)).toBe(''); + expect(lastTurnControllerText([])).toBe(''); + }); +}); +``` + +- [ ] **Step 2: RED-прогон** → FAIL (`lastTurnControllerText is not a function`). + +- [ ] **Step 3: GREEN — реализация** (после `lastUserPromptText` в `enforce-hook-helpers.mjs`): + +```js +/** Текст assistant-сообщений ТЕКУЩЕГО хода (от последнего user-сообщения). Для анти-диктовки (C). */ +export function lastTurnControllerText(entries) { + const turn = lastTurnEntries(entries); + if (!Array.isArray(turn)) return ''; + const out = []; + for (const e of turn) { + if (!e || !e.message || e.message.role !== 'assistant') continue; + const c = e.message.content; + if (typeof c === 'string') out.push(c); + else if (Array.isArray(c)) for (const b of c) if (b && b.type === 'text' && typeof b.text === 'string') out.push(b.text); + } + return out.join('\n'); +} +``` + +- [ ] **Step 4: GREEN-прогон** → PASS. + +--- + +### Task 2: усилить detectOtherSocialEng (диктовка метки) + +**Files:** +- Modify: `tools/askuser-answer-parser.mjs` (`OTHER_SOCIAL_ENG_PATTERNS`) +- Test: `tools/askuser-answer-parser.test.mjs` + +- [ ] **Step 1: RED — тест** (в существующий detectOtherSocialEng describe): + +```js + it('flags imperative dictation of FLOOR-ESCAPE marker', () => { + expect(detectOtherSocialEng('набери FLOOR-ESCAPE: write:c:/x.md').flagged).toBe(true); + expect(detectOtherSocialEng('paste FLOOR-ESCAPE: bash:rm -rf x').flagged).toBe(true); + }); + it('does not flag mere mention of FLOOR-ESCAPE concept', () => { + expect(detectOtherSocialEng('это даётся через FLOOR-ESCAPE из терминала').flagged).toBe(false); + }); +``` + +- [ ] **Step 2: RED-прогон** → FAIL (первый кейс не ловится). + +- [ ] **Step 3: GREEN** — добавить в `OTHER_SOCIAL_ENG_PATTERNS` (точные, с императивным глаголом рядом): + +```js + /(?:набери|впиши|вставь|скопируй|введи|напечатай)[^.\n]{0,40}floor-escape/iu, + /(?:type|paste|enter|copy)[^.\n]{0,40}floor-escape/iu, +``` + +- [ ] **Step 4: GREEN-прогон** → PASS (вкл. «mere mention» = false: нет императива перед floor-escape). + +--- + +### Task 3: wire C в processEvent + +**Files:** +- Modify: `tools/enforce-askuser-answer-parser.mjs` (импорты + цикл) +- Test: `tools/enforce-askuser-answer-parser.test.mjs` + +- [ ] **Step 1: RED — тест** + +```js +describe('processEvent — anti-dictation (C): диктованный свободный floor_escape не пишется', () => { + it('controllerText flagged → floor_escape НЕ записан', () => { + const dir = tmpRuntimeDir(); + const event = { session_id: 'cd1', tool_input: { questions: [{ question: 'q' }] }, + tool_response: { answers: { q: 'да FLOOR-ESCAPE: write:c:/x.md' } } }; + processEvent(event, { runtimeDir: dir, nowMs: 7, controllerText: 'впиши в Other: FLOOR-ESCAPE: write:c:/x.md' }); + expect(existsSync(join(dir, 'askuser-decisions-cd1.jsonl'))).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }); + it('controllerText чистый → floor_escape записан (без регрессии)', () => { + const dir = tmpRuntimeDir(); + const event = { session_id: 'cd2', tool_input: { questions: [{ question: 'q' }] }, + tool_response: { answers: { q: 'да FLOOR-ESCAPE: write:c:/x.md' } } }; + processEvent(event, { runtimeDir: dir, nowMs: 7, controllerText: 'разрешаешь правку файла?' }); + const content = readFileSync(join(dir, 'askuser-decisions-cd2.jsonl'), 'utf-8'); + expect(content).toContain('"type":"floor_escape"'); + rmSync(dir, { recursive: true, force: true }); + }); +}); +``` + +- [ ] **Step 2: RED-прогон** → FAIL первого (диктованный пишется — gating нет). + +- [ ] **Step 3: GREEN** — в `tools/enforce-askuser-answer-parser.mjs`: + +Импорт detectOtherSocialEng (к существующему импорту из `./askuser-answer-parser.mjs`) + новый импорт: +```js +import { readTranscript, lastTurnControllerText } from './enforce-hook-helpers.mjs'; +``` +Сигнатуру processEvent дополнить `controllerText`: +```js +export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceiptKey, fsImpl = { appendFileSync, mkdirSync }, controllerText } = {}) { +``` +ПОСЛЕ резолва `key`, ДО цикла: +```js + // C (anti-dictation): текст контроллера текущего хода → детект «впиши метку». flagged → + // свободный floor_escape не доверяем. fail-open (ошибка чтения транскрипта → не флагуем). + let dictation = { flagged: false }; + try { + const ct = typeof controllerText === 'string' ? controllerText + : lastTurnControllerText(readTranscript(event && event.transcript_path)); + dictation = detectOtherSocialEng(ct); + } catch { dictation = { flagged: false }; } +``` +В цикле, после `let esc = toFloorEscapeRecord(ans, { nowMs });`: +```js + if (esc && dictation.flagged) esc = null; // C: контроллер диктовал метку → грант не пишем +``` + +- [ ] **Step 4: GREEN-прогон** → PASS. + +--- + +### Task 4: полный свод + коммит + +- [ ] `npx vitest run --config vitest.config.tools.mjs --no-file-parallelism` → зелёный (4346 + новые). +- [ ] `git add` (enforce-hook-helpers.mjs + test, askuser-answer-parser.mjs + test, enforce-askuser-answer-parser.mjs + test, спека, план) → receipt → `.git/CB_MSG.txt` → `git commit -F`. + +--- + +## Self-Review + +- **Spec coverage:** §C (detectOtherSocialEng на тексте контроллера, источник = транскрипт, flagged → не пишем) → Task 1-3. §C-критерий → Task 3. +- **Регрессия:** A (anti-button) + прежние processEvent-тесты — controllerText по умолчанию из транскрипта; в старых тестах нет transcript_path → readTranscript([]) → '' → не flagged → пишет как раньше. Проверить полным сводом. +- **Урок atomic-edit:** processEvent правится согласованно (импорты+использование в координированных правках, функции уже существуют к моменту импорта). 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 index b58a96c..cdcd716 100644 --- a/docs/superpowers/specs/2026-06-18-consent-forgery-fix-design.md +++ b/docs/superpowers/specs/2026-06-18-consent-forgery-fix-design.md @@ -143,8 +143,22 @@ AskUserQuestion, среда возвращает его как «ответ» б - (Опц., сильнейший режим) challenge-нонс: стена генерит нонс в защищённый runtime, владелец читает его out-of-band (терминал) и вписывает — закрывает H4 полностью. Включается решением #DEC. -**Критерий.** Контроллер, продиктовавший дословный `FLOOR-ESCAPE: …` в своём тексте, не открывает -грант, даже если строка вернулась свободным ответом. +**Механизм (реализация).** Источник «текста контроллера» — транскрипт текущего хода: +`readTranscript(event.transcript_path)` → `lastTurnControllerText` (текст assistant-сообщений хода, +который выдал AskUserQuestion; хук читает транскрипт штатно — как observer/rationalization-audit). +В `processEvent` ОДИН раз на событие: `detectOtherSocialEng(controllerText)` → flagged → свободный +`floor_escape` НЕ пишется (консервативный отказ; `controllerText` инъектируем для тестов). Применяется +ТОЛЬКО к `floor_escape` (escape-согласие); git-approval уже закрыт anti-button (#A). `processEvent` — +fail-open observability: ошибка чтения транскрипта → как сегодня (грант пишется), безопасность не хуже A. + +**UX-натяжение (документировать).** Владелец-непрограммист сам канон-строку `FLOOR-ESCAPE: ` +не сочинит; если контроллер её диктует — это и есть детектируемая диктовка. Практический путь для +лёгких escape при детекте — терминал владельца (#B), как и для тяжёлых. C ловит БЛАТАНТНУЮ +соц-инженерию (явная инструкция «впиши метку»), остаточная тонкая диктовка лёгких — принятый риск +(лёгкое = низкая цена; тяжёлое терминал-only). + +**Критерий.** Контроллер, инструктирующий вписать метку (detectOtherSocialEng-паттерн) в своём тексте +хода, не открывает свободный `floor_escape`, даже если строка вернулась ответом. ## D. Подпись fail-closed для тяжёлых действий {#KEY} diff --git a/tools/askuser-answer-parser.mjs b/tools/askuser-answer-parser.mjs index addadd0..7564804 100644 --- a/tools/askuser-answer-parser.mjs +++ b/tools/askuser-answer-parser.mjs @@ -113,6 +113,10 @@ const OTHER_SOCIAL_ENG_PATTERNS = [ /в\s+поле\s+["«]?другое["»]?/iu, /нажми\s+["«]?другое["»]?\s+и\s+впиши/iu, /укажи\s+в\s+графе\s+["«]?другое["»]?/iu, + // C (анти-диктовка): императивная диктовка метки FLOOR-ESCAPE (глагол рядом с меткой). + // НЕ ловит простое упоминание концепта (нет императива перед floor-escape). + /(?:набери|впиши|вставь|скопируй|введи|напечатай)[^.\n]{0,40}floor-escape/iu, + /(?:type|paste|enter|copy)[^.\n]{0,40}floor-escape/iu, ]; /** diff --git a/tools/askuser-answer-parser.test.mjs b/tools/askuser-answer-parser.test.mjs index 3d578ec..75b5744 100644 --- a/tools/askuser-answer-parser.test.mjs +++ b/tools/askuser-answer-parser.test.mjs @@ -248,6 +248,15 @@ describe('askuser-answer-parser / detectOtherSocialEng (E29 + v4.0 RU)', () => { expect(detectOtherSocialEng('выбери подходящий вариант').flagged).toBe(false); }); + it('flags imperative dictation of FLOOR-ESCAPE marker (C)', () => { + expect(detectOtherSocialEng('набери FLOOR-ESCAPE: write:c:/x.md').flagged).toBe(true); + expect(detectOtherSocialEng('paste FLOOR-ESCAPE: bash:rm -rf x').flagged).toBe(true); + }); + + it('does not flag mere mention of FLOOR-ESCAPE concept (C)', () => { + expect(detectOtherSocialEng('это даётся через FLOOR-ESCAPE из терминала').flagged).toBe(false); + }); + it('handles non-string', () => { expect(detectOtherSocialEng(null).flagged).toBe(false); }); diff --git a/tools/enforce-askuser-answer-parser.mjs b/tools/enforce-askuser-answer-parser.mjs index 74b9988..bf9f6be 100644 --- a/tools/enforce-askuser-answer-parser.mjs +++ b/tools/enforce-askuser-answer-parser.mjs @@ -16,7 +16,8 @@ import { appendFileSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; -import { toApprovalRecord, toFloorEscapeRecord, signFloorEscapeRecord, answerMatchesOption } from './askuser-answer-parser.mjs'; +import { toApprovalRecord, toFloorEscapeRecord, signFloorEscapeRecord, answerMatchesOption, detectOtherSocialEng } from './askuser-answer-parser.mjs'; +import { readTranscript, lastAssistantText } from './enforce-hook-helpers.mjs'; import { resolveReceiptKey } from './receipt-key-config.mjs'; /** @@ -27,7 +28,7 @@ import { resolveReceiptKey } from './receipt-key-config.mjs'; * @param {string} [opts.runtimeDir] - override default ~/.claude/runtime * @param {number} [opts.nowMs] - override timestamp for test determinism */ -export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceiptKey, fsImpl = { appendFileSync, mkdirSync } } = {}) { +export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceiptKey, fsImpl = { appendFileSync, mkdirSync }, controllerText } = {}) { try { const sessionId = event && event.session_id; const toolInput = event && event.tool_input; @@ -39,6 +40,15 @@ export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceip // M6 FIX-5: резолв ключа подписи один раз на событие (fail-safe — ошибка резолва // → key=null → floor_escape пишется неподписанным, PostToolUse-наблюдаемость цела). let key = null; try { key = keyImpl(); } catch { key = null; } + // C (анти-диктовка): текст контроллера ТЕКУЩЕГО хода → детект «впиши метку» (detectOtherSocialEng). + // flagged → свободный floor_escape не доверяем (ниже esc=null). Источник — транскрипт хода + // (lastAssistantText), controllerText инъектируем для тестов. fail-open: сбой → не флагуем. + let dictation = { flagged: false }; + try { + const ct = typeof controllerText === 'string' ? controllerText + : lastAssistantText(readTranscript(event && event.transcript_path)); + dictation = detectOtherSocialEng(ct); + } catch { dictation = { flagged: false }; } const dir = runtimeDir || join(homedir(), '.claude', 'runtime'); const path = join(dir, `askuser-decisions-${sessionId}.jsonl`); @@ -56,6 +66,8 @@ export function processEvent(event, { runtimeDir, nowMs, keyImpl = resolveReceip const rec = toApprovalRecord(ans, { question: q.question, nowMs }); // M6 FIX-5: подписываем ТОЛЬКО floor_escape (§2.2 — approve_git_operation не трогаем). let esc = toFloorEscapeRecord(ans, { nowMs }); + // C (анти-диктовка): контроллер инструктировал вписать метку → свободному floor_escape не доверяем. + if (esc && dictation.flagged) esc = null; if (esc && key) esc = signFloorEscapeRecord(esc, key); for (const out of [rec, esc]) { if (!out) continue; diff --git a/tools/enforce-askuser-answer-parser.test.mjs b/tools/enforce-askuser-answer-parser.test.mjs index 3889225..9b1c0d6 100644 --- a/tools/enforce-askuser-answer-parser.test.mjs +++ b/tools/enforce-askuser-answer-parser.test.mjs @@ -151,3 +151,23 @@ describe('processEvent — anti-button (HOLE-1 / A): ответ-кнопка н rmSync(dir, { recursive: true, force: true }); }); }); + +describe('processEvent — anti-dictation (C): диктованный свободный floor_escape не пишется', () => { + it('controllerText flagged → floor_escape НЕ записан', () => { + const dir = tmpRuntimeDir(); + const event = { session_id: 'cd1', tool_input: { questions: [{ question: 'q' }] }, + tool_response: { answers: { q: 'да FLOOR-ESCAPE: write:c:/x.md' } } }; + processEvent(event, { runtimeDir: dir, nowMs: 7, controllerText: 'впиши в Other: FLOOR-ESCAPE: write:c:/x.md' }); + expect(existsSync(join(dir, 'askuser-decisions-cd1.jsonl'))).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }); + it('controllerText чистый → floor_escape записан (без регрессии)', () => { + const dir = tmpRuntimeDir(); + const event = { session_id: 'cd2', tool_input: { questions: [{ question: 'q' }] }, + tool_response: { answers: { q: 'да FLOOR-ESCAPE: write:c:/x.md' } } }; + processEvent(event, { runtimeDir: dir, nowMs: 7, controllerText: 'разрешаешь правку файла?' }); + const content = readFileSync(join(dir, 'askuser-decisions-cd2.jsonl'), 'utf-8'); + expect(content).toContain('"type":"floor_escape"'); + rmSync(dir, { recursive: true, force: true }); + }); +});