Skip to main content

agent_bridle_core/
gate.rs

1//! The [`Gate`] — the single mint site for [`ToolContext`].
2//!
3//! `authorize` is the choke point of the whole design (DESIGN §2). It:
4//!
5//! 1. confines the grant to the tool's declared need:
6//!    `effective = granted.meet(tool.required())` — least authority, provably
7//!    non-amplifying (the `meet` law is property-tested in agent-mesh-protocol);
8//! 2. enforces the `max_calls` budget (charges one call, denies when exhausted);
9//! 3. enforces `valid_for_generation` (denies if the gate's **generation
10//!    counter** — a causal, NOT wall-clock, coordinate — is not in the grant's
11//!    permitted set);
12//! 4. mints a [`ToolContext`] from the effective caveats and the sandbox kind.
13//!
14//! Because `ToolContext` has no other constructor, step 4 is the only way a
15//! tool can ever obtain the proof it needs to run.
16
17use std::collections::HashSet;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::Mutex;
20
21use crate::step_up::{
22    Attestation, CallRequest, Challenge, Decision, DischargeAttempt, DischargeProvider,
23    DischargeVerifier, StepUpPolicy,
24};
25use crate::{
26    AxisEnforcement, Caveats, CountBound, Sandbox, SandboxKind, Scope, Tool, ToolContext,
27    ToolError, ToolResult,
28};
29
30/// The fence-strength floor a gate stamps into every context it mints when the
31/// host does not set one (ADR 0012 D3). It is **[`AxisEnforcement::Advisory`]**
32/// (permissive — accept down to advisory) so this mechanism lands non-breaking:
33/// every existing grant keeps running. ADR 0012 D3 specifies the *eventual*
34/// strict default is `Kernel` ("fail-closed by omission"); flipping it there is
35/// the un-stub gate's job (#31), because a `Kernel` default refuses every
36/// `exec:Only` / `net:Only` run today (exec/net are not yet kernel-enforceable)
37/// and must land together with the call-site/test updates that behavior change
38/// requires. A strong principal opts in now via [`Gate::with_strength_floor`].
39pub(crate) const DEFAULT_STRENGTH_FLOOR: AxisEnforcement = AxisEnforcement::Advisory;
40
41/// Defensive cap on the freshness-window scan (ADR 0007 D4). The gate recomputes
42/// the bound challenge once per generation in the window, so an unbounded
43/// `freshness_generations` would be an unbounded loop. A discharge may be reused
44/// for at most this many generations regardless of a larger requested window —
45/// the cap **tightens** the window (fail-closed), never loosens it.
46pub(crate) const MAX_FRESHNESS_WINDOW: u64 = 4096;
47
48/// The leash enforcer. One gate backs a session (or a sub-delegation); it
49/// tracks the remaining call budget and the generation it is valid for.
50///
51/// The gate is interior-mutable on its budget (an [`AtomicU64`]) so `authorize`
52/// takes `&self` — a registry can hold one shared gate behind an `Arc`.
53pub struct Gate {
54    /// Remaining calls that may still be charged. `None` ⇒ unlimited.
55    remaining: Option<AtomicU64>,
56    /// The causal generation this gate currently embodies (a counter, never a
57    /// clock). A grant authorizes the gate only if this is in its
58    /// `valid_for_generation` set.
59    generation: u64,
60    /// The OS-level sandbox stamped into every context this gate mints.
61    sandbox_kind: SandboxKind,
62    /// The required fence-strength floor stamped into every context (ADR 0012 D3):
63    /// the weakest per-axis enforcement this principal accepts before a
64    /// confinement site fails closed. Defaults to [`DEFAULT_STRENGTH_FLOOR`].
65    strength_floor: AxisEnforcement,
66    /// Bound challenges already consumed by an accepted [`crate::Discharge`].
67    /// Makes every verified human gesture **single-use** (replay-proof): a
68    /// discharge re-presented after it first succeeded is denied. Interior-mutable
69    /// like the budget so `authorize_with_discharge` keeps `&self`. ADR 0007 D4.
70    consumed: Mutex<HashSet<[u8; 32]>>,
71}
72
73impl Gate {
74    /// A gate at `generation`, with no independent budget cap of its own (the
75    /// grant's `max_calls` still applies on the first `authorize`) and the
76    /// honest P0 sandbox kind ([`SandboxKind::None`]).
77    #[must_use]
78    pub fn new(generation: u64) -> Self {
79        Self {
80            remaining: None,
81            generation,
82            sandbox_kind: SandboxKind::None,
83            strength_floor: DEFAULT_STRENGTH_FLOOR,
84            consumed: Mutex::new(HashSet::new()),
85        }
86    }
87
88    /// A gate whose call budget is seeded from a [`CountBound`] — typically the
89    /// `max_calls` of the session grant — so the budget persists across
90    /// multiple `authorize` calls on this gate. `Unlimited` ⇒ no cap.
91    #[must_use]
92    pub fn with_budget(generation: u64, max_calls: CountBound) -> Self {
93        let remaining = match max_calls {
94            CountBound::Unlimited => None,
95            CountBound::AtMost(n) => Some(AtomicU64::new(n)),
96        };
97        Self {
98            remaining,
99            generation,
100            sandbox_kind: SandboxKind::None,
101            strength_floor: DEFAULT_STRENGTH_FLOOR,
102            consumed: Mutex::new(HashSet::new()),
103        }
104    }
105
106    /// Record the OS-level sandbox this gate's contexts run under. A tool reads
107    /// it back via [`ToolContext::sandbox_kind`]. (P3 wires a real
108    /// [`Sandbox`].)
109    #[must_use]
110    pub fn with_sandbox(mut self, sandbox: &dyn Sandbox) -> Self {
111        self.sandbox_kind = sandbox.kind();
112        self
113    }
114
115    /// Set the required fence-strength floor for every context this gate mints
116    /// (ADR 0012 D3) — the weakest per-axis enforcement the principal accepts
117    /// before a confinement site refuses to spawn. A **strong** principal raises
118    /// it to [`AxisEnforcement::Kernel`] (fail closed on any restricted axis the
119    /// real backend cannot kernel-confine); the default is the permissive
120    /// [`AxisEnforcement::Advisory`]. The floor only ever *raises* on delegation
121    /// (it cannot be lowered from inside a running tool — it has no setter on the
122    /// minted [`ToolContext`]).
123    #[must_use]
124    pub fn with_strength_floor(mut self, floor: AxisEnforcement) -> Self {
125        self.strength_floor = floor;
126        self
127    }
128
129    /// The generation this gate embodies.
130    #[must_use]
131    pub fn generation(&self) -> u64 {
132        self.generation
133    }
134
135    /// The **only** path to a [`ToolContext`].
136    ///
137    /// See the module docs for the four enforcement steps. Order matters: we
138    /// deny on authority/generation *before* charging the budget, so a denied
139    /// request does not consume a call.
140    pub fn authorize(&self, tool: &dyn Tool, granted: &Caveats) -> ToolResult<ToolContext> {
141        // (1) Least-authority confinement. `required()` is a *ceiling the tool
142        // promises to stay under*, defaulting to `top` ("confine me entirely by
143        // the grant"). The meet is the greatest lower bound, so the tool can
144        // never receive more than the grant *or* more than it declared:
145        // `effective ⊑ granted` and `effective ⊑ required`, always. There is no
146        // separate `required.leq(granted)` precondition — a tool declaring
147        // `top` would spuriously fail it; confinement is the meet, and
148        // per-operation denial happens later in the tool via the context's
149        // `check_*` leash methods.
150        let effective = granted.meet(&tool.required());
151
152        // (2) Generation check (causal, not wall-clock). Checked before
153        // charging so a denied request does not consume a call.
154        self.check_generation(granted)?;
155
156        // (3) Budget: charge exactly one call; deny (without charging) when
157        // exhausted. The grant's own max_calls is honored even when this gate
158        // carries no independent budget.
159        self.charge_one(granted)?;
160
161        // (4) The single mint site.
162        Ok(ToolContext::mint(
163            effective,
164            self.sandbox_kind,
165            self.strength_floor,
166        ))
167    }
168
169    /// Deny unless this gate's generation is in the grant's
170    /// `valid_for_generation` set (`All` ⇒ valid for every generation).
171    fn check_generation(&self, granted: &Caveats) -> ToolResult<()> {
172        let ok = match &granted.valid_for_generation {
173            Scope::All => true,
174            Scope::Only(set) => set.contains(&self.generation),
175        };
176        if ok {
177            Ok(())
178        } else {
179            Err(ToolError::Generation)
180        }
181    }
182
183    /// Charge one call against whichever budget is tighter: the gate's persisted
184    /// remaining count (if any) or the grant's `max_calls` for this single
185    /// dispatch. Returns [`ToolError::Budget`] when exhausted, **without**
186    /// charging.
187    fn charge_one(&self, granted: &Caveats) -> ToolResult<()> {
188        // Per-dispatch floor from the grant: a grant of AtMost(0) is always
189        // denied regardless of the gate's persisted budget.
190        if let CountBound::AtMost(0) = granted.max_calls {
191            return Err(ToolError::Budget);
192        }
193
194        match &self.remaining {
195            None => Ok(()),
196            Some(counter) => {
197                // Compare-and-decrement so concurrent authorize calls cannot
198                // over-spend the budget.
199                loop {
200                    let cur = counter.load(Ordering::Acquire);
201                    if cur == 0 {
202                        return Err(ToolError::Budget);
203                    }
204                    if counter
205                        .compare_exchange_weak(cur, cur - 1, Ordering::AcqRel, Ordering::Acquire)
206                        .is_ok()
207                    {
208                        return Ok(());
209                    }
210                }
211            }
212        }
213    }
214}
215
216/// Step-up admission (human-presence capabilities) — see [`crate::step_up`].
217///
218/// [`Gate::evaluate`] is the pure entry point; when a step-up is owed it returns
219/// [`Decision::NeedsDischarge`] without minting or charging. The caller obtains a
220/// proof and re-presents it to [`Gate::authorize_with_discharge`]. The gate only
221/// ever *verifies* a proof — it never performs the gesture (that is a host
222/// capability, a sibling of [`Sandbox`](crate::Sandbox)).
223impl Gate {
224    /// Evaluate a call under a [`StepUpPolicy`] without performing any gesture.
225    ///
226    /// [`Decision::Allow`] (minted and charged) when no step-up is owed,
227    /// [`Decision::NeedsDischarge`] (nothing minted or charged) when one is, and
228    /// [`Decision::Deny`] on a generation or budget failure.
229    pub fn evaluate(
230        &self,
231        tool: &dyn Tool,
232        granted: &Caveats,
233        request: &CallRequest,
234        policy: &StepUpPolicy,
235    ) -> Decision {
236        let effective = granted.meet(&tool.required());
237        if self.check_generation(granted).is_err() {
238            return Decision::Deny(ToolError::Generation);
239        }
240        let required = policy.required_for(request);
241        if required.demands_gesture() {
242            return Decision::NeedsDischarge(required);
243        }
244        match self.charge_one(granted) {
245            Ok(()) => Decision::Allow(ToolContext::mint(
246                effective,
247                self.sandbox_kind,
248                self.strength_floor,
249            )),
250            Err(e) => Decision::Deny(e),
251        }
252    }
253
254    /// Admit a call that owes a step-up by verifying a [`Discharge`].
255    ///
256    /// Recomputes the bound [`Challenge`] from `request`, the gate's generation,
257    /// and `nonce`, then asks `verifier` to check the proof. On success mints the
258    /// context (least authority, exactly as [`Gate::authorize`]) and — when the
259    /// policy demanded a record — returns a content-addressed [`Attestation`].
260    /// Ordering matches `authorize`: deny on generation or verification *before*
261    /// charging, so a rejected discharge consumes no call. With no step-up owed
262    /// this degenerates to an ordinary authorize.
263    pub fn authorize_with_discharge(
264        &self,
265        tool: &dyn Tool,
266        granted: &Caveats,
267        request: &CallRequest,
268        policy: &StepUpPolicy,
269        attempt: &DischargeAttempt,
270    ) -> ToolResult<(ToolContext, Option<Attestation>)> {
271        let effective = granted.meet(&tool.required());
272        self.check_generation(granted)?;
273
274        let required = policy.required_for(request);
275        if !required.demands_gesture() {
276            self.charge_one(granted)?;
277            return Ok((
278                ToolContext::mint(effective, self.sandbox_kind, self.strength_floor),
279                None,
280            ));
281        }
282
283        // Freshness window (ADR 0007 D4): accept a discharge bound to any
284        // generation in `[generation - freshness_generations, generation]`.
285        // Recompute the bound challenge across the window (newest first) and use
286        // the one the discharge actually answers. If none match, fall back to the
287        // current-generation challenge so the verifier yields the canonical "does
288        // not answer this action's challenge" denial — which covers both a wrong
289        // action/nonce and a too-stale gesture. `freshness_generations: 0` ⇒ only
290        // the current generation is accepted (fresh-per-act). The window is
291        // capped (MAX_FRESHNESS_WINDOW) to bound the scan, fail-closed.
292        let content_id = request.content_id();
293        let window = required.freshness_generations.min(MAX_FRESHNESS_WINDOW);
294        let oldest = self.generation.saturating_sub(window);
295        let expected = (oldest..=self.generation)
296            .rev()
297            .map(|g| Challenge::bind(&content_id, g, &attempt.nonce))
298            .find(|c| c.as_bytes() == &attempt.discharge.challenge)
299            .unwrap_or_else(|| Challenge::bind(&content_id, self.generation, &attempt.nonce));
300
301        if let Err(reason) = attempt
302            .verifier
303            .verify(attempt.discharge, &required, &expected)
304        {
305            return Err(ToolError::denied(reason));
306        }
307
308        // Single-use (ADR 0007 D4): consume the bound challenge atomically,
309        // *before* charging or minting, so a replay (same content_id+generation+
310        // nonce) is denied and charges nothing — one gesture authorizes exactly
311        // one act. The lock makes two concurrent identical discharges resolve to
312        // exactly one success.
313        {
314            let mut consumed = self
315                .consumed
316                .lock()
317                .expect("step-up consumed-challenge ledger mutex poisoned");
318            if !consumed.insert(*expected.as_bytes()) {
319                return Err(ToolError::denied("discharge already consumed (replay)"));
320            }
321        }
322
323        let attestation = required.record.then(|| {
324            Attestation::from_verified(
325                &request.tool,
326                &request.resource,
327                attempt.discharge,
328                self.generation,
329            )
330        });
331
332        self.charge_one(granted)?;
333        Ok((
334            ToolContext::mint(effective, self.sandbox_kind, self.strength_floor),
335            attestation,
336        ))
337    }
338
339    /// Orchestrate the whole step-up sequence — evaluate, run the host ceremony,
340    /// and authorize — so a host needs **one** call for the gated path.
341    ///
342    /// Computes the requirement for `request`; if no gesture is owed this
343    /// degenerates to an ordinary [`Gate::authorize`] (with `None` for the
344    /// attestation). Otherwise it runs the host's `provider` ceremony, supplying
345    /// the gate's generation and the caller's single-use `nonce`, then forwards
346    /// the produced proof to [`Gate::authorize_with_discharge`] — reusing that
347    /// single verified mint path (this adds **no** second mint site).
348    ///
349    /// Fail-closed: a provider error (the human declined, no authenticator, a
350    /// transport failure) returns [`ToolError::denied`] and mints/charges
351    /// nothing. The gate still verifies the proof itself via `verifier` (the
352    /// presence floor and the challenge binding); the provider is never trusted
353    /// to self-attest (ADR 0007 D5), so a `verifier` that rejects a too-weak or
354    /// mismatched proof still denies even when the provider returned `Ok`.
355    #[allow(clippy::too_many_arguments)]
356    pub fn authorize_step_up(
357        &self,
358        tool: &dyn Tool,
359        granted: &Caveats,
360        request: &CallRequest,
361        policy: &StepUpPolicy,
362        provider: &dyn DischargeProvider,
363        verifier: &dyn DischargeVerifier,
364        nonce: [u8; 32],
365    ) -> ToolResult<(ToolContext, Option<Attestation>)> {
366        let required = policy.required_for(request);
367        if !required.demands_gesture() {
368            // No step-up owed: the base authorize is the whole story.
369            return self.authorize(tool, granted).map(|cx| (cx, None));
370        }
371        // Run the host ceremony. A failure is fail-closed — nothing minted or
372        // charged, because we have not reached the mint path yet.
373        let discharge = provider
374            .obtain(request, &required, self.generation, &nonce)
375            .map_err(ToolError::denied)?;
376        let attempt = DischargeAttempt {
377            nonce,
378            discharge: &discharge,
379            verifier,
380        };
381        // Reuse the single verified mint path. The gate re-checks presence and
382        // the challenge binding regardless of what the provider claimed.
383        self.authorize_with_discharge(tool, granted, request, policy, &attempt)
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::ToolContext;
391
392    struct NoopTool {
393        required: Caveats,
394    }
395    #[async_trait::async_trait]
396    impl Tool for NoopTool {
397        fn name(&self) -> &str {
398            "noop"
399        }
400        fn schema(&self) -> serde_json::Value {
401            serde_json::json!({})
402        }
403        fn required(&self) -> Caveats {
404            self.required.clone()
405        }
406        async fn invoke(
407            &self,
408            _args: serde_json::Value,
409            _cx: &ToolContext,
410        ) -> ToolResult<serde_json::Value> {
411            Ok(serde_json::Value::Null)
412        }
413    }
414
415    fn top_tool() -> NoopTool {
416        NoopTool {
417            required: Caveats::top(),
418        }
419    }
420
421    /// ADR 0012 D3: the fence-strength floor defaults to the permissive
422    /// `Advisory` (non-breaking) and is stamped, immutable, into every context;
423    /// a strong principal raises it via `with_strength_floor`.
424    #[test]
425    fn strength_floor_defaults_advisory_and_rides_into_the_context() {
426        let cx = Gate::new(0)
427            .authorize(&top_tool(), &Caveats::top())
428            .expect("authorize");
429        assert_eq!(cx.strength_floor(), AxisEnforcement::Advisory);
430
431        let strong = Gate::new(0)
432            .with_strength_floor(AxisEnforcement::Kernel)
433            .authorize(&top_tool(), &Caveats::top())
434            .expect("authorize");
435        assert_eq!(strong.strength_floor(), AxisEnforcement::Kernel);
436    }
437
438    #[test]
439    fn effective_is_meet_and_leq_granted() {
440        let granted = Caveats {
441            exec: Scope::only(["echo".to_string(), "ls".to_string()]),
442            max_calls: CountBound::AtMost(5),
443            ..Caveats::top()
444        };
445        // Tool needs only `echo`.
446        let tool = NoopTool {
447            required: Caveats {
448                exec: Scope::only(["echo".to_string()]),
449                ..Caveats::top()
450            },
451        };
452        let gate = Gate::new(0);
453        let cx = gate.authorize(&tool, &granted).unwrap();
454        // effective == granted.meet(required)
455        assert_eq!(*cx.caveats(), granted.meet(&tool.required()));
456        // effective ⊑ granted
457        assert!(cx.caveats().leq(&granted));
458        // and exec narrowed to the meet (just echo)
459        assert_eq!(cx.caveats().exec, Scope::only(["echo".to_string()]));
460    }
461
462    #[test]
463    fn effective_is_intersection_when_tool_declares_more_than_granted() {
464        // Tool declares it may exec `rm`; session only granted `echo`. The meet
465        // is the empty intersection, so the tool is authorized but effectively
466        // can exec *nothing* — denial surfaces at the per-operation check, not
467        // at authorize. This is the least-authority guarantee in action.
468        let tool = NoopTool {
469            required: Caveats {
470                exec: Scope::only(["rm".to_string()]),
471                ..Caveats::top()
472            },
473        };
474        let granted = Caveats {
475            exec: Scope::only(["echo".to_string()]),
476            ..Caveats::top()
477        };
478        let gate = Gate::new(0);
479        let cx = gate.authorize(&tool, &granted).expect("authorize succeeds");
480        assert_eq!(cx.caveats().exec, Scope::none());
481        assert!(cx.check_exec("rm").is_err());
482        assert!(cx.check_exec("echo").is_err()); // not in the meet either
483        assert!(cx.caveats().leq(&granted));
484    }
485
486    #[test]
487    fn default_required_top_is_confined_by_grant() {
488        // A tool that declares `required = top` (the default) must NOT be
489        // denied under a restricted grant — it is confined *to* the grant.
490        let granted = Caveats {
491            exec: Scope::only(["echo".to_string()]),
492            ..Caveats::top()
493        };
494        let gate = Gate::new(0);
495        let cx = gate.authorize(&top_tool(), &granted).expect("authorize");
496        assert_eq!(*cx.caveats(), granted); // meet(top, granted) == granted
497    }
498
499    #[test]
500    fn budget_at_most_two_allows_two_then_denies() {
501        let granted = Caveats::top();
502        let gate = Gate::with_budget(0, CountBound::AtMost(2));
503        assert!(gate.authorize(&top_tool(), &granted).is_ok());
504        assert!(gate.authorize(&top_tool(), &granted).is_ok());
505        let err = gate.authorize(&top_tool(), &granted).unwrap_err();
506        assert!(matches!(err, ToolError::Budget));
507    }
508
509    #[test]
510    fn grant_max_calls_zero_always_denied() {
511        let granted = Caveats {
512            max_calls: CountBound::AtMost(0),
513            ..Caveats::top()
514        };
515        let gate = Gate::new(0);
516        assert!(matches!(
517            gate.authorize(&top_tool(), &granted).unwrap_err(),
518            ToolError::Budget
519        ));
520    }
521
522    #[test]
523    fn generation_mismatch_denies() {
524        // Gate is generation 7; grant only valid for generation 3.
525        let gate = Gate::new(7);
526        let granted = Caveats {
527            valid_for_generation: Scope::only([3u64]),
528            ..Caveats::top()
529        };
530        assert!(matches!(
531            gate.authorize(&top_tool(), &granted).unwrap_err(),
532            ToolError::Generation
533        ));
534
535        // Matching generation is allowed.
536        let granted_ok = Caveats {
537            valid_for_generation: Scope::only([7u64]),
538            ..Caveats::top()
539        };
540        assert!(gate.authorize(&top_tool(), &granted_ok).is_ok());
541    }
542
543    #[test]
544    fn denied_request_does_not_charge_budget() {
545        // Generation mismatch should be checked before charging, so the budget
546        // survives a denied authorize.
547        let gate = Gate::with_budget(7, CountBound::AtMost(1));
548        let bad = Caveats {
549            valid_for_generation: Scope::only([3u64]),
550            ..Caveats::top()
551        };
552        assert!(gate.authorize(&top_tool(), &bad).is_err());
553        // Budget untouched: a valid grant still works.
554        let good = Caveats::top();
555        assert!(gate.authorize(&top_tool(), &good).is_ok());
556    }
557}