diff --git a/crates/buzz-sdk/src/sharing.rs b/crates/buzz-sdk/src/sharing.rs index 2dc50b16679..5248ee47ac2 100644 --- a/crates/buzz-sdk/src/sharing.rs +++ b/crates/buzz-sdk/src/sharing.rs @@ -10,6 +10,9 @@ use uuid::Uuid; use crate::{kind::KIND_AGENT_CHANNEL_GRANT, nip_oa, SdkError}; +pub mod policy; +pub mod task; + /// One versioned owner opt-in. A disabled head is a durable revocation. #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] diff --git a/crates/buzz-sdk/src/sharing/policy.rs b/crates/buzz-sdk/src/sharing/policy.rs new file mode 100644 index 00000000000..356ff41df3b --- /dev/null +++ b/crates/buzz-sdk/src/sharing/policy.rs @@ -0,0 +1,356 @@ +//! One fresh authorization policy for sleeping-agent startup and running turns. +//! Callers impose an overall deadline and query using the addressed agent's +//! existing authenticated relay identity. No successful verdict is cached. + +use super::{ + profile_owner, + task::{self, Origin, Scope}, + Grant, +}; +use nostr::{Event, EventId, Kind, PublicKey}; +use serde_json::{json, Value}; +use std::future::Future; + +/// A permanent policy refusal or an authorization lookup requiring retry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// No task may run for this request under the current policy. + #[error("shared task refused: {0}")] + Denied(&'static str), + /// Current authorization could not be established. Retain the task for retry. + #[error("shared task authorization unavailable")] + Unavailable, +} + +/// Verified provenance to persist in the manager's atomic admission journal. +#[derive(Debug, Clone, Copy)] +pub struct Task { + /// Immutable conversation scope. + pub scope: Scope, + /// The actual signed requester, never replaced with the authorizing owner. + pub requester: PublicKey, + /// Addressed agent. + pub agent: PublicKey, + /// Target agent's current owner. + pub owner: PublicKey, + /// Original human request and preceding admission. + pub origin: Origin, + /// Original human identity. + pub human: PublicKey, + /// Initial agent's owner; chooses its existing namespace for the journal. + pub origin_owner: PublicKey, + /// The incoming task event. + pub trigger: EventId, + /// Whether the requester is a verified agent. + pub handoff: bool, +} + +/// Load current grants, identities, metadata and membership, then verify task +/// provenance. A caller must atomically admit the returned task before execution. +pub async fn authorize( + mut query: F, + relay: PublicKey, + owner: PublicKey, + agent: PublicKey, + event: &Event, + now: u64, +) -> Result +where + F: FnMut(Value) -> Fut, + Fut: Future, ()>>, +{ + let scope = task::scope(event, agent, now).map_err(|_| Error::Denied("invalid mention"))?; + let stored = one( + &mut query, + json!({"kinds":[9,40002], "ids":[event.id], "#h":[scope.channel], "limit":1}), + ) + .await? + .ok_or(Error::Denied("task is not currently visible on the relay"))?; + if stored != *event { + return Err(Error::Denied("task does not match the signed relay event")); + } + let roster = one( + &mut query, + json!({"kinds":[39002], "authors":[relay], "#d":[scope.channel], "limit":1}), + ) + .await? + .ok_or(Error::Unavailable)?; + let metadata = one( + &mut query, + json!({"kinds":[39000], "authors":[relay], "#d":[scope.channel], "limit":1}), + ) + .await? + .ok_or(Error::Unavailable)?; + for (snapshot, kind) in [(&roster, 39002), (&metadata, 39000)] { + if snapshot.pubkey != relay + || snapshot.kind.as_u16() != kind + || snapshot.verify().is_err() + || task::single_tag(snapshot, "d", 2) + .map_err(|_| Error::Unavailable)? + .is_none_or(|t| t[1] != scope.channel.to_string()) + { + return Err(Error::Unavailable); + } + } + if metadata.tags.iter().any(|t| { + let t = t.as_slice(); + t == ["t", "dm"] + || t == ["archived", "true"] + || t.first().map(String::as_str) == Some("hidden") + }) { + return Err(Error::Denied("conversation cannot be shared")); + } + for member in [owner, agent, event.pubkey] { + member_role(&roster, member)?; + } + let target = profile(&mut query, agent).await?; + if profile_owner(&target, now).ok() != Some(owner) { + return Err(Error::Denied("agent ownership changed")); + } + grant(&mut query, owner, agent, &scope, event, now).await?; + // Channel roles and self-authored profiles can change. Only a direct + // workspace member may originate a human budget; an agent cannot gain a + // fresh budget by stripping its profile credential or rejoining as member. + let humans = one( + &mut query, + json!({"kinds":[crate::kind::KIND_NIP43_MEMBERSHIP_LIST], "authors":[relay], "limit":1}), + ) + .await? + .ok_or(Error::Unavailable)?; + if humans.pubkey != relay + || humans.kind.as_u16() as u32 != crate::kind::KIND_NIP43_MEMBERSHIP_LIST + || humans.verify().is_err() + { + return Err(Error::Unavailable); + } + let requester = profile(&mut query, event.pubkey).await?; + let automated = is_agent(&requester, member_role(&roster, event.pubkey)?, now)?; + let claimed = task::origin(event).map_err(|_| Error::Denied("invalid task ancestry"))?; + let origin = if automated { + claimed.ok_or(Error::Denied("agent handoff requires task ancestry"))? + } else { + workspace_human(&humans, event.pubkey)?; + if claimed.is_some() { + return Err(Error::Denied("human tasks cannot claim agent ancestry")); + } + Origin { + request: event.id, + agent, + parent: event.id, + } + }; + let root = if origin.request == event.id { + event.clone() + } else { + one( + &mut query, + json!({"kinds":[9,40002], "ids":[origin.request], "#h":[scope.channel], "limit":1}), + ) + .await? + .ok_or(Error::Unavailable)? + }; + if root.id != origin.request + || task::scope(&root, origin.agent, now).ok() != Some(scope) + || task::origin(&root) + .map_err(|_| Error::Denied("invalid human request"))? + .is_some() + { + return Err(Error::Denied("request is outside this task")); + } + let human = profile(&mut query, root.pubkey).await?; + if is_agent(&human, member_role(&roster, root.pubkey)?, now)? { + return Err(Error::Denied("an agent cannot reset the human budget")); + } + workspace_human(&humans, root.pubkey)?; + if automated { + ancestry(&mut query, event, &root, origin, scope, now).await?; + } + member_role(&roster, origin.agent)?; + let initial = profile(&mut query, origin.agent).await?; + let origin_owner = + profile_owner(&initial, now).map_err(|_| Error::Denied("invalid initial agent owner"))?; + member_role(&roster, origin_owner)?; + if root.pubkey != origin_owner { + grant(&mut query, origin_owner, origin.agent, &scope, &root, now).await?; + } + Ok(Task { + scope, + requester: event.pubkey, + agent, + owner, + origin, + human: root.pubkey, + origin_owner, + trigger: event.id, + handoff: automated, + }) +} + +// Verify the signed relationship as well as the later manager's durable +// receipt. Four links bound both work and relay queries; a cycle never grows. +async fn ancestry( + query: &mut F, + event: &Event, + root: &Event, + origin: Origin, + scope: Scope, + now: u64, +) -> Result<(), Error> +where + F: FnMut(Value) -> Fut, + Fut: Future, ()>>, +{ + let denied = || Error::Denied("handoff parent does not match this task"); + let mut child = event.clone(); + for _ in 0..4 { + let claimed = task::origin(&child) + .map_err(|_| denied())? + .ok_or_else(denied)?; + if claimed.request != origin.request || claimed.agent != origin.agent { + return Err(denied()); + } + let replies: Vec<_> = child + .tags + .iter() + .map(nostr::Tag::as_slice) + .filter(|t| { + t.first().map(String::as_str) == Some("e") + && t.get(3).map(String::as_str) == Some("reply") + }) + .collect(); + if replies.len() != 1 || replies[0].get(1) != Some(&claimed.parent.to_hex()) { + return Err(denied()); + } + let parent = if claimed.parent == root.id { + root.clone() + } else { + one( + query, + json!({"kinds":[9,40002],"ids":[claimed.parent],"#h":[scope.channel],"limit":1}), + ) + .await? + .ok_or_else(denied)? + }; + if parent.id != claimed.parent + || task::scope(&parent, child.pubkey, now).ok() != Some(scope) + { + return Err(denied()); + } + if parent.id == root.id { + return Ok(()); + } + child = parent; + } + Err(Error::Denied("handoff ancestry exceeds the work budget")) +} + +fn workspace_human(members: &Event, key: PublicKey) -> Result<(), Error> { + let key = key.to_hex(); + if members.tags.iter().any(|t| { + let t = t.as_slice(); + t.len() == 3 + && t[0] == "member" + && t[1] == key + && matches!(t[2].as_str(), "owner" | "admin" | "member") + }) { + Ok(()) + } else { + Err(Error::Denied( + "human task requires a direct workspace member", + )) + } +} + +fn member_role(roster: &Event, key: PublicKey) -> Result<&str, Error> { + let key = key.to_hex(); + let tags: Vec<_> = roster + .tags + .iter() + .map(|t| t.as_slice()) + .filter(|t| t.first().map(String::as_str) == Some("p") && t.get(1) == Some(&key)) + .collect(); + if tags.len() != 1 { + return Err(Error::Denied( + "requester, owner and agent must remain channel members", + )); + } + tags[0].get(3).map(String::as_str).ok_or(Error::Unavailable) +} + +fn is_agent(profile: &Event, role: &str, now: u64) -> Result { + let has_auth = profile + .tags + .iter() + .any(|t| t.as_slice().first().map(String::as_str) == Some("auth")); + if has_auth { + profile_owner(profile, now).map_err(|_| Error::Denied("invalid requester identity"))?; + return Ok(true); + } + if role == "bot" { + return Err(Error::Denied("bot cannot claim human identity")); + } + Ok(false) +} + +async fn profile(query: &mut F, key: PublicKey) -> Result +where + F: FnMut(Value) -> Fut, + Fut: Future, ()>>, +{ + let event = one(query, json!({"kinds":[0],"authors":[key],"limit":1})) + .await? + .ok_or(Error::Unavailable)?; + if event.pubkey != key || event.kind != Kind::Metadata || event.verify().is_err() { + return Err(Error::Unavailable); + } + Ok(event) +} + +async fn grant( + query: &mut F, + owner: PublicKey, + agent: PublicKey, + scope: &Scope, + event: &Event, + now: u64, +) -> Result<(), Error> +where + F: FnMut(Value) -> Fut, + Fut: Future, ()>>, +{ + let head = one( + query, + json!({"kinds":[crate::kind::KIND_AGENT_CHANNEL_GRANT],"authors":[owner], + "#h":[scope.channel],"#d":[Grant::coordinate(agent, scope.channel)],"limit":1}), + ) + .await? + .ok_or(Error::Denied("agent is private in this channel"))?; + let grant = Grant::parse(&head).map_err(|_| Error::Denied("invalid sharing grant"))?; + if head.pubkey != owner + || grant.agent != agent + || grant.channel != scope.channel + || !grant.enabled + || head.created_at.as_secs() > now.saturating_add(60) + || event.created_at < head.created_at + { + return Err(Error::Denied( + "sharing grant is revoked or does not cover this task", + )); + } + Ok(()) +} + +async fn one(query: &mut F, filter: Value) -> Result, Error> +where + F: FnMut(Value) -> Fut, + Fut: Future, ()>>, +{ + let mut events = query(filter).await.map_err(|_| Error::Unavailable)?; + if events.len() > 1 { + return Err(Error::Unavailable); + } + Ok(events.pop()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-sdk/src/sharing/policy/tests.rs b/crates/buzz-sdk/src/sharing/policy/tests.rs new file mode 100644 index 00000000000..b61bb0ace53 --- /dev/null +++ b/crates/buzz-sdk/src/sharing/policy/tests.rs @@ -0,0 +1,462 @@ +use super::*; +use nostr::{EventBuilder, Keys, Tag, Timestamp}; +use std::{ + future::{ready, Future}, + sync::Arc, + task::{Context, Poll, Wake, Waker}, +}; +use uuid::Uuid; + +struct Fixture { + relay: Keys, + owner: Keys, + other: Keys, + agent: Keys, + peer: Keys, + channel: Uuid, + events: Vec, + request: Event, +} +fn signed(keys: &Keys, kind: u16, tags: Vec>, content: &str, time: u64) -> Event { + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) + .custom_created_at(Timestamp::from(time)) + .sign_with_keys(keys) + .unwrap() +} +impl Fixture { + fn new() -> Self { + let (relay, owner, other, agent, peer) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let channel = Uuid::new_v4(); + let mut events = Vec::new(); + for human in [&owner, &other] { + events.push(signed(human, 0, vec![], "{}", 100)); + } + for (human, bot) in [(&owner, &agent), (&other, &peer)] { + let auth = crate::nip_oa::compute_auth_tag(human, &bot.public_key(), "").unwrap(); + events.push( + EventBuilder::new(Kind::Metadata, "{}") + .tag(crate::nip_oa::parse_auth_tag(&auth).unwrap()) + .custom_created_at(Timestamp::from(100)) + .sign_with_keys(bot) + .unwrap(), + ); + events.push( + Grant::builder(bot.public_key(), channel, true) + .unwrap() + .custom_created_at(Timestamp::from(100)) + .sign_with_keys(human) + .unwrap(), + ); + } + let mut members = vec![vec!["d".into(), channel.to_string()]]; + events.push(signed( + &relay, + 13534, + vec![ + vec!["member".into(), owner.public_key().to_hex(), "owner".into()], + vec![ + "member".into(), + other.public_key().to_hex(), + "member".into(), + ], + ], + "", + 100, + )); + for (key, role) in [ + (&owner, "member"), + (&other, "member"), + (&agent, "bot"), + (&peer, "bot"), + ] { + members.push(vec![ + "p".into(), + key.public_key().to_hex(), + "".into(), + role.into(), + ]); + } + events.push(signed(&relay, 39002, members, "", 100)); + events.push(signed( + &relay, + 39000, + vec![ + vec!["d".into(), channel.to_string()], + vec!["t".into(), "stream".into()], + ], + "", + 100, + )); + let request = signed( + &other, + 9, + vec![ + vec!["h".into(), channel.to_string()], + vec!["p".into(), agent.public_key().to_hex()], + ], + "please work with the team", + 101, + ); + events.push(request.clone()); + Self { + relay, + owner, + other, + agent, + peer, + channel, + events, + request, + } + } + fn read(&self, filter: Value) -> Vec { + self.events + .iter() + .filter(|e| { + filter["kinds"] + .as_array() + .unwrap() + .contains(&json!(e.kind.as_u16())) + && filter + .get("authors") + .is_none_or(|a| a.as_array().unwrap().contains(&json!(e.pubkey))) + && filter + .get("ids") + .is_none_or(|a| a.as_array().unwrap().contains(&json!(e.id))) + && ["h", "d"].iter().all(|tag| { + filter.get(format!("#{tag}")).is_none_or(|values| { + e.tags.iter().any(|t| { + t.as_slice().first().map(String::as_str) == Some(tag) + && values.as_array().unwrap().contains(&json!(t.as_slice()[1])) + }) + }) + }) + }) + .cloned() + .collect() + } + fn check(&self, event: &Event, owner: PublicKey, agent: PublicKey) -> Result { + complete(authorize( + |f| ready(Ok(self.read(f))), + self.relay.public_key(), + owner, + agent, + event, + 103, + )) + } +} +fn complete(future: F) -> F::Output { + struct Noop; + impl Wake for Noop { + fn wake(self: Arc) {} + } + let waker = Waker::from(Arc::new(Noop)); + match std::pin::pin!(future).poll(&mut Context::from_waker(&waker)) { + Poll::Ready(result) => result, + Poll::Pending => panic!("fixture futures must complete immediately"), + } +} + +#[test] +fn teammate_tasks_and_cross_owner_handoffs_preserve_actual_requester() { + let mut f = Fixture::new(); + let initial = f + .check(&f.request, f.owner.public_key(), f.agent.public_key()) + .unwrap(); + assert!(!initial.handoff); + assert_eq!(initial.requester, f.other.public_key()); + assert_eq!(initial.human, f.other.public_key()); + let handoff = task::handoff( + &f.request, + f.agent.public_key(), + f.peer.public_key(), + "please review", + 103, + ) + .unwrap() + .custom_created_at(Timestamp::from(102)) + .sign_with_keys(&f.agent) + .unwrap(); + f.events.push(handoff.clone()); + let next = f + .check(&handoff, f.other.public_key(), f.peer.public_key()) + .unwrap(); + assert!(next.handoff); + assert_eq!(next.origin.request, f.request.id); + assert_eq!(next.origin.agent, f.agent.public_key()); + assert_eq!(next.origin.parent, f.request.id); + assert_eq!(next.origin_owner, f.owner.public_key()); + assert_eq!(next.requester, f.agent.public_key()); + assert_eq!(next.human, f.other.public_key()); + assert_eq!(next.scope, initial.scope); + let returned = task::handoff( + &handoff, + f.peer.public_key(), + f.agent.public_key(), + "review result", + 103, + ) + .unwrap() + .custom_created_at(Timestamp::from(103)) + .sign_with_keys(&f.peer) + .unwrap(); + f.events.push(returned.clone()); + let back = f + .check(&returned, f.owner.public_key(), f.agent.public_key()) + .unwrap(); + assert_eq!(back.origin.request, f.request.id); + assert_eq!(back.origin.parent, handoff.id); + assert_eq!(back.requester, f.peer.public_key()); +} + +#[test] +fn every_admission_reads_current_grant_and_membership() { + let mut f = Fixture::new(); + assert!(f + .check(&f.request, f.owner.public_key(), f.agent.public_key()) + .is_ok()); + let old = f + .events + .iter() + .position(|e| e.kind.as_u16() == 30180 && e.pubkey == f.owner.public_key()) + .unwrap(); + let saved = f.events[old].clone(); + f.events[old] = Grant::builder(f.agent.public_key(), f.channel, false) + .unwrap() + .custom_created_at(Timestamp::from(102)) + .sign_with_keys(&f.owner) + .unwrap(); + assert!(matches!( + f.check(&f.request, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied(_)) + )); + f.events[old] = saved; + for removed in [ + f.other.public_key(), + f.agent.public_key(), + f.owner.public_key(), + ] { + let index = f + .events + .iter() + .position(|e| e.kind.as_u16() == 39002) + .unwrap(); + let prior = f.events[index].clone(); + let tags = prior + .tags + .iter() + .filter(|t| t.as_slice().get(1) != Some(&removed.to_hex())) + .map(|t| t.as_slice().to_vec()) + .collect(); + f.events[index] = signed(&f.relay, 39002, tags, "", 102); + assert!(matches!( + f.check(&f.request, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied(_)) + )); + f.events[index] = prior; + } +} + +#[test] +fn handoff_cannot_claim_an_unrelated_parent_or_mismatched_reply() { + let mut f = Fixture::new(); + let unrelated = signed( + &f.other, + 9, + vec![ + vec!["h".into(), f.channel.to_string()], + vec!["p".into(), f.agent.public_key().to_hex()], + vec!["e".into(), f.request.id.to_hex(), "".into(), "root".into()], + ], + "unrelated request in the same thread", + 102, + ); + f.events.push(unrelated.clone()); + let valid = task::handoff( + &f.request, + f.agent.public_key(), + f.peer.public_key(), + "review", + 103, + ) + .unwrap() + .custom_created_at(Timestamp::from(103)) + .sign_with_keys(&f.agent) + .unwrap(); + for change_reply in [false, true] { + let tags = valid + .tags + .iter() + .map(|tag| { + let mut t = tag.as_slice().to_vec(); + if t[0] == "buzz-parent" + || (change_reply + && t[0] == "e" + && t.get(3).map(String::as_str) == Some("reply")) + { + t[1] = unrelated.id.to_hex(); + } + t + }) + .collect(); + let forged = signed(&f.agent, 9, tags, "forged ancestry", 103); + f.events.push(forged.clone()); + assert!(matches!( + f.check(&forged, f.other.public_key(), f.peer.public_key()), + Err(Error::Denied(_)) + )); + } +} + +#[test] +fn absent_foreign_forged_and_unavailable_authorization_never_broaden_access() { + let mut f = Fixture::new(); + let grant = f + .events + .iter() + .position(|e| e.kind.as_u16() == 30180 && e.pubkey == f.owner.public_key()) + .unwrap(); + f.events.remove(grant); + assert!(matches!( + f.check(&f.request, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied(_)) + )); + f.events.push( + Grant::builder(f.agent.public_key(), f.channel, true) + .unwrap() + .sign_with_keys(&f.other) + .unwrap(), + ); + assert!(matches!( + f.check(&f.request, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied(_)) + )); + let unavailable = complete(authorize( + |_| ready(Err(())), + f.relay.public_key(), + f.owner.public_key(), + f.agent.public_key(), + &f.request, + 103, + )); + assert!(matches!(unavailable, Err(Error::Unavailable))); + let other_relay = Keys::generate(); + assert!(matches!( + complete(authorize( + |q| ready(Ok(f.read(q))), + other_relay.public_key(), + f.owner.public_key(), + f.agent.public_key(), + &f.request, + 103 + )), + Err(Error::Unavailable) + )); +} + +#[test] +fn agent_cannot_reset_budget_as_human_or_handoff_without_ancestry() { + let mut f = Fixture::new(); + let ordinary = signed( + &f.peer, + 9, + vec![ + vec!["h".into(), f.channel.to_string()], + vec!["p".into(), f.agent.public_key().to_hex()], + ], + "pretend human", + 102, + ); + f.events.push(ordinary.clone()); + assert!(matches!( + f.check(&ordinary, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied("agent handoff requires task ancestry")) + )); + // Stripping the self-authored profile tag does not remove the relay's bot designation. + let index = f + .events + .iter() + .position(|e| e.kind == Kind::Metadata && e.pubkey == f.peer.public_key()) + .unwrap(); + f.events[index] = signed(&f.peer, 0, vec![], "{}", 102); + assert!(matches!( + f.check(&ordinary, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied("bot cannot claim human identity")) + )); + let index = f + .events + .iter() + .position(|e| e.kind.as_u16() == 39002) + .unwrap(); + let tags = f.events[index] + .tags + .iter() + .map(|t| { + let mut tag = t.as_slice().to_vec(); + if tag.first().map(String::as_str) == Some("p") + && tag.get(1) == Some(&f.peer.public_key().to_hex()) + { + tag[3] = "member".into(); + } + tag + }) + .collect(); + f.events[index] = signed(&f.relay, 39002, tags, "", 102); + assert!(matches!( + f.check(&ordinary, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied( + "human task requires a direct workspace member" + )) + )); +} + +#[test] +fn malformed_scope_dm_and_different_threads_are_denied() { + let f = Fixture::new(); + for tags in [ + vec![vec!["h".into(), f.channel.to_string()]], + vec![ + vec!["h".into(), f.channel.to_string()], + vec!["p".into(), f.agent.public_key().to_hex()], + vec!["e".into(), f.request.id.to_hex()], + ], + vec![ + vec!["h".into(), f.channel.to_string().to_uppercase()], + vec!["p".into(), f.agent.public_key().to_hex()], + ], + ] { + let event = signed(&f.other, 9, tags, "bad scope", 102); + assert!(matches!( + f.check(&event, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied(_)) + )); + } + let mut f = Fixture::new(); + let index = f + .events + .iter() + .position(|e| e.kind.as_u16() == 39000) + .unwrap(); + f.events[index] = signed( + &f.relay, + 39000, + vec![ + vec!["d".into(), f.channel.to_string()], + vec!["t".into(), "dm".into()], + ], + "", + 102, + ); + assert!(matches!( + f.check(&f.request, f.owner.public_key(), f.agent.public_key()), + Err(Error::Denied(_)) + )); +} diff --git a/crates/buzz-sdk/src/sharing/task.rs b/crates/buzz-sdk/src/sharing/task.rs new file mode 100644 index 00000000000..befd70bb727 --- /dev/null +++ b/crates/buzz-sdk/src/sharing/task.rs @@ -0,0 +1,150 @@ +//! Signed task scope and explicit, verifiable handoff ancestry. + +use crate::SdkError; +use nostr::{Event, EventBuilder, EventId, Kind, PublicKey, Tag}; +use uuid::Uuid; + +/// A mention's signed channel and canonical thread binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Scope { + /// The sole canonical channel tag. + pub channel: Uuid, + /// The NIP-10 containing thread, or the message itself for a new thread. + pub root: EventId, +} + +/// Provenance retained across cross-owner agent handoffs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Origin { + /// The original human's signed task message. + pub request: EventId, + /// The agent that human initially addressed. + pub agent: PublicKey, + /// The incoming task the handing-off agent is processing. + pub parent: EventId, +} + +fn invalid() -> SdkError { + SdkError::InvalidInput("invalid shared task scope or provenance".into()) +} + +/// Read exactly one named tag, rejecting duplicates and malformed arity. +pub fn single_tag<'a>( + event: &'a Event, + name: &str, + arity: usize, +) -> Result, SdkError> { + let mut tags = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(name)); + let first = tags.next().map(Tag::as_slice); + if tags.next().is_some() || first.is_some_and(|tag| tag.len() != arity) { + return Err(invalid()); + } + Ok(first) +} + +/// Verify an explicit actionable mention and derive its immutable scope. +pub fn scope(event: &Event, agent: PublicKey, now: u64) -> Result { + if event.verify().is_err() + || !matches!(event.kind.as_u16(), 9 | 40002) + || event.pubkey == agent + || event.created_at.as_secs() > now.saturating_add(60) + { + return Err(invalid()); + } + let channel = single_tag(event, "h", 2)?.ok_or_else(invalid)?; + let parsed = Uuid::parse_str(&channel[1]).map_err(|_| invalid())?; + if parsed.to_string() != channel[1] { + return Err(invalid()); + } + let addressed = event + .tags + .iter() + .filter(|tag| { + let p = tag.as_slice(); + p.first().map(String::as_str) == Some("p") + && p.get(1).map(String::as_str) == Some(&agent.to_hex()) + }) + .count(); + if addressed != 1 { + return Err(invalid()); + } + let mut root = None; + let mut reply = None; + for tag in event + .tags + .iter() + .map(Tag::as_slice) + .filter(|t| t.first().map(String::as_str) == Some("e")) + { + let slot = match tag.get(3).map(String::as_str) { + Some("root") => &mut root, + Some("reply") => &mut reply, + Some("mention") => continue, + _ => return Err(invalid()), + }; + let id = EventId::from_hex(tag.get(1).ok_or_else(invalid)?).map_err(|_| invalid())?; + if id.to_hex() != tag[1] || slot.replace(id).is_some() { + return Err(invalid()); + } + } + Ok(Scope { + channel: parsed, + root: root.or(reply).unwrap_or(event.id), + }) +} + +/// Decode the paired task/parent tags. Claimed ancestry never grants access by itself. +pub fn origin(event: &Event) -> Result, SdkError> { + let task = single_tag(event, "buzz-task", 3)?; + let parent = single_tag(event, "buzz-parent", 2)?; + match (task, parent) { + (None, None) => Ok(None), + (Some(task), Some(parent)) => Ok(Some(Origin { + request: EventId::from_hex(&task[1]).map_err(|_| invalid())?, + agent: PublicKey::from_hex(&task[2]).map_err(|_| invalid())?, + parent: EventId::from_hex(&parent[1]).map_err(|_| invalid())?, + })), + _ => Err(invalid()), + } +} + +/// Build an explicit handoff from an admitted incoming message. The manager +/// independently checks the parent's durable admission and remaining budget. +pub fn handoff( + source: &Event, + sender: PublicKey, + target: PublicKey, + text: &str, + now: u64, +) -> Result { + let scope = scope(source, sender, now)?; + if sender == target || text.trim().is_empty() || text.len() > 60_000 { + return Err(invalid()); + } + let prior = origin(source)?; + let origin = prior.unwrap_or(Origin { + request: source.id, + agent: sender, + parent: source.id, + }); + let tags = [ + vec!["h".into(), scope.channel.to_string()], + vec!["p".into(), target.to_hex()], + vec!["e".into(), scope.root.to_hex(), "".into(), "root".into()], + vec!["e".into(), source.id.to_hex(), "".into(), "reply".into()], + vec![ + "buzz-task".into(), + origin.request.to_hex(), + origin.agent.to_hex(), + ], + vec!["buzz-parent".into(), source.id.to_hex()], + ] + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|_| invalid())?; + Ok(EventBuilder::new(Kind::Custom(9), text).tags(tags)) +} diff --git a/crates/buzz-session-control/src/lib.rs b/crates/buzz-session-control/src/lib.rs index f7c6a94ee9b..1d0c55d16b2 100644 --- a/crates/buzz-session-control/src/lib.rs +++ b/crates/buzz-session-control/src/lib.rs @@ -56,6 +56,9 @@ pub enum Op { Profile, /// Start an already registered agent for an owner-signed channel mention. Start, + /// Admit one shared task. Signed by the addressed agent; never authorizes + /// provider lifecycle operations or exposes registered launch credentials. + Admit, } impl Op { @@ -68,6 +71,7 @@ impl Op { Op::Enroll => "enroll", Op::Profile => "profile", Op::Start => "start", + Op::Admit => "admit", } } @@ -79,6 +83,7 @@ impl Op { Op::Enroll, Op::Profile, Op::Start, + Op::Admit, ] .into_iter() .find(|op| op.as_str() == value) diff --git a/crates/buzz-session-control/src/tests.rs b/crates/buzz-session-control/src/tests.rs index 2d1f8f3dbcf..592d87046e1 100644 --- a/crates/buzz-session-control/src/tests.rs +++ b/crates/buzz-session-control/src/tests.rs @@ -177,6 +177,7 @@ fn envelopes_round_trip_for_every_op_and_status() { Op::Enroll, Op::Profile, Op::Start, + Op::Admit, ] { let request = Request::new(op, "r-1", json!({"op": op.as_str()})); let owner_signed = matches!(op, Op::Enroll | Op::Profile | Op::Start);