Files
brain/tools/enforce-askuser-answer-parser.test.mjs
Дмитрий 0d31e62248 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>
2026-06-18 19:54:25 +03:00

174 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { processEvent } from './enforce-askuser-answer-parser.mjs';
function tmpRuntimeDir() {
return mkdtempSync(join(tmpdir(), 'askuser-decisions-test-'));
}
describe('enforce-askuser-answer-parser wrapper (Stream H Task 6)', () => {
it('appends approve_git_operation record for git-pattern answer', () => {
const dir = tmpRuntimeDir();
const event = {
session_id: 'sess-abc',
tool_input: { questions: [{ question: 'разрешить?' }] },
tool_response: { answers: { 'разрешить?': 'подтверди git push origin main' } },
};
processEvent(event, { runtimeDir: dir, nowMs: 1700000000000 });
const path = join(dir, 'askuser-decisions-sess-abc.jsonl');
expect(existsSync(path)).toBe(true);
const lines = readFileSync(path, 'utf-8').split(/\r?\n/).filter(Boolean);
expect(lines.length).toBe(1);
const rec = JSON.parse(lines[0]);
expect(rec).toMatchObject({ type: 'approve_git_operation', command: 'git push origin main', ts: 1700000000000 });
rmSync(dir, { recursive: true, force: true });
});
it('appends nothing for non-git answer', () => {
const dir = tmpRuntimeDir();
const event = {
session_id: 'sess-def',
tool_input: { questions: [{ question: 'continue?' }] },
tool_response: { answers: { 'continue?': 'yes' } },
};
processEvent(event, { runtimeDir: dir });
const path = join(dir, 'askuser-decisions-sess-def.jsonl');
expect(existsSync(path)).toBe(false);
rmSync(dir, { recursive: true, force: true });
});
it('appends multiple records across multiple answers', () => {
const dir = tmpRuntimeDir();
const event = {
session_id: 'sess-multi',
tool_input: { questions: [{ question: 'A?' }, { question: 'B?' }] },
tool_response: { answers: { 'A?': 'git push origin main', 'B?': 'git add tools/x.mjs' } },
};
processEvent(event, { runtimeDir: dir, nowMs: 1700000000000 });
const path = join(dir, 'askuser-decisions-sess-multi.jsonl');
const lines = readFileSync(path, 'utf-8').split(/\r?\n/).filter(Boolean);
expect(lines.length).toBe(2);
rmSync(dir, { recursive: true, force: true });
});
it('fail-open: missing tool_response does not throw', () => {
const dir = tmpRuntimeDir();
expect(() => processEvent({ session_id: 's' }, { runtimeDir: dir })).not.toThrow();
rmSync(dir, { recursive: true, force: true });
});
it('fail-open: missing answer key does not throw', () => {
const dir = tmpRuntimeDir();
expect(() => processEvent({
session_id: 's',
tool_input: { questions: [{ question: 'X?' }] },
tool_response: { answers: {} },
}, { runtimeDir: dir })).not.toThrow();
rmSync(dir, { recursive: true, force: true });
});
it('fail-open: missing session_id does not throw and does not write', () => {
const dir = tmpRuntimeDir();
expect(() => processEvent({
tool_input: { questions: [{ question: 'X?' }] },
tool_response: { answers: { 'X?': 'git push origin main' } },
}, { runtimeDir: dir })).not.toThrow();
rmSync(dir, { recursive: true, force: true });
});
});
describe('enforce-askuser-answer-parser floor_escape (M6)', () => {
it('ответ с FLOOR-ESCAPE пишет floor_escape-запись', () => {
const dir = tmpRuntimeDir();
const event = {
session_id: 's1',
tool_input: { questions: [{ question: 'q' }] },
tool_response: { answers: { q: 'да FLOOR-ESCAPE: bash:reset --hard' } },
};
processEvent(event, { runtimeDir: dir, nowMs: 7 });
const content = readFileSync(join(dir, 'askuser-decisions-s1.jsonl'), 'utf-8');
expect(content).toContain('"type":"floor_escape"');
expect(content).toContain('"action":"bash:reset --hard"');
rmSync(dir, { recursive: true, force: true });
});
it('git-ответ по-прежнему пишет approve_git_operation (без регрессии)', () => {
const dir = tmpRuntimeDir();
const event = {
session_id: 's2',
tool_input: { questions: [{ question: 'q' }] },
tool_response: { answers: { q: 'подтверди git push origin main' } },
};
processEvent(event, { runtimeDir: dir, nowMs: 9 });
const content = readFileSync(join(dir, 'askuser-decisions-s2.jsonl'), 'utf-8');
expect(content).toContain('"type":"approve_git_operation"');
expect(content).not.toContain('floor_escape');
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 });
});
});
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 });
});
});