Skip to main content

agent_bridle_core/
registry.rs

1//! The [`Registry`] — explicit-builder tool catalog + leashed dispatch.
2//!
3//! Explicit registration is the **default** (DESIGN §5): newt's release profile
4//! is `strip=true` + `lto="thin"`, the verified real-world trigger for linker
5//! DCE silently dropping an `inventory`-self-registered tool from `tools/list`.
6//! A `Registry::builder().tool(...).build()` is immune because every tool is
7//! referenced by an explicit anchor symbol. We deliberately do **not** use
8//! `inventory` in P0.
9
10use std::collections::BTreeMap;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::sync::Arc;
13
14use crate::gate::DEFAULT_STRENGTH_FLOOR;
15use crate::{
16    AxisEnforcement, CallRequest, Caveats, DischargeProvider, DischargeVerifier, Gate,
17    StepUpPolicy, Tool, ToolError, ToolResult,
18};
19
20/// Optional step-up enforcement wired into [`Registry::dispatch`] (ADR 0018 R2 /
21/// ADR 0007). When present, dispatch runs the gate's step-up ceremony
22/// (`evaluate → obtain → authorize_with_discharge`) instead of a plain
23/// `authorize`, so a host-designated HIGH-consequence call demands a human
24/// gesture on the **default** path — and even while *unbridled* (the human gate
25/// is orthogonal to the capability axis, ADR 0018 D8). A refused/failed gesture
26/// is a fail-closed denial; nothing is minted or charged. Absent ⇒ today's
27/// behavior (no gestures).
28struct StepUp {
29    policy: StepUpPolicy,
30    provider: Arc<dyn DischargeProvider + Send + Sync>,
31    verifier: Arc<dyn DischargeVerifier + Send + Sync>,
32}
33
34/// A catalog of tools that dispatches through the leash.
35///
36/// Each [`Registry::dispatch`] looks up the named tool, has a fresh [`Gate`]
37/// authorize it against the supplied grant (the single mint site), then runs
38/// it. A registry has no ambient authority of its own — all authority flows in
39/// per-dispatch as the `granted` caveats.
40pub struct Registry {
41    tools: BTreeMap<String, Arc<dyn Tool>>,
42    /// The causal generation dispatched gates embody. Defaults to 0; set via
43    /// [`RegistryBuilder::generation`]. A *counter*, never a clock.
44    generation: u64,
45    /// Optional step-up enforcement on the dispatch path (`None` ⇒ today's plain
46    /// authorize). Set via [`RegistryBuilder::step_up`].
47    step_up: Option<StepUp>,
48    /// Monotonic single-use nonce counter for the step-up ceremony. Core is
49    /// rng-less; a per-registry counter is single-use *across* dispatches, which
50    /// is what anti-replay needs here — the gate binds `challenge(action,
51    /// generation, nonce)`, so a fresh nonce makes a captured discharge invalid on
52    /// any later call. (A host wanting unpredictable nonces runs its own ceremony.)
53    step_up_nonce: AtomicU64,
54}
55
56impl Registry {
57    /// Start building a registry with explicit tool registration.
58    #[must_use]
59    pub fn builder() -> RegistryBuilder {
60        RegistryBuilder::default()
61    }
62
63    /// The MCP `tools/list` payload: one object per tool with `name`,
64    /// `description`-free `inputSchema`. (Descriptions are a frontend concern.)
65    #[must_use]
66    pub fn tool_definitions(&self) -> Vec<serde_json::Value> {
67        self.tools
68            .values()
69            .map(|t| {
70                serde_json::json!({
71                    "name": t.name(),
72                    "inputSchema": t.schema(),
73                })
74            })
75            .collect()
76    }
77
78    /// The set of registered tool names (sorted). Used by the CI presence test.
79    #[must_use]
80    pub fn tool_names(&self) -> Vec<&str> {
81        self.tools.keys().map(String::as_str).collect()
82    }
83
84    /// Whether a tool is registered under `name`.
85    #[must_use]
86    pub fn contains(&self, name: &str) -> bool {
87        self.tools.contains_key(name)
88    }
89
90    /// Dispatch `name` with `args`, enforced by the leash.
91    ///
92    /// A fresh gate (seeded with the grant's `max_calls` and the registry's
93    /// generation) authorizes the tool, minting the [`crate::ToolContext`] the
94    /// tool needs. If authorization is denied, the tool never runs.
95    pub async fn dispatch(
96        &self,
97        name: &str,
98        args: serde_json::Value,
99        granted: &Caveats,
100    ) -> ToolResult<serde_json::Value> {
101        self.dispatch_with_strength_floor(name, args, granted, DEFAULT_STRENGTH_FLOOR)
102            .await
103    }
104
105    /// Dispatch `name` with an explicit minimum confinement strength.
106    ///
107    /// This is the strong-principal form of [`Self::dispatch`]. The selected
108    /// floor is stamped into the unforgeable [`crate::ToolContext`] at the
109    /// gate's mint site and follows delegated trusted-worker requests. A
110    /// subprocess boundary then refuses to launch if any restricted axis would
111    /// fall below that floor. This closes the gap between a host's prospective
112    /// enforcement check and the backend actually governing execution.
113    ///
114    /// The ordinary [`Self::dispatch`] remains backwards-compatible and uses
115    /// the default [`AxisEnforcement::Advisory`] floor.
116    pub async fn dispatch_with_strength_floor(
117        &self,
118        name: &str,
119        args: serde_json::Value,
120        granted: &Caveats,
121        strength_floor: AxisEnforcement,
122    ) -> ToolResult<serde_json::Value> {
123        let tool = self
124            .tools
125            .get(name)
126            .ok_or_else(|| ToolError::not_found(name))?;
127
128        let gate = self.gate_for(granted, strength_floor);
129        let cx = match &self.step_up {
130            // Step-up wired in (ADR 0018 R2): run the host-orchestrated ceremony
131            // through the gate — a policy-demanded gesture is obtained + verified
132            // before minting; a refusal is a fail-closed denial (nothing minted or
133            // charged). The gate stays the single mint site. This holds on the
134            // default path and while unbridled (the human gate is orthogonal).
135            Some(su) => {
136                let request = CallRequest::unspecified(name);
137                // Fresh single-use nonce per ceremony (monotonic counter → the
138                // gate's bound challenge differs each call, defeating replay).
139                let mut nonce = [0u8; 32];
140                let n = self.step_up_nonce.fetch_add(1, Ordering::Relaxed);
141                nonce[..8].copy_from_slice(&n.to_le_bytes());
142                let (cx, _attestation) = gate.authorize_step_up(
143                    tool.as_ref(),
144                    granted,
145                    &request,
146                    &su.policy,
147                    su.provider.as_ref(),
148                    su.verifier.as_ref(),
149                    nonce,
150                )?;
151                cx
152            }
153            None => gate.authorize(tool.as_ref(), granted)?,
154        };
155        tool.invoke(args, &cx).await
156    }
157
158    /// Construct the per-dispatch gate. Factored out so the budget seeding and
159    /// generation stay in one place. The gate's budget is seeded from the
160    /// grant's `max_calls` so a single dispatch's per-call charge interacts
161    /// correctly with `AtMost(n)`.
162    fn gate_for(&self, granted: &Caveats, strength_floor: AxisEnforcement) -> Gate {
163        Gate::with_budget(self.generation, granted.max_calls).with_strength_floor(strength_floor)
164    }
165}
166
167/// Explicit builder for a [`Registry`]. The supported, DCE-proof registration
168/// path.
169#[derive(Default)]
170pub struct RegistryBuilder {
171    tools: BTreeMap<String, Arc<dyn Tool>>,
172    generation: u64,
173    step_up: Option<StepUp>,
174}
175
176impl RegistryBuilder {
177    /// Register a tool. A later registration with the same name replaces an
178    /// earlier one.
179    #[must_use]
180    pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
181        self.tools.insert(tool.name().to_string(), tool);
182        self
183    }
184
185    /// Set the causal generation dispatched gates will embody (default 0).
186    #[must_use]
187    pub fn generation(mut self, generation: u64) -> Self {
188        self.generation = generation;
189        self
190    }
191
192    /// Enforce **step-up** on the dispatch path (ADR 0018 R2 / ADR 0007): a
193    /// policy-demanded human gesture is obtained via `provider`, verified by
194    /// `verifier`, and required before the tool runs — on the default path and
195    /// even while unbridled. Omit to keep today's gesture-free dispatch.
196    #[must_use]
197    pub fn step_up(
198        mut self,
199        policy: StepUpPolicy,
200        provider: Arc<dyn DischargeProvider + Send + Sync>,
201        verifier: Arc<dyn DischargeVerifier + Send + Sync>,
202    ) -> Self {
203        self.step_up = Some(StepUp {
204            policy,
205            provider,
206            verifier,
207        });
208        self
209    }
210
211    /// Finish building.
212    #[must_use]
213    pub fn build(self) -> Registry {
214        Registry {
215            tools: self.tools,
216            generation: self.generation,
217            step_up: self.step_up,
218            step_up_nonce: AtomicU64::new(0),
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::{CountBound, Scope, ToolContext};
227
228    /// A tool that records that it ran and echoes its `program` arg back, but
229    /// only after the leash lets it exec that program.
230    struct ProbeTool;
231    #[async_trait::async_trait]
232    impl Tool for ProbeTool {
233        fn name(&self) -> &str {
234            "probe"
235        }
236        fn schema(&self) -> serde_json::Value {
237            serde_json::json!({ "type": "object" })
238        }
239        async fn invoke(
240            &self,
241            args: serde_json::Value,
242            cx: &ToolContext,
243        ) -> ToolResult<serde_json::Value> {
244            let program = args["program"].as_str().unwrap_or("");
245            cx.check_exec(program)?;
246            Ok(serde_json::json!({ "ran": program }))
247        }
248    }
249
250    /// A tool whose only action is to cross a subprocess boundary. The fake
251    /// path must never reach the OS when the requested strength exceeds the
252    /// governing backend.
253    struct SpawnProbeTool;
254    #[async_trait::async_trait]
255    impl Tool for SpawnProbeTool {
256        fn name(&self) -> &str {
257            "spawn_probe"
258        }
259
260        fn schema(&self) -> serde_json::Value {
261            serde_json::json!({ "type": "object" })
262        }
263
264        async fn invoke(
265            &self,
266            _args: serde_json::Value,
267            cx: &ToolContext,
268        ) -> ToolResult<serde_json::Value> {
269            crate::ConfinedCommand::new("agent-bridle-strength-floor-must-refuse").spawn(cx)?;
270            panic!("a backend downgrade must be refused before process creation")
271        }
272    }
273
274    fn reg() -> Registry {
275        Registry::builder().tool(Arc::new(ProbeTool)).build()
276    }
277
278    /// Minimal no-dependency `block_on`. `agent-bridle-core` deliberately does
279    /// NOT depend on tokio (the dep budget is the leanness win, DESIGN §3), so
280    /// these async-dispatch tests drive the future with a tiny std-only
281    /// executor. The futures here complete synchronously (no real I/O), so a
282    /// noop-waker poll loop is sufficient.
283    fn block_on<F: std::future::Future>(fut: F) -> F::Output {
284        use std::task::{Context, Poll, Waker};
285        // The crate forbids `unsafe`, so we use the safe `Waker::noop()`
286        // (stable since 1.85) rather than hand-rolling a RawWaker vtable.
287        let mut cx = Context::from_waker(Waker::noop());
288        let mut fut = std::pin::pin!(fut);
289        loop {
290            match fut.as_mut().poll(&mut cx) {
291                Poll::Ready(out) => return out,
292                Poll::Pending => std::thread::yield_now(),
293            }
294        }
295    }
296
297    #[test]
298    fn dispatch_unknown_tool_is_not_found() {
299        let r = reg();
300        let err = block_on(r.dispatch("nope", serde_json::json!({}), &Caveats::top())).unwrap_err();
301        assert!(matches!(err, ToolError::NotFound { .. }));
302    }
303
304    #[test]
305    fn dispatch_runs_in_scope_and_denies_out_of_scope() {
306        let r = reg();
307        let granted = Caveats {
308            exec: Scope::only(["echo".to_string()]),
309            ..Caveats::top()
310        };
311        let ok = block_on(r.dispatch("probe", serde_json::json!({ "program": "echo" }), &granted))
312            .unwrap();
313        assert_eq!(ok["ran"], "echo");
314
315        let denied =
316            block_on(r.dispatch("probe", serde_json::json!({ "program": "rm" }), &granted))
317                .unwrap_err();
318        assert!(matches!(denied, ToolError::Denied { .. }));
319    }
320
321    #[test]
322    fn explicit_dispatch_strength_floor_refuses_backend_downgrade() {
323        let registry = Registry::builder().tool(Arc::new(SpawnProbeTool)).build();
324        // A non-empty hostname allow-list is advisory for a directly spawned
325        // process on every current host backend. Requiring Kernel must therefore
326        // refuse before the deliberately nonexistent program reaches the OS.
327        let granted = Caveats {
328            net: Scope::only(["example.invalid".to_string()]),
329            ..Caveats::top()
330        };
331        let error = block_on(registry.dispatch_with_strength_floor(
332            "spawn_probe",
333            serde_json::json!({}),
334            &granted,
335            AxisEnforcement::Kernel,
336        ))
337        .unwrap_err();
338        let ToolError::Denied { reason } = error else {
339            panic!("backend downgrade returned the wrong error: {error:?}");
340        };
341        assert!(
342            reason.contains("required strength floor (Kernel)"),
343            "denial must identify the unachievable strength floor: {reason}"
344        );
345    }
346
347    #[test]
348    fn dispatch_budget_two_then_denied() {
349        let granted = Caveats {
350            max_calls: CountBound::AtMost(2),
351            ..Caveats::top()
352        };
353        // Each `dispatch` builds a fresh gate seeded from the grant's bound, so
354        // a single dispatch's per-call charge interacts with AtMost(n). To prove
355        // budget exhaustion *across* calls the persistent budget must live on
356        // one shared gate — so we drive the gate directly here:
357        let gate = Gate::with_budget(0, CountBound::AtMost(2));
358        let tool = ProbeTool;
359        assert!(gate.authorize(&tool, &granted).is_ok());
360        assert!(gate.authorize(&tool, &granted).is_ok());
361        assert!(matches!(
362            gate.authorize(&tool, &granted).unwrap_err(),
363            ToolError::Budget
364        ));
365    }
366
367    /// A provider whose ceremony always fails (no authenticator / human declined)
368    /// — enough to prove the gesture is *demanded* and a refusal is fail-closed,
369    /// without any crypto. The verifier is never reached (obtain fails first).
370    struct FailingProvider;
371    impl crate::DischargeProvider for FailingProvider {
372        fn obtain(
373            &self,
374            _request: &crate::CallRequest,
375            _required: &crate::AttestRequirement,
376            _generation: u64,
377            _nonce: &[u8; 32],
378        ) -> Result<crate::Discharge, String> {
379            Err("test: no authenticator present".into())
380        }
381    }
382    struct StubVerifier;
383    impl crate::DischargeVerifier for StubVerifier {
384        fn verify(
385            &self,
386            _discharge: &crate::Discharge,
387            _required: &crate::AttestRequirement,
388            _expected: &crate::Challenge,
389        ) -> Result<(), String> {
390            Ok(()) // never called in this test — the provider refuses first
391        }
392    }
393
394    /// R2 (ADR 0018): a step-up policy demanding a gesture is enforced on the
395    /// **default dispatch path** — a refused gesture is a fail-closed denial and
396    /// the tool never runs (nothing minted/charged). Without the seam, dispatch is
397    /// unchanged (covered by `dispatch_runs_in_scope_and_denies_out_of_scope`).
398    #[test]
399    fn step_up_policy_demands_a_gesture_on_the_default_path() {
400        let policy = crate::StepUpPolicy::new(
401            vec![crate::Rule {
402                selector: "probe".to_string(),
403                requirement: crate::AttestRequirement::passkey_recorded(),
404            }],
405            crate::AttestRequirement::NONE,
406        );
407        let r = Registry::builder()
408            .tool(Arc::new(ProbeTool))
409            .step_up(policy, Arc::new(FailingProvider), Arc::new(StubVerifier))
410            .build();
411        let granted = Caveats {
412            exec: Scope::only(["echo".to_string()]),
413            ..Caveats::top()
414        };
415        // The policy demands a passkey for `probe`; the provider refuses → denied.
416        let err = block_on(r.dispatch("probe", serde_json::json!({ "program": "echo" }), &granted))
417            .unwrap_err();
418        assert!(
419            matches!(err, ToolError::Denied { .. }),
420            "a demanded-but-refused gesture must fail closed: {err:?}"
421        );
422    }
423
424    /// R3 (ADR 0018 D8): the step-up (human) axis is **independent of authority**.
425    /// Even a `top()` grant — the maximally-permissive extreme an unbridled
426    /// principal carries — does not lower the step-up floor: a demanded gesture is
427    /// still required. Caveats decide *whether the authority exists*; step-up
428    /// decides *what gesture admits its use*. Nothing launders one into the other.
429    #[test]
430    fn step_up_holds_at_maximal_authority() {
431        let policy = crate::StepUpPolicy::new(
432            vec![crate::Rule {
433                selector: "probe".to_string(),
434                requirement: crate::AttestRequirement::passkey_recorded(),
435            }],
436            crate::AttestRequirement::NONE,
437        );
438        let r = Registry::builder()
439            .tool(Arc::new(ProbeTool))
440            .step_up(policy, Arc::new(FailingProvider), Arc::new(StubVerifier))
441            .build();
442        // top() authority does NOT bypass the demanded gesture.
443        let err = block_on(r.dispatch(
444            "probe",
445            serde_json::json!({ "program": "echo" }),
446            &Caveats::top(),
447        ))
448        .unwrap_err();
449        assert!(
450            matches!(err, ToolError::Denied { .. }),
451            "maximal (top) authority must still owe the step-up gesture: {err:?}"
452        );
453    }
454
455    #[test]
456    fn tool_definitions_have_name_and_schema() {
457        let r = reg();
458        let defs = r.tool_definitions();
459        assert_eq!(defs.len(), 1);
460        assert_eq!(defs[0]["name"], "probe");
461        assert!(defs[0]["inputSchema"].is_object());
462        assert!(r.contains("probe"));
463        assert_eq!(r.tool_names(), vec!["probe"]);
464    }
465}