#!/usr/bin/env node // Spike S3 (2e passe) · D2..D5 avec commandes hors allowlist, scénarios isolés, // détection registre + fallback écran. D1 (trust) déjà validé en 1re passe. import { execSync } from 'node:child_process'; import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { ClaudeSession, sleep, setLogFile, log } from '../s1-resume/harness.mjs'; const CAPTURES = new URL('./captures/', import.meta.url).pathname; mkdirSync(CAPTURES, { recursive: true }); setLogFile(join(CAPTURES, 'steps2.jsonl')); const results = {}; const save = () => writeFileSync(join(CAPTURES, 'results2.json'), JSON.stringify(results, null, 1)); function freshRepo(name) { const d = `/tmp/spike-s3-${name}`; rmSync(d, { recursive: true, force: true }); mkdirSync(d, { recursive: true }); execSync('git init -q -b main && echo hello > witness.txt && git add -A && git -c user.email=s@s -c user.name=spike commit -qm init', { cwd: d, shell: '/bin/bash' }); return d; } function extractOptions(screenTail) { const opts = []; for (const m of screenTail.matchAll(/^\s*(❯\s*)?(\d+)\.\s*(.+)$/gm)) { opts.push({ selected: !!m[1], n: m[2], label: m[3].trim().slice(0, 90) }); } return opts; } // Attente d'un dialogue : registre 'waiting' OU motif de dialogue à l'écran (texte aplati sans espaces) async function waitDialog(S, timeoutMs = 120000) { return S.until('dialogue détecté', () => { const r = S.registry(); const flat = S.tail(2500).replace(/\s+/g, ''); const screenDialog = /❯1\./.test(flat) && /(Doyouwant|permission|Allow|autoris)/i.test(flat); if (r?.status === 'waiting' || screenDialog) { return { registryStatus: r?.status ?? null, waitingFor: r?.waitingFor ?? null, screenDialog }; } return false; }, timeoutMs, 400); } async function respond(S, digit) { const stillWaiting = () => { const r = S.registry(); const flat = S.tail(2000).replace(/\s+/g, ''); return (r?.status === 'waiting') || /❯\d+\./.test(flat) && /(Doyouwant|permission|Allow)/i.test(flat); }; const tries = []; S.write(digit); await sleep(2500); tries.push({ method: `digit ${digit}`, dialogGone: !stillWaiting() }); if (tries.at(-1).dialogGone) return { ok: true, tries }; S.write('\r'); await sleep(2500); tries.push({ method: 'enter', dialogGone: !stillWaiting() }); if (tries.at(-1).dialogGone) return { ok: true, tries }; const downs = Math.max(0, parseInt(digit, 10) - 1); for (let i = 0; i < downs; i++) { S.write('\x1b[B'); await sleep(250); } S.write('\r'); await sleep(2500); tries.push({ method: `arrows x${downs} + enter`, dialogGone: !stillWaiting() }); return { ok: tries.at(-1).dialogGone, tries }; } async function scenario(name, fn) { log(`=== ${name} ===`); try { results[name] = await fn(); } catch (err) { results[name] = { error: String(err) }; log(`${name}: ÉCHEC`, { err: String(err) }); } save(); } // ---------- D2 : permission Bash (node -e, hors allowlist) → approbation "1" ---------- await scenario('d2_permBash', async () => { const d = freshRepo('bash2'); const S = new ClaudeSession([], d, 'perm-bash2', CAPTURES); try { await S.waitReady(); await S.type(`Run this exact bash command and show me its output: node -e "console.log('spike-s3-marker-xyz')", do not do anything else.`); const det = await waitDialog(S); await sleep(1200); const screen = S.tail(2500); const resp = await respond(S, '1'); let completed = false; try { await S.waitTurnDone(120000); completed = true; } catch {} return { detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100), respond: resp, turnCompleted: completed, markerInOutput: S.plain().includes('spike-s3-marker-xyz'), }; } finally { S.kill('SIGTERM'); } }); // ---------- D3 : permission Write (création de fichier) → approbation ---------- await scenario('d3_permWrite', async () => { const d = freshRepo('write2'); const S = new ClaudeSession([], d, 'perm-write2', CAPTURES); try { await S.waitReady(); await S.type('Create a file named s3-edit.txt containing exactly the word hello. Use the Write tool. Do not do anything else.'); const det = await waitDialog(S); await sleep(1200); const screen = S.tail(2500); const resp = await respond(S, '1'); let completed = false; try { await S.waitTurnDone(120000); completed = true; } catch {} return { detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100), respond: resp, turnCompleted: completed, fileCreated: existsSync(join(d, 's3-edit.txt')), }; } finally { S.kill('SIGTERM'); } }); // ---------- D4 : refus par Esc ---------- await scenario('d4_denyEsc', async () => { const d = freshRepo('deny2'); const S = new ClaudeSession([], d, 'deny-esc2', CAPTURES); try { await S.waitReady(); await S.type(`Run this exact bash command and show me its output: node -e "console.log('should-be-denied')", do not do anything else.`); await waitDialog(S); await sleep(1200); const screenBefore = S.tail(1200); S.write('\x1b'); await sleep(4000); const reg = S.registry(); return { screenBeforeEsc: screenBefore.slice(-800), statusAfterEsc: reg?.status ?? null, screenAfterEsc: S.tail(900), commandExecuted: /should-be-denied\s*$/m.test(S.plain().replace(/\s+/g, ' ')), }; } finally { S.kill('SIGTERM'); } }); // ---------- D5 : AskUserQuestion ---------- await scenario('d5_askUserQuestion', async () => { const d = freshRepo('ask2'); const S = new ClaudeSession([], d, 'ask2', CAPTURES); try { await S.waitReady(); await S.type('Use the AskUserQuestion tool to ask me whether I prefer red or blue. Offer exactly those two options.'); const det = await waitDialog(S); await sleep(1200); const screen = S.tail(2500); // 2e option : flèche bas + Entrée d'abord (hypothèse sélecteur), chiffre en fallback S.write('\x1b[B'); await sleep(400); S.write('\r'); await sleep(3000); let reg = S.registry(); const arrowsWorked = reg?.status !== 'waiting'; let digitFallback = null; if (!arrowsWorked) { S.write('2'); await sleep(2500); reg = S.registry(); digitFallback = reg?.status; } let completed = false; try { await S.waitTurnDone(90000); completed = true; } catch {} return { detection: det, options: extractOptions(screen), screenExcerpt: screen.slice(-1100), arrowsEnterWorked: arrowsWorked, digitFallbackStatus: digitFallback, turnCompleted: completed, finalScreen: S.tail(700), }; } finally { S.kill('SIGTERM'); } }); log('=== S3 passe 2 terminée ==='); process.exit(0);