agentplane 0.19.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! On whose behalf: workload identity and attenuating delegation.
//!
//! # The question the protocols cannot answer
//!
//! "Which agent did this" is answerable from a log line. "**On whose behalf**"
//! is not, and it is the one an auditor asks. A run that refunded €4,200 was
//! started by some agent, which was delegated to by another, which was
//! authorized by a person — and unless that chain is carried and recorded, the
//! answer is reconstructed from timestamps and hope.
//!
//! So a principal here is not a config string. It is a link in a chain that runs
//! from a human owner down to the workload actually calling a tool, and the
//! chain travels with the request and lands in the journal.
//!
//! # Attenuation is the property, not the credential format
//!
//! SPIFFE, WIMSE, AIP and the OAuth agent drafts are all still moving — 2026
//! Internet-Drafts, not RFCs. Binding the runtime to any of their wire formats
//! would mean a rewrite when they settle. What the runtime actually *depends on*
//! is one property they all provide:
//!
//! > **Scope is monotonically non-increasing.** A delegate can never hold more
//! > authority than its delegator.
//!
//! That is what [`Delegation::delegate`] enforces, and it is enforced at
//! construction rather than checked in review — a widened scope is not
//! representable, so there is no code path that has to remember to look. The
//! credential format sits behind [`DelegationScheme`]; swapping JWT for
//! something else is a driver change, not a redesign.
//!
//! # Verified once, then journaled
//!
//! A credential expires. Re-verifying a chain during replay would fail for any
//! run older than its tokens, so an audit of last year's decision would report a
//! problem that did not exist when the decision was made — and the obvious
//! "fix", skipping verification on replay, would let a forged chain in through
//! the audit path.
//!
//! The resolution is the one the effect protocol already gives, and the one
//! `core::policy` uses for the same reason: **verify at admission, journal the
//! result, read it back on replay.** The chain in `IdentityBound` is what
//! governed the run, and it stays true regardless of what has since expired.

use std::collections::BTreeSet;
use std::fmt::Debug;

use serde::{Deserialize, Serialize};

use crate::core::Capability;

/// What a principal is allowed to do.
///
/// A set of capability patterns. Two forms, and deliberately only two:
///
/// * `"billing.reconcile"` — exactly that capability.
/// * `"billing.*"` — that prefix and everything under it.
///
/// A richer grammar (regex, negation, conditions) is where scope stops being
/// *checkable* — attenuation has to be decidable by containment, and negation
/// makes containment undecidable in the general case. Conditions belong in the
/// policy engine, which is built to evaluate them; this is the part that must be
/// simple enough to be provably monotonic.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Scope(BTreeSet<String>);

impl Scope {
    /// Authority over everything. The root of a chain, held by an owner.
    #[must_use]
    pub fn root() -> Self {
        Self(BTreeSet::from(["*".to_owned()]))
    }

    /// No authority at all.
    ///
    /// Not the same as [`Scope::root`] with nothing in it — an empty scope
    /// permits nothing, which is what an over-attenuated chain ends at.
    #[must_use]
    pub fn empty() -> Self {
        Self(BTreeSet::new())
    }

    /// Build from patterns.
    pub fn of<I, S>(patterns: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        Self(patterns.into_iter().map(Into::into).collect())
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// The patterns, in a stable order.
    ///
    /// Sorted, because this is hashed into the journal: a set that serialized in
    /// iteration order would give the same chain two different digests.
    pub fn patterns(&self) -> impl Iterator<Item = &str> {
        self.0.iter().map(String::as_str)
    }

    /// Whether one pattern covers a capability.
    ///
    /// `"billing.*"` covers `"billing.reconcile"` and `"billing"` itself, but
    /// **not** `"billingx.y"` — the boundary is a segment, not a character. A
    /// prefix match without that check is the classic authorization bug where
    /// `admin.*` also grants `administrator-override`.
    fn pattern_covers(pattern: &str, capability: &str) -> bool {
        if pattern == "*" {
            return true;
        }
        let Some(prefix) = pattern.strip_suffix(".*") else {
            return pattern == capability;
        };
        capability == prefix
            || (capability.starts_with(prefix)
                && capability.as_bytes().get(prefix.len()) == Some(&b'.'))
    }

    /// Whether this scope permits a capability.
    #[must_use]
    pub fn permits(&self, capability: &Capability) -> bool {
        self.0
            .iter()
            .any(|p| Self::pattern_covers(p, &capability.0))
    }

    /// Whether this scope covers everything `other` covers.
    ///
    /// The attenuation test. `other` must be no wider than `self`, which means
    /// **every** pattern in `other` is covered by **some** pattern in `self`.
    ///
    /// A pattern covers another pattern when it covers everything that pattern
    /// could match — so `"billing.*"` contains `"billing.reconcile"`, and
    /// `"billing.reconcile"` does not contain `"billing.*"`.
    #[must_use]
    pub fn contains(&self, other: &Self) -> bool {
        other.0.iter().all(|o| self.0.iter().any(|s| covers(s, o)))
    }
}

/// Whether pattern `a` covers everything pattern `b` can match.
///
/// Split out from [`Scope::contains`] because the wildcard-versus-wildcard case
/// is where this is easy to get wrong: `"billing.*"` covers `"billing.eu.*"`,
/// but `"billing.eu.*"` covers neither `"billing.*"` nor `"billing.fr"`.
fn covers(a: &str, b: &str) -> bool {
    if a == "*" {
        return true;
    }
    if b == "*" {
        // Only `*` covers `*`, and that was handled above.
        return false;
    }
    match (a.strip_suffix(".*"), b.strip_suffix(".*")) {
        // Both wildcards: a's prefix must be a segment-prefix of b's.
        (Some(pa), Some(pb)) => pb == pa || (pb.starts_with(pa) && pb.as_bytes()[pa.len()] == b'.'),
        // a is a wildcard, b is exact.
        (Some(_), None) => Scope::pattern_covers(a, b),
        // a is exact and b is a wildcard: an exact pattern can never cover a
        // family, however similar they look.
        (None, Some(_)) => false,
        // Both exact.
        (None, None) => a == b,
    }
}

/// One link in a delegation chain.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Principal {
    /// Workload or human identity. A SPIFFE ID in practice, but opaque here —
    /// the runtime depends on attenuation, not on a naming scheme.
    pub id: String,
    /// What this link may do. Never wider than its delegator's.
    pub scope: Scope,
}

impl Principal {
    pub fn new(id: impl Into<String>, scope: Scope) -> Self {
        Self {
            id: id.into(),
            scope,
        }
    }
}

/// Why a delegation was refused.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum DelegationError {
    /// The delegate asked for authority the delegator does not hold.
    ///
    /// Rejected at construction, not detected in review: this is the escalation
    /// the whole mechanism exists to make unrepresentable.
    #[error(
        "'{to}' would hold authority '{widened}' that its delegator '{from}' does not — \
         delegation may only narrow"
    )]
    ScopeWidened {
        from: String,
        to: String,
        widened: String,
    },

    /// The chain is longer than the manifest allows.
    ///
    /// A depth cap is not about scope; it bounds how far a request can travel
    /// from the human who authorized it before nobody can reason about it.
    #[error("delegation depth {depth} exceeds the limit of {max}")]
    TooDeep { depth: usize, max: usize },

    /// A chain arrived with no links, or with no owner at its root.
    #[error("delegation chain is empty: there is no principal to act as")]
    Empty,
}

/// A verified chain from a human owner down to the acting workload.
///
/// Constructed only through [`Delegation::root`], [`Delegation::delegate`] and
/// [`Delegation::rehydrate`], so **every value of this type has already been
/// checked**. There is no `Delegation::new(links)` that would let an unverified
/// chain exist — the invariant is carried by the type rather than by a function
/// somebody has to remember to call.
///
/// # Deserialization is one of those constructors
///
/// `#[serde(try_from)]` rather than a derived `Deserialize`, which would reach
/// the fields directly and *be* the `new(links)` this type refuses to offer.
/// The claim above is what the rest of the crate spends: [`DelegationScheme`]
/// is a public seam whose implementations parse credentials, and a chain also
/// arrives from a journal record and from a peer. A derive would let any of
/// them assert a chain that widens at a hop — [`I6`] inverted, through the one
/// door nobody reads as a door.
///
/// The owner is a field rather than the head of a list for the same reason: a
/// `Vec` that must be non-empty delegates the invariant to whoever remembers to
/// check, and the accessors below would each need an `expect` that a hostile
/// record could reach.
///
/// [`I6`]: https://hupe1980.github.io/agentplane/docs/security/
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(into = "DelegationWire", try_from = "DelegationWire")]
pub struct Delegation {
    /// The human the whole chain descends from.
    root: Principal,
    /// Each narrowing hop below the owner, delegator first.
    rest: Vec<Principal>,
}

/// The wire form of a [`Delegation`]: the links, in order, owner first.
///
/// A shape `serde` can build that is not yet a chain. Everything that turns one
/// into the other goes through [`Delegation::rehydrate`], so the structural
/// property is re-established on every read rather than assumed from the fact
/// that something wrote it.
#[derive(Serialize, Deserialize)]
struct DelegationWire {
    links: Vec<Principal>,
}

impl From<Delegation> for DelegationWire {
    fn from(chain: Delegation) -> Self {
        Self {
            links: chain.links().cloned().collect(),
        }
    }
}

impl TryFrom<DelegationWire> for Delegation {
    type Error = DelegationError;

    fn try_from(wire: DelegationWire) -> Result<Self, Self::Error> {
        Self::rehydrate(wire.links)
    }
}

/// How deep a chain may go before nobody can reason about it.
///
/// Three is the shape the research converges on: owner → agent → sub-agent →
/// peer. A
/// deployment can lower it; raising it is a decision someone should have to make
/// deliberately, which is why it is a constant here rather than a default that
/// quietly grows.
pub const MAX_DELEGATION_DEPTH: usize = 3;

impl Delegation {
    /// Start a chain at its owner.
    #[must_use]
    pub fn root(owner: Principal) -> Self {
        Self {
            root: owner,
            rest: Vec::new(),
        }
    }

    /// Extend the chain, narrowing authority.
    ///
    /// # Errors
    ///
    /// [`DelegationError::ScopeWidened`] if the delegate asks for anything the
    /// delegator does not hold, and [`DelegationError::TooDeep`] past
    /// [`MAX_DELEGATION_DEPTH`].
    pub fn delegate(&self, to: Principal) -> Result<Self, DelegationError> {
        let from = self.subject();
        if !from.scope.contains(&to.scope) {
            let widened = to
                .scope
                .patterns()
                .find(|p| !from.scope.contains(&Scope::of([*p])))
                .unwrap_or("<unknown>")
                .to_owned();
            return Err(DelegationError::ScopeWidened {
                from: from.id.clone(),
                to: to.id,
                widened,
            });
        }
        if self.depth() + 1 > MAX_DELEGATION_DEPTH {
            return Err(DelegationError::TooDeep {
                depth: self.depth() + 1,
                max: MAX_DELEGATION_DEPTH,
            });
        }
        let mut next = self.clone();
        next.rest.push(to);
        Ok(next)
    }

    /// The human at the root.
    #[must_use]
    pub const fn owner(&self) -> &Principal {
        &self.root
    }

    /// The workload actually acting. The policy principal.
    ///
    /// The last hop, or the owner when nobody has been delegated to yet.
    #[must_use]
    pub fn subject(&self) -> &Principal {
        self.rest.last().unwrap_or(&self.root)
    }

    /// Hops below the owner. A bare owner has depth 0.
    #[must_use]
    pub const fn depth(&self) -> usize {
        self.rest.len()
    }

    /// What this chain may actually do.
    ///
    /// The subject's scope, which by construction is already no wider than every
    /// link above it — so there is no intersection to compute here, and if there
    /// were, that would mean the invariant had been violated somewhere upstream.
    #[must_use]
    pub fn effective_scope(&self) -> &Scope {
        &self.subject().scope
    }

    /// The chain, owner first.
    pub fn links(&self) -> impl Iterator<Item = &Principal> {
        std::iter::once(&self.root).chain(self.rest.iter())
    }

    /// Rebuild a chain read back from the journal, re-checking it.
    ///
    /// Replay must not re-verify *credentials* — they expire, and a run older
    /// than its tokens would fail an audit for a problem that did not exist when
    /// the decision was made. But the *structural* property is timeless and
    /// costs nothing to confirm, so a journal that has been tampered into
    /// holding a widening chain is caught rather than trusted.
    ///
    /// # Errors
    ///
    /// If the recorded chain is empty, too deep, or widens at any hop.
    pub fn rehydrate(links: Vec<Principal>) -> Result<Self, DelegationError> {
        let mut it = links.into_iter();
        let root = it.next().ok_or(DelegationError::Empty)?;
        let mut chain = Self::root(root);
        for link in it {
            chain = chain.delegate(link)?;
        }
        Ok(chain)
    }
}

impl Delegation {
    /// The chain as policy context.
    ///
    /// Depth is capped both by manifest and by policy — and a rule can
    /// only say `context.delegation_depth >= 3` if depth is actually in the
    /// context. Likewise a rule keyed on the human owner needs the owner, not
    /// just the workload that happens to be acting.
    #[must_use]
    pub fn as_context(&self) -> serde_json::Value {
        serde_json::json!({
            "owner": self.owner().id,
            "subject": self.subject().id,
            "delegation_depth": self.depth(),
            "scope": self.effective_scope().patterns().collect::<Vec<_>>(),
        })
    }
}

/// Verifies a credential and produces the chain it asserts.
///
/// Behind a trait because AIP, WIMSE and the OAuth agent drafts are all still
/// Internet-Drafts: the credential format will change, and when it does this is
/// a driver swap rather than a redesign. What the runtime depends on is the
/// [`Delegation`] that comes out, whose attenuation is guaranteed by its own
/// constructors regardless of how it was obtained.
pub trait DelegationScheme: Send + Sync + Debug {
    /// Verify a presented credential.
    ///
    /// # Errors
    ///
    /// If the credential does not verify, or asserts a chain that widens.
    fn verify(&self, credential: &str) -> Result<Delegation, DelegationError>;
}