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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user