0d31e62248
Свободный 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>
174 lines
8.0 KiB
JavaScript
174 lines
8.0 KiB
JavaScript
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 });
|
||
});
|
||
});
|