diff --git a/packages/types/src/__tests__/hooks.test.ts b/packages/types/src/__tests__/hooks.test.ts new file mode 100644 index 0000000000..f29603cd15 --- /dev/null +++ b/packages/types/src/__tests__/hooks.test.ts @@ -0,0 +1,172 @@ +import { + DEFAULT_HOOK_DEFINITIONS, + HOOK_CAPTURE_MAX_BYTES, + HOOK_CAPTURE_TRUNCATION_MARKER, + HOOK_MODEL_OUTPUT_MAX_BYTES, + HOOK_MODEL_TRUNCATION_MARKER, + HOOK_TIMEOUT_MS, + classifyHookExit, + findDuplicateHookDefinitionIds, + getMatchingHooks, + hookDefinitionSchema, + hookDefinitionsSchema, + hookInvocationSchema, + sanitizeHookOutput, + truncateHookCaptureOutput, + truncateHookModelOutput, + type HookDefinition, +} from "../hooks.js" +import { clineMessageSchema, clineSaySchema } from "../message.js" + +const encoder = new TextEncoder() + +const sessionHook: HookDefinition = { + id: "session", + name: "Session setup", + enabled: true, + phase: "sessionStart", + executable: "/usr/bin/env", + argv: ["node", "setup.js"], +} + +const preToolHook: HookDefinition = { + id: "pre-read", + name: "Check reads", + enabled: true, + phase: "preToolUse", + toolMatcher: ["read_file", "list_files"], + executable: "node", + argv: ["check.js"], +} + +describe("hook definition contracts", () => { + it("accepts separate executables and argument arrays", () => { + expect(hookDefinitionSchema.parse(preToolHook)).toEqual(preToolHook) + expect(HOOK_TIMEOUT_MS).toBe(10_000) + }) + + it("enforces phase-specific exact tool matchers", () => { + expect(hookDefinitionSchema.safeParse({ ...sessionHook, toolMatcher: ["read_file"] }).success).toBe(false) + expect(hookDefinitionSchema.safeParse({ ...preToolHook, toolMatcher: [] }).success).toBe(false) + expect(hookDefinitionSchema.safeParse({ ...preToolHook, toolMatcher: ["read"] }).success).toBe(false) + }) + + it("rejects NUL characters in executables and arguments", () => { + expect(hookDefinitionSchema.safeParse({ ...sessionHook, executable: "node\0evil" }).success).toBe(false) + expect(hookDefinitionSchema.safeParse({ ...sessionHook, argv: ["safe", "bad\0arg"] }).success).toBe(false) + }) + + it("rejects duplicate IDs and exposes a reusable duplicate helper", () => { + expect(findDuplicateHookDefinitionIds([sessionHook, preToolHook, { ...preToolHook, id: "session" }])).toEqual([ + "session", + ]) + expect(hookDefinitionsSchema.safeParse([sessionHook, { ...preToolHook, id: sessionHook.id }]).success).toBe( + false, + ) + }) + + it("provides one immutable empty default", () => { + expect(DEFAULT_HOOK_DEFINITIONS).toEqual([]) + expect(Object.isFrozen(DEFAULT_HOOK_DEFINITIONS)).toBe(true) + }) +}) + +describe("hook invocation contract", () => { + it("requires only a tool name for pre-tool invocations", () => { + const invocation = { + version: 1, + hookRunId: "run-1", + phase: "preToolUse", + taskId: "task-1", + instanceId: "instance-1", + workspacePath: "/workspace", + tool: { name: "read_file" }, + } as const + + expect(hookInvocationSchema.parse(invocation)).toEqual(invocation) + expect(hookInvocationSchema.safeParse({ ...invocation, tool: undefined }).success).toBe(false) + }) +}) + +describe("hook matching", () => { + it("returns enabled matching hooks in configured order", () => { + const later = { ...preToolHook, id: "later", name: "Later" } + const disabled = { ...preToolHook, id: "disabled", enabled: false } + const definitions = [sessionHook, later, disabled, preToolHook] + + expect(getMatchingHooks(definitions, "sessionStart")).toEqual([sessionHook]) + expect(getMatchingHooks(definitions, "preToolUse", "read_file")).toEqual([later, preToolHook]) + expect(getMatchingHooks(definitions, "preToolUse", "write_to_file")).toEqual([]) + }) +}) + +describe("hook exit policy", () => { + it("keeps every session-start failure nonfatal", () => { + expect(classifyHookExit("sessionStart", 0)).toEqual({ status: "succeeded", decision: "continue" }) + expect(classifyHookExit("sessionStart", 2)).toEqual({ status: "failed", decision: "continue" }) + expect(classifyHookExit("sessionStart", null)).toEqual({ status: "failed", decision: "continue" }) + }) + + it("allows zero, blocks two, and fails closed otherwise before tools", () => { + expect(classifyHookExit("preToolUse", 0)).toEqual({ status: "succeeded", decision: "allow" }) + expect(classifyHookExit("preToolUse", 2)).toEqual({ status: "blocked", decision: "block" }) + expect(classifyHookExit("preToolUse", 1)).toEqual({ status: "failed", decision: "block" }) + expect(classifyHookExit("preToolUse", null)).toEqual({ status: "failed", decision: "block" }) + }) +}) + +describe("hook output policy", () => { + it("caps capture output at 64 KiB with an explicit marker", () => { + const result = truncateHookCaptureOutput("a".repeat(HOOK_CAPTURE_MAX_BYTES + 100)) + + expect(result.truncated).toBe(true) + expect(result.output.endsWith(HOOK_CAPTURE_TRUNCATION_MARKER)).toBe(true) + expect(encoder.encode(result.output).length).toBeLessThanOrEqual(HOOK_CAPTURE_MAX_BYTES) + expect(result.omittedBytes).toBeGreaterThan(0) + }) + + it("preserves the beginning and end within the 16 KiB model cap", () => { + const output = `BEGIN-${"x".repeat(HOOK_MODEL_OUTPUT_MAX_BYTES)}-END` + const result = truncateHookModelOutput(output) + + expect(result.output.startsWith("BEGIN-")).toBe(true) + expect(result.output.endsWith("-END")).toBe(true) + expect(result.output).toContain(HOOK_MODEL_TRUNCATION_MARKER) + expect(encoder.encode(result.output).length).toBeLessThanOrEqual(HOOK_MODEL_OUTPUT_MAX_BYTES) + }) + + it("truncates multibyte output only at valid UTF-8 boundaries", () => { + const result = truncateHookModelOutput("🐘".repeat(HOOK_MODEL_OUTPUT_MAX_BYTES)) + + expect(result.output).not.toContain("�") + expect(encoder.encode(result.output).length).toBeLessThanOrEqual(HOOK_MODEL_OUTPUT_MAX_BYTES) + }) + + it("removes terminal escapes and unsafe control characters", () => { + expect(sanitizeHookOutput("\u001b[31mred\u001b[0m\0\u0007\nnext")).toBe("red\nnext") + }) +}) + +describe("hook messages", () => { + it("accepts hook say messages with structured payloads", () => { + const message = { + ts: 1, + type: "say", + say: "hook", + hook: { + hookRunId: "run-1", + hookId: "pre-read", + name: "Check reads", + phase: "preToolUse", + status: "blocked", + matchedTool: "read_file", + outputSummary: "blocked by policy", + startedAt: 1, + completedAt: 2, + }, + } as const + + expect(clineSaySchema.parse("hook")).toBe("hook") + expect(clineMessageSchema.parse(message)).toEqual(message) + }) +}) diff --git a/packages/types/src/hooks.ts b/packages/types/src/hooks.ts new file mode 100644 index 0000000000..6f75be8817 --- /dev/null +++ b/packages/types/src/hooks.ts @@ -0,0 +1,265 @@ +import { z } from "zod" + +import { toolNamesSchema, type ToolName } from "./tool.js" + +export const HOOK_TIMEOUT_MS = 10_000 +export const HOOK_CAPTURE_MAX_BYTES = 64 * 1024 +export const HOOK_MODEL_OUTPUT_MAX_BYTES = 16 * 1024 +export const MAX_HOOK_DEFINITIONS = 50 +export const MAX_HOOK_TOOL_MATCHERS = 32 +export const MAX_HOOK_ARGV = 64 + +export const HOOK_CAPTURE_TRUNCATION_MARKER = "\n[hook output truncated at capture limit]\n" +export const HOOK_MODEL_TRUNCATION_MARKER = "\n[hook output omitted to fit model limit]\n" + +export const hookPhases = ["sessionStart", "preToolUse"] as const +export const hookPhaseSchema = z.enum(hookPhases) +export type HookPhase = z.infer + +const nulFreeString = (max: number, field: string) => + z + .string() + .max(max) + .refine((value) => !value.includes("\0"), `${field} must not contain NUL characters`) + +const hookDefinitionBaseSchema = z.object({ + id: z.string().min(1).max(64), + name: z.string().trim().min(1).max(80), + enabled: z.boolean(), + executable: z + .string() + .trim() + .min(1) + .max(4096) + .refine((value) => !value.includes("\0"), "Executable must not contain NUL characters"), + argv: z.array(nulFreeString(4096, "Arguments")).max(MAX_HOOK_ARGV), +}) + +export const sessionStartHookDefinitionSchema = hookDefinitionBaseSchema.extend({ + phase: z.literal("sessionStart"), + toolMatcher: z.never().optional(), +}) + +export const preToolUseHookDefinitionSchema = hookDefinitionBaseSchema.extend({ + phase: z.literal("preToolUse"), + toolMatcher: z.array(toolNamesSchema).min(1).max(MAX_HOOK_TOOL_MATCHERS), +}) + +export const hookDefinitionSchema = z.discriminatedUnion("phase", [ + sessionStartHookDefinitionSchema, + preToolUseHookDefinitionSchema, +]) +export type HookDefinition = z.infer + +export function findDuplicateHookDefinitionIds(definitions: readonly Pick[]): string[] { + const seen = new Set() + const duplicates = new Set() + + for (const { id } of definitions) { + if (seen.has(id)) { + duplicates.add(id) + } + seen.add(id) + } + + return [...duplicates] +} + +export const hookDefinitionsSchema = z + .array(hookDefinitionSchema) + .max(MAX_HOOK_DEFINITIONS) + .superRefine((definitions, context) => { + for (const duplicateId of findDuplicateHookDefinitionIds(definitions)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Hook definition ID must be unique: ${duplicateId}`, + path: [definitions.findIndex(({ id }) => id === duplicateId), "id"], + }) + } + }) + +export const DEFAULT_HOOK_DEFINITIONS: readonly HookDefinition[] = Object.freeze([]) + +export const hookInvocationSchema = z.discriminatedUnion("phase", [ + z.object({ + version: z.literal(1), + hookRunId: z.string().min(1).max(128), + phase: z.literal("sessionStart"), + taskId: z.string().min(1).max(128), + instanceId: z.string().min(1).max(128), + workspacePath: z.string().min(1).max(4096), + }), + z.object({ + version: z.literal(1), + hookRunId: z.string().min(1).max(128), + phase: z.literal("preToolUse"), + taskId: z.string().min(1).max(128), + instanceId: z.string().min(1).max(128), + workspacePath: z.string().min(1).max(4096), + tool: z.object({ name: toolNamesSchema }), + }), +]) +export type HookInvocation = z.infer + +export const hookRunStatuses = ["succeeded", "blocked", "failed", "timedOut", "cancelled"] as const +export const hookRunStatusSchema = z.enum(hookRunStatuses) +export type HookRunStatus = z.infer + +export const hookRunResultSchema = z.object({ + hookRunId: z.string().min(1).max(128), + hookId: z.string().min(1).max(64), + phase: hookPhaseSchema, + status: hookRunStatusSchema, + exitCode: z.number().int().optional(), + stdoutSummary: z.string().optional(), + stderrSummary: z.string().optional(), + truncated: z.boolean(), + startedAt: z.number(), + completedAt: z.number(), +}) +export type HookRunResult = z.infer + +export const hookMessageStatuses = [...hookRunStatuses, "running", "interrupted"] as const +export const hookMessageStatusSchema = z.enum(hookMessageStatuses) + +export const hookMessageSchema = z.object({ + hookRunId: z.string().min(1).max(128), + hookId: z.string().min(1).max(64), + name: z.string().min(1).max(80), + phase: hookPhaseSchema, + status: hookMessageStatusSchema, + matchedTool: toolNamesSchema.optional(), + outputSummary: z.string().optional(), + errorSummary: z.string().optional(), + truncated: z.boolean().optional(), + startedAt: z.number(), + completedAt: z.number().optional(), +}) +export type HookMessage = z.infer + +export function hookMatches(definition: HookDefinition, phase: "sessionStart"): boolean +export function hookMatches(definition: HookDefinition, phase: "preToolUse", toolName: ToolName): boolean +export function hookMatches(definition: HookDefinition, phase: HookPhase, toolName?: ToolName): boolean { + if (definition.phase !== phase) { + return false + } + + return ( + phase === "sessionStart" || + (definition.phase === "preToolUse" && toolName !== undefined && definition.toolMatcher.includes(toolName)) + ) +} + +export function getMatchingHooks(definitions: readonly HookDefinition[], phase: "sessionStart"): HookDefinition[] +export function getMatchingHooks( + definitions: readonly HookDefinition[], + phase: "preToolUse", + toolName: ToolName, +): HookDefinition[] +export function getMatchingHooks( + definitions: readonly HookDefinition[], + phase: HookPhase, + toolName?: ToolName, +): HookDefinition[] { + return definitions.filter((definition) => definition.enabled && hookMatchesDefinition(definition, phase, toolName)) +} + +function hookMatchesDefinition(definition: HookDefinition, phase: HookPhase, toolName?: ToolName): boolean { + if (phase === "sessionStart") { + return hookMatches(definition, phase) + } + + return toolName !== undefined && hookMatches(definition, phase, toolName) +} + +export type HookExitClassification = + | { status: "succeeded"; decision: "continue" | "allow" } + | { status: "blocked"; decision: "block" } + | { status: "failed"; decision: "continue" | "block" } + +export function classifyHookExit(phase: HookPhase, exitCode: number | null): HookExitClassification { + if (phase === "sessionStart") { + return exitCode === 0 + ? { status: "succeeded", decision: "continue" } + : { status: "failed", decision: "continue" } + } + + if (exitCode === 0) { + return { status: "succeeded", decision: "allow" } + } + + return exitCode === 2 ? { status: "blocked", decision: "block" } : { status: "failed", decision: "block" } +} + +export interface TruncatedHookOutput { + output: string + truncated: boolean + omittedBytes: number +} + +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder("utf-8", { fatal: true }) + +function decodePrefix(bytes: Uint8Array, maxBytes: number): string { + for (let end = Math.min(maxBytes, bytes.length); end >= 0; end--) { + try { + return textDecoder.decode(bytes.subarray(0, end)) + } catch { + // Move to the previous UTF-8 boundary. + } + } + return "" +} + +function decodeSuffix(bytes: Uint8Array, maxBytes: number): string { + for (let start = Math.max(0, bytes.length - maxBytes); start <= bytes.length; start++) { + try { + return textDecoder.decode(bytes.subarray(start)) + } catch { + // Move to the next UTF-8 boundary. + } + } + return "" +} + +export function truncateHookCaptureOutput(output: string): TruncatedHookOutput { + const bytes = textEncoder.encode(output) + if (bytes.length <= HOOK_CAPTURE_MAX_BYTES) { + return { output, truncated: false, omittedBytes: 0 } + } + + const markerBytes = textEncoder.encode(HOOK_CAPTURE_TRUNCATION_MARKER).length + const retained = decodePrefix(bytes, HOOK_CAPTURE_MAX_BYTES - markerBytes) + const retainedBytes = textEncoder.encode(retained).length + + return { + output: retained + HOOK_CAPTURE_TRUNCATION_MARKER, + truncated: true, + omittedBytes: bytes.length - retainedBytes, + } +} + +export function truncateHookModelOutput(output: string): TruncatedHookOutput { + const bytes = textEncoder.encode(output) + if (bytes.length <= HOOK_MODEL_OUTPUT_MAX_BYTES) { + return { output, truncated: false, omittedBytes: 0 } + } + + const markerBytes = textEncoder.encode(HOOK_MODEL_TRUNCATION_MARKER).length + const retainedBudget = HOOK_MODEL_OUTPUT_MAX_BYTES - markerBytes + const prefix = decodePrefix(bytes, Math.ceil(retainedBudget / 2)) + const suffix = decodeSuffix(bytes, Math.floor(retainedBudget / 2)) + const retainedBytes = textEncoder.encode(prefix).length + textEncoder.encode(suffix).length + + return { + output: prefix + HOOK_MODEL_TRUNCATION_MARKER + suffix, + truncated: true, + omittedBytes: bytes.length - retainedBytes, + } +} + +export function sanitizeHookOutput(output: string): string { + // ANSI control sequences are removed before other non-printable characters. + // eslint-disable-next-line no-control-regex + return output.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "").replace(/[^\t\n\r\x20-\x7E\u0080-\uFFFF]/g, "") +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 82588ae537..d1c376ca3b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -12,6 +12,7 @@ export * from "./followup.js" export * from "./git.js" export * from "./global-settings.js" export * from "./history.js" +export * from "./hooks.js" export * from "./image-generation.js" export * from "./ipc.js" export * from "./mcp.js" diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 28d5af82ac..c3fff6a25c 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -1,5 +1,7 @@ import { z } from "zod" +import { hookMessageSchema } from "./hooks.js" + /** * ClineAsk */ @@ -171,6 +173,7 @@ export const clineSays = [ "user_edit_todos", "too_many_tools_warning", "tool", + "hook", ] as const export const clineSaySchema = z.enum(clineSays) @@ -259,6 +262,7 @@ export const clineMessageSchema = z.object({ conversationHistoryIndex: z.number().optional(), checkpoint: z.record(z.string(), z.unknown()).optional(), progressStatus: toolProgressStatusSchema.optional(), + hook: hookMessageSchema.optional(), /** * Data for successful context condensation. * Present when `say: "condense_context"` and `partial: false`.