OXYGENHUB

Scoped, expiring delegates, and revocation by the delegator 7ba155f20aa0

An identity's delegates are entries {key, scope, expires}. The scope
is any of changes (sign changes whose author is the delegator), seal
(seal them) and refs (attest references and tags as the delegator
would); a delegation past its expiry authenticates nothing made after
it. An AI agent is added with --scope changes, so its signature
authenticates what it makes but a reference or tag it attests, or a
seal it signs, is refused: it can never land on a protected bookmark,
even when its owner could. A host that signs and lands for a user
gets changes,refs. A bare KeyId in an older identity means every
scope, so what is already stored still reads.

oxy identity delegate add --scope --expires (30m, 8h, 7d, days);
delegate list and identity show print scope and expiry; oxy identity
revoke accepts a key you delegated to, not only your own or a root's,
and re-issues your identity without it.

Spec §14.2, §14.4, §14.5; guide §9; the agent workflows doc marks
both items done. Tests: a changes-only delegate's change lands when
its owner attests and not when it does; its seal is refused; an
expired delegation is refused; the delegator may revoke it.

closes #67, refs #64
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 02:43 UTC
Signature
✔ signed by Andy McCall
Hash
7ba155f20aa080b927154967d98e3d13a6cde4c1c092df385a728adad1c3b5e9
Depends on
05e71ed6cdf1 132b884b9a8d 1983686fb7a7 28fc23d7706a 3526d339519f 648724d8e0de 75167672ebcd 83e0677b07fe 84b9e9ac0ab4 86e682c242c6 982864068a59 b277870b0749 d42369abf180 eae595568832 ee06a6c557b7 f8c695764c95 fce44ae9ad29 ff0af1ee299d
Depended on by
d36ba2f79f95 028545dfa7a4 a2387d547373 f53cbe7a204e f0a517d28237 3a6effc74835 02e691881a4e e5d9b0513f3e 68a924846379 614f55e14eee 5cde7882d767 445ce913ba56 ff1a64f81c1c
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.10.0 v0.11.0 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
74 atoms, 0 tree operations, 13 files

Browse the tree at this change

modified README.md +3 −2
…
69 unchanged lines
70 70
commits on top of them, and `oxy git export <dir>` writes a plain Git mirror.
71 71
72 72
Keys have a lifecycle: `oxy identity rotate` moves to a fresh key pair (the policy follows),
73 -
`oxy identity renew --expires 365` sets an expiry, `oxy identity delegate add bot.identity`
74 -
lets another key sign for you, and `oxy identity revoke` retires a key. With several root
73 +
`oxy identity renew --expires 365` sets an expiry, `oxy identity delegate add agent.identity --scope changes --expires 8h`
74 +
lets another key sign changes for you (an AI agent, say) for a while without ever being able
75 +
to move a bookmark, and `oxy identity revoke` retires a key. With several root
75 76
keys, `oxy policy root add|remove|threshold` and `oxy policy sign` gather the required signatures
76 77
through proposal files.
77 78
…
15 unchanged lines
modified crates/oxy-core/src/identity.rs +220 −19
…
25 unchanged lines
26 26
/// `alg` value for Ed25519 + ML-DSA-65.
27 27
pub const ALG_COMPOSITE: u64 = 1;
28 28
29 +
/// What a delegate may do on the delegator's behalf (spec §14.2): a set of
30 +
/// `changes` (sign individual changes), `seal` (seal batches of them) and
31 +
/// `refs` (reference and tag attestations). An agent gets `changes` alone,
32 +
/// so it can never move a reference, whatever its owner may do.
33 +
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
34 +
pub struct Scope(u8);
35 +
36 +
impl Scope {
37 +
    pub const NONE: Scope = Scope(0);
38 +
    pub const CHANGES: Scope = Scope(1);
39 +
    pub const REFS: Scope = Scope(2);
40 +
    pub const SEAL: Scope = Scope(4);
41 +
    /// Everything: what a delegate could do before scopes existed.
42 +
    pub const ALL: Scope = Scope(7);
43 +
    const NAMES: [(Scope, &'static str); 3] = [
44 +
        (Scope::CHANGES, "changes"),
45 +
        (Scope::REFS, "refs"),
46 +
        (Scope::SEAL, "seal"),
47 +
    ];
48 +
49 +
    pub fn has(self, other: Scope) -> bool {
50 +
        self.0 & other.0 == other.0
51 +
    }
52 +
    pub fn is_empty(self) -> bool {
53 +
        self.0 == 0
54 +
    }
55 +
    /// The names in the set, in the order they are written.
56 +
    pub fn names(self) -> Vec<&'static str> {
57 +
        Self::NAMES
58 +
            .iter()
59 +
            .filter(|(s, _)| self.has(*s))
60 +
            .map(|(_, n)| *n)
61 +
            .collect()
62 +
    }
63 +
    fn named(name: &str) -> Option<Scope> {
64 +
        Self::NAMES
65 +
            .iter()
66 +
            .find(|(_, n)| *n == name)
67 +
            .map(|(s, _)| *s)
68 +
    }
69 +
    /// `changes,refs` (also with spaces); `all` is every scope.
70 +
    pub fn parse(s: &str) -> Result<Scope, String> {
71 +
        let mut out = Scope::NONE;
72 +
        for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
73 +
            if part == "all" {
74 +
                return Ok(Scope::ALL);
75 +
            }
76 +
            match Self::named(part) {
77 +
                Some(sc) => out = out | sc,
78 +
                None => return Err(format!("unknown scope {part:?}: use changes, refs or seal")),
79 +
            }
80 +
        }
81 +
        if out.is_empty() {
82 +
            return Err("a delegate needs at least one scope: changes, refs or seal".into());
83 +
        }
84 +
        Ok(out)
85 +
    }
86 +
}
87 +
88 +
impl std::ops::BitOr for Scope {
89 +
    type Output = Scope;
90 +
    fn bitor(self, rhs: Scope) -> Scope {
91 +
        Scope(self.0 | rhs.0)
92 +
    }
93 +
}
94 +
95 +
impl std::fmt::Display for Scope {
96 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 +
        f.write_str(&self.names().join(","))
98 +
    }
99 +
}
100 +
101 +
impl std::fmt::Debug for Scope {
102 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 +
        write!(f, "Scope({self})")
104 +
    }
105 +
}
106 +
107 +
/// A key allowed to act for an identity, within `scope`, until `expires`
108 +
/// (wall milliseconds; `None` for as long as the delegation stands).
109 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110 +
pub struct Delegate {
111 +
    pub key: KeyId,
112 +
    pub scope: Scope,
113 +
    pub expires: Option<u64>,
114 +
}
115 +
116 +
impl Delegate {
117 +
    /// A delegate with every scope and no expiry of its own.
118 +
    pub fn full(key: KeyId) -> Delegate {
119 +
        Delegate {
120 +
            key,
121 +
            scope: Scope::ALL,
122 +
            expires: None,
123 +
        }
124 +
    }
125 +
    /// Whether the delegation covers `scope` at `at_ms`.
126 +
    pub fn allows(&self, scope: Scope, at_ms: u64) -> bool {
127 +
        self.scope.has(scope) && !self.expires.is_some_and(|e| at_ms > e)
128 +
    }
129 +
    fn to_value(self) -> Value {
130 +
        Value::map([
131 +
            (0, Value::Bytes(self.key.0.to_vec())),
132 +
            (
133 +
                1,
134 +
                Value::Array(
135 +
                    self.scope
136 +
                        .names()
137 +
                        .into_iter()
138 +
                        .map(|n| Value::Text(n.to_string()))
139 +
                        .collect(),
140 +
                ),
141 +
            ),
142 +
            (2, self.expires.map(Value::Uint).unwrap_or(Value::Null)),
143 +
        ])
144 +
    }
145 +
    /// An entry, or a bare KeyId as identities carried before scopes
146 +
    /// existed, which meant every scope.
147 +
    fn from_value(v: &Value) -> Result<Delegate, ObjectError> {
148 +
        if let Value::Bytes(_) = v {
149 +
            return Ok(Delegate::full(oref(v, "delegate")?));
150 +
        }
151 +
        let m = v
152 +
            .as_map()
153 +
            .ok_or_else(|| ObjectError::Schema("delegate: expected map".into()))?;
154 +
        let key = oref(field(m, 0, "delegate key")?, "delegate key")?;
155 +
        let mut scope = Scope::NONE;
156 +
        for n in array(field(m, 1, "delegate scope")?, "delegate scope")? {
157 +
            let n = text(n, "delegate scope")?;
158 +
            scope = scope
159 +
                | Scope::named(n)
160 +
                    .ok_or_else(|| ObjectError::Schema(format!("delegate: unknown scope {n:?}")))?;
161 +
        }
162 +
        let expires = match field(m, 2, "delegate expires")? {
163 +
            v if v.is_null() => None,
164 +
            v => Some(uint(v, "delegate expires")?),
165 +
        };
166 +
        Ok(Delegate {
167 +
            key,
168 +
            scope,
169 +
            expires,
170 +
        })
171 +
    }
172 +
}
173 +
29 174
#[derive(Clone, Debug, PartialEq, Eq)]
30 175
pub struct Identity {
31 176
    pub ed25519_pk: [u8; ED25519_PK_LEN],
…
3 unchanged lines
35 180
    pub created: u64,
36 181
    pub expires: Option<u64>,
37 182
    pub previous: Option<KeyId>,
38 -
    pub delegates: Vec<KeyId>,
183 +
    pub delegates: Vec<Delegate>,
184 +
}
185 +
186 +
/// Sorted by key, one entry per key.
187 +
fn tidy(mut delegates: Vec<Delegate>) -> Vec<Delegate> {
188 +
    delegates.sort_by_key(|d| d.key);
189 +
    delegates.dedup_by(|a, b| a.key == b.key);
190 +
    delegates
39 191
}
40 192
41 193
impl Identity {
194 +
    /// The delegation to `key`, if any.
195 +
    pub fn delegate(&self, key: &KeyId) -> Option<&Delegate> {
196 +
        self.delegates.iter().find(|d| d.key == *key)
197 +
    }
198 +
    /// Whether `key` may act for this identity within `scope` at `at_ms`.
199 +
    pub fn delegates_to(&self, key: &KeyId, scope: Scope, at_ms: u64) -> bool {
200 +
        self.delegate(key).is_some_and(|d| d.allows(scope, at_ms))
201 +
    }
42 202
    pub fn to_value(&self) -> Value {
43 203
        Value::map([
44 204
            (0, Value::Uint(FORMAT_VERSION)),
…
11 unchanged lines
56 216
            ),
57 217
            (
58 218
                8,
59 -
                Value::Array(
60 -
                    self.delegates
61 -
                        .iter()
62 -
                        .map(|d| Value::Bytes(d.0.to_vec()))
63 -
                        .collect(),
64 -
                ),
219 +
                Value::Array(self.delegates.iter().map(|d| d.to_value()).collect()),
65 220
            ),
66 221
        ])
67 222
    }
…
30 unchanged lines
98 253
                oref(v, "previous").map(Some)
99 254
            }
100 255
        };
101 -
        let mut delegates = array(field(m, 8, "delegates")?, "delegates")?
102 -
            .iter()
103 -
            .map(|d| oref(d, "delegate"))
104 -
            .collect::<Result<Vec<_>, _>>()?;
105 -
        delegates.sort();
106 -
        delegates.dedup();
256 +
        let delegates = tidy(
257 +
            array(field(m, 8, "delegates")?, "delegates")?
258 +
                .iter()
259 +
                .map(Delegate::from_value)
260 +
                .collect::<Result<Vec<_>, _>>()?,
261 +
        );
107 262
        Ok(Identity {
108 263
            ed25519_pk: ed.try_into().unwrap(),
109 264
            mldsa65_pk: ml.to_vec(),
…
13 unchanged lines
123 278
124 279
    /// A successor identity with the same keys, `previous` set to this
125 280
    /// identity's KeyId, and the given delegates and expiry (spec §14.2).
126 -
    pub fn successor(&self, delegates: Vec<KeyId>, expires: Option<u64>, created: u64) -> Identity {
127 -
        let mut delegates = delegates;
128 -
        delegates.sort();
129 -
        delegates.dedup();
281 +
    pub fn successor(
282 +
        &self,
283 +
        delegates: Vec<Delegate>,
284 +
        expires: Option<u64>,
285 +
        created: u64,
286 +
    ) -> Identity {
130 287
        Identity {
131 288
            previous: Some(self.hash()),
132 -
            delegates,
289 +
            delegates: tidy(delegates),
133 290
            expires,
134 291
            created,
135 292
            ..self.clone()
…
257 unchanged lines
393 550
            id.hash()
394 551
        );
395 552
        let mut other = id.clone();
396 -
        other.delegates.push(ObjectRef([9; 32]));
553 +
        other.delegates.push(Delegate::full(ObjectRef([9; 32])));
397 554
        assert_ne!(other.hash(), id.hash());
555 +
    }
556 +
557 +
    #[test]
558 +
    fn delegates_carry_scope_and_expiry_and_bare_keys_mean_every_scope() {
559 +
        let sk = fixed_key();
560 +
        let mut id = sk.identity("Ada", "[email protected]", 1_700_000_000_000);
561 +
        let agent = ObjectRef([7; 32]);
562 +
        let host = ObjectRef([3; 32]);
563 +
        id.delegates = vec![
564 +
            Delegate {
565 +
                key: agent,
566 +
                scope: Scope::CHANGES,
567 +
                expires: Some(2_000),
568 +
            },
569 +
            Delegate {
570 +
                key: host,
571 +
                scope: Scope::CHANGES | Scope::REFS,
572 +
                expires: None,
573 +
            },
574 +
        ];
575 +
        let d = Identity::decode(&id.encode()).unwrap();
576 +
        assert_eq!(d.delegates.len(), 2);
577 +
        assert!(d.delegates_to(&agent, Scope::CHANGES, 1_000));
578 +
        assert!(!d.delegates_to(&agent, Scope::CHANGES, 2_001), "expired");
579 +
        assert!(!d.delegates_to(&agent, Scope::REFS, 1_000));
580 +
        assert!(!d.delegates_to(&agent, Scope::SEAL, 1_000));
581 +
        assert!(d.delegates_to(&host, Scope::REFS, u64::MAX));
582 +
        assert!(!d.delegates_to(&host, Scope::SEAL, 0));
583 +
        assert!(!d.delegates_to(&ObjectRef([1; 32]), Scope::CHANGES, 0));
584 +
        assert_eq!(
585 +
            Scope::parse("changes, refs").unwrap(),
586 +
            Scope::CHANGES | Scope::REFS
587 +
        );
588 +
        assert_eq!(Scope::parse("all").unwrap(), Scope::ALL);
589 +
        assert_eq!((Scope::CHANGES | Scope::SEAL).to_string(), "changes,seal");
590 +
        assert!(Scope::parse("push").is_err() && Scope::parse("").is_err());
591 +
        // An identity written before scopes existed lists bare KeyIds.
592 +
        let mut v = id.to_value();
593 +
        if let Value::Map(m) = &mut v {
594 +
            m.insert(8, Value::Array(vec![Value::Bytes(host.0.to_vec())]));
595 +
        }
596 +
        let old = Identity::decode(&cbor::encode(&v)).unwrap();
597 +
        assert_eq!(old.delegates, vec![Delegate::full(host)]);
598 +
        assert!(old.delegates_to(&host, Scope::ALL, u64::MAX));
398 599
    }
399 600
400 601
    #[test]
…
150 unchanged lines
modified crates/oxy-core/src/repo/hosted.rs +116 −1
…
249 unchanged lines
250 250
            user.policy_init().unwrap();
251 251
        }
252 252
        if delegate {
253 -
            user.delegate_add(hub_id.clone()).unwrap();
253 +
            user.delegate_add(hub_id.clone(), Scope::CHANGES | Scope::REFS, None)
254 +
                .unwrap();
254 255
        }
255 256
        write(&work, "a.txt", b"one\ntwo\n");
256 257
        user.snapshot("t").unwrap();
…
212 unchanged lines
469 470
        let update = host.host_ref_update("main", &[hash], None).unwrap();
470 471
        let report = host.apply_hosted(None, &[], update).unwrap();
471 472
        assert!(report.results.iter().all(|r| !r.ok));
473 +
    }
474 +
475 +
    /// Agent doc §2: an agent's key signs changes for its owner and nothing
476 +
    /// more. Its reference attestation is refused even though its owner's
477 +
    /// would pass, and a seal it makes authenticates nothing.
478 +
    #[test]
479 +
    fn a_changes_only_delegate_signs_changes_but_cannot_seal_or_move_a_reference() {
480 +
        let dir = tempfile::tempdir().unwrap();
481 +
        let (host, mut user, _, _, _) = setup(dir.path(), true, false);
482 +
        let agent_sk = SecretKey::generate();
483 +
        let agent_id = agent_sk.identity("aider on a laptop", "agent@example", 1);
484 +
        let agent = agent_id.hash();
485 +
        user.delegate_add(agent_id.clone(), Scope::CHANGES, None)
486 +
            .unwrap();
487 +
        // The successor identity reaches the host with the rotated policy.
488 +
        assert!(user.push("origin", false).unwrap().iter().all(|x| x.ok));
489 +
        let author = user.config.author_key.unwrap();
490 +
        let ids = vec![agent_id.clone(), user.identity(&author).unwrap()];
491 +
        let heads = host.bookmark_heads().unwrap();
492 +
        let change = host
493 +
            .build_change(
494 +
                &heads,
495 +
                &[FileEdit {
496 +
                    path: "a.txt".into(),
497 +
                    content: Some(b"by the agent\n".to_vec()),
498 +
                }],
499 +
                "Agent edit",
500 +
                author,
501 +
            )
502 +
            .unwrap();
503 +
        let hash = change.hash();
504 +
        let sig = agent_sk.sign(Domain::Change, &agent, &hash);
505 +
        // Attested by the agent: refused, the reference stays.
506 +
        let update = host
507 +
            .host_ref_update("main", &[hash], Some((&agent, &agent_sk)))
508 +
            .unwrap();
509 +
        let report = host
510 +
            .apply_hosted(Some((change.clone(), vec![sig.clone()])), &ids, update)
511 +
            .unwrap();
512 +
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
513 +
        assert_eq!(host.bookmark_heads().unwrap(), heads);
514 +
        // Attested by the owner: the agent-signed change lands.
515 +
        let (owner, owner_sk) = user.secret_key().unwrap().unwrap();
516 +
        let update = host
517 +
            .host_ref_update("main", &[hash], Some((&owner, &owner_sk)))
518 +
            .unwrap();
519 +
        let report = host
520 +
            .apply_hosted(Some((change, vec![sig])), &ids, update)
521 +
            .unwrap();
522 +
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
523 +
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
524 +
        assert!(matches!(
525 +
            host.authenticate_change(&hash).unwrap(),
526 +
            SignatureStatus::Valid { key, sealed: false, .. } if key == agent
527 +
        ));
528 +
        // A seal by the agent is outside its scope.
529 +
        let change2 = host
530 +
            .build_change(
531 +
                &[hash],
532 +
                &[FileEdit {
533 +
                    path: "a.txt".into(),
534 +
                    content: Some(b"sealed by the agent\n".to_vec()),
535 +
                }],
536 +
                "Sealed",
537 +
                author,
538 +
            )
539 +
            .unwrap();
540 +
        let h2 = host.store().put_change(&change2).unwrap();
541 +
        let seal = host
542 +
            .store()
543 +
            .put_attest(&Attest::seal(host.id().unwrap(), vec![h2]))
544 +
            .unwrap();
545 +
        host.store()
546 +
            .put_signature(&seal, &agent_sk.sign(Domain::Attest, &agent, &seal))
547 +
            .unwrap();
548 +
        host.store().index_seal(&h2, &seal).unwrap();
549 +
        assert!(matches!(
550 +
            host.authenticate_change(&h2).unwrap(),
551 +
            SignatureStatus::Invalid(e) if e.contains("may not seal")
552 +
        ));
553 +
    }
554 +
555 +
    /// A delegation past its expiry authenticates nothing made after it,
556 +
    /// and the delegator may revoke the key; a stranger's key, they may not.
557 +
    #[test]
558 +
    fn an_expired_delegation_is_refused_and_the_delegator_may_revoke() {
559 +
        let dir = tempfile::tempdir().unwrap();
560 +
        let (_, mut user, _, _, _) = setup(dir.path(), false, false);
561 +
        let agent_sk = SecretKey::generate();
562 +
        let agent_id = agent_sk.identity("agent", "agent@example", 1);
563 +
        let agent = agent_id.hash();
564 +
        let yesterday = Hlc::now().wall_ms - 24 * 3600 * 1000;
565 +
        user.delegate_add(agent_id, Scope::CHANGES, Some(yesterday))
566 +
            .unwrap();
567 +
        user.config.set("core.sign", "seal").unwrap();
568 +
        user.save_config().unwrap();
569 +
        write(&dir.path().join("work"), "a.txt", b"later\n");
570 +
        user.snapshot("t").unwrap();
571 +
        let h = user.commit("after the delegation ended", &[]).unwrap();
572 +
        user.store()
573 +
            .put_signature(&h, &agent_sk.sign(Domain::Change, &agent, &h))
574 +
            .unwrap();
575 +
        let status = user.verify_change(&h).unwrap();
576 +
        assert!(
577 +
            matches!(&status, SignatureStatus::Invalid(e) if e.contains("expired")),
578 +
            "{status:?}"
579 +
        );
580 +
        // No policy here, so the user is no root: only delegation lets them revoke.
581 +
        let stranger = SecretKey::generate().identity("x", "x@example", 1).hash();
582 +
        assert!(user.revoke_identity(&stranger, "").is_err());
583 +
        assert!(user.revoke_identity(&agent, "session over").is_ok());
584 +
        assert!(user.is_revoked(&agent).unwrap());
585 +
        let r = user.delegate_remove(&agent).unwrap();
586 +
        assert!(user.identity(&r.new).unwrap().delegates.is_empty());
472 587
    }
473 588
474 589
    #[test]
…
93 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +71 −25
…
170 unchanged lines
171 171
        self.replace_identity(id, sk, "oxy identity renew")
172 172
    }
173 173
174 -
    /// Let `delegate` sign or seal on this identity's behalf. Delegates are
175 -
    /// part of the identity object, so this re-issues it with the same keys.
176 -
    pub fn delegate_add(&mut self, delegate: Identity) -> Result<Rotation> {
174 +
    /// Let `delegate` act on this identity's behalf within `scope` until
175 +
    /// `expires` (spec §14.2). Delegates are part of the identity object,
176 +
    /// so this re-issues it with the same keys.
177 +
    pub fn delegate_add(
178 +
        &mut self,
179 +
        delegate: Identity,
180 +
        scope: Scope,
181 +
        expires: Option<u64>,
182 +
    ) -> Result<Rotation> {
177 183
        let Some((old, sk)) = self.secret_key()? else {
178 184
            return fail("no secret key: run `oxy identity create` first");
179 185
        };
186 +
        if scope.is_empty() {
187 +
            return fail("a delegate needs at least one scope: changes, refs or seal");
188 +
        }
180 189
        let dk = self.store.put_identity(&delegate)?;
181 190
        if dk == old {
182 191
            return fail("an identity cannot delegate to itself");
183 192
        }
184 193
        let cur = self.identity(&old)?;
185 -
        if cur.delegates.contains(&dk) {
186 -
            return fail(format!("{} is already a delegate", dk.short(12)));
194 +
        if cur.delegate(&dk).is_some() {
195 +
            return fail(format!(
196 +
                "{} is already a delegate; remove it first to change its scope or expiry",
197 +
                dk.short(12)
198 +
            ));
187 199
        }
188 200
        let mut delegates = cur.delegates.clone();
189 -
        delegates.push(dk);
201 +
        delegates.push(Delegate {
202 +
            key: dk,
203 +
            scope,
204 +
            expires,
205 +
        });
190 206
        let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms);
191 207
        self.replace_identity(
192 208
            id,
193 209
            sk,
194 -
            &format!("oxy identity delegate add {}", dk.short(12)),
210 +
            &format!("oxy identity delegate add {} --scope {scope}", dk.short(12)),
195 211
        )
196 212
    }
197 213
…
3 unchanged lines
201 217
            return fail("no secret key: run `oxy identity create` first");
202 218
        };
203 219
        let cur = self.identity(&old)?;
204 -
        if !cur.delegates.contains(delegate) {
220 +
        if cur.delegate(delegate).is_none() {
205 221
            return fail(format!("{} is not a delegate", delegate.short(12)));
206 222
        }
207 223
        let delegates = cur
208 224
            .delegates
209 225
            .iter()
210 226
            .copied()
211 -
            .filter(|d| d != delegate)
227 +
            .filter(|d| d.key != *delegate)
212 228
            .collect();
213 229
        let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms);
214 230
        self.replace_identity(
…
3 unchanged lines
218 234
        )
219 235
    }
220 236
221 -
    /// Revoke `key` (your own, or any key when you hold a policy root):
222 -
    /// record a signed `revoke` attestation and a local marker, and drop
223 -
    /// the key from the policy where it has one.
237 +
    /// Revoke `key` (your own, one you delegated to, or any key when you
238 +
    /// hold a policy root): record a signed `revoke` attestation and a
239 +
    /// local marker, and drop the key from the policy where it has one.
240 +
    /// Revoking a delegate does not re-issue the identity; `delegate_remove`
241 +
    /// does that, and the CLI does both.
224 242
    pub fn revoke_identity(&self, key: &KeyId, reason: &str) -> Result<Option<PolicyUpdate>> {
225 243
        let Some((own, sk)) = self.secret_key()? else {
226 244
            return fail("no secret key: run `oxy identity create` first");
227 245
        };
228 246
        let policy = self.current_policy()?.map(|(_, sp)| sp.policy);
229 247
        let is_root = policy.as_ref().is_some_and(|p| p.roots.contains(&own));
230 -
        if *key != own && !is_root {
231 -
            return fail("only the key's holder or a policy root can revoke it");
248 +
        let is_delegate = self.identity(&own)?.delegate(key).is_some();
249 +
        if *key != own && !is_root && !is_delegate {
250 +
            return fail("only the key's holder, the identity that delegated to it, or a policy root can revoke it");
232 251
        }
233 252
        let attest = Attest {
234 253
            repo: self.id()?,
…
134 unchanged lines
369 388
        // The author-or-delegate rule (spec §14.4). A change whose author is
370 389
        // the zero key names nobody (Git imports, history from before an
371 390
        // identity existed), so any key may adopt it with a seal; policy
372 -
        // then authorises that key for the reference as usual.
373 -
        let authorised = change.author.is_zero()
374 -
            || sig.key == change.author
375 -
            || self
391 +
        // then authorises that key for the reference as usual. A delegate
392 +
        // acts within its scope, signing changes or sealing them, and only
393 +
        // until its delegation expires (§14.2).
394 +
        if !change.author.is_zero() && sig.key != change.author {
395 +
            let needed = match domain {
396 +
                Domain::Change => Scope::CHANGES,
397 +
                _ => Scope::SEAL,
398 +
            };
399 +
            let delegation = self
376 400
                .store
377 401
                .get_identity(&change.author)
378 -
                .map(|a| a.delegates.contains(&sig.key))
379 -
                .unwrap_or(false);
380 -
        if !authorised {
381 -
            return Err(format!(
382 -
                "key {} is neither the author nor a delegate",
383 -
                sig.key.short(12)
384 -
            ));
402 +
                .ok()
403 +
                .and_then(|a| a.delegate(&sig.key).copied());
404 +
            match delegation {
405 +
                None => {
406 +
                    return Err(format!(
407 +
                        "key {} is neither the author nor a delegate",
408 +
                        sig.key.short(12)
409 +
                    ))
410 +
                }
411 +
                Some(d) if !d.scope.has(needed) => {
412 +
                    return Err(format!(
413 +
                        "key {} is a delegate for {} only and may not {}",
414 +
                        sig.key.short(12),
415 +
                        d.scope,
416 +
                        if needed == Scope::SEAL {
417 +
                            "seal"
418 +
                        } else {
419 +
                            "sign changes"
420 +
                        }
421 +
                    ))
422 +
                }
423 +
                Some(d) if !d.allows(needed, change.hlc.wall_ms) => {
424 +
                    return Err(format!(
425 +
                        "key {}'s delegation had expired when the change was made",
426 +
                        sig.key.short(12)
427 +
                    ))
428 +
                }
429 +
                Some(_) => {}
430 +
            }
385 431
        }
386 432
        if id.is_expired_at(change.hlc.wall_ms) {
387 433
            return Err(format!(
…
121 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +3 −1
…
9 unchanged lines
10 10
use crate::attest::{Attest, AttestBody};
11 11
use crate::diff::{ranges, split_lines, text_edits};
12 12
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
13 -
use crate::identity::{verify as verify_signature, Domain, Identity, SecretKey, Signature};
13 +
use crate::identity::{
14 +
    verify as verify_signature, Delegate, Domain, Identity, Scope, SecretKey, Signature,
15 +
};
14 16
use crate::ids::{ChangeId, NodeId, WorkspaceId};
15 17
use crate::objects::{
16 18
    Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp,
…
1558 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +13 −6
…
414 unchanged lines
415 415
    /// server receiving a push requires a strictly greater serial.
416 416
    /// A reference attestation signed by a delegate of an identity the
417 417
    /// rule lists is authorized as that identity (spec §14.5): the identity
418 -
    /// named the delegate, and the policy names the identity. Revoked keys
419 -
    /// authorize nothing either way.
418 +
    /// named the delegate with the `refs` scope, still in force at `at_ms`
419 +
    /// (the attestation's wall time), and the policy names the identity.
420 +
    /// Revoked keys authorize nothing either way.
420 421
    pub(crate) fn delegate_authorized(
421 422
        &self,
422 423
        p: &crate::policy::Policy,
423 424
        signer: &KeyId,
424 425
        reference: &str,
426 +
        at_ms: u64,
425 427
    ) -> bool {
426 428
        if p.is_revoked(signer) {
427 429
            return false;
…
6 unchanged lines
434 436
                && p.identity(k)
435 437
                    .cloned()
436 438
                    .or_else(|| self.store.get_identity(k).ok())
437 -
                    .is_some_and(|id| id.delegates.contains(signer))
439 +
                    .is_some_and(|id| id.delegates_to(signer, Scope::REFS, at_ms))
438 440
        })
439 441
    }
440 442
…
199 unchanged lines
640 642
                .first()
641 643
                .map(|s| s.key)
642 644
                .ok_or("attestation carries no signature")?;
643 -
            if !p.authorizes(&signer, &u.name) && !self.delegate_authorized(p, &signer, &u.name) {
645 +
            if !p.authorizes(&signer, &u.name)
646 +
                && !self.delegate_authorized(p, &signer, &u.name, sa.attest.hlc.wall_ms)
647 +
            {
644 648
                return Err(format!(
645 649
                    "key {} may not delete {:?} under this repository's policy",
646 650
                    signer.short(12),
…
85 unchanged lines
732 736
                .first()
733 737
                .map(|s| s.key)
734 738
                .ok_or("attestation carries no signature")?;
735 -
            if !p.authorizes(&signer, &u.name) && !self.delegate_authorized(p, &signer, &u.name) {
739 +
            if !p.authorizes(&signer, &u.name)
740 +
                && !self.delegate_authorized(p, &signer, &u.name, sa.attest.hlc.wall_ms)
741 +
            {
736 742
                return Err(format!(
737 743
                    "key {} may not update {:?} under this repository's policy",
738 744
                    signer.short(12),
…
350 unchanged lines
1089 1095
                    .first()
1090 1096
                    .map(|s| s.key)
1091 1097
                    .ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?;
1092 -
                if !p.authorizes(&signer, &r.name) && !self.delegate_authorized(p, &signer, &r.name)
1098 +
                if !p.authorizes(&signer, &r.name)
1099 +
                    && !self.delegate_authorized(p, &signer, &r.name, sa.attest.hlc.wall_ms)
1093 1100
                {
1094 1101
                    return fail(format!(
1095 1102
                        "{remote_name}/{}: attested by {}, which the policy does not authorize for this bookmark; a policy owner can grant it with `oxy policy grant`",
…
550 unchanged lines
modified crates/oxy-core/src/repo/tags.rs +1 −1
…
211 unchanged lines
212 212
            if let Some(p) = policy {
213 213
                let reference = format!("tags/{}", t.name);
214 214
                if !p.authorizes(&sig.key, &reference)
215 -
                    && !self.delegate_authorized(p, &sig.key, &reference)
215 +
                    && !self.delegate_authorized(p, &sig.key, &reference, sa.attest.hlc.wall_ms)
216 216
                {
217 217
                    return Err(format!(
218 218
                        "key {} may not create tags/{} under this repository's policy",
…
26 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +9 −3
…
2483 unchanged lines
2484 2484
    let (key_b, id_b) = b
2485 2485
        .create_identity("Bob", "[email protected]", IdentityScope::User)
2486 2486
        .unwrap();
2487 -
    let r = a.delegate_add(id_b.clone()).unwrap();
2487 +
    let r = a.delegate_add(id_b.clone(), Scope::ALL, None).unwrap();
2488 2488
    assert_eq!(r.old, key_a);
2489 2489
    let key_a2 = r.new;
2490 -
    assert_eq!(a.identity(&key_a2).unwrap().delegates, vec![key_b]);
2491 2490
    assert_eq!(
2491 +
        a.identity(&key_a2).unwrap().delegates,
2492 +
        vec![Delegate::full(key_b)]
2493 +
    );
2494 +
    assert_eq!(
2492 2495
        a.identity(&key_a2).unwrap().ed25519_pk,
2493 2496
        a.identity(&key_a).unwrap().ed25519_pk
2497 +
    );
2498 +
    assert!(
2499 +
        a.delegate_add(id_b.clone(), Scope::ALL, None).is_err(),
2500 +
        "already a delegate"
2494 2501
    );
2495 -
    assert!(a.delegate_add(id_b.clone()).is_err(), "already a delegate");
2496 2502
    assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok));
2497 2503
    write(&root.join("a"), "a.txt", b"a3\n");
2498 2504
    let h3 = a.commit("three", &[]).unwrap();
…
2552 unchanged lines
modified docs/ai_native_agent_workflows.md +9 −9
…
46 unchanged lines
47 47
  [9]` is reserved and must stay empty, and the signature already proves
48 48
  which agent made the change, which a marker could not.
49 49
50 -
**New: scoped delegation.** Today a delegate moves references as far as
51 -
its delegator may (§14.5). That is right for a hub signing browser edits,
52 -
and wrong for an agent. `delegates` becomes a list of entries:
50 +
**Scoped delegation (§14.2, implemented 2026-10-04).** Before it, a
51 +
delegate moved references as far as its delegator may (§14.5). That is
52 +
right for a hub signing browser edits, and wrong for an agent. `delegates`
53 +
is a list of entries:
53 54
54 55
```
55 56
delegates [8]: [{ key [0]: KeyId, scope [1]: ["changes" | "refs" | "seal"], expires [2]: u64? }]
…
7 unchanged lines
63 64
  (`--expires 8h`), without rotating the human's own expiry.
64 65
- Hosts that sign for users keep the current behaviour with
65 66
  `--scope changes,refs`.
66 -
- This is a format change to the identity object. That is acceptable
67 -
  before 1.0.
67 +
- A bare KeyId in an older identity means every scope.
68 68
69 -
**New: revocation by the delegator.** Today only the revoked key itself
70 -
or a policy root key may sign `revoke`. An agent's key may be lost on a
69 +
**Revocation by the delegator (implemented 2026-10-04).** Before it,
70 +
only the revoked key itself or a policy root key could sign `revoke`. An agent's key may be lost on a
71 71
compromised machine, and the policy root is often someone else, so the
72 72
identity that delegated a key may also revoke it. Removing a delegate in
73 73
a successor identity stops future use. Revoking it also marks the changes
…
144 unchanged lines
218 218
219 219
| Item | Spec section to change |
220 220
|---|---|
221 -
| Scoped, expiring delegates | §14.2 identity object, §14.5 policy |
222 -
| Revocation by the delegator | §14.2 |
221 +
| Scoped, expiring delegates (done 2026-10-04) | §14.2 identity object, §14.4, §14.5 policy |
222 +
| Revocation by the delegator (done 2026-10-04) | §14.2 |
223 223
| `oxy agent start` / `drop`, session operation | §12, CLI |
224 224
| `oxy undo --to OP` | §12, CLI |
225 225
| Display folding, `oxy fold -r` | CLI, hub |
…
3 unchanged lines
modified docs/guide.md +3 −1
…
261 unchanged lines
262 262
```
263 263
oxy identity rotate                   # fresh key pair; old signatures stay valid
264 264
oxy identity renew --expires 365      # same keys, new expiry in days (0 = never)
265 -
oxy identity delegate add ci.identity # let another key sign on your behalf
265 +
oxy identity delegate add agent.identity --expires 8h       # sign changes for me today
266 +
oxy identity delegate add ci.identity --scope changes,refs  # sign and push for me
266 267
oxy identity delegate remove 3f2a…
268 +
oxy identity revoke 3f2a… --reason "agent host compromised" # a key you delegated to
267 269
oxy identity revoke                   # retire your own key
268 270
oxy identity revoke 3f2a… --reason "laptop stolen"   # any key, if you hold a root
269 271
```
…
173 unchanged lines
modified docs/spec.md +9 −4
…
586 unchanged lines
587 587
  created    [5]: u64 (wall ms)
588 588
  expires    [6]: u64 | null
589 589
  previous   [7]: KeyId | null        // rotation chain
590 -
  delegates  [8]: [KeyId]             // keys allowed to sign or seal on this identity's behalf
590 +
  delegates  [8]: [Delegate]          // keys allowed to act on this identity's behalf
591 591
}
592 +
Delegate {
593 +
  key     [0]: KeyId
594 +
  scope   [1]: ["changes" | "refs" | "seal"]   // what it may do, in that order
595 +
  expires [2]: u64 | null                       // wall ms; null for as long as it is listed
596 +
}
592 597
KeyId = ObjectRef of the Identity
593 598
```
594 599
595 -
The two public keys live in one object, so a `KeyId` names the pair. `delegates` lists keys that may authenticate changes whose `author` is this identity: a proxy acting for a Thin client (§22), or a CI signer. Delegation is one level deep and is not transitive. Rotation is a new identity with `previous` set, plus an attestation of kind `rotate` signed by the previous key. Revocation is an attestation of kind `revoke` signed by the revoked key or by a policy root key (§14.5).
600 +
The two public keys live in one object, so a `KeyId` names the pair. `delegates` lists keys that may act for this identity, each within a **scope**: `changes` lets the key sign changes whose `author` is this identity, `seal` lets it seal them, and `refs` lets it attest references and tags as this identity would (§14.5). A proxy acting for a Thin client (§22) or a host that commits browser edits for a user gets `changes,refs`; an AI agent gets `changes` alone, so its signature authenticates the changes it makes but it can never move a reference, whatever its owner may do. A delegation with `expires` authenticates nothing whose `hlc.wall_ms` is later than that, so an agent can be let in for a session without changing the owner's own expiry. Delegation is one level deep and is not transitive. A bare `KeyId` in `delegates`, as identities carried before scopes existed, means every scope with no expiry. Rotation is a new identity with `previous` set, plus an attestation of kind `rotate` signed by the previous key. Revocation is an attestation of kind `revoke` signed by the revoked key, by the identity that delegated to it, or by a policy root key (§14.5); the delegator's revocation marks the changes the key already signed as valid but revoked, and the delegator then re-issues their identity without it.
596 601
597 602
**Lifecycle rules.** Because the `KeyId` is the hash of the whole object, any change to an identity, including its `delegates` or `expires`, is a rotation: a successor object with `previous` set. A successor MAY keep the same key pair (`oxy identity renew`, `oxy identity delegate add|remove`) or carry a fresh one (`oxy identity rotate`); either way earlier signatures remain verifiable under the earlier object, which stays stored. A rotation **supersedes** the previous KeyId: a policy that named it MUST be updated to name the successor (§14.5), and a rule key covers every predecessor in its `previous` chain that is not revoked, so changes signed before a rotation remain authorized. Revocation is for compromised or abandoned keys: a revoked key authorizes nothing, whatever its chain. `expires` bounds the changes a key can authenticate: a signature or seal over a change whose `hlc.wall_ms` is later than the identity's `expires` is invalid. A client records the revocations it makes or verifies under `.oxy/revoked/` and reports signatures by such keys as valid but revoked.
598 603
…
38 unchanged lines
637 642
638 643
**Cadence.** With the default `core.sign = "seal"`, changes are left unsigned locally and `oxy push` creates one seal covering every change in the push that carries no individual signature, immediately before the reference attestation. Seals are created only at push: a seal made earlier would be invalidated by any amend, since rewriting produces a new hash. With `core.sign = "change"`, every change is signed at commit and no seal is needed. Policy (§14.5) decides which the receiving repository accepts.
639 644
640 -
**Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate (§14.2). The same author-or-delegate rule applies to individual change signatures. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed.
645 +
**Verification.** A sealed change `C` is authenticated iff a seal `S` exists such that `hash(C) ∈ S.body.hashes`, `S` carries a valid signature from key `K`, and `K` equals `C.author` or is listed in `C.author`'s identity as a delegate with the `seal` scope, not expired at `C.hlc.wall_ms` (§14.2). The same author-or-delegate rule applies to individual change signatures, with the `changes` scope. This rule is what stops a collaborator from sealing a change whose `author` field names someone else (§3.1). A change whose `author` is the zero KeyId names nobody (history imported from Git, §21, or made before an identity existed), so a seal from any key authenticates it: the sealer **adopts** it, and policy authorizes the sealer for the reference as usual. A push seals the pusher's own unauthenticated changes and adopts anonymous ones in the same seal. A seal's signer must additionally be authorized by policy for the reference being pushed.
641 646
642 647
**Handling.** A seal is stored and transported with the changes it covers: `fetch` (§16.1) streams the seals covering every change it returns, `push` (§16.4) MUST include a seal or signature for every change, and a seal covering any reachable change is itself reachable (§21). Seals from different keys MAY overlap; a change needs at least one valid authenticator, and policy thresholds count distinct authenticating keys.
643 648
…
17 unchanged lines
661 666
662 667
- The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys of the policy it replaces and have a higher `serial`. An update that a single holder cannot complete is a **proposal**: the signed policy (`[policy bytes, [Signature]]`, §16.3) is passed to other root holders, each of whom adds a signature with `oxy policy sign`; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it.
663 668
- A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's `author` (or a successor) when the signer is that author's delegate.
664 -
- A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it (§14.2). Delegation is the identity's own signed statement and the policy names that identity, so a delegate moves references exactly as far as its delegator may; revoking either key ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key.
669 +
- A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it with the `refs` scope, not expired at the attestation's `hlc.wall_ms` (§14.2). Delegation is the identity's own signed statement and the policy names that identity, so a delegate with `refs` moves references exactly as far as its delegator may; revoking either key, or the delegation expiring, ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. A delegate without `refs` (an agent) is never authorized for a reference or tag, even when its delegator is.
665 670
- A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`.
666 671
- Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push.
667 672
- A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors.
…
344 unchanged lines
modified src/cli/identity.rs +80 −28
…
6 unchanged lines
7 7
use anyhow::Result;
8 8
use oxy_core::hash::ChangeHash;
9 9
use oxy_core::hash::KeyId;
10 -
use oxy_core::identity::Identity;
10 +
use oxy_core::identity::{Delegate, Identity, Scope};
11 11
use oxy_core::objects::Hlc;
12 12
use oxy_core::protocol::SignedPolicy;
13 13
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus};
…
5 unchanged lines
19 19
    (days > 0).then(|| Hlc::now().wall_ms + days * DAY_MS)
20 20
}
21 21
22 +
/// `30m`, `8h`, `7d`, or a bare number of days, as a wall time from now.
23 +
fn expiry_from_duration(s: &str) -> Result<u64> {
24 +
    let s = s.trim();
25 +
    let (n, unit) = match s.char_indices().find(|(_, c)| !c.is_ascii_digit()) {
26 +
        Some((i, _)) => (&s[..i], &s[i..]),
27 +
        None => (s, "d"),
28 +
    };
29 +
    let n: u64 = n.parse().map_err(|_| {
30 +
        anyhow::anyhow!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}")
31 +
    })?;
32 +
    let ms = match unit {
33 +
        "m" => 60_000,
34 +
        "h" => 3_600_000,
35 +
        "d" => DAY_MS,
36 +
        _ => anyhow::bail!("--expires takes 30m, 8h, 7d or a number of days, not {s:?}"),
37 +
    };
38 +
    if n == 0 {
39 +
        anyhow::bail!("--expires needs a length of time; leave it out for no expiry");
40 +
    }
41 +
    Ok(Hlc::now().wall_ms + n * ms)
42 +
}
43 +
44 +
/// One line per delegate: key, scope, expiry.
45 +
fn delegate_lines(delegates: &[Delegate]) -> String {
46 +
    delegates
47 +
        .iter()
48 +
        .map(|d| {
49 +
            format!(
50 +
                "{} ({}{})",
51 +
                d.key.short(12),
52 +
                d.scope,
53 +
                d.expires
54 +
                    .map(|e| format!(", until {}", when(e)))
55 +
                    .unwrap_or_default()
56 +
            )
57 +
        })
58 +
        .collect::<Vec<_>>()
59 +
        .join(" ")
60 +
}
61 +
22 62
/// Resolve a KeyId prefix among the identities the repository knows.
23 63
fn resolve_key(repo: &Repo, prefix: &str) -> Result<KeyId> {
24 64
    if let Some(k) = KeyId::from_hex(prefix) {
…
7 unchanged lines
32 72
    if let Some(own) = repo.config.author_key {
33 73
        candidates.push(own);
34 74
        if let Ok(id) = repo.identity(&own) {
35 -
            candidates.extend(id.delegates.iter().copied());
75 +
            candidates.extend(id.delegates.iter().map(|d| d.key));
36 76
        }
37 77
    }
38 78
    candidates.sort();
…
48 unchanged lines
87 127
    if !r.identity.delegates.is_empty() {
88 128
        println!(
89 129
            "{}",
90 -
            ui::kv(
91 -
                "delegates",
92 -
                r.identity
93 -
                    .delegates
94 -
                    .iter()
95 -
                    .map(|d| d.short(12))
96 -
                    .collect::<Vec<_>>()
97 -
                    .join(" ")
98 -
            )
130 +
            ui::kv("delegates", delegate_lines(&r.identity.delegates))
99 131
        );
100 132
    }
101 133
    if let Some(e) = r.identity.expires {
…
100 unchanged lines
202 234
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
203 235
    }
204 236
    if !id.delegates.is_empty() {
205 -
        println!(
206 -
            "{}",
207 -
            ui::kv(
208 -
                "delegates",
209 -
                id.delegates
210 -
                    .iter()
211 -
                    .map(|d| d.short(12))
212 -
                    .collect::<Vec<_>>()
213 -
                    .join(" ")
214 -
            )
215 -
        );
237 +
        println!("{}", ui::kv("delegates", delegate_lines(&id.delegates)));
216 238
    }
217 239
    if repo.is_revoked(&key)? {
218 240
        println!(
…
193 unchanged lines
412 434
            ui::dim("recorded locally; no policy named the key")
413 435
        ),
414 436
    }
437 +
    // A revoked delegate comes off the identity too, so the successor no
438 +
    // longer names it (spec §14.2).
439 +
    let own = repo.config.author_key;
440 +
    let delegated = own.is_some_and(|o| {
441 +
        repo.identity(&o)
442 +
            .is_ok_and(|id| id.delegate(&key).is_some())
443 +
    });
444 +
    if delegated {
445 +
        let mut repo = repo;
446 +
        let r = repo.delegate_remove(&key)?;
447 +
        report_rotation(&r, "Undelegated")?;
448 +
        return Ok(0);
449 +
    }
415 450
    if repo.config.author_key == Some(key) {
416 451
        println!(
417 452
            "  {} {}",
…
4 unchanged lines
422 457
    Ok(0)
423 458
}
424 459
425 -
pub fn delegate_add(identity_file: &Path) -> Result<i32> {
460 +
pub fn delegate_add(identity_file: &Path, scope: &str, expires: Option<&str>) -> Result<i32> {
426 461
    let mut repo = open()?;
427 462
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
428 -
    let r = repo.delegate_add(id)?;
463 +
    let scope = Scope::parse(scope).map_err(|e| anyhow::anyhow!("{e}"))?;
464 +
    let expires = expires.map(expiry_from_duration).transpose()?;
465 +
    let r = repo.delegate_add(id, scope, expires)?;
429 466
    report_rotation(&r, "Delegated")?;
467 +
    if !scope.has(Scope::REFS) {
468 +
        println!(
469 +
            "  {} {}",
470 +
            ui::note(),
471 +
            ui::dim("it may not move references: a push or tag it attests is refused")
472 +
        );
473 +
    }
430 474
    Ok(0)
431 475
}
432 476
…
15 unchanged lines
448 492
    if id.delegates.is_empty() {
449 493
        println!("{}", ui::dim("No delegates."));
450 494
    }
495 +
    let now = Hlc::now().wall_ms;
451 496
    for d in &id.delegates {
452 497
        let who = repo
453 -
            .identity(d)
498 +
            .identity(&d.key)
454 499
            .map(|i| format!("{} <{}>", i.name, i.email))
455 500
            .unwrap_or_else(|_| "(unknown identity)".into());
501 +
        let until = match d.expires {
502 +
            Some(e) if now > e => ui::err(format!("expired {}", ui::ago(e))),
503 +
            Some(e) => ui::dim(format!("until {}", when(e))),
504 +
            None => String::new(),
505 +
        };
456 506
        println!(
457 -
            "  {} {}  {}",
507 +
            "  {} {}  {}  {} {}",
458 508
            ui::ok(ui::glyph("🔑", "*")),
459 -
            ui::hash(d.short(12)),
460 -
            ui::author(who)
509 +
            ui::hash(d.key.short(12)),
510 +
            ui::author(who),
511 +
            ui::dim(d.scope.to_string()),
512 +
            until
461 513
        );
462 514
    }
463 515
    Ok(0)
…
309 unchanged lines
modified src/cli/mod.rs +12 −1
…
680 unchanged lines
681 681
    Add {
682 682
        /// The delegate's exported identity file
683 683
        identity_file: PathBuf,
684 +
        /// What it may do for you: changes, refs, seal (comma-separated) or all.
685 +
        /// An agent gets `changes`; a host that lands for you needs `changes,refs`
686 +
        #[arg(long, default_value = "changes")]
687 +
        scope: String,
688 +
        /// How long for: 30m, 8h, 7d, or a number of days (default: until removed)
689 +
        #[arg(long)]
690 +
        expires: Option<String>,
684 691
    },
685 692
    /// Remove a delegate by KeyId prefix
686 693
    Remove {
…
297 unchanged lines
984 991
            IdentityCmd::Renew { expires } => identity::renew(expires),
985 992
            IdentityCmd::Revoke { key, reason } => identity::revoke(key.as_deref(), &reason),
986 993
            IdentityCmd::Delegate { cmd } => match cmd {
987 -
                DelegateCmd::Add { identity_file } => identity::delegate_add(&identity_file),
994 +
                DelegateCmd::Add {
995 +
                    identity_file,
996 +
                    scope,
997 +
                    expires,
998 +
                } => identity::delegate_add(&identity_file, &scope, expires.as_deref()),
988 999
                DelegateCmd::Remove { key } => identity::delegate_remove(&key),
989 1000
                DelegateCmd::List => identity::delegate_list(),
990 1001
            },
…
91 unchanged lines