fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006)

Every AskUserQuestion died with "Tool result missing due to internal error"
on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause:
the question-preference-hook emitted permissionDecision:'defer' on every
pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89
its semantics are "pause this tool call for external resumption" (headless
resume) — never "abstain". Interactive sessions have nothing to resume the
paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown
value, which is why the hook worked when it shipped and broke later.

The fix is the two-branch pass-through contract:
- no context -> exit 0 with EXACTLY empty stdout
- memory nuggets present -> hookSpecificOutput with hookEventName +
  additionalContext ONLY (the documented shape; plan-tune Layer 8 memory
  injection ships through this branch and keeps working)

defer() is renamed passThrough() so the function says what it does, and
docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the
hook header) is corrected in the same commit — it taught '"defer" — let
permission flow continue' and was the reintroduction vector.

Test contract rewritten in the same commit (13 assertions across 3 files,
verified fail-first against the unfixed hook): pass-through paths assert
exact-empty stdout (a garbage/partial write cannot slip past an
optional-chained parse), the nugget path asserts permissionDecision is
ABSENT while additionalContext survives, and a new tripwire asserts no
non-deny path ever puts the string "permissionDecision" on stdout. The
deny (auto-decide) and Conductor prose-redirect paths are unchanged.

Deployment: no migration needed — settings.json points at the absolute
bash shim which execs the .ts live; /gstack-upgrade delivers the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-09 19:08:49 -07:00
co-authored by Claude Fable 5
parent c4efc2a4f7
commit 3d97863b14
5 changed files with 137 additions and 61 deletions
+35 -19
View File
@@ -12,11 +12,15 @@
* 2. Look up door_type from scripts/question-registry.ts (default two-way).
* 3. Read preferences with precedence: project-local > global (D8).
* 4. Apply:
* never-ask + one-way → defer (safety override; one-way always asks).
* never-ask + one-way → pass through (safety override; one-way always asks).
* never-ask + two-way + marker → deny with auto-decided recommendation
* in reason. Mark tool_use_id so PostToolUse logs as 'auto-decided'.
* ask-only-for-one-way + two-way + marker → same as never-ask.
* always-ask, or no preference → defer.
* always-ask, or no preference → pass through.
*
* Pass-through = exit 0 with empty stdout (or additionalContext-only output
* when memory nuggets exist) — NEVER permissionDecision:'defer', whose
* CC v2.1.89+ semantics are pause-for-external-resumption (#2035, #2006).
*
* Why deny+reason instead of allow+updatedInput:
* AskUserQuestion's `updatedInput` shape for "pre-resolve this question"
@@ -31,7 +35,7 @@
* - First: (recommended) label suffix on an option.
* - Fall back: "Recommendation: X" prose match against option labels.
* - Refuse to auto-decide if ambiguous (multiple labels OR no parseable
* recommendation): defer instead of silent-wrong.
* recommendation): pass through instead of silent-wrong.
*
* Always exits 0. Hook errors land in ~/.gstack/hook-errors.log.
* See docs/spikes/claude-code-hook-mutation.md for the protocol contract.
@@ -92,13 +96,25 @@ function readStdin(): Promise<string> {
});
}
function defer(additionalContext?: string): void {
const out: Record<string, unknown> = {
hookEventName: 'PreToolUse',
permissionDecision: 'defer',
};
if (additionalContext) out.additionalContext = additionalContext;
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
function passThrough(additionalContext?: string): void {
// Abstain = exit 0 with EMPTY stdout (#2035, #2006). Never emit a
// permissionDecision here: 'defer' is a real PreToolUse value, but since
// Claude Code v2.1.89 its semantics are "pause this tool call for external
// resumption" (a headless-resume feature) — NOT "no opinion". In an
// interactive session nothing resumes the paused call, so every
// AskUserQuestion died with "Tool result missing due to internal error".
// additionalContext-only hookSpecificOutput is the documented shape for
// injecting context (plan-tune memory nuggets) without a decision.
if (additionalContext) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext,
},
}),
);
}
process.exit(0);
}
@@ -347,7 +363,7 @@ function logAutoDecided(
async function main(): Promise<void> {
const raw = await readStdin();
if (!raw.trim()) {
defer();
passThrough();
return;
}
let stdin: HookStdin;
@@ -355,7 +371,7 @@ async function main(): Promise<void> {
stdin = JSON.parse(raw);
} catch (e) {
logHookError(`stdin parse failed: ${(e as Error).message}`);
defer();
passThrough();
return;
}
@@ -364,26 +380,26 @@ async function main(): Promise<void> {
toolName !== 'AskUserQuestion' &&
!toolName.match(/^mcp__.+__AskUserQuestion$/)
) {
defer();
passThrough();
return;
}
const questions = stdin.tool_input?.questions || [];
if (questions.length === 0) {
defer();
passThrough();
return;
}
// For multi-question AUQ, enforcement is all-or-nothing per call:
// we deny only if ALL questions have marker + never-ask + safe door type.
// Mixed cases pass through (defer) so the user still gets to answer.
// Mixed cases pass through so the user still gets to answer.
const registry = loadRegistry();
const slug = slugFromCwd(stdin.cwd);
const memoryNuggets = loadMemoryNuggets(stdin.session_id);
// Compute Layer 8 memory context inline: any nuggets matching the
// signal_keys of the questions in this AUQ get surfaced as additionalContext.
// This applies whether we defer OR deny — gives the agent + user the
// This applies whether we pass through OR deny — gives the agent + user the
// relevant prior context either way.
const contextNuggets: string[] = [];
for (const q of questions) {
@@ -402,7 +418,7 @@ async function main(): Promise<void> {
: undefined;
// Determine whether EVERY question is eligible for never-ask auto-decide.
// We deliberately do NOT early-return defer on the first ineligible question:
// We deliberately do NOT early-return pass-through on the first ineligible question:
// a Conductor session still needs the [conductor] prose deny as a fallback,
// so we compute eligibility, then branch. memoryContext is preserved on every
// non-enforcing exit. (All-or-nothing per-call semantics are unchanged: any
@@ -471,10 +487,10 @@ async function main(): Promise<void> {
return;
}
defer(memoryContext);
passThrough(memoryContext);
}
main().catch((e) => {
logHookError(`main crash: ${(e as Error).message}`);
defer();
passThrough();
});