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        #[cfg(windows)]
411        {
412            ("cmd.exe", "/C")
413        }
414
415        #[cfg(not(windows))]
416        {
417            Self::detect_shell_impl(std::env::var("SHELL").ok(), &|s| {
418                std::path::Path::new(s).exists()
419            })
420        }
421    }
422
423    /// Core shell-detection logic with injectable env and filesystem checks
424    /// for testing.
425    ///
426    /// `shell_exists` is a trait object (`&dyn Fn(&str) -> bool`) rather
427    /// than `impl Fn(&str) -> bool` so every caller - production's real
428    /// `Path::exists` closure and each test's distinct closure - shares
429    /// exactly ONE monomorphization of this function instead of one per
430    /// closure type (this function was a confirmed generic-monomorphization
431    /// coverage-attribution artifact: every source position had a covered
432    /// instantiation, but the summary table still reported some as missed).
433    #[cfg(not(windows))]
434    pub(crate) fn detect_shell_impl(
435        env_shell: Option<String>,
436        shell_exists: &dyn Fn(&str) -> bool,
437    ) -> (&'static str, &'static str) {
438        if let Some(shell) = env_shell
439            && (shell.ends_with("/zsh") || shell.ends_with("/bash") || shell.ends_with("/sh"))
440            && shell_exists(&shell)
441        {
442            // Only trust `$SHELL` when it actually exists - a stale or
443            // sandbox-missing `$SHELL` (e.g. `/bin/zsh` in an environment that
444            // doesn't ship it) otherwise made every shell call fail to spawn.
445            // When it's missing, fall through to the known-path fallback list.
446            let shell: &'static str = Box::leak(shell.into_boxed_str());
447            return (shell, "-c");
448        }
449        for &shell in &[
450            "/bin/bash",
451            "/usr/bin/bash",
452            "/bin/zsh",
453            "/usr/bin/zsh",
454            "/bin/sh",
455        ] {
456            if shell_exists(shell) {
457                return (shell, "-c");
458            }
459        }
460        ("sh", "-c")
461    }
462
463    pub(crate) async fn shell(&self, args: &Value) -> String {
464        self.shell_with_timeout(args, Duration::from_secs(60)).await
465    }
466
467    /// Same as [`Self::shell`], with an injectable timeout so tests can
468    /// exercise the timeout branch without a real 60-second wait.
469    pub(crate) async fn shell_with_timeout(
470        &self,
471        args: &Value,
472        timeout_duration: Duration,
473    ) -> String {
474        let command = match args.get("command").and_then(|v| v.as_str()) {
475            Some(c) => c,
476            None => return "[error] missing 'command' argument".to_string(),
477        };
478
479        let workdir = self.ctx.workdir.clone();
480        let (shell, flag) = Self::detect_shell();
481
482        // When a sandbox executor is attached, it builds a command that runs
483        // inside a container / namespace (still targeting `workdir`); otherwise
484        // run the shell directly on the host - the exact prior behavior.
485        let mut cmd = match &self.shell_executor {
486            Some(executor) => executor.build_command(shell, flag, command, &workdir),
487            None => {
488                let mut c = Command::new(shell);
489                c.arg(flag).arg(command).current_dir(&workdir);
490                c
491            }
492        };
493        // Reap the whole command on drop, not just the shell.
494        //
495        // Dropping a `Command` future detaches its process by default, so a
496        // cancelled agent (or an elapsed timeout, which drops the future the
497        // same way) left its shell running: the run vanished from every listing
498        // while its command carried on writing to the workspace. `kill_on_drop`
499        // fixes the shell - but only the shell. Anything the shell itself
500        // started (`sleep 400 && …`) is a *grandchild*, gets reparented to init,
501        // and keeps running. Putting the shell in its own process group and
502        // signalling the group on drop takes the whole tree down with it.
503        cmd.kill_on_drop(true);
504        own_process_group(&mut cmd);
505        // `spawn` inherits stdio where `output` pipes it; pipe explicitly so the
506        // command's output is still captured.
507        cmd.stdout(std::process::Stdio::piped())
508            .stderr(std::process::Stdio::piped());
509
510        // Spawn *inside* the timed future so the reaper guard lives exactly as
511        // long as the command does: dropping this future (timeout, or the whole
512        // batch dropped because the agent was cancelled) drops the guard, which
513        // signals the group. Keeping spawn and wait in one fallible block also
514        // keeps a single error arm, as `Command::output()` had.
515        let run = async {
516            let child = cmd.spawn()?;
517            // The child leads its own group, so its pid is the group id.
518            let _reaper = child.id().map(ProcessGroupReaper);
519            child.wait_with_output().await
520        };
521
522        match timeout(timeout_duration, run).await {
523            Err(_) => format!("[timed out] Command exceeded 60s: {}", command),
524            Ok(Err(e)) => format!("[error] Failed to spawn shell '{}': {}", shell, e),
525            Ok(Ok(output)) => Self::format_command_output(
526                &output.stdout,
527                &output.stderr,
528                output.status.success(),
529                output.status.code().unwrap_or(-1),
530            ),
531        }
532    }
533
534    /// Format captured command output. Split out (behavior-preserving) from
535    /// [`Self::shell_with_timeout`] so the success / non-zero-exit
536    /// stdout+stderr formatting arms can be exercised deterministically on
537    /// every platform, independent of the host shell's command-chaining and
538    /// redirection syntax (`cmd.exe` and `sh` differ, so an integration test
539    /// that produces stdout+stderr+non-zero-exit in one command is not
540    /// portable).
541    pub(crate) fn format_command_output(
542        stdout: &[u8],
543        stderr: &[u8],
544        success: bool,
545        exit_code: i32,
546    ) -> String {
547        let stdout = String::from_utf8_lossy(stdout);
548        let stderr = String::from_utf8_lossy(stderr);
549
550        if success {
551            if stdout.trim().is_empty() {
552                "(command succeeded with no output)".to_string()
553            } else {
554                stdout.to_string()
555            }
556        } else {
557            let mut result = format!("[exit code {}]\n", exit_code);
558            if !stdout.trim().is_empty() {
559                result.push_str(&format!("stdout:\n{}\n", stdout));
560            }
561            if !stderr.trim().is_empty() {
562                result.push_str(&format!("stderr:\n{}", stderr));
563            }
564            result
565        }
566    }
567}