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}