From 7f73801ea26f822996a2da55eed9bf7c3e280485 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:37:23 +0800 Subject: [PATCH] feat(cli): support user-owned PreToolUse hooks that can deny a tool call (#217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a hooks.PreToolUse section to user settings so operators can declare a command that runs before a tool executes and can block it (exit code 2 or a JSON permissionDecision of deny). A hook can only make a decision more restrictive — silence leaves the normal approval flow unchanged, so a hook can never relax an approval. Hook configuration is read only from the user scope; a trusted project scope cannot inject a hook. Malformed configuration, an oversized configuration, and a hook timeout fail closed before any tool side effect. The hook command, arguments, and deny reason are redacted. Includes a redacted read-only listing (--list-hooks) that reports an empty inventory when no hooks section exists. --- src/agent.ts | 35 ++ src/effective-settings.ts | 12 +- src/hook-contract.ts | 578 ++++++++++++++++++++++++ src/index.ts | 52 +++ tests/integration/hook-contract.test.ts | 312 +++++++++++++ tests/unit/hook-contract.test.ts | 376 +++++++++++++++ 6 files changed, 1364 insertions(+), 1 deletion(-) create mode 100644 src/hook-contract.ts create mode 100644 tests/integration/hook-contract.test.ts create mode 100644 tests/unit/hook-contract.test.ts diff --git a/src/agent.ts b/src/agent.ts index f6660cc..7f64a99 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -7,6 +7,7 @@ import type { Workspace } from "./workspace.js"; import type { ApprovalMode } from "./approval.js"; import { needsApproval, promptApproval } from "./approval.js"; import { evaluateCommandPolicy, policyDenialMessage } from "./command-policy.js"; +import { evaluatePreToolUseHooks, type PreToolUseHook } from "./hook-contract.js"; import { folderTrustDenialMessage } from "./folder-trust.js"; import { estimateCostUsd, lookupModelPrice, formatCostUsd } from "./cost.js"; import { buildEffectiveSystemPrompt } from "./instruction-context.js"; @@ -44,6 +45,10 @@ export interface AgentOptions { // each file a mutating-file tool touches is captured before the tool runs, so // the turn's workspace mutations can later be reversed without a Git reset. turnImages?: TurnImageCollector; + // User-owned PreToolUse hooks (resolved from the user settings scope by the + // caller). Each matching hook runs as a deny-only gate before a tool executes; + // a hook can only block a call, never approve it. Empty/undefined ⇒ no hooks. + preToolUseHooks?: readonly PreToolUseHook[]; } // Cumulative usage and cost reported after each round. `estimatedCostUsd` is an @@ -187,6 +192,7 @@ export async function runAgent( const tools = createTools(); const toolMap = new Map(tools.map((t) => [t.name, t])); const schemas = toolSchemasForOpenAI(tools); + const preToolUseHooks = opts.preToolUseHooks ?? []; const messages: SessionMessage[] = [...existingMessages]; @@ -403,6 +409,7 @@ export async function runAgent( opts.workspace, opts.mutatingAllowed ?? true, requestApproval, + preToolUseHooks, opts.turnImages, ); sink.toolResult({ id: tc.id, name: tc.name, result, round }); @@ -442,6 +449,7 @@ async function executeToolCall( workspace: Workspace, mutatingAllowed: boolean, requestApproval: (name: string, args: Record) => Promise, + preToolUseHooks: readonly PreToolUseHook[], turnImages?: TurnImageCollector, ): Promise { const tool = toolMap.get(tc.name); @@ -474,6 +482,33 @@ async function executeToolCall( } } + // PreToolUse hook gate (user-owned, deny-only): a matching hook may block the + // call before approval is considered; silence leaves the normal approval flow + // unchanged. A hook can only ever deny, never approve. A hook timeout or spawn + // failure fails closed (the call is blocked) before any tool side effect. + if (preToolUseHooks.length > 0) { + let hookArgs: Record = {}; + try { + const parsed = JSON.parse(tc.arguments); + if (parsed && typeof parsed === "object") hookArgs = parsed as Record; + } catch { + /* leave hookArgs empty */ + } + const decision = await evaluatePreToolUseHooks( + preToolUseHooks, + { toolName: tc.name, toolInput: hookArgs }, + { cwd: workspace.root }, + ); + if (decision.denied) { + return { + content: decision.reason + ? `Tool call denied by a user PreToolUse hook: ${decision.reason}` + : "Tool call denied by a user PreToolUse hook", + isError: true, + }; + } + } + if (needsApproval(approvalMode, tool.category)) { let parsed: Record = {}; try { diff --git a/src/effective-settings.ts b/src/effective-settings.ts index 0eb6b0e..bc21a58 100644 --- a/src/effective-settings.ts +++ b/src/effective-settings.ts @@ -47,6 +47,7 @@ export const REGISTERED_SETTINGS_KEYS: readonly string[] = [ "approval", "ui", "workflows", + "hooks", "diagnostics", "probeTimeoutMs", ]; @@ -69,6 +70,7 @@ const OBJECT_SECTIONS = new Set([ "approval", "ui", "workflows", + "hooks", "diagnostics", ]); @@ -76,7 +78,15 @@ const OBJECT_SECTIONS = new Set([ // never set at all. sandbox/approval are security policy; profiles/defaultProfile // select the model endpoint and credential source, so a repository can never // silently replace the selected endpoint or credential (only the user scope may). -const PROTECTED_PROJECT_SECTIONS = new Set(["sandbox", "approval", "profiles", "defaultProfile"]); +// hooks run arbitrary local shell commands before tool calls, so a repository can +// never inject a hook either — only the user-owned scope may declare one. +const PROTECTED_PROJECT_SECTIONS = new Set([ + "sandbox", + "approval", + "profiles", + "defaultProfile", + "hooks", +]); // Fields within a section a trusted project scope may never set. The model // endpoint and the credential-source variable name are credential-bearing, so a diff --git a/src/hook-contract.ts b/src/hook-contract.ts new file mode 100644 index 0000000..3f331f7 --- /dev/null +++ b/src/hook-contract.ts @@ -0,0 +1,578 @@ +// Hook contract: declare user-owned `PreToolUse` hooks as a versioned section of +// the unified user settings file (settings.ts), then evaluate them as a deny-only +// gate before a tool executes in the agent loop (agent.ts) — all without changing +// core code. A hook is a named tool matcher plus a bounded local command; before a +// matching tool call runs, the command is invoked with the tool call as JSON +// context and may return a deny decision (process exit code 2, or a JSON +// `permissionDecision` of `deny`) that blocks the call. A hook can only ever make +// a decision MORE restrictive: silence — or an `allow`/`ask` decision — leaves the +// normal approval flow unchanged, so a hook can never relax an approval. +// +// Trust boundary: hook configuration is untrusted input read ONLY from the +// user-owned settings scope (resolveSettingsPath never resolves a project-local +// path), so an untrusted repository cannot define or run a hook. A hook may only +// deny, never approve, so it cannot widen the permission system; it is best-effort +// policy, not a hard security boundary. Raw credential fields inside a hook are +// rejected rather than ignored, matching the workflow/provider contracts. The hook +// command runs directly (no shell, so its arguments cannot be reinterpreted), +// confined to the workspace and bounded by a hard timeout and an output-size cap; +// a timeout or a spawn failure fails closed (the call is denied) before any tool +// side effect. The hook command, its arguments, and any deny reason are redacted +// (no secrets, no home path) in every listing, error, and tool result. An +// unsupported contract version, an unknown/misspelled key, an invalid matcher, or +// a malformed entry fails closed before any side effect, consistent with the +// effective-settings registry (effective-settings.ts) and the workflow contract. + +import fs from "node:fs"; +import { spawn } from "node:child_process"; +import { z } from "zod"; +import { resolveSettingsPath } from "./settings.js"; +import { redactHomePath, redactSecrets } from "./permission-impact.js"; + +export const HOOK_CONTRACT_SCHEMA = "oh-my-cli.hook-contract"; +export const HOOK_CONTRACT_VERSION = 1; + +// The contract versions this build can negotiate. A settings file declaring a +// version outside this range is refused (fail closed) rather than coerced, so a +// future format change cannot silently reinterpret an older or newer definition. +export const SUPPORTED_HOOK_CONTRACT_VERSIONS: readonly number[] = [1]; + +// Bounded execution window (milliseconds) for one hook. A hook slower than this is +// killed and the call fails closed, so a hung hook can never block the run or a +// tool side effect. +export const HOOK_TIMEOUT_MS = 5_000; + +// Bounded captured decision output (bytes, across stdout + stderr). A hook returns +// its decision as a small JSON object; anything larger is truncated, so an +// unbounded producer cannot exhaust memory or flood the report. +export const HOOK_MAX_OUTPUT_BYTES = 8_192; + +// Per-string bound applied to the tool-call context handed to a hook on stdin, so +// a tool with a large argument (e.g. a file's content) cannot stall the hook's +// stdin pipe. The JSON stays valid; long strings are truncated. +const HOOK_INPUT_STRING_MAX = 2_048; + +// Raw secret field names that must never appear in a hook entry. The parser rejects +// (rather than ignores) them so a plaintext secret cannot become a supported +// configuration path. Mirrors the workflow/model/provider contracts. +const FORBIDDEN_HOOK_KEYS = [ + "apiKey", + "apikey", + "api_key", + "key", + "token", + "secret", + "password", + "credential", +]; + +// A matcher is `"*"` (every tool) or a single built-in tool name. Tool names are +// portable lowercase identifiers; the matcher is validated to that shape so a +// typo or a glob cannot silently match nothing. +export const HOOK_MATCHER_ALL = "*"; +export const HOOK_TOOL_NAME_RE = /^[a-z][a-z0-9_-]*$/; + +const MAX_MATCHER = 128; +const MAX_COMMAND = 4_096; +const MAX_ARG = 4_096; +const MAX_ARGS = 64; +const MAX_REASON = 500; + +// One declared PreToolUse hook: a tool matcher and the bounded command to run. +export interface PreToolUseHook { + /** `"*"` or an exact tool name (validated against HOOK_TOOL_NAME_RE). */ + matcher: string; + command: string; + args: string[]; +} + +// The validated `hooks` section: a negotiated contract version and the validated +// PreToolUse hooks. +export interface HookContract { + contractVersion: number; + preToolUse: PreToolUseHook[]; +} + +const HookEntrySchema = z + .object({ + matcher: z + .string() + .min(1, "hook.matcher must be a non-empty string") + .max(MAX_MATCHER, "hook.matcher is too long"), + command: z + .string() + .min(1, "hook.command must be a non-empty string") + .max(MAX_COMMAND, "hook.command is too long"), + args: z + .array(z.string().max(MAX_ARG, "hook argument is too long")) + .max(MAX_ARGS, "too many hook arguments") + .optional(), + }) + .strict(); + +function isValidMatcher(matcher: string): boolean { + return matcher === HOOK_MATCHER_ALL || HOOK_TOOL_NAME_RE.test(matcher); +} + +function assertNoForbiddenKeys(obj: Record, label: string): void { + for (const forbidden of FORBIDDEN_HOOK_KEYS) { + if (forbidden in obj) { + throw new Error( + `Settings error: ${label} field "${forbidden}" is a raw credential field; ` + + "reference credentials via the environment, never inline in a hook", + ); + } + } +} + +interface HooksSection { + found: boolean; + section?: unknown; +} + +// Read and return only the optional `hooks` section of the user settings file. +// Throws a redacted, actionable error on invalid JSON or a non-object root — before +// any hook is evaluated. A missing file or absent `hooks` section is not an error +// here; the caller decides how to treat that. Reads only the user-owned scope. +function readHooksSection(settingsPath: string): HooksSection { + let raw: string; + try { + raw = fs.readFileSync(settingsPath, "utf8"); + } catch { + return { found: false }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Settings error: ${redactHomePath(settingsPath)} contains invalid JSON`); + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Settings error: ${redactHomePath(settingsPath)} must contain a JSON object`); + } + + const root = parsed as Record; + const hooks = root.hooks; + if (hooks === undefined) { + return { found: true }; + } + return { found: true, section: hooks }; +} + +// Validate an untrusted `hooks` section into a HookContract. Negotiates the contract +// version (fail closed on an unsupported version), rejects unknown envelope keys, +// raw credential fields per hook, malformed entries, and invalid matchers. Every +// failure raises a redacted, deterministic error. +export function parseHookContract(section: unknown): HookContract { + if (section === null || typeof section !== "object" || Array.isArray(section)) { + throw new Error("Settings error: settings.hooks must be an object"); + } + const obj = section as Record; + + // Envelope: only contractVersion + PreToolUse are allowed. An unknown key is a + // typo (e.g. "version", "preToolUse") and is rejected rather than ignored. + for (const key of Object.keys(obj)) { + if (key !== "contractVersion" && key !== "PreToolUse") { + throw new Error(`Settings error: settings.hooks has unknown key "${key}"`); + } + } + + // Version negotiation: a required integer within the supported range. + const version = obj.contractVersion; + if (version === undefined) { + throw new Error("Settings error: settings.hooks.contractVersion is required"); + } + if (typeof version !== "number" || !Number.isInteger(version)) { + throw new Error("Settings error: settings.hooks.contractVersion must be an integer"); + } + if (!SUPPORTED_HOOK_CONTRACT_VERSIONS.includes(version)) { + throw new Error( + `Settings error: hook contract version ${version} is not supported; ` + + `supported versions: ${SUPPORTED_HOOK_CONTRACT_VERSIONS.join(", ")}`, + ); + } + + const rawHooks = obj.PreToolUse; + if (rawHooks === undefined) { + // A hooks section that declares no PreToolUse event is an empty inventory. + return { contractVersion: version, preToolUse: [] }; + } + if (!Array.isArray(rawHooks)) { + throw new Error("Settings error: settings.hooks.PreToolUse must be an array"); + } + + const preToolUse: PreToolUseHook[] = []; + rawHooks.forEach((raw, index) => { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`Settings error: settings.hooks.PreToolUse[${index}] must be an object`); + } + const entry = raw as Record; + assertNoForbiddenKeys(entry, `settings.hooks.PreToolUse[${index}]`); + + const result = HookEntrySchema.safeParse(entry); + if (!result.success) { + const issues = result.error.issues.map((i) => i.message).join("; "); + throw new Error(`Settings error: settings.hooks.PreToolUse[${index}]: ${issues}`); + } + if (!isValidMatcher(result.data.matcher)) { + throw new Error( + `Settings error: settings.hooks.PreToolUse[${index}].matcher "${result.data.matcher}" ` + + `must be "*" or a tool name (${HOOK_TOOL_NAME_RE.source})`, + ); + } + preToolUse.push({ + matcher: result.data.matcher, + command: result.data.command, + args: result.data.args ?? [], + }); + }); + + return { contractVersion: version, preToolUse }; +} + +// Read the user settings file, negotiate the hook contract, and return the declared +// PreToolUse hooks. Returns an empty array when no `hooks` section exists (or the +// file is missing); throws a redacted error on a present-but-malformed section. +// Reads only the user-owned scope, so a project-local file is never consulted. +export function resolvePreToolUseHooks(opts: { settingsPath?: string } = {}): PreToolUseHook[] { + const settingsPath = resolveSettingsPath(opts.settingsPath); + const { section } = readHooksSection(settingsPath); + if (section === undefined) return []; + return parseHookContract(section).preToolUse; +} + +// Whether a hook's matcher selects a given tool name. +export function hookMatches(hook: PreToolUseHook, toolName: string): boolean { + return hook.matcher === HOOK_MATCHER_ALL || hook.matcher === toolName; +} + +// The raw, unredacted outcome of running one hook. Redaction happens when a reason +// is surfaced, never here. +export interface HookRunResult { + exitCode: number | null; + timedOut: boolean; + outputCapped: boolean; + stdout: string; + stderr: string; + /** Present when the process could not be spawned (e.g. command not found). */ + spawnError?: string; +} + +export interface HookRunOptions { + command: string; + args: string[]; + /** JSON tool-call context written to the hook's stdin. */ + input: string; + cwd: string; + env: Record; + timeoutMs: number; + maxOutputBytes: number; +} + +// Runs the declared hook command and reports its raw outcome. Injectable so tests +// can drive the decision logic deterministically without spawning real processes. +export type HookRunner = (opts: HookRunOptions) => Promise; + +// Default runner: spawn the command directly (no shell, so arguments cannot be +// reinterpreted), confined to `cwd`, bounded by a hard timeout and an output-size +// cap, with no controlling terminal. The tool-call context is written to stdin and +// the stream is closed; a hook that never reads stdin cannot stall the run (a write +// error is swallowed). On timeout or cap the process is killed; the outcome is +// reported, never thrown. +export const spawnHookRunner: HookRunner = (opts) => + new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let total = 0; + let timedOut = false; + let outputCapped = false; + let settled = false; + + const proc = spawn(opts.command, opts.args, { + cwd: opts.cwd, + env: opts.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + + const kill = () => { + try { + proc.kill("SIGKILL"); + } catch { + // The process already exited; nothing to kill. + } + }; + + const timer = setTimeout(() => { + if (settled) return; + timedOut = true; + kill(); + }, opts.timeoutMs); + + const onData = (stream: "stdout" | "stderr") => (chunk: Buffer) => { + if (outputCapped || timedOut) return; + const text = chunk.toString("utf8"); + if (total + text.length > opts.maxOutputBytes) { + const remaining = Math.max(0, opts.maxOutputBytes - total); + if (stream === "stdout") stdout += text.slice(0, remaining); + else stderr += text.slice(0, remaining); + total = opts.maxOutputBytes; + outputCapped = true; + kill(); + return; + } + if (stream === "stdout") stdout += text; + else stderr += text; + total += text.length; + }; + + proc.stdout.on("data", onData("stdout")); + proc.stderr.on("data", onData("stderr")); + + proc.on("error", (err: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ + exitCode: null, + timedOut, + outputCapped, + stdout: "", + stderr: "", + spawnError: err.message, + }); + }); + + proc.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ exitCode: code, timedOut, outputCapped, stdout, stderr }); + }); + + // Hand the bounded tool-call context to the hook, then close stdin. A hook that + // exits before reading (or never reads) must not crash the run on EPIPE. + try { + proc.stdin.on("error", () => { + /* the hook exited early; the context is simply unused */ + }); + proc.stdin.end(opts.input); + } catch { + /* ignore */ + } + }); + +export interface PreToolUseContext { + toolName: string; + toolInput: Record; +} + +// The outcome of evaluating the matching hooks for one tool call: either a deny +// (with a redacted reason) or silence (the normal approval flow applies). +export interface HookDecision { + denied: boolean; + reason: string; +} + +export interface EvaluateHooksOptions { + cwd: string; + env?: Record; + /** Override the hook runner (tests). Defaults to spawning the command. */ + runner?: HookRunner; + timeoutMs?: number; + maxOutputBytes?: number; +} + +const SILENT: HookDecision = { denied: false, reason: "" }; + +// Evaluate the PreToolUse hooks that match a tool call, in declared order, stopping +// at the first deny. No matching hook ⇒ silence. A hook may only deny; an +// `allow`/`ask` decision or any non-deny outcome is treated as silence so the +// normal approval flow is unchanged. A hook timeout or spawn failure fails closed +// (deny) before any tool side effect. +export async function evaluatePreToolUseHooks( + hooks: readonly PreToolUseHook[], + ctx: PreToolUseContext, + opts: EvaluateHooksOptions, +): Promise { + const matching = hooks.filter((h) => hookMatches(h, ctx.toolName)); + if (matching.length === 0) return SILENT; + + const runner = opts.runner ?? spawnHookRunner; + const env = opts.env ?? process.env; + const timeoutMs = opts.timeoutMs ?? HOOK_TIMEOUT_MS; + const maxOutputBytes = opts.maxOutputBytes ?? HOOK_MAX_OUTPUT_BYTES; + const input = JSON.stringify({ + hook_event_name: "PreToolUse", + tool_name: ctx.toolName, + tool_input: boundForHook(ctx.toolInput, HOOK_INPUT_STRING_MAX), + }); + + for (const hook of matching) { + const run = await runner({ + command: hook.command, + args: hook.args, + input, + cwd: opts.cwd, + env, + timeoutMs, + maxOutputBytes, + }); + const decision = interpretHookRun(run); + if (decision.denied) return decision; + } + return SILENT; +} + +// Interpret one hook run into a deny/silent decision. Fail closed (deny) on a +// timeout or a spawn failure; honor an explicit deny (JSON `permissionDecision` +// or exit code 2); treat everything else as silence (a hook can only ever deny). +function interpretHookRun(run: HookRunResult): HookDecision { + if (run.timedOut) { + return { + denied: true, + reason: "a PreToolUse hook exceeded its timeout and the call was denied (fail closed)", + }; + } + if (run.spawnError) { + return { + denied: true, + reason: "a PreToolUse hook failed to start and the call was denied (fail closed)", + }; + } + const parsed = parseDecision(run.stdout); + if (parsed && parsed.decision === "deny") { + return { + denied: true, + reason: redactReason(parsed.reason) ?? "a PreToolUse hook denied the tool call", + }; + } + if (run.exitCode === 2) { + return { + denied: true, + reason: redactReason(parsed?.reason) ?? "a PreToolUse hook denied the tool call (exit 2)", + }; + } + return SILENT; +} + +// Parse a hook's stdout as a JSON decision object. Returns null when stdout is +// empty, not JSON, or carries no `permissionDecision` — none of which is a deny. +function parseDecision(stdout: string): { decision: string; reason?: string } | null { + const trimmed = stdout.trim(); + if (trimmed === "") return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const obj = parsed as Record; + if (typeof obj.permissionDecision !== "string") return null; + const reason = typeof obj.reason === "string" ? obj.reason : undefined; + return { decision: obj.permissionDecision, reason }; +} + +// Replace every occurrence of the host home directory with `~`. Unlike +// redactHomePath (which only collapses a leading prefix), an untrusted hook +// reason can embed the home path mid-string, so any occurrence must be redacted. +function redactHomeOccurrences(text: string): string { + const home = process.env.HOME ?? process.env.USERPROFILE; + if (home && home.length > 1 && text.includes(home)) { + return text.split(home).join("~"); + } + return text; +} + +// Redact an untrusted hook reason for safe surfacing: strip secrets, collapse the +// home path (anywhere it appears), remove control characters, and bound the +// length. Returns undefined when nothing usable remains. +function redactReason(reason: string | undefined): string | undefined { + if (reason === undefined) return undefined; + let text = redactSecrets(reason).text; + text = redactHomeOccurrences(text); + text = text.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); + if (text === "") return undefined; + return text.length <= MAX_REASON ? text : `${text.slice(0, MAX_REASON - 1)}…`; +} + +// Deep-copy a tool-call context with long strings truncated, so a tool with a large +// argument cannot stall the hook's stdin while keeping the JSON valid. +function boundForHook(value: unknown, maxLen: number): unknown { + if (typeof value === "string") { + return value.length <= maxLen ? value : `${value.slice(0, maxLen)}…`; + } + if (Array.isArray(value)) return value.map((v) => boundForHook(v, maxLen)); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) out[k] = boundForHook(v, maxLen); + return out; + } + return value; +} + +// The redacted, serializable result of listing the declared hooks. No secret and no +// unredacted home path; the command is home-redacted and arguments are reported as a +// count (their values are never surfaced). +export interface HookListEntry { + event: "PreToolUse"; + matcher: string; + command: string; + args: number; +} + +export interface HookListReport { + schema: string; + version: number; + contractVersion: number; + hooks: HookListEntry[]; + settings: string; +} + +// Read the user settings file, negotiate the hook contract, and build the redacted +// list report. Like the workflow/profile listings, listing never throws when no +// `hooks` section exists — it reports an empty inventory — but a +// present-but-malformed section still fails closed. +export function collectHookList(opts: { settingsPath?: string } = {}): HookListReport { + const settingsPath = resolveSettingsPath(opts.settingsPath); + const { found, section } = readHooksSection(settingsPath); + const contract = section === undefined ? undefined : parseHookContract(section); + const hooks: HookListEntry[] = (contract?.preToolUse ?? []).map((h) => ({ + event: "PreToolUse" as const, + matcher: h.matcher, + command: redactHomePath(h.command), + args: h.args.length, + })); + return { + schema: HOOK_CONTRACT_SCHEMA, + version: HOOK_CONTRACT_VERSION, + contractVersion: contract?.contractVersion ?? HOOK_CONTRACT_VERSION, + hooks, + settings: found ? redactHomePath(settingsPath) : `${redactHomePath(settingsPath)} (not found)`, + }; +} + +// A redacted, human-readable summary of the declared hooks. +export function formatHookList(report: HookListReport): string { + const lines: string[] = []; + lines.push("Hooks"); + lines.push("─".repeat(40)); + lines.push( + `Contract: ${report.schema} v${report.version} (settings contract version ${report.contractVersion})`, + ); + lines.push(`Settings: ${report.settings}`); + if (report.hooks.length === 0) { + lines.push("PreToolUse: (none)"); + } else { + lines.push(`PreToolUse: ${report.hooks.length}`); + for (const h of report.hooks) { + const command = redactSecrets(h.command).text; + const argWord = h.args === 1 ? "arg" : "args"; + lines.push(` ${h.matcher} — ${command} (${h.args} ${argWord})`); + } + } + return lines.join("\n"); +} diff --git a/src/index.ts b/src/index.ts index 5946cce..4504f31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,12 @@ import { } from "./slash-command.js"; import { resolveEffectiveSettings, formatEffectiveSettings } from "./effective-settings.js"; import { collectWorkflowList, formatWorkflowList } from "./workflow-contract.js"; +import { + collectHookList, + formatHookList, + resolvePreToolUseHooks, + type PreToolUseHook, +} from "./hook-contract.js"; import { runWorkflow, formatWorkflowStepLine } from "./workflow-runner.js"; import { collectProfileList, @@ -287,6 +293,7 @@ program .option("--settings ", "Unified settings file for model config and --health (default ~/.oh-my-cli/settings.json)") .option("--effective-settings", "Show the effective, redacted, hierarchical settings snapshot (user + trusted project, validated; read-only) and exit") .option("--list-workflows", "List declared workflows from user settings (read-only, redacted) and exit") + .option("--list-hooks", "List declared PreToolUse hooks from user settings (read-only, redacted) and exit") .option("--run-workflow ", "Run a named workflow from user settings non-interactively (sequential headless steps) and exit") .option("--list-profiles", "List declared model profiles from user settings (read-only, redacted) and exit") .option("--profile ", "Select a named model profile from user settings (overrides settings.defaultProfile)") @@ -1516,6 +1523,34 @@ program process.exit(0); } + // List-hooks mode: a read-only, redacted inventory of the PreToolUse hooks + // declared in the user-owned settings scope (hook-contract.ts). The project + // scope is never read, so an untrusted repository cannot surface a hook. + // Exits 0 on success (an absent `hooks` section lists as an empty inventory); + // a malformed/unknown contract or an invalid output format exits 2 as a + // usage/input error. + if (opts.listHooks) { + const format = String(opts.output ?? "text"); + if (format !== "text" && format !== "json") { + process.stderr.write(`Error: invalid output format "${format}"\n`); + process.exit(2); + } + let report; + try { + report = collectHookList({ settingsPath: resolveSettingsPath(opts.settings) }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`${msg}\n`); + process.exit(2); + } + if (format === "json") { + process.stdout.write(JSON.stringify(report) + "\n"); + } else { + process.stdout.write(formatHookList(report) + "\n"); + } + process.exit(0); + } + // List-profiles mode: a read-only, redacted inventory of the model profiles // declared in the user-owned settings scope (model-profiles.ts). The project // scope is never read, so an untrusted repository cannot surface a profile. @@ -1830,6 +1865,20 @@ program store.checkpoint(sessionId, store.load(sessionId), store.readMeta(sessionId)); }; + // Resolve user-owned PreToolUse hooks once (user settings scope only) so a + // matching hook can gate tool calls in both the headless and interactive + // paths. A malformed/oversized hooks section fails closed before any tool + // runs (exit 2), consistent with the other settings listings; an absent + // `hooks` section yields no hooks. + let preToolUseHooks: PreToolUseHook[] = []; + try { + preToolUseHooks = resolvePreToolUseHooks({ settingsPath }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`${msg}\n`); + process.exit(2); + } + if (opts.prompt) { // Non-interactive mode const format = String(opts.output ?? "text"); @@ -1901,6 +1950,7 @@ program mutatingAllowed, images, turnImages, + preToolUseHooks, }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); @@ -1972,6 +2022,7 @@ program mutatingAllowed, images, turnImages, + preToolUseHooks, }); sealSession(); recordTurnCheckpoint(turnImages); @@ -2280,6 +2331,7 @@ program compactThreshold, mutatingAllowed, images, + preToolUseHooks, }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); diff --git a/tests/integration/hook-contract.test.ts b/tests/integration/hook-contract.test.ts new file mode 100644 index 0000000..9fcfde8 --- /dev/null +++ b/tests/integration/hook-contract.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { createFakeServer } from "../fake-provider.js"; +import type { FakeServer } from "../fake-provider.js"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +function runCli( + args: string[], + env: Record, + timeoutMs = 20_000, +): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + const cliPath = path.resolve(import.meta.dirname, "../../dist/index.js"); + const proc = spawn("node", [cliPath, ...args], { + env: { ...process.env, ...env }, + timeout: timeoutMs, + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => { stdout += d; }); + proc.stderr.on("data", (d) => { stderr += d; }); + proc.on("close", (code) => resolve({ stdout, stderr, code })); + proc.on("error", reject); + }); +} + +// The tool result the agent fed back on the follow-up request (proves the tool +// actually executed — or was denied by a hook — against the workspace). +function toolResultContent( + requests: Array<{ body: unknown }>, + reqIndex: number, +): string { + const body = requests[reqIndex]?.body as { + messages?: Array<{ role: string; content: unknown }>; + }; + const toolMsgs = (body?.messages ?? []).filter((m) => m.role === "tool"); + return String(toolMsgs[toolMsgs.length - 1]?.content ?? ""); +} + +function writeCall(fileName: string) { + return { + type: "tool_calls" as const, + toolCalls: [ + { + id: "call_1", + name: "write", + arguments: JSON.stringify({ path: fileName, content: "hi" }), + }, + ], + }; +} + +// A hook command (no-shell spawn) that exits with a given code. `node` is always +// on PATH (the CLI itself runs under node), so this is portable. +function nodeExit(code: number): { command: string; args: string[] } { + return { command: "node", args: ["-e", `process.exit(${code})`] }; +} + +// A hook command that prints a JSON permission decision on stdout then exits 0. +function nodeDecision(decision: { permissionDecision: string; reason?: string }): { + command: string; + args: string[]; +} { + const script = `process.stdout.write(${JSON.stringify(JSON.stringify(decision))})`; + return { command: "node", args: ["-e", script] }; +} + +describe("Integration: PreToolUse hook contract", () => { + let server: FakeServer; + let tmpRoot: string; + let cleanEnv: Record; + + beforeAll(async () => { + server = await createFakeServer(); + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omc-hook-int-")); + // Blank the OPENAI_* variables so the host environment cannot leak into a run. + cleanEnv = { OPENAI_API_KEY: "", OPENAI_BASE_URL: "", OPENAI_MODEL: "" }; + }); + + afterAll(async () => { + await server.close(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + beforeEach(() => { + server.requests.length = 0; + }); + + function homeWith(settings: unknown): string { + const home = fs.mkdtempSync(path.join(tmpRoot, "home-")); + fs.mkdirSync(path.join(home, ".oh-my-cli"), { recursive: true }); + fs.writeFileSync(path.join(home, ".oh-my-cli", "settings.json"), JSON.stringify(settings)); + return home; + } + + function runEnv(home: string): Record { + return { + ...cleanEnv, + OPENAI_API_KEY: "fake-key", + OPENAI_BASE_URL: server.url, + OPENAI_MODEL: "fake-model", + HOME: home, + }; + } + + function hooksSection(...preToolUse: unknown[]): unknown { + return { hooks: { contractVersion: 1, PreToolUse: preToolUse } }; + } + + const validHooks = hooksSection({ matcher: "*", command: "/usr/bin/guard", args: ["--strict"] }); + + // --- Listing / contract validation (no provider needed) --- + + it("lists declared hooks as redacted JSON and exits 0", async () => { + const home = homeWith(validHooks); + const r = await runCli(["--list-hooks", "--output", "json"], { ...cleanEnv, HOME: home }); + expect(r.code).toBe(0); + const report = JSON.parse(r.stdout); + expect(report.schema).toBe("oh-my-cli.hook-contract"); + expect(report.contractVersion).toBe(1); + expect(report.hooks).toHaveLength(1); + expect(report.hooks[0].event).toBe("PreToolUse"); + expect(report.hooks[0].matcher).toBe("*"); + expect(report.hooks[0].args).toBe(1); + }); + + it("emits a human-readable list by default", async () => { + const home = homeWith(validHooks); + const r = await runCli(["--list-hooks"], { ...cleanEnv, HOME: home }); + expect(r.code).toBe(0); + expect(r.stdout).toContain("PreToolUse: 1"); + expect(r.stdout).toContain("*"); + expect(r.stdout).toContain("/usr/bin/guard"); + }); + + it("reports an empty inventory and exits 0 when there is no hooks section", async () => { + const home = homeWith({ model: { name: "m", apiKeyEnv: "K" } }); + const r = await runCli(["--list-hooks", "--output", "json"], { ...cleanEnv, HOME: home }); + expect(r.code).toBe(0); + const report = JSON.parse(r.stdout); + expect(report.hooks).toEqual([]); + }); + + it("exits 2 (fail closed) on an unsupported contract version", async () => { + const home = homeWith({ hooks: { contractVersion: 99, PreToolUse: [] } }); + const r = await runCli(["--list-hooks"], { ...cleanEnv, HOME: home }); + expect(r.code).toBe(2); + expect(r.stderr).toContain("not supported"); + }); + + it("exits 2 on a raw credential field in a hook entry", async () => { + const home = homeWith(hooksSection({ matcher: "*", command: "g", token: "leaked" })); + const r = await runCli(["--list-hooks"], { ...cleanEnv, HOME: home }); + expect(r.code).toBe(2); + expect(r.stderr).toContain("raw credential field"); + }); + + it("exits 2 on an invalid output format", async () => { + const home = homeWith(validHooks); + const r = await runCli(["--list-hooks", "--output", "yaml"], { ...cleanEnv, HOME: home }); + expect(r.code).toBe(2); + expect(r.stderr).toContain("invalid output format"); + }); + + it("does not honor a project-scope hook (user scope only)", async () => { + // User scope has no hooks; the workspace's project file declares one. + const home = homeWith({ model: { name: "m", apiKeyEnv: "K" } }); + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + fs.mkdirSync(path.join(ws, ".oh-my-cli"), { recursive: true }); + fs.writeFileSync( + path.join(ws, ".oh-my-cli", "settings.json"), + JSON.stringify(hooksSection({ matcher: "*", command: "evil" })), + ); + const r = await runCli(["--list-hooks", "--output", "json", "--workspace", ws], { + ...cleanEnv, + HOME: home, + }); + expect(r.code).toBe(0); + const report = JSON.parse(r.stdout); + expect(report.hooks).toEqual([]); + expect(r.stdout + r.stderr).not.toContain("evil"); + }); + + // --- Deny gate end-to-end via the headless -p path --- + + it("a deny hook (exit 2) blocks a matching tool call; the side effect never happens", async () => { + server.setResponses([writeCall("denied.txt"), { type: "text", content: "done" }]); + const home = homeWith(hooksSection({ matcher: "*", ...nodeExit(2) })); + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + const r = await runCli( + ["-p", "write", "--approval-mode", "auto-edit", "--workspace", ws], + runEnv(home), + ); + expect(r.code).toBe(0); + expect(toolResultContent(server.requests, 1)).toMatch(/denied by a user PreToolUse hook/i); + expect(fs.existsSync(path.join(ws, "denied.txt"))).toBe(false); + }); + + it("a deny hook is a hard floor even in yolo mode", async () => { + server.setResponses([writeCall("yolo.txt"), { type: "text", content: "done" }]); + const home = homeWith(hooksSection({ matcher: "write", ...nodeExit(2) })); + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + const r = await runCli( + ["-p", "write", "--approval-mode", "yolo", "--workspace", ws], + runEnv(home), + ); + expect(r.code).toBe(0); + expect(toolResultContent(server.requests, 1)).toMatch(/denied by a user PreToolUse hook/i); + expect(fs.existsSync(path.join(ws, "yolo.txt"))).toBe(false); + }); + + it("a non-matching hook does not block the tool call", async () => { + server.setResponses([writeCall("allowed.txt"), { type: "text", content: "done" }]); + const home = homeWith(hooksSection({ matcher: "shell", ...nodeExit(2) })); + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + const r = await runCli( + ["-p", "write", "--approval-mode", "auto-edit", "--workspace", ws], + runEnv(home), + ); + expect(r.code).toBe(0); + expect(toolResultContent(server.requests, 1)).toContain("Wrote"); + expect(fs.existsSync(path.join(ws, "allowed.txt"))).toBe(true); + }); + + it("an allow / exit-0 hook does not block (a hook can never relax)", async () => { + server.setResponses([writeCall("ok.txt"), { type: "text", content: "done" }]); + const home = homeWith(hooksSection({ matcher: "*", ...nodeExit(0) })); + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + const r = await runCli( + ["-p", "write", "--approval-mode", "auto-edit", "--workspace", ws], + runEnv(home), + ); + expect(r.code).toBe(0); + expect(toolResultContent(server.requests, 1)).toContain("Wrote"); + expect(fs.existsSync(path.join(ws, "ok.txt"))).toBe(true); + }); + + it("a JSON deny decision blocks and surfaces the reason", async () => { + server.setResponses([writeCall("policy.txt"), { type: "text", content: "done" }]); + const home = homeWith( + hooksSection({ matcher: "*", ...nodeDecision({ permissionDecision: "deny", reason: "blocked by org policy" }) }), + ); + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + const r = await runCli( + ["-p", "write", "--approval-mode", "auto-edit", "--workspace", ws], + runEnv(home), + ); + expect(r.code).toBe(0); + const tool = toolResultContent(server.requests, 1); + expect(tool).toMatch(/denied by a user PreToolUse hook/i); + expect(tool).toContain("blocked by org policy"); + expect(fs.existsSync(path.join(ws, "policy.txt"))).toBe(false); + }); + + it("redacts secrets and the home path in the surfaced deny reason", async () => { + server.setResponses([writeCall("leak.txt"), { type: "text", content: "done" }]); + // Low-entropy fake token: matches the redactor's `sk-<16+ alnum>` pattern but + // stays under gitleaks' entropy threshold so CI's secret scan does not flag it. + const decoy = "sk-aaaaaaaaaaaaaaaaaaaa"; + const home = homeWith( + hooksSection({ + matcher: "*", + ...nodeDecision({ permissionDecision: "deny", reason: `leak ${decoy} at HOME` }), + }), + ); + // The hook injects the real HOME path into its reason at runtime; the CLI must + // collapse it to ~ before the reason is ever surfaced. + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + const r = await runCli( + ["-p", "write", "--approval-mode", "auto-edit", "--workspace", ws], + runEnv(home), + ); + expect(r.code).toBe(0); + const tool = toolResultContent(server.requests, 1); + expect(tool).toContain("[REDACTED]"); + expect(tool).not.toContain(decoy); + expect(fs.existsSync(path.join(ws, "leak.txt"))).toBe(false); + }); + + it("fails closed (exit 2) on a malformed hooks section before any provider call", async () => { + const home = homeWith({ hooks: { contractVersion: 99, PreToolUse: [] } }); + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + const r = await runCli( + ["-p", "write", "--approval-mode", "auto-edit", "--workspace", ws], + runEnv(home), + ); + expect(r.code).toBe(2); + expect(r.stderr).toContain("not supported"); + expect(server.requests.length).toBe(0); + }); + + it("a project-scope deny hook is never honored at runtime; the write succeeds", async () => { + server.setResponses([writeCall("safe.txt"), { type: "text", content: "done" }]); + // User scope has no hooks; the (untrusted) workspace declares a deny hook. + const home = homeWith({ model: { name: "m", apiKeyEnv: "K" } }); + const ws = fs.mkdtempSync(path.join(tmpRoot, "ws-")); + fs.mkdirSync(path.join(ws, ".oh-my-cli"), { recursive: true }); + fs.writeFileSync( + path.join(ws, ".oh-my-cli", "settings.json"), + JSON.stringify(hooksSection({ matcher: "*", ...nodeExit(2) })), + ); + const r = await runCli( + ["-p", "write", "--approval-mode", "auto-edit", "--workspace", ws], + runEnv(home), + ); + expect(r.code).toBe(0); + expect(toolResultContent(server.requests, 1)).toContain("Wrote"); + expect(fs.existsSync(path.join(ws, "safe.txt"))).toBe(true); + }); +}); diff --git a/tests/unit/hook-contract.test.ts b/tests/unit/hook-contract.test.ts new file mode 100644 index 0000000..213bc54 --- /dev/null +++ b/tests/unit/hook-contract.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, afterEach } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + HOOK_CONTRACT_SCHEMA, + HOOK_CONTRACT_VERSION, + SUPPORTED_HOOK_CONTRACT_VERSIONS, + parseHookContract, + resolvePreToolUseHooks, + hookMatches, + evaluatePreToolUseHooks, + collectHookList, + formatHookList, + type HookRunner, + type HookRunResult, + type HookRunOptions, + type PreToolUseHook, +} from "../../src/hook-contract.js"; + +const tmpDirs: string[] = []; + +function tmpDir(): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), "omc-hook-contract-")); + tmpDirs.push(d); + return d; +} + +function writeSettings(obj: unknown): string { + const p = path.join(tmpDir(), "settings.json"); + fs.writeFileSync(p, JSON.stringify(obj)); + return p; +} + +function missingPath(): string { + return path.join(tmpDir(), "does-not-exist.json"); +} + +// A fake runner so the decision logic is exercised deterministically without +// spawning real processes. +function fakeRunner(result: Partial): HookRunner { + return async () => ({ + exitCode: 0, + timedOut: false, + outputCapped: false, + stdout: "", + stderr: "", + ...result, + }); +} + +const SHELL_HOOK: PreToolUseHook = { matcher: "shell", command: "/usr/bin/guard", args: [] }; + +afterEach(() => { + while (tmpDirs.length) { + fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); + } +}); + +describe("parseHookContract: version negotiation", () => { + it("accepts a supported contract version with one hook", () => { + const contract = parseHookContract({ + contractVersion: 1, + PreToolUse: [{ matcher: "shell", command: "/usr/bin/guard", args: ["--strict"] }], + }); + expect(contract.contractVersion).toBe(1); + expect(contract.preToolUse).toHaveLength(1); + expect(contract.preToolUse[0]).toEqual({ + matcher: "shell", + command: "/usr/bin/guard", + args: ["--strict"], + }); + expect(SUPPORTED_HOOK_CONTRACT_VERSIONS).toContain(HOOK_CONTRACT_VERSION); + }); + + it("accepts the wildcard matcher and defaults args to []", () => { + const contract = parseHookContract({ + contractVersion: 1, + PreToolUse: [{ matcher: "*", command: "guard" }], + }); + expect(contract.preToolUse[0].matcher).toBe("*"); + expect(contract.preToolUse[0].args).toEqual([]); + }); + + it("treats a hooks section with no PreToolUse event as an empty inventory", () => { + const contract = parseHookContract({ contractVersion: 1 }); + expect(contract.preToolUse).toEqual([]); + }); + + it("fails closed when contractVersion is missing", () => { + expect(() => parseHookContract({ PreToolUse: [] })).toThrow(/contractVersion is required/); + }); + + it("fails closed on an unsupported contract version", () => { + expect(() => parseHookContract({ contractVersion: 99, PreToolUse: [] })).toThrow(/not supported/); + }); + + it("fails closed on a non-integer contract version", () => { + expect(() => parseHookContract({ contractVersion: 1.5, PreToolUse: [] })).toThrow(/must be an integer/); + }); +}); + +describe("parseHookContract: strict validation (fail closed)", () => { + it("rejects a non-object section", () => { + expect(() => parseHookContract([])).toThrow(/settings\.hooks must be an object/); + }); + + it("rejects an unknown envelope key", () => { + expect(() => parseHookContract({ contractVersion: 1, PostToolUse: [] })).toThrow(/unknown key/); + }); + + it("rejects a non-array PreToolUse", () => { + expect(() => parseHookContract({ contractVersion: 1, PreToolUse: {} })).toThrow(/must be an array/); + }); + + it("rejects a non-object entry", () => { + expect(() => parseHookContract({ contractVersion: 1, PreToolUse: ["nope"] })).toThrow( + /PreToolUse\[0\] must be an object/, + ); + }); + + it("rejects an unknown key in an entry", () => { + expect(() => + parseHookContract({ + contractVersion: 1, + PreToolUse: [{ matcher: "shell", command: "guard", when: "always" }], + }), + ).toThrow(/unrecognized|unknown/i); + }); + + it("rejects a raw credential field in an entry", () => { + expect(() => + parseHookContract({ + contractVersion: 1, + PreToolUse: [{ matcher: "shell", command: "guard", token: "leaked" }], + }), + ).toThrow(/raw credential field/); + }); + + it("rejects an invalid matcher", () => { + expect(() => + parseHookContract({ contractVersion: 1, PreToolUse: [{ matcher: "Shell*", command: "g" }] }), + ).toThrow(/matcher/); + }); + + it("rejects an empty command", () => { + expect(() => + parseHookContract({ contractVersion: 1, PreToolUse: [{ matcher: "shell", command: "" }] }), + ).toThrow(/command must be a non-empty string/); + }); +}); + +describe("resolvePreToolUseHooks: user-scope-only resolution", () => { + it("returns the declared hooks from the user settings file", () => { + const p = writeSettings({ + hooks: { contractVersion: 1, PreToolUse: [{ matcher: "shell", command: "guard" }] }, + }); + const hooks = resolvePreToolUseHooks({ settingsPath: p }); + expect(hooks).toHaveLength(1); + expect(hooks[0].matcher).toBe("shell"); + }); + + it("returns [] when there is no hooks section", () => { + const p = writeSettings({ model: { name: "m", apiKeyEnv: "K" } }); + expect(resolvePreToolUseHooks({ settingsPath: p })).toEqual([]); + }); + + it("returns [] when the settings file is missing", () => { + expect(resolvePreToolUseHooks({ settingsPath: missingPath() })).toEqual([]); + }); + + it("fails closed (throws) on a present-but-malformed hooks section", () => { + const p = writeSettings({ hooks: { contractVersion: 99, PreToolUse: [] } }); + expect(() => resolvePreToolUseHooks({ settingsPath: p })).toThrow(/not supported/); + }); + + it("never reads a project-local file (user scope only)", () => { + // The user file has no hooks; a sibling project file declares one. Resolution + // takes only the user path, so the project hook is never seen. + const userPath = writeSettings({ model: { name: "m", apiKeyEnv: "K" } }); + const projectPath = writeSettings({ + hooks: { contractVersion: 1, PreToolUse: [{ matcher: "*", command: "evil" }] }, + }); + const hooks = resolvePreToolUseHooks({ settingsPath: userPath }); + expect(hooks).toEqual([]); + expect(JSON.stringify(hooks)).not.toContain("evil"); + expect(projectPath).toBeTruthy(); // the project file exists but is never consulted + }); +}); + +describe("hookMatches", () => { + it("matches the wildcard for any tool", () => { + expect(hookMatches({ matcher: "*", command: "g", args: [] }, "write")).toBe(true); + expect(hookMatches({ matcher: "*", command: "g", args: [] }, "shell")).toBe(true); + }); + + it("matches an exact tool name only", () => { + expect(hookMatches(SHELL_HOOK, "shell")).toBe(true); + expect(hookMatches(SHELL_HOOK, "write")).toBe(false); + }); +}); + +describe("evaluatePreToolUseHooks: deny-only decisions", () => { + const cwd = tmpDir(); + const ctx = { toolName: "shell", toolInput: { command: "rm -rf /" } }; + + it("is silent when no hook matches the tool", async () => { + const d = await evaluatePreToolUseHooks([SHELL_HOOK], { toolName: "write", toolInput: {} }, { cwd, runner: fakeRunner({ exitCode: 2 }) }); + expect(d.denied).toBe(false); + }); + + it("denies on exit code 2", async () => { + const d = await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { cwd, runner: fakeRunner({ exitCode: 2 }) }); + expect(d.denied).toBe(true); + expect(d.reason).toMatch(/exit 2/); + }); + + it("denies on a JSON permissionDecision of deny with a reason", async () => { + const runner = fakeRunner({ + exitCode: 0, + stdout: JSON.stringify({ permissionDecision: "deny", reason: "blocked by org policy" }), + }); + const d = await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { cwd, runner }); + expect(d.denied).toBe(true); + expect(d.reason).toContain("blocked by org policy"); + }); + + it("treats an allow decision as silence (a hook can never relax)", async () => { + const runner = fakeRunner({ + exitCode: 0, + stdout: JSON.stringify({ permissionDecision: "allow" }), + }); + const d = await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { cwd, runner }); + expect(d.denied).toBe(false); + }); + + it("treats exit 0 with no decision as silence", async () => { + const d = await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { cwd, runner: fakeRunner({ exitCode: 0 }) }); + expect(d.denied).toBe(false); + }); + + it("treats a non-blocking non-zero exit (not 2) as silence", async () => { + const d = await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { cwd, runner: fakeRunner({ exitCode: 1 }) }); + expect(d.denied).toBe(false); + }); + + it("fails closed (denies) on a timeout", async () => { + const d = await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { cwd, runner: fakeRunner({ timedOut: true }) }); + expect(d.denied).toBe(true); + expect(d.reason).toMatch(/timeout/i); + }); + + it("fails closed (denies) on a spawn error", async () => { + const d = await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { + cwd, + runner: fakeRunner({ exitCode: null, spawnError: "ENOENT" }), + }); + expect(d.denied).toBe(true); + expect(d.reason).toMatch(/failed to start/i); + }); + + it("stops at the first denying hook", async () => { + const calls: string[] = []; + const deny: HookRunner = async () => { + calls.push("deny"); + return { exitCode: 2, timedOut: false, outputCapped: false, stdout: "", stderr: "" }; + }; + const never: HookRunner = async () => { + calls.push("never"); + return { exitCode: 0, timedOut: false, outputCapped: false, stdout: "", stderr: "" }; + }; + const hooks: PreToolUseHook[] = [ + { matcher: "shell", command: "first", args: [] }, + { matcher: "shell", command: "second", args: [] }, + ]; + // A composite runner: first hook denies, so the second must not run. + let n = 0; + const composite: HookRunner = async (opts) => { + const r = n++ === 0 ? deny : never; + return r(opts); + }; + const d = await evaluatePreToolUseHooks(hooks, ctx, { cwd, runner: composite }); + expect(d.denied).toBe(true); + expect(calls).toEqual(["deny"]); + }); + + it("redacts secrets and the home path in the deny reason", async () => { + const home = process.env.HOME ?? ""; + const runner = fakeRunner({ + exitCode: 0, + stdout: JSON.stringify({ + permissionDecision: "deny", + reason: `blocked sk-aaaaaaaaaaaaaaaaaaaa at ${home}/secret.txt`, + }), + }); + const d = await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { cwd, runner }); + expect(d.denied).toBe(true); + expect(d.reason).toContain("[REDACTED]"); + expect(d.reason).not.toContain("sk-aaaaaaaaaaaaaaaaaaaa"); + if (home) { + expect(d.reason).not.toContain(`${home}/secret.txt`); + expect(d.reason).toContain("~"); + } + }); + + it("hands the tool-call context to the runner on stdin as JSON", async () => { + let captured: HookRunOptions | null = null; + const runner: HookRunner = async (opts) => { + captured = opts; + return { exitCode: 0, timedOut: false, outputCapped: false, stdout: "", stderr: "" }; + }; + await evaluatePreToolUseHooks([SHELL_HOOK], ctx, { cwd, runner }); + expect(captured).not.toBeNull(); + const input = JSON.parse(captured!.input); + expect(input.hook_event_name).toBe("PreToolUse"); + expect(input.tool_name).toBe("shell"); + expect(input.tool_input).toEqual({ command: "rm -rf /" }); + expect(captured!.cwd).toBe(cwd); + }); +}); + +describe("collectHookList / formatHookList", () => { + it("lists declared hooks with a redacted command and an arg count", () => { + const home = process.env.HOME ?? os.homedir(); + const p = path.join(tmpDir(), "settings.json"); + fs.writeFileSync( + p, + JSON.stringify({ + hooks: { + contractVersion: 1, + PreToolUse: [{ matcher: "shell", command: `${home}/bin/guard`, args: ["--strict", "-v"] }], + }, + }), + ); + const report = collectHookList({ settingsPath: p }); + expect(report.schema).toBe(HOOK_CONTRACT_SCHEMA); + expect(report.contractVersion).toBe(1); + expect(report.hooks).toHaveLength(1); + expect(report.hooks[0].event).toBe("PreToolUse"); + expect(report.hooks[0].matcher).toBe("shell"); + expect(report.hooks[0].args).toBe(2); + // The home path is collapsed to ~ in the listing. + expect(report.hooks[0].command).toContain("~"); + expect(report.hooks[0].command).not.toContain(home); + }); + + it("reports an empty inventory when there is no hooks section", () => { + const p = writeSettings({ model: { name: "m", apiKeyEnv: "K" } }); + const report = collectHookList({ settingsPath: p }); + expect(report.hooks).toEqual([]); + expect(formatHookList(report)).toContain("PreToolUse: (none)"); + }); + + it("marks the settings path (not found) when the file is missing", () => { + const report = collectHookList({ settingsPath: missingPath() }); + expect(report.hooks).toEqual([]); + expect(report.settings).toContain("(not found)"); + }); + + it("fails closed (throws) on a malformed hooks section", () => { + const p = writeSettings({ + hooks: { contractVersion: 1, PreToolUse: [{ matcher: "shell", command: "g", token: "x" }] }, + }); + expect(() => collectHookList({ settingsPath: p })).toThrow(/raw credential field/); + }); + + it("renders a human-readable list with the matcher and command", () => { + const p = writeSettings({ + hooks: { contractVersion: 1, PreToolUse: [{ matcher: "shell", command: "guard", args: [] }] }, + }); + const text = formatHookList(collectHookList({ settingsPath: p })); + expect(text).toContain("PreToolUse: 1"); + expect(text).toContain("shell"); + expect(text).toContain("guard"); + }); +});