Files
brain/tools/enforce-askuser-answer-parser.test.mjs
T
Дмитрий cdc32b1a1d 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>
2026-06-18 17:43:58 +03:00

154 lines
6.7 KiB
JavaScript
Raw 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 });
});
});