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
59 changes: 59 additions & 0 deletions crates/buzz-cli/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<nostr::Event> = 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<PublicKey, CliError> {
Expand Down
68 changes: 68 additions & 0 deletions crates/buzz-cli/src/commands/agents_sharing_tests.rs
Original file line number Diff line number Diff line change
@@ -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::<Event>::new()));
let app = Router::new()
.route("/query", post(move |Json(filter): Json<Vec<serde_json::Value>>| {
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<Arc<Mutex<Vec<Event>>>>, Json(event): Json<Event>| 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();
}
34 changes: 32 additions & 2 deletions crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
/// Open a prefilled create-agent form in the owner's Buzz Desktop
DraftCreate {
/// Current channel UUID; the new agent is added here after save
Expand Down Expand Up @@ -2436,7 +2463,10 @@ mod tests {
"archived",
"draft-create",
"draft-update",
"unarchive"
"share",
"sharing",
"unarchive",
"unshare"
]
);
assert_eq!(
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 6 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<agent hex>:<channel UUID>`. 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)`
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading