spectra agent allow: let one agent post in another project's channel 87f601ccce6d
An owner-signed agent.scoped event grants (or takes back) channel:NAME; an agent refused a channel is told the command. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 4e19551bb779
- When
- · 2026-11-03 19:16 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
87f601ccce6dc4d2c27ae62b65421be7b3ad7ed33651dbc124dc75abea63e99d- Depends on
- 09752034b5a8 258bb3824813 36537112fcac 571572f5fb2c 5eb57578f4ef 8d803d858c8c 98a0fb9f0948 b044d90930d9
- Depended on by
- 1876f2ce71e4 fe6b29c66cd4
- In bookmarks
- main
- Edits
- 16 atoms, 1 tree operation, 7 files
Browse the tree at this change
added tests/allow.rs +60 −0
| 1 | + | //! `spectra agent allow` (spec §8.4): the owner lets one agent post in |
|
| 2 | + | //! another project's channel, and can take it back; an agent cannot grant |
|
| 3 | + | //! itself anything. |
|
| 4 | + | ||
| 5 | + | mod common; |
|
| 6 | + | ||
| 7 | + | use common::{run, setup, text, Serving}; |
|
| 8 | + | use serde_json::{json, Value}; |
|
| 9 | + | use spectra_core::keys::KeyPair; |
|
| 10 | + | use spectra_mcp::client::{rpc_result, Client}; |
|
| 11 | + | ||
| 12 | + | async fn say(c: &Client, channel: &str) -> Value { |
|
| 13 | + | let req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": format!("hello {channel}"), "channel": channel } } }); |
|
| 14 | + | rpc_result(&c.mcp("2025-11-25", &req).await.unwrap()).unwrap()["result"].clone() |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | #[tokio::test] |
|
| 18 | + | async fn the_owner_opens_one_channel_to_one_agent() { |
|
| 19 | + | let dir = setup(); |
|
| 20 | + | let d = dir.path(); |
|
| 21 | + | assert!(run(d, &["project", "add", "bubblebobble"]).status.success()); |
|
| 22 | + | let home = d.join("agents/claude-mg"); |
|
| 23 | + | let o = |
|
| 24 | + | run(d, &["agent", "add", "claude-mg", "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--yes"]); |
|
| 25 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 26 | + | let srv = Serving::start(d); |
|
| 27 | + | let c = Client::new(&srv.socket, Some(KeyPair::load_home(&home).unwrap())); |
|
| 28 | + | ||
| 29 | + | let r = say(&c, "bubblebobble").await; |
|
| 30 | + | assert_eq!(r["isError"], true); |
|
| 31 | + | assert!(r["content"][0]["text"].as_str().unwrap().contains("spectra agent allow NAME --channel bubblebobble"), "{r}"); |
|
| 32 | + | ||
| 33 | + | let o = run(d, &["agent", "allow", "claude-mg", "--channel", "#bubblebobble"]); |
|
| 34 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 35 | + | assert_eq!(say(&c, "bubblebobble").await["isError"], false, "allowed"); |
|
| 36 | + | assert_eq!(say(&c, "oxygen").await["isError"], false, "its own channel still"); |
|
| 37 | + | ||
| 38 | + | assert!(run(d, &["agent", "allow", "claude-mg", "--channel", "bubblebobble", "--remove"]).status.success()); |
|
| 39 | + | assert_eq!(say(&c, "bubblebobble").await["isError"], true, "taken back"); |
|
| 40 | + | assert!(!run(d, &["agent", "allow", "claude-mg", "--channel", "Not A Channel"]).status.success()); |
|
| 41 | + | ||
| 42 | + | // An agent cannot grant itself: its key signing agent.scoped fails verify. |
|
| 43 | + | let me = KeyPair::load_home(&home).unwrap(); |
|
| 44 | + | let forged = spectra_core::principals::signed_event( |
|
| 45 | + | &me, |
|
| 46 | + | "agent:claude-mg", |
|
| 47 | + | "agent.scoped", |
|
| 48 | + | json!({ "name": "claude-mg", "scope": "projects:all" }), |
|
| 49 | + | ); |
|
| 50 | + | let w = spectra_core::daemon::WireEvent::from_new(&forged); |
|
| 51 | + | let r = c.send("POST", "/api/v1/ledger/events", &[], serde_json::to_vec(&vec![w]).unwrap()).await.unwrap(); |
|
| 52 | + | assert_eq!(r.status, 403, "{}", String::from_utf8_lossy(&r.body)); |
|
| 53 | + | assert_eq!(say(&c, "bubblebobble").await["isError"], true); |
|
| 54 | + | srv.stop(); |
|
| 55 | + | ||
| 56 | + | for args in [["admin", "verify"], ["admin", "check"], ["admin", "rebuild"]] { |
|
| 57 | + | let o = run(d, &args); |
|
| 58 | + | assert!(o.status.success(), "{args:?}: {}", text(&o)); |
|
| 59 | + | } |
|
| 60 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: `spectra agent allow NAME --channel CHANNEL [--remove]`.** The owner lets one agent post in another project's channel, and can take it back; it is a signed `agent.scoped` event that no agent can make for itself. An agent refused a channel is told this command. |
|
| 5 | 6 | - **Added: `spectra mcp --auto [--kind KIND]`, one MCP entry for every folder.** The shim speaks for the agent whose project folder the client started in, at any depth inside it, so a client needs Spectra added once (`claude mcp add --scope user spectra -- spectra mcp --auto`) rather than once per folder. Each folder still signs as its own agent. `spectra agent add` labels the agent's home with an `agent.toml` for this, and older homes are labelled on first use. A folder with no agent gets a quiet server whose instructions say how to add one, instead of an error. |
|
| 6 | 7 | - **Added: `spectra open --page constellation`** (or `briefs`, `channels`, `log`) lands on that page once signed in; only Radar's own pages are accepted. |
|
| 7 | 8 | - **Added: `spectra status`, `spectra probe` and `spectra bridge` (#31).** `status [--json]` says what is set up here: your identity, and the Spectra you use and whether it answers. `probe URL [--json]` says what a server is (its name, instance key and owner, or the identity it waits for) without trusting it. `bridge` serves a loopback address that signs requests as you, for a program that cannot sign itself: it prints its address and a one-run token, refuses anything without the token or from a web page, passes streams through, and ends when its input closes. `identity create --json` prints the new key. This is how the VS Code extension sets Spectra up and talks to it. |
|
| … | 31 unchanged lines |
modified crates/spectra-core/src/conversation.rs +7 −2
| … | 173 unchanged lines |
||
| 174 | 174 | (Principal::Owner { .. }, _) => Ok(()), |
|
| 175 | 175 | (_, "direct") if !ch.members().contains(&who.actor()) => Err(format!("{} is a direct channel you are not in", ch.display())), |
|
| 176 | 176 | (Principal::Agent { project, scopes, .. }, "project") => { |
|
| 177 | - | if ch.project.as_deref() == Some(project) || scopes.iter().any(|s| s == "projects:all") { |
|
| 177 | + | let granted = format!("channel:{}", ch.name); |
|
| 178 | + | if ch.project.as_deref() == Some(project) || scopes.iter().any(|s| s == "projects:all" || *s == granted) { |
|
| 178 | 179 | Ok(()) |
|
| 179 | 180 | } else { |
|
| 180 | - | Err(format!("{} belongs to another project; post in #{project}, #general, or ask for projects:all", ch.display())) |
|
| 181 | + | Err(format!( |
|
| 182 | + | "{} belongs to another project; post in #{project} or #general, mention its agents from there, or ask the owner to run `spectra agent allow NAME --channel {}`", |
|
| 183 | + | ch.display(), |
|
| 184 | + | ch.name |
|
| 185 | + | )) |
|
| 181 | 186 | } |
|
| 182 | 187 | } |
|
| 183 | 188 | _ => Ok(()), |
|
| … | 586 unchanged lines |
modified crates/spectra-core/src/daemon.rs +10 −2
| … | 17 unchanged lines |
||
| 18 | 18 | ||
| 19 | 19 | /// Kinds the owner's CLI may hand the daemon, already signed over their |
|
| 20 | 20 | /// payload (spec §8.5, "One writer"). |
|
| 21 | - | pub const OWNER_KINDS: &[&str] = |
|
| 22 | - | &["identity.rotated", "agent.added", "agent.disabled", "project.added", "project.linked", "project.unlinked", "brief.set"]; |
|
| 21 | + | pub const OWNER_KINDS: &[&str] = &[ |
|
| 22 | + | "identity.rotated", |
|
| 23 | + | "agent.added", |
|
| 24 | + | "agent.disabled", |
|
| 25 | + | "agent.scoped", |
|
| 26 | + | "project.added", |
|
| 27 | + | "project.linked", |
|
| 28 | + | "project.unlinked", |
|
| 29 | + | "brief.set", |
|
| 30 | + | ]; |
|
| 23 | 31 | ||
| 24 | 32 | pub struct Daemon { |
|
| 25 | 33 | pub data: PathBuf, |
|
| … | 294 unchanged lines |
modified crates/spectra-core/src/principals.rs +54 −2
| … | 82 unchanged lines |
||
| 83 | 83 | let mut from = 1; |
|
| 84 | 84 | loop { |
|
| 85 | 85 | let rows = ledger |
|
| 86 | - | .events_of_kinds(from, &["identity.owner", "identity.rotated", "identity.instance", "agent.added", "agent.disabled"], 1000) |
|
| 86 | + | .events_of_kinds( |
|
| 87 | + | from, |
|
| 88 | + | &["identity.owner", "identity.rotated", "identity.instance", "agent.added", "agent.disabled", "agent.scoped"], |
|
| 89 | + | 1000, |
|
| 90 | + | ) |
|
| 87 | 91 | .await?; |
|
| 88 | 92 | if rows.is_empty() { |
|
| 89 | 93 | break; |
|
| … | 137 unchanged lines |
||
| 227 | 231 | a.disabled = true; |
|
| 228 | 232 | } |
|
| 229 | 233 | } |
|
| 234 | + | // The owner grants or takes back one scope (spec §8.4), such |
|
| 235 | + | // as `channel:NAME` to post in another project's channel. |
|
| 236 | + | "agent.scoped" => { |
|
| 237 | + | self.owner_signed(e)?; |
|
| 238 | + | let name = text(&p, "name")?; |
|
| 239 | + | let scope = text(&p, "scope")?; |
|
| 240 | + | if !grantable(&scope) { |
|
| 241 | + | return Err(format!("{scope} is not a scope an agent may be given")); |
|
| 242 | + | } |
|
| 243 | + | let grant = p.get("grant").and_then(Json::as_bool).unwrap_or(true); |
|
| 244 | + | let key = *self.by_name.get(&name).ok_or("an unknown agent")?; |
|
| 245 | + | if let Some(a) = self.agents.get_mut(&key) { |
|
| 246 | + | a.scopes.retain(|s| s != &scope); |
|
| 247 | + | if grant { |
|
| 248 | + | a.scopes.push(scope); |
|
| 249 | + | } |
|
| 250 | + | } |
|
| 251 | + | } |
|
| 230 | 252 | _ => {} |
|
| 231 | 253 | } |
|
| 232 | 254 | Ok(()) |
|
| … | 43 unchanged lines |
||
| 276 | 298 | impl KeyResolver for Principals { |
|
| 277 | 299 | fn check(&mut self, e: &Event) -> Result<(), String> { |
|
| 278 | 300 | match e.kind.as_str() { |
|
| 279 | - | "identity.owner" | "identity.rotated" | "identity.instance" | "agent.added" | "agent.disabled" => { |
|
| 301 | + | "identity.owner" | "identity.rotated" | "identity.instance" | "agent.added" | "agent.disabled" | "agent.scoped" => { |
|
| 280 | 302 | if e.auth.method != Method::Signature { |
|
| 281 | 303 | return Err(format!("{} must be signed", e.kind)); |
|
| 282 | 304 | } |
|
| … | 80 unchanged lines |
||
| 363 | 385 | sqlx::query("UPDATE agents SET disabled_ms = ? WHERE name = ?") |
|
| 364 | 386 | .bind(e.at_ms as i64) |
|
| 365 | 387 | .bind(p.get("name").and_then(Json::as_str)) |
|
| 388 | + | .execute(&mut *conn) |
|
| 389 | + | .await?; |
|
| 390 | + | } |
|
| 391 | + | "agent.scoped" if e.actor == "human" => { |
|
| 392 | + | let (Some(name), Some(scope)) = (p.get("name").and_then(Json::as_str), p.get("scope").and_then(Json::as_str)) else { |
|
| 393 | + | return Ok(()); |
|
| 394 | + | }; |
|
| 395 | + | if !grantable(scope) { |
|
| 396 | + | return Ok(()); |
|
| 397 | + | } |
|
| 398 | + | let grant = p.get("grant").and_then(Json::as_bool).unwrap_or(true); |
|
| 399 | + | let current: Option<String> = |
|
| 400 | + | sqlx::query_scalar("SELECT scopes FROM agents WHERE name = ?").bind(name).fetch_optional(&mut *conn).await?; |
|
| 401 | + | let mut scopes: Vec<String> = current.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); |
|
| 402 | + | scopes.retain(|s| s != scope); |
|
| 403 | + | if grant { |
|
| 404 | + | scopes.push(scope.to_string()); |
|
| 405 | + | } |
|
| 406 | + | sqlx::query("UPDATE agents SET scopes = ? WHERE name = ?") |
|
| 407 | + | .bind(serde_json::to_string(&scopes).unwrap_or_default()) |
|
| 408 | + | .bind(name) |
|
| 366 | 409 | .execute(&mut *conn) |
|
| 367 | 410 | .await?; |
|
| 368 | 411 | } |
|
| 369 | 412 | _ => {} |
|
| 370 | 413 | } |
|
| 371 | 414 | Ok(()) |
|
| 415 | + | } |
|
| 416 | + | ||
| 417 | + | /// A scope the owner may give an agent (spec §8.4): never the owner's own |
|
| 418 | + | /// authority, only reach. |
|
| 419 | + | pub fn grantable(scope: &str) -> bool { |
|
| 420 | + | match scope.strip_prefix("channel:") { |
|
| 421 | + | Some(ch) => !ch.is_empty() && ch.len() <= 64 && ch.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'), |
|
| 422 | + | None => matches!(scope, "projects:all" | "schedule:any" | "webhooks:read"), |
|
| 423 | + | } |
|
| 372 | 424 | } |
|
| 373 | 425 | ||
| 374 | 426 | /// The payload of an identity event for `identity`. |
|
| … | 65 unchanged lines |
modified src/cli/agent.rs +23 −0
| … | 285 unchanged lines |
||
| 286 | 286 | }) |
|
| 287 | 287 | } |
|
| 288 | 288 | ||
| 289 | + | /// `spectra agent allow NAME --channel CHANNEL [--remove]`: the owner lets |
|
| 290 | + | /// an agent post in another project's channel (spec §8.4). |
|
| 291 | + | pub async fn allow(ctx: &Ctx, name: &str, channel: &str, remove: bool, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 292 | + | let channel = channel.trim_start_matches('#'); |
|
| 293 | + | let scope = format!("channel:{channel}"); |
|
| 294 | + | if !spectra_core::principals::grantable(&scope) { |
|
| 295 | + | bail!("{channel} is not a channel name"); |
|
| 296 | + | } |
|
| 297 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 298 | + | let w = ctx.owner_writer(&home).await?; |
|
| 299 | + | agent_key(&w, name).await?.with_context(|| format!("no agent named {name}"))?; |
|
| 300 | + | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| 301 | + | let owner = KeyPair::load_home(&home)?; |
|
| 302 | + | events.push(signed_event(&owner, "human", "agent.scoped", serde_json::json!({ "name": name, "scope": scope, "grant": !remove }))); |
|
| 303 | + | w.append(events).await?; |
|
| 304 | + | if remove { |
|
| 305 | + | println!("{} {} {}", ui::done("Removed"), ui::subject(name), ui::dim(format!("may no longer post in #{channel}"))); |
|
| 306 | + | } else { |
|
| 307 | + | println!("{} {} {}", ui::done("Allowed"), ui::subject(name), ui::dim(format!("may post in #{channel}"))); |
|
| 308 | + | } |
|
| 309 | + | Ok(0) |
|
| 310 | + | } |
|
| 311 | + | ||
| 289 | 312 | /// `spectra agent renew NAME --expires 8h`: the owner's successor with a |
|
| 290 | 313 | /// new expiry for the agent's key, recorded. |
|
| 291 | 314 | pub async fn renew(ctx: &Ctx, name: &str, expires: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| … | 18 unchanged lines |
modified src/cli/mod.rs +13 −0
| … | 345 unchanged lines |
||
| 346 | 346 | #[arg(long)] |
|
| 347 | 347 | owner_home: Option<PathBuf>, |
|
| 348 | 348 | }, |
|
| 349 | + | /// Let an agent post in another project's channel (or take it back) |
|
| 350 | + | Allow { |
|
| 351 | + | name: String, |
|
| 352 | + | /// The channel, without the # |
|
| 353 | + | #[arg(long)] |
|
| 354 | + | channel: String, |
|
| 355 | + | /// Take the permission back |
|
| 356 | + | #[arg(long)] |
|
| 357 | + | remove: bool, |
|
| 358 | + | #[arg(long)] |
|
| 359 | + | owner_home: Option<PathBuf>, |
|
| 360 | + | }, |
|
| 349 | 361 | /// Give an agent more time: delegate to its key again with a new expiry |
|
| 350 | 362 | Renew { |
|
| 351 | 363 | name: String, |
|
| … | 317 unchanged lines |
||
| 669 | 681 | AgentCmd::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home } => { |
|
| 670 | 682 | agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await |
|
| 671 | 683 | } |
|
| 684 | + | AgentCmd::Allow { name, channel, remove, owner_home } => agent::allow(&ctx, &name, &channel, remove, owner_home).await, |
|
| 672 | 685 | AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, owner_home).await, |
|
| 673 | 686 | AgentCmd::List { json } => agent::list(&ctx, json).await, |
|
| 674 | 687 | AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await, |
|
| … | 28 unchanged lines |