Skip to main content

leviath_tools/
exec.rs

1//! Tool execution: dispatch, filesystem operations, and the shell.
2
3use super::*;
4
5impl BuiltinTools {
6    /// Execute a built-in tool by name (resolving aliases), returning the result
7    /// as a string.
8    pub async fn execute(&self, name: &str, args: Value) -> String {
9        let canonical = canonical_tool_name(name);
10        // A tool whose platform capabilities aren't met never advertises, but a
11        // caller could still dispatch to it directly - reject it here too.
12        if !self.available(canonical) {
13            return format!("[error] tool '{}' is not available on this platform", name);
14        }
15        match canonical {
16            "read_file" => self.read_file(&args).await,
17            "read_files" => self.read_files(&args).await,
18            "write_file" => self.write_file(&args).await,
19            "edit_file" => self.edit_file(&args).await,
20            "list_dir" => self.list_dir(&args).await,
21            "shell" => self.shell(&args).await,
22            n if n.starts_with("context_") => {
23                "[error] context tools must be handled by the runtime".to_string()
24            }
25            _ => format!("[error] Unknown built-in tool: {}", name),
26        }
27    }
28
29    /// Refuse to create anything when the working directory itself is gone.
30    ///
31    /// `write_file` calls `create_dir_all`, which would otherwise silently
32    /// resurrect a workspace an external harness deleted mid-run - leaving the
33    /// agent writing into an empty tree that no longer resembles the checkout it
34    /// reasoned about, and masking the loss from the runtime's health check.
35    /// Creating *sub*directories inside a live workspace is untouched; only a
36    /// missing workspace root is refused.
37    pub(crate) fn ensure_workspace(&self) -> Result<(), String> {
38        if std::fs::metadata(&self.ctx.workdir).is_ok_and(|m| m.is_dir()) {
39            return Ok(());
40        }
41        Err(format!(
42            "[error] workspace '{}' is no longer accessible",
43            self.ctx.workdir.display()
44        ))
45    }
46
47    /// Resolve a requested path to an absolute path inside the workdir.
48    ///
49    /// Two checks, because either alone is insufficient:
50    ///
51    /// 1. **Lexical.** `..` and `.` are folded out and the result must sit under
52    ///    the workdir. Cheap, and it catches the obvious `../../etc/passwd`.
53    /// 2. **Symbolic.** The deepest *existing* ancestor is canonicalized and the
54    ///    result re-checked. Without this the containment was purely textual: a
55    ///    symlink at `<workdir>/link` pointing at `/` made
56    ///    `read_file("link/etc/passwd")` normalize to a path that starts with the
57    ///    workdir, pass, and then be followed by `fs::read_to_string`. The same
58    ///    hole let `write_file` overwrite `~/.ssh/authorized_keys`.
59    ///
60    /// That mattered most where the containment is load-bearing. Leviath's file
61    /// tools run **on the host over the bind-mounted workdir** even when the
62    /// stage's `shell` is confined to a container - so a symlink the agent
63    /// created inside the container escaped the container through the file
64    /// tools. It also matters for a freshly cloned repository, which is exactly
65    /// what a coding agent operates on and which can carry a checked-in symlink
66    /// pointing anywhere.
67    ///
68    /// The check is not TOCTOU-proof: a symlink planted between this call and the
69    /// subsequent `open` still wins. Closing that needs `openat`/`O_NOFOLLOW`
70    /// throughout, which is a larger change; this stops the planted-symlink case,
71    /// which is the one an agent can actually arrange.
72    pub(crate) fn resolve(&self, requested: &str) -> anyhow::Result<PathBuf> {
73        Self::resolve_within(requested, &self.ctx.workdir, resolves_within)
74    }
75
76    /// Resolve a requested path for a *read-only* tool.
77    ///
78    /// Identical to [`resolve`](Self::resolve) - same two checks, same
79    /// errors - until the workdir refuses. Only then, and only when the
80    /// agent's `[read_paths]` policy is active, the path is checked against
81    /// that policy: canonicalized first (fail closed), then both predicates
82    /// of [`leviath_core::ReadPathPolicy::decide`] must hold - the blueprint
83    /// declared it AND the user's config grants it.
84    ///
85    /// This function is deliberately not called by `write_file`/`edit_file`.
86    /// `[read_paths]` grants reads; the write tools stay on
87    /// [`resolve`](Self::resolve) so an allowlisted directory can be read but
88    /// never written.
89    pub(crate) fn resolve_read(&self, requested: &str) -> anyhow::Result<PathBuf> {
90        match Self::resolve_within(requested, &self.ctx.workdir, resolves_within) {
91            Ok(path) => Ok(path),
92            Err(workdir_err) => {
93                if !self.ctx.read_paths.is_active() {
94                    return Err(workdir_err);
95                }
96                Self::resolve_outside(
97                    requested,
98                    &self.ctx.workdir,
99                    &self.ctx.read_paths,
100                    leviath_core::canonicalize_for_match,
101                )
102            }
103        }
104    }
105
106    /// The out-of-workdir arm of [`resolve_read`](Self::resolve_read), with
107    /// the canonicalizer injected (`fn` pointer, same seam idiom as
108    /// [`resolve_within`](Self::resolve_within)) so the fail-closed refusal is
109    /// testable on every platform.
110    ///
111    /// The returned path is the *canonicalized* one - the path that was
112    /// actually vetted - so the subsequent `open` operates on what the policy
113    /// approved rather than re-walking any symlinks.
114    pub(crate) fn resolve_outside(
115        requested: &str,
116        workdir: &Path,
117        policy: &leviath_core::ReadPathPolicy,
118        canon: fn(&Path) -> Option<PathBuf>,
119    ) -> anyhow::Result<PathBuf> {
120        // Relative requests resolve against the workdir here too, so a
121        // relative `[read_paths]` entry like "../shared" is reachable by the
122        // matching relative request. Canonicalization below is what decides
123        // containment; the workdir join is just the base.
124        let raw = if Path::new(requested).is_absolute() {
125            PathBuf::from(requested)
126        } else {
127            workdir.join(requested)
128        };
129
130        // Fold `..` lexically; popping past the filesystem root is
131        // unresolvable no matter what any allowlist says. `Path::components`
132        // already drops interior `.`, and `raw` is absolute here (an absolute
133        // request, or a relative one joined onto the canonicalized workdir),
134        // so no `CurDir` survives to this loop - the catch-all mirrors
135        // `resolve_within`.
136        let mut normalized = PathBuf::new();
137        for component in raw.components() {
138            match component {
139                Component::ParentDir => {
140                    if !normalized.pop() {
141                        anyhow::bail!("path '{requested}' cannot be resolved");
142                    }
143                }
144                other => normalized.push(other),
145            }
146        }
147
148        // The policy only ever sees the real, symlink-resolved path. A path
149        // that cannot be verified is refused, never matched.
150        let Some(canonical) = canon(&normalized) else {
151            anyhow::bail!("path '{requested}' cannot be verified against [read_paths]");
152        };
153
154        match policy.decide(&canonical) {
155            leviath_core::ReadPathDecision::Allowed => Ok(canonical),
156            leviath_core::ReadPathDecision::NotDeclared => anyhow::bail!(
157                "path '{requested}' is outside the working directory and not in this \
158                 agent's [read_paths]"
159            ),
160            leviath_core::ReadPathDecision::NotGranted => anyhow::bail!(
161                "path '{requested}' matches this agent's [read_paths], but your config \
162                 does not grant it; add it under [agent_read_paths.{agent}] (or set \
163                 allow_blueprint_read_paths = true under [security]) in your config.toml",
164                agent = policy.agent
165            ),
166        }
167    }
168
169    /// Core of [`resolve`](Self::resolve) with the containment check injected.
170    ///
171    /// A `fn` pointer (not `impl Fn`) so there is one monomorphization, matching
172    /// the seam idiom used elsewhere in the workspace. The seam exists because
173    /// the refusal cannot be reached otherwise on every platform: producing the
174    /// escape needs a real symlink, and creating one on Windows requires a
175    /// privilege CI runners do not have. Injecting the predicate lets the
176    /// refusal itself be tested everywhere, while the `#[cfg(unix)]` tests below
177    /// still prove the real filesystem behaviour end to end.
178    pub(crate) fn resolve_within(
179        requested: &str,
180        workdir: &Path,
181        within: fn(&Path, &Path) -> bool,
182    ) -> anyhow::Result<PathBuf> {
183        let raw = if Path::new(requested).is_absolute() {
184            PathBuf::from(requested)
185        } else {
186            workdir.join(requested)
187        };
188
189        // Normalize by resolving .. and . without requiring the path to exist.
190        let mut normalized = PathBuf::new();
191        for component in raw.components() {
192            match component {
193                Component::ParentDir => {
194                    if !normalized.pop() {
195                        anyhow::bail!("path '{}' escapes the working directory", requested);
196                    }
197                }
198                c => normalized.push(c),
199            }
200        }
201
202        if !normalized.starts_with(workdir) {
203            anyhow::bail!("path '{}' would escape the working directory", requested);
204        }
205
206        if !within(&normalized, workdir) {
207            anyhow::bail!(
208                "path '{requested}' resolves outside the working directory through a symlink"
209            );
210        }
211
212        Ok(normalized)
213    }
214
215    pub(crate) async fn read_file(&self, args: &Value) -> String {
216        let path_str = match args.get("path").and_then(|v| v.as_str()) {
217            Some(p) => p,
218            None => return "[error] missing 'path' argument".to_string(),
219        };
220
221        let path = match self.resolve_read(path_str) {
222            Ok(p) => p,
223            Err(e) => return format!("[error] {}", e),
224        };
225
226        match std::fs::read_to_string(&path) {
227            Ok(content) => content,
228            Err(e) => format!("[error] Failed to read '{}': {}", path_str, e),
229        }
230    }
231
232    pub(crate) async fn read_files(&self, args: &Value) -> String {
233        let paths = match args.get("paths").and_then(|v| v.as_array()) {
234            Some(arr) => arr,
235            None => return "[error] missing 'paths' argument (expected array)".to_string(),
236        };
237
238        if paths.is_empty() {
239            return "[error] 'paths' array is empty".to_string();
240        }
241
242        let mut results = Vec::with_capacity(paths.len());
243        for path_val in paths {
244            let path_str = match path_val.as_str() {
245                Some(p) => p,
246                None => {
247                    results.push("[error] non-string path in array".to_string());
248                    continue;
249                }
250            };
251
252            let path = match self.resolve_read(path_str) {
253                Ok(p) => p,
254                Err(e) => {
255                    results.push(format!("### [{}]\n[error] {}", path_str, e));
256                    continue;
257                }
258            };
259
260            match std::fs::read_to_string(&path) {
261                Ok(content) => {
262                    results.push(format!("### [{}]\n{}", path_str, content));
263                }
264                Err(e) => {
265                    results.push(format!("### [{}]\n[error] Failed to read: {}", path_str, e));
266                }
267            }
268        }
269
270        results.join("\n\n")
271    }
272
273    pub(crate) async fn write_file(&self, args: &Value) -> String {
274        let path_str = match args.get("path").and_then(|v| v.as_str()) {
275            Some(p) => p,
276            None => return "[error] missing 'path' argument".to_string(),
277        };
278        let content = match args.get("content").and_then(|v| v.as_str()) {
279            Some(c) => c,
280            None => return "[error] missing 'content' argument".to_string(),
281        };
282        if let Err(e) = self.ensure_workspace() {
283            return e;
284        }
285
286        let path = match self.resolve(path_str) {
287            Ok(p) => p,
288            Err(e) => return format!("[error] {}", e),
289        };
290
291        // Serialize concurrent writes to the same file (fan-out workers).
292        let lock = self.ctx.lock_for(&path);
293        let _guard = lock.lock().await;
294
295        let parent = {
296            let mut p = path.clone();
297            p.pop();
298            p
299        };
300        if let Err(e) = std::fs::create_dir_all(&parent) {
301            return format!(
302                "[error] Failed to create directories for '{}': {}",
303                path_str, e
304            );
305        }
306
307        match std::fs::write(&path, content) {
308            Ok(()) => format!(
309                "Successfully wrote {} bytes to '{}'",
310                content.len(),
311                path_str
312            ),
313            Err(e) => format!("[error] Failed to write '{}': {}", path_str, e),
314        }
315    }
316
317    pub(crate) async fn edit_file(&self, args: &Value) -> String {
318        let path_str = match args.get("path").and_then(|v| v.as_str()) {
319            Some(p) => p,
320            None => return "[error] missing 'path' argument".to_string(),
321        };
322        let old_str = match args.get("old_str").and_then(|v| v.as_str()) {
323            Some(s) => s,
324            None => return "[error] missing 'old_str' argument".to_string(),
325        };
326        let new_str = match args.get("new_str").and_then(|v| v.as_str()) {
327            Some(s) => s,
328            None => return "[error] missing 'new_str' argument".to_string(),
329        };
330        if let Err(e) = self.ensure_workspace() {
331            return e;
332        }
333
334        let path = match self.resolve(path_str) {
335            Ok(p) => p,
336            Err(e) => return format!("[error] {}", e),
337        };
338
339        // Serialize the read-modify-write against concurrent edits/writes to the
340        // same file (fan-out workers), preventing lost updates.
341        let lock = self.ctx.lock_for(&path);
342        let _guard = lock.lock().await;
343
344        let content = match std::fs::read_to_string(&path) {
345            Ok(c) => c,
346            Err(e) => return format!("[error] Failed to read '{}': {}", path_str, e),
347        };
348
349        let count = content.matches(old_str).count();
350        match count {
351            0 => format!(
352                "[error] String not found in '{}'. Ensure old_str matches the file exactly.",
353                path_str
354            ),
355            1 => {
356                let new_content = content.replacen(old_str, new_str, 1);
357                match std::fs::write(&path, &new_content) {
358                    Ok(()) => format!("Successfully edited '{}'", path_str),
359                    Err(e) => format!("[error] Failed to write '{}': {}", path_str, e),
360                }
361            }
362            n => format!(
363                "[error] Found {} occurrences of the string in '{}'. old_str must be unique.",
364                n, path_str
365            ),
366        }
367    }
368
369    pub(crate) async fn list_dir(&self, args: &Value) -> String {
370        let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
371
372        let path = match self.resolve_read(path_str) {
373            Ok(p) => p,
374            Err(e) => return format!("[error] {}", e),
375        };
376
377        let entries = match std::fs::read_dir(&path) {
378            Ok(e) => e,
379            Err(e) => return format!("[error] Failed to read directory '{}': {}", path_str, e),
380        };
381
382        let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
383        items.sort_by_key(|e| e.file_name());
384
385        let mut lines = Vec::new();
386        for entry in items {
387            let name = entry.file_name().to_string_lossy().to_string();
388            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
389            if is_dir {
390                lines.push(format!("{}/", name));
391            } else {
392                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
393                lines.push(format!("{} ({}B)", name, size));
394            }
395        }
396
397        if lines.is_empty() {
398            format!("(empty directory: {})", path_str)
399        } else {
400            lines.join("\n")
401        }
402    }
403
404    /// Detect the best available shell on the system.
405    ///
406    /// Priority:
407    /// - Windows: cmd.exe (always available)
408    /// - Unix: $SHELL env var (user's preferred shell) → bash → zsh → sh
409    pub(crate) fn detect_shell() -> (&'static str, &'static str) {
410        Self::detect_shell_for(
411            std::env::consts::OS,
412            std::env::var("SHELL").ok(),
413            &Self::shell_path_exists,
414        )
415    }
416
417    /// Whether `path` names something on disk.
418    ///
419    /// A named `fn` rather than the closure this used to be. On Windows
420    /// [`Self::detect_shell_for`] returns before probing anything, so a closure
421    /// written at the call site would be a region the Windows coverage leg
422    /// never executes; a `fn` can be handed to the seam directly by a test that
423    /// runs on every platform.
424    pub(crate) fn shell_path_exists(path: &str) -> bool {
425        std::path::Path::new(path).exists()
426    }
427
428    /// Core shell-detection logic with injectable OS, env, and filesystem
429    /// checks for testing.
430    ///
431    /// `os` is a parameter rather than a `#[cfg(windows)]` branch, following
432    /// `leviath_sys::browser::open_command_for` and
433    /// `leviath_runtime::pipeline::shell_guidance_for`: the Windows answer is
434    /// then reachable under test from any platform instead of only on the
435    /// Windows CI leg. Callers pass `std::env::consts::OS`.
436    ///
437    /// `$SHELL` is deliberately ignored on Windows even though Git for Windows
438    /// sets it - the value names an MSYS path (`/usr/bin/bash`) that
439    /// `CreateProcess` cannot run.
440    ///
441    /// `shell_exists` is a trait object (`&dyn Fn(&str) -> bool`) rather
442    /// than `impl Fn(&str) -> bool` so every caller - production's real
443    /// `Path::exists` probe and each test's distinct closure - shares
444    /// exactly ONE monomorphization of this function instead of one per
445    /// closure type (this function was a confirmed generic-monomorphization
446    /// coverage-attribution artifact: every source position had a covered
447    /// instantiation, but the summary table still reported some as missed).
448    pub(crate) fn detect_shell_for(
449        os: &str,
450        env_shell: Option<String>,
451        shell_exists: &dyn Fn(&str) -> bool,
452    ) -> (&'static str, &'static str) {
453        if os == "windows" {
454            return ("cmd.exe", "/C");
455        }
456        if let Some(shell) = env_shell
457            && (shell.ends_with("/zsh") || shell.ends_with("/bash") || shell.ends_with("/sh"))
458            && shell_exists(&shell)
459        {
460            // Only trust `$SHELL` when it actually exists - a stale or
461            // sandbox-missing `$SHELL` (e.g. `/bin/zsh` in an environment that
462            // doesn't ship it) otherwise made every shell call fail to spawn.
463            // When it's missing, fall through to the known-path fallback list.
464            let shell: &'static str = Box::leak(shell.into_boxed_str());
465            return (shell, "-c");
466        }
467        for &shell in &[
468            "/bin/bash",
469            "/usr/bin/bash",
470            "/bin/zsh",
471            "/usr/bin/zsh",
472            "/bin/sh",
473        ] {
474            if shell_exists(shell) {
475                return (shell, "-c");
476            }
477        }
478        ("sh", "-c")
479    }
480
481    pub(crate) async fn shell(&self, args: &Value) -> String {
482        self.shell_with_timeout(args, Duration::from_secs(60)).await
483    }
484
485    /// Same as [`Self::shell`], with an injectable timeout so tests can
486    /// exercise the timeout branch without a real 60-second wait.
487    pub(crate) async fn shell_with_timeout(
488        &self,
489        args: &Value,
490        timeout_duration: Duration,
491    ) -> String {
492        let command = match args.get("command").and_then(|v| v.as_str()) {
493            Some(c) => c,
494            None => return "[error] missing 'command' argument".to_string(),
495        };
496
497        let workdir = self.ctx.workdir.clone();
498        let (shell, flag) = Self::detect_shell();
499
500        // When a sandbox executor is attached, it builds a command that runs
501        // inside a container / namespace (still targeting `workdir`); otherwise
502        // run the shell directly on the host - the exact prior behavior.
503        let mut cmd = match &self.shell_executor {
504            Some(executor) => executor.build_command(shell, flag, command, &workdir),
505            None => {
506                let mut c = Command::new(shell);
507                c.arg(flag).arg(command).current_dir(&workdir);
508                c
509            }
510        };
511        // Reap the whole command on drop, not just the shell.
512        //
513        // Dropping a `Command` future detaches its process by default, so a
514        // cancelled agent (or an elapsed timeout, which drops the future the
515        // same way) left its shell running: the run vanished from every listing
516        // while its command carried on writing to the workspace. `kill_on_drop`
517        // fixes the shell - but only the shell. Anything the shell itself
518        // started (`sleep 400 && …`) is a *grandchild*, gets reparented to init,
519        // and keeps running. Putting the shell in its own process group and
520        // signalling the group on drop takes the whole tree down with it.
521        cmd.kill_on_drop(true);
522        own_process_group(&mut cmd);
523        // An agent runs this dozens of times per run, and on Windows each spawn
524        // would otherwise be given a console window. Applied here rather than in
525        // either branch above so it covers the sandboxed command too.
526        hide_console_window(&mut cmd);
527        // `spawn` inherits stdio where `output` pipes it; pipe explicitly so the
528        // command's output is still captured.
529        cmd.stdout(std::process::Stdio::piped())
530            .stderr(std::process::Stdio::piped());
531
532        // Spawn *inside* the timed future so the reaper guard lives exactly as
533        // long as the command does: dropping this future (timeout, or the whole
534        // batch dropped because the agent was cancelled) drops the guard, which
535        // signals the group. Keeping spawn and wait in one fallible block also
536        // keeps a single error arm, as `Command::output()` had.
537        let run = async {
538            let child = cmd.spawn()?;
539            // The child leads its own group, so its pid is the group id.
540            let _reaper = child.id().map(ProcessGroupReaper);
541            child.wait_with_output().await
542        };
543
544        match timeout(timeout_duration, run).await {
545            Err(_) => format!("[timed out] Command exceeded 60s: {}", command),
546            Ok(Err(e)) => format!("[error] Failed to spawn shell '{}': {}", shell, e),
547            Ok(Ok(output)) => Self::format_command_output(
548                &output.stdout,
549                &output.stderr,
550                output.status.success(),
551                output.status.code().unwrap_or(-1),
552            ),
553        }
554    }
555
556    /// Format captured command output. Split out (behavior-preserving) from
557    /// [`Self::shell_with_timeout`] so the success / non-zero-exit
558    /// stdout+stderr formatting arms can be exercised deterministically on
559    /// every platform, independent of the host shell's command-chaining and
560    /// redirection syntax (`cmd.exe` and `sh` differ, so an integration test
561    /// that produces stdout+stderr+non-zero-exit in one command is not
562    /// portable).
563    pub(crate) fn format_command_output(
564        stdout: &[u8],
565        stderr: &[u8],
566        success: bool,
567        exit_code: i32,
568    ) -> String {
569        let stdout = String::from_utf8_lossy(stdout);
570        let stderr = String::from_utf8_lossy(stderr);
571
572        if success {
573            if stdout.trim().is_empty() {
574                "(command succeeded with no output)".to_string()
575            } else {
576                stdout.to_string()
577            }
578        } else {
579            let mut result = format!("[exit code {}]\n", exit_code);
580            if !stdout.trim().is_empty() {
581                result.push_str(&format!("stdout:\n{}\n", stdout));
582            }
583            if !stderr.trim().is_empty() {
584                result.push_str(&format!("stderr:\n{}", stderr));
585            }
586            result
587        }
588    }
589}