Skip to main content

agent_bridle/
lib.rs

1//! `agent-bridle` — the facade.
2//!
3//! Re-exports the [`agent_bridle_core`] leash and assembles the default tool
4//! [`Registry`] a host consumes. Tools are registered through the **explicit
5//! builder** (DESIGN §5) — the DCE-proof path under `strip+lto` release
6//! profiles — and each tool's symbol is anchored here by a `pub use`, so the
7//! linker can never silently drop one from `tools/list`.
8//!
9//! ```
10//! use agent_bridle::registry;
11//! use agent_bridle::{Caveats, CountBound, Scope};
12//!
13//! # async fn demo() -> anyhow::Result<()> {
14//! let reg = registry();
15//! let granted = Caveats {
16//!     exec: Scope::only(["echo".to_string()]),
17//!     max_calls: CountBound::AtMost(2),
18//!     ..Caveats::top()
19//! };
20//! let out = reg
21//!     .dispatch("shell", serde_json::json!({ "program": "echo", "args": ["hi"] }), &granted)
22//!     .await?;
23//! assert_eq!(out["exit_code"], 0);
24//! # Ok(())
25//! # }
26//! ```
27
28#![forbid(unsafe_code)]
29#![warn(missing_docs)]
30
31// Re-export the whole leash so hosts depend on one crate.
32pub use agent_bridle_core::*;
33
34// Anchor each tool's symbol in the facade (DESIGN §5): an explicit `pub use`
35// keeps the linker from DCE-ing a tool module under strip+lto.
36#[cfg(any(feature = "shell", feature = "carried-coreutils"))]
37pub use agent_bridle_tool_shell::ShellTool;
38#[cfg(any(feature = "shell", feature = "host-shell", feature = "brush"))]
39pub use agent_bridle_tool_shell::{ShellInvocationId, ShellOutputObserver, ShellOutputStream};
40// The sandboxed-host engine (ADR 0019 / #194). Anchored here so a host can
41// construct its own registry with it (`Registry::builder().tool(Arc::new(
42// HostShellTool::new()))`); it is deliberately NOT added to `registry()` — it
43// is a complementary construction-time engine choice, and it shares the
44// `"shell"` name with `ShellTool` (ADR 0019 D3).
45#[cfg(feature = "host-shell")]
46pub use agent_bridle_tool_shell::HostShellTool;
47// The carried brush engine (agent-bridle#20): a bash-in-Rust shell run in a
48// dedicated worker. Its CommandInterceptor provides the worker-local L2 leash;
49// when effective caveats engage a native L3 backend, the worker and descendants
50// inherit it. Behind the `brush` feature (the crates.io `brush-ocap-*` fork);
51// NOT auto-added to `registry()` — it shares the `"shell"` name with ShellTool
52// (ADR 0005 D2), so the embedder selects it.
53#[cfg(feature = "brush")]
54pub use agent_bridle_tool_shell::{brush_private_control_supported, BrushShellTool};
55/// Parse and inspect Brush shell source without expansion or execution.
56///
57/// The returned source-bound schema lets an embedder present command
58/// substitutions, state-free arithmetic expansions, redirections, and
59/// statically discoverable commands for approval before invoking
60/// [`BrushShellTool`]. Arithmetic that depends on shell variables, arrays,
61/// assignments, or nested expansion fails closed, as do parameter forms that
62/// can reinterpret runtime values as expansion syntax.
63#[cfg(feature = "brush")]
64pub use agent_bridle_tool_shell::{
65    inspect_shell, DescendantExec, InspectedCommand, InspectedConstruct, InspectedRedirect,
66    RedirectOperation, ShellConstructKind, ShellInspection, ShellInspectionError,
67};
68// An embedder calls `maybe_dispatch()` at the very top of `main` so the private
69// sandboxed Brush worker re-exec resolves before normal application startup.
70#[cfg(feature = "brush")]
71pub use agent_bridle_tool_shell::maybe_dispatch;
72// With carried-coreutils, non-conflicting `ls`/`cat`/… shims additionally
73// re-exec `<self> --invoke-bundled <name>` and resolve against the host binary.
74// Registration helpers are re-exported for completeness.
75#[cfg(feature = "carried-coreutils")]
76pub use agent_bridle_tool_shell::{install_default_providers, register_shims};
77#[cfg(feature = "web")]
78pub use agent_bridle_tool_web::WebFetchTool;
79
80/// Build the default tool registry for this host's compiled feature set.
81///
82/// Uses the explicit [`Registry::builder`] — never `inventory` — so the tool
83/// set is deterministic and DCE-proof. Which tools are present depends on the
84/// compiled features:
85///
86/// - `carried-coreutils` (default): adds the carried Brush-backed `shell` tool
87///   where authenticated private control is supported; otherwise it selects
88///   the safe-subset shell rather than advertising an unusable worker.
89/// - `shell`: selects the lean argv + safe-subset `shell` instead when
90///   `carried-coreutils` is disabled.
91/// - `web`: adds the confined `web_fetch` tool — the `net` enforcer (host
92///   allowlist + SSRF block + per-redirect re-check + IP pinning).
93///
94/// Under `--no-default-features` the registry is empty but valid; a host adds
95/// tools by enabling features (or building its own registry).
96#[must_use]
97pub fn registry() -> Registry {
98    #[allow(unused_mut)]
99    let mut builder = Registry::builder();
100
101    #[cfg(feature = "carried-coreutils")]
102    {
103        if brush_private_control_supported() {
104            builder = builder.tool(std::sync::Arc::new(BrushShellTool::new()));
105        } else {
106            builder = builder.tool(std::sync::Arc::new(ShellTool::new()));
107        }
108    }
109
110    #[cfg(all(feature = "shell", not(feature = "carried-coreutils")))]
111    {
112        builder = builder.tool(std::sync::Arc::new(ShellTool::new()));
113    }
114
115    #[cfg(feature = "web")]
116    {
117        builder = builder.tool(std::sync::Arc::new(WebFetchTool::new()));
118    }
119
120    builder.build()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    /// Presence test (DESIGN §5): either registry-selected shell feature must
128    /// register exactly the public `shell` identity.
129    #[cfg(any(feature = "shell", feature = "carried-coreutils"))]
130    #[test]
131    fn shell_tool_is_present_with_feature() {
132        let reg = registry();
133        assert!(
134            reg.contains("shell"),
135            "expected `shell` tool to be registered"
136        );
137        let names = reg.tool_names();
138        assert!(
139            names.contains(&"shell"),
140            "tool_names missing shell: {names:?}"
141        );
142    }
143
144    /// The default carried engine publishes its full-shell `cmd` schema, not
145    /// the safe-subset engine's argv form.
146    #[cfg(all(
147        feature = "carried-coreutils",
148        any(target_os = "linux", target_os = "macos")
149    ))]
150    #[test]
151    fn default_shell_is_the_carried_brush_engine() {
152        let reg = registry();
153        let shell = reg
154            .tool_definitions()
155            .into_iter()
156            .find(|definition| definition["name"] == "shell")
157            .expect("carried shell present");
158        let properties = shell["inputSchema"]["properties"]
159            .as_object()
160            .expect("shell schema properties");
161        assert!(properties.contains_key("cmd"), "Brush schema needs `cmd`");
162        assert!(
163            !properties.contains_key("program"),
164            "default registry must not select the argv safe-subset engine"
165        );
166    }
167
168    /// A default build on a target without authenticated private control keeps
169    /// a functional shell, but advertises the safe-subset argv schema rather
170    /// than pretending the unavailable Brush worker can run.
171    #[cfg(all(
172        feature = "carried-coreutils",
173        not(any(target_os = "linux", target_os = "macos"))
174    ))]
175    #[test]
176    fn default_shell_falls_back_to_safe_subset_when_private_control_is_unsupported() {
177        assert!(!brush_private_control_supported());
178        let reg = registry();
179        let shell = reg
180            .tool_definitions()
181            .into_iter()
182            .find(|definition| definition["name"] == "shell")
183            .expect("safe-subset shell present");
184        let properties = shell["inputSchema"]["properties"]
185            .as_object()
186            .expect("shell schema properties");
187        assert!(
188            properties.contains_key("program"),
189            "fallback schema must disclose the argv safe-subset engine"
190        );
191    }
192
193    #[cfg(feature = "brush")]
194    #[test]
195    fn private_control_probe_matches_the_compiled_transport() {
196        assert_eq!(
197            brush_private_control_supported(),
198            cfg!(any(target_os = "linux", target_os = "macos"))
199        );
200    }
201
202    /// Without either registry-selected shell feature the tool must be absent.
203    #[cfg(not(any(feature = "shell", feature = "carried-coreutils")))]
204    #[test]
205    fn shell_tool_absent_without_feature() {
206        let reg = registry();
207        assert!(!reg.contains("shell"));
208    }
209
210    /// Presence test (DESIGN §5): under `--features web` the `web_fetch` tool —
211    /// the `net` enforcer — must be registered (and thus exposed by
212    /// `agent-bridle-mcp`). This is the CI guard that linker DCE has not dropped
213    /// it under strip+lto.
214    #[cfg(feature = "web")]
215    #[test]
216    fn web_fetch_tool_is_present_with_feature() {
217        let reg = registry();
218        assert!(
219            reg.contains("web_fetch"),
220            "expected `web_fetch` tool to be registered"
221        );
222        assert!(
223            reg.tool_names().contains(&"web_fetch"),
224            "tool_names missing web_fetch: {:?}",
225            reg.tool_names()
226        );
227    }
228
229    /// Without the `web` feature the web tool must be absent.
230    #[cfg(not(feature = "web"))]
231    #[test]
232    fn web_fetch_tool_absent_without_feature() {
233        let reg = registry();
234        assert!(!reg.contains("web_fetch"));
235    }
236
237    /// Under `--no-default-features` (no `shell`, no `web`) the registry is empty
238    /// but valid.
239    #[cfg(all(
240        not(feature = "shell"),
241        not(feature = "carried-coreutils"),
242        not(feature = "web")
243    ))]
244    #[test]
245    fn registry_is_empty_with_no_tool_features() {
246        let reg = registry();
247        assert!(reg.tool_names().is_empty());
248    }
249
250    /// The facade re-exports the core leash types.
251    #[test]
252    fn leash_types_are_reexported() {
253        let _c = Caveats::top();
254        let _s: Scope<String> = Scope::top();
255        let _b = CountBound::Unlimited;
256        let _k = SandboxKind::None;
257    }
258
259    /// The facade forwards the EdDSA WebAuthn verifier feature to core.
260    #[cfg(feature = "verifier-webauthn")]
261    #[test]
262    fn webauthn_verifier_is_reexported() {
263        let _ = WebAuthnVerifier;
264    }
265
266    /// The facade forwards the ES256 WebAuthn verifier feature to core.
267    #[cfg(feature = "verifier-webauthn-es256")]
268    #[test]
269    fn webauthn_es256_verifier_is_reexported() {
270        let _ = WebAuthnEs256Verifier;
271    }
272
273    /// The facade exposes parse-only Brush inspection so a host can preflight
274    /// dynamic constructs before any shell tool is invoked.
275    #[cfg(feature = "brush")]
276    #[test]
277    fn brush_inspection_is_reexported_for_preflight() {
278        let inspected: ShellInspection =
279            inspect_shell(r#"echo "$(printf '%s' "$((1 + 2))")""#).expect("inspection");
280
281        let substitution: &InspectedConstruct = &inspected.constructs[0];
282        assert_eq!(substitution.kind, ShellConstructKind::CommandSubstitution);
283        assert!(substitution.quoted);
284
285        let nested = substitution
286            .inspection
287            .as_deref()
288            .expect("recursive substitution inspection");
289        assert_eq!(
290            nested.constructs[0].kind,
291            ShellConstructKind::ArithmeticExpansion
292        );
293        assert_eq!(nested.constructs[0].body, "1 + 2");
294
295        let error = inspect_shell("echo $((runtime_value))")
296            .expect_err("runtime-state arithmetic must fail closed through the facade");
297        assert!(error.message().contains("runtime shell state"));
298    }
299}