Skip to content

Instantly share code, notes, and snippets.

@stephenfeather
Created April 4, 2026 19:11
Show Gist options
  • Select an option

  • Save stephenfeather/7dea567f35ca0040309b13ae04a83bfc to your computer and use it in GitHub Desktop.

Select an option

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
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");
});
});
});
/**
* 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