Skip to content

Commit a11370f

Browse files
committed
feat(plugins): add in-process intercept hooks after secret-guard and permission gate
1 parent 081c161 commit a11370f

4 files changed

Lines changed: 586 additions & 0 deletions

File tree

‎src/agent/posix-tool-plugins.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ import {
2727
type ReadFileGuardPluginOptions,
2828
} from "../plugins/read-file-guard-plugin.js";
2929
import type { PermissionGate } from "../permission/gate.js";
30+
import {
31+
hasInterceptHooks,
32+
interceptHookPlugin,
33+
normalizeInterceptHooks,
34+
type InterceptHookRegistration,
35+
type InterceptHookRegistry,
36+
} from "../plugins/intercept-hooks.js";
3037
import { createWorktreeRootsProvider } from "../permission/worktree-roots.js";
3138
import type { CompactionArchive } from "../session/compaction-archive.js";
3239
import type { ShellOutputFeedMap } from "../session/shell-output-feed.js";
@@ -59,6 +66,16 @@ export interface CorePosixToolPluginsArgs {
5966
* only — the default settings file stays covered either way.
6067
*/
6168
secretGuardExtraDeniedPaths?: readonly string[];
69+
// CL-9888 in-process intercept hooks (beforeModel/afterTool stages;
70+
// beforePrompt is a prompt-assembly pure function, not middleware).
71+
// Composed immediately after secret-guard/permission: a denied call
72+
// short-circuits at the gate above this layer and never reaches a hook,
73+
// so plugins can narrow or annotate allowed calls but never revive a
74+
// denied one. Hook-mutated calls still pass the downstream guards below.
75+
interceptHooks?: InterceptHookRegistration | InterceptHookRegistry;
76+
// Warning sink for hook failures (hooks never throw into the run).
77+
// Defaults to stderr; interactive callers pass a diagnostics-backed sink.
78+
onInterceptHookWarning?: (msg: string) => void;
6279
}
6380

6481
// Middleware order matches docs/ARCHITECTURE.md: path escape through truncation,
@@ -99,6 +116,8 @@ export function buildCorePosixToolPlugins(
99116
getShellOutputFeeds,
100117
getEvidenceArchive,
101118
secretGuardExtraDeniedPaths,
119+
interceptHooks,
120+
onInterceptHookWarning,
102121
} = args;
103122
// Pre-gate sandboxes honor yolo mode so outside-workspace path tools and shell
104123
// cwd are not hard-denied after the gate already auto-allows. Pass a live
@@ -144,6 +163,17 @@ export function buildCorePosixToolPlugins(
144163
: undefined,
145164
),
146165
permissionPlugin(permissionGate),
166+
...(interceptHooks !== undefined &&
167+
hasInterceptHooks(normalizeInterceptHooks(interceptHooks))
168+
? [
169+
interceptHookPlugin(
170+
interceptHooks,
171+
onInterceptHookWarning !== undefined
172+
? { onWarning: onInterceptHookWarning }
173+
: undefined,
174+
),
175+
]
176+
: []),
147177
shellGuardPlugin(cwd, shellTimeout, shellEnv, {
148178
allowOutsideCwd: allowOutside,
149179
...(getBackgroundShellRegistry !== undefined
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { composeMiddleware } from "@intx/tools-posix";
3+
import type { ToolCall, ToolResult } from "@intx/types/runtime";
4+
import { buildCorePosixToolPlugins } from "../agent/posix-tool-plugins.js";
5+
import { createPermissionGate } from "../permission/gate.js";
6+
import type { PluginModule } from "./loader.js";
7+
import {
8+
applyAfterToolHooks,
9+
applyBeforeModelHooks,
10+
applyBeforePromptHooks,
11+
collectInterceptHooks,
12+
interceptHookPlugin,
13+
normalizeInterceptHooks,
14+
} from "./intercept-hooks.js";
15+
16+
const signal = (): AbortSignal => new AbortController().signal;
17+
18+
const call = (overrides: Partial<ToolCall> = {}): ToolCall => ({
19+
id: "call-1",
20+
name: "read_file",
21+
arguments: { path: "src/index.ts" },
22+
...overrides,
23+
});
24+
25+
const okResult = (callId = "call-1", content = "base-ok"): ToolResult => ({
26+
callId,
27+
content,
28+
});
29+
30+
function warnings() {
31+
const messages: string[] = [];
32+
return {
33+
messages,
34+
onWarning: (msg: string): void => {
35+
messages.push(msg);
36+
},
37+
};
38+
}
39+
40+
function baseHandler(seen: ToolCall[]) {
41+
return async (c: ToolCall): Promise<ToolResult> => {
42+
seen.push(c);
43+
return okResult(c.id);
44+
};
45+
}
46+
47+
describe("applyBeforePromptHooks", () => {
48+
test("threads prompt replacements in order (sync and async)", async () => {
49+
const result = await applyBeforePromptHooks(
50+
[
51+
({ prompt }) => ({ prompt: `${prompt} [a]` }),
52+
async ({ prompt }) => ({ prompt: `${prompt}[b]` }),
53+
() => undefined,
54+
],
55+
"hello",
56+
);
57+
expect(result).toBe("hello [a][b]");
58+
});
59+
60+
test("a throwing hook is reported and skipped", async () => {
61+
const sink = warnings();
62+
const result = await applyBeforePromptHooks(
63+
[
64+
() => {
65+
throw new Error("boom");
66+
},
67+
({ prompt }) => ({ prompt: `${prompt}!` }),
68+
],
69+
"hello",
70+
sink,
71+
);
72+
expect(result).toBe("hello!");
73+
expect(sink.messages).toHaveLength(1);
74+
expect(sink.messages[0]).toMatch(/beforePrompt hook 0 failed/);
75+
});
76+
});
77+
78+
describe("interceptHookPlugin middleware", () => {
79+
test("beforeModel skip answers without executing", async () => {
80+
const seen: ToolCall[] = [];
81+
const afterSeen: string[] = [];
82+
const plugin = interceptHookPlugin({
83+
beforeModel: () => ({ skip: okResult("call-1", "skipped") }),
84+
afterTool: ({ result }) => {
85+
afterSeen.push(String(result.content));
86+
return undefined;
87+
},
88+
});
89+
const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen);
90+
const result = await handler(call(), signal());
91+
expect(result.content).toBe("skipped");
92+
expect(seen).toHaveLength(0);
93+
// Skip results still flow through afterTool for observability.
94+
expect(afterSeen).toEqual(["skipped"]);
95+
});
96+
97+
test("beforeModel call replacement reaches the handler", async () => {
98+
const seen: ToolCall[] = [];
99+
const plugin = interceptHookPlugin({
100+
beforeModel: ({ call: c }) => ({
101+
call: { ...c, arguments: { path: "src/rewritten.ts" } },
102+
}),
103+
});
104+
const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen);
105+
const result = await handler(call(), signal());
106+
expect(result.content).toBe("base-ok");
107+
expect(seen).toHaveLength(1);
108+
expect(seen[0]?.arguments).toEqual({ path: "src/rewritten.ts" });
109+
});
110+
111+
test("afterTool replaces the result", async () => {
112+
const seen: ToolCall[] = [];
113+
const plugin = interceptHookPlugin({
114+
afterTool: ({ result }) => ({
115+
result: { ...result, content: `${result.content}+annotated` },
116+
}),
117+
});
118+
const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen);
119+
const result = await handler(call(), signal());
120+
expect(result.content).toBe("base-ok+annotated");
121+
expect(seen).toHaveLength(1);
122+
});
123+
124+
test("hook failure is logged, not thrown; last good value wins", async () => {
125+
const seen: ToolCall[] = [];
126+
const sink = warnings();
127+
const plugin = interceptHookPlugin(
128+
{
129+
beforeModel: [
130+
() => {
131+
throw new Error("pre-boom");
132+
},
133+
({ call: c }) => ({
134+
call: { ...c, arguments: { path: "src/kept.ts" } },
135+
}),
136+
],
137+
afterTool: [
138+
() => {
139+
throw new Error("post-boom");
140+
},
141+
],
142+
},
143+
sink,
144+
);
145+
const handler = plugin.middleware?.(baseHandler(seen)) ?? baseHandler(seen);
146+
const result = await handler(call(), signal());
147+
expect(result.content).toBe("base-ok");
148+
expect(seen).toHaveLength(1);
149+
expect(seen[0]?.arguments).toEqual({ path: "src/kept.ts" });
150+
expect(sink.messages).toHaveLength(2);
151+
expect(sink.messages[0]).toMatch(/beforeModel hook 0 failed/);
152+
expect(sink.messages[1]).toMatch(/afterTool hook 0 failed/);
153+
});
154+
});
155+
156+
describe("applyBeforeModelHooks / applyAfterToolHooks", () => {
157+
test("beforeModel is callable directly with skip short-circuit", async () => {
158+
const outcome = await applyBeforeModelHooks(
159+
[() => ({ skip: okResult("call-1", "direct-skip") })],
160+
call(),
161+
signal(),
162+
);
163+
expect(outcome.skip?.content).toBe("direct-skip");
164+
});
165+
166+
test("afterTool is callable directly with replacement", async () => {
167+
const c = call();
168+
const outcome = await applyAfterToolHooks(
169+
[({ result }) => ({ result: { ...result, content: "replaced" } })],
170+
c,
171+
okResult(),
172+
signal(),
173+
);
174+
expect(outcome.content).toBe("replaced");
175+
});
176+
});
177+
178+
describe("collectInterceptHooks", () => {
179+
test("flattens single and list registrations in order", () => {
180+
const a = ({ prompt }: { prompt: string }) => ({ prompt: `${prompt}a` });
181+
const b = ({ prompt }: { prompt: string }) => ({ prompt: `${prompt}b` });
182+
const modules = [
183+
{ interceptHooks: { beforePrompt: a } },
184+
{ interceptHooks: { beforePrompt: [b] } },
185+
{},
186+
] as PluginModule[];
187+
const registry = collectInterceptHooks(modules);
188+
expect(registry.beforePrompt).toEqual([a, b]);
189+
expect(registry.beforeModel).toEqual([]);
190+
expect(registry.afterTool).toEqual([]);
191+
});
192+
193+
test("metadata-only (untrusted) modules contribute nothing", () => {
194+
const hook = () => ({ skip: okResult() });
195+
const modules = [
196+
{ metadataOnly: true, interceptHooks: { beforeModel: hook } },
197+
] as PluginModule[];
198+
const registry = collectInterceptHooks(modules);
199+
expect(registry.beforeModel).toEqual([]);
200+
});
201+
202+
test("normalize accepts a registry as-is", () => {
203+
const registry = normalizeInterceptHooks({
204+
beforePrompt: [],
205+
beforeModel: [],
206+
afterTool: [],
207+
});
208+
expect(registry.beforeModel).toEqual([]);
209+
});
210+
});
211+
212+
describe("gate still enforced (CL-9888 safety)", () => {
213+
function stackWithHook(cwd: string, hookCalls: string[]) {
214+
const gate = createPermissionGate({
215+
approvals: [],
216+
interactive: false,
217+
skipPermissions: false,
218+
reactorGated: false,
219+
auto: false,
220+
cwd,
221+
});
222+
const plugins = buildCorePosixToolPlugins({
223+
cwd,
224+
permissionGate: gate,
225+
interceptHooks: {
226+
beforeModel: ({ call: c }) => {
227+
hookCalls.push(c.name);
228+
return undefined;
229+
},
230+
afterTool: ({ result }) => ({
231+
result: { ...result, content: `${result.content}+hook` },
232+
}),
233+
},
234+
});
235+
const middlewares = plugins.flatMap((p) =>
236+
p.middleware !== undefined ? [p.middleware] : [],
237+
);
238+
return composeMiddleware(middlewares, async (c) => okResult(c.id));
239+
}
240+
241+
test("secret-guard denial never reaches hooks", async () => {
242+
const hookCalls: string[] = [];
243+
const handler = stackWithHook(process.cwd(), hookCalls);
244+
const result = await handler(
245+
{
246+
id: "deny-1",
247+
name: "read_file",
248+
arguments: { path: ".env" },
249+
},
250+
signal(),
251+
);
252+
expect(result.isError).toBe(true);
253+
expect(String(result.content)).toMatch(/sensitive file blocked/);
254+
expect(hookCalls).toEqual([]);
255+
expect(String(result.content)).not.toContain("+hook");
256+
});
257+
258+
test("permission-gate denial of a catastrophic shell never reaches hooks", async () => {
259+
const hookCalls: string[] = [];
260+
const handler = stackWithHook(process.cwd(), hookCalls);
261+
const result = await handler(
262+
{
263+
id: "deny-2",
264+
name: "run_shell",
265+
arguments: { command: "rm -rf /" },
266+
},
267+
signal(),
268+
);
269+
expect(result.isError).toBe(true);
270+
expect(hookCalls).toEqual([]);
271+
expect(String(result.content)).not.toContain("+hook");
272+
});
273+
274+
test("an allowed call flows through hooks", async () => {
275+
const hookCalls: string[] = [];
276+
const handler = stackWithHook(process.cwd(), hookCalls);
277+
const result = await handler(
278+
{
279+
id: "allow-1",
280+
name: "read_file",
281+
arguments: { path: "src/index.ts" },
282+
},
283+
signal(),
284+
);
285+
expect(hookCalls).toEqual(["read_file"]);
286+
expect(String(result.content)).toContain("+hook");
287+
});
288+
});

0 commit comments

Comments
 (0)