Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
42 changes: 28 additions & 14 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse.mjs\"",
"timeout": 30
}
]
}
Expand All @@ -18,7 +19,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/precompact.mjs\"",
"timeout": 30
}
]
}
Expand All @@ -29,7 +31,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
},
Expand All @@ -38,7 +41,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
},
Expand All @@ -47,7 +51,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
},
Expand All @@ -56,7 +61,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
},
Expand All @@ -65,7 +71,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
},
Expand All @@ -74,7 +81,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
},
Expand All @@ -83,7 +91,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
},
Expand All @@ -92,7 +101,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
},
Expand All @@ -101,7 +111,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.mjs\"",
"timeout": 10
}
]
}
Expand All @@ -112,7 +123,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/userpromptsubmit.mjs\"",
"timeout": 30
}
]
}
Expand All @@ -123,7 +135,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/sessionstart.mjs\"",
"timeout": 30
}
]
}
Expand All @@ -134,7 +147,8 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/stop.mjs\""
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/stop.mjs\"",
"timeout": 30
}
]
}
Expand Down
61 changes: 61 additions & 0 deletions tests/hooks/shipped-hooks-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Shipped hooks.json timeout guard (#1226).
*
* Every command hook the plugin ships must declare a numeric `timeout`, so a
* hung hook script (infinite loop, blocked stdin) ends after a bounded wait
* instead of blocking the host CLI indefinitely with no way out. Values live
* in the shipped file itself, so `ctx upgrade` keeps them; patching the
* plugin cache by hand is overwritten on every update.
*
* PreToolUse gets the tighter bound: those hooks sit on the critical path of
* every tool call.
*/

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const here = dirname(fileURLToPath(import.meta.url));
const hooksJson = JSON.parse(
readFileSync(join(here, "..", "..", "hooks", "hooks.json"), "utf8"),
) as {
hooks: Record<string, Array<{ hooks: Array<Record<string, unknown>> }>>;
};

type Entry = { event: string; command: unknown; timeout: unknown };

const entries: Entry[] = [];
for (const [event, matchers] of Object.entries(hooksJson.hooks)) {
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
entries.push({
event,
command: hook.command,
timeout: hook.timeout,
});
}
}
}

describe("shipped hooks.json declares a timeout on every hook", () => {
it("has entries to check", () => {
expect(entries.length).toBeGreaterThanOrEqual(10);
});

it.each(entries.map(entry => [entry.event, entry] as const))(
"%s hook command carries a numeric timeout",
(_event, entry) => {
expect(typeof entry.timeout).toBe("number");
expect(entry.timeout as number).toBeGreaterThan(0);
},
);

it("keeps PreToolUse bounded tighter than session-lifecycle events", () => {
const preToolUse = entries.filter(entry => entry.event === "PreToolUse");
expect(preToolUse.length).toBeGreaterThan(0);
for (const entry of preToolUse) {
expect(entry.timeout as number).toBeLessThanOrEqual(10);
}
});
});
Loading