Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/agent-tool-hooks.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,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 = {
Expand Down
57 changes: 57 additions & 0 deletions src/bundled/claude-code/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,3 +620,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<string, unknown>)?.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();
});
44 changes: 40 additions & 4 deletions src/bundled/claude-code/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
) => Promise<unknown>;
}>;

/** Answers the in-process server's JSON-RPC for the conversation a process
Expand Down Expand Up @@ -146,6 +150,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,
) {
Expand Down Expand Up @@ -230,6 +235,7 @@ export class ClaudeProcess {
process,
launch.resume ?? launch.sessionId,
launch.tools,
launch.hook,
launch.model,
);
for (const data of earlyErrors) claude.readError(data);
Expand All @@ -239,16 +245,26 @@ 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<ClaudeLaunch, "systemPrompt" | "tools">) {
async initialize(
launch: Pick<ClaudeLaunch, "systemPrompt" | "tools" | "hook">,
) {
if (launch.tools) this.tools = launch.tools;
this.hook = launch.hook;
const { written, answer } = this.request({
subtype: "initialize",
appendSystemPrompt: launch.systemPrompt,
...(launch.tools
? {
sdkMcpServers: [TOOL_SERVER],
hooks: {
...(launch.hook
? {
PreToolUse: [{ hookCallbackIds: ["tools"] }],
PostToolUseFailure: [{ hookCallbackIds: ["tools"] }],
}
: {}),
PostToolUse: [
...(launch.hook ? [{ hookCallbackIds: ["tools"] }] : []),
{ matcher: FINAL_TOOL, hookCallbackIds: ["final"] },
],
},
Expand Down Expand Up @@ -596,9 +612,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,
Expand Down
1 change: 1 addition & 0 deletions src/bundled/claude-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
55 changes: 55 additions & 0 deletions src/bundled/claude-code/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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]) => {
Expand Down Expand Up @@ -2295,3 +2297,56 @@ it("delivers a rootless subscription to its channel without publishing its ancho
expect(event.tags.some((tag) => tag[0] === "e")).toBe(false);
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();
});
76 changes: 72 additions & 4 deletions src/bundled/claude-code/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
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
// timer's, runs as a one-off process. The run returns once the turn is handed over,
// 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";
Expand Down Expand Up @@ -123,6 +129,7 @@ type Entry = {
contexts: Map<string, Context>;
/** The project folders each Solo chat's latest turn named, for its process. */
directories: Map<string, readonly string[]>;
hookMessages: Map<string, readonly AgentHookMessage[]>;
/** Each conversation's latest posting policy: when its turns were handed
* over, and when all of them settled. */
policies: Map<
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -536,6 +551,24 @@ export class ClaudeRuntime {
? { channel }
: { channel, root: threadReference(event)?.rootId ?? event.id },
);
if (trigger.type === "mention")
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(),
},
]);
delivered = entry.sessions.deliver(key, text, event.created_at, {
...(wake ? { current } : {}),
steer: steerPrompt(text),
Expand Down Expand Up @@ -719,9 +752,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. */
Expand Down Expand Up @@ -829,6 +866,7 @@ export class ClaudeRuntime {
team: "",
sessions: undefined as unknown as AgentSessions,
contexts: new Map(),
hookMessages: new Map(),
policies: new Map(),
directories: new Map(),
};
Expand Down Expand Up @@ -877,13 +915,43 @@ 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;
return {
...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<string, unknown>,
context: entry.contexts.get(conversation) ?? {},
messages: entry.hookMessages.get(conversation) ?? [],
});
return reason
? {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: reason,
},
}
: {};
},
systemPrompt: systemPrompt({
scope,
cwd: workspace,
Expand Down
Loading
Loading