feat: анти-диктовка свободного floor_escape (consent forgery C)

Свободный 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 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-18 19:54:25 +03:00
parent 11969ac3ea
commit 0d31e62248
6 changed files with 237 additions and 4 deletions
@@ -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 правится согласованно (импорты+использование в координированных правках, функции уже существуют к моменту импорта).
@@ -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: <action>`
не сочинит; если контроллер её диктует — это и есть детектируемая диктовка. Практический путь для
лёгких escape при детекте — терминал владельца (#B), как и для тяжёлых. C ловит БЛАТАНТНУЮ
соц-инженерию (явная инструкция «впиши метку»), остаточная тонкая диктовка лёгких — принятый риск
(лёгкое = низкая цена; тяжёлое терминал-only).
**Критерий.** Контроллер, инструктирующий вписать метку (detectOtherSocialEng-паттерн) в своём тексте
хода, не открывает свободный `floor_escape`, даже если строка вернулась ответом.
## D. Подпись fail-closed для тяжёлых действий {#KEY}
+4
View File
@@ -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,
];
/**
+9
View File
@@ -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);
});
+14 -2
View File
@@ -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;
@@ -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 });
});
});