From 18b3d4bf033b37655adc0d974479d31624728a8a Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 3 Nov 2026 18:27:07 -0500 Subject: [PATCH] feat(agents): add owner-signed channel invocation grants Signed-off-by: Tee8z --- crates/buzz-cli/src/commands/agents.rs | 59 +++++ .../src/commands/agents_sharing_tests.rs | 68 ++++++ crates/buzz-cli/src/lib.rs | 34 ++- crates/buzz-core/src/kind.rs | 6 + crates/buzz-relay/src/handlers/agent_grant.rs | 218 ++++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 19 +- crates/buzz-relay/src/handlers/mod.rs | 2 + crates/buzz-sdk/src/lib.rs | 1 + crates/buzz-sdk/src/sharing.rs | 113 +++++++++ crates/buzz-sdk/src/sharing/tests.rs | 97 ++++++++ voltage/channel-agent-sharing.md | 123 ++++++++++ 11 files changed, 735 insertions(+), 5 deletions(-) create mode 100644 crates/buzz-cli/src/commands/agents_sharing_tests.rs create mode 100644 crates/buzz-relay/src/handlers/agent_grant.rs create mode 100644 crates/buzz-sdk/src/sharing.rs create mode 100644 crates/buzz-sdk/src/sharing/tests.rs create mode 100644 voltage/channel-agent-sharing.md diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 568249d0829..eedc2d40a4e 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -9,8 +9,31 @@ use crate::error::CliError; use crate::validate::{read_or_stdin, validate_hex64}; use crate::{AgentsCmd, RespondToArg}; +#[cfg(test)] +#[path = "agents_sharing_tests.rs"] +mod sharing_tests; + pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> { match command { + AgentsCmd::Share { agent, channel } => sharing(client, &agent, channel, true).await, + AgentsCmd::Unshare { agent, channel } => sharing(client, &agent, channel, false).await, + AgentsCmd::Sharing { + agent, + channel, + owner, + } => { + validate_hex64(&agent)?; + let agent = PublicKey::from_hex(&agent).map_err(|e| CliError::Usage(e.to_string()))?; + let owner = owner.unwrap_or_else(|| client.keys().public_key().to_hex()); + validate_hex64(&owner)?; + let output = client + .query(&json!({"kinds":[buzz_core::kind::KIND_AGENT_CHANNEL_GRANT], + "authors":[owner], "#h":[channel], + "#d":[buzz_sdk::sharing::Grant::coordinate(agent, channel)], "limit":1})) + .await?; + println!("{output}"); + Ok(()) + } AgentsCmd::DraftCreate { channel, display_name, @@ -167,6 +190,42 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } } +async fn sharing( + client: &BuzzClient, + agent: &str, + channel: uuid::Uuid, + enabled: bool, +) -> Result<(), CliError> { + validate_hex64(agent)?; + if client.auth_tag_owner_hex().is_some() { + return Err(CliError::Auth( + "sharing requires the human owner's signing key".into(), + )); + } + let agent = PublicKey::from_hex(agent).map_err(|e| CliError::Usage(e.to_string()))?; + let raw = client + .query(&json!({"kinds":[0], "authors":[agent], "limit":1})) + .await?; + let profiles: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid agent profile response: {e}")))?; + let profile = profiles + .first() + .ok_or_else(|| CliError::Auth("agent has no current profile".into()))?; + if profile.pubkey != agent + || buzz_sdk::sharing::profile_owner(profile, nostr::Timestamp::now().as_secs()).ok() + != Some(client.keys().public_key()) + { + return Err(CliError::Auth( + "only the verified human owner may change sharing".into(), + )); + } + let builder = buzz_sdk::sharing::Grant::builder(agent, channel, enabled) + .map_err(|e| CliError::Usage(e.to_string()))?; + let event = client.sign_event(builder)?; + println!("{}", client.submit_event(event).await?); + Ok(()) +} + /// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by /// the `draft-create` and `draft-update` paths. fn require_owner(client: &BuzzClient) -> Result { diff --git a/crates/buzz-cli/src/commands/agents_sharing_tests.rs b/crates/buzz-cli/src/commands/agents_sharing_tests.rs new file mode 100644 index 00000000000..e4cdead739b --- /dev/null +++ b/crates/buzz-cli/src/commands/agents_sharing_tests.rs @@ -0,0 +1,68 @@ +use super::*; +use axum::{extract::State, routing::post, Json, Router}; +use nostr::{Event, EventBuilder, Keys, Kind}; +use std::sync::{Arc, Mutex}; + +#[tokio::test] +async fn share_and_unshare_publish_signed_scoped_heads_after_owner_verification() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let channel = uuid::Uuid::new_v4(); + let auth = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tag(buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap()) + .sign_with_keys(&agent) + .unwrap(); + let writes = Arc::new(Mutex::new(Vec::::new())); + let app = Router::new() + .route("/query", post(move |Json(filter): Json>| { + let profile = profile.clone(); + async move { + assert_eq!(filter[0]["kinds"], json!([0])); + assert_eq!(filter[0]["authors"], json!([profile.pubkey])); + Json(vec![profile]) + } + })) + .route("/events", post(|State(writes): State>>>, Json(event): Json| async move { + assert!(event.verify().is_ok()); + let id = event.id; + writes.lock().unwrap().push(event); + Json(json!({"event_id":id, "accepted":true, "message":""})) + })).with_state(writes.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = BuzzClient::new(url.clone(), owner.clone(), None, None).unwrap(); + for command in [ + AgentsCmd::Share { + agent: agent.public_key().to_hex(), + channel, + }, + AgentsCmd::Unshare { + agent: agent.public_key().to_hex(), + channel, + }, + ] { + dispatch(command, &client).await.unwrap(); + } + let denied = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + assert!(dispatch( + AgentsCmd::Share { + agent: agent.public_key().to_hex(), + channel + }, + &denied + ) + .await + .is_err()); + let events = writes.lock().unwrap(); + assert_eq!(events.len(), 2, "a non-owner must never publish a grant"); + for (event, enabled) in events.iter().zip([true, false]) { + let grant = buzz_sdk::sharing::Grant::parse(event).unwrap(); + assert_eq!(event.pubkey, owner.public_key()); + assert_eq!(grant.enabled, enabled); + assert_eq!(grant.channel, channel); + assert_eq!(grant.agent, agent.public_key()); + } + server.abort(); +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089c..c92bf1eecbb 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -301,6 +301,33 @@ impl RespondToArg { #[derive(Subcommand)] pub enum AgentsCmd { + /// Let current members invoke your agent in one channel using explicit mentions + Share { + /// Agent pubkey (hex); you must sign as its human owner + agent: String, + /// Channel UUID; owner and agent must both be members + #[arg(long)] + channel: uuid::Uuid, + }, + /// Revoke channel invocation by publishing a disabled grant head + Unshare { + /// Agent pubkey (hex) + agent: String, + /// Channel UUID + #[arg(long)] + channel: uuid::Uuid, + }, + /// Read this owner's current sharing grant for one agent and channel + Sharing { + /// Agent pubkey (hex) + agent: String, + /// Channel UUID + #[arg(long)] + channel: uuid::Uuid, + /// Human owner pubkey; defaults to the signing identity + #[arg(long)] + owner: Option, + }, /// Open a prefilled create-agent form in the owner's Buzz Desktop DraftCreate { /// Current channel UUID; the new agent is added here after save @@ -2436,7 +2463,10 @@ mod tests { "archived", "draft-create", "draft-update", - "unarchive" + "share", + "sharing", + "unarchive", + "unshare" ] ); assert_eq!( @@ -2579,7 +2609,7 @@ mod tests { #[test] fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ - ("agents", 5), + ("agents", 8), ("canvas", 4), ("channels", 16), ("dms", 4), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 9ac4116e3c8..d21f1745484 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -301,6 +301,11 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// Owner-signed, channel-scoped permission to invoke an agent (NIP-33). +/// The coordinate is `:`. Disabled heads revoke access; +/// absent grants never authorize another member. Credentials are not included. +pub const KIND_AGENT_CHANNEL_GRANT: u32 = 30180; + /// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored). /// /// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)` @@ -679,6 +684,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_AGENT_CHANNEL_GRANT, KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, KIND_REPORT, diff --git a/crates/buzz-relay/src/handlers/agent_grant.rs b/crates/buzz-relay/src/handlers/agent_grant.rs new file mode 100644 index 00000000000..39fca18ac37 --- /dev/null +++ b/crates/buzz-relay/src/handlers/agent_grant.rs @@ -0,0 +1,218 @@ +use std::sync::Arc; + +use buzz_db::EventQuery; +use buzz_sdk::sharing::{profile_owner, Grant}; +use nostr::{Event, Timestamp}; + +use super::ingest::IngestError; +use crate::state::AppState; +use buzz_core::tenant::TenantContext; + +pub(super) async fn validate( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result { + let reject = |text: &str| IngestError::Rejected(format!("invalid agent grant: {text}")); + let grant = Grant::parse(event).map_err(|_| reject("malformed signed grant"))?; + let profiles = state + .db + .query_events(&EventQuery { + kinds: Some(vec![0]), + authors: Some(vec![grant.agent.to_bytes().to_vec()]), + limit: Some(1), + global_only: true, + ..EventQuery::for_community(tenant.community()) + }) + .await + .map_err(|e| IngestError::Internal(format!("agent profile lookup failed: {e}")))?; + let profile = profiles + .first() + .ok_or_else(|| reject("agent has no current profile"))?; + if profile.event.pubkey != grant.agent + || profile_owner(&profile.event, Timestamp::now().as_secs()).ok() != Some(event.pubkey) + { + return Err(reject("only the agent's verified owner may change sharing")); + } + if grant.enabled { + // Explicit membership is required even for open channels. A human must + // deliberately add their agent before exposing its configured tools. + for member in [event.pubkey, grant.agent] { + let is_member = state + .db + .is_member(tenant.community(), grant.channel, &member.to_bytes()) + .await + .map_err(|e| { + IngestError::Internal(format!("grant membership lookup failed: {e}")) + })?; + if !is_member { + return Err(reject("owner and agent must be channel members")); + } + } + let channel = state + .db + .get_channel_for_event_write(tenant.community(), grant.channel) + .await + .map_err(|e| IngestError::Internal(format!("grant channel lookup failed: {e}")))?; + if channel.channel_type == "dm" { + return Err(reject("direct messages cannot be shared")); + } + } + Ok(grant) +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use buzz_core::{ + channel::{ChannelType, ChannelVisibility, MemberRole}, + CommunityId, + }; + use nostr::{EventBuilder, Keys, Kind}; + use uuid::Uuid; + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_grant_requires_live_owner_and_explicit_membership() { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .unwrap(); + let state = crate::state::tests::test_state_with_database_pool(pool.clone()).await; + let id = Uuid::new_v4(); + let host = format!("grant-{id}.test"); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&host) + .execute(&pool) + .await + .unwrap(); + let tenant = TenantContext::resolved(CommunityId::from_uuid(id), host); + let owner = Keys::generate(); + let agent = Keys::generate(); + let stranger = Keys::generate(); + let channel = state + .db + .create_channel( + tenant.community(), + "sharing", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &owner.public_key().to_bytes(), + None, + ) + .await + .unwrap(); + let auth = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let profile = EventBuilder::new(Kind::Metadata, "{}") + .tag(buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap()) + .sign_with_keys(&agent) + .unwrap(); + state + .db + .insert_event(tenant.community(), &profile, None) + .await + .unwrap(); + let grant = |signer: &Keys, enabled| { + Grant::builder(agent.public_key(), channel.id, enabled) + .unwrap() + .sign_with_keys(signer) + .unwrap() + }; + // Open-channel write permission alone cannot expose an agent's tools. + assert!(validate(&tenant, &state, &grant(&owner, true)) + .await + .is_err()); + // create_channel already made the human an owner. Add the agent + // through that owner, preserving the canonical membership roles. + state + .db + .add_member( + tenant.community(), + channel.id, + &agent.public_key().to_bytes(), + MemberRole::Bot, + Some(&owner.public_key().to_bytes()), + ) + .await + .unwrap(); + assert!(validate(&tenant, &state, &grant(&owner, true)) + .await + .is_ok()); + assert!(validate(&tenant, &state, &grant(&stranger, true)) + .await + .is_err()); + assert!(validate(&tenant, &state, &grant(&stranger, false)) + .await + .is_err()); + assert!(validate(&tenant, &state, &grant(&owner, false)) + .await + .is_ok()); + // Bind the full ingestion seam: a removed owner can still durably + // revoke, including after archive, but cannot re-enable sharing. + state + .db + .add_member( + tenant.community(), + channel.id, + &stranger.public_key().to_bytes(), + MemberRole::Owner, + Some(&owner.public_key().to_bytes()), + ) + .await + .unwrap(); + state + .db + .remove_member( + tenant.community(), + channel.id, + &owner.public_key().to_bytes(), + &stranger.public_key().to_bytes(), + ) + .await + .unwrap(); + let ingest = |event| { + super::super::ingest::ingest_event( + &state, + &tenant, + event, + super::super::ingest::IngestAuth::Http { + pubkey: owner.public_key(), + scopes: vec![buzz_auth::Scope::MessagesWrite], + auth_method: super::super::ingest::HttpAuthMethod::Nip98, + }, + ) + }; + assert!(ingest(grant(&owner, true)).await.is_err()); + assert!(ingest(grant(&owner, false)).await.unwrap().accepted); + state + .db + .archive_channel(tenant.community(), channel.id) + .await + .unwrap(); + let revoke = Grant::builder(agent.public_key(), channel.id, false) + .unwrap() + .custom_created_at(Timestamp::from(Timestamp::now().as_secs() + 1)) + .sign_with_keys(&owner) + .unwrap(); + assert!(ingest(revoke).await.unwrap().accepted); + // A profile rotation invalidates the old owner's authority. + let auth = buzz_sdk::nip_oa::compute_auth_tag(&stranger, &agent.public_key(), "").unwrap(); + let rotated = EventBuilder::new(Kind::Metadata, "{}") + .custom_created_at(Timestamp::from(profile.created_at.as_secs() + 1)) + .tag(buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap()) + .sign_with_keys(&agent) + .unwrap(); + state + .db + .insert_event(tenant.community(), &rotated, None) + .await + .unwrap(); + assert!(validate(&tenant, &state, &grant(&owner, true)) + .await + .is_err()); + assert!(validate(&tenant, &state, &grant(&owner, false)) + .await + .is_err()); + } +} diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index b46911b4cb5..a03936bd42f 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -498,6 +498,7 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { /// Returns `Err` for unknown kinds — the relay rejects them. fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { + buzz_core::kind::KIND_AGENT_CHANNEL_GRANT => Ok(Scope::MessagesWrite), KIND_PROFILE => Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM | buzz_core::kind::KIND_ARTIFACT => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM @@ -769,7 +770,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { matches!( kind, - KIND_STREAM_MESSAGE + buzz_core::kind::KIND_AGENT_CHANNEL_GRANT + | KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2 | KIND_STREAM_MESSAGE_EDIT | KIND_STREAM_MESSAGE_PINNED @@ -2643,6 +2645,16 @@ async fn ingest_event_inner( )); } + // A verified owner must be able to revoke after removal or archive. + // Enabling still requires explicit membership in the grant validator. + let disabled_agent_grant = if kind_u32 == buzz_core::kind::KIND_AGENT_CHANNEL_GRANT { + !super::agent_grant::validate(tenant, state, &event) + .await? + .enabled + } else { + false + }; + if let Some(ch_id) = channel_id { check_token_channel_access(&auth, ch_id).map_err(IngestError::AuthFailed)?; } else if auth.channel_ids().is_some() { @@ -2699,7 +2711,8 @@ async fn ingest_event_inner( || kind_u32 == KIND_STREAM_MESSAGE_EDIT || kind_u32 == KIND_NIP29_EDIT_METADATA || kind_u32 == KIND_NIP29_DELETE_EVENT - || kind_u32 == KIND_NIP29_DELETE_GROUP; + || kind_u32 == KIND_NIP29_DELETE_GROUP + || disabled_agent_grant; if !skip_membership { // Spec AuthCheck (line 794): emit the verdict at the actual // call site. claimed_community comes from the event's h tag @@ -2878,7 +2891,7 @@ async fn ingest_event_inner( parts.len() >= 2 && parts[0] == "archived" && parts[1] == "false" }); - if !is_unarchive { + if !is_unarchive && !disabled_agent_grant { if let Some(channel) = &channel_row { if channel.archived_at.is_some() { return Err(IngestError::Rejected("invalid: channel is archived".into())); diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index e4eea46906c..330de51c34f 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -1,6 +1,8 @@ /// NIP-42 authentication handler. pub mod admin_action_worker; pub mod admin_outbox_worker; +/// Owner-signed channel invocation grants for remote agents. +mod agent_grant; pub mod auth; /// Pure NIP-29 channel membership-authority decisions (kinds 9000/9001/9022). pub mod channel_authz; diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 845505c56d5..295a67d2a95 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -16,6 +16,7 @@ pub mod broker; pub mod builders; pub mod mentions; pub mod nip_oa; +pub mod sharing; pub use builders::*; diff --git a/crates/buzz-sdk/src/sharing.rs b/crates/buzz-sdk/src/sharing.rs new file mode 100644 index 00000000000..2dc50b16679 --- /dev/null +++ b/crates/buzz-sdk/src/sharing.rs @@ -0,0 +1,113 @@ +//! Owner-authored permission to invoke one agent in one channel. +//! +//! Grants convey task invocation only. They never authorize lifecycle operations, +//! credential access, or access to other conversations. Consumers must also check +//! current channel membership before accepting a task. + +use nostr::{Event, EventBuilder, Kind, PublicKey, Tag}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{kind::KIND_AGENT_CHANNEL_GRANT, nip_oa, SdkError}; + +/// One versioned owner opt-in. A disabled head is a durable revocation. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Grant { + /// Wire version; currently one. + pub version: u32, + /// The agent whose configured tools members may invoke. + pub agent: PublicKey, + /// The single conversation in which invocation is permitted. + pub channel: Uuid, + /// Whether members may send explicit task mentions. + pub enabled: bool, +} + +impl Grant { + /// The stable NIP-33 coordinate, including both target and channel. + pub fn coordinate(agent: PublicKey, channel: Uuid) -> String { + format!("{agent}:{channel}") + } + + /// Build a public grant without embedding runtime configuration or secrets. + /// The caller must sign with the human owner's key. + pub fn builder( + agent: PublicKey, + channel: Uuid, + enabled: bool, + ) -> Result { + let grant = Self { + version: 1, + agent, + channel, + enabled, + }; + let tags = [ + vec!["d".into(), Self::coordinate(agent, channel)], + vec!["h".into(), channel.to_string()], + vec!["p".into(), agent.to_hex()], + ] + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|e| SdkError::InvalidTag(e.to_string()))?; + let content = + serde_json::to_string(&grant).map_err(|e| SdkError::InvalidInput(e.to_string()))?; + Ok(EventBuilder::new(Kind::Custom(KIND_AGENT_CHANNEL_GRANT as u16), content).tags(tags)) + } + + /// Verify the signature, version and unambiguous signed scope. + /// Ownership is checked separately against the agent's live profile. + pub fn parse(event: &Event) -> Result { + if event.kind.as_u16() as u32 != KIND_AGENT_CHANNEL_GRANT || event.verify().is_err() { + return Err(invalid("invalid grant signature or kind")); + } + let grant: Self = + serde_json::from_str(&event.content).map_err(|_| invalid("invalid grant content"))?; + if grant.version != 1 || grant.agent == event.pubkey { + return Err(invalid("invalid grant version or agent")); + } + for (name, value) in [ + ("d", Self::coordinate(grant.agent, grant.channel)), + ("h", grant.channel.to_string()), + ("p", grant.agent.to_hex()), + ] { + let tags: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(name)) + .collect(); + if tags.len() != 1 || tags[0].as_slice() != [name, &value] { + return Err(invalid("ambiguous grant scope")); + } + } + Ok(grant) + } +} + +/// Verify an agent's current kind:0 owner attestation at admission time. +/// Missing or ambiguous credentials fail closed; caller obtains the current head. +pub fn profile_owner(profile: &Event, now: u64) -> Result { + if profile.kind != Kind::Metadata || profile.verify().is_err() { + return Err(invalid("invalid agent profile")); + } + let auth: Vec<_> = profile + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")) + .collect(); + if auth.len() != 1 || auth[0].as_slice().len() != 4 { + return Err(invalid("agent profile must have one owner attestation")); + } + let text = serde_json::to_string(auth[0].as_slice()) + .map_err(|_| invalid("invalid owner attestation"))?; + nip_oa::verify_auth_tag_for_auth_event(&text, &profile.pubkey, now) +} + +fn invalid(message: &str) -> SdkError { + SdkError::InvalidInput(message.into()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-sdk/src/sharing/tests.rs b/crates/buzz-sdk/src/sharing/tests.rs new file mode 100644 index 00000000000..f55602298d4 --- /dev/null +++ b/crates/buzz-sdk/src/sharing/tests.rs @@ -0,0 +1,97 @@ +use super::*; +use nostr::{Keys, Timestamp}; + +#[test] +fn signed_grants_round_trip_and_revocations_keep_the_coordinate() { + let owner = Keys::generate(); + let agent = Keys::generate().public_key(); + let channel = Uuid::new_v4(); + let make = |enabled| { + Grant::builder(agent, channel, enabled) + .unwrap() + .sign_with_keys(&owner) + .unwrap() + }; + for enabled in [true, false] { + let event = make(enabled); + let grant = Grant::parse(&event).unwrap(); + assert_eq!( + grant, + Grant { + version: 1, + agent, + channel, + enabled + } + ); + assert!(event + .tags + .iter() + .any(|t| t.as_slice() == ["d", &Grant::coordinate(agent, channel)])); + } + assert_ne!( + Grant::coordinate(agent, channel), + Grant::coordinate(agent, Uuid::new_v4()) + ); +} + +#[test] +fn altered_signed_content_and_ambiguous_tags_are_rejected() { + let owner = Keys::generate(); + let agent = Keys::generate().public_key(); + let channel = Uuid::new_v4(); + let builder = || Grant::builder(agent, channel, true).unwrap(); + let mut event = builder().sign_with_keys(&owner).unwrap(); + event.content = event.content.replace("true", "false"); + assert!(Grant::parse(&event).is_err()); + for tag in [vec!["h", "wrong"], vec!["p", "wrong"], vec!["d", "wrong"]] { + let event = builder() + .tag(Tag::parse(tag).unwrap()) + .sign_with_keys(&owner) + .unwrap(); + assert!(Grant::parse(&event).is_err()); + } + let own = Grant::builder(owner.public_key(), channel, true) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + assert!(Grant::parse(&own).is_err()); + for body in ["{}", "{\"version\":2}", "null"] { + let event = EventBuilder::new(Kind::Custom(KIND_AGENT_CHANNEL_GRANT as u16), body) + .sign_with_keys(&owner) + .unwrap(); + assert!(Grant::parse(&event).is_err()); + } +} + +#[test] +fn only_a_valid_current_owner_attestation_proves_ownership() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let now = Timestamp::now().as_secs(); + let auth = nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let tag = nip_oa::parse_auth_tag(&auth).unwrap(); + let event = EventBuilder::new(Kind::Metadata, "{}") + .tag(tag.clone()) + .sign_with_keys(&agent) + .unwrap(); + assert_eq!(profile_owner(&event, now).unwrap(), owner.public_key()); + let other = Keys::generate(); + let forged = EventBuilder::new(Kind::Metadata, "{}") + .tag(tag.clone()) + .sign_with_keys(&other) + .unwrap(); + assert!(profile_owner(&forged, now).is_err()); + let expired = + nip_oa::compute_auth_tag(&owner, &agent.public_key(), &format!("created_at<{now}")) + .unwrap(); + let expired = EventBuilder::new(Kind::Metadata, "{}") + .tag(nip_oa::parse_auth_tag(&expired).unwrap()) + .sign_with_keys(&agent) + .unwrap(); + assert!(profile_owner(&expired, now).is_err()); + let human = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&owner) + .unwrap(); + assert!(profile_owner(&human, now).is_err()); +} diff --git a/voltage/channel-agent-sharing.md b/voltage/channel-agent-sharing.md new file mode 100644 index 00000000000..e92051cc904 --- /dev/null +++ b/voltage/channel-agent-sharing.md @@ -0,0 +1,123 @@ +# Channel sharing for remote agents + +Status: implementation in progress. This layer implements signed grants and the owner CLI. +Task admission, wakeup authorization, and harness enforcement must land before enabling sharing. + +An owner can share an agent with selected channels. Channel members can mention that agent and work together in its thread. +Agents can make explicit handoffs to other shared agents. The owner's laptop does not need to remain open. + +This advances the shared workspace intent in [VISION.md](../VISION.md) and the replaceable-body contract in [VISION_REMOTE_AGENTS.md](../VISION_REMOTE_AGENTS.md). +Brokkr remains the default builder and coordinator. Other specialists participate when a task needs them. + +## Current behavior + +The [desktop launcher](../apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs) accepts owner messages and verified sibling-agent mentions. +The [Kubernetes provider](../crates/buzz-backend-kubernetes/src/sandbox.rs) forces owner-only input for each thread Sandbox. +The [harness configuration](../crates/buzz-acp/src/config.rs) rejects other response policies for bound sessions. +Consequently, a sibling mention can start a Sandbox whose harness drops the triggering message. + +The [manager's saved launch path](../crates/buzz-sandbox-reaper/src/mobile_launch.rs) also requires an owner's signed mention. +Opening these gates independently would leave inconsistent startup and delivery behavior. + +## Behavior contract + +| Area | Contract | +| --- | --- | +| Sharing | The owner enables sharing for an agent and specific channels. Existing agents remain private until enabled. | +| Invocation | Current channel members, including verified agent members, can directly mention a shared agent. | +| Access | Shared tasks use the access explicitly granted by the owner through the agent's configured tools. | +| Context | Each conversation stays bound to its community, channel, and thread. Private conversations are not loaded into shared prompts. | +| Ownership | Sharing does not transfer ownership, credentials, configuration, approval rights, or lifecycle control. | +| Availability | A valid mention can wake a sleeping agent through the manager without the owner's desktop. | +| Attribution | The original requester and any handoff agent remain visible. A teammate's request never becomes an owner's signed instruction. | +| Cost | Work consumes the agent owner's existing namespace, session quota, and Spot capacity. | +| Revocation | Removing a sharing grant or channel membership prevents new turns. Failed authorization lookup does not broaden access. | + +The sharing control should state: “Allow members of these channels to run tasks with this agent's configured tool access.” +This grants task invocation, rather than creating a separate tool sandbox for each requester. +Any narrower tool policy must be enforced by the relevant tool or service. +The owner can retain private agents or use a separate agent with team credentials. + +## Authorization and wakeup + +Use signed Nostr events and the existing relay transport. Do not add a feature-specific HTTP endpoint. +Define new event kinds centrally before implementation, if existing kinds cannot express the sharing grant and task provenance. + +The owner publishes a grant identifying the community, agent, channels, and permitted invocation behavior. +Verify ownership through the agent's cryptographic ownership attestation; a display name or claimed owner tag is insufficient. +The owner registers the agent's launch configuration with the manager before sharing becomes available. +Launch credentials stay in the manager's namespaced storage. + +Before starting a Sandbox or admitting a new shared turn, verify: + +1. The original event signature, explicit agent mention, channel tag, and canonical thread binding. +2. The current owner-authorized grant for that agent and channel. +3. Current membership for the requester and the addressed agent in the same community and channel. +4. Handoff provenance and remaining work budget when the requester is an agent. +5. The agent owner's configured namespace, launch registration, quota, and image policy. + +Apply the same policy at startup and in the running harness. +Retain the actual requester alongside the authorizing owner and grant. +Owner-only settings, enrollment, recovery, and lifecycle commands continue to require the owner. +A shared task cannot authorize those commands by quoting an owner or using an ownership tag. + +The registered manager reads mentions using the addressed agent's existing relay identity. +The agent's channel permissions constrain those reads; the manager does not join channels as a separate member. +This reuses [server-side mention startup](../docs/server-mention-launch.md) instead of adding a relay wake-notice protocol. +The manager rechecks the current grant, membership, and signed task provenance before starting shared work. +Backfill and live wake subscriptions overlap, with bounded deduplication and reconnect recovery. +Use bounded registration and subscription sets. Reuse existing session convergence and checkpoint recovery. +A duplicate mention or concurrent start must resolve to one agent-and-thread Sandbox. + +## Bound agent handoffs + +An agent handoff requires an explicit mention and a valid parent task in the same channel and thread. +A conversational reply alone never starts another agent. +Derive handoff depth from verified parent events, rather than trusting a claimed counter. +An agent message or delegated workflow cannot reset the budget by claiming to be a human. + +Start with these limits, subject to validation: + +- Four agent handoffs per human-to-agent task. A human message addressing multiple agents creates separate initial tasks. +- At most two task admissions for one agent under that request. +- Existing per-turn deadlines and five primary sessions per owner. +- Existing worker quotas and 100% Spot capacity. + +Enforce admission budgets across restarts and replicas, with durable task provenance or an atomic shared admission record. +Return a visible refusal when a grant, budget, or capacity check prevents work. +Preserve checkpoints and report partial outcomes when cancellation interrupts an admitted task. + +## Acceptance and rollout + +Exercise the complete workflow with two human owners and their agents in an isolated channel. + +- A teammate can mention a shared agent and receive a response while its owner is offline. +- Two owners' shared agents can perform a bounded handoff and report results in the original thread. +- Private agents, nonmembers, removed members, another community, and unshared channels cannot invoke the agent. +- Forged ownership tags, replayed grants, ambiguous thread tags, and forged handoff counters are rejected. +- Sharing does not expose stored credentials, automatically load private conversations, or transfer lifecycle rights. +- Private-channel wakeup works without adding the manager as a channel member or granting general channel reads. +- Revocation blocks new turns; authorization outages produce retryable refusals. +- Duplicate delivery, reconnect, concurrent starts, and Spot recovery preserve convergence and work budgets. +- A reply loop reaches its limit visibly and cannot reset that limit after a restart. +- Quota exhaustion preserves existing workspaces and gives an actionable response. + +Keep the feature disabled until startup, delivery, provenance, revocation, and handoff budgets pass together. +Verify mixed-version behavior: older components must refuse unsupported sharing, with a visible explanation. +Require agent review, an exercised workflow, and explicit human testing before marking implementation ready. +No IAM expansion, quota increase, or move to on-demand capacity is required. + +## Owner CLI + +Use the human owner's account to change sharing. The relay independently verifies the agent's current ownership attestation. +The owner and agent must already belong to the selected channel. Direct messages cannot be shared. + +```bash +buzz agents share --channel +buzz agents sharing --channel +buzz agents unshare --channel +``` + +Each command uses kind `30180`, scoped by `h` and addressed by `d=:`. +An enabled head grants invocation. A disabled head revokes it. Absence means private. +These events contain no launch configuration, credentials, or private conversation history.