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    /// Core of [`resolve`](Self::resolve) with the containment check injected.
77    ///
78    /// A `fn` pointer (not `impl Fn`) so there is one monomorphization, matching
79    /// the seam idiom used elsewhere in the workspace. The seam exists because
80    /// the refusal cannot be reached otherwise on every platform: producing the
81    /// escape needs a real symlink, and creating one on Windows requires a
82    /// privilege CI runners do not have. Injecting the predicate lets the
83    /// refusal itself be tested everywhere, while the `#[cfg(unix)]` tests below
84    /// still prove the real filesystem behaviour end to end.
85    pub(crate) fn resolve_within(
86        requested: &str,
87        workdir: &Path,
88        within: fn(&Path, &Path) -> bool,
89    ) -> anyhow::Result<PathBuf> {
90        let raw = if Path::new(requested).is_absolute() {
91            PathBuf::from(requested)
92        } else {
93            workdir.join(requested)
94        };
95
96        // Normalize by resolving .. and . without requiring the path to exist.
97        let mut normalized = PathBuf::new();
98        for component in raw.components() {
99            match component {
100                Component::ParentDir => {
101                    if !normalized.pop() {
102                        anyhow::bail!("path '{}' escapes the working directory", requested);
103                    }
104                }
105                c => normalized.push(c),
106            }
107        }
108
109        if !normalized.starts_with(workdir) {
110            anyhow::bail!("path '{}' would escape the working directory", requested);
111        }
112
113        if !within(&normalized, workdir) {
114            anyhow::bail!(
115                "path '{requested}' resolves outside the working directory through a symlink"
116            );
117        }
118
119        Ok(normalized)
120    }
121
122    pub(crate) async fn read_file(&self, args: &Value) -> String {
123        let path_str = match args.get("path").and_then(|v| v.as_str()) {
124            Some(p) => p,
125            None => return "[error] missing 'path' argument".to_string(),
126        };
127
128        let path = match self.resolve(path_str) {
129            Ok(p) => p,
130            Err(e) => return format!("[error] {}", e),
131        };
132
133        match std::fs::read_to_string(&path) {
134            Ok(content) => content,
135            Err(e) => format!("[error] Failed to read '{}': {}", path_str, e),
136        }
137    }
138
139    pub(crate) async fn read_files(&self, args: &Value) -> String {
140        let paths = match args.get("paths").and_then(|v| v.as_array()) {
141            Some(arr) => arr,
142            None => return "[error] missing 'paths' argument (expected array)".to_string(),
143        };
144
145        if paths.is_empty() {
146            return "[error] 'paths' array is empty".to_string();
147        }
148
149        let mut results = Vec::with_capacity(paths.len());
150        for path_val in paths {
151            let path_str = match path_val.as_str() {
152                Some(p) => p,
153                None => {
154                    results.push("[error] non-string path in array".to_string());
155                    continue;
156                }
157            };
158
159            let path = match self.resolve(path_str) {
160                Ok(p) => p,
161                Err(e) => {
162                    results.push(format!("### [{}]\n[error] {}", path_str, e));
163                    continue;
164                }
165            };
166
167            match std::fs::read_to_string(&path) {
168                Ok(content) => {
169                    results.push(format!("### [{}]\n{}", path_str, content));
170                }
171                Err(e) => {
172                    results.push(format!("### [{}]\n[error] Failed to read: {}", path_str, e));
173                }
174            }
175        }
176
177        results.join("\n\n")
178    }
179
180    pub(crate) async fn write_file(&self, args: &Value) -> String {
181        let path_str = match args.get("path").and_then(|v| v.as_str()) {
182            Some(p) => p,
183            None => return "[error] missing 'path' argument".to_string(),
184        };
185        let content = match args.get("content").and_then(|v| v.as_str()) {
186            Some(c) => c,
187            None => return "[error] missing 'content' argument".to_string(),
188        };
189        if let Err(e) = self.ensure_workspace() {
190            return e;
191        }
192
193        let path = match self.resolve(path_str) {
194            Ok(p) => p,
195            Err(e) => return format!("[error] {}", e),
196        };
197
198        // Serialize concurrent writes to the same file (fan-out workers).
199        let lock = self.ctx.lock_for(&path);
200        let _guard = lock.lock().await;
201
202        let parent = {
203            let mut p = path.clone();
204            p.pop();
205            p
206        };
207        if let Err(e) = std::fs::create_dir_all(&parent) {
208            return format!(
209                "[error] Failed to create directories for '{}': {}",
210                path_str, e
211            );
212        }
213
214        match std::fs::write(&path, content) {
215            Ok(()) => format!(
216                "Successfully wrote {} bytes to '{}'",
217                content.len(),
218                path_str
219            ),
220            Err(e) => format!("[error] Failed to write '{}': {}", path_str, e),
221        }
222    }
223
224    pub(crate) async fn edit_file(&self, args: &Value) -> String {
225        let path_str = match args.get("path").and_then(|v| v.as_str()) {
226            Some(p) => p,
227            None => return "[error] missing 'path' argument".to_string(),
228        };
229        let old_str = match args.get("old_str").and_then(|v| v.as_str()) {
230            Some(s) => s,
231            None => return "[error] missing 'old_str' argument".to_string(),
232        };
233        let new_str = match args.get("new_str").and_then(|v| v.as_str()) {
234            Some(s) => s,
235            None => return "[error] missing 'new_str' argument".to_string(),
236        };
237        if let Err(e) = self.ensure_workspace() {
238            return e;
239        }
240
241        let path = match self.resolve(path_str) {
242            Ok(p) => p,
243            Err(e) => return format!("[error] {}", e),
244        };
245
246        // Serialize the read-modify-write against concurrent edits/writes to the
247        // same file (fan-out workers), preventing lost updates.
248        let lock = self.ctx.lock_for(&path);
249        let _guard = lock.lock().await;
250
251        let content = match std::fs::read_to_string(&path) {
252            Ok(c) => c,
253            Err(e) => return format!("[error] Failed to read '{}': {}", path_str, e),
254        };
255
256        let count = content.matches(old_str).count();
257        match count {
258            0 => format!(
259                "[error] String not found in '{}'. Ensure old_str matches the file exactly.",
260                path_str
261            ),
262            1 => {
263                let new_content = content.replacen(old_str, new_str, 1);
264                match std::fs::write(&path, &new_content) {
265                    Ok(()) => format!("Successfully edited '{}'", path_str),
266                    Err(e) => format!("[error] Failed to write '{}': {}", path_str, e),
267                }
268            }
269            n => format!(
270                "[error] Found {} occurrences of the string in '{}'. old_str must be unique.",
271                n, path_str
272            ),
273        }
274    }
275
276    pub(crate) async fn list_dir(&self, args: &Value) -> String {
277        let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
278
279        let path = match self.resolve(path_str) {
280            Ok(p) => p,
281            Err(e) => return format!("[error] {}", e),
282        };
283
284        let entries = match std::fs::read_dir(&path) {
285            Ok(e) => e,
286            Err(e) => return format!("[error] Failed to read directory '{}': {}", path_str, e),
287        };
288
289        let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
290        items.sort_by_key(|e| e.file_name());
291
292        let mut lines = Vec::new();
293        for entry in items {
294            let name = entry.file_name().to_string_lossy().to_string();
295            let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
296            if is_dir {
297                lines.push(format!("{}/", name));
298            } else {
299                let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
300                lines.push(format!("{} ({}B)", name, size));
301            }
302        }
303
304        if lines.is_empty() {
305            format!("(empty directory: {})", path_str)
306        } else {
307            lines.join("\n")
308        }
309    }
310
311    /// Detect the best available shell on the system.
312    ///
313    /// Priority:
314    /// - Windows: cmd.exe (always available)
315    /// - Unix: $SHELL env var (user's preferred shell) → bash → zsh → sh
316    pub(crate) fn detect_shell() -> (&'static str, &'static str) {
317        #[cfg(windows)]
318        {
319            ("cmd.exe", "/C")
320        }
321
322        #[cfg(not(windows))]
323        {
324            Self::detect_shell_impl(std::env::var("SHELL").ok(), &|s| {
325                std::path::Path::new(s).exists()
326            })
327        }
328    }
329
330    /// Core shell-detection logic with injectable env and filesystem checks
331    /// for testing.
332    ///
333    /// `shell_exists` is a trait object (`&dyn Fn(&str) -> bool`) rather
334    /// than `impl Fn(&str) -> bool` so every caller - production's real
335    /// `Path::exists` closure and each test's distinct closure - shares
336    /// exactly ONE monomorphization of this function instead of one per
337    /// closure type (this function was a confirmed generic-monomorphization
338    /// coverage-attribution artifact: every source position had a covered
339    /// instantiation, but the summary table still reported some as missed).
340    #[cfg(not(windows))]
341    pub(crate) fn detect_shell_impl(
342        env_shell: Option<String>,
343        shell_exists: &dyn Fn(&str) -> bool,
344    ) -> (&'static str, &'static str) {
345        if let Some(shell) = env_shell
346            && (shell.ends_with("/zsh") || shell.ends_with("/bash") || shell.ends_with("/sh"))
347            && shell_exists(&shell)
348        {
349            // Only trust `$SHELL` when it actually exists - a stale or
350            // sandbox-missing `$SHELL` (e.g. `/bin/zsh` in an environment that
351            // doesn't ship it) otherwise made every shell call fail to spawn.
352            // When it's missing, fall through to the known-path fallback list.
353            let shell: &'static str = Box::leak(shell.into_boxed_str());
354            return (shell, "-c");
355        }
356        for &shell in &[
357            "/bin/bash",
358            "/usr/bin/bash",
359            "/bin/zsh",
360            "/usr/bin/zsh",
361            "/bin/sh",
362        ] {
363            if shell_exists(shell) {
364                return (shell, "-c");
365            }
366        }
367        ("sh", "-c")
368    }
369
370    pub(crate) async fn shell(&self, args: &Value) -> String {
371        self.shell_with_timeout(args, Duration::from_secs(60)).await
372    }
373
374    /// Same as [`Self::shell`], with an injectable timeout so tests can
375    /// exercise the timeout branch without a real 60-second wait.
376    pub(crate) async fn shell_with_timeout(
377        &self,
378        args: &Value,
379        timeout_duration: Duration,
380    ) -> String {
381        let command = match args.get("command").and_then(|v| v.as_str()) {
382            Some(c) => c,
383            None => return "[error] missing 'command' argument".to_string(),
384        };
385
386        let workdir = self.ctx.workdir.clone();
387        let (shell, flag) = Self::detect_shell();
388
389        // When a sandbox executor is attached, it builds a command that runs
390        // inside a container / namespace (still targeting `workdir`); otherwise
391        // run the shell directly on the host - the exact prior behavior.
392        let mut cmd = match &self.shell_executor {
393            Some(executor) => executor.build_command(shell, flag, command, &workdir),
394            None => {
395                let mut c = Command::new(shell);
396                c.arg(flag).arg(command).current_dir(&workdir);
397                c
398            }
399        };
400        // Reap the whole command on drop, not just the shell.
401        //
402        // Dropping a `Command` future detaches its process by default, so a
403        // cancelled agent (or an elapsed timeout, which drops the future the
404        // same way) left its shell running: the run vanished from every listing
405        // while its command carried on writing to the workspace. `kill_on_drop`
406        // fixes the shell - but only the shell. Anything the shell itself
407        // started (`sleep 400 && …`) is a *grandchild*, gets reparented to init,
408        // and keeps running. Putting the shell in its own process group and
409        // signalling the group on drop takes the whole tree down with it.
410        cmd.kill_on_drop(true);
411        own_process_group(&mut cmd);
412        // `spawn` inherits stdio where `output` pipes it; pipe explicitly so the
413        // command's output is still captured.
414        cmd.stdout(std::process::Stdio::piped())
415            .stderr(std::process::Stdio::piped());
416
417        // Spawn *inside* the timed future so the reaper guard lives exactly as
418        // long as the command does: dropping this future (timeout, or the whole
419        // batch dropped because the agent was cancelled) drops the guard, which
420        // signals the group. Keeping spawn and wait in one fallible block also
421        // keeps a single error arm, as `Command::output()` had.
422        let run = async {
423            let child = cmd.spawn()?;
424            // The child leads its own group, so its pid is the group id.
425            let _reaper = child.id().map(ProcessGroupReaper);
426            child.wait_with_output().await
427        };
428
429        match timeout(timeout_duration, run).await {
430            Err(_) => format!("[timed out] Command exceeded 60s: {}", command),
431            Ok(Err(e)) => format!("[error] Failed to spawn shell '{}': {}", shell, e),
432            Ok(Ok(output)) => Self::format_command_output(
433                &output.stdout,
434                &output.stderr,
435                output.status.success(),
436                output.status.code().unwrap_or(-1),
437            ),
438        }
439    }
440
441    /// Format captured command output. Split out (behavior-preserving) from
442    /// [`Self::shell_with_timeout`] so the success / non-zero-exit
443    /// stdout+stderr formatting arms can be exercised deterministically on
444    /// every platform, independent of the host shell's command-chaining and
445    /// redirection syntax (`cmd.exe` and `sh` differ, so an integration test
446    /// that produces stdout+stderr+non-zero-exit in one command is not
447    /// portable).
448    pub(crate) fn format_command_output(
449        stdout: &[u8],
450        stderr: &[u8],
451        success: bool,
452        exit_code: i32,
453    ) -> String {
454        let stdout = String::from_utf8_lossy(stdout);
455        let stderr = String::from_utf8_lossy(stderr);
456
457        if success {
458            if stdout.trim().is_empty() {
459                "(command succeeded with no output)".to_string()
460            } else {
461                stdout.to_string()
462            }
463        } else {
464            let mut result = format!("[exit code {}]\n", exit_code);
465            if !stdout.trim().is_empty() {
466                result.push_str(&format!("stdout:\n{}\n", stdout));
467            }
468            if !stderr.trim().is_empty() {
469                result.push_str(&format!("stderr:\n{}", stderr));
470            }
471            result
472        }
473    }
474}