From af4fcffb05da04b95f309eb437e0dc58a83945d9 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 10:28:14 -0400 Subject: [PATCH 1/9] feat(agents): expose live plugin tool hooks to Claude and Codex Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/bundled/agents/AgentsPage.test.tsx | 2 + src/bundled/claude-code/claude.test.ts | 57 ++++++++++ src/bundled/claude-code/claude.ts | 44 +++++++- src/bundled/claude-code/index.ts | 1 + src/bundled/claude-code/runtime.ts | 56 ++++++++- src/bundled/codex/hooks.test.ts | 61 ++++++++++ src/bundled/codex/hooks.ts | 150 +++++++++++++++++++++++++ src/bundled/codex/index.ts | 6 + src/bundled/codex/manifest.json | 1 + src/bundled/codex/runtime.ts | 122 ++++++++++++++++++-- src/features/agents/service.ts | 14 ++- src/features/agents/tool-hooks.test.ts | 78 +++++++++++++ src/features/agents/tool-hooks.ts | 77 +++++++++++++ src/plugins/author.ts | 6 + 14 files changed, 656 insertions(+), 19 deletions(-) create mode 100644 src/bundled/codex/hooks.test.ts create mode 100644 src/bundled/codex/hooks.ts create mode 100644 src/features/agents/tool-hooks.test.ts create mode 100644 src/features/agents/tool-hooks.ts diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index b88a1d82f6..ad99c1cc34 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 7e0106a7d1..b3e3e82216 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 6592f72c74..32e74e1bc8 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 10cfb251af..0ad11b7c5c 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.ts b/src/bundled/claude-code/runtime.ts index 402945db27..6c6c87b3b8 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -5,6 +5,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 +128,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 +194,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 +469,7 @@ 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 +544,11 @@ export class ClaudeRuntime { ? { channel } : { channel, root: threadReference(event)?.rootId ?? event.id }, ); + entry.hookMessages.set(key, [...(entry.hookMessages.get(key) ?? []).slice(-199), { + id: event.id, channel, + ...(topLevel ? { topLevel } : dm ? {} : { root: threadReference(event)?.rootId ?? event.id }), + deliveredAt: Date.now(), + }]); delivered = entry.sessions.deliver(key, text, event.created_at, { ...(wake ? { @@ -731,9 +744,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 +858,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 +905,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 +913,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 0000000000..86859d2ee7 --- /dev/null +++ b/src/bundled/codex/hooks.test.ts @@ -0,0 +1,61 @@ +import { expect, it, vi } from "vitest"; +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 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", + }); + 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" }, + }); + 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"); +}); diff --git a/src/bundled/codex/hooks.ts b/src/bundled/codex/hooks.ts new file mode 100644 index 0000000000..e412919f88 --- /dev/null +++ b/src/bundled/codex/hooks.ts @@ -0,0 +1,150 @@ +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 server: HostMcpServer | undefined; + private closed = false; + constructor( + private readonly serve: NonNullable, + private readonly before: ( + input: Record, + ) => Promise, + ) {} + async open() { + 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.server = server; + 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.server) throw new Error("The Codex hook bridge is unavailable"); + 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", + ); + return { + server: { + url: this.server.url, + http_headers: Object.fromEntries( + this.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 server = this.server; + this.server = undefined; + await server?.close(); + } +} +export const HOOK_SERVER = SERVER; diff --git a/src/bundled/codex/index.ts b/src/bundled/codex/index.ts index 39c174dde0..3562a4de96 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 f9ba465ea7..e9dad7c623 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.ts b/src/bundled/codex/runtime.ts index d72302289e..e9d304e4e5 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -1,3 +1,9 @@ +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 +75,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 +239,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 +260,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); @@ -499,26 +511,64 @@ 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 active = [...this.entries.values()] + .flatMap((entry) => [...entry.lanes.values()]) + .map((lane) => lane.active) + .find((turn) => turn?.threadId === input.session_id); + if (!active) return undefined; // Standalone turns have no mentions to acknowledge. + await active.ready; + if (active.turnId !== input.turn_id || active.finished) + return "The Codex turn has ended"; + return this.hooks?.run( + this.hookEvent( + active, + 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 +859,7 @@ export class CodexRuntime { request.answered(); return; } + request.deliveredAt = Date.now(); await active.rpc.request("turn/steer", { threadId: active.threadId, expectedTurnId: active.turnId, @@ -1216,13 +1267,27 @@ export class CodexRuntime { : {}), } : {}; - const reply = await callSessionTool( - client, - context, - call.tool, - args, - this.added(), - ); + const event = + active && + this.hookEvent(active, call.callId, `mcp__buzz__${call.tool}`, args); + const denied = event && (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) { + if (event) + await this.hooks?.run({ ...event, phase: "after", success: false }); + throw error; + } + if (event) + 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 +1308,37 @@ export class CodexRuntime { ], }; } + private hookEvent( + active: Active, + callId: string, + tool: string, + input: Record, + ): AgentToolHookEvent { + const request = active.reply; + return { + phase: "before", + agent: request.agent.pubkey, + session: active.threadId ?? "", + callId, + tool, + input, + context: { + channel: request.conversation.channelId, + ...(request.toolRoot ? { root: request.toolRoot } : {}), + ...(request.conversation.topLevel ? { topLevel: true } : {}), + }, + messages: active.requests.map((request) => ({ + 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( @@ -1422,6 +1518,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 +1529,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, @@ -1568,6 +1669,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( diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index c878256f4e..cfcbdba269 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 0000000000..990aac04ed --- /dev/null +++ b/src/features/agents/tool-hooks.test.ts @@ -0,0 +1,78 @@ +import { Context } from "@deepseek-ai/cordis"; +import { expect, it, vi } from "vitest"; +import { + createAgentToolHooks, + 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(); +}); diff --git a/src/features/agents/tool-hooks.ts b/src/features/agents/tool-hooks.ts new file mode 100644 index 0000000000..a73846604c --- /dev/null +++ b/src/features/agents/tool-hooks.ts @@ -0,0 +1,77 @@ +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; +}>; +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 }); + } + clear() { + this.calls.clear(); + } +} diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 97fce53814..862ad7ed4e 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"; From 7925aa6c15e0e33521273aa53cba9d6971dec28f Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 10:42:07 -0400 Subject: [PATCH 2/9] fix(agents): keep plugin wakes outside mention hook obligations Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- docs/agent-tool-hooks.md | 13 +++++++ src/bundled/claude-code/runtime.test.ts | 51 +++++++++++++++++++++++++ src/bundled/claude-code/runtime.ts | 27 ++++++++++--- src/bundled/codex/runtime.ts | 22 ++++++----- src/features/agents/tool-hooks.ts | 3 -- 5 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 docs/agent-tool-hooks.md diff --git a/docs/agent-tool-hooks.md b/docs/agent-tool-hooks.md new file mode 100644 index 0000000000..3168404115 --- /dev/null +++ b/docs/agent-tool-hooks.md @@ -0,0 +1,13 @@ +# 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. 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. + +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 only releases pending mentions after a successful public send in their conversation. diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 9d2764de98..90637ddf7f 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]) => { @@ -2328,3 +2330,52 @@ it("refreshes Attention instructions after Claude starts", async () => { f.runtime.dispose(); } }); + +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]?.context).toEqual({ channel, root: root.id }); + f.runtime.dispose(); +}); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 6c6c87b3b8..9118e67419 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -469,7 +469,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, dm } = 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}`, @@ -544,11 +550,20 @@ export class ClaudeRuntime { ? { channel } : { channel, root: threadReference(event)?.rootId ?? event.id }, ); - entry.hookMessages.set(key, [...(entry.hookMessages.get(key) ?? []).slice(-199), { - id: event.id, channel, - ...(topLevel ? { topLevel } : dm ? {} : { root: threadReference(event)?.rootId ?? event.id }), - deliveredAt: Date.now(), - }]); + if (trigger.type === "mention") + entry.hookMessages.set(key, [ + ...(entry.hookMessages.get(key) ?? []).slice(-199), + { + id: event.id, + channel, + ...(topLevel + ? { topLevel } + : dm + ? {} + : { root: threadReference(event)?.rootId ?? event.id }), + deliveredAt: Date.now(), + }, + ]); delivered = entry.sessions.deliver(key, text, event.created_at, { ...(wake ? { diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index e9d304e4e5..644bb551a5 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -1327,16 +1327,18 @@ export class CodexRuntime { ...(request.toolRoot ? { root: request.toolRoot } : {}), ...(request.conversation.topLevel ? { topLevel: true } : {}), }, - messages: active.requests.map((request) => ({ - id: request.event.id, - channel: request.conversation.channelId, - ...(request.conversation.topLevel - ? { topLevel: true } - : request.toolRoot - ? { root: request.toolRoot } - : {}), - deliveredAt: request.deliveredAt ?? 0, - })), + messages: active.requests + .filter((request) => !request.wake) + .map((request) => ({ + 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 diff --git a/src/features/agents/tool-hooks.ts b/src/features/agents/tool-hooks.ts index a73846604c..9cc3261668 100644 --- a/src/features/agents/tool-hooks.ts +++ b/src/features/agents/tool-hooks.ts @@ -71,7 +71,4 @@ export class ToolHookCallbacks { this.calls.delete(callId); if (event) await this.run({ ...event, phase: "after", success }); } - clear() { - this.calls.clear(); - } } From 6ac744d34cfc4d5eb49e2fa2c01f6412db761c1b Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 11:19:10 -0400 Subject: [PATCH 3/9] fix(codex): isolate hook transports for each thread Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- docs/agent-tool-hooks.md | 2 + src/bundled/codex/hooks.test.ts | 118 ++++++++++++++++++++++++++---- src/bundled/codex/hooks.ts | 96 ++++++++++++------------ src/bundled/codex/runtime.test.ts | 95 +++++++++++++++++++++++- src/bundled/codex/runtime.ts | 38 +++++++--- 5 files changed, 274 insertions(+), 75 deletions(-) diff --git a/docs/agent-tool-hooks.md b/docs/agent-tool-hooks.md index 3168404115..8e619f2e2b 100644 --- a/docs/agent-tool-hooks.md +++ b/docs/agent-tool-hooks.md @@ -6,6 +6,8 @@ External plugins inject `agents` and register a live policy with `ctx.agents.reg 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. diff --git a/src/bundled/codex/hooks.test.ts b/src/bundled/codex/hooks.test.ts index 86859d2ee7..79d7048d95 100644 --- a/src/bundled/codex/hooks.test.ts +++ b/src/bundled/codex/hooks.test.ts @@ -18,19 +18,6 @@ it("exposes evaluation but no forged completion, and trusts only its own current "-c", expect.stringContaining("hooks.PreToolUse="), ]); - 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", - }); const own = { key: "own", currentHash: "hash", @@ -46,6 +33,19 @@ it("exposes evaluation but no forged completion, and trusts only its own current 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", @@ -59,3 +59,95 @@ it("normalizes native dynamic-tool names for plugin policies", () => { 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: { + url: string; + headers: never[]; + close: ReturnType; + closed: Promise; + }) => 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 index e412919f88..72e649b2bf 100644 --- a/src/bundled/codex/hooks.ts +++ b/src/bundled/codex/hooks.ts @@ -20,7 +20,7 @@ const toml = (value: unknown): string => { /** 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 server: HostMcpServer | undefined; + private readonly servers = new Set(); private closed = false; constructor( private readonly serve: NonNullable, @@ -29,6 +29,45 @@ export class CodexHooks { ) => 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; @@ -83,51 +122,16 @@ export class CodexHooks { await server.close(); throw new Error("The Codex hook bridge was closed"); } - this.server = server; - 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.server) throw new Error("The Codex hook bridge is unavailable"); - 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", - ); + this.servers.add(server); + const close = async () => { + if (this.servers.delete(server)) await server.close(); + }; return { + close, server: { - url: this.server.url, + url: server.url, http_headers: Object.fromEntries( - this.server.headers.map(({ name, value }) => [name, value]), + server.headers.map(({ name, value }) => [name, value]), ), enabled: true, required: true, @@ -142,9 +146,9 @@ export class CodexHooks { } async close() { this.closed = true; - const server = this.server; - this.server = undefined; - await server?.close(); + 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/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 8adcff86bf..44dc1452b8 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -11,7 +11,7 @@ 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 { 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 +53,10 @@ function fixture( model_reasoning_summary?: string; } = {}, repositories?: (scope: string | undefined) => string, + hooks?: { + serve: NonNullable; + run: () => Promise; + }, ) { let terminals = remainingTerminals; let options: HostProcessOptions | undefined; @@ -138,6 +142,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 +339,7 @@ function fixture( config, () => added, repositories, + hooks, ); runtimes.push(runtime); const events: EventData[] = []; @@ -3524,7 +3546,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 +3556,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 +3730,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 +3741,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 +3780,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 +4053,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 +4085,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 +4322,61 @@ 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 servers: { url: string; close: ReturnType }[] = []; + const f = fixture([], undefined, false, {}, undefined, { + serve: 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; + }, + run: async () => undefined, + }); + return { f, servers }; +} diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 644bb551a5..c75e1fc06e 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -334,15 +334,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, @@ -363,6 +363,7 @@ export class CodexRuntime { await rpc .request("thread/unsubscribe", { threadId: saved.threadId }) .catch(() => undefined); + await closeHooks?.(); } }); } @@ -964,19 +965,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); @@ -1042,6 +1040,7 @@ export class CodexRuntime { .request("thread/unsubscribe", { threadId }) .catch(() => undefined); } + await closeHooks?.(); entry.onces.delete(once); release(); } @@ -1441,7 +1440,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", { @@ -1450,7 +1451,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; @@ -1470,6 +1479,7 @@ export class CodexRuntime { await rpc .request("thread/unsubscribe", { threadId: turn.threadId }) .catch(() => undefined); + await hookConfig?.close(); } } @@ -1544,7 +1554,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, @@ -1575,6 +1585,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; @@ -1600,13 +1611,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 @@ -1775,6 +1788,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; } } From d5b7ab6f0080b1fd8bd435a2ed3605e7518b0c0c Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 11:23:09 -0400 Subject: [PATCH 4/9] test(codex): type deferred hook transport with host contract Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/bundled/codex/hooks.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/bundled/codex/hooks.test.ts b/src/bundled/codex/hooks.test.ts index 79d7048d95..22d8b6b4ee 100644 --- a/src/bundled/codex/hooks.test.ts +++ b/src/bundled/codex/hooks.test.ts @@ -1,4 +1,5 @@ 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 () => { @@ -107,12 +108,7 @@ it("gives concurrent and resumed threads separate transports and releases each i }); it("closes a transport whose allocation finishes after process shutdown", async () => { - let resolve!: (server: { - url: string; - headers: never[]; - close: ReturnType; - closed: Promise; - }) => void; + let resolve!: (server: HostMcpServer) => void; const serve = vi.fn( () => new Promise[0]>((done) => { From bbaa38311b785f61ba9b445f00828fe36a7669f1 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 11:30:52 -0400 Subject: [PATCH 5/9] fix(codex): apply tool policies to standalone attention turns Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/bundled/codex/runtime.test.ts | 85 +++++++++++++++++++++++++++++-- src/bundled/codex/runtime.ts | 60 +++++++++++++--------- 2 files changed, 118 insertions(+), 27 deletions(-) diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 44dc1452b8..4c4b3ff250 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -11,6 +11,7 @@ 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 { 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"; @@ -55,7 +56,7 @@ function fixture( repositories?: (scope: string | undefined) => string, hooks?: { serve: NonNullable; - run: () => Promise; + run: AgentToolHooks["toolHook"]; }, ) { let terminals = remainingTerminals; @@ -4364,9 +4365,12 @@ it("isolates hook transports for overlapping conversations and a resumed turn", }); 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 () => { + serve: async (handler) => { + handlers.push(handler); const server = { url: `http://127.0.0.1:${1234 + servers.length}/mcp`, headers: [], @@ -4376,7 +4380,80 @@ function hookFixture() { servers.push(server); return server; }, - run: async () => undefined, + run, }); - return { f, servers }; + 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]; +}); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index c75e1fc06e..0d52c2fc46 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -522,17 +522,27 @@ export class CodexRuntime { const bridge = this.hooks && new CodexHooks(this.hooks.serve, async (input) => { - const active = [...this.entries.values()] + const entries = [...this.entries.values()]; + const active = entries .flatMap((entry) => [...entry.lanes.values()]) .map((lane) => lane.active) .find((turn) => turn?.threadId === input.session_id); - if (!active) return undefined; // Standalone turns have no mentions to acknowledge. - await active.ready; - if (active.turnId !== input.turn_id || active.finished) + 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( - active, + turn, String(input.tool_use_id), hookToolName(String(input.tool_name)), (input.tool_input ?? {}) as Record, @@ -1266,10 +1276,13 @@ export class CodexRuntime { : {}), } : {}; - const event = - active && - this.hookEvent(active, call.callId, `mcp__buzz__${call.tool}`, args); - const denied = event && (await this.hooks?.run(event)); + const event = this.hookEvent( + turn, + call.callId, + `mcp__buzz__${call.tool}`, + args, + ); + const denied = await this.hooks?.run(event); if (denied) throw new Error(denied); let reply: Awaited>; try { @@ -1281,12 +1294,10 @@ export class CodexRuntime { this.added(), ); } catch (error) { - if (event) - await this.hooks?.run({ ...event, phase: "after", success: false }); + await this.hooks?.run({ ...event, phase: "after", success: false }); throw error; } - if (event) - await this.hooks?.run({ ...event, phase: "after", success: true }); + 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(); @@ -1308,25 +1319,28 @@ export class CodexRuntime { }; } private hookEvent( - active: Active, + turn: Active | Once, callId: string, tool: string, input: Record, ): AgentToolHookEvent { - const request = active.reply; + const active = "reply" in turn ? turn : undefined; + const request = active?.reply; return { phase: "before", - agent: request.agent.pubkey, - session: active.threadId ?? "", + agent: (request ?? (turn as Once)).agent.pubkey, + session: turn.threadId ?? "", callId, tool, input, - context: { - channel: request.conversation.channelId, - ...(request.toolRoot ? { root: request.toolRoot } : {}), - ...(request.conversation.topLevel ? { topLevel: true } : {}), - }, - messages: active.requests + 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) => ({ id: request.event.id, From 75e8d1f267f2fffc08f600115d58ca70c1530ab9 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 15:44:54 -0400 Subject: [PATCH 6/9] Expose sender and audience facts to agent tool hooks Co-authored-by: John Tennant Signed-off-by: John Tennant --- docs/agent-tool-hooks.md | 4 ++-- src/bundled/claude-code/runtime.test.ts | 4 ++++ src/bundled/claude-code/runtime.ts | 5 +++++ src/bundled/codex/runtime.test.ts | 27 ++++++++++++++++++++++++ src/bundled/codex/runtime.ts | 5 +++++ src/features/agents/tool-hooks.test.ts | 28 +++++++++++++++++++++++++ src/features/agents/tool-hooks.ts | 17 +++++++++++++++ 7 files changed, 88 insertions(+), 2 deletions(-) diff --git a/docs/agent-tool-hooks.md b/docs/agent-tool-hooks.md index 8e619f2e2b..7464eb3408 100644 --- a/docs/agent-tool-hooks.md +++ b/docs/agent-tool-hooks.md @@ -2,7 +2,7 @@ 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. Plugin wakes and standalone attention turns are not mentions. +`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. @@ -12,4 +12,4 @@ Codex's process-local bridge definition is checked with `hooks/list`. Only that 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 only releases pending mentions after a successful public send in their conversation. +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/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 90637ddf7f..14afc03b65 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -2376,6 +2376,10 @@ it("passes real mention snapshots to tool hooks without treating plugin wakes as 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(); }); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 9118e67419..956e6a037c 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 @@ -554,6 +555,10 @@ export class ClaudeRuntime { entry.hookMessages.set(key, [ ...(entry.hookMessages.get(key) ?? []).slice(-199), { + ...hookMessageSource( + event, + this.reads(agent.pubkey)?.profile(event.pubkey), + ), id: event.id, channel, ...(topLevel diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index 4c4b3ff250..c5a2e23727 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -4457,3 +4457,30 @@ it("enforces native and Buzz tool policies for standalone turns without mention 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 0d52c2fc46..f6439cf0c0 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -1,3 +1,4 @@ +import { hookMessageSource } from "../../features/agents/tool-hooks"; import type { Host } from "../../features/host/service"; import type { AgentToolHookEvent, @@ -1343,6 +1344,10 @@ export class CodexRuntime { 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 diff --git a/src/features/agents/tool-hooks.test.ts b/src/features/agents/tool-hooks.test.ts index 990aac04ed..8a574461c6 100644 --- a/src/features/agents/tool-hooks.test.ts +++ b/src/features/agents/tool-hooks.test.ts @@ -2,6 +2,7 @@ import { Context } from "@deepseek-ai/cordis"; import { expect, it, vi } from "vitest"; import { createAgentToolHooks, + hookMessageSource, ToolHookCallbacks, type AgentToolHookEvent, } from "./tool-hooks"; @@ -76,3 +77,30 @@ it("does not record a denied call as an executable tool", async () => { 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 index 9cc3261668..6d313aa1f3 100644 --- a/src/features/agents/tool-hooks.ts +++ b/src/features/agents/tool-hooks.ts @@ -1,3 +1,6 @@ +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"; @@ -9,7 +12,21 @@ export type AgentHookMessage = Readonly<{ 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; From e6655ca9a84b42f53aa2bd03c0cd08cfe0330ac0 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 23 Oct 2026 15:45:48 -0400 Subject: [PATCH 7/9] Record agent authorship for sender metadata fix Zack authored the implementation and tests in 1b75369a7183c168ffad260e5a0b8cf696980323. This adds the omitted agent credit without amending that commit. Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant From e309a264d17d79c4cf5f4c527841cc080e0bd9c9 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 24 Oct 2026 22:15:17 -0400 Subject: [PATCH 8/9] fix(claude): drop undelivered mentions from tool hook snapshots Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/bundled/claude-code/runtime.test.ts | 54 +++++++++++++++++++++++++ src/bundled/claude-code/runtime.ts | 44 ++++++++++++-------- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 14afc03b65..2bd857952c 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -2383,3 +2383,57 @@ it("passes real mention snapshots to tool hooks without treating plugin wakes as 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(); + } +}); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 956e6a037c..f4b1b3c85f 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -551,23 +551,27 @@ export class ClaudeRuntime { ? { channel } : { channel, root: threadReference(event)?.rootId ?? event.id }, ); - if (trigger.type === "mention") + 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), - { - ...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(), - }, + hookMessage, ]); delivered = entry.sessions.deliver(key, text, event.created_at, { ...(wake @@ -600,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, ); From 87c7c32e1996040fb33876fb746e203fd3d0b636 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Sat, 24 Oct 2026 22:17:17 -0400 Subject: [PATCH 9/9] test(claude): keep hook regressions clear of Attention additions Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Signed-off-by: John Tennant --- src/bundled/claude-code/runtime.test.ts | 66 ++++++++++++------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index 2bd857952c..d968080235 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -2298,39 +2298,6 @@ it("delivers a rootless subscription to its channel without publishing its ancho expect(JSON.stringify(f.published)).not.toContain(WAKE_ID); }); -it("refreshes Attention instructions after Claude starts", async () => { - let release!: () => void; - const starting = new Promise((resolve) => { - release = resolve; - }); - const f = setup([], { hold: true, starting }); - let instructions = "Old Interest instructions"; - const wakeText = vi.fn(async () => instructions); - try { - await f.deliver( - { - ...pluginWake(), - type: "attention", - topLevel: true, - text: instructions, - }, - { wakeText }, - ); - await vi.waitFor(() => expect(wakeText).toHaveBeenCalled()); - instructions = "Updated Interest instructions"; - release(); - await vi.waitFor(() => - expect(f.claudes().some((p) => p.prompts.length)).toBe(true), - ); - const prompt = f.claudes().find((p) => p.prompts.length)?.prompts[0]; - expect(prompt).toContain(instructions); - expect(prompt).not.toContain("Old Interest instructions"); - } finally { - release(); - f.runtime.dispose(); - } -}); - it("passes real mention snapshots to tool hooks without treating plugin wakes as mentions", async () => { const seen: import("../../features/agents/tool-hooks").AgentToolHookEvent[] = []; @@ -2437,3 +2404,36 @@ it("omits undelivered mentions from the next Claude tool hook snapshot", async ( f.runtime.dispose(); } }); + +it("refreshes Attention instructions after Claude starts", async () => { + let release!: () => void; + const starting = new Promise((resolve) => { + release = resolve; + }); + const f = setup([], { hold: true, starting }); + let instructions = "Old Interest instructions"; + const wakeText = vi.fn(async () => instructions); + try { + await f.deliver( + { + ...pluginWake(), + type: "attention", + topLevel: true, + text: instructions, + }, + { wakeText }, + ); + await vi.waitFor(() => expect(wakeText).toHaveBeenCalled()); + instructions = "Updated Interest instructions"; + release(); + await vi.waitFor(() => + expect(f.claudes().some((p) => p.prompts.length)).toBe(true), + ); + const prompt = f.claudes().find((p) => p.prompts.length)?.prompts[0]; + expect(prompt).toContain(instructions); + expect(prompt).not.toContain("Old Interest instructions"); + } finally { + release(); + f.runtime.dispose(); + } +});