Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions bin/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,38 @@ 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');
// 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, matcher: 'AskUserQuestion' }
);
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') {
Expand Down
68 changes: 68 additions & 0 deletions scripts/block-modal-questions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/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', () => {
// 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 {
return; // fail open — never block on a parse error
}

if (toolName !== 'AskUserQuestion') {
return; // 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,
},
}));
// no process.exit — let stdout flush and node exit 0 on its own.
});
8 changes: 6 additions & 2 deletions src/claude-settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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;
}

Expand Down
71 changes: 71 additions & 0 deletions test/block-modal-questions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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';
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');

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('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: {} }));
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(), '');
});

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, '');
});
Loading