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}