OXYGENHUB

Identities instead of tokens: init, owned agents as delegates, verified replay of identities, request authentication, one writer (#4) 09752034b5a8

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b0ec18e9744c
When
· 2026-11-02 22:01 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
09752034b5a89aae173923eeb219013978138a77fbe29c0d491599c95942c270
Depends on
258bb3824813 36537112fcac 8dc6df8c4d4e
Depended on by
87f601ccce6d 8d803d858c8c c7161ec1a607 b044d90930d9 98a0fb9f0948 813b6330f3ad 67bfa06a894f eca5af92279b 77df591d5a2f b576d05cf862 d17cbc7069ce da84e98f104e 39f5c913f617 037c45d8093c 571572f5fb2c 5eb57578f4ef 2fc88a7b3a5c 5d695d869fbf
In bookmarks
main
In tags
v0.1.0
Edits
61 atoms, 8 tree operations, 20 files

Browse the tree at this change

added crates/spectra-core/migrations/0002_principals.sql +27 −0
1 +
-- Projections of who is who (spec §8): identities the ledger knows, by
2 +
-- role, and agents with their owner and delegation.
3 +
CREATE TABLE identities (
4 +
    key       TEXT PRIMARY KEY,           -- KeyId, hex
5 +
    role      TEXT NOT NULL,              -- owner | instance | agent
6 +
    name      TEXT NOT NULL,
7 +
    identity  BLOB NOT NULL,              -- the encoded identity object
8 +
    previous  TEXT,                       -- the identity it succeeds
9 +
    current   INTEGER NOT NULL DEFAULT 1, -- 0 once succeeded
10 +
    seq       INTEGER NOT NULL
11 +
);
12 +
13 +
CREATE TABLE agents (
14 +
    name         TEXT PRIMARY KEY,
15 +
    key          TEXT NOT NULL UNIQUE,
16 +
    owner        TEXT NOT NULL,           -- the owner's KeyId when added
17 +
    owner_name   TEXT NOT NULL,
18 +
    kind         TEXT NOT NULL,
19 +
    project      TEXT NOT NULL,
20 +
    home         TEXT NOT NULL,
21 +
    uid          INTEGER,
22 +
    learn_uid    INTEGER NOT NULL DEFAULT 0,
23 +
    scopes       TEXT NOT NULL,
24 +
    created_ms   INTEGER NOT NULL,
25 +
    disabled_ms  INTEGER,
26 +
    seq          INTEGER NOT NULL
27 +
);
added crates/spectra-core/src/authn.rs +92 −0
1 +
//! Authenticating a request (spec §8.5): a signature by a known key over the
2 +
//! method, the body, a time within five minutes and a nonce not seen in
3 +
//! that window, from a key the owner delegates to now, and, when the agent
4 +
//! is bound to a Unix user, from that user. What it returns becomes the
5 +
//! `auth` of every event the request causes.
6 +
7 +
use crate::event::{Auth, Method};
8 +
use crate::principals::{Principal, Principals};
9 +
use crate::proof::{Proof, WINDOW_MS};
10 +
use std::collections::HashMap;
11 +
use std::sync::Mutex;
12 +
13 +
/// Why a request was refused.
14 +
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
15 +
pub enum Refusal {
16 +
    #[error("the request is not signed")]
17 +
    Unsigned,
18 +
    #[error("the signature is for {0}, not this request")]
19 +
    WrongMethod(String),
20 +
    #[error("the request's time is {0} s from the server's: check the clock")]
21 +
    Stale(i64),
22 +
    #[error("the request's nonce was used before: a replay")]
23 +
    Replay,
24 +
    #[error("{0}")]
25 +
    NotAPrincipal(String),
26 +
    #[error("{0}")]
27 +
    BadSignature(String),
28 +
    /// The key is bound to another Unix user (logged as `guard.impersonation`).
29 +
    #[error("agent {agent}'s key is bound to uid {bound}, used from uid {from}")]
30 +
    WrongUid { agent: String, bound: u32, from: u32 },
31 +
}
32 +
33 +
/// The outcome: who, and the `auth` for their events.
34 +
#[derive(Clone, Debug)]
35 +
pub struct Caller {
36 +
    pub principal: Principal,
37 +
    pub auth: Auth,
38 +
    /// The agent asked for its uid to be learnt and has none yet: the
39 +
    /// caller records `agent.uid_bound` with this uid.
40 +
    pub learn_uid: Option<u32>,
41 +
}
42 +
43 +
/// Remembers nonces for the window.
44 +
#[derive(Debug, Default)]
45 +
pub struct Authenticator {
46 +
    nonces: Mutex<HashMap<[u8; 16], u64>>,
47 +
}
48 +
49 +
impl Authenticator {
50 +
    pub fn authenticate(
51 +
        &self,
52 +
        principals: &Principals,
53 +
        proof: Option<&Proof>,
54 +
        method: &str,
55 +
        body: &[u8],
56 +
        now_ms: u64,
57 +
        peer_uid: Option<u32>,
58 +
    ) -> Result<Caller, Refusal> {
59 +
        let proof = proof.ok_or(Refusal::Unsigned)?;
60 +
        if proof.method != method {
61 +
            return Err(Refusal::WrongMethod(proof.method.clone()));
62 +
        }
63 +
        let skew = proof.time_ms as i64 - now_ms as i64;
64 +
        if skew.unsigned_abs() > WINDOW_MS {
65 +
            return Err(Refusal::Stale(skew / 1000));
66 +
        }
67 +
        let key = oxy_core::hash::ObjectRef(proof.key());
68 +
        let principal = principals.principal(&key, now_ms).map_err(Refusal::NotAPrincipal)?;
69 +
        let identity = principals.identity(&key).ok_or_else(|| Refusal::NotAPrincipal("an unknown key".into()))?;
70 +
        proof.verify(identity, Some(body)).map_err(Refusal::BadSignature)?;
71 +
        let mut learn_uid = None;
72 +
        if let Principal::Agent { name, uid, learn_uid: learn, .. } = &principal {
73 +
            match (uid, peer_uid) {
74 +
                (Some(bound), Some(from)) if *bound != from => return Err(Refusal::WrongUid { agent: name.clone(), bound: *bound, from }),
75 +
                (None, Some(from)) if *learn => learn_uid = Some(from),
76 +
                _ => {}
77 +
            }
78 +
        }
79 +
        {
80 +
            let mut seen = self.nonces.lock().unwrap();
81 +
            seen.retain(|_, t| now_ms.saturating_sub(*t) <= 2 * WINDOW_MS);
82 +
            if seen.insert(proof.nonce, now_ms).is_some() {
83 +
                return Err(Refusal::Replay);
84 +
            }
85 +
        }
86 +
        Ok(Caller {
87 +
            principal,
88 +
            auth: Auth { key: Some(proof.key()), method: Method::Signature, assurance: 2, proof: Some(proof.encode()) },
89 +
            learn_uid,
90 +
        })
91 +
    }
92 +
}
added crates/spectra-core/src/principals.rs +396 −0
1 +
//! Who is who (spec §8): the owner, the instance, and agents, each an Oxygen
2 +
//! identity; an agent is valid while the owner's current identity delegates
3 +
//! to its key (oxygen spec §14.2). Everything here is derived from the
4 +
//! ledger, and every claim in it was proven when it was recorded:
5 +
//!
6 +
//! - `identity.owner` and `identity.rotated` are signed by the identity they
7 +
//!   record, so only the holder of its secret key can name it the owner.
8 +
//!   A successor must keep the key pair and chain back, by `previous`, to
9 +
//!   the owner already known; a fresh key pair needs `oxy identity rotate`'s
10 +
//!   attestation, which 0.1 does not accept yet.
11 +
//! - `identity.instance` is signed by the instance key.
12 +
//! - `agent.added` and `agent.disabled` are signed by the owner, and an
13 +
//!   agent is added only while the owner's identity delegates to it.
14 +
//!
15 +
//! [`Principals`] replays these in ledger order, so `spectra admin verify`
16 +
//! checks every signed event against who was who at that moment.
17 +
18 +
use crate::event::{Event, Method};
19 +
use crate::ledger::KeyResolver;
20 +
use crate::proof::Proof;
21 +
use oxy_core::hash::KeyId;
22 +
use oxy_core::identity::{Identity, Scope};
23 +
use serde_json::Value as Json;
24 +
use sqlx::SqliteConnection;
25 +
use std::collections::HashMap;
26 +
27 +
/// The scopes a new agent gets (spec §8.4).
28 +
pub const DEFAULT_SCOPES: &[&str] = &["read", "say", "tasks", "claims", "decisions", "status", "search", "schedule:self"];
29 +
30 +
/// The Oxygen delegation scope an agent's key needs from its owner.
31 +
pub const DELEGATION_SCOPE: Scope = Scope::CHANGES;
32 +
33 +
/// Who caused something.
34 +
#[derive(Clone, Debug, PartialEq, Eq)]
35 +
pub enum Principal {
36 +
    Owner { key: KeyId, name: String },
37 +
    Agent { name: String, key: KeyId, owner_name: String, uid: Option<u32>, learn_uid: bool, project: String, scopes: Vec<String> },
38 +
    Instance { key: KeyId },
39 +
}
40 +
41 +
impl Principal {
42 +
    /// The `actor` events carry: `human`, `agent:NAME` or `system`.
43 +
    pub fn actor(&self) -> String {
44 +
        match self {
45 +
            Principal::Owner { .. } => "human".into(),
46 +
            Principal::Agent { name, .. } => format!("agent:{name}"),
47 +
            Principal::Instance { .. } => "system".into(),
48 +
        }
49 +
    }
50 +
    /// How it is shown to people: `claude-oxygen · Andy McCall's agent`.
51 +
    pub fn display(&self) -> String {
52 +
        match self {
53 +
            Principal::Owner { name, .. } => name.clone(),
54 +
            Principal::Agent { name, owner_name, .. } => format!("{name} · {owner_name}'s agent"),
55 +
            Principal::Instance { .. } => "Spectra".into(),
56 +
        }
57 +
    }
58 +
}
59 +
60 +
#[derive(Clone, Debug)]
61 +
struct AgentRec {
62 +
    name: String,
63 +
    identity: Identity,
64 +
    owner_name: String,
65 +
    uid: Option<u32>,
66 +
    learn_uid: bool,
67 +
    project: String,
68 +
    scopes: Vec<String>,
69 +
    disabled: bool,
70 +
}
71 +
72 +
/// The principals as of some point in the ledger.
73 +
#[derive(Clone, Debug, Default)]
74 +
pub struct Principals {
75 +
    /// The owner's identities, oldest first; the last is current.
76 +
    owner: Vec<Identity>,
77 +
    instance: Option<Identity>,
78 +
    agents: HashMap<KeyId, AgentRec>,
79 +
    by_name: HashMap<String, KeyId>,
80 +
}
81 +
82 +
impl Principals {
83 +
    /// Replay every identity and agent event of the ledger.
84 +
    pub async fn load(ledger: &crate::ledger::Ledger) -> anyhow::Result<Principals> {
85 +
        let mut p = Principals::default();
86 +
        let mut from = 1;
87 +
        loop {
88 +
            let rows = ledger
89 +
                .events_of_kinds(from, &["identity.owner", "identity.rotated", "identity.instance", "agent.added", "agent.disabled"], 1000)
90 +
                .await?;
91 +
            if rows.is_empty() {
92 +
                break;
93 +
            }
94 +
            for e in &rows {
95 +
                // Recorded facts were checked when written; `verify` checks them again.
96 +
                let _ = p.observe(e);
97 +
            }
98 +
            from = rows.last().map(|e| e.seq + 1).unwrap_or(from);
99 +
        }
100 +
        Ok(p)
101 +
    }
102 +
103 +
    /// The owner's current identity.
104 +
    pub fn owner(&self) -> Option<&Identity> {
105 +
        self.owner.last()
106 +
    }
107 +
108 +
    pub fn instance(&self) -> Option<&Identity> {
109 +
        self.instance.as_ref()
110 +
    }
111 +
112 +
    /// The agent named `name`, whatever its state.
113 +
    pub fn agent_key(&self, name: &str) -> Option<KeyId> {
114 +
        self.by_name.get(name).copied()
115 +
    }
116 +
117 +
    /// Who `key` is at `at_ms`, if anyone may act with it then: the owner
118 +
    /// (any of its identities' KeyIds), the instance, or an agent that is
119 +
    /// not disabled and that the owner's current identity delegates to.
120 +
    pub fn principal(&self, key: &KeyId, at_ms: u64) -> Result<Principal, String> {
121 +
        if let Some(owner) = self.owner.iter().rev().find(|i| &i.hash() == key) {
122 +
            return Ok(Principal::Owner { key: *key, name: owner.name.clone() });
123 +
        }
124 +
        if self.instance.as_ref().is_some_and(|i| &i.hash() == key) {
125 +
            return Ok(Principal::Instance { key: *key });
126 +
        }
127 +
        let a = self.agents.get(key).ok_or("an unknown key")?;
128 +
        if a.disabled {
129 +
            return Err(format!("agent {} is disabled", a.name));
130 +
        }
131 +
        let owner = self.owner().ok_or("no owner is recorded")?;
132 +
        if !owner.delegates_to(key, DELEGATION_SCOPE, at_ms) {
133 +
            return Err(match owner.delegate(key) {
134 +
                Some(d) if d.expires.is_some_and(|x| at_ms > x) => format!("agent {}'s delegation from {} has expired", a.name, owner.name),
135 +
                Some(_) => format!("{}'s delegation to agent {} does not include `changes`", owner.name, a.name),
136 +
                None => format!("{}'s current identity does not delegate to agent {}", owner.name, a.name),
137 +
            });
138 +
        }
139 +
        Ok(Principal::Agent {
140 +
            name: a.name.clone(),
141 +
            key: *key,
142 +
            owner_name: a.owner_name.clone(),
143 +
            uid: a.uid,
144 +
            learn_uid: a.learn_uid,
145 +
            project: a.project.clone(),
146 +
            scopes: a.scopes.clone(),
147 +
        })
148 +
    }
149 +
150 +
    /// The identity behind `key`, for checking its signatures.
151 +
    pub fn identity(&self, key: &KeyId) -> Option<&Identity> {
152 +
        self.owner
153 +
            .iter()
154 +
            .find(|i| &i.hash() == key)
155 +
            .or_else(|| self.instance.as_ref().filter(|i| &i.hash() == key))
156 +
            .or_else(|| self.agents.get(key).map(|a| &a.identity))
157 +
    }
158 +
159 +
    /// Take one event into account, checking what it claims first.
160 +
    pub fn observe(&mut self, e: &Event) -> Result<(), String> {
161 +
        let p = e.json();
162 +
        match e.kind.as_str() {
163 +
            "identity.owner" => {
164 +
                if !self.owner.is_empty() {
165 +
                    return Err("an owner is already recorded".into());
166 +
                }
167 +
                let id = identity_of(&p)?;
168 +
                self_signed(e, &id)?;
169 +
                self.owner.push(id);
170 +
            }
171 +
            "identity.rotated" => {
172 +
                let new = identity_of(&p)?;
173 +
                let cur = self.owner().ok_or("no owner to succeed")?.clone();
174 +
                accept_successor(&cur, &new, &p)?;
175 +
                self_signed(e, &new)?;
176 +
                self.owner.push(new);
177 +
            }
178 +
            "identity.instance" => {
179 +
                let id = identity_of(&p)?;
180 +
                self_signed(e, &id)?;
181 +
                self.instance = Some(id);
182 +
            }
183 +
            "agent.added" => {
184 +
                let id = identity_of(&p)?;
185 +
                let key = id.hash();
186 +
                let owner = self.owner_signed(e)?;
187 +
                if !owner.delegates_to(&key, DELEGATION_SCOPE, e.hlc.wall_ms) {
188 +
                    return Err("added without the owner's delegation to its key".into());
189 +
                }
190 +
                let name = text(&p, "name")?;
191 +
                if self.by_name.get(&name).is_some_and(|k| *k != key) {
192 +
                    return Err(format!("agent {name} already exists with another key"));
193 +
                }
194 +
                self.by_name.insert(name.clone(), key);
195 +
                self.agents.insert(
196 +
                    key,
197 +
                    AgentRec {
198 +
                        name,
199 +
                        identity: id,
200 +
                        owner_name: owner.name.clone(),
201 +
                        uid: p.get("uid").and_then(Json::as_u64).map(|u| u as u32),
202 +
                        learn_uid: p.get("learn_uid").and_then(Json::as_bool).unwrap_or(false),
203 +
                        project: text(&p, "project")?,
204 +
                        scopes: p
205 +
                            .get("scopes")
206 +
                            .and_then(Json::as_array)
207 +
                            .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
208 +
                            .unwrap_or_default(),
209 +
                        disabled: false,
210 +
                    },
211 +
                );
212 +
            }
213 +
            "agent.disabled" => {
214 +
                self.owner_signed(e)?;
215 +
                let name = text(&p, "name")?;
216 +
                let key = *self.by_name.get(&name).ok_or("an unknown agent")?;
217 +
                if let Some(a) = self.agents.get_mut(&key) {
218 +
                    a.disabled = true;
219 +
                }
220 +
            }
221 +
            _ => {}
222 +
        }
223 +
        Ok(())
224 +
    }
225 +
226 +
    /// Check that `e` is signed by the owner's current identity; returns it.
227 +
    fn owner_signed(&self, e: &Event) -> Result<Identity, String> {
228 +
        let owner = self.owner().ok_or("no owner is recorded")?.clone();
229 +
        if e.auth.key != Some(owner.hash().0) {
230 +
            return Err("not signed by the owner's current identity".into());
231 +
        }
232 +
        check_proof(e, &owner)?;
233 +
        Ok(owner)
234 +
    }
235 +
236 +
    /// Check a signed event's proof against the principal its key was at
237 +
    /// the event's time.
238 +
    pub fn check_signed(&self, e: &Event) -> Result<(), String> {
239 +
        let key = oxy_core::hash::ObjectRef(e.auth.key.ok_or("signed, but names no key")?);
240 +
        let who = self.principal(&key, e.hlc.wall_ms)?;
241 +
        // The actor an event names must be the principal that signed it:
242 +
        // an agent's key never makes an event in another's name.
243 +
        if who.actor() != e.actor {
244 +
            return Err(format!("names {} as its actor but is signed by {}", e.actor, who.display()));
245 +
        }
246 +
        let identity = self.identity(&key).ok_or("an unknown key")?;
247 +
        check_proof(e, identity)
248 +
    }
249 +
}
250 +
251 +
impl KeyResolver for Principals {
252 +
    fn check(&mut self, e: &Event) -> Result<(), String> {
253 +
        match e.kind.as_str() {
254 +
            "identity.owner" | "identity.rotated" | "identity.instance" | "agent.added" | "agent.disabled" => {
255 +
                if e.auth.method != Method::Signature {
256 +
                    return Err(format!("{} must be signed", e.kind));
257 +
                }
258 +
                self.observe(e)
259 +
            }
260 +
            _ if matches!(e.auth.method, Method::Signature | Method::Passkey) => self.check_signed(e),
261 +
            _ => Ok(()),
262 +
        }
263 +
    }
264 +
}
265 +
266 +
/// Apply identity and agent events to the projection tables.
267 +
pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
268 +
    let p = e.json();
269 +
    let id = |p: &Json| identity_of(p).ok();
270 +
    match e.kind.as_str() {
271 +
        "identity.owner" | "identity.rotated" | "identity.instance" => {
272 +
            let Some(identity) = id(&p) else { return Ok(()) };
273 +
            let role = if e.kind == "identity.instance" { "instance" } else { "owner" };
274 +
            sqlx::query("UPDATE identities SET current = 0 WHERE role = ?").bind(role).execute(&mut *conn).await?;
275 +
            sqlx::query(
276 +
                "INSERT OR REPLACE INTO identities (key, role, name, identity, previous, current, seq) VALUES (?, ?, ?, ?, ?, 1, ?)",
277 +
            )
278 +
            .bind(identity.hash().to_hex())
279 +
            .bind(role)
280 +
            .bind(&identity.name)
281 +
            .bind(identity.encode())
282 +
            .bind(identity.previous.map(|k| k.to_hex()))
283 +
            .bind(e.seq as i64)
284 +
            .execute(&mut *conn)
285 +
            .await?;
286 +
        }
287 +
        "agent.added" => {
288 +
            let Some(identity) = id(&p) else { return Ok(()) };
289 +
            let s = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or("").to_string();
290 +
            sqlx::query(
291 +
                "INSERT OR REPLACE INTO agents (name, key, owner, owner_name, kind, project, home, uid, learn_uid, scopes, created_ms, disabled_ms, seq) \
292 +
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)",
293 +
            )
294 +
            .bind(s("name"))
295 +
            .bind(identity.hash().to_hex())
296 +
            .bind(s("owner"))
297 +
            .bind(s("owner_name"))
298 +
            .bind(s("kind"))
299 +
            .bind(s("project"))
300 +
            .bind(s("home"))
301 +
            .bind(p.get("uid").and_then(Json::as_i64))
302 +
            .bind(p.get("learn_uid").and_then(Json::as_bool).unwrap_or(false))
303 +
            .bind(p.get("scopes").map(|v| v.to_string()).unwrap_or_else(|| "[]".into()))
304 +
            .bind(e.at_ms as i64)
305 +
            .bind(e.seq as i64)
306 +
            .execute(&mut *conn)
307 +
            .await?;
308 +
            sqlx::query(
309 +
                "INSERT OR REPLACE INTO identities (key, role, name, identity, previous, current, seq) VALUES (?, 'agent', ?, ?, ?, 1, ?)",
310 +
            )
311 +
            .bind(identity.hash().to_hex())
312 +
            .bind(&identity.name)
313 +
            .bind(identity.encode())
314 +
            .bind(identity.previous.map(|k| k.to_hex()))
315 +
            .bind(e.seq as i64)
316 +
            .execute(&mut *conn)
317 +
            .await?;
318 +
        }
319 +
        "agent.disabled" => {
320 +
            sqlx::query("UPDATE agents SET disabled_ms = ? WHERE name = ?")
321 +
                .bind(e.at_ms as i64)
322 +
                .bind(p.get("name").and_then(Json::as_str))
323 +
                .execute(&mut *conn)
324 +
                .await?;
325 +
        }
326 +
        _ => {}
327 +
    }
328 +
    Ok(())
329 +
}
330 +
331 +
/// The payload of an identity event for `identity`.
332 +
pub fn identity_payload(identity: &Identity) -> Json {
333 +
    serde_json::json!({ "key": identity.hash().to_hex(), "name": identity.name, "identity": hex::encode(identity.encode()) })
334 +
}
335 +
336 +
fn identity_of(p: &Json) -> Result<Identity, String> {
337 +
    let b = p.get("identity").and_then(Json::as_str).and_then(|h| hex::decode(h).ok()).ok_or("no identity in the event")?;
338 +
    Identity::decode(&b).map_err(|e| format!("malformed identity: {e}"))
339 +
}
340 +
341 +
fn text(p: &Json, k: &str) -> Result<String, String> {
342 +
    p.get(k).and_then(Json::as_str).map(String::from).ok_or_else(|| format!("no {k}"))
343 +
}
344 +
345 +
fn self_signed(e: &Event, id: &Identity) -> Result<(), String> {
346 +
    if e.auth.key != Some(id.hash().0) {
347 +
        return Err("not signed by the identity it records".into());
348 +
    }
349 +
    check_proof(e, id)
350 +
}
351 +
352 +
/// The event's proof verifies against `identity`, and a CLI proof covers
353 +
/// exactly the event's payload.
354 +
fn check_proof(e: &Event, identity: &Identity) -> Result<(), String> {
355 +
    let proof = Proof::decode(e.auth.proof.as_deref().ok_or("no proof")?)?;
356 +
    if proof.key() != identity.hash().0 {
357 +
        return Err("the proof is by another key".into());
358 +
    }
359 +
    let body = (proof.method == "cli").then(|| e.payload.as_deref().unwrap_or(b""));
360 +
    proof.verify(identity, body)
361 +
}
362 +
363 +
/// A successor of the owner must keep the key pair and chain back to the
364 +
/// current identity through `previous` (the intermediate identities, if
365 +
/// any, are carried in the event as `chain`).
366 +
fn accept_successor(cur: &Identity, new: &Identity, p: &Json) -> Result<(), String> {
367 +
    if new.ed25519_pk != cur.ed25519_pk || new.mldsa65_pk != cur.mldsa65_pk {
368 +
        return Err("a successor with a fresh key pair needs `oxy identity rotate`'s attestation, not accepted in 0.1".into());
369 +
    }
370 +
    let chain: Vec<Identity> = p
371 +
        .get("chain")
372 +
        .and_then(Json::as_array)
373 +
        .map(|a| a.iter().filter_map(|h| h.as_str().and_then(|h| hex::decode(h).ok()).and_then(|b| Identity::decode(&b).ok())).collect())
374 +
        .unwrap_or_default();
375 +
    let mut at = new.clone();
376 +
    for _ in 0..=chain.len() {
377 +
        match at.previous {
378 +
            Some(prev) if prev == cur.hash() => return Ok(()),
379 +
            Some(prev) => match chain.iter().find(|i| i.hash() == prev) {
380 +
                Some(i) if i.ed25519_pk == cur.ed25519_pk => at = i.clone(),
381 +
                _ => break,
382 +
            },
383 +
            None => break,
384 +
        }
385 +
    }
386 +
    Err("does not succeed the recorded owner".into())
387 +
}
388 +
389 +
/// An event signed by `pair` from the command line: its proof covers the
390 +
/// canonical payload, so the event's content is bound to the signature.
391 +
pub fn signed_event(pair: &crate::keys::KeyPair, actor: &str, kind: &str, payload: Json) -> crate::event::NewEvent {
392 +
    let body = crate::event::canonical_json(&payload);
393 +
    let proof = Proof::sign(pair, "cli", &body);
394 +
    crate::event::NewEvent::system(kind, payload)
395 +
        .by(actor, crate::event::Auth { key: Some(pair.key.0), method: Method::Signature, assurance: 2, proof: Some(proof.encode()) })
396 +
}
added crates/spectra-core/src/proof.rs +117 −0
1 +
//! Signed requests and the proofs events carry (spec §8.5, §9.5).
2 +
//!
3 +
//! A principal signs `"spectra1 request " || method || 0x00 || BLAKE3(body)
4 +
//! || time (8 bytes) || nonce (16 bytes)` with its key, through `oxy-core`'s
5 +
//! challenge signing. The signature, with what it covered, is the proof
6 +
//! kept on every event the request causes, so a verifier can check later,
7 +
//! from the ledger alone, which key asked for it.
8 +
//!
9 +
//! The CLI, writing to the ledger itself, signs with `method = "cli"` and
10 +
//! `body` = the event's canonical payload, so its proof also binds the
11 +
//! event's content.
12 +
13 +
use crate::keys::KeyPair;
14 +
use oxy_core::cbor::{self, Value};
15 +
use oxy_core::identity::{Identity, Signature};
16 +
use rand::RngCore;
17 +
18 +
/// How far a request's time may be from the server's clock (spec §8.5).
19 +
pub const WINDOW_MS: u64 = 5 * 60 * 1000;
20 +
21 +
/// A signature with everything it covered except the body itself.
22 +
#[derive(Clone, Debug, PartialEq, Eq)]
23 +
pub struct Proof {
24 +
    pub sig: Signature,
25 +
    pub method: String,
26 +
    pub body_hash: [u8; 32],
27 +
    pub time_ms: u64,
28 +
    pub nonce: [u8; 16],
29 +
}
30 +
31 +
impl Proof {
32 +
    /// Sign `method` and `body` now with `pair`.
33 +
    pub fn sign(pair: &KeyPair, method: &str, body: &[u8]) -> Proof {
34 +
        let mut nonce = [0u8; 16];
35 +
        rand::rngs::OsRng.fill_bytes(&mut nonce);
36 +
        Proof::sign_at(pair, method, body, crate::ids::now_ms(), nonce)
37 +
    }
38 +
39 +
    pub fn sign_at(pair: &KeyPair, method: &str, body: &[u8], time_ms: u64, nonce: [u8; 16]) -> Proof {
40 +
        let body_hash = *blake3::hash(body).as_bytes();
41 +
        let sig = pair.sign(&message(method, &body_hash, time_ms, &nonce));
42 +
        Proof { sig, method: method.into(), body_hash, time_ms, nonce }
43 +
    }
44 +
45 +
    /// The signing key's KeyId.
46 +
    pub fn key(&self) -> [u8; 32] {
47 +
        self.sig.key.0
48 +
    }
49 +
50 +
    /// Check the signature against `identity` and, when given, the body.
51 +
    pub fn verify(&self, identity: &Identity, body: Option<&[u8]>) -> Result<(), String> {
52 +
        if let Some(b) = body {
53 +
            if *blake3::hash(b).as_bytes() != self.body_hash {
54 +
                return Err("the signature covers a different body".into());
55 +
            }
56 +
        }
57 +
        let msg = message(&self.method, &self.body_hash, self.time_ms, &self.nonce);
58 +
        oxy_core::identity::verify_challenge(identity, &self.sig, &msg).map_err(|e| format!("signature does not verify: {e}"))
59 +
    }
60 +
61 +
    pub fn encode(&self) -> Vec<u8> {
62 +
        cbor::encode(&Value::map([
63 +
            (0, self.sig.to_value()),
64 +
            (1, Value::Text(self.method.clone())),
65 +
            (2, Value::Bytes(self.body_hash.to_vec())),
66 +
            (3, Value::Uint(self.time_ms)),
67 +
            (4, Value::Bytes(self.nonce.to_vec())),
68 +
        ]))
69 +
    }
70 +
71 +
    pub fn decode(b: &[u8]) -> Result<Proof, String> {
72 +
        let v = cbor::decode(b).map_err(|e| format!("malformed proof: {e}"))?;
73 +
        let m = v.as_map().ok_or("malformed proof")?;
74 +
        let get = |k: u64| m.get(&k).ok_or_else(|| format!("proof lacks field {k}"));
75 +
        Ok(Proof {
76 +
            sig: Signature::from_value(get(0)?).map_err(|e| format!("malformed signature: {e}"))?,
77 +
            method: get(1)?.as_text().ok_or("proof method")?.to_string(),
78 +
            body_hash: get(2)?.as_bytes().and_then(|b| b.try_into().ok()).ok_or("proof body hash")?,
79 +
            time_ms: get(3)?.as_uint().ok_or("proof time")?,
80 +
            nonce: get(4)?.as_bytes().and_then(|b| b.try_into().ok()).ok_or("proof nonce")?,
81 +
        })
82 +
    }
83 +
}
84 +
85 +
/// `"spectra1 request " || method || 0 || body_hash || time || nonce`.
86 +
pub fn message(method: &str, body_hash: &[u8; 32], time_ms: u64, nonce: &[u8; 16]) -> Vec<u8> {
87 +
    let mut m = b"spectra1 request ".to_vec();
88 +
    m.extend_from_slice(method.as_bytes());
89 +
    m.push(0);
90 +
    m.extend_from_slice(body_hash);
91 +
    m.extend_from_slice(&time_ms.to_be_bytes());
92 +
    m.extend_from_slice(nonce);
93 +
    m
94 +
}
95 +
96 +
#[cfg(test)]
97 +
mod tests {
98 +
    use super::*;
99 +
100 +
    #[test]
101 +
    fn a_proof_round_trips_and_binds_key_method_body_and_time() {
102 +
        let a = KeyPair::generate("a", "");
103 +
        let b = KeyPair::generate("b", "");
104 +
        let p = Proof::sign(&a, "POST /mcp", b"{\"x\":1}");
105 +
        let q = Proof::decode(&p.encode()).unwrap();
106 +
        assert_eq!(p, q);
107 +
        q.verify(&a.identity, Some(b"{\"x\":1}")).unwrap();
108 +
        assert!(q.verify(&a.identity, Some(b"{\"x\":2}")).is_err(), "another body");
109 +
        assert!(q.verify(&b.identity, None).is_err(), "another key");
110 +
        let mut r = q.clone();
111 +
        r.method = "POST /api".into();
112 +
        assert!(r.verify(&a.identity, None).is_err(), "another method");
113 +
        let mut t = q.clone();
114 +
        t.time_ms += 1;
115 +
        assert!(t.verify(&a.identity, None).is_err(), "another time");
116 +
    }
117 +
}
added crates/spectra-core/tests/principals.rs +224 −0
1 +
//! Who may act as whom (spec §5.2 G1, G7; §8): an agent is its owner's
2 +
//! delegate or nothing, may not act in another's name, stops when its
3 +
//! delegation expires or is disabled, and a request is refused unless
4 +
//! signed, fresh, unreplayed and from the bound Unix user.
5 +
6 +
use oxy_core::durable::{write_atomic, write_atomic_private};
7 +
use oxy_core::identity::{Delegate, Identity, SecretKey};
8 +
use spectra_core::authn::{Authenticator, Refusal};
9 +
use spectra_core::event::NewEvent;
10 +
use spectra_core::ids::now_ms;
11 +
use spectra_core::keys::KeyPair;
12 +
use spectra_core::ledger::{Ledger, Problem};
13 +
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DELEGATION_SCOPE};
14 +
use spectra_core::proof::Proof;
15 +
use std::path::Path;
16 +
17 +
/// What `oxy identity delegate add KEY --scope changes [--expires]` does to
18 +
/// the owner's home: a successor with the same key pair, its own secret
19 +
/// file, and `author.key` naming it. Only the owner's tools do this.
20 +
fn delegate(owner_home: &Path, agent: &Identity, expires: Option<u64>) -> KeyPair {
21 +
    let cur = KeyPair::load_home(owner_home).unwrap();
22 +
    let keys = owner_home.join("keys");
23 +
    let secret_file = std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap();
24 +
    let (_, secret) = SecretKey::decode(&secret_file).unwrap();
25 +
    let mut delegates = cur.identity.delegates.clone();
26 +
    delegates.retain(|d| d.key != agent.hash());
27 +
    delegates.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires });
28 +
    let next = cur.identity.successor(delegates, None, now_ms());
29 +
    let key = next.hash();
30 +
    write_atomic_private(&keys.join(format!("{}.secret", key.to_hex())), &secret.encode(&key)).unwrap();
31 +
    write_atomic(&keys.join(format!("{}.identity", key.to_hex())), &next.encode()).unwrap();
32 +
    oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &key.to_hex()).unwrap();
33 +
    KeyPair::load_home(owner_home).unwrap()
34 +
}
35 +
36 +
struct World {
37 +
    _dir: tempfile::TempDir,
38 +
    owner_home: std::path::PathBuf,
39 +
    ledger: Ledger,
40 +
    owner: KeyPair,
41 +
}
42 +
43 +
async fn world() -> World {
44 +
    let dir = tempfile::tempdir().unwrap();
45 +
    let owner_home = dir.path().join("owner");
46 +
    let owner = KeyPair::create_home(&owner_home, "Andy McCall", "[email protected]").unwrap();
47 +
    let data = dir.path().join("data");
48 +
    std::fs::create_dir_all(&data).unwrap();
49 +
    let instance = KeyPair::instance(&data, "laptop").unwrap();
50 +
    let ledger = Ledger::open(&data.join("spectra.db"), Some(instance.clone())).await.unwrap();
51 +
    ledger.append_one(signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity))).await.unwrap();
52 +
    ledger.append_one(signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity))).await.unwrap();
53 +
    World { _dir: dir, owner_home, ledger, owner }
54 +
}
55 +
56 +
/// Add `name` as the owner would: delegate, record the successor, add.
57 +
async fn add_agent(w: &mut World, name: &str, expires: Option<u64>) -> KeyPair {
58 +
    let agent = KeyPair::generate(name, "");
59 +
    let prev = w.owner.identity.clone();
60 +
    w.owner = delegate(&w.owner_home, &agent.identity, expires);
61 +
    let mut rotated = identity_payload(&w.owner.identity);
62 +
    rotated["chain"] = serde_json::json!([]);
63 +
    assert_eq!(w.owner.identity.previous, Some(prev.hash()));
64 +
    w.ledger.append_one(signed_event(&w.owner, "human", "identity.rotated", rotated)).await.unwrap();
65 +
    let mut p = identity_payload(&agent.identity);
66 +
    p["name"] = name.into();
67 +
    p["kind"] = "claude-code".into();
68 +
    p["project"] = "oxygen".into();
69 +
    p["owner"] = w.owner.key.to_hex().into();
70 +
    p["owner_name"] = "Andy McCall".into();
71 +
    w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap();
72 +
    agent
73 +
}
74 +
75 +
fn said(agent: &KeyPair, actor: &str, body: &str) -> NewEvent {
76 +
    signed_event(agent, actor, "message.posted", serde_json::json!({ "body": body }))
77 +
}
78 +
79 +
#[tokio::test]
80 +
async fn an_owned_agent_acts_and_the_ledger_verifies_every_signature() {
81 +
    let mut w = world().await;
82 +
    let a = add_agent(&mut w, "claude-oxygen", None).await;
83 +
    w.ledger.append_one(said(&a, "agent:claude-oxygen", "hello")).await.unwrap();
84 +
    w.ledger.checkpoint().await.unwrap();
85 +
    let mut p = Principals::default();
86 +
    let v = w.ledger.verify(&mut p).await.unwrap();
87 +
    assert!(v.clean(), "{:?}", v.findings);
88 +
    let who = p.principal(&a.key, now_ms()).unwrap();
89 +
    assert_eq!(who.display(), "claude-oxygen · Andy McCall's agent");
90 +
    // The projection agrees, and so does a fresh load.
91 +
    assert!(w.ledger.check().await.unwrap().is_empty());
92 +
    let loaded = Principals::load(&w.ledger).await.unwrap();
93 +
    assert!(matches!(loaded.principal(&a.key, now_ms()), Ok(Principal::Agent { .. })));
94 +
}
95 +
96 +
#[tokio::test]
97 +
async fn an_agent_without_the_owners_delegation_cannot_be_added() {
98 +
    let w = world().await;
99 +
    let stray = KeyPair::generate("stray", "");
100 +
    let mut p = identity_payload(&stray.identity);
101 +
    p["name"] = "stray".into();
102 +
    p["project"] = "oxygen".into();
103 +
    w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap();
104 +
    w.ledger.append_one(said(&stray, "agent:stray", "I am somebody's")).await.unwrap();
105 +
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
106 +
    let bad: Vec<_> = v.findings.iter().filter(|f| f.problem == Problem::BadProof).collect();
107 +
    assert_eq!(bad.len(), 2, "the addition and the message: {:?}", v.findings);
108 +
    assert!(bad[0].detail.contains("without the owner's delegation"), "{}", bad[0].detail);
109 +
}
110 +
111 +
#[tokio::test]
112 +
async fn an_agent_cannot_act_in_another_agents_name_or_the_owners() {
113 +
    let mut w = world().await;
114 +
    let a = add_agent(&mut w, "claude-oxygen", None).await;
115 +
    let _b = add_agent(&mut w, "antigravity-hub", None).await;
116 +
    w.ledger.append_one(said(&a, "agent:antigravity-hub", "I am antigravity")).await.unwrap();
117 +
    w.ledger.append_one(said(&a, "human", "I am Andy")).await.unwrap();
118 +
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
119 +
    assert_eq!(v.findings.len(), 2, "{:?}", v.findings);
120 +
    assert!(v.findings.iter().all(|f| f.detail.contains("is signed by claude-oxygen")));
121 +
}
122 +
123 +
#[tokio::test]
124 +
async fn an_agent_cannot_record_itself_as_the_owner_or_a_successor() {
125 +
    let mut w = world().await;
126 +
    let a = add_agent(&mut w, "claude-oxygen", None).await;
127 +
    // A successor of the owner that the agent made: it has no owner key to sign it with.
128 +
    let forged =
129 +
        w.owner.identity.successor(vec![Delegate { key: a.key, scope: oxy_core::identity::Scope::ALL, expires: None }], None, now_ms());
130 +
    let mut p = identity_payload(&forged);
131 +
    p["chain"] = serde_json::json!([]);
132 +
    w.ledger.append_one(signed_event(&a, "human", "identity.rotated", p)).await.unwrap();
133 +
    // And a second owner, signed by itself.
134 +
    w.ledger.append_one(signed_event(&a, "human", "identity.owner", identity_payload(&a.identity))).await.unwrap();
135 +
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
136 +
    assert_eq!(v.findings.len(), 2, "{:?}", v.findings);
137 +
}
138 +
139 +
#[tokio::test]
140 +
async fn an_expired_or_disabled_agent_is_refused_and_flagged() {
141 +
    let mut w = world().await;
142 +
    let a = add_agent(&mut w, "overnight", Some(now_ms() + 60_000)).await;
143 +
    let principals = Principals::load(&w.ledger).await.unwrap();
144 +
    assert!(principals.principal(&a.key, now_ms()).is_ok());
145 +
    let later = now_ms() + 120_000;
146 +
    let why = principals.principal(&a.key, later).unwrap_err();
147 +
    assert!(why.contains("expired"), "{why}");
148 +
149 +
    let b = add_agent(&mut w, "claude-oxygen", None).await;
150 +
    w.ledger.append_one(signed_event(&w.owner, "human", "agent.disabled", serde_json::json!({ "name": "claude-oxygen" }))).await.unwrap();
151 +
    w.ledger.append_one(said(&b, "agent:claude-oxygen", "still here?")).await.unwrap();
152 +
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
153 +
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
154 +
    assert!(v.findings[0].detail.contains("disabled"));
155 +
}
156 +
157 +
#[tokio::test]
158 +
async fn a_proof_swapped_from_another_event_is_caught() {
159 +
    let mut w = world().await;
160 +
    let a = add_agent(&mut w, "claude-oxygen", None).await;
161 +
    let first = w.ledger.append_one(said(&a, "agent:claude-oxygen", "one")).await.unwrap();
162 +
    let second = w.ledger.append_one(said(&a, "agent:claude-oxygen", "two")).await.unwrap();
163 +
    let pool = w.ledger.pool().clone();
164 +
    sqlx::query("UPDATE events SET auth_proof = ? WHERE seq = ?")
165 +
        .bind(first.auth.proof.clone())
166 +
        .bind(second.seq as i64)
167 +
        .execute(&pool)
168 +
        .await
169 +
        .unwrap();
170 +
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
171 +
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
172 +
    assert!(v.findings[0].detail.contains("different body"));
173 +
}
174 +
175 +
#[tokio::test]
176 +
async fn requests_are_refused_unless_signed_fresh_unreplayed_and_from_the_bound_uid() {
177 +
    let mut w = world().await;
178 +
    let a = add_agent(&mut w, "claude-oxygen", None).await;
179 +
    let principals = Principals::load(&w.ledger).await.unwrap();
180 +
    let auth = Authenticator::default();
181 +
    let now = now_ms();
182 +
    let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
183 +
184 +
    assert_eq!(auth.authenticate(&principals, None, "POST /mcp", body, now, Some(1000)).unwrap_err(), Refusal::Unsigned);
185 +
    let good = Proof::sign(&a, "POST /mcp", body);
186 +
    let caller = auth.authenticate(&principals, Some(&good), "POST /mcp", body, now, Some(1000)).unwrap();
187 +
    assert_eq!(caller.principal.actor(), "agent:claude-oxygen");
188 +
    assert_eq!(caller.auth.assurance, 2);
189 +
    assert_eq!(auth.authenticate(&principals, Some(&good), "POST /mcp", body, now, Some(1000)).unwrap_err(), Refusal::Replay);
190 +
    let other_body = Proof::sign(&a, "POST /mcp", body);
191 +
    assert!(matches!(auth.authenticate(&principals, Some(&other_body), "POST /mcp", b"{}", now, None), Err(Refusal::BadSignature(_))));
192 +
    let old = Proof::sign_at(&a, "POST /mcp", body, now - 10 * 60_000, [7; 16]);
193 +
    assert!(matches!(auth.authenticate(&principals, Some(&old), "POST /mcp", body, now, None), Err(Refusal::Stale(_))));
194 +
    let wrong = Proof::sign(&a, "POST /api/v1/channels", body);
195 +
    assert!(matches!(auth.authenticate(&principals, Some(&wrong), "POST /mcp", body, now, None), Err(Refusal::WrongMethod(_))));
196 +
    let stranger = KeyPair::generate("stranger", "");
197 +
    assert!(matches!(
198 +
        auth.authenticate(&principals, Some(&Proof::sign(&stranger, "POST /mcp", body)), "POST /mcp", body, now, None),
199 +
        Err(Refusal::NotAPrincipal(_))
200 +
    ));
201 +
    let owner = auth.authenticate(&principals, Some(&Proof::sign(&w.owner, "POST /mcp", body)), "POST /mcp", body, now, None).unwrap();
202 +
    assert_eq!(owner.principal.actor(), "human");
203 +
204 +
    // An agent bound to a Unix user, used from another.
205 +
    let b = add_agent_bound(&mut w, "bound", 1001).await;
206 +
    let principals = Principals::load(&w.ledger).await.unwrap();
207 +
    let r = auth.authenticate(&principals, Some(&Proof::sign(&b, "POST /mcp", body)), "POST /mcp", body, now, Some(1000));
208 +
    assert_eq!(r.unwrap_err(), Refusal::WrongUid { agent: "bound".into(), bound: 1001, from: 1000 });
209 +
    assert!(auth.authenticate(&principals, Some(&Proof::sign(&b, "POST /mcp", body)), "POST /mcp", body, now, Some(1001)).is_ok());
210 +
}
211 +
212 +
async fn add_agent_bound(w: &mut World, name: &str, uid: u32) -> KeyPair {
213 +
    let agent = KeyPair::generate(name, "");
214 +
    w.owner = delegate(&w.owner_home, &agent.identity, None);
215 +
    let mut rotated = identity_payload(&w.owner.identity);
216 +
    rotated["chain"] = serde_json::json!([]);
217 +
    w.ledger.append_one(signed_event(&w.owner, "human", "identity.rotated", rotated)).await.unwrap();
218 +
    let mut p = identity_payload(&agent.identity);
219 +
    p["name"] = name.into();
220 +
    p["project"] = "oxygen".into();
221 +
    p["uid"] = uid.into();
222 +
    w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap();
223 +
    agent
224 +
}
added src/cli/agent.rs +166 −0
1 +
//! `spectra agent`: agents as their owner's delegates (spec §8.1).
2 +
//!
3 +
//! `add` makes the agent's key pair in its own home, prints the one `oxy`
4 +
//! command that makes it the owner's, and records the agent only once the
5 +
//! owner's identity delegates to it. There is no agent without an owner.
6 +
7 +
use super::identity::{owner_home, refresh_owner};
8 +
use super::{ui, Ctx};
9 +
use anyhow::{bail, Context, Result};
10 +
use spectra_core::ids::now_ms;
11 +
use spectra_core::keys::{home_identity, KeyPair};
12 +
use spectra_core::ledger::Ledger;
13 +
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES, DELEGATION_SCOPE};
14 +
use std::path::PathBuf;
15 +
use std::time::Duration;
16 +
17 +
pub const KINDS: &[&str] = &["claude-code", "antigravity", "kimi-code", "codex", "other"];
18 +
19 +
pub struct Add {
20 +
    pub name: String,
21 +
    pub kind: String,
22 +
    pub project: String,
23 +
    pub home: PathBuf,
24 +
    pub uid: Option<u32>,
25 +
    pub learn_uid: bool,
26 +
    pub wait_secs: u64,
27 +
    pub owner_home: Option<PathBuf>,
28 +
}
29 +
30 +
pub async fn add(ctx: &Ctx, a: Add) -> Result<i32> {
31 +
    if !valid_name(&a.name) {
32 +
        bail!("an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64");
33 +
    }
34 +
    if !KINDS.contains(&a.kind.as_str()) {
35 +
        bail!("--kind is one of {}", KINDS.join(", "));
36 +
    }
37 +
    {
38 +
        let ledger = Ledger::open_read_only(&ctx.db()?).await?;
39 +
        let p = Principals::load(&ledger).await?;
40 +
        if p.agent_key(&a.name).is_some() {
41 +
            bail!("an agent named {} already exists (spectra agent list)", a.name);
42 +
        }
43 +
    }
44 +
    let home = owner_home(ctx, a.owner_home.clone());
45 +
    // The agent's key, made once in its own home and reused if `add` is run again.
46 +
    let agent = if a.home.join("keys").exists() {
47 +
        KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))?
48 +
    } else {
49 +
        let pair = KeyPair::create_home(&a.home, &a.name, "")?;
50 +
        println!("{} made {}'s key in {}", ui::done(ui::glyph("✔", "ok")), a.name, ui::path(a.home.display()));
51 +
        pair
52 +
    };
53 +
    let delegated = |id: &oxy_core::identity::Identity| id.delegates_to(&agent.key, DELEGATION_SCOPE, now_ms());
54 +
    if !delegated(&home_identity(&home)?) {
55 +
        println!("To make {} yours, delegate to its key (as yourself):", a.name);
56 +
        println!(
57 +
            "  {}",
58 +
            ui::id(format!(
59 +
                "oxy identity delegate add {} --scope changes --expires 8h",
60 +
                a.home.join("keys").join(format!("{}.identity", agent.key.to_hex())).display()
61 +
            ))
62 +
        );
63 +
        println!(
64 +
            "{}",
65 +
            ui::dim(format!("waiting up to {} s for the delegation; Ctrl-C stops, and running this again resumes", a.wait_secs))
66 +
        );
67 +
        let deadline = std::time::Instant::now() + Duration::from_secs(a.wait_secs);
68 +
        loop {
69 +
            if home_identity(&home).is_ok_and(|id| delegated(&id)) {
70 +
                break;
71 +
            }
72 +
            if std::time::Instant::now() >= deadline {
73 +
                bail!("{}'s key is not delegated yet: run the command above, then this again", a.name);
74 +
            }
75 +
            tokio::time::sleep(Duration::from_secs(2)).await;
76 +
        }
77 +
    }
78 +
    let ledger = ctx.writer().await?;
79 +
    refresh_owner(&ledger, &home).await?;
80 +
    let owner = KeyPair::load_home(&home)?;
81 +
    let mut p = identity_payload(&agent.identity);
82 +
    p["name"] = a.name.clone().into();
83 +
    p["kind"] = a.kind.into();
84 +
    p["project"] = a.project.into();
85 +
    p["home"] = a.home.display().to_string().into();
86 +
    p["owner"] = owner.key.to_hex().into();
87 +
    p["owner_name"] = owner.identity.name.clone().into();
88 +
    p["scopes"] = DEFAULT_SCOPES.into();
89 +
    if let Some(u) = a.uid {
90 +
        p["uid"] = u.into();
91 +
    }
92 +
    p["learn_uid"] = a.learn_uid.into();
93 +
    let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?;
94 +
    p["delegation"] = serde_json::json!({ "scope": d.scope.names(), "expires": d.expires });
95 +
    ledger.append_one(signed_event(&owner, "human", "agent.added", p)).await?;
96 +
    ledger.checkpoint().await?;
97 +
    println!("{} {} · {}'s agent", ui::done(ui::glyph("✔", "ok")), a.name, owner.identity.name);
98 +
    println!("Connect its client with {}.", ui::id(format!("spectra mcp --agent {}", a.name)));
99 +
    Ok(0)
100 +
}
101 +
102 +
pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> {
103 +
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
104 +
    let p = Principals::load(&ledger).await?;
105 +
    let rows: Vec<(String, String, String, String, Option<i64>)> =
106 +
        sqlx::query_as("SELECT name, key, kind, project, disabled_ms FROM agents ORDER BY name").fetch_all(ledger.pool()).await?;
107 +
    let owner = p.owner();
108 +
    let now = now_ms();
109 +
    let mut out = Vec::new();
110 +
    for (name, key, kind, project, disabled) in rows {
111 +
        let k = oxy_core::hash::ObjectRef::from_hex(&key).context("a stored key")?;
112 +
        let state = match p.principal(&k, now) {
113 +
            Ok(_) => "ok".to_string(),
114 +
            Err(why) => why,
115 +
        };
116 +
        let expires = owner.and_then(|o| o.delegate(&k)).and_then(|d| d.expires);
117 +
        let who = match p.principal(&k, now) {
118 +
            Ok(pr @ Principal::Agent { .. }) => pr.display(),
119 +
            _ => format!("{name} · {}'s agent", owner.map(|o| o.name.as_str()).unwrap_or("?")),
120 +
        };
121 +
        out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "delegation_expires": expires, "disabled": disabled.is_some() }));
122 +
    }
123 +
    if json {
124 +
        println!("{}", serde_json::Value::Array(out));
125 +
        return Ok(0);
126 +
    }
127 +
    if out.is_empty() {
128 +
        println!("No agents yet: {}", ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR"));
129 +
    }
130 +
    for a in out {
131 +
        let state = a["state"].as_str().unwrap_or("");
132 +
        let mark = if state == "ok" { ui::ok(ui::glyph("●", "*")) } else { ui::warn(ui::glyph("○", "o")) };
133 +
        let until = a["delegation_expires"].as_u64().map(|e| format!(" until {}", super::format::when(e))).unwrap_or_default();
134 +
        println!(
135 +
            "{mark} {}  {}  {}  {}{until}",
136 +
            a["display"].as_str().unwrap_or(""),
137 +
            a["kind"].as_str().unwrap_or(""),
138 +
            a["project"].as_str().unwrap_or(""),
139 +
            ui::hash(&a["key"].as_str().unwrap_or("")[..12])
140 +
        );
141 +
        if state != "ok" {
142 +
            println!("    {}", ui::warn(state));
143 +
        }
144 +
    }
145 +
    Ok(0)
146 +
}
147 +
148 +
pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> {
149 +
    let ledger = ctx.writer().await?;
150 +
    let p = Principals::load(&ledger).await?;
151 +
    p.agent_key(name).with_context(|| format!("no agent named {name}"))?;
152 +
    let home = owner_home(ctx, owner_home_flag);
153 +
    refresh_owner(&ledger, &home).await?;
154 +
    let owner = KeyPair::load_home(&home)?;
155 +
    ledger.append_one(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name }))).await?;
156 +
    ledger.checkpoint().await?;
157 +
    println!("{} {name} is disabled; its key is refused from now on", ui::done(ui::glyph("✔", "ok")));
158 +
    Ok(0)
159 +
}
160 +
161 +
pub fn valid_name(n: &str) -> bool {
162 +
    !n.is_empty()
163 +
        && n.len() <= 64
164 +
        && n.bytes().next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
165 +
        && n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
166 +
}
added src/cli/identity.rs +145 −0
1 +
//! `spectra init` and `spectra identity`: the owner and the instance
2 +
//! (spec §8.3, §9.5). The owner proves possession of their identity by
3 +
//! signing with it; Spectra never holds the owner's secret key.
4 +
5 +
use super::{ui, Ctx};
6 +
use anyhow::{bail, Context, Result};
7 +
use oxy_core::identity::Identity;
8 +
use spectra_core::keys::KeyPair;
9 +
use spectra_core::ledger::Ledger;
10 +
use spectra_core::principals::{identity_payload, signed_event, Principals};
11 +
use std::path::{Path, PathBuf};
12 +
13 +
/// The owner's `OXY_HOME`: `--owner-home`, else `[owner] home` in
14 +
/// spectra.toml, else oxy's own default.
15 +
pub fn owner_home(ctx: &Ctx, flag: Option<PathBuf>) -> PathBuf {
16 +
    flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(oxy_core::repo::default_home)
17 +
}
18 +
19 +
pub async fn init(ctx: &Ctx, owner_home_flag: Option<PathBuf>, name: Option<String>) -> Result<i32> {
20 +
    let data = &ctx.data;
21 +
    let db = data.join("spectra.db");
22 +
    if db.exists() {
23 +
        bail!("{} already holds a Spectra (spectra identity show)", data.display());
24 +
    }
25 +
    let home = owner_home(ctx, owner_home_flag);
26 +
    let owner = KeyPair::load_home(&home).with_context(|| {
27 +
        format!("the owner signs with their own identity: none usable in {} (oxy identity create, or --owner-home)", home.display())
28 +
    })?;
29 +
    std::fs::create_dir_all(data)?;
30 +
    #[cfg(unix)]
31 +
    {
32 +
        use std::os::unix::fs::PermissionsExt;
33 +
        std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?;
34 +
    }
35 +
    let name = name.unwrap_or_else(hostname);
36 +
    let instance = KeyPair::instance(data, &name)?;
37 +
    let ledger = Ledger::open(&db, Some(instance.clone())).await?;
38 +
    ledger
39 +
        .append(vec![
40 +
            signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)),
41 +
            signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity)),
42 +
        ])
43 +
        .await?;
44 +
    ledger.checkpoint().await?;
45 +
    let home_s = home.display().to_string();
46 +
    std::fs::write(
47 +
        data.join("spectra.toml"),
48 +
        format!("# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{}\"\nhome = {}\n", owner.key.to_hex(), toml_str(&home_s)),
49 +
    )?;
50 +
    println!("{} Spectra initialised in {}", ui::done(ui::glyph("✔", "ok")), ui::path(data.display()));
51 +
    println!("{}", ui::kv("owner", format!("{} {}", owner.identity.name, ui::hash(owner.key.short(12)))));
52 +
    println!("{}", ui::kv("instance", format!("{} {}", name, ui::hash(instance.key.short(12)))));
53 +
    println!();
54 +
    println!("So that checkpoints are verifiably from your machine, delegate to the instance key:");
55 +
    println!(
56 +
        "  {}",
57 +
        ui::id(format!("oxy identity delegate add {} --scope changes", data.join("keys").join("instance.identity").display()))
58 +
    );
59 +
    println!("Then add agents with {}.", ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR"));
60 +
    Ok(0)
61 +
}
62 +
63 +
pub async fn show(ctx: &Ctx, json: bool) -> Result<i32> {
64 +
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
65 +
    let p = Principals::load(&ledger).await?;
66 +
    let owner = p.owner().context("no owner is recorded")?;
67 +
    let instance = p.instance();
68 +
    let delegated =
69 +
        instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms()));
70 +
    if json {
71 +
        println!(
72 +
            "{}",
73 +
            serde_json::json!({
74 +
                "owner": { "name": owner.name, "key": owner.hash().to_hex() },
75 +
                "instance": instance.map(|i| serde_json::json!({ "name": i.name, "key": i.hash().to_hex(), "delegated": delegated })),
76 +
            })
77 +
        );
78 +
        return Ok(0);
79 +
    }
80 +
    println!("{}", ui::kv("owner", format!("{} <{}> {}", owner.name, owner.email, ui::hash(owner.hash().to_hex()))));
81 +
    if let Some(i) = instance {
82 +
        let note = if delegated { ui::ok("delegated by the owner") } else { ui::warn("not delegated by the owner yet") };
83 +
        println!("{}", ui::kv("instance", format!("{} {} {}", i.name, ui::hash(i.hash().short(12)), note)));
84 +
    }
85 +
    Ok(0)
86 +
}
87 +
88 +
/// Record the owner's current identity when it has moved on (a delegation
89 +
/// added or removed): `spectra identity refresh`.
90 +
pub async fn refresh(ctx: &Ctx, owner_home_flag: Option<PathBuf>) -> Result<i32> {
91 +
    let ledger = ctx.writer().await?;
92 +
    let outcome = refresh_owner(&ledger, &owner_home(ctx, owner_home_flag)).await?;
93 +
    ledger.checkpoint().await?;
94 +
    match outcome {
95 +
        Some(id) => println!("{} recorded the owner's current identity {}", ui::done(ui::glyph("✔", "ok")), ui::hash(id.hash().short(12))),
96 +
        None => println!("{} the owner's identity is current", ui::done(ui::glyph("✔", "ok"))),
97 +
    }
98 +
    Ok(0)
99 +
}
100 +
101 +
/// If the owner's home names a successor of the recorded owner, record it,
102 +
/// signed with it, with the identities between. Returns the new identity.
103 +
pub async fn refresh_owner(ledger: &Ledger, home: &Path) -> Result<Option<Identity>> {
104 +
    let p = Principals::load(ledger).await?;
105 +
    let recorded = p.owner().context("no owner is recorded (spectra init)")?.clone();
106 +
    let pair = KeyPair::load_home(home)?;
107 +
    if pair.key == recorded.hash() {
108 +
        return Ok(None);
109 +
    }
110 +
    let mut chain = Vec::new();
111 +
    let mut at = pair.identity.clone();
112 +
    while let Some(prev) = at.previous {
113 +
        if prev == recorded.hash() {
114 +
            break;
115 +
        }
116 +
        let file = home.join("keys").join(format!("{}.identity", prev.to_hex()));
117 +
        at = Identity::decode(
118 +
            &std::fs::read(&file).with_context(|| format!("{} is not a successor of the recorded owner", pair.identity.name))?,
119 +
        )
120 +
        .map_err(|e| anyhow::anyhow!("{}: {e}", file.display()))?;
121 +
        chain.push(hex::encode(at.encode()));
122 +
        if chain.len() > 1000 {
123 +
            bail!("the owner's identity chain is too long to follow");
124 +
        }
125 +
    }
126 +
    if at.previous != Some(recorded.hash()) && pair.identity.previous != Some(recorded.hash()) {
127 +
        bail!("{} in {} is not a successor of the recorded owner {}", pair.identity.name, home.display(), recorded.hash().short(12));
128 +
    }
129 +
    let mut payload = identity_payload(&pair.identity);
130 +
    payload["chain"] = chain.into();
131 +
    ledger.append_one(signed_event(&pair, "human", "identity.rotated", payload)).await?;
132 +
    Ok(Some(pair.identity))
133 +
}
134 +
135 +
fn hostname() -> String {
136 +
    std::fs::read_to_string("/etc/hostname")
137 +
        .ok()
138 +
        .map(|h| h.trim().to_string())
139 +
        .filter(|h| !h.is_empty())
140 +
        .unwrap_or_else(|| "spectra".into())
141 +
}
142 +
143 +
fn toml_str(s: &str) -> String {
144 +
    serde_json::to_string(s).unwrap_or_default()
145 +
}
added tests/cli_identity.rs +109 −0
1 +
//! The owner and agents from the command line (spec §8.1, §8.3), as the
2 +
//! built binary in a hermetic environment: `init` proves the owner's
3 +
//! identity, `agent add` refuses until the owner delegates, ownership is
4 +
//! shown, and the ledger verifies throughout.
5 +
6 +
use oxy_core::durable::{write_atomic, write_atomic_private};
7 +
use oxy_core::identity::{Delegate, Identity, SecretKey};
8 +
use spectra_core::keys::KeyPair;
9 +
use spectra_core::principals::DELEGATION_SCOPE;
10 +
use std::path::Path;
11 +
use std::process::{Command, Output};
12 +
13 +
fn hermetic(dir: &Path) -> Command {
14 +
    let mut c = Command::new(env!("CARGO_BIN_EXE_spectra"));
15 +
    c.env("SPECTRA_DATA", dir.join("data"))
16 +
        .env("OXY_HOME", dir.join("owner"))
17 +
        .env("HOME", dir)
18 +
        .env("XDG_DATA_HOME", dir.join("xdg"))
19 +
        .env("NO_COLOR", "1");
20 +
    c
21 +
}
22 +
23 +
fn run(dir: &Path, args: &[&str]) -> Output {
24 +
    hermetic(dir).args(args).output().unwrap()
25 +
}
26 +
27 +
fn text(o: &Output) -> String {
28 +
    format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
29 +
}
30 +
31 +
/// `oxy identity delegate add IDENTITY --scope changes` in the owner's home.
32 +
fn delegate(owner_home: &Path, agent: &Identity) {
33 +
    let cur = KeyPair::load_home(owner_home).unwrap();
34 +
    let keys = owner_home.join("keys");
35 +
    let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap();
36 +
    let mut d = cur.identity.delegates.clone();
37 +
    d.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires: None });
38 +
    let next = cur.identity.successor(d, None, spectra_core::ids::now_ms());
39 +
    let k = next.hash();
40 +
    write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap();
41 +
    write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap();
42 +
    oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap();
43 +
}
44 +
45 +
fn verify_clean(dir: &Path) {
46 +
    let o = run(dir, &["admin", "verify", "--json"]);
47 +
    let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| panic!("{}", text(&o)));
48 +
    assert!(o.status.success() && v["findings"].as_array().unwrap().is_empty(), "{v}");
49 +
}
50 +
51 +
#[test]
52 +
fn init_then_an_owned_agent_end_to_end() {
53 +
    let dir = tempfile::tempdir().unwrap();
54 +
    let d = dir.path();
55 +
    KeyPair::create_home(&d.join("owner"), "Andy McCall", "[email protected]").unwrap();
56 +
57 +
    let o = run(d, &["init", "--name", "laptop"]);
58 +
    assert!(o.status.success(), "{}", text(&o));
59 +
    assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o));
60 +
    assert!(!run(d, &["init"]).status.success(), "a second init is refused");
61 +
    verify_clean(d);
62 +
63 +
    let home = d.join("agents/claude-oxygen");
64 +
    let home_s = home.to_str().unwrap();
65 +
    let args = ["agent", "add", "claude-oxygen", "--kind", "claude-code", "--project", "oxygen", "--home", home_s, "--wait", "0"];
66 +
    let o = run(d, &args);
67 +
    assert!(!o.status.success(), "refused until delegated");
68 +
    assert!(text(&o).contains("oxy identity delegate add") && text(&o).contains("not delegated yet"), "{}", text(&o));
69 +
    let agent = KeyPair::load_home(&home).unwrap();
70 +
71 +
    delegate(&d.join("owner"), &agent.identity);
72 +
    let o = run(d, &args);
73 +
    assert!(o.status.success(), "{}", text(&o));
74 +
    assert!(text(&o).contains("claude-oxygen · Andy McCall's agent"), "{}", text(&o));
75 +
    assert_eq!(KeyPair::load_home(&home).unwrap().key, agent.key, "the key made first is the key recorded");
76 +
77 +
    let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap();
78 +
    assert_eq!(list[0]["display"], "claude-oxygen · Andy McCall's agent");
79 +
    assert_eq!(list[0]["state"], "ok");
80 +
    verify_clean(d);
81 +
82 +
    assert!(run(d, &["agent", "disable", "claude-oxygen"]).status.success());
83 +
    let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap();
84 +
    assert!(list[0]["state"].as_str().unwrap().contains("disabled"), "{list}");
85 +
    verify_clean(d);
86 +
    assert!(run(d, &["admin", "check"]).status.success());
87 +
}
88 +
89 +
#[test]
90 +
fn an_owner_without_a_secret_key_cannot_init() {
91 +
    let dir = tempfile::tempdir().unwrap();
92 +
    let o = run(dir.path(), &["init"]);
93 +
    assert!(!o.status.success());
94 +
    assert!(text(&o).contains("the owner signs with their own identity"), "{}", text(&o));
95 +
}
96 +
97 +
#[tokio::test]
98 +
async fn a_second_writer_is_refused() {
99 +
    let dir = tempfile::tempdir().unwrap();
100 +
    let d = dir.path();
101 +
    KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap();
102 +
    assert!(run(d, &["init"]).status.success());
103 +
    let instance = KeyPair::instance(&d.join("data"), "x").unwrap();
104 +
    let held = spectra_core::ledger::Ledger::open(&d.join("data/spectra.db"), Some(instance)).await.unwrap();
105 +
    let o = run(d, &["agent", "disable", "nobody"]);
106 +
    assert!(text(&o).contains("another process is writing"), "{}", text(&o));
107 +
    assert!(run(d, &["admin", "verify"]).status.success(), "reading needs no lock");
108 +
    drop(held);
109 +
}
modified CHANGELOG.md +1 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Added: identities instead of tokens (#4).** `spectra init` records the owner's Oxygen identity, proven by signing with it, and makes the instance key. `spectra agent add` makes an agent's key pair in its own home and records the agent only once the owner's identity delegates to it (`oxy identity delegate add`), so every agent is somebody's: `claude-oxygen · Andy McCall's agent`. Expiry and `spectra agent disable` stop an agent's key. `spectra admin verify` replays identities and delegations and checks every signed event, refusing an event whose actor is not its signer. Requests are authenticated by signature, a five-minute window, single-use nonces and an optional Unix-user binding. One process writes a ledger at a time.
5 6
- **Added: the ledger (#3).** Every event is deterministic CBOR (through `oxy-core`) hashed with a domain-separated BLAKE3 and chained to the one before; the envelope, with its hybrid logical clock, is frozen. The instance key signs a checkpoint every 256 events, every ten minutes while writing, and on demand. `spectra admin verify [--json]` recomputes the chain and checks every checkpoint, catching an edited, removed or re-signed event; `spectra admin check` and `rebuild` keep projections equal to the ledger. A single append takes 0.15 ms.
6 7
- **Added: CI on OxygenFlow (#2).** `.flow/pipeline.kdl` in the shape of Oxygen's: fmt, clippy, test (nextest under llvm-cov, with JUnit and coverage for the hub) and build, in the vendored image with no network.
7 8
- **Added: the project skeleton (#1).** A Cargo workspace in Oxygen's shape (`spectra-core`, `spectra-mcp`, `spectra-api`, `radar` and the `spectra` binary), Oxygen pinned under `deps/oxygen` for identities and signatures, `make check`, the changelog check, `spectra version [--json]` with the build identity, and `scripts/deps-check.sh` to keep Spectra buildable by the vendored OxygenFlow image.
modified Cargo.lock +1 −0
…
1912 unchanged lines
1913 1913
 "sqlx",
1914 1914
 "tempfile",
1915 1915
 "tokio",
1916 +
 "toml",
1916 1917
]
1917 1918
1918 1919
[[package]]
…
1057 unchanged lines
modified Cargo.toml +3 −0
…
67 unchanged lines
68 68
serde_json.workspace = true
69 69
tokio.workspace = true
70 70
hex.workspace = true
71 +
toml.workspace = true
72 +
sqlx.workspace = true
71 73
console = "0.15"
72 74
indicatif = "0.17"
73 75
libc = "0.2"
…
2 unchanged lines
76 78
[dev-dependencies]
77 79
tempfile.workspace = true
78 80
sqlx.workspace = true
81 +
serde_json.workspace = true
79 82
80 83
[profile.release]
81 84
debug = 1
modified crates/spectra-core/src/ledger.rs +54 −9
…
28 unchanged lines
29 29
/// The ledger: the database, the chain head, the clock and the bus.
30 30
pub struct Ledger {
31 31
    pool: SqlitePool,
32 +
    /// Held for as long as this ledger may append: one writer per data
33 +
    /// directory, so the chain never forks (`None` when opened read-only).
34 +
    _lock: Option<std::fs::File>,
32 35
    head: Mutex<Head>,
33 36
    clock: Clock,
34 37
    bus: broadcast::Sender<Arc<Event>>,
…
13 unchanged lines
48 51
    /// written by a newer format. Checkpoints are signed with `instance`;
49 52
    /// without one (tests, read-only tools) none are made.
50 53
    pub async fn open(path: &Path, instance: Option<KeyPair>) -> Result<Ledger> {
54 +
        let lock_path = path.with_extension("lock");
55 +
        let lock = std::fs::OpenOptions::new()
56 +
            .create(true)
57 +
            .truncate(false)
58 +
            .write(true)
59 +
            .open(&lock_path)
60 +
            .with_context(|| format!("opening {}", lock_path.display()))?;
61 +
        match lock.try_lock() {
62 +
            Ok(()) => {}
63 +
            Err(std::fs::TryLockError::WouldBlock) => {
64 +
                bail!("another process is writing to {} (is `spectra serve` running? stop it, or do this through it)", path.display())
65 +
            }
66 +
            Err(std::fs::TryLockError::Error(e)) => return Err(e).with_context(|| format!("locking {}", lock_path.display())),
67 +
        }
68 +
        let mut l = Ledger::open_unlocked(path, instance).await?;
69 +
        l._lock = Some(lock);
70 +
        Ok(l)
71 +
    }
72 +
73 +
    /// Open for reading and verifying only, while another process may be
74 +
    /// writing: no lock, and `append` must not be called.
75 +
    pub async fn open_read_only(path: &Path) -> Result<Ledger> {
76 +
        Ledger::open_unlocked(path, None).await
77 +
    }
78 +
79 +
    async fn open_unlocked(path: &Path, instance: Option<KeyPair>) -> Result<Ledger> {
51 80
        let opts = SqliteConnectOptions::new()
52 81
            .filename(path)
53 82
            .create_if_missing(true)
…
33 unchanged lines
87 116
        let (bus, _) = broadcast::channel(4096);
88 117
        Ok(Ledger {
89 118
            pool,
119 +
            _lock: None,
90 120
            head: Mutex::new(Head { seq, hash, since_checkpoint: since, oldest_unchecked_ms: (since > 0).then(ids::now_ms) }),
91 121
            clock: Clock::starting_at(hlc),
92 122
            bus,
…
142 unchanged lines
235 265
        rows.iter().map(row_to_event).collect()
236 266
    }
237 267
268 +
    /// Events of the given kinds from `from` (inclusive), oldest first.
269 +
    pub async fn events_of_kinds(&self, from: u64, kinds: &[&str], limit: u32) -> Result<Vec<Event>> {
270 +
        let marks = vec!["?"; kinds.len()].join(",");
271 +
        let sql = format!("{SELECT_EVENT} WHERE seq >= ? AND kind IN ({marks}) ORDER BY seq LIMIT ?");
272 +
        let mut q = sqlx::query(&sql).bind(from as i64);
273 +
        for k in kinds {
274 +
            q = q.bind(*k);
275 +
        }
276 +
        let rows = q.bind(limit as i64).fetch_all(&self.pool).await?;
277 +
        rows.iter().map(row_to_event).collect()
278 +
    }
279 +
238 280
    /// Recompute the chain and check every checkpoint and every proof it
239 281
    /// can, from the ledger alone (spec §9.5, `spectra admin verify`).
240 -
    pub async fn verify(&self, keys: &dyn KeyResolver) -> Result<Verification> {
282 +
    pub async fn verify(&self, keys: &mut dyn KeyResolver) -> Result<Verification> {
241 283
        let mut v = Verification::default();
242 284
        let mut prev = [0u8; 32];
243 285
        let mut expect = 1u64;
…
29 unchanged lines
273 315
                    match check_checkpoint(e, prev, instance.as_ref()) {
274 316
                        Ok(()) => v.last_checkpoint = Some(e.seq),
275 317
                        Err(why) => v.finding(e.seq, Problem::BadCheckpoint, why),
276 -
                    }
277 -
                } else if e.auth.method == Method::Signature || e.auth.method == Method::Passkey {
278 -
                    if let Err(why) = keys.check(e) {
279 -
                        v.finding(e.seq, Problem::BadProof, why);
280 318
                    }
319 +
                } else if let Err(why) = keys.check(e) {
320 +
                    v.finding(e.seq, Problem::BadProof, why);
281 321
                }
282 322
                prev = e.hash;
283 323
                expect += 1;
…
63 unchanged lines
347 387
348 388
/// Checks the proof of a signed event: the identities and delegations that
349 389
/// say whose key it was (spec §8.5). Principals supply it (`principals`).
350 -
pub trait KeyResolver: Send + Sync {
351 -
    fn check(&self, e: &Event) -> std::result::Result<(), String>;
390 +
/// It is given every event but checkpoints, in order, so it can follow
391 +
/// identities as they are recorded.
392 +
pub trait KeyResolver: Send {
393 +
    fn check(&mut self, e: &Event) -> std::result::Result<(), String>;
352 394
}
353 395
354 396
/// A resolver that knows no keys: every signed event is reported.
355 397
pub struct NoKeys;
356 398
impl KeyResolver for NoKeys {
357 -
    fn check(&self, _: &Event) -> std::result::Result<(), String> {
358 -
        Err("no identity is known for its key".into())
399 +
    fn check(&mut self, e: &Event) -> std::result::Result<(), String> {
400 +
        match e.auth.method {
401 +
            Method::Signature | Method::Passkey => Err("no identity is known for its key".into()),
402 +
            _ => Ok(()),
403 +
        }
359 404
    }
360 405
}
361 406
…
145 unchanged lines
modified crates/spectra-core/src/lib.rs +3 −0
…
4 unchanged lines
5 5
//! Identities, delegation, composite signatures, deterministic CBOR and
6 6
//! hashing come from `oxy-core`; nothing here reimplements them.
7 7
8 +
pub mod authn;
8 9
pub mod event;
9 10
pub mod hlc;
10 11
pub mod ids;
11 12
pub mod keys;
12 13
pub mod ledger;
14 +
pub mod principals;
13 15
pub mod projections;
16 +
pub mod proof;
14 17
15 18
/// The ledger's format version: the frozen event envelope (spec §9.1). A
16 19
/// database written with a newer format is refused, never guessed at.
…
1 unchanged lines
modified crates/spectra-core/src/projections.rs +2 −1
…
6 unchanged lines
7 7
use sqlx::SqliteConnection;
8 8
9 9
/// The projection tables, in the order they are cleared and compared.
10 -
pub const TABLES: &[&str] = &["event_counts"];
10 +
pub const TABLES: &[&str] = &["event_counts", "identities", "agents"];
11 11
12 12
/// Apply one event to every projection.
13 13
pub async fn apply(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
…
1 unchanged lines
15 15
        .bind(&e.kind)
16 16
        .execute(&mut *conn)
17 17
        .await?;
18 +
    crate::principals::project(conn, e).await?;
18 19
    Ok(())
19 20
}
modified crates/spectra-core/tests/ledger.rs +7 −7
…
41 unchanged lines
42 42
    }
43 43
    let cp = ledger.checkpoint().await.unwrap().expect("events since the last checkpoint");
44 44
    assert_eq!(cp.kind, "ledger.checkpoint");
45 -
    let v = ledger.verify(&NoKeys).await.unwrap();
45 +
    let v = ledger.verify(&mut NoKeys).await.unwrap();
46 46
    assert!(v.clean(), "{:?}", v.findings);
47 47
    assert_eq!(v.checkpoints, 2, "one at 256 events, one on demand");
48 48
    assert_eq!(v.uncovered, 0);
…
11 unchanged lines
60 60
    ledger.checkpoint().await.unwrap();
61 61
    drop(ledger);
62 62
    raw(dir.path(), r#"UPDATE events SET payload = CAST('{"body":"message 5, edited","n":5}' AS BLOB) WHERE subject = 'm5'"#).await;
63 -
    let v = open(dir.path()).await.verify(&NoKeys).await.unwrap();
63 +
    let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap();
64 64
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
65 65
    assert_eq!(v.findings[0].problem, Problem::HashMismatch);
66 66
}
…
17 unchanged lines
84 84
        &format!(r#"UPDATE events SET payload = CAST('{{"body":"forged","n":19}}' AS BLOB), hash = X'{forged}' WHERE seq = {}"#, e.seq),
85 85
    )
86 86
    .await;
87 -
    let v = open(dir.path()).await.verify(&NoKeys).await.unwrap();
87 +
    let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap();
88 88
    assert!(v.findings.iter().any(|f| f.problem == Problem::ChainBreak || f.problem == Problem::BadCheckpoint), "{:?}", v.findings);
89 89
}
90 90
…
6 unchanged lines
97 97
    }
98 98
    drop(ledger);
99 99
    raw(dir.path(), "DELETE FROM events WHERE subject = 'm4'").await;
100 -
    let v = open(dir.path()).await.verify(&NoKeys).await.unwrap();
100 +
    let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap();
101 101
    assert!(v.findings.iter().any(|f| f.problem == Problem::Gap), "{:?}", v.findings);
102 102
    assert!(v.findings.iter().any(|f| f.problem == Problem::ChainBreak), "{:?}", v.findings);
103 103
}
…
9 unchanged lines
113 113
    let sig = mallory.sign(&spectra_core::ledger::checkpoint_message(cp.seq - 1, &cp.prev));
114 114
    let proof = hex::encode(oxy_core::cbor::encode(&sig.to_value()));
115 115
    raw(dir.path(), &format!("UPDATE events SET auth_proof = X'{proof}' WHERE seq = {}", cp.seq)).await;
116 -
    let v = open(dir.path()).await.verify(&NoKeys).await.unwrap();
116 +
    let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap();
117 117
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
118 118
    assert_eq!(v.findings[0].problem, Problem::BadCheckpoint);
119 119
}
…
20 unchanged lines
140 140
    assert!(ledger.check().await.unwrap().is_empty(), "projections drifted from the ledger");
141 141
    ledger.rebuild().await.unwrap();
142 142
    assert!(ledger.check().await.unwrap().is_empty());
143 -
    let v = ledger.verify(&NoKeys).await.unwrap();
143 +
    let v = ledger.verify(&mut NoKeys).await.unwrap();
144 144
    assert!(v.clean(), "{:?}", &v.findings[..v.findings.len().min(3)]);
145 145
    assert!(v.checkpoints >= 10_000 / CHECKPOINT_EVERY);
146 146
    // Generous in a debug build; the release number is in docs/plan.md.
…
29 unchanged lines
176 176
    let ledger = open(dir.path()).await;
177 177
    let e = ledger.append_one(msg(2)).await.unwrap();
178 178
    assert_eq!((e.seq, e.prev), (seq + 1, hash));
179 -
    assert!(ledger.verify(&NoKeys).await.unwrap().clean());
179 +
    assert!(ledger.verify(&mut NoKeys).await.unwrap().clean());
180 180
}
181 181
182 182
#[tokio::test]
…
12 unchanged lines
modified docs/plan.md +2 −1
…
16 unchanged lines
17 17
- [x] #1 Project skeleton in Oxygen's shape — done 2026-11-02: workspace, deps/oxygen pinned, make check, deps-check (212 crates, all vendored).
18 18
- [x] #2 CI on OxygenFlow — done 2026-11-02: run #1 passed fmt, clippy, test, build with no image rebuild.
19 19
- [x] #3 The ledger — done 2026-11-02: chain, frozen envelope, HLC, signed checkpoints every 256 events, verify/check/rebuild; append 0.15 ms (release), 10,000 random events with rebuild and verify 1.3 s.
20 -
- [ ] #4 Identities instead of tokens
20 +
- [x] #4 Identities instead of tokens — done 2026-11-02: init, agent add (waits for the delegation), list, disable, identity show/refresh; Principals replay in verify; Authenticator (window, nonce, uid); write lock. Checked against the real `oxy identity delegate add`. The socket and its SO_PEERCRED arrive with #7, which uses the Authenticator.
21 21
- [ ] #5 Projects, presence and sessions
22 22
- [ ] #6 Channels, threads, messages, inbox and search
23 23
- [ ] #7 Tuner: MCP over Streamable HTTP
…
8 unchanged lines
32 32
33 33
- 2026-11-02 plan created; sprint adjusted to spec Draft 0.3 (identities and the ledger).
34 34
- 2026-11-02 #1 and #2 done (CI green on run #1); #3 done. Root disk was full: build trees go to /mnt/development/spectra-target (CARGO_TARGET_DIR).
35 +
- 2026-11-02 #4 done. Found and fixed while testing: a signed event's actor was not checked against its signer.
modified docs/spec.md +10 −7
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.3 |
5 +
| **Status** | Draft 0.3a |
6 6
| **Date** | 2026-11-02 |
7 7
| **Scope** | One Rust binary, `spectra`, that AI coding agents on one machine connect to over the Model Context Protocol (MCP) to find each other, talk, hand work over, keep to the house rules, remember what was said and decided, and let the human see all of it live. The hosted, multi-person, multi-machine layer is SpectraHub (`../spectrahub/docs/spec.md`). |
8 8
| **Companion documents** | `../spectrahub/docs/spec.md`, `../spectra-vscode/docs/spec.md`, `../spectrahub-vscode/docs/spec.md` |
…
246 unchanged lines
255 255
spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen
256 256
```
257 257
258 -
1. Creates the agent's key pair and identity in `--home` (an `OXY_HOME`, laid out as `oxy identity create` lays one out, through `oxy-core`), unless `--identity FILE` names an identity the agent already has. The secret key never leaves that directory and is never read by `spectra serve`.
259 -
2. Prints the one command the owner runs to make it theirs, and waits for it: `oxy identity delegate add ~/.oxy-agents/claude-oxygen/identity --scope changes --expires 8h`. The owner's current identity (§8.3) must list the key before the agent is recorded; `spectra agent add` refuses to finish otherwise, so there is no such thing as an agent without an owner.
258 +
1. Refuses a name already taken. Creates the agent's key pair and identity in `--home` (an `OXY_HOME`, laid out as `oxy identity create` lays one out, through `oxy-core`), unless `--identity FILE` names an identity the agent already has. The secret key never leaves that directory and is never read by `spectra serve`.
259 +
2. Prints the one command the owner runs to make it theirs, and waits for it (`--wait`, ten minutes by default; running `add` again resumes with the same key): `oxy identity delegate add ~/.oxy-agents/claude-oxygen/identity --scope changes --expires 8h`. The owner's current identity (§8.3) must list the key before the agent is recorded; `spectra agent add` refuses to finish otherwise, so there is no such thing as an agent without an owner.
260 260
3. Records `agent.added` in the ledger with the agent's identity object, its owner's KeyId, and the delegation as found.
261 261
262 262
```
…
14 unchanged lines
277 277
278 278
**Ownership is shown everywhere.** Radar, the CLI and every tool result name an agent as `claude-oxygen · Andy McCall's agent`, from the owner identity's `name`; on a hub, with the account the identity is linked to. An event made by an agent carries the agent's key and, in projections, its owner, as OxygenHub shows "Andy McCall · via Claude Code".
279 279
280 -
**Expiry and revocation are Oxygen's.** Authentication (§8.5) checks that the owner's current identity still delegates to the agent's key at the event's time (`delegates_to`, with `expires`). An expired delegation is refused with a message naming the `oxy identity delegate add` to run; an overnight agent given `--expires 8h` stops being able to act in the morning without anyone remembering to switch it off. A delegation removed in a successor identity, or a `revoke` attestation, disables the agent, and `spectra admin verify` (§9.5) marks what it signed as *valid but revoked*, as `oxy verify` does. The owner's successor identities reach Spectra through `spectra identity refresh` (reads `oxy identity export` from the owner's home) or through a hub that already links them (§23.3).
280 +
**Expiry and revocation are Oxygen's.** Authentication (§8.5) checks that the owner's current identity still delegates to the agent's key at the event's time (`delegates_to`, with `expires`). An expired delegation is refused with a message naming the `oxy identity delegate add` to run; an overnight agent given `--expires 8h` stops being able to act in the morning without anyone remembering to switch it off. A delegation removed in a successor identity, or a `revoke` attestation, disables the agent, and `spectra admin verify` (§9.5) marks what it signed as *valid but revoked*, as `oxy verify` does. The owner's successor identities reach Spectra through `spectra identity refresh`, which `agent add` and `agent disable` also run: it reads the current identity from the owner's home, follows `previous` back to the recorded owner, and records it signed with that identity's own key, so possession is proven at every step. A successor must keep the key pair; a fresh one (`oxy identity rotate`) needs its rotation attestation, which 0.1 does not accept yet.
281 281
282 282
An agent MAY belong to one project. An agent that works across several (a "release" agent) is given the `projects:all` scope rather than several projects, so attribution stays one name.
283 283
…
34 unchanged lines
318 318
319 319
**Transports.** Local principals reach Spectra over the Unix socket `<data>/spectra.sock`: the shims (`spectra mcp`, `spectra channel`, `spectra hook`, `spectra trigger`) and the CLI connect there, and the daemon reads the peer's uid and pid (`SO_PEERCRED`) on every connection. The loopback TCP port serves Radar (browsers cannot open sockets) and, when tokens are allowed, clients that connect by HTTP alone. Both carry the same HTTP and the same MCP endpoint.
320 320
321 -
**Signed requests.** A shim signs every request it forwards with the agent's key; the CLI, with the owner's. The headers are `Spectra-Key` (the KeyId), `Spectra-Time` (milliseconds), `Spectra-Nonce` (16 random bytes, hex) and `Spectra-Signature` (an Oxygen composite signature, oxygen spec §14.3, with the domain string `spectra1 request`, over `method || BLAKE3(body) || time || nonce`). The daemon refuses a time more than five minutes from its clock, a nonce it has seen in that window, a key it does not know, and a key whose owner's current identity does not delegate to it now (§8.1). The signature is kept as the resulting events' proof (§9.1), so an event can be checked later against the identity in the ledger without trusting the daemon that wrote it.
321 +
**Signed requests.** A shim signs every request it forwards with the agent's key; the CLI, with the owner's. The headers are `Spectra-Key` (the KeyId), `Spectra-Time` (milliseconds), `Spectra-Nonce` (16 random bytes, hex) and `Spectra-Signature` (an Oxygen composite signature, oxygen spec §14.3, over `"spectra1 request " || method || 0x00 || BLAKE3(body) || time || nonce`, made through `oxy-core`'s challenge signing so the signed target is `BLAKE3("oxy1 challenge " || message)` and can never be taken for a change, an attestation or a policy). The daemon refuses a time more than five minutes from its clock, a nonce it has seen in that window, a key it does not know, and a key whose owner's current identity does not delegate to it now (§8.1). The signature is kept as the resulting events' proof (§9.1), so an event can be checked later against the identity in the ledger without trusting the daemon that wrote it.
322 322
323 323
**Passkey assertions** are the proof of the human's Radar actions (§8.3). **Tokens**, when allowed, are proof of nothing beyond possession and are recorded as such.
324 324
…
7 unchanged lines
332 332
333 333
**Policy** (`[policy]` in `spectra.toml`) names the level each class of the owner's actions needs. Defaults: `chat = 2`, `tasks = 2`, `human_answers = 3`, `permission_verdicts = 3`, `rules = 3`, `identities = 3`, `admin = 3` (pruning, retention, exports that leave the machine). An agent's actions are limited by its scopes (§8.4) and are level 2 at most; the classes above that are the owner's cannot be reached by an agent at any level. Lowering a class to 2 is allowed, for a single-user machine the owner trusts, and is itself a level-3 action that leaves a `policy.weakened` event and a standing banner in Radar.
334 334
335 -
**Key to uid.** An agent's record MAY carry the Unix uid its key is used from (`spectra agent add --uid`, or learnt from the first signed connection with `--learn-uid`). A request signed with that key from another uid is refused, and `guard.impersonation` is logged with both uids and pids and shown to the owner. The same key seen on two live connections from different pids under one uid is logged as `guard.key_shared`. These are detection, not prevention, under one uid (§5.3); with one uid per agent (§21.6) they are prevention.
335 +
**One writer.** Only one process appends to a data directory's ledger at a time: it holds an exclusive lock on `spectra.lock`, so the chain can never fork. While `spectra serve` runs, the CLI's writes go through it; reading and `spectra admin verify` need no lock.
336 336
337 +
**Key to uid.** An agent's record MAY carry the Unix uid its key is used from (`spectra agent add --uid`, or learnt from the first signed connection with `--learn-uid`, recorded as `agent.uid_bound`). A request signed with that key from another uid is refused, and `guard.impersonation` is logged with both uids and pids and shown to the owner. The same key seen on two live connections from different pids under one uid is logged as `guard.key_shared`. These are detection, not prevention, under one uid (§5.3); with one uid per agent (§21.6) they are prevention.
337 338
338 339
340 +
339 341
## 9. The event log and the data model
340 342
341 343
### 9.1 Events
…
63 unchanged lines
405 407
406 408
- **Canonical encoding.** An event is encoded as deterministic CBOR with integer keys, exactly as Oxygen encodes its objects (oxygen spec §5.1, through `oxy-core`), and hashed as `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`, the domain-separated form of oxygen spec §5.2, with `hash` left out of the body. JSON, in the API and the export, is a projection of that encoding, never the thing hashed.
407 409
- **The chain.** `prev` is the previous event's `hash`; the first event's is zero. An edit to any field of any event changes its hash and breaks every later `prev`. Removing or reordering events does the same. A pruned event's stub keeps `prev` and `hash`, so the chain verifies across it.
408 -
- **Proofs.** `auth.proof` is the request signature (§8.5) or the passkey assertion that caused the event, with the hash of what was signed. Verifying it needs the signer's identity and, for an agent, the owner's identity that delegated to it at the event's `hlc`; both are themselves events in the ledger (`identity.*`, `agent.added`), so verification is closed over the ledger and needs nothing else.
410 +
- **Proofs.** `auth.proof` is the request signature (§8.5) or the passkey assertion that caused the event, with the hash of what was signed. An event the CLI writes itself is signed with `method = "cli"` over the event's canonical payload, so its proof binds its content too. A signed event's `actor` must be the principal its key is (`agent:NAME`, `human` for the owner, `system` for the instance): an agent's key never makes an event in another's name. Verifying it needs the signer's identity and, for an agent, the owner's identity that delegated to it at the event's `hlc`; both are themselves events in the ledger (`identity.*`, `agent.added`), so verification is closed over the ledger and needs nothing else.
409 411
- **Checkpoints.** Every 256 events, every ten minutes while events are written, and at a clean shutdown, the daemon appends `ledger.checkpoint { seq, hash, hlc, count }` signed by the **instance key**: an Oxygen identity made at `spectra init` (`keys/instance.secret`, 0600; its identity object is the `identity.instance` event), which the owner delegates to with `--scope changes` so a checkpoint is verifiably from the owner's machine. Checkpoints go up the uplink (§23.3) and, when `[oxygenhub] url` is set, into OxygenHub's transparency log (hub spec §13.5.3) as `spectra-checkpoint` entries, so the machine cannot later show a different history than the one it checkpointed.
410 412
- **Verification.** `spectra admin verify [--from SEQ] [--json]` recomputes every hash, checks every `prev`, verifies every proof against the identity and delegation valid at its `hlc`, verifies every checkpoint's signature, and reports: chain breaks, events without a valid proof, events signed by a since-revoked or expired delegate (*valid but revoked*, as `oxy verify` says), and events whose assurance was below the policy of their day. Exit 0 when clean, 1 otherwise. It runs nightly, records `ledger.verified`, and Radar's Log page shows the last result and the head's hash. `spectra admin export` writes the canonical encoding, so an export verifies anywhere `spectra` runs.
411 413
- **Signatures are outside the hash** (oxygen spec §5.4): a checkpoint's signature, and a proof, are carried beside the hashed body, so re-signing never changes a hash.
…
578 unchanged lines
990 992
991 993
## 27. Changelog
992 994
995 +
- Draft 0.3a (2026-11-02), while building #3 and #4: request and checkpoint signatures go through `oxy-core`'s challenge signing with Spectra's prefix inside the message (§8.5, §9.5); CLI-written events are signed over their payload, and a signed event's actor must be its signer (§9.5); one writer per data directory under a lock (§8.5); `identity refresh` proves possession at every successor and refuses a fresh key pair in 0.1 (§8.1); `agent add` waits for the delegation and resumes (§8.1).
993 996
- Draft 0.3 (2026-11-02), security: agents are their owner's Oxygen delegates with ownership verified and shown (§8.1, G7); no owner token: the owner signs from the CLI and uses a passkey in Radar (§8.3); a Unix socket with peer credentials, signed requests, assurance levels and the policy that keeps the human's authority out of agents' reach (§8.5, G8); the ledger: hash chain, proofs on every event, checkpoints signed by an instance key and witnessed by OxygenHub's transparency log, `spectra admin verify`, pruning to stubs (§9.5, G6); full tool-call payloads content addressed (§9.2); committed rules with their `oxy` change (§12.4); claimed changes verified (§23.5); one uid per agent as the recommended deployment and the limit stated (§5.3, §21.6).
994 997
- Draft 0.2 (2026-11-02), after review: `wait` is for in-task waiting only and is refused when idle (§14.1); `spectra hook stop --wait` and `spectra trigger` wake agents at no model cost (§14.3, §14.4); an owner token on every request, loopback included (§8.3); the lifecycle transitions folded into `task_update`, claims and schedules into one tool each, twenty tools with a token budget on `tools/list` (§15.2); a hybrid logical clock in the event envelope, which is frozen at 0.1 (§9.1); SpectraHub deferred until Spectra 0.3 is in daily use (hub spec §17).
995 998
- Draft 0.1 (2026-11-02): initial specification: event-sourced core, three delivery tiers against MCP 2026-07-28, tasks with contracts and lifecycle, claims, layered rules with versions, Echo search, Radar, guard rails, the Hub split and the Oxygen integration.
modified src/cli/admin.rs +4 −3
…
1 unchanged lines
2 2
3 3
use super::{ui, Ctx};
4 4
use anyhow::Result;
5 -
use spectra_core::ledger::{Ledger, NoKeys};
5 +
use spectra_core::ledger::Ledger;
6 +
use spectra_core::principals::Principals;
6 7
7 8
/// `spectra admin verify`: recompute the chain, check every checkpoint and
8 9
/// proof; exit 1 when anything is wrong.
9 10
pub async fn verify(ctx: &Ctx, json: bool) -> Result<i32> {
10 -
    let ledger = Ledger::open(&ctx.db()?, None).await?;
11 -
    let v = ledger.verify(&NoKeys).await?;
11 +
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
12 +
    let v = ledger.verify(&mut Principals::default()).await?;
12 13
    if json {
13 14
        println!("{}", serde_json::to_string(&v)?);
14 15
    } else if v.clean() {
…
41 unchanged lines
modified src/cli/mod.rs +108 −0
…
2 unchanged lines
3 3
//! `NO_COLOR`; `--json` prints one document on stdout and nothing else.
4 4
5 5
pub mod admin;
6 +
pub mod agent;
6 7
pub mod format;
8 +
pub mod identity;
7 9
pub mod json;
8 10
pub mod ui;
9 11
…
22 unchanged lines
32 34
        #[arg(long)]
33 35
        json: bool,
34 36
    },
37 +
    /// Make this machine's Spectra: record the owner's identity (read from
38 +
    /// their OXY_HOME and proven by signing) and make the instance key
39 +
    Init {
40 +
        /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy)
41 +
        #[arg(long)]
42 +
        owner_home: Option<PathBuf>,
43 +
        /// This instance's name (default: the host name)
44 +
        #[arg(long)]
45 +
        name: Option<String>,
46 +
    },
47 +
    /// The owner's and the instance's identities: show, refresh
48 +
    Identity {
49 +
        #[command(subcommand)]
50 +
        command: IdentityCmd,
51 +
    },
52 +
    /// Agents, each its owner's delegate: add, list, disable
53 +
    Agent {
54 +
        #[command(subcommand)]
55 +
        command: AgentCmd,
56 +
    },
35 57
    /// The ledger's own checks: verify, check, rebuild
36 58
    Admin {
37 59
        #[command(subcommand)]
38 60
        command: Admin,
61 +
    },
62 +
}
63 +
64 +
#[derive(Subcommand)]
65 +
pub enum IdentityCmd {
66 +
    /// The owner and the instance as the ledger knows them
67 +
    Show {
68 +
        #[arg(long)]
69 +
        json: bool,
70 +
    },
71 +
    /// Record the owner's current identity after `oxy identity delegate add|remove`
72 +
    Refresh {
73 +
        #[arg(long)]
74 +
        owner_home: Option<PathBuf>,
39 75
    },
40 76
}
41 77
42 78
#[derive(Subcommand)]
79 +
pub enum AgentCmd {
80 +
    /// Make an agent's key in its own home and record it once you delegate to it
81 +
    Add {
82 +
        /// Lower-case letters, digits and dashes
83 +
        name: String,
84 +
        /// claude-code, antigravity, kimi-code, codex or other
85 +
        #[arg(long)]
86 +
        kind: String,
87 +
        /// The project it works in
88 +
        #[arg(long)]
89 +
        project: String,
90 +
        /// The agent's own OXY_HOME, where its key is made and kept
91 +
        #[arg(long)]
92 +
        home: PathBuf,
93 +
        /// Bind the key to this Unix user: a request from another is refused
94 +
        #[arg(long)]
95 +
        uid: Option<u32>,
96 +
        /// Bind the key to the Unix user of its first signed connection
97 +
        #[arg(long, conflicts_with = "uid")]
98 +
        learn_uid: bool,
99 +
        /// How long to wait for your delegation, in seconds
100 +
        #[arg(long, default_value_t = 600)]
101 +
        wait: u64,
102 +
        /// The owner's OXY_HOME
103 +
        #[arg(long)]
104 +
        owner_home: Option<PathBuf>,
105 +
    },
106 +
    /// Every agent with its owner, key, delegation and state
107 +
    List {
108 +
        #[arg(long)]
109 +
        json: bool,
110 +
    },
111 +
    /// Refuse an agent's key from now on
112 +
    Disable {
113 +
        name: String,
114 +
        #[arg(long)]
115 +
        owner_home: Option<PathBuf>,
116 +
    },
117 +
}
118 +
119 +
#[derive(Subcommand)]
43 120
pub enum Admin {
44 121
    /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding)
45 122
    Verify {
…
21 unchanged lines
67 144
        }
68 145
        Ok(db)
69 146
    }
147 +
148 +
    /// The ledger, for writing: the instance key signs its checkpoints, and
149 +
    /// the lock keeps any other writer out.
150 +
    pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> {
151 +
        let db = self.db()?;
152 +
        let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?;
153 +
        spectra_core::ledger::Ledger::open(&db, Some(instance)).await
154 +
    }
155 +
156 +
    /// `[owner] home` from spectra.toml.
157 +
    pub fn config_owner_home(&self) -> Option<PathBuf> {
158 +
        let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?;
159 +
        let t: toml::Table = text.parse().ok()?;
160 +
        t.get("owner")?.get("home")?.as_str().map(PathBuf::from)
161 +
    }
70 162
}
71 163
72 164
fn default_data() -> PathBuf {
…
14 unchanged lines
87 179
    let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) };
88 180
    match cli.command {
89 181
        Command::Version { json } => version(json),
182 +
        Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)),
183 +
        Command::Identity { command } => runtime()?.block_on(async {
184 +
            match command {
185 +
                IdentityCmd::Show { json } => identity::show(&ctx, json).await,
186 +
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
187 +
            }
188 +
        }),
189 +
        Command::Agent { command } => runtime()?.block_on(async {
190 +
            match command {
191 +
                AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => {
192 +
                    agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await
193 +
                }
194 +
                AgentCmd::List { json } => agent::list(&ctx, json).await,
195 +
                AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await,
196 +
            }
197 +
        }),
90 198
        Command::Admin { command } => runtime()?.block_on(async {
91 199
            match command {
92 200
                Admin::Verify { json } => admin::verify(&ctx, json).await,
…
23 unchanged lines
modified tests/cli_admin.rs +7 −1
…
22 unchanged lines
23 23
    std::fs::create_dir_all(data).unwrap();
24 24
    let instance = KeyPair::instance(data, "test").unwrap();
25 25
    let ledger = Ledger::open(&data.join("spectra.db"), Some(instance.clone())).await.unwrap();
26 +
    // As `spectra init` records it: signed by the instance key.
26 27
    ledger
27 -
        .append_one(NewEvent::system("identity.instance", serde_json::json!({ "identity": hex::encode(instance.identity.encode()) })))
28 +
        .append_one(spectra_core::principals::signed_event(
29 +
            &instance,
30 +
            "system",
31 +
            "identity.instance",
32 +
            spectra_core::principals::identity_payload(&instance.identity),
33 +
        ))
28 34
        .await
29 35
        .unwrap();
30 36
    for i in 0..n {
…
37 unchanged lines