Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
14 changes: 14 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions src/bundled/claude-code/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ function setup(
status: "ready",
session,
viewer: alice,
origin: "https://agent.example",
scope: `https://agent.example:${alice}`,
}),
} as unknown as RelayData;
Expand Down Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/bundled/goose/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions src/features/agent-harness/kit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
36 changes: 32 additions & 4 deletions src/features/agents/owner-attestation.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ProfileQueries, "ensure" | "event"> | undefined;
Expand All @@ -104,6 +124,7 @@ export function workflowOf(
): WorkflowCheck {
return {
agent,
community: reads?.origin,
authority: reads?.workflowAuthority(),
profiles: reads && {
ensure: async (ids) => {
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/features/agents/reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChannelFacts | undefined>;
read(
Expand Down Expand Up @@ -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) ??
Expand Down
74 changes: 73 additions & 1 deletion src/features/agents/workflow-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<RelayEvent[]>((resolve) => {
Expand Down
9 changes: 9 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) : "";
Expand Down Expand Up @@ -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",
Expand Down
Loading