diff --git a/docs/agent-tool-hooks.md b/docs/agent-tool-hooks.md new file mode 100644 index 000000000..7464eb340 --- /dev/null +++ b/docs/agent-tool-hooks.md @@ -0,0 +1,15 @@ +# Agent tool hooks + +External plugins inject `agents` and register a live policy with `ctx.agents.registerToolHook({ id, run })`. Registrations follow the plugin's active revision and Cordis lifetime. Disable or unload the plugin to remove its enforcement without replacing harness sessions. + +`run(event)` returns a denial reason for a `before` event, or `undefined` to continue. Throwing before a tool denies it. After events are observational; throwing there is logged. The hook receives agent and harness-session identities, a call ID, tool name, input, default destination, and the admitted mention snapshot. An after event retains the before snapshot so a successful call cannot acknowledge a mention delivered while it was in flight. Each mention includes its signed message audience and an `authorIsAgent` profile hint, so plugins can exempt agent coordination. The profile hint uses the sender profile already read for the conversation and is not authorization evidence. Plugin wakes and standalone attention turns are not mentions. + +Claude's SDK callbacks cover native and Buzz tools, including failed completions. Codex's native pre-tool hooks use an authenticated host MCP server. Buzz dynamic-tool completion is observed directly in the runtime, so a model cannot forge a successful completion by calling the MCP hook tool. Dynamic Buzz tool names are normalized as `mcp__buzz__NAME`. Codex emits after events for Buzz tools; it does not currently expose native-tool completions through this adapter. + +Each Codex thread start or resume gets its own host MCP transport, including ephemeral wake and title threads. Unsubscribing releases that transport; closing the app-server releases any that remain. + +Codex's process-local bridge definition is checked with `hooks/list`. Only that exact definition receives trust in thread configuration. User hook trust is never bypassed or written. A Codex version that cannot register the bridge fails session preparation. Native MCP hook transport errors are fail-open in Codex itself, so this feature is a conversational guardrail, not a sandbox. The host also evaluates Buzz dynamic tools directly. + +The bridge is installed when a session starts. Sessions running an older Buzz build need a one-time restart after updating. Policy changes and plugin settings are read on each call. Ordering tools does not place a time limit on model reasoning or guarantee response latency. + +The reply-first policy belongs to the separate external plugin, rather than the host API. It exempts agent senders and agents-only messages, and only releases pending human mentions after a successful public send in their conversation. diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index b88a1d82f..ad99c1cc3 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -375,6 +375,8 @@ function setup( updateRequests: () => [], dismissUpdate: vi.fn(), approveUpdate: vi.fn(), + registerToolHook: vi.fn(), + toolHook: vi.fn(async () => undefined), } satisfies Agents; const disconnected = { status: "disconnected", generation: 0 }; const relay = { diff --git a/src/bundled/claude-code/claude.test.ts b/src/bundled/claude-code/claude.test.ts index 7e0106a7d..b3e3e8221 100644 --- a/src/bundled/claude-code/claude.test.ts +++ b/src/bundled/claude-code/claude.test.ts @@ -635,3 +635,60 @@ describe("stepLabel", () => { expect(stepLabel({ type: "assistant" })).toBeUndefined(); }); }); + +it("installs a live pre-tool callback and returns native denial without ending the turn", async () => { + const { spawn, processes } = fakeSpawn(); + let deny = true; + const hook = vi.fn(async () => + deny + ? { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "Reply first", + }, + } + : {}, + ); + const claude = await ClaudeProcess.start(spawn, { + ...launch, + tools: async () => ({}), + hook, + }); + const fake = processes[0]; + if (!fake) throw new Error("Claude did not start"); + expect(fake.received[0]).toMatchObject({ + request: { hooks: { PreToolUse: [{ hookCallbackIds: ["tools"] }] } }, + }); + for (const [id, expected] of [ + ["blocked", "deny"], + ["allowed", undefined], + ] as const) { + fake.emit({ + type: "control_request", + request_id: id, + request: { + subtype: "hook_callback", + callback_id: "tools", + input: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: id, + tool_input: { command: "pwd" }, + }, + }, + }); + await flush(); + const reply = fake.received.find( + (wire) => (wire.response as Record)?.request_id === id, + )?.response as { + response: { hookSpecificOutput?: { permissionDecision?: string } }; + }; + expect(reply.response.hookSpecificOutput?.permissionDecision).toBe( + expected, + ); + deny = false; + } + expect(hook).toHaveBeenCalledTimes(2); + await claude.kill(); +}); diff --git a/src/bundled/claude-code/claude.ts b/src/bundled/claude-code/claude.ts index 6592f72c7..32e74e1bc 100644 --- a/src/bundled/claude-code/claude.ts +++ b/src/bundled/claude-code/claude.ts @@ -39,6 +39,10 @@ export type ClaudeLaunch = Readonly<{ sessionId?: string; /** The in-process MCP server's tools. */ tools?: ToolServer; + hook?: ( + conversation: string | undefined, + input: Record, + ) => Promise; }>; /** Answers the in-process server's JSON-RPC for the conversation a process @@ -150,6 +154,7 @@ export class ClaudeProcess { private readonly process: HostProcess, resume: string | undefined, private tools: ToolServer | undefined, + private hook: ClaudeLaunch["hook"], /** The session's own model, as launched; undefined is Claude's default. */ private readonly ownModel: string | undefined, ) { @@ -234,6 +239,7 @@ export class ClaudeProcess { process, launch.resume ?? launch.sessionId, launch.tools, + launch.hook, launch.model, ); for (const data of earlyErrors) claude.readError(data); @@ -243,8 +249,11 @@ export class ClaudeProcess { /** Gives a started process its prompt, and its tools to whoever it now * works for. Loads settings and tools now rather than on the first message. */ - async initialize(launch: Pick) { + async initialize( + launch: Pick, + ) { if (launch.tools) this.tools = launch.tools; + this.hook = launch.hook; const { written, answer } = this.request({ subtype: "initialize", appendSystemPrompt: launch.systemPrompt, @@ -252,7 +261,14 @@ export class ClaudeProcess { ? { sdkMcpServers: [TOOL_SERVER], hooks: { + ...(launch.hook + ? { + PreToolUse: [{ hookCallbackIds: ["tools"] }], + PostToolUseFailure: [{ hookCallbackIds: ["tools"] }], + } + : {}), PostToolUse: [ + ...(launch.hook ? [{ hookCallbackIds: ["tools"] }] : []), { matcher: FINAL_TOOL, hookCallbackIds: ["final"] }, ], }, @@ -615,9 +631,29 @@ export class ClaudeProcess { behavior: "allow", updatedInput: request.input ?? {}, }); - else if (request.subtype === "hook_callback") - response = success(this.final(request.input) ? { continue: false } : {}); - else + else if (request.subtype === "hook_callback") { + if (request.callback_id === "tools" && this.hook) { + try { + response = success( + await this.hook( + this.conversation, + (request.input ?? {}) as Message, + ), + ); + } catch (error) { + response = success({ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: String(error), + }, + }); + } + } else + response = success( + this.final(request.input) ? { continue: false } : {}, + ); + } else response = { subtype: "error", request_id: id, diff --git a/src/bundled/claude-code/index.ts b/src/bundled/claude-code/index.ts index 10cfb251a..0ad11b7c5 100644 --- a/src/bundled/claude-code/index.ts +++ b/src/bundled/claude-code/index.ts @@ -35,6 +35,7 @@ export const apply: PluginModule["apply"] = (ctx) => { (raw) => providerDefaults.resolve(raw), () => agents.tools(), repositoriesContext, + (event) => agents.toolHook(event), ); /** A Claude agent is running, so Buzz's copy is needed; unknown until the * agents have loaded. */ diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 9d2764de9..d96808023 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -65,6 +65,7 @@ function setup( /** The channel's Canvas, as it reads now. */ canvas?: { content: string; pubkey?: string }; repositories?: (scope: string | undefined) => string; + toolHook?: import("../../features/agents/tool-hooks").AgentToolHooks["toolHook"]; }> = {}, ) { const fake = fakeSpawn(); @@ -166,6 +167,7 @@ function setup( config, () => added, options.repositories, + options.toolHook, ); const published: { kind: number; content: string; tags: string[][] }[] = []; const publish = vi.fn(async (template: (typeof published)[number]) => { @@ -2296,6 +2298,113 @@ it("delivers a rootless subscription to its channel without publishing its ancho expect(JSON.stringify(f.published)).not.toContain(WAKE_ID); }); +it("passes real mention snapshots to tool hooks without treating plugin wakes as mentions", async () => { + const seen: import("../../features/agents/tool-hooks").AgentToolHookEvent[] = + []; + const f = setup([], { + toolHook: async (event) => { + seen.push(event); + return undefined; + }, + }); + const root = message("1", "original"); + await f.deliver(pluginWake(root.id)); + await flush(10); + const process = f.claudes().find((process) => process.prompts.length); + if (!process) throw new Error("Claude did not start"); + const hook = (id: string) => + process.emit({ + type: "control_request", + request_id: id, + request: { + subtype: "hook_callback", + callback_id: "tools", + input: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: id, + tool_input: { command: "pwd" }, + }, + }, + }); + hook("wake"); + await flush(); + expect(seen[0]?.messages).toEqual([]); + const mention = message("2", "@Claude continue", { + created_at: 210, + tags: [ + ["h", channel], + ["e", root.id, "", "reply"], + ["p", self], + ], + }); + await f.deliver({ type: "mention", event: mention }); + await flush(10); + hook("mention"); + await flush(); + expect(seen[1]?.messages.map(({ id }) => id)).toEqual([mention.id]); + expect(seen[1]?.messages[0]).toMatchObject({ + authorIsAgent: false, + audience: "everyone", + }); + expect(seen[1]?.context).toEqual({ channel, root: root.id }); + f.runtime.dispose(); +}); + +it("omits undelivered mentions from the next Claude tool hook snapshot", async () => { + const seen: import("../../features/agents/tool-hooks").AgentToolHookEvent[] = + []; + const root = message("1", "original"); + const f = setup([root], { + toolHook: async (event) => { + seen.push(event); + return undefined; + }, + }); + const mention = (id: string) => + message(id, "@Claude continue", { + tags: [ + ["h", channel], + ["e", root.id, "", "reply"], + ["p", self], + ], + }); + const failed = mention("2"); + const admitted = mention("3"); + vi.spyOn(AgentSessions.prototype, "deliver").mockResolvedValueOnce({ + ok: false, + error: "Claude could not start", + undelivered: true, + }); + try { + await f.deliver({ type: "mention", event: failed }); + await flush(10); + await f.deliver({ type: "mention", event: admitted }); + await flush(10); + const process = f.claudes().find((process) => process.prompts.length); + if (!process) throw new Error("Claude did not start"); + process.emit({ + type: "control_request", + request_id: "hook", + request: { + subtype: "hook_callback", + callback_id: "tools", + input: { + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_use_id: "hook", + tool_input: { command: "pwd" }, + }, + }, + }); + await flush(); + expect(seen).toHaveLength(1); + expect(seen[0]?.messages.map(({ id }) => id)).toEqual([admitted.id]); + } finally { + f.runtime.dispose(); + } +}); + it("refreshes Attention instructions after Claude starts", async () => { let release!: () => void; const starting = new Promise((resolve) => { diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 402945db2..f4b1b3c85 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -1,3 +1,4 @@ +import { hookMessageSource } from "../../features/agents/tool-hooks"; // Turns Agents deliveries into Claude turns. Each agent of this type has its // own sessions; a delivery becomes a prompt in the harness format, sent to the // session for its conversation; a standalone turn, such as a watch's or a @@ -5,6 +6,11 @@ // so one agent works in several conversations at once. Claude acts in Buzz with // the in-process Buzz tools, signed by the agent's native-held key; the runtime // only reports a turn that fails. +import { + ToolHookCallbacks, + type AgentHookMessage, + type AgentToolHooks, +} from "../../features/agents/tool-hooks"; import type { Context } from "../../buzz-mcp/client"; import { RESUMED_PROMPT } from "../../buzz-mcp/prompt"; import { type Listing, respond } from "../../buzz-mcp/rpc"; @@ -123,6 +129,7 @@ type Entry = { contexts: Map; /** The project folders each Solo chat's latest turn named, for its process. */ directories: Map; + hookMessages: Map; /** Each conversation's latest posting policy: when its turns were handed * over, and when all of them settled. */ policies: Map< @@ -188,6 +195,8 @@ export class ClaudeRuntime { /** Desktop-local repository instructions; standalone hosts keep their own layout. */ private readonly repositories: (scope: string | undefined) => string = () => "", + private readonly toolHook: AgentToolHooks["toolHook"] = async () => + undefined, ) {} private get spawn(): Spawn { @@ -461,7 +470,13 @@ export class ClaudeRuntime { if (wake && !wakeLands(wake, topLevel)) return ignored?.(); // Frozen with the policy: a scope change while this waits must not move // its prompt to another conversation than the one it is delivered to. - const key = await this.key(entry, agent.pubkey, event, channel, topLevel); + const { key, dm } = await this.key( + entry, + agent.pubkey, + event, + channel, + topLevel, + ); signal.throwIfAborted(); const handoff = this.controls.deliver( `${agent.pubkey}/${key}`, @@ -536,6 +551,28 @@ export class ClaudeRuntime { ? { channel } : { channel, root: threadReference(event)?.rootId ?? event.id }, ); + const hookMessage: AgentHookMessage | undefined = + trigger.type === "mention" + ? { + ...hookMessageSource( + event, + this.reads(agent.pubkey)?.profile(event.pubkey), + ), + id: event.id, + channel, + ...(topLevel + ? { topLevel } + : dm + ? {} + : { root: threadReference(event)?.rootId ?? event.id }), + deliveredAt: Date.now(), + } + : undefined; + if (hookMessage) + entry.hookMessages.set(key, [ + ...(entry.hookMessages.get(key) ?? []).slice(-199), + hookMessage, + ]); delivered = entry.sessions.deliver(key, text, event.created_at, { ...(wake ? { @@ -567,8 +604,16 @@ export class ClaudeRuntime { }); void delivered.then( (result) => { - if (result.undelivered) + if (result.undelivered) { + if (hookMessage) + entry.hookMessages.set( + key, + (entry.hookMessages.get(key) ?? []).filter( + (message) => message !== hookMessage, + ), + ); agent.restoreNotes?.(channel, notes, noteRoot); + } }, () => undefined, ); @@ -731,9 +776,13 @@ export class ClaudeRuntime { topLevel: boolean, ) { const summary = await this.reads(self)?.channel(channelId); - return topLevel || summary?.dm === true || entry.config.scope === "channel" - ? channelId - : `${channelId}/${threadReference(event)?.rootId ?? event.id}`; + return { + dm: summary?.dm === true, + key: + topLevel || summary?.dm === true || entry.config.scope === "channel" + ? channelId + : `${channelId}/${threadReference(event)?.rootId ?? event.id}`, + }; } /** Where the event's turn goes, and what its prompt needs. */ @@ -841,6 +890,7 @@ export class ClaudeRuntime { team: "", sessions: undefined as unknown as AgentSessions, contexts: new Map(), + hookMessages: new Map(), policies: new Map(), directories: new Map(), }; @@ -887,6 +937,7 @@ export class ClaudeRuntime { addedKey(this.added()), ]), launch: async (key) => { + const callbacks = new ToolHookCallbacks(this.toolHook); const { workspace, instructions, scope } = entry.config; const memory = await this.core(pubkey); const directories = key ? entry.directories.get(key) : undefined; @@ -894,6 +945,35 @@ export class ClaudeRuntime { ...where(entry.config), ...(directories ? { directories } : {}), tools: tools(workspace), + hook: async (conversation, input) => { + if (!conversation) return {}; + const callId = String(input.tool_use_id ?? ""); + if (input.hook_event_name !== "PreToolUse") { + await callbacks.after( + callId, + input.hook_event_name === "PostToolUse", + ); + return {}; + } + const reason = await callbacks.before({ + agent: pubkey, + session: conversation, + callId, + tool: String(input.tool_name), + input: (input.tool_input ?? {}) as Record, + context: entry.contexts.get(conversation) ?? {}, + messages: entry.hookMessages.get(conversation) ?? [], + }); + return reason + ? { + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: reason, + }, + } + : {}; + }, systemPrompt: systemPrompt({ scope, cwd: workspace, diff --git a/src/bundled/codex/hooks.test.ts b/src/bundled/codex/hooks.test.ts new file mode 100644 index 000000000..22d8b6b4e --- /dev/null +++ b/src/bundled/codex/hooks.test.ts @@ -0,0 +1,149 @@ +import { expect, it, vi } from "vitest"; +import type { HostMcpServer } from "../../features/host/service"; +import type { AppServer } from "./rpc"; +import { CodexHooks, hookToolName } from "./hooks"; +it("exposes evaluation but no forged completion, and trusts only its own current hook hash", async () => { + let handler!: (message: unknown) => Promise; + const close = vi.fn(async () => {}); + const before = vi.fn(async () => "Reply first"); + const hooks = new CodexHooks(async (run) => { + handler = run; + return { + url: "http://127.0.0.1:123/mcp", + headers: [{ name: "Authorization", value: "Bearer fixture" }], + close, + closed: new Promise(() => {}), + }; + }, before); + expect(await hooks.open()).toEqual([ + "-c", + expect.stringContaining("hooks.PreToolUse="), + ]); + const own = { + key: "own", + currentHash: "hash", + source: "sessionFlags", + server: "buzz_tool_hooks", + tool: "before_tool", + eventName: "preToolUse", + }; + const request = vi.fn(async () => ({ + data: [{ hooks: [own, { ...own, key: "user", source: "user" }] }], + })); + const rpc = { request } as unknown as AppServer; + expect((await hooks.config(rpc, "/workspace")).state).toEqual({ + own: { enabled: true, trusted_hash: "hash" }, + }); + const list = (await handler({ id: 1, method: "tools/list" })) as { + result: { tools: { name: string }[] }; + }; + expect(list.result.tools.map((tool) => tool.name)).toEqual(["before_tool"]); + const reply = (await handler({ + id: 2, + method: "tools/call", + params: { name: "before_tool", arguments: { session_id: "s" } }, + })) as { result: { content: { text: string }[] } }; + expect(JSON.parse(reply.result.content[0]?.text ?? "")).toEqual({ + decision: "block", + reason: "Reply first", + }); + request.mockResolvedValue({ data: [{ hooks: [] }] }); + await expect(hooks.config(rpc, "/workspace")).rejects.toThrow( + "did not register", + ); + await hooks.close(); + expect(close).toHaveBeenCalledOnce(); +}); + +it("normalizes native dynamic-tool names for plugin policies", () => { + expect(hookToolName("buzzsend")).toBe("mcp__buzz__send"); + expect(hookToolName("buzzread")).toBe("mcp__buzz__read"); + expect(hookToolName("Bash")).toBe("Bash"); +}); + +it("gives concurrent and resumed threads separate transports and releases each independently", async () => { + const servers: { url: string; close: ReturnType }[] = []; + const hooks = new CodexHooks( + async () => { + const server = { + url: `http://127.0.0.1:${1234 + servers.length}/mcp`, + headers: [], + close: vi.fn(async () => {}), + closed: new Promise(() => {}), + }; + servers.push(server); + return server; + }, + async () => undefined, + ); + const rpc = { + request: async () => ({ + data: [ + { + hooks: [ + { + key: "own", + currentHash: "hash", + source: "sessionFlags", + server: "buzz_tool_hooks", + tool: "before_tool", + eventName: "preToolUse", + }, + ], + }, + ], + }), + } as unknown as AppServer; + await hooks.open(); + const first = await hooks.config(rpc, "/workspace"); + const second = await hooks.config(rpc, "/workspace"); + expect(first.server.url).not.toBe(second.server.url); + await first.close(); + expect(servers[0]?.close).toHaveBeenCalledOnce(); + expect(servers[1]?.close).not.toHaveBeenCalled(); + const resumed = await hooks.config(rpc, "/workspace"); + expect(resumed.server.url).not.toBe(first.server.url); + await hooks.close(); + for (const server of servers) expect(server.close).toHaveBeenCalledOnce(); +}); + +it("closes a transport whose allocation finishes after process shutdown", async () => { + let resolve!: (server: HostMcpServer) => void; + const serve = vi.fn( + () => + new Promise[0]>((done) => { + resolve = done; + }), + ); + const hooks = new CodexHooks(serve, async () => undefined); + const rpc = { + request: async () => ({ + data: [ + { + hooks: [ + { + key: "own", + currentHash: "hash", + source: "sessionFlags", + server: "buzz_tool_hooks", + tool: "before_tool", + eventName: "preToolUse", + }, + ], + }, + ], + }), + } as unknown as AppServer; + const preparing = hooks.config(rpc, "/workspace"); + await vi.waitFor(() => expect(serve).toHaveBeenCalledOnce()); + await hooks.close(); + const close = vi.fn(async () => {}); + resolve({ + url: "http://127.0.0.1:123/mcp", + headers: [], + close, + closed: new Promise(() => {}), + }); + await expect(preparing).rejects.toThrow("was closed"); + expect(close).toHaveBeenCalledOnce(); +}); diff --git a/src/bundled/codex/hooks.ts b/src/bundled/codex/hooks.ts new file mode 100644 index 000000000..72e649b2b --- /dev/null +++ b/src/bundled/codex/hooks.ts @@ -0,0 +1,154 @@ +import type { Host, HostMcpServer } from "../../features/host/service"; +import type { AppServer } from "./rpc"; + +export function hookToolName(name: string) { + return name.startsWith("buzz") ? `mcp__buzz__${name.slice(4)}` : name; +} + +const SERVER = "buzz_tool_hooks"; +const TOOL = "before_tool"; +/** JSON values as TOML inline values, for Codex's process-local overrides. */ +const toml = (value: unknown): string => { + if (Array.isArray(value)) return `[${value.map(toml).join(",")}]`; + if (value && typeof value === "object") + return `{${Object.entries(value) + .map(([key, item]) => `${JSON.stringify(key)}=${toml(item)}`) + .join(",")}}`; + return JSON.stringify(value); +}; + +/** An authenticated host transport. Only pre-tool evaluation is exposed over + * MCP: a model calling this tool cannot forge a successful tool completion. */ +export class CodexHooks { + private readonly servers = new Set(); + private closed = false; + constructor( + private readonly serve: NonNullable, + private readonly before: ( + input: Record, + ) => Promise, + ) {} + async open() { + const input = Object.fromEntries( + ["session_id", "turn_id", "tool_use_id", "tool_name", "tool_input"].map( + (key) => [key, `\${${key}}`], + ), + ); + return [ + "-c", + `hooks.PreToolUse=${toml([{ matcher: ".*", hooks: [{ type: "mcp_tool", server: SERVER, tool: TOOL, input, timeout: 30 }] }])}`, + ]; + } + /** Trust only the exact bridge definition returned by this running binary. + * Keep it in thread config; never write the user's persisted hook trust. */ + async config(rpc: AppServer, cwd: string) { + if (this.closed) throw new Error("The Codex hook bridge was closed"); + const listed = await rpc.request<{ + data: { + hooks: { + key: string; + currentHash: string; + source: string; + server?: string; + tool?: string; + eventName: string; + }[]; + }[]; + }>("hooks/list", { cwds: [cwd] }); + const own = listed.data + .flatMap((row) => row.hooks) + .filter( + (hook) => + hook.server === SERVER && + hook.tool === TOOL && + hook.eventName === "preToolUse" && + hook.source === "sessionFlags", + ); + if (own.length !== 1) + throw new Error( + "This Codex version did not register Buzz's pre-tool hook", + ); + const server = await this.serve(async (value) => { + const wire = value as { + id?: string | number; + method?: string; + params?: Record; + }; + const result = (result: unknown) => ({ + jsonrpc: "2.0", + id: wire.id ?? 0, + result, + }); + if (wire.method === "initialize") + return result({ + protocolVersion: "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: SERVER, version: "1" }, + }); + if (wire.method === "tools/list") + return result({ + tools: [ + { + name: TOOL, + description: "Evaluate the host's pre-tool hooks.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: true, + }, + }, + ], + }); + if (wire.method !== "tools/call") return result({}); + let reason: string | undefined; + try { + if (wire.params?.name !== TOOL) throw new Error("Unknown hook tool"); + reason = await this.before( + (wire.params.arguments ?? {}) as Record, + ); + } catch (error) { + reason = String(error); + } + return result({ + content: [ + { + type: "text", + text: JSON.stringify(reason ? { decision: "block", reason } : {}), + }, + ], + }); + }); + if (this.closed) { + await server.close(); + throw new Error("The Codex hook bridge was closed"); + } + this.servers.add(server); + const close = async () => { + if (this.servers.delete(server)) await server.close(); + }; + return { + close, + server: { + url: server.url, + http_headers: Object.fromEntries( + server.headers.map(({ name, value }) => [name, value]), + ), + enabled: true, + required: true, + }, + state: Object.fromEntries( + own.map((hook) => [ + hook.key, + { enabled: true, trusted_hash: hook.currentHash }, + ]), + ), + }; + } + async close() { + this.closed = true; + const servers = [...this.servers]; + this.servers.clear(); + await Promise.all(servers.map((server) => server.close())); + } +} +export const HOOK_SERVER = SERVER; diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 39c174dde..3562a4de9 100644 --- a/src/bundled/codex/index.ts +++ b/src/bundled/codex/index.ts @@ -36,6 +36,12 @@ export const apply: PluginModule["apply"] = (ctx) => { (raw) => providerController.resolve(raw), () => ctx.agents.tools(), repositoriesContext, + ctx.host.serveMcp + ? { + serve: ctx.host.serveMcp.bind(ctx.host), + run: (event) => ctx.agents.toolHook(event), + } + : undefined, ); const typeKey = `${ctx.pluginOwner?.id ?? "buzz.codex"}/${TYPE_ID}`; /** A Codex agent is running, so Buzz's copy is needed; unknown until the diff --git a/src/bundled/codex/manifest.json b/src/bundled/codex/manifest.json index f9ba465ea..e9dad7c62 100644 --- a/src/bundled/codex/manifest.json +++ b/src/bundled/codex/manifest.json @@ -8,6 +8,7 @@ "tags": ["Essentials", "code", "coding-agents"] }, "host": { + "mcp": true, "networkOrigins": ["https://releases.openai.com"], "processes": [ { diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 8adcff86b..c5a2e2372 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -11,7 +11,8 @@ import type { RelayData } from "../../features/relay/service"; import type { EventData } from "../../features/relay/events"; import { eventDto } from "../../features/relay/events"; import { keypair, signed } from "../../features/relay/testing"; -import type { HostProcessOptions } from "../../features/host/service"; +import type { AgentToolHooks } from "../../features/agents/tool-hooks"; +import type { Host, HostProcessOptions } from "../../features/host/service"; import { BUZZ_PROMPT, TOP_LEVEL_PROMPT } from "../../buzz-mcp/prompt"; import { CodexRuntime, codexStep, programs } from "./runtime"; import { config } from "./config"; @@ -53,6 +54,10 @@ function fixture( model_reasoning_summary?: string; } = {}, repositories?: (scope: string | undefined) => string, + hooks?: { + serve: NonNullable; + run: AgentToolHooks["toolHook"]; + }, ) { let terminals = remainingTerminals; let options: HostProcessOptions | undefined; @@ -138,6 +143,23 @@ function fixture( }, }, }; + if (request.method === "hooks/list") + result = { + data: [ + { + hooks: [ + { + key: "own", + currentHash: "hash", + source: "sessionFlags", + server: "buzz_tool_hooks", + tool: "before_tool", + eventName: "preToolUse", + }, + ], + }, + ], + }; if (request.method === "thread/start") result = { thread: { id: `thread-${++thread}`, cwd: params.cwd } }; if (request.method === "thread/resume") @@ -318,6 +340,7 @@ function fixture( config, () => added, repositories, + hooks, ); runtimes.push(runtime); const events: EventData[] = []; @@ -3524,7 +3547,7 @@ it("ends a queued mention with the attention note current when it starts", async }); it("stops a wake when its turn signal aborts, and lists it as needing attention", async () => { - const f = fixture(); + const { f, servers } = hookFixture(); const turn = new AbortController(); f.wake(watchDelivery(f, { signal: turn.signal })); await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); @@ -3534,6 +3557,7 @@ it("stops a wake when its turn signal aborts, and lists it as needing attention" error: "The turn ran past its deadline", }); expect(f.sent.some((wire) => wire.method === "turn/interrupt")).toBe(true); + expect(servers[0]?.close).toHaveBeenCalledOnce(); expect(f.runtime.sessions(pubkey)).toEqual([ { key: "watch/deploys", @@ -3707,7 +3731,7 @@ it("continues an owner's Solo chat as one channel conversation posted at the top }); it("titles a new Solo chat once, from its first message on an ephemeral read-only thread", async () => { - const f = fixture(); + const { f, servers } = hookFixture(); asSoloChat(f); const named = vi.fn(); const titling = () => @@ -3718,6 +3742,8 @@ it("titles a new Solo chat once, from its first message on an ephemeral read-onl ); await f.runtime.run({ ...f.delivery("@Codex fix the login bug"), named }); await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); + expect(servers).toHaveLength(2); + expect(servers[0]?.url).not.toBe(servers[1]?.url); // Without Codex's title model in the catalog, the chat's own model titles it. expect(titling()[0]?.params).toMatchObject({ model: "test-model", @@ -3755,6 +3781,9 @@ it("titles a new Solo chat once, from its first message on an ephemeral read-onl await vi.waitFor(() => expect(f.runtime.sessions(pubkey)[0]?.status).toBe("Idle"), ); + await vi.waitFor(() => { + for (const server of servers) expect(server.close).toHaveBeenCalledOnce(); + }); // A resumed conversation is not new, so it is not titled again. await f.runtime.run({ ...f.delivery("@Codex later"), named }); await vi.waitFor(() => expect(f.starts()).toHaveLength(3)); @@ -4025,7 +4054,7 @@ describe("session actions", () => { f.sent.flatMap((wire) => (wire.method ? [wire.method] : [])); it("compacts immediately, and keeps the session active until an early interrupt ends its turn", async () => { - const f = fixture(); + const { f, servers } = hookFixture(); const delivery = f.delivery("first"); const agent = { ...delivery.agent, @@ -4057,6 +4086,7 @@ describe("session actions", () => { params: { threadId: "thread-1", turn: { id: "compact" } }, }); expect(await compacted).toBeInstanceOf(Error); + expect(servers[1]?.close).toHaveBeenCalledOnce(); await later; await vi.waitFor(() => expect(f.starts()).toHaveLength(2)); expect( @@ -4293,3 +4323,164 @@ it("allows an addressed channel turn to reply in an existing thread", async () = ); await f.complete(0); }); + +it("isolates hook transports for overlapping conversations and a resumed turn", async () => { + const { f, servers } = hookFixture(); + const first = f.runtime.run(f.delivery("first")); + const second = f.runtime.run(f.delivery("second", "e".repeat(64))); + const starts = () => f.sent.filter((wire) => wire.method === "turn/start"); + await vi.waitFor(() => expect(starts()).toHaveLength(2)); + const configs = () => + f.sent + .filter( + (wire) => + wire.method === "thread/start" || wire.method === "thread/resume", + ) + .map( + (wire) => + wire.params as { + config: { mcp_servers: { buzz_tool_hooks: { url: string } } }; + }, + ); + expect( + new Set(configs().map((p) => p.config.mcp_servers.buzz_tool_hooks.url)) + .size, + ).toBe(2); + await f.complete(0); + await first; + await vi.waitFor(() => expect(servers[0]?.close).toHaveBeenCalledOnce()); + expect(servers[1]?.close).not.toHaveBeenCalled(); + const resumed = f.runtime.run(f.delivery("again")); + await vi.waitFor(() => expect(starts()).toHaveLength(3)); + expect( + new Set(configs().map((p) => p.config.mcp_servers.buzz_tool_hooks.url)) + .size, + ).toBe(3); + await f.complete(1); + await f.complete(2); + await Promise.all([second, resumed]); + await vi.waitFor(() => { + for (const server of servers) expect(server.close).toHaveBeenCalledOnce(); + }); +}); + +function hookFixture() { + const run = vi.fn(async () => undefined); + const handlers: Parameters>[0][] = []; + const servers: { url: string; close: ReturnType }[] = []; + const f = fixture([], undefined, false, {}, undefined, { + serve: async (handler) => { + handlers.push(handler); + const server = { + url: `http://127.0.0.1:${1234 + servers.length}/mcp`, + headers: [], + close: vi.fn(async () => {}), + closed: new Promise(() => {}), + }; + servers.push(server); + return server; + }, + run, + }); + return { f, servers, handlers, run }; +} + +it("enforces native and Buzz tool policies for standalone turns without mention obligations", async () => { + const { f, handlers, run } = hookFixture(); + f.wake(watchDelivery(f)); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + const { threadId } = (f.starts()[0]?.params ?? {}) as { threadId: string }; + run.mockResolvedValue("Blocked by policy"); + const native = (await handlers[0]?.({ + id: 1, + method: "tools/call", + params: { + name: "before_tool", + arguments: { + session_id: threadId, + turn_id: "turn-1", + tool_use_id: "native", + tool_name: "Bash", + tool_input: { cmd: "pwd" }, + }, + }, + })) as { result: { content: { text: string }[] } }; + expect(JSON.parse(native.result.content[0]?.text ?? "")).toEqual({ + decision: "block", + reason: "Blocked by policy", + }); + expect(run).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "before", + agent: pubkey, + session: threadId, + tool: "Bash", + context: {}, + messages: [], + }), + ); + const denied = await f.tool("send", { + channel: "channel", + audience: "everyone", + text: "blocked", + }); + expect(denied?.result).toMatchObject({ + success: false, + contentItems: [{ text: "Blocked by policy" }], + }); + expect(f.publish).not.toHaveBeenCalled(); + run.mockResolvedValue(undefined); + await f.tool("send", { audience: "everyone", text: "missing destination" }); + expect(run).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "after", + success: false, + context: {}, + messages: [], + }), + ); + await f.tool("send", { + channel: "channel", + audience: "everyone", + text: "allowed", + }); + expect(f.publish).toHaveBeenCalledOnce(); + expect(run).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "after", + success: true, + tool: "mcp__buzz__send", + context: {}, + messages: [], + }), + ); + await f.complete(0, ""); + await f.workingUntil.mock.calls[0]?.[0]; +}); + +it("passes an agents-only mention audience to tool hooks", async () => { + const { f, run } = hookFixture(); + const delivery = f.delivery("coordination"); + if (delivery.trigger.type !== "mention") throw new Error("Expected mention"); + const work = f.runtime.run({ + ...delivery, + trigger: { + ...delivery.trigger, + event: { + ...delivery.trigger.event, + tags: [...delivery.trigger.event.tags, ["audience", "agents"]], + }, + }, + }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + await f.tool("send", { audience: "agents", text: "working" }); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + expect.objectContaining({ authorIsAgent: false, audience: "agents" }), + ], + }), + ); + await f.complete(); + await work; +}); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index d72302289..f6439cf0c 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -1,3 +1,10 @@ +import { hookMessageSource } from "../../features/agents/tool-hooks"; +import type { Host } from "../../features/host/service"; +import type { + AgentToolHookEvent, + AgentToolHooks, +} from "../../features/agents/tool-hooks"; +import { CodexHooks, HOOK_SERVER, hookToolName } from "./hooks"; import type { WorkOutcome } from "../../features/agents/local-activity"; import { fromOwner, workflowOf } from "../../features/agents/owner-attestation"; import { @@ -69,6 +76,7 @@ type Request = { /** The agent's community and owner, as `origin:viewer`. */ scope: string; event: EventData; + deliveredAt?: number; agent: AgentHandle; settings: Config; conversation: Conversation; @@ -232,6 +240,7 @@ function textOf(event: EventData, name: string) { * calls find their agent by thread and turn. */ export class CodexRuntime { private entries = new Map(); + private readonly bridges = new WeakMap(); private server: Server | undefined; private stopping = new Map>(); private views = new Map(); @@ -252,6 +261,10 @@ export class CodexRuntime { /** Desktop-local repository instructions; standalone hosts keep their own layout. */ private readonly repositories: (scope: string | undefined) => string = () => "", + private readonly hooks?: { + serve: NonNullable; + run: AgentToolHooks["toolHook"]; + }, ) {} subscribe = (listener: () => void) => { this.listeners.add(listener); @@ -322,15 +335,15 @@ export class CodexRuntime { throw new Error( "The workspace changed. Send a message to start its session first.", ); - const { params } = await this.prepare( + const { params, closeHooks } = await this.prepare( rpc, agent, settings, workspace, {}, ); - signal.throwIfAborted(); try { + signal.throwIfAborted(); await rpc.request("thread/resume", { ...params, threadId: saved.threadId, @@ -351,6 +364,7 @@ export class CodexRuntime { await rpc .request("thread/unsubscribe", { threadId: saved.threadId }) .catch(() => undefined); + await closeHooks?.(); } }); } @@ -499,26 +513,74 @@ export class CodexRuntime { this.open(); } private close() { + if (this.server) void this.bridges.get(this.server.rpc)?.close(); void this.server?.rpc.close(); this.server = undefined; } private open() { if (this.server) return this.server; const rpc = new AppServer((call) => this.tool(call)); + const bridge = + this.hooks && + new CodexHooks(this.hooks.serve, async (input) => { + const entries = [...this.entries.values()]; + const active = entries + .flatMap((entry) => [...entry.lanes.values()]) + .map((lane) => lane.active) + .find((turn) => turn?.threadId === input.session_id); + const turn = + active ?? + entries + .flatMap((entry) => [...entry.onces]) + .find((turn) => turn.threadId === input.session_id); + if (!turn) return undefined; // Background titles have no agent tools. + if (active) await active.ready; + if ( + turn.turnId !== input.turn_id || + turn.finished || + turn.signal.aborted + ) + return "The Codex turn has ended"; + return this.hooks?.run( + this.hookEvent( + turn, + String(input.tool_use_id), + hookToolName(String(input.tool_name)), + (input.tool_input ?? {}) as Record, + ), + ); + }); + if (bridge) this.bridges.set(rpc, bridge); // Threads set their own cwd; the process just needs a directory that exists. const server: Server = { rpc, - opening: rpc.open(this.spawn, defaults.workspace), + opening: bridge + ? bridge.open().then((args) => + rpc.open( + (id, options) => + this.spawn(id, { + ...options, + ...(args + ? { args: [...(options?.args ?? []), ...args] } + : {}), + }), + defaults.workspace, + ), + ) + : rpc.open(this.spawn, defaults.workspace), threads: new Map(), }; // An exit fails current work and permits the next ordinary mention to reopen. rpc.onMessage((wire) => { - if (wire.method === "connection/closed" && this.server === server) - this.server = undefined; + if (wire.method === "connection/closed") { + void bridge?.close(); + if (this.server === server) this.server = undefined; + } }); void server.opening.catch(() => { if (this.server === server) this.server = undefined; void rpc.close(); + void bridge?.close(); }); this.server = server; return server; @@ -809,6 +871,7 @@ export class CodexRuntime { request.answered(); return; } + request.deliveredAt = Date.now(); await active.rpc.request("turn/steer", { threadId: active.threadId, expectedTurnId: active.turnId, @@ -913,19 +976,16 @@ export class CodexRuntime { }; const followed = follow(server.rpc, signal, once); entry.onces.add(once); + let closeHooks: (() => Promise) | undefined; try { const rpc = await server.opening; signal.throwIfAborted(); const workspace = await this.workspace(settings.workspace); signal.throwIfAborted(); once.workspace = workspace; - const { params, model, effort, summary } = await this.prepare( - rpc, - agent, - settings, - workspace, - {}, - ); + const prepared = await this.prepare(rpc, agent, settings, workspace, {}); + const { params, model, effort, summary } = prepared; + closeHooks = prepared.closeHooks; const text = await prompt(); if (text === undefined) return { ok: true }; const memory = await this.core(agent.pubkey, signal); @@ -991,6 +1051,7 @@ export class CodexRuntime { .request("thread/unsubscribe", { threadId }) .catch(() => undefined); } + await closeHooks?.(); entry.onces.delete(once); release(); } @@ -1216,13 +1277,28 @@ export class CodexRuntime { : {}), } : {}; - const reply = await callSessionTool( - client, - context, - call.tool, + const event = this.hookEvent( + turn, + call.callId, + `mcp__buzz__${call.tool}`, args, - this.added(), ); + const denied = await this.hooks?.run(event); + if (denied) throw new Error(denied); + let reply: Awaited>; + try { + reply = await callSessionTool( + client, + context, + call.tool, + args, + this.added(), + ); + } catch (error) { + await this.hooks?.run({ ...event, phase: "after", success: false }); + throw error; + } + await this.hooks?.run({ ...event, phase: "after", success: true }); const final = call.tool === "send" && args.final === true; if (final) { for (const taken of answering) taken.answered(); @@ -1243,6 +1319,46 @@ export class CodexRuntime { ], }; } + private hookEvent( + turn: Active | Once, + callId: string, + tool: string, + input: Record, + ): AgentToolHookEvent { + const active = "reply" in turn ? turn : undefined; + const request = active?.reply; + return { + phase: "before", + agent: (request ?? (turn as Once)).agent.pubkey, + session: turn.threadId ?? "", + callId, + tool, + input, + context: request + ? { + channel: request.conversation.channelId, + ...(request.toolRoot ? { root: request.toolRoot } : {}), + ...(request.conversation.topLevel ? { topLevel: true } : {}), + } + : {}, + messages: (active?.requests ?? []) + .filter((request) => !request.wake) + .map((request) => ({ + ...hookMessageSource( + request.event, + this.reads(request.agent.pubkey)?.profile(request.event.pubkey), + ), + id: request.event.id, + channel: request.conversation.channelId, + ...(request.conversation.topLevel + ? { topLevel: true } + : request.toolRoot + ? { root: request.toolRoot } + : {}), + deliveredAt: request.deliveredAt ?? 0, + })), + }; + } /** The agent's memory, or only entry `slug`, as its community's reads give * it. */ private async memories( @@ -1343,7 +1459,9 @@ export class CodexRuntime { if (method === "item/completed" && item.type === "agentMessage") reply = item.text ?? ""; }); + let hookConfig: Awaited> | undefined; try { + hookConfig = await this.bridges.get(rpc)?.config(rpc, cwd); const started = await rpc.request<{ thread: { id: string } }>( "thread/start", { @@ -1352,7 +1470,15 @@ export class CodexRuntime { approvalPolicy: "never", sandbox: "read-only", ephemeral: true, - config, + config: hookConfig + ? { + ...config, + mcp_servers: { + ...(config.mcp_servers as object), + [HOOK_SERVER]: hookConfig.server, + }, + } + : config, }, ); turn.threadId = started.thread.id; @@ -1372,6 +1498,7 @@ export class CodexRuntime { await rpc .request("thread/unsubscribe", { threadId: turn.threadId }) .catch(() => undefined); + await hookConfig?.close(); } } @@ -1422,6 +1549,8 @@ export class CodexRuntime { ], ), ); + const hookConfig = await this.bridges.get(rpc)?.config(rpc, workspace); + if (hookConfig) mcp[HOOK_SERVER] = hookConfig.server; const params = { model, cwd: workspace, @@ -1431,6 +1560,9 @@ export class CodexRuntime { // agent's own watches and timers start work. sandbox: "danger-full-access", config: { + ...(hookConfig + ? { "hooks.state": hookConfig.state, "features.hooks": true } + : {}), web_search: "disabled", mcp_servers: mcp, "features.plugins": false, @@ -1441,7 +1573,7 @@ export class CodexRuntime { const summary = normalized.config.model_reasoning_summary ? {} : { summary: "auto" }; - return { params, model, effort, summary }; + return { params, model, effort, summary, closeHooks: hookConfig?.close }; } private async execute( request: Request, @@ -1472,6 +1604,7 @@ export class CodexRuntime { }; void active.ready.catch(() => undefined); lane.active = active; + let closeHooks: (() => Promise) | undefined; const { settings, agent } = request; const followed = follow(server.rpc, signal, active, (method, item) => { const step = method === "item/started" ? codexStep(item) : undefined; @@ -1497,13 +1630,15 @@ export class CodexRuntime { signal.throwIfAborted(); active.workspace = workspace; const existing = entry.saved[key]; - const { params, model, effort, summary } = await this.prepare( + const prepared = await this.prepare( rpc, agent, settings, workspace, request, ); + const { params, model, effort, summary } = prepared; + closeHooks = prepared.closeHooks; active.model = model; active.effort = effort; // Resume restores the thread's tools, so one started with other added @@ -1568,6 +1703,7 @@ export class CodexRuntime { ready(); return; } + request.deliveredAt = Date.now(); const turn = await rpc.request<{ turn: { id: string } }>("turn/start", { threadId: active.threadId, input: input( @@ -1671,6 +1807,7 @@ export class CodexRuntime { await server.rpc .request("thread/unsubscribe", { threadId: active.threadId }) .catch(() => server.rpc.close()); + await closeHooks?.(); if (lane.active === active) delete lane.active; } } diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index c878256f4..cfcbdba26 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -1,3 +1,9 @@ +import { + createAgentToolHooks, + type AgentToolHook, + type AgentToolHooks, + type AgentToolHookEvent, +} from "./tool-hooks"; import { appClient } from "../../buzz-mcp/app-client"; import { respond } from "../../buzz-mcp/rpc"; import { connectMcp } from "../../buzz-mcp/connection"; @@ -839,7 +845,7 @@ export type NewAgent = Readonly<{ * A hosted type's settings are its provider's, so it has none here. */ config?: unknown; }>; -export type Agents = { +export type Agents = AgentToolHooks & { /** Registers a deterministic source owned by this plugin. Attention controls * its lifetime; output goes privately to the watch's saved conversation. * The provider key is pluginId/id. Local agents only. */ @@ -1213,6 +1219,11 @@ export class AgentsService extends Service implements Agents { * that what a read or classifier call found under an older key is not * shown over the newer one. */ private keyGeneration = 0; + private readonly toolHooks; + registerToolHook(hook: AgentToolHook) { + this.toolHooks.register(this.ctx, hook); + } + toolHook = (event: AgentToolHookEvent) => this.toolHooks.run(event); private readonly contributions; private readonly addedTools; /** Every added tool's name, its plugin active or still starting. */ @@ -1306,6 +1317,7 @@ export class AgentsService extends Service implements Agents { ) { super(ctx, "agents"); this.contributions = createContributions(ctx); + this.toolHooks = createAgentToolHooks(ctx); this.addedTools = createContributions(ctx); this.attentionSources = createContributions(ctx); // Each changes the copy at once and logs a failed save itself. diff --git a/src/features/agents/tool-hooks.test.ts b/src/features/agents/tool-hooks.test.ts new file mode 100644 index 000000000..8a574461c --- /dev/null +++ b/src/features/agents/tool-hooks.test.ts @@ -0,0 +1,106 @@ +import { Context } from "@deepseek-ai/cordis"; +import { expect, it, vi } from "vitest"; +import { + createAgentToolHooks, + hookMessageSource, + ToolHookCallbacks, + type AgentToolHookEvent, +} from "./tool-hooks"; +const event: AgentToolHookEvent = { + phase: "before", + agent: "a", + session: "s", + callId: "c", + tool: "Bash", + input: {}, + context: {}, + messages: [{ id: "one", channel: "ch", deliveredAt: 1 }], +}; +it("consults active plugin revisions and removes enforcement on unload", async () => { + const root = new Context(); + let active = true; + const listeners = new Set<() => void>(); + root.provide("pluginStatus", { + isActive: () => active, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }); + const hooks = createAgentToolHooks(root); + const fiber = root + .extend({ pluginOwner: { id: "policy", revision: "one" } }) + .plugin((ctx) => + hooks.register(ctx, { id: "gate", run: () => "Reply first" }), + ); + await fiber.await(); + expect(await hooks.run(event)).toBe("Reply first"); + active = false; + for (const listener of listeners) listener(); + expect(await hooks.run(event)).toBeUndefined(); + active = true; + for (const listener of listeners) listener(); + expect(await hooks.run(event)).toBe("Reply first"); + await fiber.dispose(); + expect(await hooks.run(event)).toBeUndefined(); + await root.fiber.dispose(); +}); +it("retains pre-call messages when a newer mention arrives and ignores unmatched completions", async () => { + const seen: AgentToolHookEvent[] = []; + const callbacks = new ToolHookCallbacks(async (event) => { + seen.push(event); + return undefined; + }); + await callbacks.before(event); + const next = { + ...event, + callId: "new", + messages: [...event.messages, { id: "two", channel: "ch", deliveredAt: 2 }], + }; + await callbacks.before(next); + await callbacks.after("c", true); + await callbacks.after("missing", true); + await callbacks.after("new", false); + expect( + seen + .filter((event) => event.phase === "after") + .map(({ messages, success }) => [messages.map(({ id }) => id), success]), + ).toEqual([ + [["one"], true], + [["one", "two"], false], + ]); +}); +it("does not record a denied call as an executable tool", async () => { + const run = vi.fn(async () => "denied"); + const callbacks = new ToolHookCallbacks(run); + expect(await callbacks.before(event)).toBe("denied"); + await callbacks.after("c", true); + expect(run).toHaveBeenCalledOnce(); +}); + +it("exposes sender and audience facts without imposing a reply policy", () => { + const message = { + id: "m", + kind: 9, + pubkey: "a", + content: "hi", + tags: [], + created_at: 1, + }; + const profile = { + ...message, + kind: 0, + content: JSON.stringify({ is_agent: true }), + }; + expect(hookMessageSource(message, profile)).toEqual({ + authorIsAgent: true, + audience: "everyone", + }); + expect( + hookMessageSource({ ...message, tags: [["audience", "agents"]] }), + ).toEqual({ authorIsAgent: false, audience: "agents" }); + expect(hookMessageSource(message)).toEqual({ + authorIsAgent: false, + audience: "everyone", + }); +}); diff --git a/src/features/agents/tool-hooks.ts b/src/features/agents/tool-hooks.ts new file mode 100644 index 000000000..6d313aa1f --- /dev/null +++ b/src/features/agents/tool-hooks.ts @@ -0,0 +1,91 @@ +import type { EventData } from "../relay/events"; +import { agentAudience } from "../relay/audience"; +import { foldProfiles } from "../relay/profiles"; +import type { Context } from "@deepseek-ai/cordis"; +import { createContributions } from "../../plugins/contributions"; + +/** A mention admitted to a harness session. Tool hooks receive a snapshot, + * so a send already in flight cannot acknowledge a later mention. */ +export type AgentHookMessage = Readonly<{ + id: string; + channel: string; + root?: string; + topLevel?: boolean; + deliveredAt: number; + /** Profile hint, not an authorization decision. Absent on older hosts. */ + authorIsAgent?: boolean; + audience?: "everyone" | "agents"; +}>; +/** Shared sender facts for policies in either harness. */ +export function hookMessageSource(event: EventData, profile?: EventData) { + return { + authorIsAgent: !!( + profile && foldProfiles([profile]).get(event.pubkey)?.isAgent + ), + audience: agentAudience(event) + ? ("agents" as const) + : ("everyone" as const), + }; +} +export type AgentToolHookEvent = Readonly<{ + phase: "before" | "after"; + agent: string; + session: string; + callId: string; + tool: string; + input: Readonly>; + messages: readonly AgentHookMessage[]; + /** The tool's default destination when it started. */ + context: Readonly<{ channel?: string; root?: string; topLevel?: boolean }>; + success?: boolean; +}>; +export type AgentToolHook = Readonly<{ + id: string; + /** Return a reason to deny a before event. After events are observational. + * Invoked for every call against the currently active plugin revision. */ + run( + event: AgentToolHookEvent, + ): undefined | string | Promise; +}>; +export type AgentToolHooks = { + registerToolHook(hook: AgentToolHook): void; + toolHook(event: AgentToolHookEvent): Promise; +}; + +export function createAgentToolHooks(root: Context) { + const hooks = createContributions(root); + return { + register: hooks.register, + async run(event: AgentToolHookEvent) { + for (const hook of hooks.snapshot()) { + try { + const reason = await hook.run(event); + if (event.phase === "before" && reason) return reason; + } catch (error) { + if (event.phase === "before") + return `Agent tool hook ${hook.key} failed: ${String(error)}`; + console.error(`Agent tool hook ${hook.key} failed`, error); + } + } + return undefined; + }, + }; +} + +/** Adapts a harness's native pre/post callbacks while retaining the exact + * pre-call snapshot. Missing post callbacks do not count as successful tools. */ +export class ToolHookCallbacks { + private calls = new Map(); + constructor(private readonly run: AgentToolHooks["toolHook"]) {} + async before(event: Omit) { + const snapshot: AgentToolHookEvent = { ...event, phase: "before" }; + const reason = await this.run(snapshot); + if (!reason) this.calls.set(event.callId, snapshot); + return reason; + } + async after(callId: string, success: boolean) { + const event = this.calls.get(callId); + this.calls.delete(callId); + if (event) await this.run({ ...event, phase: "after", success }); + } +} diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 97fce5381..862ad7ed4 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -1,5 +1,11 @@ // FOUNDATION: Type-only author entry. Runtime capabilities come from injected ctx. export type { PluginManifest, PluginModule } from "./api"; +export type { + AgentHookMessage, + AgentToolHookEvent, + AgentToolHook, + AgentToolHooks, +} from "../features/agents/tool-hooks"; export type { SettingsCard, SettingsCards } from "../features/settings/service"; export type { Context } from "@deepseek-ai/cordis"; export type { Host, HostRequest, HostResponse } from "../features/host/service";