Skip to main content

agent_bridle_core/
context.rs

1//! [`ToolContext`] — the mint-token that proves a tool passed the leash.
2//!
3//! This is the structural core of the design (DESIGN §2). A `ToolContext`:
4//!
5//! - has **private fields** and **no public constructor**, so it cannot be
6//!   forged outside this crate;
7//! - is minted **only** by [`crate::Gate::authorize`] (via the crate-private
8//!   [`ToolContext::mint`]);
9//! - carries the **effective** caveats (`granted.meet(required)`) plus the
10//!   [`SandboxKind`] actually in force.
11//!
12//! A [`crate::Tool`] receives a `&ToolContext` to do anything, so the only path
13//! to running a tool runs through the gate. Tools enforce per-operation policy
14//! by calling the `check_*` methods below — which consult the *effective*
15//! caveats, never the originally granted ones.
16
17use std::path::{Component, Path, PathBuf};
18
19use crate::{AxisEnforcement, Caveats, SandboxKind, Scope, ToolError, ToolResult};
20
21/// Proof that a tool invocation has passed the capability leash, carrying the
22/// least-authority caveats it is permitted to act under.
23///
24/// Constructible only inside this crate (see [`ToolContext::mint`], called
25/// solely by [`crate::Gate::authorize`]). There is intentionally no public
26/// constructor and no `pub` field — that un-forgeability is the enforcement.
27#[derive(Debug, Clone)]
28pub struct ToolContext {
29    // PRIVATE. Do not add `pub`. Do not add a public constructor.
30    effective: Caveats,
31    sandbox_kind: SandboxKind,
32    // The required fence strength (ADR 0012 D3): the *weakest* per-axis
33    // enforcement this principal will accept before a confinement site refuses.
34    // Launch-time, immutable from inside (no setter) — a running tool can neither
35    // lower it nor raise its own achieved strength (I1/I3/I13).
36    strength_floor: AxisEnforcement,
37}
38
39impl ToolContext {
40    /// The **only** mint site. Crate-private so that [`crate::Gate::authorize`]
41    /// is the single place a `ToolContext` can come into existence.
42    pub(crate) fn mint(
43        effective: Caveats,
44        sandbox_kind: SandboxKind,
45        strength_floor: AxisEnforcement,
46    ) -> Self {
47        Self {
48            effective,
49            sandbox_kind,
50            strength_floor,
51        }
52    }
53
54    /// The effective (least-authority) caveats this invocation may act under.
55    #[must_use]
56    pub fn caveats(&self) -> &Caveats {
57        &self.effective
58    }
59
60    /// The OS-level sandbox actually in force for this invocation.
61    #[must_use]
62    pub fn sandbox_kind(&self) -> SandboxKind {
63        self.sandbox_kind
64    }
65
66    /// The required fence strength (ADR 0012 D3): a confinement site refuses to
67    /// spawn when the *real* backend cannot enforce every restricted axis at or
68    /// above this floor. Default is the permissive [`AxisEnforcement::Advisory`]
69    /// (set on the [`crate::Gate`]); a strong principal raises it to `Kernel`.
70    #[must_use]
71    pub fn strength_floor(&self) -> AxisEnforcement {
72        self.strength_floor
73    }
74
75    /// Leash check: may this invocation execute `program`?
76    ///
77    /// Allowed iff `exec` is `All`, or the bounded `exec` scope contains the
78    /// program **as named** (the string passed in, typically argv0 or a
79    /// PATH-resolved absolute path) **or its basename**
80    /// (`Path::new(program).file_name()`).
81    ///
82    /// This is what makes *bare-name* grants usable: a grant of `["git"]`
83    /// allows `git`, `/usr/bin/git`, and `/opt/homebrew/bin/git` alike, because
84    /// the resolved absolute path the interceptor hands in has basename `git`.
85    /// To pin an exact executable instead, **grant a full path**: a grant of
86    /// `["/usr/bin/git"]` matches only `/usr/bin/git`, not a `git` found
87    /// elsewhere on PATH.
88    ///
89    /// Security tradeoff: a bare-name grant authorizes *any* binary named
90    /// `git` reachable on PATH (PATH ordering / shadowing decides which one
91    /// actually runs). When that ambiguity is unacceptable, grant the full
92    /// path to pin exactly. A grant that contains a path separator only ever
93    /// matches that exact path (its basename is still considered, but a grant
94    /// like `["/bin/echo"]` will not be matched by a bare `echo` because the
95    /// grant's own basename `echo` is compared against the program token, not
96    /// the reverse — see [`exec_scope_allows`]). Out-of-scope programs are
97    /// denied here, before the tool spawns anything.
98    pub fn check_exec(&self, program: &str) -> ToolResult<()> {
99        if exec_scope_allows(&self.effective.exec, program) {
100            Ok(())
101        } else {
102            Err(ToolError::denied(format!(
103                "exec of {program:?} is not within the granted authority"
104            )))
105        }
106    }
107
108    /// Leash check: may this invocation reach network `host`?
109    pub fn check_net(&self, host: &str) -> ToolResult<()> {
110        if scope_allows(&self.effective.net, host) {
111            Ok(())
112        } else {
113            Err(ToolError::denied(format!(
114                "network access to {host:?} is not within the granted authority"
115            )))
116        }
117    }
118
119    /// Leash check: may this invocation read `path`?
120    ///
121    /// See [`Self::check_path_write`] for the canonicalization contract; the
122    /// only difference is which axis (`fs_read`) is consulted.
123    pub fn check_path_read(&self, path: &Path) -> ToolResult<()> {
124        self.check_path(&self.effective.fs_read, path, "read")
125    }
126
127    /// Leash check: may this invocation write `path`?
128    ///
129    /// **Canonicalizes first, then tests membership** (DESIGN §6): the path is
130    /// resolved to a real, symlink-free location and rejected if it escapes the
131    /// granted scope via `..` or a symlink. Membership is a *containment* test
132    /// against each granted scope entry (an entry authorizes that path and its
133    /// descendants), computed on canonical paths — **never** a raw string
134    /// prefix. This closes the `@repo`/`../../etc` traversal class.
135    pub fn check_path_write(&self, path: &Path) -> ToolResult<()> {
136        self.check_path(&self.effective.fs_write, path, "write")
137    }
138
139    /// Shared path-leash logic for read and write.
140    fn check_path(&self, axis: &Scope<String>, path: &Path, op: &str) -> ToolResult<()> {
141        // `All` short-circuits — unrestricted on this axis.
142        let allowed = match axis {
143            Scope::All => return Ok(()),
144            Scope::Only(set) => set,
145        };
146
147        let canon = canonicalize_for_check(path).map_err(|e| {
148            ToolError::denied(format!(
149                "{op} of {path:?} denied: cannot canonicalize ({e})"
150            ))
151        })?;
152
153        for entry in allowed {
154            // Each scope entry is itself canonicalized so that a relative or
155            // symlinked grant is compared on equal footing. An entry that does
156            // not resolve cannot authorize anything.
157            let Ok(base) = canonicalize_for_check(Path::new(entry)) else {
158                continue;
159            };
160            if path_is_within(&canon, &base) {
161                return Ok(());
162            }
163        }
164
165        Err(ToolError::denied(format!(
166            "{op} of {} (resolved {}) is not within the granted fs_{op} scope",
167            path.display(),
168            canon.display(),
169        )))
170    }
171}
172
173/// `scope.contains(item)` for the exact string axis (`net` host matching).
174///
175/// This stays a strict membership test — network hosts must match exactly and
176/// must NOT be subjected to basename matching. Only `check_net` uses this.
177fn scope_allows(scope: &Scope<String>, item: &str) -> bool {
178    match scope {
179        Scope::All => true,
180        Scope::Only(set) => set.contains(item),
181    }
182}
183
184/// Exec-axis membership: `All`, OR the bounded set contains the program string
185/// **as given**, OR the set contains the program's **basename**.
186///
187/// Basename matching is what lets a bare-name grant (`["git"]`) match the
188/// resolved absolute path the brush interceptor hands in (`/usr/bin/git`),
189/// while a full-path grant (`["/usr/bin/git"]`) still pins exactly because the
190/// program string passed in is compared verbatim first. This is deliberately
191/// distinct from [`scope_allows`] (host matching), which must stay exact.
192fn exec_scope_allows(scope: &Scope<String>, program: &str) -> bool {
193    let set = match scope {
194        Scope::All => return true,
195        Scope::Only(set) => set,
196    };
197    // Exact match against the token as named (full-path grants pin here).
198    if set.contains(program) {
199        return true;
200    }
201    // Basename match: a bare-name grant matches any resolved path with that
202    // basename. `["git"]` allows `/usr/bin/git`; `["echo"]` does NOT allow
203    // `/bin/rm` because the basename `rm` is not in the grant.
204    if let Some(base) = Path::new(program).file_name().and_then(|b| b.to_str()) {
205        if set.contains(base) {
206            return true;
207        }
208    }
209    false
210}
211
212/// Resolve a path for a leash check.
213///
214/// We must reject symlink escapes *before* membership, but we also must support
215/// checking a path whose final component does not exist yet (the common
216/// `fs_write` case: creating a new file under an allowed directory). So we
217/// canonicalize the deepest existing ancestor and re-attach the trailing
218/// not-yet-existing components, rejecting any `..` we cannot resolve away.
219fn canonicalize_for_check(path: &Path) -> std::io::Result<PathBuf> {
220    // Fast path: the whole thing exists (this also resolves all symlinks).
221    if let Ok(c) = path.canonicalize() {
222        return Ok(c);
223    }
224
225    // Walk up to the deepest existing ancestor, canonicalize it (resolving any
226    // symlinks in the existing prefix), then re-append the tail. Reject `..`
227    // and `.` in the tail rather than letting them silently climb — `..` past a
228    // canonical, symlink-free base would be an escape we refuse to normalize.
229    let mut existing = path;
230    let mut tail: Vec<Component<'_>> = Vec::new();
231    loop {
232        if existing.exists() {
233            break;
234        }
235        match existing.parent() {
236            Some(parent) => {
237                if let Some(name) = existing.file_name() {
238                    tail.push(Component::Normal(name));
239                } else {
240                    // No file name (e.g. just `..` or `/`): nothing sane to
241                    // attach — bail to the error path below.
242                    return Err(std::io::Error::new(
243                        std::io::ErrorKind::NotFound,
244                        "path has no resolvable existing ancestor",
245                    ));
246                }
247                existing = parent;
248            }
249            None => {
250                return Err(std::io::Error::new(
251                    std::io::ErrorKind::NotFound,
252                    "no existing ancestor to canonicalize",
253                ));
254            }
255        }
256    }
257
258    let mut base = existing.canonicalize()?;
259    for comp in tail.into_iter().rev() {
260        match comp {
261            Component::Normal(name) => base.push(name),
262            Component::ParentDir => {
263                return Err(std::io::Error::new(
264                    std::io::ErrorKind::InvalidInput,
265                    "refusing to resolve `..` in a non-existent path tail",
266                ));
267            }
268            // CurDir / Prefix / RootDir in the tail are degenerate; reject.
269            _ => {
270                return Err(std::io::Error::new(
271                    std::io::ErrorKind::InvalidInput,
272                    "unexpected component in path tail",
273                ));
274            }
275        }
276    }
277    Ok(base)
278}
279
280/// True iff `candidate` is `base` itself or a descendant of `base`. Both are
281/// expected to be canonical, symlink-free paths, so this component-wise check
282/// is sound (it is *not* a string prefix test — `/a/bc` is not within `/a/b`).
283fn path_is_within(candidate: &Path, base: &Path) -> bool {
284    candidate == base || candidate.starts_with(base)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::{CountBound, Gate};
291
292    /// Mint a context the only legitimate way: through the gate.
293    fn ctx(granted: Caveats) -> ToolContext {
294        struct AnyTool;
295        #[async_trait::async_trait]
296        impl crate::Tool for AnyTool {
297            fn name(&self) -> &str {
298                "any"
299            }
300            fn schema(&self) -> serde_json::Value {
301                serde_json::json!({})
302            }
303            async fn invoke(
304                &self,
305                _args: serde_json::Value,
306                _cx: &ToolContext,
307            ) -> ToolResult<serde_json::Value> {
308                Ok(serde_json::Value::Null)
309            }
310        }
311        let gate = Gate::new(0);
312        gate.authorize(&AnyTool, &granted).expect("authorize")
313    }
314
315    #[test]
316    fn check_exec_allows_in_scope_denies_out_of_scope() {
317        let cx = ctx(Caveats {
318            exec: Scope::only(["echo".to_string()]),
319            ..Caveats::top()
320        });
321        assert!(cx.check_exec("echo").is_ok());
322        assert!(cx.check_exec("rm").is_err());
323    }
324
325    /// A bare-name grant must match the RESOLVED ABSOLUTE PATH the interceptor
326    /// hands in. This is the usability bug: `["git"]` previously denied
327    /// `/usr/bin/git` because membership was exact on the full path. Now the
328    /// basename matches.
329    #[test]
330    fn check_exec_bare_name_grant_matches_resolved_paths() {
331        let cx = ctx(Caveats {
332            exec: Scope::only(["git".to_string()]),
333            ..Caveats::top()
334        });
335        // Bare name itself.
336        assert!(cx.check_exec("git").is_ok());
337        // Resolved absolute paths with basename `git`.
338        assert!(cx.check_exec("/usr/bin/git").is_ok());
339        assert!(cx.check_exec("/opt/homebrew/bin/git").is_ok());
340    }
341
342    /// A FULL-PATH grant is the escape hatch for exactness: it pins to exactly
343    /// that path and does NOT allow a same-named binary found elsewhere.
344    #[test]
345    fn check_exec_full_path_grant_pins_exactly() {
346        let cx = ctx(Caveats {
347            exec: Scope::only(["/usr/bin/git".to_string()]),
348            ..Caveats::top()
349        });
350        // The exact pinned path is allowed.
351        assert!(cx.check_exec("/usr/bin/git").is_ok());
352        // A `git` somewhere else is denied — full-path grant pins.
353        assert!(cx.check_exec("/opt/homebrew/bin/git").is_err());
354        // NOTE: a bare `git` carries basename `git`, which is not equal to the
355        // full-path grant token, so it is denied too.
356        assert!(cx.check_exec("git").is_err());
357    }
358
359    /// Path-separator deny is preserved: granting `echo` must not let `/bin/rm`
360    /// through, because the basename `rm` was never granted.
361    #[test]
362    fn check_exec_basename_deny_preserved() {
363        let cx = ctx(Caveats {
364            exec: Scope::only(["echo".to_string()]),
365            ..Caveats::top()
366        });
367        assert!(cx.check_exec("/bin/rm").is_err());
368        // And `echo` granted does allow a resolved `/bin/echo` via basename.
369        assert!(cx.check_exec("/bin/echo").is_ok());
370    }
371
372    /// `All` allows anything on the exec axis.
373    #[test]
374    fn check_exec_all_allows_anything() {
375        let cx = ctx(Caveats {
376            exec: Scope::All,
377            ..Caveats::top()
378        });
379        assert!(cx.check_exec("git").is_ok());
380        assert!(cx.check_exec("/usr/bin/anything").is_ok());
381        assert!(cx.check_exec("/bin/rm").is_ok());
382    }
383
384    #[test]
385    fn check_net_allows_in_scope_denies_out_of_scope() {
386        let cx = ctx(Caveats {
387            net: Scope::only(["example.com".to_string()]),
388            ..Caveats::top()
389        });
390        assert!(cx.check_net("example.com").is_ok());
391        assert!(cx.check_net("evil.test").is_err());
392    }
393
394    #[test]
395    fn check_path_write_denies_outside_scope() {
396        let dir = std::env::temp_dir();
397        let cx = ctx(Caveats {
398            fs_write: Scope::only([dir.to_string_lossy().into_owned()]),
399            ..Caveats::top()
400        });
401        // A new file directly under the allowed dir is fine.
402        assert!(cx.check_path_write(&dir.join("brandnew.txt")).is_ok());
403        // Somewhere clearly outside is denied.
404        assert!(cx.check_path_write(Path::new("/etc/shadow")).is_err());
405    }
406
407    /// The load-bearing security test (DESIGN §6): canonicalize BEFORE the
408    /// membership test, so a `..` traversal and a symlink that escapes the
409    /// granted scope are both denied. A naive string-prefix check would let
410    /// both through.
411    #[test]
412    fn check_path_write_rejects_dotdot_and_symlink_escape() {
413        use std::fs;
414
415        // Unique sandbox root so concurrent test runs don't collide.
416        let root = std::env::temp_dir().join(format!(
417            "agent-bridle-pathtest-{}-{}",
418            std::process::id(),
419            // A monotonic-ish disambiguator that is NOT used for coordination —
420            // just a unique dir name. (Counter, not a clock.)
421            COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
422        ));
423        let allowed = root.join("allowed");
424        let secret_dir = root.join("secret");
425        fs::create_dir_all(&allowed).expect("mkdir allowed");
426        fs::create_dir_all(&secret_dir).expect("mkdir secret");
427        let secret_file = secret_dir.join("loot.txt");
428        fs::write(&secret_file, b"top secret").expect("write secret");
429
430        // Grant fs_write ONLY to `allowed`.
431        let cx = ctx(Caveats {
432            fs_write: Scope::only([allowed.to_string_lossy().into_owned()]),
433            ..Caveats::top()
434        });
435
436        // (a) A file genuinely inside the allowed dir is permitted.
437        assert!(cx.check_path_write(&allowed.join("ok.txt")).is_ok());
438
439        // (b) `allowed/../secret/loot.txt` escapes the scope; after
440        // canonicalization it resolves under `secret`, NOT `allowed`. DENIED.
441        let dotdot = allowed.join("..").join("secret").join("loot.txt");
442        assert!(
443            cx.check_path_write(&dotdot).is_err(),
444            "..-traversal out of scope must be denied (got Ok for {dotdot:?})"
445        );
446
447        // (c) A symlink *inside* the allowed dir pointing OUT to the secret dir.
448        // String-prefix matching would see the path start with `allowed/` and
449        // wrongly allow it; canonicalization follows the link to `secret` and
450        // DENIES.
451        #[cfg(unix)]
452        {
453            let link = allowed.join("escape");
454            std::os::unix::fs::symlink(&secret_dir, &link).expect("symlink");
455            let via_symlink = link.join("loot.txt");
456            assert!(
457                cx.check_path_write(&via_symlink).is_err(),
458                "symlink escape must be denied (got Ok for {via_symlink:?})"
459            );
460        }
461
462        // Best-effort cleanup of our own scratch.
463        let _ = fs::remove_dir_all(&root);
464    }
465
466    /// Test-only unique-name disambiguator (a counter, never a clock).
467    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
468
469    #[test]
470    fn caveats_and_sandbox_kind_are_exposed() {
471        let cx = ctx(Caveats {
472            max_calls: CountBound::AtMost(3),
473            ..Caveats::top()
474        });
475        assert_eq!(cx.caveats().max_calls, CountBound::AtMost(3));
476        assert_eq!(cx.sandbox_kind(), SandboxKind::None);
477    }
478}