Created
April 4, 2026 19:11
-
-
Save stephenfeather/7dea567f35ca0040309b13ae04a83bfc to your computer and use it in GitHub Desktop.
PreToolUse:Agent hook — blocks Claude Code Agent calls with missing/null/general-purpose subagent_type and suggests specialist agents
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { describe, it, expect } from "vitest"; | |
| import { | |
| processInput, | |
| isSubagentTypeValid, | |
| suggestAgents, | |
| buildDenyResponse, | |
| type HookInput, | |
| } from "../agent-subagent-guard"; | |
| describe("agent-subagent-guard", () => { | |
| describe("isSubagentTypeValid", () => { | |
| it("returns true for non-empty string", () => { | |
| expect(isSubagentTypeValid("scout")).toBe(true); | |
| }); | |
| it("returns false for null", () => { | |
| expect(isSubagentTypeValid(null)).toBe(false); | |
| }); | |
| it("returns false for undefined", () => { | |
| expect(isSubagentTypeValid(undefined)).toBe(false); | |
| }); | |
| it("returns false for empty string", () => { | |
| expect(isSubagentTypeValid("")).toBe(false); | |
| }); | |
| it("returns false for whitespace-only string", () => { | |
| expect(isSubagentTypeValid(" ")).toBe(false); | |
| }); | |
| it("returns false for general-purpose", () => { | |
| expect(isSubagentTypeValid("general-purpose")).toBe(false); | |
| }); | |
| it("returns false for general-purpose with mixed case", () => { | |
| expect(isSubagentTypeValid("General-Purpose")).toBe(false); | |
| }); | |
| }); | |
| describe("suggestAgents", () => { | |
| it("suggests sleuth for bug-related prompts", () => { | |
| const result = suggestAgents("Debug this failing test"); | |
| expect(result.some((s) => s.includes("sleuth"))).toBe(true); | |
| }); | |
| it("suggests kraken for implementation prompts", () => { | |
| const result = suggestAgents("Implement the new authentication feature"); | |
| expect(result.some((s) => s.includes("kraken"))).toBe(true); | |
| }); | |
| it("suggests scout for search prompts", () => { | |
| const result = suggestAgents("Search the codebase for patterns"); | |
| expect(result.some((s) => s.includes("scout"))).toBe(true); | |
| }); | |
| it("suggests aegis for security prompts", () => { | |
| const result = suggestAgents("Run a security audit on the API"); | |
| expect(result.some((s) => s.includes("aegis"))).toBe(true); | |
| }); | |
| it("returns default suggestions when no keywords match", () => { | |
| const result = suggestAgents("xyzzy foobar baz"); | |
| expect(result.some((s) => s.includes("scout"))).toBe(true); | |
| expect(result.some((s) => s.includes("kraken"))).toBe(true); | |
| expect(result.some((s) => s.includes("spark"))).toBe(true); | |
| }); | |
| it("limits suggestions to 3", () => { | |
| // Prompt that matches many keywords | |
| const result = suggestAgents("debug and fix the broken build, then test and review"); | |
| expect(result.length).toBeLessThanOrEqual(3); | |
| }); | |
| }); | |
| describe("processInput", () => { | |
| it("allows Agent calls with explicit subagent_type", () => { | |
| const input: HookInput = { | |
| tool_name: "Agent", | |
| tool_input: { subagent_type: "scout", prompt: "Find files" }, | |
| }; | |
| expect(processInput(input)).toEqual({}); | |
| }); | |
| it("denies Agent calls with null subagent_type", () => { | |
| const input: HookInput = { | |
| tool_name: "Agent", | |
| tool_input: { subagent_type: null, prompt: "Search the codebase" }, | |
| }; | |
| const result = processInput(input); | |
| expect(result).toHaveProperty("hookSpecificOutput.permissionDecision", "deny"); | |
| }); | |
| it("denies Agent calls with missing subagent_type", () => { | |
| const input: HookInput = { | |
| tool_name: "Agent", | |
| tool_input: { prompt: "Implement a feature" }, | |
| }; | |
| const result = processInput(input); | |
| expect(result).toHaveProperty("hookSpecificOutput.permissionDecision", "deny"); | |
| }); | |
| it("denies Agent calls with empty subagent_type", () => { | |
| const input: HookInput = { | |
| tool_name: "Agent", | |
| tool_input: { subagent_type: " ", prompt: "Do something" }, | |
| }; | |
| const result = processInput(input); | |
| expect(result).toHaveProperty("hookSpecificOutput.permissionDecision", "deny"); | |
| }); | |
| it("denies Agent calls with explicit general-purpose subagent_type", () => { | |
| const input: HookInput = { | |
| tool_name: "Agent", | |
| tool_input: { subagent_type: "general-purpose", prompt: "Do something" }, | |
| }; | |
| const result = processInput(input); | |
| expect(result).toHaveProperty("hookSpecificOutput.permissionDecision", "deny"); | |
| }); | |
| it("passes through non-Agent tool calls", () => { | |
| const input = { | |
| tool_name: "Bash", | |
| tool_input: { command: "echo hello" }, | |
| } as unknown as HookInput; | |
| expect(processInput(input)).toEqual({}); | |
| }); | |
| it("includes agent suggestions in deny reason", () => { | |
| const input: HookInput = { | |
| tool_name: "Agent", | |
| tool_input: { subagent_type: null, prompt: "Debug the failing hook" }, | |
| }; | |
| const result = processInput(input) as any; | |
| const reason = result.hookSpecificOutput.permissionDecisionReason; | |
| expect(reason).toContain("sleuth"); | |
| expect(reason).toContain("Always specify a specialist agent type"); | |
| }); | |
| it("uses description as fallback when prompt is missing", () => { | |
| const input: HookInput = { | |
| tool_name: "Agent", | |
| tool_input: { subagent_type: null, description: "Research codebase patterns" }, | |
| }; | |
| const result = processInput(input) as any; | |
| const reason = result.hookSpecificOutput.permissionDecisionReason; | |
| expect(reason).toContain("scout"); | |
| }); | |
| }); | |
| describe("buildDenyResponse", () => { | |
| it("returns properly structured deny response", () => { | |
| const result = buildDenyResponse("test prompt") as any; | |
| expect(result.hookSpecificOutput.hookEventName).toBe("PreToolUse"); | |
| expect(result.hookSpecificOutput.permissionDecision).toBe("deny"); | |
| expect(typeof result.hookSpecificOutput.permissionDecisionReason).toBe("string"); | |
| }); | |
| it("includes full agent list in reason", () => { | |
| const result = buildDenyResponse("test") as any; | |
| const reason = result.hookSpecificOutput.permissionDecisionReason; | |
| expect(reason).toContain("Full agent list:"); | |
| expect(reason).toContain("scout"); | |
| expect(reason).toContain("maestro"); | |
| }); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * PreToolUse:Agent hook — blocks Agent calls with missing/null subagent_type. | |
| * | |
| * The general-purpose default (Claude Code internal) grants all tools including | |
| * Write/Edit, which is too permissive. This hook denies the call and suggests | |
| * appropriate specialist agent types based on the prompt content. | |
| */ | |
| export interface HookInput { | |
| tool_name: string; | |
| tool_input: { | |
| subagent_type?: string | null; | |
| prompt?: string; | |
| description?: string; | |
| }; | |
| } | |
| interface AgentSuggestion { | |
| keyword: RegExp; | |
| agent: string; | |
| description: string; | |
| } | |
| export const AGENT_SUGGESTIONS: AgentSuggestion[] = [ | |
| { keyword: /\b(fix|bug|debug|error|fail|broken|trace|root.?cause)\b/i, agent: "sleuth", description: "Bug investigation and root cause analysis" }, | |
| { keyword: /\b(implement|build|create|add|feature|write code)\b/i, agent: "kraken", description: "Implementation (large) or spark (small fix)" }, | |
| { keyword: /\b(quick fix|patch|tweak|minor|small change)\b/i, agent: "spark", description: "Lightweight fixes and quick tweaks" }, | |
| { keyword: /\b(test|unit test|integration test|spec)\b/i, agent: "arbiter", description: "Unit and integration test execution" }, | |
| { keyword: /\b(e2e|end.to.end|acceptance)\b/i, agent: "atlas", description: "End-to-end and acceptance tests" }, | |
| { keyword: /\b(security|vulnerabilit|audit|cve|owasp)\b/i, agent: "aegis", description: "Security vulnerability analysis" }, | |
| { keyword: /\b(refactor|migrat|restructur|rewrite)\b/i, agent: "phoenix", description: "Refactoring and migration planning" }, | |
| { keyword: /\b(plan|design|architect|strateg)\b/i, agent: "architect", description: "Feature planning and design" }, | |
| { keyword: /\b(review|code review|check quality)\b/i, agent: "critic", description: "Code review" }, | |
| { keyword: /\b(document|readme|guide|explain)\b/i, agent: "scribe", description: "Documentation" }, | |
| { keyword: /\b(research|find|search|explore|codebase)\b/i, agent: "scout", description: "Codebase exploration and pattern finding" }, | |
| { keyword: /\b(external|docs|web|api|library|best practice)\b/i, agent: "oracle", description: "External research — web, docs, APIs" }, | |
| { keyword: /\b(perform|profil|bottleneck|slow|memory|race)\b/i, agent: "profiler", description: "Performance profiling" }, | |
| { keyword: /\b(release|version|changelog|deploy)\b/i, agent: "herald", description: "Release prep and changelog" }, | |
| ]; | |
| const DEFAULT_SUGGESTIONS = [ | |
| " - scout: Codebase exploration and pattern finding", | |
| " - kraken: Implementation (TDD workflow)", | |
| " - spark: Lightweight fixes and quick tweaks", | |
| ]; | |
| export function suggestAgents(prompt: string): string[] { | |
| const matches: string[] = []; | |
| for (const { keyword, agent, description } of AGENT_SUGGESTIONS) { | |
| if (keyword.test(prompt)) { | |
| matches.push(` - ${agent}: ${description}`); | |
| } | |
| } | |
| return matches.length > 0 ? matches.slice(0, 3) : DEFAULT_SUGGESTIONS; | |
| } | |
| const BLOCKED_TYPES = new Set(["general-purpose"]); | |
| export function isSubagentTypeValid(subagentType: string | null | undefined): boolean { | |
| if (typeof subagentType !== "string" || subagentType.trim().length === 0) { | |
| return false; | |
| } | |
| return !BLOCKED_TYPES.has(subagentType.trim().toLowerCase()); | |
| } | |
| export function buildDenyResponse(promptText: string): Record<string, unknown> { | |
| const suggestions = suggestAgents(promptText); | |
| const reason = `Agent call blocked: missing subagent_type (defaults to general-purpose with all tools). | |
| Always specify a specialist agent type. Based on your prompt, consider: | |
| ${suggestions.join("\n")} | |
| Full agent list: scout, oracle, kraken, spark, sleuth, aegis, architect, phoenix, critic, scribe, arbiter, atlas, profiler, herald, maestro | |
| Re-run with subagent_type set to the appropriate specialist.`; | |
| return { | |
| hookSpecificOutput: { | |
| hookEventName: "PreToolUse", | |
| permissionDecision: "deny", | |
| permissionDecisionReason: reason, | |
| }, | |
| }; | |
| } | |
| export function processInput(input: HookInput): Record<string, unknown> { | |
| if (input.tool_name !== "Agent") { | |
| return {}; | |
| } | |
| const subagentType = input.tool_input?.subagent_type; | |
| if (isSubagentTypeValid(subagentType)) { | |
| return {}; | |
| } | |
| const promptText = input.tool_input?.prompt ?? input.tool_input?.description ?? ""; | |
| return buildDenyResponse(promptText); | |
| } | |
| // --- stdin entry point (only runs when executed directly) --- | |
| function readStdin(): Promise<string> { | |
| return new Promise((resolve) => { | |
| let data = ""; | |
| process.stdin.setEncoding("utf8"); | |
| process.stdin.on("data", (chunk: string) => { data += chunk; }); | |
| process.stdin.on("end", () => resolve(data)); | |
| }); | |
| } | |
| async function main(): Promise<void> { | |
| let input: HookInput; | |
| try { | |
| input = JSON.parse(await readStdin()); | |
| } catch { | |
| console.log("{}"); | |
| return; | |
| } | |
| const result = processInput(input); | |
| console.log(JSON.stringify(result)); | |
| } | |
| main().catch(console.error); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment