From d7eeff6a956e117597a48ef0c6b6b737e1549fdc Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Tue, 14 Jul 2026 22:36:14 +0300 Subject: [PATCH 1/3] feat(init): block AskUserQuestion modal, force decisions to the room The built-in AskUserQuestion modal freezes an agent's whole session behind a screen-only popup the user never sees on their phone, and it goes stale when the user acts meanwhile. On 2026-07-14 this caused a 9.5h invisible hang: an agent asked 'merge these PRs?' via the modal; the user had already merged them from their phone, but the agent sat frozen behind a stale modal. Ships a PreToolUse hook (scripts/block-modal-questions.mjs) that iak init wires into .claude/settings.json for claude-code/antigravity. It denies only AskUserQuestion with a reason redirecting to room_post / request_confirmation + stay-live discipline; every other tool passes through; fails OPEN on any parse error so it can never wedge the agent. Structural enforcement of the behavioral rule that already existed and still got broken. 4 tests; suite 146 pass. Co-Authored-By: Claude Fable 5 --- bin/cli.mjs | 29 +++++++++++++ scripts/block-modal-questions.mjs | 63 +++++++++++++++++++++++++++++ test/block-modal-questions.test.mjs | 41 +++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100755 scripts/block-modal-questions.mjs create mode 100644 test/block-modal-questions.test.mjs diff --git a/bin/cli.mjs b/bin/cli.mjs index ac03510..bfc7183 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -1809,6 +1809,35 @@ fi } catch (err) { console.warn(`Skipped SessionStart auto-bootstrap hook: ${err?.message || err}`); } + + // PreToolUse guard: block Claude Code's built-in AskUserQuestion modal. + // It freezes the whole session behind a screen-only popup the user never + // sees on their phone, and goes stale — the 9.5h hang on 2026-07-14. + // Decisions must go to the room / a phone card, and the agent stays live. + try { + const { chmodSync, copyFileSync } = await import('node:fs'); + const { fileURLToPath } = await import('node:url'); + const scriptsDir = resolve('.claude', 'scripts'); + if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true }); + const guardScript = resolve(scriptsDir, 'block-modal-questions.mjs'); + if (!existsSync(guardScript)) { + const sourceScript = fileURLToPath(new URL('../scripts/block-modal-questions.mjs', import.meta.url)); + copyFileSync(sourceScript, guardScript); + chmodSync(guardScript, 0o755); + console.log(`Created ${guardScript}`); + } + const settingsPath = resolve('.claude', 'settings.json'); + const { changed, backupPath } = ensureHookInSettingsFile( + settingsPath, 'PreToolUse', `node ${guardScript}`, { timeout: 5 } + ); + if (changed) { + console.log(`Installed PreToolUse modal-question guard in ${settingsPath}${backupPath ? ` (backup: ${backupPath})` : ''}`); + } else { + console.log(`PreToolUse modal-question guard already present in ${settingsPath}`); + } + } catch (err) { + console.warn(`Skipped PreToolUse modal-question guard: ${err?.message || err}`); + } } if (targetIde === 'codex') { diff --git a/scripts/block-modal-questions.mjs b/scripts/block-modal-questions.mjs new file mode 100755 index 0000000..e09be56 --- /dev/null +++ b/scripts/block-modal-questions.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: AGPL-3.0-only +// +// PreToolUse hook: block Claude Code's built-in AskUserQuestion modal. +// +// Why: AskUserQuestion opens a modal that HALTS the agent's whole session +// until answered, and it renders only on the machine's screen — invisible to +// a user who lives on their phone/watch. It also goes stale: a frozen agent +// can't notice the user already did the thing. On 2026-07-14 this caused a +// 9.5-hour invisible hang (an agent asked "merge these PRs?" via the modal; +// the user had already merged them from their phone, but the agent sat frozen +// behind a modal it couldn't see was stale). +// +// The discipline instead: questions go to the ROOM (or an IAK confirmation +// card that reaches the phone/watch as Approve/Deny), and the agent STAYS +// LIVE — keeps monitoring, keeps working, re-checks reality when the answer +// lands. Already-sanctioned low-risk work (CI-green + reviewed + approved) +// gets done, not asked about. +// +// Reads the PreToolUse payload on stdin. Denies only AskUserQuestion; every +// other tool passes through untouched. Fails OPEN (allows the tool) on any +// parse error so a malformed payload can never wedge the agent. + +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { input += chunk; }); +process.stdin.on('end', () => { + let toolName = ''; + try { + toolName = JSON.parse(input || '{}').tool_name || ''; + } catch { + process.exit(0); // fail open — never block on a parse error + } + + if (toolName !== 'AskUserQuestion') { + process.exit(0); // allow every other tool + } + + const reason = [ + 'AskUserQuestion is disabled by IDE Agent Kit.', + '', + 'It opens a modal that freezes your whole session and only shows on this', + "machine's screen — the user is on their phone/watch and will never see it,", + 'and it goes stale if they act while you wait. This caused a 9.5h hang.', + '', + 'Do this instead:', + ' 1. Post the question to the room (room_post), or raise an IAK', + ' confirmation card (request_confirmation) so it reaches the phone as', + ' Approve/Deny — then STAY LIVE: keep monitoring and doing other work.', + ' 2. Re-check reality when the answer lands (they may have already done it).', + ' 3. For already-sanctioned low-risk work (CI-green + reviewed + approved),', + " just do it — don't ask what's already answered.", + ].join('\n'); + + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason, + }, + })); + process.exit(0); +}); diff --git a/test/block-modal-questions.test.mjs b/test/block-modal-questions.test.mjs new file mode 100644 index 0000000..99c1764 --- /dev/null +++ b/test/block-modal-questions.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const hook = path.join(repoRoot, 'scripts', 'block-modal-questions.mjs'); + +function run(stdin) { + return spawnSync('node', [hook], { input: stdin, encoding: 'utf8' }); +} + +test('denies AskUserQuestion with a redirect-to-room reason', () => { + const r = run(JSON.stringify({ tool_name: 'AskUserQuestion', tool_input: { questions: [] } })); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.equal(out.hookSpecificOutput.hookEventName, 'PreToolUse'); + assert.equal(out.hookSpecificOutput.permissionDecision, 'deny'); + assert.match(out.hookSpecificOutput.permissionDecisionReason, /room|phone|Approve\/Deny/); +}); + +test('allows every other tool (no output, exit 0)', () => { + for (const tool of ['Bash', 'Read', 'Edit', 'room_post', 'request_confirmation']) { + const r = run(JSON.stringify({ tool_name: tool, tool_input: {} })); + assert.equal(r.status, 0, `${tool} should pass`); + assert.equal(r.stdout.trim(), '', `${tool} should produce no block output`); + } +}); + +test('fails OPEN on malformed payload (never wedges the agent)', () => { + const r = run('not json at all'); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), ''); +}); + +test('fails OPEN on empty stdin', () => { + const r = run(''); + assert.equal(r.status, 0); + assert.equal(r.stdout.trim(), ''); +}); From cd8f25ba597cf48d7cf74abfaf39dcbe6fc53210 Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Tue, 14 Jul 2026 22:47:13 +0300 Subject: [PATCH 2/3] fix(#36 review): scope hook matcher to AskUserQuestion + re-copy on upgrade claudemm review nits: (1) install with matcher:'AskUserQuestion' so node isn't spawned on every tool call, only for that tool; ensureHookCommand now takes an optional matcher (defaults to '' for lifecycle events like SessionStart). (2) iak init always re-copies the managed guard script so an upgrade refreshes it. +2 matcher tests; suite 146. Co-Authored-By: Claude Fable 5 --- bin/cli.mjs | 17 ++++++++++------- src/claude-settings.mjs | 8 ++++++-- test/block-modal-questions.test.mjs | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/bin/cli.mjs b/bin/cli.mjs index bfc7183..d5a328e 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -1820,15 +1820,18 @@ fi const scriptsDir = resolve('.claude', 'scripts'); if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true }); const guardScript = resolve(scriptsDir, 'block-modal-questions.mjs'); - if (!existsSync(guardScript)) { - const sourceScript = fileURLToPath(new URL('../scripts/block-modal-questions.mjs', import.meta.url)); - copyFileSync(sourceScript, guardScript); - chmodSync(guardScript, 0o755); - console.log(`Created ${guardScript}`); - } + // Always re-copy so `iak init` on an existing install upgrades the + // managed script to the current version (claudemm review, #36). + const sourceScript = fileURLToPath(new URL('../scripts/block-modal-questions.mjs', import.meta.url)); + const existedBefore = existsSync(guardScript); + copyFileSync(sourceScript, guardScript); + chmodSync(guardScript, 0o755); + console.log(`${existedBefore ? 'Updated' : 'Created'} ${guardScript}`); const settingsPath = resolve('.claude', 'settings.json'); + // matcher scopes the hook to AskUserQuestion only, so node isn't spawned + // on every tool call (claudemm review, #36). const { changed, backupPath } = ensureHookInSettingsFile( - settingsPath, 'PreToolUse', `node ${guardScript}`, { timeout: 5 } + settingsPath, 'PreToolUse', `node ${guardScript}`, { timeout: 5, matcher: 'AskUserQuestion' } ); if (changed) { console.log(`Installed PreToolUse modal-question guard in ${settingsPath}${backupPath ? ` (backup: ${backupPath})` : ''}`); diff --git a/src/claude-settings.mjs b/src/claude-settings.mjs index db80e8c..2d66c57 100644 --- a/src/claude-settings.mjs +++ b/src/claude-settings.mjs @@ -16,7 +16,7 @@ import { dirname } from 'node:path'; * Ensure settings.hooks[event] contains a {type:'command', command} hook. * Mutates the given settings object. Returns true when something changed. */ -export function ensureHookCommand(settings, event, command, { timeout } = {}) { +export function ensureHookCommand(settings, event, command, { timeout, matcher = '' } = {}) { if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { throw new TypeError('settings must be a plain object'); } @@ -31,7 +31,11 @@ export function ensureHookCommand(settings, event, command, { timeout } = {}) { } const hook = { type: 'command', command }; if (Number.isFinite(timeout)) hook.timeout = timeout; - entries.push({ matcher: '', hooks: [hook] }); + // matcher scopes which tools trigger the hook. '' = all tools (default, + // for session-lifecycle events like SessionStart that have no tool). A tool + // name (e.g. "AskUserQuestion") means the hook only spawns for that tool, + // not on every tool call. + entries.push({ matcher, hooks: [hook] }); return true; } diff --git a/test/block-modal-questions.test.mjs b/test/block-modal-questions.test.mjs index 99c1764..f404c25 100644 --- a/test/block-modal-questions.test.mjs +++ b/test/block-modal-questions.test.mjs @@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; +import { ensureHookCommand } from '../src/claude-settings.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const hook = path.join(repoRoot, 'scripts', 'block-modal-questions.mjs'); @@ -39,3 +40,17 @@ test('fails OPEN on empty stdin', () => { assert.equal(r.status, 0); assert.equal(r.stdout.trim(), ''); }); + +test('ensureHookCommand scopes the matcher when given (spawn only for that tool)', () => { + const s = {}; + ensureHookCommand(s, 'PreToolUse', 'node guard.mjs', { matcher: 'AskUserQuestion', timeout: 5 }); + const entry = s.hooks.PreToolUse[0]; + assert.equal(entry.matcher, 'AskUserQuestion'); + assert.deepEqual(entry.hooks[0], { type: 'command', command: 'node guard.mjs', timeout: 5 }); +}); + +test('ensureHookCommand matcher defaults to empty (all tools / lifecycle events)', () => { + const s = {}; + ensureHookCommand(s, 'SessionStart', 'bash boot.sh'); + assert.equal(s.hooks.SessionStart[0].matcher, ''); +}); From f7b4f220d3863bd8b53f0b7aad42c63a4e034fc8 Mon Sep 17 00:00:00 2001 From: Petrus Pennanen Date: Tue, 14 Jul 2026 22:54:03 +0300 Subject: [PATCH 3/3] fix(#36 review): drop process.exit after stdout.write (async-pipe truncation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex caught a real silent-failure bug: on POSIX a hook's stdout is a pipe and writes are async, so process.exit(0) right after stdout.write() can kill the process before the deny payload flushes — intermittently shipping a partial/empty decision that fails to block AskUserQuestion. The old tests were a false green (spawnSync drained fast enough). Fix: remove every process.exit(); return from the handler and let node exit 0 naturally once stdout drains. Add a 50-run regression asserting the full deny JSON is always parseable and complete. Suite 147. Co-Authored-By: Claude Fable 5 --- scripts/block-modal-questions.mjs | 11 ++++++++--- test/block-modal-questions.test.mjs | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/block-modal-questions.mjs b/scripts/block-modal-questions.mjs index e09be56..2c17fb2 100755 --- a/scripts/block-modal-questions.mjs +++ b/scripts/block-modal-questions.mjs @@ -25,15 +25,20 @@ let input = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => { + // NOTE: never call process.exit() after writing to stdout. On POSIX a + // hook's stdout is a PIPE and writes are async, so process.exit() can kill + // the process before the deny payload flushes — silently shipping a partial + // or empty decision and failing to block (codex review, #36). Instead we + // just `return`; node exits 0 naturally once stdout has drained. let toolName = ''; try { toolName = JSON.parse(input || '{}').tool_name || ''; } catch { - process.exit(0); // fail open — never block on a parse error + return; // fail open — never block on a parse error } if (toolName !== 'AskUserQuestion') { - process.exit(0); // allow every other tool + return; // allow every other tool } const reason = [ @@ -59,5 +64,5 @@ process.stdin.on('end', () => { permissionDecisionReason: reason, }, })); - process.exit(0); + // no process.exit — let stdout flush and node exit 0 on its own. }); diff --git a/test/block-modal-questions.test.mjs b/test/block-modal-questions.test.mjs index f404c25..a9e9655 100644 --- a/test/block-modal-questions.test.mjs +++ b/test/block-modal-questions.test.mjs @@ -21,6 +21,21 @@ test('denies AskUserQuestion with a redirect-to-room reason', () => { assert.match(out.hookSpecificOutput.permissionDecisionReason, /room|phone|Approve\/Deny/); }); +test('deny payload is never truncated across many runs (no process.exit-before-flush)', () => { + // Regression for the POSIX async-pipe truncation bug (codex review, #36): + // process.exit() after stdout.write could ship a partial/empty decision. + // A large reason string + repeated runs would surface truncation as a + // JSON parse failure or a missing permissionDecision. + for (let i = 0; i < 50; i++) { + const r = run(JSON.stringify({ tool_name: 'AskUserQuestion', tool_input: {} })); + assert.equal(r.status, 0, `run ${i} exit`); + let out; + assert.doesNotThrow(() => { out = JSON.parse(r.stdout); }, `run ${i} produced unparseable/truncated JSON: ${JSON.stringify(r.stdout)}`); + assert.equal(out.hookSpecificOutput.permissionDecision, 'deny', `run ${i} missing deny`); + assert.ok(out.hookSpecificOutput.permissionDecisionReason.length > 100, `run ${i} reason truncated`); + } +}); + test('allows every other tool (no output, exit 0)', () => { for (const tool of ['Bash', 'Read', 'Edit', 'room_post', 'request_confirmation']) { const r = run(JSON.stringify({ tool_name: tool, tool_input: {} }));