diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8008f800e..91e21f5a07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,7 @@ permissions: env: BUZZ_EXTERNAL_MCP_COMMUNITIES: https://buzz.block.builderlab.xyz BUZZ_OWNER_MCP_COMMUNITIES: https://buzz.block.builderlab.xyz + BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES: https://buzz.block.builderlab.xyz # Automatic key backup to bb-public production, only for builds that sign in to # app.builderlab.xyz: every build without an FI descriptor does, through the # built-in server. The namespace must match that service's configuration. diff --git a/docs/buzz-help.md b/docs/buzz-help.md index a40e0e6736..e962b63937 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -563,6 +563,11 @@ available rather than silently discarding it. - On desktop, Buzz starts every agent with `BUZZ_VERSION` set to the app's version, and agents usually pass it on to the shell commands they run. A script or shell config can check for it to tell it is running under Buzz. +- In bb-block on desktop preview releases, workflow messages do not + automatically start or steer local Claude, Codex, Goose, or Pi agents. To + have one act on a workflow result, send it a direct request explaining what + to do with that result. Other communities, including bb-public, still let a + workflow you or your agents created mention your agents. - **Agents** is where people create and manage agents. For supported types, open an agent there and use **Identity → Instructions**. Opening its avatar or name in a conversation shows its profile, where **Settings → Instructions** diff --git a/docs/configuration.md b/docs/configuration.md index 7770494d48..089b3cdb64 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,6 +10,7 @@ Build defaults are readable binary data, **never secret storage**. | --- | --- | | `BUZZ_EXTERNAL_MCP_COMMUNITIES` | Frontend build: space-separated secure community origins allowed to authorize external MCP access. Empty disables access. See below. | | `BUZZ_OWNER_MCP_COMMUNITIES` | Frontend build: space-separated secure community origins where an agent's app-hosted runs can read its owner's channels and members with the owner's access. Empty disables them. See below. | +| `BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES` | Frontend build: space-separated secure community origins where workflow messages cannot start or steer local agents. Empty keeps checked workflow delegation everywhere. See below. | | `BUZZ_BUILDERLAB_URL` | Shared frontend/native build: the live development broker's Builderlab, and whether a build without an added sign-in server keeps the built-in one; no runtime override. See below. | | `BUZZ_BUILDERLAB_APPS_DOMAIN` | Shared frontend/native build: exact Apps cookie domain paired with the login origin for native viewer SSO; no runtime override. | | `BUZZ_DEFAULT_COMMUNITY_URL` | Packaged frontend build: secure relay origin to select once, after identity setup, only when this viewer has no saved community record. Saved choices, including Personal space, always win. Unset in OSS builds. | @@ -54,6 +55,19 @@ The Agents service compares the agent's exact canonical community origin when it starts a run and again on each call. See [owner tools](agents.md#owner-tools). +## Workflow restriction community flag + +`BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES` is a space-separated list of `https://` +or `wss://` community origins, parsed as the external flag is. The default is +empty: in every community, a workflow mention can start or steer a local Claude, +Codex, Goose or Pi agent when its creator is the agent's owner or a sibling the +owner attested. The desktop preview workflow sets the flag to +`https://buzz.block.builderlab.xyz` for bb-block only. In a listed community no +workflow-marked message authorizes a local agent request, in bundled or plugin +harnesses; the message stays visible. While any community is listed, an agent +whose community cannot be read is restricted too. See +[workflows](workflows.md). + ## Packaged community default Internal packagers can set a public community origin for fresh installs: diff --git a/docs/workflows.md b/docs/workflows.md index 07edc7eb5c..f3dce3fbd9 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -255,6 +255,16 @@ Workflows created and maintained under an owned agent's own key remain owned by that agent and can wake agents with the same proven human owner. This does not grant an agent permission to edit a workflow owned by a different signing identity. +Builds can withhold this delegation per community. In communities listed by +`BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES`, no message carrying a `buzz:workflow` tag +authorizes a local agent request, whatever its signer, owner or mention tags, in +bundled or plugin harnesses. Workflow messages there stay visible but do not start +or steer local Claude, Codex, Goose or Pi agents, including for owner-created +static templates and scheduled workflows. Desktop preview releases list bb-block +only; bb-public and other communities keep the delegation above. The restriction +does not prevent workflow output from influencing a later authorized turn. See +[configuration](configuration.md#workflow-restriction-community-flag). + Cached-only startup intentionally shows the actual signer until a live connection establishes explicit relay authority. Saved operator-contact `relayAuthor` is not promoted to `self`, and no new trusted storage is introduced. Signer-based search diff --git a/src/bundled/claude-code/runtime.test.ts b/src/bundled/claude-code/runtime.test.ts index b72ff8aa32..6b1567bcee 100644 --- a/src/bundled/claude-code/runtime.test.ts +++ b/src/bundled/claude-code/runtime.test.ts @@ -166,6 +166,7 @@ function setup( status: "ready", session, viewer: alice, + origin: "https://agent.example", scope: `https://agent.example:${alice}`, }), } as unknown as RelayData; @@ -1997,6 +1998,48 @@ it.each([true, false])( }, ); +it.each([ + ["https://agent.example", false], + ["https://other.example", true], +])( + "refuses an owner's workflow mention where the build restricts %s", + async (restricted, runs) => { + vi.stubEnv("VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES", restricted); + const relay = keypair(); + const event = eventDto( + signed(relay, { + kind: 9, + content: "@Claude workflow", + tags: [ + ["h", channel], + ["p", self], + ["buzz:workflow", "true"], + ["buzz:workflow-owner", alice], + ["buzz:workflow-mention", self], + ], + }), + ); + const f = setup([event], { workflowAuthority: relay.pubkey }); + try { + await f.deliver({ type: "mention", event }); + if (runs) { + await vi.waitFor(() => + expect(f.claudes().some((process) => process.prompts.length)).toBe( + true, + ), + ); + } else { + await flush(10); + expect(f.claudes()).toHaveLength(0); + expect(f.workingUntil).not.toHaveBeenCalled(); + } + } finally { + f.runtime.dispose(); + vi.unstubAllEnvs(); + } + }, +); + it.each([false, true])( "handles sibling workflow authorization with cancellation=%s", async (cancelled) => { diff --git a/src/bundled/goose/runtime.ts b/src/bundled/goose/runtime.ts index df66a6f7ed..84e7b9b37a 100644 --- a/src/bundled/goose/runtime.ts +++ b/src/bundled/goose/runtime.ts @@ -401,6 +401,7 @@ export class GooseRuntime { if ( !(await fromOwner(trigger.event, agent.owner, { agent: agent.pubkey, + community: snapshot?.origin, authority: snapshot?.status === "ready" ? snapshot.session.workflowAuthority diff --git a/src/features/agent-harness/kit.ts b/src/features/agent-harness/kit.ts index f76589cea4..346b24384b 100644 --- a/src/features/agent-harness/kit.ts +++ b/src/features/agent-harness/kit.ts @@ -223,8 +223,17 @@ export function harnessKit(sources: Sources): HarnessKit { const wake = trigger.type !== "mention" ? trigger : undefined; if (trigger.type === "mention") { // Only the owner or an attested sibling. Unlike the bundled harnesses, - // which kept relay workflow mentions for compatibility, no workflow check. - if (!(await fromOwner(trigger.event, agent.owner))) return undefined; + // which kept relay workflow mentions for compatibility, no workflow + // authority is given, so no workflow mention is delegated; the + // community still applies the build's workflow restriction. + if ( + !(await fromOwner(trigger.event, agent.owner, { + agent: agent.pubkey, + authority: undefined, + community: sources.relay(agent.key)?.snapshot().origin, + })) + ) + return undefined; signal.throwIfAborted(); } const event = diff --git a/src/features/agents/owner-attestation.ts b/src/features/agents/owner-attestation.ts index 4cf2208bb1..ca346c5fe0 100644 --- a/src/features/agents/owner-attestation.ts +++ b/src/features/agents/owner-attestation.ts @@ -1,5 +1,6 @@ import { schnorr } from "@noble/curves/secp256k1.js"; import { hexToBytes } from "nostr-tools/utils"; +import { communityDestination } from "../communities/destination"; import type { ProfileQueries } from "../relay/profile-directory"; import { hasEventProof, type EventData } from "../relay/events"; import { workflowMentionOwner } from "../relay/workflow-attribution"; @@ -89,9 +90,28 @@ function satisfied( }); } -/** How a mention from a relay workflow is checked: the community's workflow - * key, and its creators' profiles. */ +/** Whether this build keeps workflow messages from authorizing local agent + * requests in `community`. The build flag lists exact canonical origins; with + * any listed, an unknown or unreadable community is restricted too. */ +export function workflowRequestsRestricted(community: string | undefined) { + const restricted = ( + import.meta.env.VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES ?? "" + ) + .split(" ") + .filter(Boolean); + if (!restricted.length) return false; + if (!community) return true; + try { + return restricted.includes(communityDestination(community).url); + } catch { + return true; + } +} + +/** How a mention from a relay workflow is checked: the agent's community, the + * community's workflow key, and its creators' profiles. */ export type WorkflowCheck = { + community?: string | undefined; authority: string | undefined; agent: string; profiles?: Pick | undefined; @@ -104,6 +124,7 @@ export function workflowOf( ): WorkflowCheck { return { agent, + community: reads?.origin, authority: reads?.workflowAuthority(), profiles: reads && { ensure: async (ids) => { @@ -115,16 +136,23 @@ export function workflowOf( } /** Owner prompts, attested siblings, and verified relay workflow mentions - * delegated by the owner or a profile-verified sibling to this particular agent. */ + * delegated by the owner or a profile-verified sibling to this particular agent. + * Where the build restricts workflow requests, no workflow-marked message + * authorizes one: its rendered text is not bound to the owner's template. */ export async function fromOwner( event: EventData, owner: string, workflow?: WorkflowCheck, ) { + const restricted = workflowRequestsRestricted(workflow?.community); + if (restricted && event.tags.some(([name]) => name === "buzz:workflow")) + return false; if (event.pubkey === owner || (await attestedOwner(event)) === owner) return true; const creator = - workflow && workflowMentionOwner(event, workflow.authority, workflow.agent); + !restricted && + workflow && + workflowMentionOwner(event, workflow.authority, workflow.agent); if (!creator) return false; if (creator === owner) return true; const profiles = workflow?.profiles; diff --git a/src/features/agents/reads.ts b/src/features/agents/reads.ts index 03bd036105..4a00c7d081 100644 --- a/src/features/agents/reads.ts +++ b/src/features/agents/reads.ts @@ -34,6 +34,8 @@ export type AgentReads = Readonly<{ connection: object; /** The community these reads are of; it changes when the agent's does. */ scope: string; + /** That community's normalized origin, when known. */ + origin?: string | undefined; /** The channel as the community lists it, or undefined when unknown. */ channel(id: string): Promise; read( @@ -89,6 +91,7 @@ export function ownerReads( return Object.freeze({ connection: session, scope: snapshot.scope ?? "", + origin: snapshot.origin, channel: async (id: string) => { const summary = session.channels.list().channels.find((item) => item.id === id) ?? diff --git a/src/features/agents/workflow-authorization.test.ts b/src/features/agents/workflow-authorization.test.ts index 17c39904d1..cf050b2ad7 100644 --- a/src/features/agents/workflow-authorization.test.ts +++ b/src/features/agents/workflow-authorization.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { schnorr } from "@noble/curves/secp256k1.js"; import { bytesToHex } from "nostr-tools/utils"; -import { expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { eventDto, type RelayEvent } from "../relay/events"; import { createProfileDirectory } from "../relay/profile-directory"; import type { RelayReader } from "../relay/reader"; @@ -153,6 +153,78 @@ it("recovers from failed or missing reads and respects a replaced profile head", } }); +describe("communities the build restricts", () => { + const block = "wss://buzz.block.builderlab.xyz"; + const direct = (key = owner, tags: string[][] = []) => + eventDto(signed(key, { kind: 9, content: "@target do it", tags })); + beforeEach(() => { + vi.stubEnv( + "VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES", + "https://buzz.block.builderlab.xyz", + ); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("deny every workflow mention there and keep delegation elsewhere", async () => { + const f = directory(); + try { + const blocked = { ...f.context, community: block }; + expect(await fromOwner(prompt(), owner.pubkey, blocked)).toBe(false); + expect(await fromOwner(prompt(), creator.pubkey, blocked)).toBe(false); + expect(f.read).not.toHaveBeenCalled(); + const open = { + ...f.context, + community: "wss://team.communities.buzz.xyz", + }; + expect(await fromOwner(prompt(), owner.pubkey, open)).toBe(true); + expect(await fromOwner(prompt(), creator.pubkey, open)).toBe(true); + } finally { + f.profiles.dispose(); + } + }); + + it("deny workflow-marked messages from the owner but keep direct requests", async () => { + const workflow = [["buzz:workflow", "true"]]; + const blocked = { authority: relay.pubkey, agent: target.pubkey }; + expect( + await fromOwner(direct(owner, workflow), owner.pubkey, { + ...blocked, + community: block, + }), + ).toBe(false); + expect( + await fromOwner(direct(owner, workflow), owner.pubkey, { + ...blocked, + community: "https://team.communities.buzz.xyz", + }), + ).toBe(true); + expect( + await fromOwner(direct(), owner.pubkey, { ...blocked, community: block }), + ).toBe(true); + expect( + await fromOwner(direct(creator, [auth()]), owner.pubkey, { + ...blocked, + community: block, + }), + ).toBe(true); + }); + + it("treat an unknown community as restricted while any is listed", async () => { + const f = directory(); + try { + expect(await fromOwner(prompt(), creator.pubkey, f.context)).toBe(false); + expect(await fromOwner(prompt(), creator.pubkey)).toBe(false); + expect(await fromOwner(direct(), owner.pubkey)).toBe(true); + vi.stubEnv("VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES", ""); + expect(await fromOwner(prompt(), creator.pubkey, f.context)).toBe(true); + } finally { + f.profiles.dispose(); + } + }); +}); + it("denies a profile read invalidated by session disposal", async () => { let release!: (events: RelayEvent[]) => void; const pending = new Promise((resolve) => { diff --git a/vite.config.ts b/vite.config.ts index 11496ce8e7..4935e861a0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -96,6 +96,13 @@ export default defineConfig(async ({ command, mode }) => { .filter(Boolean) .map(relayOrigin) .join(" "); + const workflowRestrictedCommunities = ( + env.BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES ?? "" + ) + .split(/\s+/) + .filter(Boolean) + .map(relayOrigin) + .join(" "); // Public routing configuration only; the viewer pin and credentials stay in Node. const defaultRelay = env.BUZZ_RELAY_URL?.trim(); const defaultOrigin = defaultRelay ? relayOrigin(defaultRelay) : ""; @@ -184,6 +191,8 @@ export default defineConfig(async ({ command, mode }) => { ), "import.meta.env.VITE_BUZZ_OWNER_MCP_COMMUNITIES": JSON.stringify(ownerMcpCommunities), + "import.meta.env.VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES": + JSON.stringify(workflowRestrictedCommunities), "import.meta.env.VITE_BUZZ_BUILDERLAB_URL": JSON.stringify(builderlabUrl), "import.meta.env.VITE_BUZZ_FEDERATED_IDENTITY": JSON.stringify( federatedUrl === undefined ? "0" : "1",