From b29ae331bbe8b39a17aa3365d8e722106d8d5efd Mon Sep 17 00:00:00 2001
From: Gil Birman
Date: Fri, 6 Nov 2026 12:24:24 -0600
Subject: [PATCH 1/4] fix(agents): accept Nexus full-agent authorization
Keep legacy grants limited and preserve existing file and memory read restrictions. Disclose short-lived Git access and cover consent, reconnect, plugin dispatch and revocation.
Co-authored-by: Codex
Signed-off-by: Gil Birman
---
docs/agents.md | 23 ++++--
docs/buzz-help.md | 11 ++-
src-tauri/src/host_mcp/pairing.rs | 28 ++++---
src/buzz-mcp/external.test.ts | 79 +++++++++++++++++++
src/buzz-mcp/external.ts | 28 ++++++-
.../ExternalAuthorizationDialog.test.tsx | 20 ++++-
.../agents/ExternalAuthorizationDialog.tsx | 17 ++--
.../agents/external-connection.test.ts | 72 +++++++++++------
src/features/agents/external-connection.ts | 9 ++-
src/features/agents/service.test.ts | 64 ++++++++++++++-
src/features/agents/service.ts | 1 +
src/features/host/service.ts | 2 +-
src/features/navigation/deep-links.test.ts | 47 ++++++-----
13 files changed, 323 insertions(+), 78 deletions(-)
diff --git a/docs/agents.md b/docs/agents.md
index 7871ebea5b..1beece9e33 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -76,14 +76,27 @@ 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:/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. Local file and memory
+reads remain unavailable, as on legacy connections. 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
@@ -103,7 +116,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
@@ -118,7 +131,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.
@@ -134,7 +147,7 @@ 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.
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 157921b36d..09766fc8a1 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -142,19 +142,26 @@ 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. Local file and memory reads remain unavailable. 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
diff --git a/src-tauri/src/host_mcp/pairing.rs b/src-tauri/src/host_mcp/pairing.rs
index 2b6c7789bc..2afe8f4144 100644
--- a/src-tauri/src/host_mcp/pairing.rs
+++ b/src-tauri/src/host_mcp/pairing.rs
@@ -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::>()
- .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::>()
+ .len()
+ == self.scopes.len())
}
}
@@ -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"],
@@ -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"],
diff --git a/src/buzz-mcp/external.test.ts b/src/buzz-mcp/external.test.ts
index 22bb32b4d8..b6f8e9cd16 100644
--- a/src/buzz-mcp/external.test.ts
+++ b/src/buzz-mcp/external.test.ts
@@ -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);
diff --git a/src/buzz-mcp/external.ts b/src/buzz-mcp/external.ts
index 43c30bc968..dabe85f23c 100644
--- a/src/buzz-mcp/external.ts
+++ b/src/buzz-mcp/external.ts
@@ -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<{
@@ -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,
+ added: readonly AddedTool[] = [],
) {
if (!input || typeof input !== "object" || Array.isArray(input))
throw new Error("Expected one JSON-RPC message.");
@@ -138,6 +141,29 @@ export async function respondExternal(
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. Local file and memory reads are unavailable. 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") {
return result({
tools,
_meta: {
diff --git a/src/features/agents/ExternalAuthorizationDialog.test.tsx b/src/features/agents/ExternalAuthorizationDialog.test.tsx
index 43a6ee6a1c..ec667e53d7 100644
--- a/src/features/agents/ExternalAuthorizationDialog.test.tsx
+++ b/src/features/agents/ExternalAuthorizationDialog.test.tsx
@@ -24,6 +24,7 @@ afterEach(() => {
it.each([
["Authorize", true],
["Create", true],
+ ["Agent", true],
["Deny", true],
["unavailable runtime", false],
["custody lost", true],
@@ -31,11 +32,16 @@ it.each([
] 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),
@@ -132,6 +138,16 @@ 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(
+ "Local file and memory reads remain unavailable",
+ );
+ }
if (requestedAction === "Create")
expect(access).toHaveTextContent(
"create private Solo chats with you, the selected agent and your saved Bestie",
diff --git a/src/features/agents/ExternalAuthorizationDialog.tsx b/src/features/agents/ExternalAuthorizationDialog.tsx
index 7c9aa7070c..fc596937fa 100644
--- a/src/features/agents/ExternalAuthorizationDialog.tsx
+++ b/src/features/agents/ExternalAuthorizationDialog.tsx
@@ -134,14 +134,19 @@ function ConsentDialog({
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",
+ scope === "agent"
+ ? "full access to the selected agent’s Buzz tools, including tools from enabled plugins"
+ : 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")
+ ? "Buzz’s channel access checks and tool confirmations still apply. Repository tools can provide short-lived Git credentials for repositories the agent can access. Local file and memory reads remain unavailable. Private identity keys stay in Buzz."
+ : "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."}
diff --git a/src/features/agents/external-connection.test.ts b/src/features/agents/external-connection.test.ts
index f52a85dd88..56996ac60b 100644
--- a/src/features/agents/external-connection.test.ts
+++ b/src/features/agents/external-connection.test.ts
@@ -39,7 +39,7 @@ afterEach(() => vi.useRealTimers());
it("parses the generic authorization request without a bearer", () => {
expect(parseExternalAuthorization(uri())).toEqual(request);
});
-it.each(["read", "post", "create", "read create", "read post create"])(
+it.each(["agent", "read", "post", "create", "read create", "read post create"])(
"parses explicitly requested access without adding scopes (%s)",
(scope) => {
const url = new URL(uri());
@@ -60,6 +60,9 @@ it.each([
["scope", "read read"],
["scope", "create create"],
["scope", "create manage"],
+ ["scope", "agent read"],
+ ["scope", "post agent"],
+ ["scope", "agent agent"],
["state", "short"],
["client", ""],
["version", "3"],
@@ -87,30 +90,47 @@ const resumeRequest: ExternalAuthorization = {
resume: { id: savedPairing.id, counter: 1, proof: "A".repeat(43) },
};
-it("restores saved approval and closes only the session on unload", async () => {
- const server = transport();
- const pairing = vi.fn(async (_operation: string) => savedPairing);
- const connection = createExternalApps();
- const unbind = connection.bind({
- connect: async () => server,
- deny: async () => {},
- pairing,
- });
- await vi.waitFor(() =>
- expect(connection.snapshot().pairing).toEqual(savedPairing),
- );
- connection.open(resumeRequest, "viewer");
- await vi.waitFor(() => expect(server.authorize).toHaveBeenCalledOnce());
- await vi.waitFor(() =>
- expect(connection.snapshot().grant?.phase).toBe("waiting"),
- );
- expect(connection.snapshot().pending).toBeUndefined();
- unbind();
- expect(server.close).toHaveBeenCalled();
- expect(pairing.mock.calls.map((call) => call[0])).not.toContain("revoke");
-});
+it.each(["read", "agent"] as const)(
+ "restores saved %s approval and closes only the session on unload",
+ async (scope) => {
+ const server = transport();
+ const saved = {
+ ...savedPairing,
+ approval: { ...savedPairing.approval, scopes: [scope] },
+ };
+ const pairing = vi.fn(async (_operation: string) => saved);
+ const connection = createExternalApps();
+ const connect = vi.fn(async () => server);
+ const unbind = connection.bind({
+ connect,
+ deny: async () => {},
+ pairing,
+ });
+ await vi.waitFor(() =>
+ expect(connection.snapshot().pairing).toEqual(saved),
+ );
+ connection.open({ ...resumeRequest, scope: [scope] }, "viewer");
+ await vi.waitFor(() => expect(server.authorize).toHaveBeenCalledOnce());
+ expect(connect).toHaveBeenCalledWith(
+ saved.approval.agent,
+ expect.any(AbortSignal),
+ expect.any(Function),
+ [scope],
+ );
+ expect(server.authorize).toHaveBeenCalledWith(
+ expect.objectContaining({ approval: saved.approval }),
+ );
+ await vi.waitFor(() =>
+ expect(connection.snapshot().grant?.phase).toBe("waiting"),
+ );
+ expect(connection.snapshot().pending).toBeUndefined();
+ unbind();
+ expect(server.close).toHaveBeenCalled();
+ expect(pairing.mock.calls.map((call) => call[0])).not.toContain("revoke");
+ },
+);
-it.each(["revoked", "proof", "viewer", "scope"])(
+it.each(["revoked", "proof", "viewer", "scope", "upgrade"])(
"refuses %s resume before connection",
async (reason) => {
const connect = vi.fn(async () => transport());
@@ -125,7 +145,9 @@ it.each(["revoked", "proof", "viewer", "scope"])(
connection.open(
reason === "scope"
? { ...resumeRequest, scope: ["read", "post"] }
- : resumeRequest,
+ : reason === "upgrade"
+ ? { ...resumeRequest, scope: ["agent"] }
+ : resumeRequest,
reason === "viewer" ? "other-viewer" : "viewer",
);
await vi.waitFor(() =>
diff --git a/src/features/agents/external-connection.ts b/src/features/agents/external-connection.ts
index 4223e3abf8..a2f4c7c12c 100644
--- a/src/features/agents/external-connection.ts
+++ b/src/features/agents/external-connection.ts
@@ -18,7 +18,7 @@ export function externalMcpEnabled(community: string | null) {
}
}
-export type ExternalScope = "read" | "post" | "create";
+export type ExternalScope = "agent" | "read" | "post" | "create";
export type ExternalAuthorization = McpAuthorization &
Readonly<{
version: 1 | 2;
@@ -78,8 +78,13 @@ export function parseExternalAuthorization(
value.code_challenge_method !== "S256" ||
!scope.length ||
new Set(scope).size !== scope.length ||
+ (scope.includes("agent") && scope.length !== 1) ||
scope.some(
- (item) => item !== "read" && item !== "post" && item !== "create",
+ (item) =>
+ item !== "agent" &&
+ item !== "read" &&
+ item !== "post" &&
+ item !== "create",
)
)
return null;
diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts
index 78d05817ca..70c3f45a52 100644
--- a/src/features/agents/service.test.ts
+++ b/src/features/agents/service.test.ts
@@ -13341,7 +13341,14 @@ it.each(["disabled runtime", "lost custody"])(
},
);
-it.each(["community selection", "reconnect", "identity", "runtime", "revoke"])(
+it.each([
+ "community selection",
+ "reconnect",
+ "identity",
+ "runtime",
+ "revoke",
+ "full agent access",
+])(
"ends an external grant on %s and refuses later tool calls",
async (reason) => {
vi.stubEnv(
@@ -13349,6 +13356,25 @@ it.each(["community selection", "reconnect", "identity", "runtime", "revoke"])(
"https://relay.example.test",
);
const f = await setup();
+ const readFile = vi.fn(async () => new Uint8Array([42]));
+ const readMemory = vi.spyOn(f.session.agentMemories, "read");
+ const pluginCall = vi.fn(
+ async (
+ client: import("../../buzz-mcp/client").BuzzClient,
+ _context: unknown,
+ args: Record,
+ ) => {
+ if (args.kind === "file") await client.read("/fixture.txt");
+ if (args.kind === "memory") await client.memories();
+ return "plugin reached";
+ },
+ );
+ f.plugin.agents.addTool({
+ name: "fixture_external",
+ description: "External connection fixture",
+ inputSchema: { type: "object", properties: { kind: { type: "string" } } },
+ call: pluginCall,
+ });
Object.assign(f.session.agentChoices, {
snapshot: () => ({ selectable: [{ pubkey: bot }] }),
subscribe: () => () => {},
@@ -13359,6 +13385,7 @@ it.each(["community selection", "reconnect", "identity", "runtime", "revoke"])(
closed.resolve();
});
f.ctx.provide("host", {
+ readFile,
serveMcp: async (receive: import("../host/service").McpHandler) => {
handler = receive;
return {
@@ -13380,6 +13407,7 @@ it.each(["community selection", "reconnect", "identity", "runtime", "revoke"])(
bot,
controller.signal,
admitted,
+ reason === "full agent access" ? ["agent"] : ["read", "post"],
);
const call = handler;
if (!call) throw new Error("Missing server handler");
@@ -13390,6 +13418,38 @@ it.each(["community selection", "reconnect", "identity", "runtime", "revoke"])(
params: { protocolVersion: "2025-06-18" },
}),
).toHaveProperty("result.protocolVersion", "2025-06-18");
+ const full = reason === "full agent access";
+ const discovery = await call({ id: 3, method: "tools/list" });
+ const listed = (discovery as { result: { tools: { name: string }[] } })
+ .result.tools;
+ expect(listed.some((tool) => tool.name === "fixture_external")).toBe(full);
+ const plugin = (kind: string) =>
+ call({
+ id: 4,
+ method: "tools/call",
+ params: { name: "fixture_external", arguments: { kind } },
+ });
+ if (full) {
+ expect(await plugin("echo")).toHaveProperty(
+ "result.content.0.text",
+ "plugin reached",
+ );
+ expect(await plugin("file")).toHaveProperty(
+ "result.content.0.text",
+ "External connections cannot read files.",
+ );
+ expect(await plugin("memory")).toHaveProperty(
+ "result.content.0.text",
+ "External connections cannot read memory.",
+ );
+ expect(pluginCall).toHaveBeenCalledTimes(3);
+ } else {
+ expect(await plugin("file")).toHaveProperty("result.isError", true);
+ expect(await plugin("memory")).toHaveProperty("result.isError", true);
+ expect(pluginCall).not.toHaveBeenCalled();
+ }
+ expect(readFile).not.toHaveBeenCalled();
+ expect(readMemory).not.toHaveBeenCalled();
const post = {
id: 2,
method: "tools/call",
@@ -13418,7 +13478,7 @@ it.each(["community selection", "reconnect", "identity", "runtime", "revoke"])(
const outcome = call(post).catch((error) => error);
try {
await entered.promise;
- if (reason === "community selection")
+ if (reason === "community selection" || reason === "full agent access")
f.select("https://other.example.test");
else if (reason === "reconnect") f.connect(true);
else if (reason === "identity") f.signIn(other);
diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts
index 8b11f197c7..7421614b4f 100644
--- a/src/features/agents/service.ts
+++ b/src/features/agents/service.ts
@@ -2916,6 +2916,7 @@ export class AgentsService extends Service implements Agents {
);
return worker;
},
+ this.tools(),
);
};
},
diff --git a/src/features/host/service.ts b/src/features/host/service.ts
index 4eeb243050..683a57e472 100644
--- a/src/features/host/service.ts
+++ b/src/features/host/service.ts
@@ -155,7 +155,7 @@ export type McpApproval = Readonly<{
viewer: string;
agent: string;
community: string;
- scopes: readonly ("read" | "post" | "create")[];
+ scopes: readonly ("agent" | "read" | "post" | "create")[];
}>;
export type McpPairing = Readonly<{
id: string;
diff --git a/src/features/navigation/deep-links.test.ts b/src/features/navigation/deep-links.test.ts
index ae37723b35..a4541b8537 100644
--- a/src/features/navigation/deep-links.test.ts
+++ b/src/features/navigation/deep-links.test.ts
@@ -223,26 +223,33 @@ const inboxOn = { status: () => "on" as const, subscribe: () => () => {} };
const ready: Client = { status: "ready", viewer, selected: origin };
const loading: Client = { status: "loading", selected: null };
-it("holds external authorization through startup without adding handshake data to navigation", async () => {
- const link = `buzz://mcp-authorize?${new URLSearchParams({ version: "1", client: "Test app", callback: "http://127.0.0.1:1234/buzz/authorize", state: "A".repeat(43), scope: "read post", code_challenge: "A".repeat(43), code_challenge_method: "S256" })}`;
- const t = harness(loading, [link]);
- try {
- await vi.waitFor(() => expect(t.take).toHaveBeenCalled());
- expect(t.host.authorize).not.toHaveBeenCalled();
- t.become({ status: "ready", viewer, selected: null });
- await vi.waitFor(() => expect(t.host.authorize).toHaveBeenCalledOnce());
- expect(t.host.authorize).toHaveBeenCalledWith(
- expect.objectContaining({ client: "Test app", scope: ["read", "post"] }),
- viewer,
- );
- expect(t.host.navigation.open).not.toHaveBeenCalled();
- expect(JSON.stringify(t.controller.navigation.snapshot())).not.toContain(
- "code_challenge",
- );
- } finally {
- t.stop();
- }
-});
+it.each(["read post", "agent"])(
+ "holds external authorization (%s) through startup without adding handshake data to navigation",
+ async (scope) => {
+ const link = `buzz://mcp-authorize?${new URLSearchParams({ version: "2", client: "Nexus", callback: "http://127.0.0.1:1234/buzz/authorize", state: "A".repeat(43), scope, code_challenge: "A".repeat(43), code_challenge_method: "S256" })}`;
+ const t = harness(loading, [link]);
+ try {
+ await vi.waitFor(() => expect(t.take).toHaveBeenCalled());
+ expect(t.host.authorize).not.toHaveBeenCalled();
+ t.become({ status: "ready", viewer, selected: null });
+ await vi.waitFor(() => expect(t.host.authorize).toHaveBeenCalledOnce());
+ expect(t.host.authorize).toHaveBeenCalledWith(
+ expect.objectContaining({
+ client: "Nexus",
+ version: 2,
+ scope: scope.split(" "),
+ }),
+ viewer,
+ );
+ expect(t.host.navigation.open).not.toHaveBeenCalled();
+ expect(JSON.stringify(t.controller.navigation.snapshot())).not.toContain(
+ "code_challenge",
+ );
+ } finally {
+ t.stop();
+ }
+ },
+);
const channelOpen = `open:${JSON.stringify({
version: 1,
kind: "conversation",
From 3f69a67b06af594cfc43998560956b3dc3b5c055 Mon Sep 17 00:00:00 2001
From: Gil Birman
Date: Fri, 6 Nov 2026 12:31:33 -0600
Subject: [PATCH 2/4] Clarify plugin access in external app consent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Distinguish built-in read restrictions from enabled plugins’ existing host permissions and cover plugin-owned file access through the external connection.
Co-authored-by: Codex
Signed-off-by: Gil Birman
---
docs/agents.md | 12 ++++++++----
docs/buzz-help.md | 5 +++--
src/buzz-mcp/external.ts | 2 +-
.../ExternalAuthorizationDialog.test.tsx | 5 ++++-
.../agents/ExternalAuthorizationDialog.tsx | 2 +-
src/features/agents/service.test.ts | 19 +++++++++++++++++++
6 files changed, 36 insertions(+), 9 deletions(-)
diff --git a/docs/agents.md b/docs/agents.md
index 1beece9e33..c8b727f155 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -88,8 +88,11 @@ 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. Local file and memory
-reads remain unavailable, as on legacy connections. Repository tools can return
+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`
@@ -160,8 +163,9 @@ the linked message's channel and thread; it cannot combine with `channel` or
`send` needs `channel`, `text`, and `audience`;
`reply_in_subthread` needs `parent`, `text`, and `audience`. Posts accept
`mentions`, and `send` accepts `reply`. Posts reject `path`, `files`, and `final`.
-Unknown fields are rejected. File access, commands, Git credentials, and plugin
-tools are unavailable. Connection credentials must stay out of logs and messages.
+Unknown fields are rejected. For legacy grants, file access, commands, Git
+credentials, and plugin tools are unavailable. Connection credentials must stay
+out of logs and messages.
### Durable external approval
diff --git a/docs/buzz-help.md b/docs/buzz-help.md
index 09766fc8a1..b48be0ac02 100644
--- a/docs/buzz-help.md
+++ b/docs/buzz-help.md
@@ -151,8 +151,9 @@ 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. Local file and memory reads remain unavailable. Check this
-in the approval dialog. Older limited approvals do not gain full access without
+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
diff --git a/src/buzz-mcp/external.ts b/src/buzz-mcp/external.ts
index dabe85f23c..4040ec30a0 100644
--- a/src/buzz-mcp/external.ts
+++ b/src/buzz-mcp/external.ts
@@ -145,7 +145,7 @@ export async function respondExternal(
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. Local file and memory reads are unavailable. 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.",
+ "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"] },
diff --git a/src/features/agents/ExternalAuthorizationDialog.test.tsx b/src/features/agents/ExternalAuthorizationDialog.test.tsx
index ec667e53d7..d4f244c26b 100644
--- a/src/features/agents/ExternalAuthorizationDialog.test.tsx
+++ b/src/features/agents/ExternalAuthorizationDialog.test.tsx
@@ -145,7 +145,10 @@ it.each([
expect(access).toHaveTextContent("tool confirmations still apply");
expect(access).toHaveTextContent("short-lived Git credentials");
expect(access).toHaveTextContent(
- "Local file and memory reads remain unavailable",
+ "Buzz’s built-in file and memory reads remain unavailable",
+ );
+ expect(access).toHaveTextContent(
+ "Enabled plugin tools keep their approved access",
);
}
if (requestedAction === "Create")
diff --git a/src/features/agents/ExternalAuthorizationDialog.tsx b/src/features/agents/ExternalAuthorizationDialog.tsx
index fc596937fa..e15fd01286 100644
--- a/src/features/agents/ExternalAuthorizationDialog.tsx
+++ b/src/features/agents/ExternalAuthorizationDialog.tsx
@@ -145,7 +145,7 @@ function ConsentDialog({
.join("; ")}
.{" "}
{request.scope.includes("agent")
- ? "Buzz’s channel access checks and tool confirmations still apply. Repository tools can provide short-lived Git credentials for repositories the agent can access. Local file and memory reads remain unavailable. Private identity keys stay in Buzz."
+ ? "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."
: "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."}
diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts
index 70c3f45a52..76869e76bd 100644
--- a/src/features/agents/service.test.ts
+++ b/src/features/agents/service.test.ts
@@ -13366,6 +13366,12 @@ it.each([
) => {
if (args.kind === "file") await client.read("/fixture.txt");
if (args.kind === "memory") await client.memories();
+ if (args.kind === "plugin-local") {
+ const host = f.plugin.host;
+ if (!host?.readFile) throw new Error("Missing plugin file access");
+ const bytes = await host.readFile("/plugin-fixture.txt");
+ return `plugin read ${bytes[0]}`;
+ }
return "plugin reached";
},
);
@@ -13450,6 +13456,19 @@ it.each([
}
expect(readFile).not.toHaveBeenCalled();
expect(readMemory).not.toHaveBeenCalled();
+ if (full) {
+ expect(await plugin("plugin-local")).toHaveProperty(
+ "result.content.0.text",
+ "plugin read 42",
+ );
+ expect(readFile).toHaveBeenCalledExactlyOnceWith("/plugin-fixture.txt");
+ } else {
+ expect(await plugin("plugin-local")).toHaveProperty(
+ "result.isError",
+ true,
+ );
+ expect(readFile).not.toHaveBeenCalled();
+ }
const post = {
id: 2,
method: "tools/call",
From 9e3e687a8c5f6063524721f886d7e3b3de5c2229 Mon Sep 17 00:00:00 2001
From: Gil Birman
Date: Fri, 6 Nov 2026 12:34:48 -0600
Subject: [PATCH 3/4] Scope legacy tool argument documentation
Co-authored-by: Codex
Signed-off-by: Gil Birman
---
docs/agents.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/agents.md b/docs/agents.md
index c8b727f155..bea1a26154 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -155,7 +155,7 @@ Legacy external clients must accept `create` in `buzz/grantedScopes`,
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`;
@@ -163,7 +163,7 @@ the linked message's channel and thread; it cannot combine with `channel` or
`send` needs `channel`, `text`, and `audience`;
`reply_in_subthread` needs `parent`, `text`, and `audience`. Posts accept
`mentions`, and `send` accepts `reply`. Posts reject `path`, `files`, and `final`.
-Unknown fields are rejected. For legacy grants, file access, commands, Git
+Unknown fields are rejected. File access, commands, Git
credentials, and plugin tools are unavailable. Connection credentials must stay
out of logs and messages.
From 495d9bf54f4e71c4090d6361212d5207a96360f0 Mon Sep 17 00:00:00 2001
From: Gil Birman
Date: Fri, 6 Nov 2026 12:37:36 -0600
Subject: [PATCH 4/4] Simplify external authorization presentation
Preserve consent text and dispatch behavior while consolidating the full-access branch and shared cursor guard.
Co-authored-by: Codex
Signed-off-by: Gil Birman
---
docs/agents.md | 5 ++--
src/buzz-mcp/external.ts | 14 +++++------
.../agents/ExternalAuthorizationDialog.tsx | 24 ++++++++-----------
3 files changed, 18 insertions(+), 25 deletions(-)
diff --git a/docs/agents.md b/docs/agents.md
index bea1a26154..204e9e967e 100644
--- a/docs/agents.md
+++ b/docs/agents.md
@@ -163,9 +163,8 @@ the linked message's channel and thread; it cannot combine with `channel` or
`send` needs `channel`, `text`, and `audience`;
`reply_in_subthread` needs `parent`, `text`, and `audience`. Posts accept
`mentions`, and `send` accepts `reply`. Posts reject `path`, `files`, and `final`.
-Unknown fields are rejected. File access, commands, Git
-credentials, and plugin tools are unavailable. Connection credentials must stay
-out of logs and messages.
+Unknown fields are rejected. File access, commands, Git credentials, and plugin
+tools are unavailable. Connection credentials must stay out of logs and messages.
### Durable external approval
diff --git a/src/buzz-mcp/external.ts b/src/buzz-mcp/external.ts
index 4040ec30a0..15ba4a0152 100644
--- a/src/buzz-mcp/external.ts
+++ b/src/buzz-mcp/external.ts
@@ -134,14 +134,12 @@ export async function respondExternal(
};
connected();
}
- if (method === "tools/list") {
- if (params.cursor !== undefined)
- return {
- jsonrpc: "2.0",
- id,
- error: { code: -32602, message: "Tool cursors are not supported." },
- };
- }
+ 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:
diff --git a/src/features/agents/ExternalAuthorizationDialog.tsx b/src/features/agents/ExternalAuthorizationDialog.tsx
index e15fd01286..1b183c8ff5 100644
--- a/src/features/agents/ExternalAuthorizationDialog.tsx
+++ b/src/features/agents/ExternalAuthorizationDialog.tsx
@@ -132,21 +132,17 @@ function ConsentDialog({
/>
Requested access:{" "}
- {request.scope
- .map((scope) =>
- scope === "agent"
- ? "full access to the selected agent’s Buzz tools, including tools from enabled plugins"
- : 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("; ")}
- .{" "}
{request.scope.includes("agent")
- ? "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."
- : "Keys stay in Buzz."}
+ ? "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."}