Skip to content

Commit 76e3a51

Browse files
committed
fix(hooks): guard against undefined output.output in all tool.execute.after hooks
MCP tool results (e.g. Slack, Toggl) don't populate output.output as a string, causing TypeError crashes in hooks that call .toLowerCase() or .startsWith(), and silent "undefined" string corruption in hooks that use +=. Add `output.output == null` guard to all 14 tool.execute.after hooks and injectors that access output.output. This is consistent with the existing guard style in empty-task-response-detector (which uses ?.) and normalizes the previous typeof check in tool-output-truncator. Hooks fixed: - comment-checker (crash: .toLowerCase on undefined) - edit-error-recovery (crash, behind tool filter) - task-resume-info (crash: .startsWith on undefined, behind tool filter) - delegate-task-retry (passed to function, behind tool filter) - context-window-monitor (corrupt: += on undefined) - task-reminder (corrupt: += on undefined) - claude-code-hooks handler (corrupt: template literal with undefined) - category-skill-reminder (corrupt, behind tool filter) - agent-usage-reminder (corrupt, behind tool filter) - interactive-bash-session (corrupt, behind tool filter) - directory-readme-injector (corrupt, behind tool filter) - directory-agents-injector (corrupt, behind tool filter) - rules-injector (corrupt, behind tool filter) - tool-output-truncator (normalize typeof to == null)
1 parent 074d8df commit 76e3a51

File tree

17 files changed

+127
-8
lines changed

17 files changed

+127
-8
lines changed

src/hooks/agent-usage-reminder/hook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) {
7777
return;
7878
}
7979

80+
if (output.output == null) return;
8081
output.output += REMINDER_MESSAGE;
8182
state.reminderCount++;
8283
state.updatedAt = Date.now();

src/hooks/category-skill-reminder/hook.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export function createCategorySkillReminderHook(
105105

106106
state.toolCallCount++
107107

108-
if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) {
108+
if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown && output.output != null) {
109109
output.output += reminderMessage
110110
state.reminderShown = true
111111
log("[category-skill-reminder] Reminder injected", {

src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo
1616
input: { tool: string; sessionID: string; callID: string },
1717
output: { title: string; output: string; metadata: unknown } | undefined,
1818
): Promise<void> => {
19-
if (!output) {
19+
if (!output || output.output == null) {
2020
return
2121
}
2222

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect, mock } from "bun:test"
2+
3+
mock.module("./cli-runner", () => ({
4+
initializeCommentCheckerCli: () => {},
5+
getCommentCheckerCliPathPromise: () => Promise.resolve("/tmp/fake-comment-checker"),
6+
isCliPathUsable: () => true,
7+
processWithCli: async () => {},
8+
processApplyPatchEditsWithCli: async () => {},
9+
}))
10+
11+
const { createCommentCheckerHooks } = await import("./hook")
12+
13+
describe("comment-checker null output guard", () => {
14+
it("does not throw when output.output is undefined", async () => {
15+
const hooks = createCommentCheckerHooks()
16+
const input = { tool: "Edit", sessionID: "ses_test", callID: "call_test" }
17+
const output = { title: "Edit", output: undefined as unknown as string, metadata: {} }
18+
19+
await hooks["tool.execute.after"](input, output)
20+
21+
expect(output.output).toBeUndefined()
22+
})
23+
24+
it("does not throw when output.output is null", async () => {
25+
const hooks = createCommentCheckerHooks()
26+
const input = { tool: "Edit", sessionID: "ses_test", callID: "call_test" }
27+
const output = { title: "Edit", output: null as unknown as string, metadata: {} }
28+
29+
await hooks["tool.execute.after"](input, output)
30+
31+
expect(output.output).toBeNull()
32+
})
33+
34+
it("still processes valid string output", async () => {
35+
const hooks = createCommentCheckerHooks()
36+
const input = { tool: "Edit", sessionID: "ses_test", callID: "call_test" }
37+
const output = { title: "Edit", output: "File edited successfully", metadata: {} }
38+
39+
await hooks["tool.execute.after"](input, output)
40+
41+
expect(typeof output.output).toBe("string")
42+
})
43+
44+
it("skips tool failure output without crashing", async () => {
45+
const hooks = createCommentCheckerHooks()
46+
const input = { tool: "Edit", sessionID: "ses_test", callID: "call_test" }
47+
const output = { title: "Edit", output: "Error: something went wrong", metadata: {} }
48+
49+
await hooks["tool.execute.after"](input, output)
50+
51+
expect(output.output).toBe("Error: something went wrong")
52+
})
53+
})

src/hooks/comment-checker/hook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
9292
const toolLower = input.tool.toLowerCase()
9393

9494
// Only skip if the output indicates a tool execution failure
95+
if (output.output == null) return
9596
const outputLower = output.output.toLowerCase()
9697
const isToolFailure =
9798
outputLower.includes("error:") ||

src/hooks/context-window-monitor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
3838
output: { title: string; output: string; metadata: unknown }
3939
) => {
4040
const { sessionID } = input
41+
if (output.output == null) return
4142

4243
if (remindedSessions.has(sessionID)) return
4344

src/hooks/delegate-task-retry/hook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export function createDelegateTaskRetryHook(_ctx: PluginInput) {
1010
output: { title: string; output: string; metadata: unknown }
1111
) => {
1212
if (input.tool.toLowerCase() !== "task") return
13+
if (output.output == null) return
1314

1415
const errorInfo = detectDelegateTaskError(output.output)
1516
if (errorInfo) {

src/hooks/directory-agents-injector/injector.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export async function processFilePathForAgentsInjection(input: {
4646
const truncationNotice = truncated
4747
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
4848
: "";
49-
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
49+
if (input.output.output != null) {
50+
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
51+
}
5052
cache.add(agentsDir);
5153
} catch {}
5254
}

src/hooks/directory-readme-injector/injector.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ export async function processFilePathForReadmeInjection(input: {
4646
const truncationNotice = truncated
4747
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]`
4848
: "";
49-
input.output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`;
49+
if (input.output.output != null) {
50+
input.output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`;
51+
}
5052
cache.add(readmeDir);
5153
} catch {}
5254
}

src/hooks/edit-error-recovery/hook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export function createEditErrorRecoveryHook(_ctx: PluginInput) {
4444
) => {
4545
if (input.tool.toLowerCase() !== "edit") return
4646

47+
if (output.output == null) return
4748
const outputLower = output.output.toLowerCase()
4849
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
4950
outputLower.includes(pattern.toLowerCase())

0 commit comments

Comments
 (0)