1use serde_json::Value;
8use std::path::{Path, PathBuf};
9use std::sync::Mutex;
10use tracing::Instrument;
11
12use crate::agent::PermissionDecision;
13use crate::error::{Error, Result};
14
15use super::audit::{
16 blake3_canonical_json, truncate_for_audit, unix_millis, AuditMeta, ExitStatus, TouchedFiles,
17};
18use super::permission_pipeline::{self, PermissionPipeline};
19use super::registry::ToolRegistry;
20
21pub struct ToolDispatch {
24 pub result: Result<String>,
25 pub audit: AuditMeta,
26}
27
28fn record_touched(name: &str, args: &Value, slot: &Mutex<TouchedFiles>) {
31 let Ok(mut t) = slot.lock() else {
32 return;
33 };
34 match name {
35 "Write" => {
36 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
37 t.paths.insert(p.to_string());
38 }
39 }
40 "Edit" => {
41 if let Some(p) = args.get("file_path").and_then(|v| v.as_str()) {
43 t.paths.insert(p.to_string());
44 }
45 }
46 "Bash" => {
47 t.saw_shell = true;
48 }
49 _ => {}
50 }
51}
52
53impl ToolRegistry {
54 pub async fn invoke(&self, name: &str, arguments: Value) -> Result<String> {
55 let effective_args = if let Some(hook) = &self.permission_hook {
58 match hook.check(name, &arguments).await {
59 PermissionDecision::Allow => arguments,
60 PermissionDecision::Transform(new_args) => new_args,
61 PermissionDecision::Deny(reason) => {
62 return Err(Error::PermissionDenied {
63 name: name.into(),
64 reason: crate::permissions::DecisionReason::Hook { name: reason },
65 });
66 }
67 }
68 } else {
69 arguments
70 };
71 self.invoke_with_audit(name, effective_args).await.result
72 }
73
74 pub async fn invoke_with_audit(&self, name: &str, arguments: Value) -> ToolDispatch {
79 let pipeline = PermissionPipeline::new(self);
87 match pipeline.check(name, arguments).await {
88 permission_pipeline::CheckOutcome::Deny { error, audit } => ToolDispatch {
89 result: Err(error),
90 audit,
91 },
92 permission_pipeline::CheckOutcome::Allow { arguments } => {
93 self.dispatch_after_permission_check(name, arguments).await
94 }
95 }
96 }
97
98 async fn dispatch_after_permission_check(&self, name: &str, arguments: Value) -> ToolDispatch {
103 if let Some(slot) = &self.touched {
105 record_touched(name, &arguments, slot);
106 }
107
108 let Some(tool) = self.find_by_name(name) else {
109 return ToolDispatch {
110 result: Err(Error::UnknownTool(name.into())),
111 audit: AuditMeta::synthetic_unknown_tool(name),
112 };
113 };
114
115 let side_effect = tool.side_effect_class();
116 let step_id = uuid::Uuid::now_v7().hyphenated().to_string();
117 let args_hash = blake3_canonical_json(&arguments);
118 let started_at = unix_millis();
119
120 let args_size = arguments.to_string().len();
121 let span = tracing::info_span!("tool.execute", name = %name, args_size);
122 let raw_result = tool
123 .execute(arguments)
124 .instrument(span)
125 .await
126 .map_err(|e| match e {
127 Error::Tool { .. }
128 | Error::BadToolArgs { .. }
129 | Error::UnknownTool(_)
130 | Error::PermissionDeniedLimit { .. } => e,
131 other => Error::Tool {
132 name: name.into(),
133 call_id: None,
134 message: other.to_string(),
135 },
136 });
137
138 let finished_at = unix_millis();
139 let exit_status = match &raw_result {
140 Ok(_) => ExitStatus::Ok,
141 Err(e) => {
142 let (clipped, truncated) = truncate_for_audit(&e.to_string());
143 ExitStatus::Err {
144 message: clipped,
145 truncated,
146 }
147 }
148 };
149
150 ToolDispatch {
151 result: raw_result,
152 audit: AuditMeta {
153 step_id,
154 started_at,
155 finished_at,
156 args_hash,
157 side_effect,
158 exit_status,
159 },
160 }
161 }
162}
163
164pub fn args_preview_for_permission(arguments: &Value) -> String {
167 let s = match arguments {
168 Value::Object(map) => {
169 let parts: Vec<String> = map
170 .iter()
171 .take(3)
172 .map(|(k, v)| {
173 let v_str = match v {
174 Value::String(s) => {
175 let short: String = s.chars().take(30).collect();
176 format!("\"{}\"", short)
177 }
178 other => {
179 let s = other.to_string();
180 s.chars().take(30).collect()
181 }
182 };
183 format!("{k}={v_str}")
184 })
185 .collect();
186 parts.join(", ")
187 }
188 other => other.to_string(),
189 };
190 if s.chars().count() > 80 {
191 let head: String = s.chars().take(79).collect();
192 format!("{head}…")
193 } else {
194 s
195 }
196}
197
198pub fn resolve_within(root: &Path, path: &str) -> Result<PathBuf> {
209 resolve_within_any(&[(root.to_path_buf(), AccessTier::ReadWrite)], path, false)
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum AccessTier {
219 ReadOnly,
220 ReadWrite,
221}
222
223pub type SharedSandboxRoots =
234 std::sync::Arc<std::sync::RwLock<Vec<(std::path::PathBuf, AccessTier)>>>;
235
236pub fn new_shared_sandbox_roots() -> SharedSandboxRoots {
238 std::sync::Arc::new(std::sync::RwLock::new(Vec::new()))
239}
240
241pub fn resolve_within_any(
256 roots: &[(PathBuf, AccessTier)],
257 path: &str,
258 write: bool,
259) -> Result<PathBuf> {
260 let candidate = Path::new(path);
261 let joined = if candidate.is_absolute() {
262 candidate.to_path_buf()
263 } else {
264 let Some((first_root, _)) = roots.first() else {
265 return Err(Error::BadToolArgs {
266 name: "<fs>".into(),
267 message: format!("path `{path}` given with no sandbox roots"),
268 });
269 };
270 first_root.join(candidate)
271 };
272 let abs_joined = absolutise(&joined);
273
274 let mut lexical_matches: Vec<AccessTier> = Vec::new();
277 for (root, tier) in roots {
278 if abs_joined.starts_with(absolutise(root)) {
279 lexical_matches.push(*tier);
280 }
281 }
282 if lexical_matches.is_empty() {
283 return Err(Error::BadToolArgs {
284 name: "<fs>".into(),
285 message: format!("path `{path}` escapes sandbox roots"),
286 });
287 }
288
289 let tier_matches: Vec<AccessTier> = if abs_joined.exists() {
293 let canonical_joined = abs_joined.canonicalize().map_err(|e| Error::BadToolArgs {
294 name: "<fs>".into(),
295 message: format!("cannot resolve path `{path}`: {e}"),
296 })?;
297 let mut matches = Vec::new();
298 for (root, tier) in roots {
299 let abs_root = absolutise(root);
300 let canonical_root = abs_root.canonicalize().unwrap_or(abs_root);
303 if canonical_joined.starts_with(&canonical_root) {
304 matches.push(*tier);
305 }
306 }
307 if matches.is_empty() {
308 return Err(Error::BadToolArgs {
309 name: "<fs>".into(),
310 message: format!(
311 "path `{path}` resolves via symlink to a location outside all sandbox roots"
312 ),
313 });
314 }
315 matches
316 } else {
317 lexical_matches
318 };
319
320 if write && !tier_matches.contains(&AccessTier::ReadWrite) {
322 return Err(Error::BadToolArgs {
323 name: "<fs>".into(),
324 message: format!(
325 "path `{path}` is inside a read-only sandbox root; writes are not allowed"
326 ),
327 });
328 }
329 Ok(abs_joined)
330}
331
332fn absolutise(p: &std::path::Path) -> std::path::PathBuf {
335 let abs = if p.is_absolute() {
336 p.to_path_buf()
337 } else {
338 std::env::current_dir()
339 .unwrap_or_else(|_| std::path::PathBuf::from("."))
340 .join(p)
341 };
342 normalise(&abs)
343}
344
345fn normalise(p: &std::path::Path) -> std::path::PathBuf {
346 let mut out = std::path::PathBuf::new();
347 for c in p.components() {
348 use std::path::Component::*;
349 match c {
350 ParentDir => {
351 out.pop();
352 }
353 CurDir => {}
354 other => out.push(other.as_os_str()),
355 }
356 }
357 out
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use tempfile::TempDir;
364
365 fn rw(p: impl Into<PathBuf>) -> (PathBuf, AccessTier) {
366 (p.into(), AccessTier::ReadWrite)
367 }
368
369 fn ro(p: impl Into<PathBuf>) -> (PathBuf, AccessTier) {
370 (p.into(), AccessTier::ReadOnly)
371 }
372
373 #[test]
374 fn single_root_allows_inside_rejects_outside() {
375 let tmp = TempDir::new().unwrap();
376 let roots = vec![rw(tmp.path())];
377 assert!(resolve_within_any(&roots, "a.txt", false).is_ok());
378 assert!(resolve_within_any(&roots, "../escape", false).is_err());
379 }
380
381 #[test]
382 fn second_root_lets_path_outside_workspace_in() {
383 let ws = TempDir::new().unwrap();
384 let extra = TempDir::new().unwrap();
385 std::fs::write(extra.path().join("note.md"), "hi").unwrap();
386 let roots = vec![rw(ws.path()), ro(extra.path())];
387 let abs = extra.path().join("note.md");
390 let got = resolve_within_any(&roots, &abs.to_string_lossy(), false);
391 assert!(
392 got.is_ok(),
393 "absolute path inside extra root must be allowed"
394 );
395 }
396
397 #[test]
398 fn read_only_root_blocks_write() {
399 let ws = TempDir::new().unwrap();
400 let extra = TempDir::new().unwrap();
401 let roots = vec![rw(ws.path()), ro(extra.path())];
402 let abs = extra.path().join("new.txt");
403 let err = resolve_within_any(&roots, &abs.to_string_lossy(), true).unwrap_err();
404 assert!(
405 err.to_string().contains("read-only"),
406 "write to read-only root must be rejected: {err}"
407 );
408 }
409
410 #[test]
411 fn read_only_root_allows_read() {
412 let ws = TempDir::new().unwrap();
413 let extra = TempDir::new().unwrap();
414 std::fs::write(extra.path().join("note.md"), "hi").unwrap();
415 let roots = vec![rw(ws.path()), ro(extra.path())];
416 let abs = extra.path().join("note.md");
417 assert!(resolve_within_any(&roots, &abs.to_string_lossy(), false).is_ok());
418 }
419
420 #[cfg(unix)]
421 #[test]
422 fn symlink_escape_into_no_root_is_rejected() {
423 let ws = TempDir::new().unwrap();
424 let outside = TempDir::new().unwrap();
425 let link = ws.path().join("trap");
426 std::os::unix::fs::symlink(outside.path(), &link).unwrap();
427 let roots = vec![rw(ws.path())];
428 assert!(resolve_within_any(&roots, "trap", false).is_err());
430 }
431
432 #[test]
433 fn empty_roots_rejects_everything() {
434 let err = resolve_within_any(&[], "a.txt", false).unwrap_err();
435 assert!(err.to_string().contains("no sandbox roots"));
436 }
437
438 #[test]
439 fn resolve_within_delegates_and_preserves_escapes_message() {
440 let tmp = TempDir::new().unwrap();
441 let err = resolve_within(tmp.path(), "../etc/passwd").unwrap_err();
442 assert!(err.to_string().contains("escapes"));
443 }
444
445 #[test]
448 fn args_preview_short_object_passthrough() {
449 let args = serde_json::json!({"path": "src/lib.rs"});
452 let preview = args_preview_for_permission(&args);
453 assert!(preview.contains("path"), "preview must contain key 'path'");
454 assert!(preview.contains("src/lib.rs"), "preview must contain value");
455 assert!(
456 !preview.ends_with('…'),
457 "short preview must not be truncated"
458 );
459 }
460
461 #[test]
462 fn args_preview_long_object_truncated_at_80() {
463 let val = "x".repeat(30);
466 let args = serde_json::json!({"alpha": val, "beta": val, "gamma": val});
467 let preview = args_preview_for_permission(&args);
468 assert!(
469 preview.ends_with('…'),
470 "preview longer than 80 chars must end with ellipsis, got: {preview}"
471 );
472 assert_eq!(
473 preview.chars().count(),
474 80,
475 "truncated preview must be exactly 80 chars"
476 );
477 }
478
479 #[test]
480 fn args_preview_exactly_80_chars_not_truncated() {
481 let mut len = 70usize;
486 loop {
487 let val = "a".repeat(len);
488 let args = serde_json::json!({"k": val});
489 let preview = args_preview_for_permission(&args);
490 if preview.chars().count() == 80 {
491 assert!(
493 !preview.ends_with('…'),
494 "exactly 80-char preview must not be truncated"
495 );
496 break;
497 }
498 len += 1;
499 if len > 200 {
500 break; }
502 }
503 }
504
505 #[test]
506 fn args_preview_non_object_value() {
507 let args = serde_json::json!("just a string");
509 let preview = args_preview_for_permission(&args);
510 assert!(
511 preview.contains("just a string"),
512 "non-object preview must contain the value"
513 );
514 }
515
516 #[test]
519 fn normalise_resolves_double_dot() {
520 let path = std::path::Path::new("/a/b/../c");
522 let got = normalise(path);
523 assert_eq!(got, std::path::PathBuf::from("/a/c"));
524 }
525
526 #[test]
527 fn normalise_ignores_single_dot() {
528 let path = std::path::Path::new("/a/./b");
530 let got = normalise(path);
531 assert_eq!(got, std::path::PathBuf::from("/a/b"));
532 }
533
534 #[test]
535 fn normalise_preserves_absolute_path() {
536 let path = std::path::Path::new("/usr/local/bin");
538 let got = normalise(path);
539 assert_eq!(got, std::path::PathBuf::from("/usr/local/bin"));
540 }
541
542 #[test]
545 fn rw_root_allows_both_read_and_write() {
546 let tmp = TempDir::new().unwrap();
547 let roots = vec![rw(tmp.path())];
548 assert!(
550 resolve_within_any(&roots, "a.txt", false).is_ok(),
551 "read from RW root must succeed"
552 );
553 assert!(
555 resolve_within_any(&roots, "b.txt", true).is_ok(),
556 "write to RW root must succeed"
557 );
558 }
559}