agent-bridle-core 0.7.14

Capability-enforcement core for agent-bridle: the Tool trait, Gate (mint-token enforcement), Registry, and Caveats leash.
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
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//! The [`Registry`] — explicit-builder tool catalog + leashed dispatch.
//!
//! Explicit registration is the **default** (DESIGN §5): newt's release profile
//! is `strip=true` + `lto="thin"`, the verified real-world trigger for linker
//! DCE silently dropping an `inventory`-self-registered tool from `tools/list`.
//! A `Registry::builder().tool(...).build()` is immune because every tool is
//! referenced by an explicit anchor symbol. We deliberately do **not** use
//! `inventory` in P0.

use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;

use crate::gate::DEFAULT_STRENGTH_FLOOR;
use crate::{
    AxisEnforcement, CallRequest, Caveats, DischargeProvider, DischargeVerifier, Gate,
    StepUpPolicy, Tool, ToolError, ToolResult,
};

/// Optional step-up enforcement wired into [`Registry::dispatch`] (ADR 0018 R2 /
/// ADR 0007). When present, dispatch runs the gate's step-up ceremony
/// (`evaluate → obtain → authorize_with_discharge`) instead of a plain
/// `authorize`, so a host-designated HIGH-consequence call demands a human
/// gesture on the **default** path — and even while *unbridled* (the human gate
/// is orthogonal to the capability axis, ADR 0018 D8). A refused/failed gesture
/// is a fail-closed denial; nothing is minted or charged. Absent ⇒ today's
/// behavior (no gestures).
struct StepUp {
    policy: StepUpPolicy,
    provider: Arc<dyn DischargeProvider + Send + Sync>,
    verifier: Arc<dyn DischargeVerifier + Send + Sync>,
}

/// A catalog of tools that dispatches through the leash.
///
/// Each [`Registry::dispatch`] looks up the named tool, has a fresh [`Gate`]
/// authorize it against the supplied grant (the single mint site), then runs
/// it. A registry has no ambient authority of its own — all authority flows in
/// per-dispatch as the `granted` caveats.
pub struct Registry {
    tools: BTreeMap<String, Arc<dyn Tool>>,
    /// The causal generation dispatched gates embody. Defaults to 0; set via
    /// [`RegistryBuilder::generation`]. A *counter*, never a clock.
    generation: u64,
    /// Optional step-up enforcement on the dispatch path (`None` ⇒ today's plain
    /// authorize). Set via [`RegistryBuilder::step_up`].
    step_up: Option<StepUp>,
    /// Monotonic single-use nonce counter for the step-up ceremony. Core is
    /// rng-less; a per-registry counter is single-use *across* dispatches, which
    /// is what anti-replay needs here — the gate binds `challenge(action,
    /// generation, nonce)`, so a fresh nonce makes a captured discharge invalid on
    /// any later call. (A host wanting unpredictable nonces runs its own ceremony.)
    step_up_nonce: AtomicU64,
}

impl Registry {
    /// Start building a registry with explicit tool registration.
    #[must_use]
    pub fn builder() -> RegistryBuilder {
        RegistryBuilder::default()
    }

    /// The MCP `tools/list` payload: one object per tool with `name`,
    /// `description`-free `inputSchema`. (Descriptions are a frontend concern.)
    #[must_use]
    pub fn tool_definitions(&self) -> Vec<serde_json::Value> {
        self.tools
            .values()
            .map(|t| {
                serde_json::json!({
                    "name": t.name(),
                    "inputSchema": t.schema(),
                })
            })
            .collect()
    }

    /// The set of registered tool names (sorted). Used by the CI presence test.
    #[must_use]
    pub fn tool_names(&self) -> Vec<&str> {
        self.tools.keys().map(String::as_str).collect()
    }

    /// Whether a tool is registered under `name`.
    #[must_use]
    pub fn contains(&self, name: &str) -> bool {
        self.tools.contains_key(name)
    }

    /// Dispatch `name` with `args`, enforced by the leash.
    ///
    /// A fresh gate (seeded with the grant's `max_calls` and the registry's
    /// generation) authorizes the tool, minting the [`crate::ToolContext`] the
    /// tool needs. If authorization is denied, the tool never runs.
    pub async fn dispatch(
        &self,
        name: &str,
        args: serde_json::Value,
        granted: &Caveats,
    ) -> ToolResult<serde_json::Value> {
        self.dispatch_with_strength_floor(name, args, granted, DEFAULT_STRENGTH_FLOOR)
            .await
    }

    /// Dispatch `name` with an explicit minimum confinement strength.
    ///
    /// This is the strong-principal form of [`Self::dispatch`]. The selected
    /// floor is stamped into the unforgeable [`crate::ToolContext`] at the
    /// gate's mint site and follows delegated trusted-worker requests. A
    /// subprocess boundary then refuses to launch if any restricted axis would
    /// fall below that floor. This closes the gap between a host's prospective
    /// enforcement check and the backend actually governing execution.
    ///
    /// The ordinary [`Self::dispatch`] remains backwards-compatible and uses
    /// the default [`AxisEnforcement::Advisory`] floor.
    pub async fn dispatch_with_strength_floor(
        &self,
        name: &str,
        args: serde_json::Value,
        granted: &Caveats,
        strength_floor: AxisEnforcement,
    ) -> ToolResult<serde_json::Value> {
        let tool = self
            .tools
            .get(name)
            .ok_or_else(|| ToolError::not_found(name))?;

        let gate = self.gate_for(granted, strength_floor);
        let cx = match &self.step_up {
            // Step-up wired in (ADR 0018 R2): run the host-orchestrated ceremony
            // through the gate — a policy-demanded gesture is obtained + verified
            // before minting; a refusal is a fail-closed denial (nothing minted or
            // charged). The gate stays the single mint site. This holds on the
            // default path and while unbridled (the human gate is orthogonal).
            Some(su) => {
                let request = CallRequest::unspecified(name);
                // Fresh single-use nonce per ceremony (monotonic counter → the
                // gate's bound challenge differs each call, defeating replay).
                let mut nonce = [0u8; 32];
                let n = self.step_up_nonce.fetch_add(1, Ordering::Relaxed);
                nonce[..8].copy_from_slice(&n.to_le_bytes());
                let (cx, _attestation) = gate.authorize_step_up(
                    tool.as_ref(),
                    granted,
                    &request,
                    &su.policy,
                    su.provider.as_ref(),
                    su.verifier.as_ref(),
                    nonce,
                )?;
                cx
            }
            None => gate.authorize(tool.as_ref(), granted)?,
        };
        tool.invoke(args, &cx).await
    }

    /// Construct the per-dispatch gate. Factored out so the budget seeding and
    /// generation stay in one place. The gate's budget is seeded from the
    /// grant's `max_calls` so a single dispatch's per-call charge interacts
    /// correctly with `AtMost(n)`.
    fn gate_for(&self, granted: &Caveats, strength_floor: AxisEnforcement) -> Gate {
        Gate::with_budget(self.generation, granted.max_calls).with_strength_floor(strength_floor)
    }
}

/// Explicit builder for a [`Registry`]. The supported, DCE-proof registration
/// path.
#[derive(Default)]
pub struct RegistryBuilder {
    tools: BTreeMap<String, Arc<dyn Tool>>,
    generation: u64,
    step_up: Option<StepUp>,
}

impl RegistryBuilder {
    /// Register a tool. A later registration with the same name replaces an
    /// earlier one.
    #[must_use]
    pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
        self.tools.insert(tool.name().to_string(), tool);
        self
    }

    /// Set the causal generation dispatched gates will embody (default 0).
    #[must_use]
    pub fn generation(mut self, generation: u64) -> Self {
        self.generation = generation;
        self
    }

    /// Enforce **step-up** on the dispatch path (ADR 0018 R2 / ADR 0007): a
    /// policy-demanded human gesture is obtained via `provider`, verified by
    /// `verifier`, and required before the tool runs — on the default path and
    /// even while unbridled. Omit to keep today's gesture-free dispatch.
    #[must_use]
    pub fn step_up(
        mut self,
        policy: StepUpPolicy,
        provider: Arc<dyn DischargeProvider + Send + Sync>,
        verifier: Arc<dyn DischargeVerifier + Send + Sync>,
    ) -> Self {
        self.step_up = Some(StepUp {
            policy,
            provider,
            verifier,
        });
        self
    }

    /// Finish building.
    #[must_use]
    pub fn build(self) -> Registry {
        Registry {
            tools: self.tools,
            generation: self.generation,
            step_up: self.step_up,
            step_up_nonce: AtomicU64::new(0),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{CountBound, Scope, ToolContext};

    /// A tool that records that it ran and echoes its `program` arg back, but
    /// only after the leash lets it exec that program.
    struct ProbeTool;
    #[async_trait::async_trait]
    impl Tool for ProbeTool {
        fn name(&self) -> &str {
            "probe"
        }
        fn schema(&self) -> serde_json::Value {
            serde_json::json!({ "type": "object" })
        }
        async fn invoke(
            &self,
            args: serde_json::Value,
            cx: &ToolContext,
        ) -> ToolResult<serde_json::Value> {
            let program = args["program"].as_str().unwrap_or("");
            cx.check_exec(program)?;
            Ok(serde_json::json!({ "ran": program }))
        }
    }

    /// A tool whose only action is to cross a subprocess boundary. The fake
    /// path must never reach the OS when the requested strength exceeds the
    /// governing backend.
    struct SpawnProbeTool;
    #[async_trait::async_trait]
    impl Tool for SpawnProbeTool {
        fn name(&self) -> &str {
            "spawn_probe"
        }

        fn schema(&self) -> serde_json::Value {
            serde_json::json!({ "type": "object" })
        }

        async fn invoke(
            &self,
            _args: serde_json::Value,
            cx: &ToolContext,
        ) -> ToolResult<serde_json::Value> {
            crate::ConfinedCommand::new("agent-bridle-strength-floor-must-refuse").spawn(cx)?;
            panic!("a backend downgrade must be refused before process creation")
        }
    }

    fn reg() -> Registry {
        Registry::builder().tool(Arc::new(ProbeTool)).build()
    }

    /// Minimal no-dependency `block_on`. `agent-bridle-core` deliberately does
    /// NOT depend on tokio (the dep budget is the leanness win, DESIGN §3), so
    /// these async-dispatch tests drive the future with a tiny std-only
    /// executor. The futures here complete synchronously (no real I/O), so a
    /// noop-waker poll loop is sufficient.
    fn block_on<F: std::future::Future>(fut: F) -> F::Output {
        use std::task::{Context, Poll, Waker};
        // The crate forbids `unsafe`, so we use the safe `Waker::noop()`
        // (stable since 1.85) rather than hand-rolling a RawWaker vtable.
        let mut cx = Context::from_waker(Waker::noop());
        let mut fut = std::pin::pin!(fut);
        loop {
            match fut.as_mut().poll(&mut cx) {
                Poll::Ready(out) => return out,
                Poll::Pending => std::thread::yield_now(),
            }
        }
    }

    #[test]
    fn dispatch_unknown_tool_is_not_found() {
        let r = reg();
        let err = block_on(r.dispatch("nope", serde_json::json!({}), &Caveats::top())).unwrap_err();
        assert!(matches!(err, ToolError::NotFound { .. }));
    }

    #[test]
    fn dispatch_runs_in_scope_and_denies_out_of_scope() {
        let r = reg();
        let granted = Caveats {
            exec: Scope::only(["echo".to_string()]),
            ..Caveats::top()
        };
        let ok = block_on(r.dispatch("probe", serde_json::json!({ "program": "echo" }), &granted))
            .unwrap();
        assert_eq!(ok["ran"], "echo");

        let denied =
            block_on(r.dispatch("probe", serde_json::json!({ "program": "rm" }), &granted))
                .unwrap_err();
        assert!(matches!(denied, ToolError::Denied { .. }));
    }

    #[test]
    fn explicit_dispatch_strength_floor_refuses_backend_downgrade() {
        let registry = Registry::builder().tool(Arc::new(SpawnProbeTool)).build();
        // A non-empty hostname allow-list is advisory for a directly spawned
        // process on every current host backend. Requiring Kernel must therefore
        // refuse before the deliberately nonexistent program reaches the OS.
        let granted = Caveats {
            net: Scope::only(["example.invalid".to_string()]),
            ..Caveats::top()
        };
        let error = block_on(registry.dispatch_with_strength_floor(
            "spawn_probe",
            serde_json::json!({}),
            &granted,
            AxisEnforcement::Kernel,
        ))
        .unwrap_err();
        let ToolError::Denied { reason } = error else {
            panic!("backend downgrade returned the wrong error: {error:?}");
        };
        assert!(
            reason.contains("required strength floor (Kernel)"),
            "denial must identify the unachievable strength floor: {reason}"
        );
    }

    #[test]
    fn dispatch_budget_two_then_denied() {
        let granted = Caveats {
            max_calls: CountBound::AtMost(2),
            ..Caveats::top()
        };
        // Each `dispatch` builds a fresh gate seeded from the grant's bound, so
        // a single dispatch's per-call charge interacts with AtMost(n). To prove
        // budget exhaustion *across* calls the persistent budget must live on
        // one shared gate — so we drive the gate directly here:
        let gate = Gate::with_budget(0, CountBound::AtMost(2));
        let tool = ProbeTool;
        assert!(gate.authorize(&tool, &granted).is_ok());
        assert!(gate.authorize(&tool, &granted).is_ok());
        assert!(matches!(
            gate.authorize(&tool, &granted).unwrap_err(),
            ToolError::Budget
        ));
    }

    /// A provider whose ceremony always fails (no authenticator / human declined)
    /// — enough to prove the gesture is *demanded* and a refusal is fail-closed,
    /// without any crypto. The verifier is never reached (obtain fails first).
    struct FailingProvider;
    impl crate::DischargeProvider for FailingProvider {
        fn obtain(
            &self,
            _request: &crate::CallRequest,
            _required: &crate::AttestRequirement,
            _generation: u64,
            _nonce: &[u8; 32],
        ) -> Result<crate::Discharge, String> {
            Err("test: no authenticator present".into())
        }
    }
    struct StubVerifier;
    impl crate::DischargeVerifier for StubVerifier {
        fn verify(
            &self,
            _discharge: &crate::Discharge,
            _required: &crate::AttestRequirement,
            _expected: &crate::Challenge,
        ) -> Result<(), String> {
            Ok(()) // never called in this test — the provider refuses first
        }
    }

    /// R2 (ADR 0018): a step-up policy demanding a gesture is enforced on the
    /// **default dispatch path** — a refused gesture is a fail-closed denial and
    /// the tool never runs (nothing minted/charged). Without the seam, dispatch is
    /// unchanged (covered by `dispatch_runs_in_scope_and_denies_out_of_scope`).
    #[test]
    fn step_up_policy_demands_a_gesture_on_the_default_path() {
        let policy = crate::StepUpPolicy::new(
            vec![crate::Rule {
                selector: "probe".to_string(),
                requirement: crate::AttestRequirement::passkey_recorded(),
            }],
            crate::AttestRequirement::NONE,
        );
        let r = Registry::builder()
            .tool(Arc::new(ProbeTool))
            .step_up(policy, Arc::new(FailingProvider), Arc::new(StubVerifier))
            .build();
        let granted = Caveats {
            exec: Scope::only(["echo".to_string()]),
            ..Caveats::top()
        };
        // The policy demands a passkey for `probe`; the provider refuses → denied.
        let err = block_on(r.dispatch("probe", serde_json::json!({ "program": "echo" }), &granted))
            .unwrap_err();
        assert!(
            matches!(err, ToolError::Denied { .. }),
            "a demanded-but-refused gesture must fail closed: {err:?}"
        );
    }

    /// R3 (ADR 0018 D8): the step-up (human) axis is **independent of authority**.
    /// Even a `top()` grant — the maximally-permissive extreme an unbridled
    /// principal carries — does not lower the step-up floor: a demanded gesture is
    /// still required. Caveats decide *whether the authority exists*; step-up
    /// decides *what gesture admits its use*. Nothing launders one into the other.
    #[test]
    fn step_up_holds_at_maximal_authority() {
        let policy = crate::StepUpPolicy::new(
            vec![crate::Rule {
                selector: "probe".to_string(),
                requirement: crate::AttestRequirement::passkey_recorded(),
            }],
            crate::AttestRequirement::NONE,
        );
        let r = Registry::builder()
            .tool(Arc::new(ProbeTool))
            .step_up(policy, Arc::new(FailingProvider), Arc::new(StubVerifier))
            .build();
        // top() authority does NOT bypass the demanded gesture.
        let err = block_on(r.dispatch(
            "probe",
            serde_json::json!({ "program": "echo" }),
            &Caveats::top(),
        ))
        .unwrap_err();
        assert!(
            matches!(err, ToolError::Denied { .. }),
            "maximal (top) authority must still owe the step-up gesture: {err:?}"
        );
    }

    #[test]
    fn tool_definitions_have_name_and_schema() {
        let r = reg();
        let defs = r.tool_definitions();
        assert_eq!(defs.len(), 1);
        assert_eq!(defs[0]["name"], "probe");
        assert!(defs[0]["inputSchema"].is_object());
        assert!(r.contains("probe"));
        assert_eq!(r.tool_names(), vec!["probe"]);
    }
}