Skip to main content

recursive/tools/
dispatch.rs

1//! Tool dispatch: the execution path from `invoke` to tool result.
2//!
3//! Contains [`ToolDispatch`] (the return type of `invoke_with_audit`),
4//! the dispatch-stage methods of [`ToolRegistry`], touched-file recording,
5//! argument preview generation, and path containment helpers.
6
7use 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
21/// Return value of [`ToolRegistry::invoke_with_audit`]: the tool result
22/// and its accompanying audit record.
23pub struct ToolDispatch {
24    pub result: Result<String>,
25    pub audit: AuditMeta,
26}
27
28/// Inspect tool arguments for known fs tools and record their paths
29/// on the shared `TouchedFiles` collector.
30fn 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            // Edit (str_replace) stores the path in file_path.
42            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        // Goal-161: runtime permission hook — checked first, before static
56        // config, so the user gets the chance to allow/deny at call time.
57        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    /// Invoke a tool and return both its result and a populated
75    /// [`AuditMeta`]. Callers that need to persist audit data should
76    /// use this method; callers that don't can call `invoke` which
77    /// discards the audit half.
78    pub async fn invoke_with_audit(&self, name: &str, arguments: Value) -> ToolDispatch {
79        // Goal-261: pre-execution permission checks are delegated to
80        // `PermissionPipeline`. This method keeps only `touched_files`
81        // recording, tool lookup, L1 policy check, side-effect
82        // classification, step_id/hash generation, and tool execution
83        // + audit construction. The pipeline owns the 7 permission-
84        // orchestration phases and exposes a public `recheck_policy`
85        // method for callers that need to re-validate hook-mutated args.
86        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    /// Goal-261: the execution half of `invoke_with_audit` — invoked
99    /// after `PermissionPipeline::check` has returned `Allow`. Records
100    /// touched files, looks up the tool, runs the L1 policy check, and
101    /// constructs the `AuditMeta` around `tool.execute()`.
102    async fn dispatch_after_permission_check(&self, name: &str, arguments: Value) -> ToolDispatch {
103        // Record touched files for the active turn (if a collector is attached).
104        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
164/// Build a short human-readable preview of tool arguments for the
165/// permission dialog. Extracts up to 80 characters.
166pub 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
198/// Resolve a possibly-relative path against the workspace root.
199///
200/// Normalises both the root and candidate to absolute, dot-free form first,
201/// then performs a second check via `canonicalize()` (which follows symlinks)
202/// when the path already exists on disk.  This prevents symlink-based escapes
203/// where a link inside the workspace points to a location outside it.
204///
205/// For paths that do not yet exist (e.g. a new file being written), only the
206/// lexical normalisation check is performed — the caller is responsible for
207/// ensuring no symlink is created that would bridge outside the root.
208pub fn resolve_within(root: &Path, path: &str) -> Result<PathBuf> {
209    resolve_within_any(&[(root.to_path_buf(), AccessTier::ReadWrite)], path, false)
210}
211
212/// Access tier for a sandbox root. `ReadOnly` roots permit read operations
213/// only; `ReadWrite` roots permit both reads and writes. The primary
214/// workspace root is always `ReadWrite`; extra dirs declared via
215/// `[sandbox] extra_readonly_dirs` (or the TUI `/add-dir --read-only` flow)
216/// are `ReadOnly`.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum AccessTier {
219    ReadOnly,
220    ReadWrite,
221}
222
223/// Shared, session-scoped sandbox roots. This is the runtime-mutable
224/// companion to the static `extra_roots` baked into each tool at build
225/// time: the TUI's `/add-dir` command (and, in future, an interactive
226/// out-of-scope-read grant) appends entries here, and every structured
227/// filesystem tool consults the snapshot in [`resolve_within_any`] on
228/// each call so newly added roots take effect immediately — without
229/// rebuilding the agent runtime.
230///
231/// Uses `std::sync::RwLock` (not tokio) because tools only take a brief
232/// read snapshot inside `execute`; no `.await` is held across the guard.
233pub type SharedSandboxRoots =
234    std::sync::Arc<std::sync::RwLock<Vec<(std::path::PathBuf, AccessTier)>>>;
235
236/// Construct an empty shared sandbox-roots slot.
237pub fn new_shared_sandbox_roots() -> SharedSandboxRoots {
238    std::sync::Arc::new(std::sync::RwLock::new(Vec::new()))
239}
240
241/// Multi-root variant of [`resolve_within`]. The candidate path is resolved
242/// against a *set* of allowed roots; it is accepted when its canonical form
243/// falls under at least one root. Symlink-aware canonicalisation is applied
244/// per-root exactly as in the single-root case.
245///
246/// `write` selects the tier check: a path that is only contained by
247/// `ReadOnly` roots is rejected for write operations (`Write` / `Edit` /
248/// `StrReplace`) with a clear error, while remaining readable.
249///
250/// This is the sandbox primitive used by every structured filesystem tool
251/// (`Read` / `Write` / `Edit` / `Glob` / `Search` / `count_lines` /
252/// `estimate_tokens`). It preserves invariant #3 — every fs path is still
253/// containment-checked — while letting the operator declare additional
254/// allowed roots beyond the primary workspace.
255pub 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    // Lexical containment check against every root (works for paths that
275    // don't exist yet).
276    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    // Symlink-aware check: if the path exists, canonicalise it once and
290    // re-check containment against each root. A link that lexically appears
291    // inside a root but canonicalises outside *all* roots is rejected.
292    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            // Roots that don't exist yet fall back to the lexical form; the
301            // candidate's canonical form is then checked against it.
302            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    // Tier gate: writes require at least one matching ReadWrite root.
321    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
332/// Turn a path into an absolute, normalised form. Does not touch the disk,
333/// so it works for files that don't yet exist (needed by `write_file`).
334fn 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        // Relative paths resolve against the FIRST root (workspace); to read
388        // the extra dir the agent passes an absolute path.
389        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        // Following the symlink lands outside every root.
429        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    // ── args_preview_for_permission ──────────────────────────────────────────
446
447    #[test]
448    fn args_preview_short_object_passthrough() {
449        // kills args_preview_for_permission → String::new() and → "xyzzy"
450        // and kills `> with <` mutant (short string stays untouched)
451        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        // kills `> with ==` and `> with >=` mutants.
464        // Use 3 keys × 30-char value each: "k1=\"xxx…\" , k2=..., k3=..." > 80 chars total.
465        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        // kills `> with >=` mutant: exactly 80 chars should NOT be truncated
482        // Build a json string whose preview is exactly 80 chars.
483        // key="k" (3 bytes with quotes→ k="<val>") → adjust val length.
484        // Let's brute-force: start short and grow until preview reaches 80.
485        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                // exactly 80 → must NOT be truncated (> 80 is false)
492                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; // guard; test is best-effort if format changes
501            }
502        }
503    }
504
505    #[test]
506    fn args_preview_non_object_value() {
507        // covers the `other => other.to_string()` branch
508        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    // ── normalise targeted tests ──────────────────────────────────────────────
517
518    #[test]
519    fn normalise_resolves_double_dot() {
520        // kills `out.pop()` → noop mutations in normalise
521        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        // kills `CurDir => {}` → `out.push(".")` mutations
529        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        // kills function-level replacement of normalise
537        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    // ── write tier gate: && vs || and delete ! ────────────────────────────────
543
544    #[test]
545    fn rw_root_allows_both_read_and_write() {
546        let tmp = TempDir::new().unwrap();
547        let roots = vec![rw(tmp.path())];
548        // read
549        assert!(
550            resolve_within_any(&roots, "a.txt", false).is_ok(),
551            "read from RW root must succeed"
552        );
553        // write
554        assert!(
555            resolve_within_any(&roots, "b.txt", true).is_ok(),
556            "write to RW root must succeed"
557        );
558    }
559}