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}