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}