OXYGENHUB

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