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}