diff --git a/src/agent/posix-tool-plugins.ts b/src/agent/posix-tool-plugins.ts index ad4caaf1f..75938d81a 100644 --- a/src/agent/posix-tool-plugins.ts +++ b/src/agent/posix-tool-plugins.ts @@ -27,6 +27,13 @@ import { type ReadFileGuardPluginOptions, } from "../plugins/read-file-guard-plugin.js"; import type { PermissionGate } from "../permission/gate.js"; +import { + hasInterceptHooks, + interceptHookPlugin, + normalizeInterceptHooks, + type InterceptHookRegistration, + type InterceptHookRegistry, +} from "../plugins/intercept-hooks.js"; import { createWorktreeRootsProvider } from "../permission/worktree-roots.js"; import type { CompactionArchive } from "../session/compaction-archive.js"; import type { ShellOutputFeedMap } from "../session/shell-output-feed.js"; @@ -59,6 +66,18 @@ export interface CorePosixToolPluginsArgs { * only — the default settings file stays covered either way. */ secretGuardExtraDeniedPaths?: readonly string[]; + // CL-9888 in-process intercept hooks (beforeModel/afterTool stages; + // beforePrompt is a prompt-assembly pure function, not middleware). + // Composed immediately after secret-guard/permission: a denied call + // short-circuits at the gate above this layer and never reaches a hook. + // The stack gate is forwarded into the hook middleware so a hook-replaced + // call is re-run through secret-guard + permission before it executes — + // mutated still gated. Hook-mutated calls also still pass the downstream + // guards below. + interceptHooks?: InterceptHookRegistration | InterceptHookRegistry; + // Warning sink for hook failures (hooks never throw into the run). + // Defaults to stderr; interactive callers pass a diagnostics-backed sink. + onInterceptHookWarning?: (msg: string) => void; } // Middleware order matches docs/ARCHITECTURE.md: path escape through truncation, @@ -99,6 +118,8 @@ export function buildCorePosixToolPlugins( getShellOutputFeeds, getEvidenceArchive, secretGuardExtraDeniedPaths, + interceptHooks, + onInterceptHookWarning, } = args; // Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell // cwd are not hard-denied after the gate already auto-allows. Pass a live @@ -144,6 +165,20 @@ export function buildCorePosixToolPlugins( : undefined, ), permissionPlugin(permissionGate), + ...(interceptHooks !== undefined && + hasInterceptHooks(normalizeInterceptHooks(interceptHooks)) + ? [ + interceptHookPlugin(interceptHooks, { + ...(onInterceptHookWarning !== undefined + ? { onWarning: onInterceptHookWarning } + : {}), + permissionGate, + ...(secretGuardExtraDeniedPaths !== undefined + ? { secretGuardExtraDeniedPaths } + : {}), + }), + ] + : []), shellGuardPlugin(cwd, shellTimeout, shellEnv, { allowOutsideCwd: allowOutside, ...(getBackgroundShellRegistry !== undefined diff --git a/src/plugins/intercept-hooks.test.ts b/src/plugins/intercept-hooks.test.ts new file mode 100644 index 000000000..5df6fa62e --- /dev/null +++ b/src/plugins/intercept-hooks.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, test } from "bun:test"; +import { composeMiddleware } from "@intx/tools-posix"; +import type { ToolCall, ToolResult } from "@intx/types/runtime"; +import { buildCorePosixToolPlugins } from "../agent/posix-tool-plugins.js"; +import { createPermissionGate } from "../permission/gate.js"; +import type { Approval } from "../permission/types.js"; +import type { PluginModule } from "./loader.js"; +import { + applyAfterToolHooks, + applyBeforeModelHooks, + applyBeforePromptHooks, + collectInterceptHooks, + interceptHookPlugin, + normalizeInterceptHooks, + type InterceptHookRegistration, +} from "./intercept-hooks.js"; + +const signal = (): AbortSignal => new AbortController().signal; + +const call = (overrides: Partial = {}): ToolCall => ({ + id: "call-1", + name: "read_file", + arguments: { path: "src/index.ts" }, + ...overrides, +}); + +const okResult = (callId = "call-1", content = "base-ok"): ToolResult => ({ + callId, + content, +}); + +function warnings() { + const messages: string[] = []; + return { + messages, + onWarning: (msg: string): void => { + messages.push(msg); + }, + }; +} + +function baseHandler(seen: ToolCall[]) { + return async (c: ToolCall): Promise => { + seen.push(c); + return okResult(c.id); + }; +} + +describe("applyBeforePromptHooks", () => { + test("threads prompt replacements in order (sync and async)", async () => { + const result = await applyBeforePromptHooks( + [ + ({ prompt }) => ({ prompt: `${prompt} [a]` }), + async ({ prompt }) => ({ prompt: `${prompt}[b]` }), + () => undefined, + ], + "hello", + ); + expect(result).toBe("hello [a][b]"); + }); + + test("a throwing hook is reported and skipped", async () => { + const sink = warnings(); + const result = await applyBeforePromptHooks( + [ + () => { + throw new Error("boom"); + }, + ({ prompt }) => ({ prompt: `${prompt}!` }), + ], + "hello", + sink, + ); + expect(result).toBe("hello!"); + expect(sink.messages).toHaveLength(1); + expect(sink.messages[0]).toMatch(/beforePrompt hook 0 failed/); + }); +}); + +describe("interceptHookPlugin middleware", () => { + test("beforeModel skip answers without executing", async () => { + const seen: ToolCall[] = []; + const afterSeen: string[] = []; + const plugin = interceptHookPlugin({ + beforeModel: () => ({ skip: okResult("call-1", "skipped") }), + afterTool: ({ result }) => { + afterSeen.push(String(result.content)); + return undefined; + }, + }); + const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen); + const result = await handler(call(), signal()); + expect(result.content).toBe("skipped"); + expect(seen).toHaveLength(0); + // Skip results still flow through afterTool for observability. + expect(afterSeen).toEqual(["skipped"]); + }); + + test("beforeModel call replacement reaches the handler", async () => { + const seen: ToolCall[] = []; + const plugin = interceptHookPlugin({ + beforeModel: ({ call: c }) => ({ + call: { ...c, arguments: { path: "src/rewritten.ts" } }, + }), + }); + const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen); + const result = await handler(call(), signal()); + expect(result.content).toBe("base-ok"); + expect(seen).toHaveLength(1); + expect(seen[0]?.arguments).toEqual({ path: "src/rewritten.ts" }); + }); + + test("afterTool replaces the result", async () => { + const seen: ToolCall[] = []; + const plugin = interceptHookPlugin({ + afterTool: ({ result }) => ({ + result: { ...result, content: `${result.content}+annotated` }, + }), + }); + const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen); + const result = await handler(call(), signal()); + expect(result.content).toBe("base-ok+annotated"); + expect(seen).toHaveLength(1); + }); + + test("hook failure is logged, not thrown; last good value wins", async () => { + const seen: ToolCall[] = []; + const sink = warnings(); + const plugin = interceptHookPlugin( + { + beforeModel: [ + () => { + throw new Error("pre-boom"); + }, + ({ call: c }) => ({ + call: { ...c, arguments: { path: "src/kept.ts" } }, + }), + ], + afterTool: [ + () => { + throw new Error("post-boom"); + }, + ], + }, + sink, + ); + const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen); + const result = await handler(call(), signal()); + expect(result.content).toBe("base-ok"); + expect(seen).toHaveLength(1); + expect(seen[0]?.arguments).toEqual({ path: "src/kept.ts" }); + expect(sink.messages).toHaveLength(2); + expect(sink.messages[0]).toMatch(/beforeModel hook 0 failed/); + expect(sink.messages[1]).toMatch(/afterTool hook 0 failed/); + }); +}); + +describe("applyBeforeModelHooks / applyAfterToolHooks", () => { + test("beforeModel is callable directly with skip short-circuit", async () => { + const outcome = await applyBeforeModelHooks( + [() => ({ skip: okResult("call-1", "direct-skip") })], + call(), + signal(), + ); + expect(outcome.skip?.content).toBe("direct-skip"); + }); + + test("afterTool is callable directly with replacement", async () => { + const c = call(); + const outcome = await applyAfterToolHooks( + [({ result }) => ({ result: { ...result, content: "replaced" } })], + c, + okResult(), + signal(), + ); + expect(outcome.content).toBe("replaced"); + }); +}); + +describe("collectInterceptHooks", () => { + test("flattens single and list registrations in order", () => { + const a = ({ prompt }: { prompt: string }) => ({ prompt: `${prompt}a` }); + const b = ({ prompt }: { prompt: string }) => ({ prompt: `${prompt}b` }); + const modules = [ + { interceptHooks: { beforePrompt: a } }, + { interceptHooks: { beforePrompt: [b] } }, + {}, + ] as PluginModule[]; + const registry = collectInterceptHooks(modules); + expect(registry.beforePrompt).toEqual([a, b]); + expect(registry.beforeModel).toEqual([]); + expect(registry.afterTool).toEqual([]); + }); + + test("metadata-only (untrusted) modules contribute nothing", () => { + const hook = () => ({ skip: okResult() }); + const modules = [ + { metadataOnly: true, interceptHooks: { beforeModel: hook } }, + ] as PluginModule[]; + const registry = collectInterceptHooks(modules); + expect(registry.beforeModel).toEqual([]); + }); + + test("normalize accepts a registry as-is", () => { + const registry = normalizeInterceptHooks({ + beforePrompt: [], + beforeModel: [], + afterTool: [], + }); + expect(registry.beforeModel).toEqual([]); + }); +}); + +describe("gate still enforced (CL-9888 safety)", () => { + function stackWithHook(cwd: string, hookCalls: string[]) { + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: false, + reactorGated: false, + auto: false, + cwd, + }); + const plugins = buildCorePosixToolPlugins({ + cwd, + permissionGate: gate, + interceptHooks: { + beforeModel: ({ call: c }) => { + hookCalls.push(c.name); + return undefined; + }, + afterTool: ({ result }) => ({ + result: { ...result, content: `${result.content}+hook` }, + }), + }, + }); + const middlewares = plugins.flatMap((p) => + p.middleware !== undefined ? [p.middleware] : [], + ); + return composeMiddleware(middlewares, async (c) => okResult(c.id)); + } + + test("secret-guard denial never reaches hooks", async () => { + const hookCalls: string[] = []; + const handler = stackWithHook(process.cwd(), hookCalls); + const result = await handler( + { + id: "deny-1", + name: "read_file", + arguments: { path: ".env" }, + }, + signal(), + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toMatch(/sensitive file blocked/); + expect(hookCalls).toEqual([]); + expect(String(result.content)).not.toContain("+hook"); + }); + + test("permission-gate denial of a catastrophic shell never reaches hooks", async () => { + const hookCalls: string[] = []; + const handler = stackWithHook(process.cwd(), hookCalls); + const result = await handler( + { + id: "deny-2", + name: "run_shell", + arguments: { command: "rm -rf /" }, + }, + signal(), + ); + expect(result.isError).toBe(true); + expect(hookCalls).toEqual([]); + expect(String(result.content)).not.toContain("+hook"); + }); + + test("an allowed call flows through hooks", async () => { + const hookCalls: string[] = []; + const handler = stackWithHook(process.cwd(), hookCalls); + const result = await handler( + { + id: "allow-1", + name: "read_file", + arguments: { path: "src/index.ts" }, + }, + signal(), + ); + expect(hookCalls).toEqual(["read_file"]); + expect(String(result.content)).toContain("+hook"); + }); + + // A hook that rewrites an allowed call into a denied shape must not bypass + // the gate: the mutated call is re-run through secret-guard + permission. + function stackForRewrite( + cwd: string, + interceptHooks: InterceptHookRegistration, + approvals: Approval[] = [], + ): { + handler: (call: ToolCall, signal: AbortSignal) => Promise; + executed: ToolCall[]; + } { + const gate = createPermissionGate({ + approvals, + interactive: false, + skipPermissions: false, + reactorGated: false, + auto: false, + cwd, + }); + const plugins = buildCorePosixToolPlugins({ + cwd, + permissionGate: gate, + interceptHooks, + }); + const middlewares = plugins.flatMap((p) => + p.middleware !== undefined ? [p.middleware] : [], + ); + const executed: ToolCall[] = []; + const handler = composeMiddleware(middlewares, async (c) => { + executed.push(c); + return okResult(c.id); + }); + return { handler, executed }; + } + + test("hook rewriting an allowed read to .env is denied (secret-guard re-gate)", async () => { + const { handler, executed } = stackForRewrite(process.cwd(), { + beforeModel: ({ call: c }) => ({ + call: { ...c, arguments: { path: ".env" } }, + }), + afterTool: ({ result }) => ({ + result: { ...result, content: `${result.content}+hook` }, + }), + }); + const result = await handler( + { + id: "rewrite-1", + name: "read_file", + arguments: { path: "src/index.ts" }, + }, + signal(), + ); + expect(result.isError).toBe(true); + expect(String(result.content)).toMatch(/sensitive file blocked/); + // The mutated call never executes and never reaches afterTool. + expect(executed).toEqual([]); + expect(String(result.content)).not.toContain("+hook"); + }); + + test("hook rewriting allowed bash to rm -rf / is denied (permission re-gate)", async () => { + const { handler, executed } = stackForRewrite( + process.cwd(), + { + beforeModel: ({ call: c }) => ({ + call: { ...c, arguments: { command: "rm -rf /" } }, + }), + }, + [{ tool: "run_shell", pattern: "echo *" }], + ); + const result = await handler( + { + id: "rewrite-2", + name: "run_shell", + arguments: { command: "echo hi" }, + }, + signal(), + ); + expect(result.isError).toBe(true); + // The gate's denial shape (not a downstream guard's): the mutated call + // never reaches shell execution. + expect(String(result.content)).toMatch(/Blocked by permission policy/); + expect(executed).toEqual([]); + }); + + test("an unmutated allowed shell call still executes (no double-gate)", async () => { + const { handler } = stackForRewrite( + process.cwd(), + { + beforeModel: () => undefined, + }, + [{ tool: "run_shell", pattern: "echo *" }], + ); + const result = await handler( + { + id: "allow-2", + name: "run_shell", + arguments: { command: "echo hi" }, + }, + signal(), + ); + // shell-guard executes the command itself and answers without reaching + // the terminal base handler. + expect(result.isError !== true).toBe(true); + expect(String(result.content)).toContain("hi"); + }); + + test("bare middleware re-gates a mutated call when wired with a gate", async () => { + const gate = createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: false, + reactorGated: false, + auto: false, + cwd: process.cwd(), + }); + const seen: ToolCall[] = []; + const plugin = interceptHookPlugin( + { + beforeModel: ({ call: c }) => ({ + call: { ...c, arguments: { path: ".env" } }, + }), + }, + { permissionGate: gate }, + ); + const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen); + const result = await handler(call(), signal()); + expect(result.isError).toBe(true); + expect(String(result.content)).toMatch(/sensitive file blocked/); + expect(seen).toHaveLength(0); + }); +}); diff --git a/src/plugins/intercept-hooks.ts b/src/plugins/intercept-hooks.ts new file mode 100644 index 000000000..911ec970c --- /dev/null +++ b/src/plugins/intercept-hooks.ts @@ -0,0 +1,334 @@ +import type { ToolCall, ToolResult } from "@intx/types/runtime"; +import type { ToolPlugin } from "@intx/tools-posix"; +import type { PermissionGate } from "../permission/gate.js"; +import { scrubSecrets } from "../web/secret-scrub.js"; +import { gateToolCall } from "./permission-plugin.js"; +import { secretGuardPlugin } from "./secret-guard-plugin.js"; +import { + resolvePluginWarningHandler, + stderrPluginWarning, + type PluginLoadDiagnostics, +} from "./diagnostics.js"; +import type { PluginModule } from "./loader.js"; + +// CL-9888: in-process plugin intercept hooks. +// +// File hooks (postTurn/postRun in src/session/hooks.ts) stay observe-only and +// off the hot path. These hooks are the hot-path interception surface: +// beforePrompt rewrites prompt text, beforeModel observes/rewrites a tool +// call (or skips it), afterTool observes/rewrites the tool result. +// +// Ordering plus re-gating is the safety property: the composed middleware +// sits after secret-guard/permission in buildCorePosixToolPlugins, so a +// denied call short-circuits at the gate and never reaches a hook. A hook can +// still rewrite an allowed call into a denied shape, so a hook-replaced call +// is re-run through secret-guard and the permission gate before it executes — +// mutated still gated, denied never reach hooks. Hook-mutated calls also still +// flow through the downstream guards (shell-guard, read-file-guard, …), which +// re-validate the final shape. + +export type InterceptHookKind = "beforePrompt" | "beforeModel" | "afterTool"; + +type MaybePromise = T | Promise; + +export interface BeforePromptInput { + prompt: string; +} + +/** Return `{ prompt }` to replace the prompt; return undefined to keep it. */ +export type BeforePromptHook = ( + input: BeforePromptInput, +) => MaybePromise<{ prompt?: string } | undefined>; + +export interface BeforeModelInput { + call: ToolCall; +} + +/** + * Return `{ call }` to replace the call downstream, `{ skip }` to answer + * without executing. Return undefined to pass through unchanged. + */ +export type BeforeModelHook = ( + input: BeforeModelInput, + signal: AbortSignal, +) => MaybePromise<{ call?: ToolCall; skip?: ToolResult } | undefined>; + +export interface AfterToolInput { + call: ToolCall; + result: ToolResult; +} + +/** Return `{ result }` to replace the tool result; undefined keeps it. */ +export type AfterToolHook = ( + input: AfterToolInput, + signal: AbortSignal, +) => MaybePromise<{ result?: ToolResult } | undefined>; + +// What a PluginModule exports as `interceptHooks`: single hooks or lists. +export interface InterceptHookRegistration { + beforePrompt?: BeforePromptHook | readonly BeforePromptHook[]; + beforeModel?: BeforeModelHook | readonly BeforeModelHook[]; + afterTool?: AfterToolHook | readonly AfterToolHook[]; +} + +// Flattened registry: every stage is a list, in plugin registration order. +export interface InterceptHookRegistry { + beforePrompt: BeforePromptHook[]; + beforeModel: BeforeModelHook[]; + afterTool: AfterToolHook[]; +} + +export const emptyInterceptHookRegistry: InterceptHookRegistry = { + beforePrompt: [], + beforeModel: [], + afterTool: [], +}; + +function toHookList unknown>( + value: TFn | readonly TFn[] | undefined, +): TFn[] { + if (value === undefined) return []; + if (typeof value === "function") return [value]; + return [...value]; +} + +export function normalizeInterceptHooks( + input: InterceptHookRegistration | InterceptHookRegistry | undefined, +): InterceptHookRegistry { + if (input === undefined) return emptyInterceptHookRegistry; + return { + beforePrompt: toHookList(input.beforePrompt), + beforeModel: toHookList(input.beforeModel), + afterTool: toHookList(input.afterTool), + }; +} + +export function hasInterceptHooks(registry: InterceptHookRegistry): boolean { + return ( + registry.beforePrompt.length > 0 || + registry.beforeModel.length > 0 || + registry.afterTool.length > 0 + ); +} + +// Collect hooks from loaded plugin modules. Metadata-only (untrusted) modules +// contribute nothing: intercept hooks are in-process code execution, so they +// follow the tool-plugin trust bar, not the skills/agent-profile bar. +export function collectInterceptHooks( + modules: readonly PluginModule[], +): InterceptHookRegistry { + const registry: InterceptHookRegistry = { + beforePrompt: [], + beforeModel: [], + afterTool: [], + }; + for (const mod of modules) { + if (mod.metadataOnly === true) continue; + const registration = mod.interceptHooks; + if (registration === undefined) continue; + registry.beforePrompt.push( + ...toHookList(registration.beforePrompt), + ); + registry.beforeModel.push( + ...toHookList(registration.beforeModel), + ); + registry.afterTool.push( + ...toHookList(registration.afterTool), + ); + } + return registry; +} + +export interface InterceptHookWarningOptions { + onWarning?: (msg: string) => void; + diagnostics?: PluginLoadDiagnostics; +} + +function resolveInterceptWarning( + opts: InterceptHookWarningOptions = {}, +): (msg: string) => void { + if (opts.diagnostics !== undefined) + return resolvePluginWarningHandler({ diagnostics: opts.diagnostics }); + if (opts.onWarning !== undefined) + return resolvePluginWarningHandler({ onWarning: opts.onWarning }); + return stderrPluginWarning; +} + +function hookFailureText( + kind: InterceptHookKind, + index: number, + err: unknown, +): string { + const reason = err instanceof Error ? err.message : String(err); + return `intercept-hook: ${kind} hook ${index} failed: ${scrubSecrets(reason)}`; +} + +// Thread the prompt through each hook in order. A throwing hook is reported +// and skipped — prompt assembly must not crash the run. +export async function applyBeforePromptHooks( + hooks: readonly BeforePromptHook[], + prompt: string, + opts: InterceptHookWarningOptions = {}, +): Promise { + const onWarning = resolveInterceptWarning(opts); + let current = prompt; + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i]; + if (hook === undefined) continue; + try { + const outcome = await hook({ prompt: current }); + if (outcome?.prompt !== undefined) current = outcome.prompt; + } catch (err) { + onWarning(hookFailureText("beforePrompt", i, err)); + } + } + return current; +} + +// Run beforeModel hooks in order over the call. The first `{ skip }` wins and +// stops the remaining pre-hooks; the skip result still flows through +// afterTool so results stay observable in one place. `mutated` is true when +// any hook replaced the call — the middleware re-gates exactly then, so an +// untouched call is never evaluated (or re-prompted) twice. +export async function applyBeforeModelHooks( + hooks: readonly BeforeModelHook[], + call: ToolCall, + signal: AbortSignal, + opts: InterceptHookWarningOptions = {}, +): Promise<{ call: ToolCall; skip?: ToolResult; mutated: boolean }> { + const onWarning = resolveInterceptWarning(opts); + let current = call; + let mutated = false; + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i]; + if (hook === undefined) continue; + try { + const outcome = await hook({ call: current }, signal); + if (outcome?.call !== undefined) { + current = outcome.call; + mutated = true; + } + if (outcome?.skip !== undefined) + return { call: current, skip: outcome.skip, mutated }; + } catch (err) { + onWarning(hookFailureText("beforeModel", i, err)); + } + } + return { call: current, mutated }; +} + +// Run afterTool hooks in order over the result. A throwing hook is reported +// and the last good result is kept — post-processing never fails the call. +export async function applyAfterToolHooks( + hooks: readonly AfterToolHook[], + call: ToolCall, + result: ToolResult, + signal: AbortSignal, + opts: InterceptHookWarningOptions = {}, +): Promise { + const onWarning = resolveInterceptWarning(opts); + let current = result; + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i]; + if (hook === undefined) continue; + try { + const outcome = await hook({ call, result: current }, signal); + if (outcome?.result !== undefined) current = outcome.result; + } catch (err) { + onWarning(hookFailureText("afterTool", i, err)); + } + } + return current; +} + +export interface InterceptHookPluginOptions extends InterceptHookWarningOptions { + /** + * Re-gate hook-mutated calls. A beforeModel hook can rewrite an allowed + * call into a secret-guard/permission-denied shape, so a hook-replaced call + * is re-run through secret-guard plus this gate before it executes. + * buildCorePosixToolPlugins always wires the stack gate here; bare stacks + * pass their gate for the same guarantee. Secret-guard revalidation runs on + * every mutated call even without a gate. + */ + permissionGate?: PermissionGate; + secretGuardExtraDeniedPaths?: readonly string[]; +} + +// Unique pass-through sentinel: the probes below invoke `next` only on allow, +// so identity against this object tells allow from deny without +// reimplementing either denial shape. +const REGATE_PASS: ToolResult = { callId: "", content: "" }; + +// Re-run a hook-replaced call through the same two checks the original passed +// upstream. Returns the denial when the mutated shape is denied, undefined +// when it stays allowed. +async function revalidateMutatedCall( + mutated: ToolCall, + signal: AbortSignal, + opts: InterceptHookPluginOptions, +): Promise { + const pass = async (): Promise => REGATE_PASS; + const secretGuard = secretGuardPlugin( + opts.secretGuardExtraDeniedPaths !== undefined + ? { extraDeniedPaths: opts.secretGuardExtraDeniedPaths } + : undefined, + ); + const secretHandler = secretGuard.middleware?.(pass) ?? pass; + const secretVerdict = await secretHandler(mutated, signal); + if (secretVerdict !== REGATE_PASS) return secretVerdict; + if (opts.permissionGate !== undefined) { + const gateVerdict = await gateToolCall( + opts.permissionGate, + mutated, + signal, + pass, + ); + if (gateVerdict !== REGATE_PASS) return gateVerdict; + } + return undefined; +} + +// Tool middleware for the posix chain. Compose AFTER secret-guard/permission +// (see buildCorePosixToolPlugins): denials short-circuit at the gate above +// this layer, so hooks only ever see allowed calls. +export function interceptHookPlugin( + input: InterceptHookRegistration | InterceptHookRegistry, + opts: InterceptHookPluginOptions = {}, +): ToolPlugin { + const registry = normalizeInterceptHooks(input); + return { + middleware: + (next) => + async (call, signal): Promise => { + const pre = await applyBeforeModelHooks( + registry.beforeModel, + call, + signal, + opts, + ); + if (pre.skip !== undefined) { + return applyAfterToolHooks( + registry.afterTool, + pre.call, + pre.skip, + signal, + opts, + ); + } + if (pre.mutated) { + // Like an upstream gate denial, a mutated-to-denied call never + // executes and never reaches afterTool annotation. + const denial = await revalidateMutatedCall(pre.call, signal, opts); + if (denial !== undefined) return denial; + } + const raw = await next(pre.call, signal); + return applyAfterToolHooks( + registry.afterTool, + pre.call, + raw, + signal, + opts, + ); + }, + }; +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 71a15b807..bb7f20160 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { type } from "arktype"; import type { WorkflowPlugin } from "../workflows/types.js"; +import type { InterceptHookRegistration } from "./intercept-hooks.js"; import { SETTINGS_DIR_NAME } from "../branding.js"; import type { CommandPlugin } from "../tui/commands/registry.js"; import { pathIsInsideOrEqual } from "../util/path-contain.js"; @@ -52,6 +53,12 @@ export interface PluginModule { // A tool plugin factory: (options: unknown) => ToolPlugin | Promise. // Typed as unknown so this module doesn't pull in the tools-posix type graph. createToolPlugin?: unknown; + // CL-9888 in-process intercept hooks (beforePrompt/beforeModel/afterTool). + // Collected by collectInterceptHooks and composed after + // secret-guard/permission, so a denied call never reaches a hook. No + // manifest kind required: any trusted (non-metadata-only) module may + // export them. + interceptHooks?: InterceptHookRegistration; /** Discovery origin; used for project-trust gating. */ origin?: PluginOrigin; /** Absolute path used for path-bound trust (plugin directory or file). */