fix: accumulate modifiedInput and common fields from allow hooks

When a hook returns 'allow' with updatedInput or common fields
(suppressOutput, systemMessage, etc.), these values were silently
dropped. Now they are accumulated across hooks and included in the
final result, matching Claude Code's behavior where allow hooks can
still modify tool input and set metadata.
This commit is contained in:
kilhyeonjun
2026-04-10 10:30:25 +09:00
parent e0d611aefc
commit 5d8bd99f8f
2 changed files with 68 additions and 7 deletions
@@ -171,5 +171,50 @@ describe("executePreToolUseHooks", () => {
expect(callCount).toBe(2)
})
it("#when hook returns allow with updatedInput #then modifiedInput is included in final result", async () => {
dispatchSpy.mockResolvedValue({
exitCode: 0,
stdout: JSON.stringify({
decision: "allow",
hookSpecificOutput: {
permissionDecision: "allow",
updatedInput: { file_path: "/tmp/modified.md" },
},
}),
stderr: "",
})
const config = createConfig([
{ matcher: "Write", hooks: [{ type: "command", command: "bash modifier.sh" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(result.decision).toBe("allow")
expect(result.modifiedInput).toEqual({ file_path: "/tmp/modified.md" })
})
it("#when hook returns allow with common fields #then fields are included in final result", async () => {
dispatchSpy.mockResolvedValue({
exitCode: 0,
stdout: JSON.stringify({
decision: "allow",
suppressOutput: true,
systemMessage: "Budget warning: approaching limit",
}),
stderr: "",
})
const config = createConfig([
{ matcher: "Write", hooks: [{ type: "command", command: "bash checker.sh" }] },
])
const result = await executePreToolUseHooks(createContext(), config)
expect(result.decision).toBe("allow")
expect(result.suppressOutput).toBe(true)
expect(result.systemMessage).toBe("Budget warning: approaching limit")
})
})
})
+23 -7
View File
@@ -73,6 +73,13 @@ export async function executePreToolUseHooks(
const startTime = Date.now()
let firstHookName: string | undefined
const inputLines = buildInputLines(ctx.toolInput)
let accumulatedModifiedInput: Record<string, unknown> | undefined
let accumulatedCommonFields: {
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
} = {}
for (const matcher of matchers) {
if (!matcher.hooks || matcher.hooks.length === 0) continue
@@ -147,27 +154,36 @@ export async function executePreToolUseHooks(
return {
decision,
reason,
modifiedInput,
modifiedInput: modifiedInput ?? accumulatedModifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
continue: output.continue ?? accumulatedCommonFields.continue,
stopReason: output.stopReason ?? accumulatedCommonFields.stopReason,
suppressOutput: output.suppressOutput ?? accumulatedCommonFields.suppressOutput,
systemMessage: output.systemMessage ?? accumulatedCommonFields.systemMessage,
}
}
// "allow" — apply modifiedInput but continue processing remaining hooks
// "allow" — accumulate modifiedInput and common fields, continue to next hook
if (modifiedInput) {
accumulatedModifiedInput = { ...accumulatedModifiedInput, ...modifiedInput }
Object.assign(stdinData.tool_input, objectToSnakeCase(modifiedInput))
}
if (output.continue !== undefined) accumulatedCommonFields.continue = output.continue
if (output.stopReason !== undefined) accumulatedCommonFields.stopReason = output.stopReason
if (output.suppressOutput !== undefined) accumulatedCommonFields.suppressOutput = output.suppressOutput
if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = output.systemMessage
} catch {
}
}
}
}
return { decision: "allow" }
return {
decision: "allow" as const,
...(accumulatedModifiedInput ? { modifiedInput: accumulatedModifiedInput } : {}),
...(Object.keys(accumulatedCommonFields).length > 0 ? accumulatedCommonFields : {}),
}
}