Skip to content
Closed
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
28 changes: 22 additions & 6 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,30 @@ permission. **Settings > External apps** shows the connection and **Revoke acces
Keys stay in Buzz. Closing Settings keeps access active.

Version 1 links contain `version=1`, the requesting `client` display name,
`callback`, random `state`, `scope` (a space-separated selection of `read`, `post`,
and `create`), `code_challenge`,
`callback`, random `state`, `scope` (`agent` alone, or a space-separated selection
of legacy `read`, `post`, and `create`), `code_challenge`,
and `code_challenge_method=S256`. The callback must be exactly
`http://127.0.0.1:<port>/buzz/authorize`, with a port from 1024 through 65535.
State and challenge each encode 32 bytes as unpadded base64url. The client name
is supplied by the requesting app; Buzz does not attest its identity.
Requests expire after five minutes. Duplicate or unknown parameters are rejected.

Clients such as Nexus request `scope=agent` for full access to the selected
agent's Buzz tools, including tools from enabled plugins. This scope must appear
alone. Buzz shows the requested full access before the owner approves it, and uses
the ordinary Buzz tool dispatcher, channel access checks and tool confirmations.
Runtime harness hooks do not run through this connection. Buzz’s built-in file
and memory reads remain unavailable, as on legacy connections. Enabled plugin
tools retain their approved host permissions, including any local file or process
access; they can use their own host capabilities without the external client's
file and memory callbacks. Repository tools can return
short-lived Git credentials for repositories the selected agent can access;
private identity keys remain in Buzz. The owner sees these limits in consent.
Its catalog reports `["agent"]` in `buzz/grantedScopes`, `buzz/supportedScopes`
and each tool's `buzz/requiredScopes`. Legacy read/post/create approvals keep
their limited catalog and behavior below; they never gain full access implicitly.
Version 2 approval and reconnection preserve the exact approved scopes.

After consent, Buzz sends a JSON POST to the callback with `version: 1`, `state`,
and `outcome: "authorized"`, plus `code` and `exchange_url`. Denial sends
`outcome: "denied"` with no code. A code lasts 60 seconds. The client sends a
Expand All @@ -103,7 +119,7 @@ identity change, or the end of the owning agent runtime, plugin, or app also
stops it. A version 1 client needs new setup after its session ends. Version 2
can use saved approval to create another session.

The read scope allows `channel_list`, `channel_get`, and `read`. The post scope
For legacy limited grants, the read scope allows `channel_list`, `channel_get`, and `read`. The post scope
allows `send` and `reply_in_subthread`. The create scope allows only
`channel_create({name})`: a private Solo with the authenticated owner, connected
agent and the owner's account-saved Bestie. Buzz derives these identities and
Expand All @@ -118,7 +134,7 @@ Buzz classifies each built-in tool through
its trusted external-access policy. Unclassified tools cannot run. Runtime plugin
tools cannot enroll in this policy.

`tools/list` returns all classified tools in one page. It rejects a supplied
For legacy grants, `tools/list` returns all classified tools in one page. It rejects a supplied
`cursor`. Each tool has `_meta["buzz/requiredScopes"]`. The list result has
`_meta["buzz/grantedScopes"]` and `_meta["buzz/supportedScopes"]`; the supported
set is `read`, `post`, and `create`. One authorization can request all three.
Expand All @@ -134,12 +150,12 @@ it already has. For example, an app with `read` that needs `post` requests
The app must not retry the call automatically after approval. New classified tools
use this same contract.

External clients must accept `create` in `buzz/grantedScopes`,
Legacy external clients must accept `create` in `buzz/grantedScopes`,
`buzz/supportedScopes` and per-tool `buzz/requiredScopes` before this catalog is
advertised. Older Nexus clients that accept only read/post reject an unknown
supported scope even when their grant still contains only read/post.

The shared schemas apply with explicit
For legacy grants, the shared schemas apply with explicit
targets: `read` needs `channel`, `thread`, or `message_url`. A `message_url` uses
the linked message's channel and thread; it cannot combine with `channel` or
`thread`. Searches with `q` need `channel` or `message_url`;
Expand Down
12 changes: 10 additions & 2 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,19 +142,27 @@ Desktop preview releases enable bb-block only. Other communities cannot grant ac

Start setup in the external app. Buzz desktop shows an **Authorize app**
dialog. Select a community and an agent, check the requested access, then select
**Authorize**. The app completes setup automatically. Keys stay in Buzz.
**Authorize**. The app completes setup automatically. Private identity keys stay in Buzz.
Only authorize an app when you started its setup. The displayed name comes from
that app. Protected agents cannot use this connection. Keep Buzz open.
An unavailable agent cannot authorize access. Enable its runtime plugin or use
the Buzz copy that runs it.

Nexus requests full access to the selected agent's Buzz tools, including tools
from enabled plugins. Buzz’s channel access checks and tool confirmations still
apply. Repository tools can provide short-lived Git credentials for repositories
the agent can access. Buzz’s built-in file and memory reads remain unavailable.
Enabled plugin tools keep their approved access, which may include local files
and running commands. Check this in the approval dialog. Older limited approvals do not gain full access without
a new approval.

Use **Settings → External apps → Revoke access** to stop it. Changing community
or identity also ends access. If a connection ends, start setup in the external
app again. Never put connection credentials in a chat or file.
An app can discover tools that need more access. Start its access request and
approve the requested scopes in Buzz. After approval, make the tool call again.
Each approval replaces prior access. Request all access the app still needs.
Creating Solo chats needs create access. An app can request it with read and
With a limited legacy grant, creating Solo chats needs create access. An app can request it with read and
post in one approval; existing read/post access never gains it without your
approval. Buzz includes you, the selected agent and your saved Bestie
automatically. Choose and save an available Bestie in this community first; the
Expand Down
28 changes: 16 additions & 12 deletions src-tauri/src/host_mcp/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,19 @@ pub(crate) struct Approval {

impl Approval {
fn valid_scopes(&self) -> bool {
!self.scopes.is_empty()
&& self.scopes.len() <= 3
&& self
.scopes
.iter()
.all(|scope| scope == "read" || scope == "post" || scope == "create")
&& self
.scopes
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
== self.scopes.len()
self.scopes == ["agent"]
|| (!self.scopes.is_empty()
&& self.scopes.len() <= 3
&& self
.scopes
.iter()
.all(|scope| scope == "read" || scope == "post" || scope == "create")
&& self
.scopes
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
== self.scopes.len())
}
}

Expand Down Expand Up @@ -426,6 +427,7 @@ mod tests {
fn approval_scopes_accept_create_without_widening_access() {
let mut approval = grant().approval;
for scopes in [
vec!["agent"],
vec!["read"],
vec!["post"],
vec!["read", "post"],
Expand All @@ -438,6 +440,8 @@ mod tests {
assert!(approval.valid_scopes(), "{scopes:?}");
}
for scopes in [
vec!["agent", "read"],
vec!["agent", "agent"],
vec![],
vec!["manage"],
vec!["create", "manage"],
Expand Down
79 changes: 79 additions & 0 deletions src/buzz-mcp/external.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,85 @@ import { expect, it, vi } from "vitest";
import type { BuzzClient, BuzzEvent, Filter, Template } from "./client";
import type { ExternalScope } from "../features/agents/external-connection";
import { respondExternal } from "./external";
import { respond } from "./rpc";
import type { AddedTool } from "./session-tools";

it("gives an explicitly approved agent grant the ordinary agent catalog and plugin dispatcher", async () => {
const { client } = fixture();
const call = vi.fn(async (agent: BuzzClient) => `called as ${agent.pubkey}`);
const added: AddedTool[] = [
{
name: "example_echo",
description: "Test plugin tool",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
call,
},
];
const request = { id: 1, method: "tools/list" };
const ordinary = await respond(client, {}, request, added);
const full = await respondExternal(
client,
request,
() => {},
["agent"],
undefined,
added,
);
const ordinaryTools = (ordinary as { result: { tools: object[] } }).result
.tools;
expect(full).toHaveProperty(
"result.tools",
ordinaryTools.map((tool) => ({
...tool,
_meta: { "buzz/requiredScopes": ["agent"] },
})),
);
expect(full).toHaveProperty("result._meta", {
"buzz/grantedScopes": ["agent"],
"buzz/supportedScopes": ["agent"],
});
const invocation = {
id: 2,
method: "tools/call",
params: { name: "example_echo", arguments: {} },
};
expect(
await respondExternal(
client,
invocation,
() => {},
["read", "post", "create"],
undefined,
added,
),
).toHaveProperty("result.isError", true);
expect(call).not.toHaveBeenCalled();
expect(
await respondExternal(
client,
invocation,
() => {},
["agent"],
undefined,
added,
),
).toHaveProperty("result.content.0.text", `called as ${client.pubkey}`);
expect(call).toHaveBeenCalledOnce();
expect(
await respondExternal(
client,
{ ...invocation, params: { name: "missing_tool" } },
() => {},
["agent"],
undefined,
added,
),
).toHaveProperty("result.isError", true);
});

function fixture(events: BuzzEvent[] = []) {
const query = vi.fn(async (_filters: readonly Filter[]) => events);
Expand Down
38 changes: 31 additions & 7 deletions src/buzz-mcp/external.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BuzzClient } from "./client";
import { respond } from "./rpc";
import { TOOLS } from "./tools";
import type { AddedTool } from "./session-tools";
import type { ExternalScope } from "../features/agents/external-connection";

type Policy = Readonly<{
Expand Down Expand Up @@ -102,13 +103,15 @@ function validate(
for (const item of value) validate(item, schema.items, name);
}

/** The external grant has no implicit conversation and cannot call plugin tools. */
/** External grants have no implicit conversation. Only full agent access uses
* the ordinary agent dispatcher, including tools from enabled plugins. */
export async function respondExternal(
client: BuzzClient,
input: unknown,
connected: () => void,
scopes: readonly ExternalScope[] = ["read", "post"],
defaultWorker?: () => Promise<string>,
added: readonly AddedTool[] = [],
) {
if (!input || typeof input !== "object" || Array.isArray(input))
throw new Error("Expected one JSON-RPC message.");
Expand All @@ -131,13 +134,34 @@ export async function respondExternal(
};
connected();
}
if (method === "tools/list" && params.cursor !== undefined)
return {
jsonrpc: "2.0",
id,
error: { code: -32602, message: "Tool cursors are not supported." },
};
if (scopes.length === 1 && scopes[0] === "agent") {
const reply = await respond(client, {}, input, added, {
instructions:
"Act as the agent selected by the owner in Buzz. Buzz enforces channel access and tool confirmations. Supply explicit destinations. Buzz’s built-in file and memory reads are unavailable; plugin tools retain their approved host permissions, which may include local files and commands. Private identity keys stay in Buzz; repository tools may return short-lived Git credentials. Never save those credentials. Never repeat a write after a lost response; read its destination first.",
tool: ({ load: _, ...tool }) => ({
...tool,
_meta: { "buzz/requiredScopes": ["agent"] },
}),
});
return method === "tools/list"
? result({
...("result" in reply && typeof reply.result === "object"
? reply.result
: {}),
_meta: {
"buzz/grantedScopes": ["agent"],
"buzz/supportedScopes": ["agent"],
},
})
: reply;
}
if (method === "tools/list") {
if (params.cursor !== undefined)
return {
jsonrpc: "2.0",
id,
error: { code: -32602, message: "Tool cursors are not supported." },
};
return result({
tools,
_meta: {
Expand Down
23 changes: 21 additions & 2 deletions src/features/agents/ExternalAuthorizationDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,24 @@ afterEach(() => {
it.each([
["Authorize", true],
["Create", true],
["Agent", true],
["Deny", true],
["unavailable runtime", false],
["custody lost", true],
["disabled community", true],
] as const)(
"handles %s without an unintended authorization",
async (requestedAction, runtimeReady) => {
const action = requestedAction === "Create" ? "Authorize" : requestedAction;
const action =
requestedAction === "Create" || requestedAction === "Agent"
? "Authorize"
: requestedAction;
const scopes =
requestedAction === "Create"
? (["read", "post", "create"] as const)
: (["read", "post"] as const);
: requestedAction === "Agent"
? (["agent"] as const)
: (["read", "post"] as const);
const user = userEvent.setup();
const key = "a".repeat(64),
secondKey = "c".repeat(64),
Expand Down Expand Up @@ -132,6 +138,19 @@ it.each([
expect(connect).not.toHaveBeenCalled();
expect(deny).not.toHaveBeenCalled();
const access = screen.getByText(/Requested access:/);
if (requestedAction === "Agent") {
expect(access).toHaveTextContent(
"full access to the selected agent’s Buzz tools",
);
expect(access).toHaveTextContent("tool confirmations still apply");
expect(access).toHaveTextContent("short-lived Git credentials");
expect(access).toHaveTextContent(
"Buzz’s built-in file and memory reads remain unavailable",
);
expect(access).toHaveTextContent(
"Enabled plugin tools keep their approved access",
);
}
if (requestedAction === "Create")
expect(access).toHaveTextContent(
"create private Solo chats with you, the selected agent and your saved Bestie",
Expand Down
21 changes: 11 additions & 10 deletions src/features/agents/ExternalAuthorizationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,17 @@ function ConsentDialog({
/>
<p className="m-0 text-body-sm text-secondary">
Requested access:{" "}
{request.scope
.map((scope) =>
scope === "read"
? "read channels, messages and threads"
: scope === "post"
? "post text as the selected agent"
: "create private Solo chats with you, the selected agent and your saved Bestie",
)
.join("; ")}
. Keys stay in Buzz.
{request.scope.includes("agent")
? "full access to the selected agent’s Buzz tools, including tools from enabled plugins. Buzz’s channel access checks and tool confirmations still apply. Repository tools can provide short-lived Git credentials for repositories the agent can access. Buzz’s built-in file and memory reads remain unavailable. Enabled plugin tools keep their approved access, which may include local files and running commands. Private identity keys stay in Buzz."
: `${request.scope
.map((scope) =>
scope === "read"
? "read channels, messages and threads"
: scope === "post"
? "post text as the selected agent"
: "create private Solo chats with you, the selected agent and your saved Bestie",
)
.join("; ")}. Keys stay in Buzz.`}
{request.version === 2 &&
" The app can renew valid approval for another 30 days. Approval expires after 30 days without renewal. The app can restore this access after either app restarts. You can revoke it in External apps."}
</p>
Expand Down
Loading
Loading