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 // Like the context tools, this one needs the live world: it writes
26 // an ECS component and a context region. Refused here so the
27 // runtime stays the only path that can record an output.
28 SUBMIT_OUTPUT_TOOL => {
29 "[error] submit_output must be handled by the runtime".to_string()
30 }
31 _ => format!("[error] Unknown built-in tool: {}", name),
32 }
33 }
34
35 /// Refuse to create anything when the working directory itself is gone.
36 ///
37 /// `write_file` calls `create_dir_all`, which would otherwise silently
38 /// resurrect a workspace an external harness deleted mid-run - leaving the
39 /// agent writing into an empty tree that no longer resembles the checkout it
40 /// reasoned about, and masking the loss from the runtime's health check.
41 /// Creating *sub*directories inside a live workspace is untouched; only a
42 /// missing workspace root is refused.
43 pub(crate) fn ensure_workspace(&self) -> Result<(), String> {
44 if std::fs::metadata(&self.ctx.workdir).is_ok_and(|m| m.is_dir()) {
45 return Ok(());
46 }
47 Err(format!(
48 "[error] workspace '{}' is no longer accessible",
49 self.ctx.workdir.display()
50 ))
51 }
52
53 /// Resolve a requested path to an absolute path inside the workdir.
54 ///
55 /// Two checks, because either alone is insufficient:
56 ///
57 /// 1. **Lexical.** `..` and `.` are folded out and the result must sit under
58 /// the workdir. Cheap, and it catches the obvious `../../etc/passwd`.
59 /// 2. **Symbolic.** The deepest *existing* ancestor is canonicalized and the
60 /// result re-checked. Without this the containment was purely textual: a
61 /// symlink at `<workdir>/link` pointing at `/` made
62 /// `read_file("link/etc/passwd")` normalize to a path that starts with the
63 /// workdir, pass, and then be followed by `fs::read_to_string`. The same
64 /// hole let `write_file` overwrite `~/.ssh/authorized_keys`.
65 ///
66 /// That mattered most where the containment is load-bearing. Leviath's file
67 /// tools run **on the host over the bind-mounted workdir** even when the
68 /// stage's `shell` is confined to a container - so a symlink the agent
69 /// created inside the container escaped the container through the file
70 /// tools. It also matters for a freshly cloned repository, which is exactly
71 /// what a coding agent operates on and which can carry a checked-in symlink
72 /// pointing anywhere.
73 ///
74 /// The check is not TOCTOU-proof: a symlink planted between this call and the
75 /// subsequent `open` still wins. Closing that needs `openat`/`O_NOFOLLOW`
76 /// throughout, which is a larger change; this stops the planted-symlink case,
77 /// which is the one an agent can actually arrange.
78 pub(crate) fn resolve(&self, requested: &str) -> anyhow::Result<PathBuf> {
79 Self::resolve_within(requested, &self.ctx.workdir, resolves_within)
80 }
81
82 /// Resolve a requested path for a *read-only* tool.
83 ///
84 /// Identical to [`resolve`](Self::resolve) - same two checks, same
85 /// errors - until the workdir refuses. Only then, and only when the
86 /// agent's `[read_paths]` policy is active, the path is checked against
87 /// that policy: canonicalized first (fail closed), then both predicates
88 /// of [`leviath_core::ReadPathPolicy::decide`] must hold - the blueprint
89 /// declared it AND the user's config grants it.
90 ///
91 /// This function is deliberately not called by `write_file`/`edit_file`.
92 /// `[read_paths]` grants reads; the write tools stay on
93 /// [`resolve`](Self::resolve) so an allowlisted directory can be read but
94 /// never written.
95 pub(crate) fn resolve_read(&self, requested: &str) -> anyhow::Result<PathBuf> {
96 match Self::resolve_within(requested, &self.ctx.workdir, resolves_within) {
97 Ok(path) => Ok(path),
98 Err(workdir_err) => {
99 if !self.ctx.read_paths.is_active() {
100 return Err(workdir_err);
101 }
102 Self::resolve_outside(
103 requested,
104 &self.ctx.workdir,
105 &self.ctx.read_paths,
106 leviath_core::canonicalize_for_match,
107 )
108 }
109 }
110 }
111
112 /// The out-of-workdir arm of [`resolve_read`](Self::resolve_read), with
113 /// the canonicalizer injected (`fn` pointer, same seam idiom as
114 /// [`resolve_within`](Self::resolve_within)) so the fail-closed refusal is
115 /// testable on every platform.
116 ///
117 /// The returned path is the *canonicalized* one - the path that was
118 /// actually vetted - so the subsequent `open` operates on what the policy
119 /// approved rather than re-walking any symlinks.
120 pub(crate) fn resolve_outside(
121 requested: &str,
122 workdir: &Path,
123 policy: &leviath_core::ReadPathPolicy,
124 canon: fn(&Path) -> Option<PathBuf>,
125 ) -> anyhow::Result<PathBuf> {
126 // Relative requests resolve against the workdir here too, so a
127 // relative `[read_paths]` entry like "../shared" is reachable by the
128 // matching relative request. Canonicalization below is what decides
129 // containment; the workdir join is just the base.
130 let raw = if Path::new(requested).is_absolute() {
131 PathBuf::from(requested)
132 } else {
133 workdir.join(requested)
134 };
135
136 // Fold `..` lexically; popping past the filesystem root is
137 // unresolvable no matter what any allowlist says. `Path::components`
138 // already drops interior `.`, and `raw` is absolute here (an absolute
139 // request, or a relative one joined onto the canonicalized workdir),
140 // so no `CurDir` survives to this loop - the catch-all mirrors
141 // `resolve_within`.
142 let mut normalized = PathBuf::new();
143 for component in raw.components() {
144 match component {
145 Component::ParentDir => {
146 if !normalized.pop() {
147 anyhow::bail!("path '{requested}' cannot be resolved");
148 }
149 }
150 other => normalized.push(other),
151 }
152 }
153
154 // The policy only ever sees the real, symlink-resolved path. A path
155 // that cannot be verified is refused, never matched.
156 let Some(canonical) = canon(&normalized) else {
157 anyhow::bail!("path '{requested}' cannot be verified against [read_paths]");
158 };
159
160 match policy.decide(&canonical) {
161 leviath_core::ReadPathDecision::Allowed => Ok(canonical),
162 leviath_core::ReadPathDecision::NotDeclared => anyhow::bail!(
163 "path '{requested}' is outside the working directory and not in this \
164 agent's [read_paths]"
165 ),
166 leviath_core::ReadPathDecision::NotGranted => anyhow::bail!(
167 "path '{requested}' matches this agent's [read_paths], but your config \
168 does not grant it; add it under [agent_read_paths.{agent}] (or set \
169 allow_blueprint_read_paths = true under [security]) in your config.toml",
170 agent = policy.agent
171 ),
172 }
173 }
174
175 /// Core of [`resolve`](Self::resolve) with the containment check injected.
176 ///
177 /// A `fn` pointer (not `impl Fn`) so there is one monomorphization, matching
178 /// the seam idiom used elsewhere in the workspace. The seam exists because
179 /// the refusal cannot be reached otherwise on every platform: producing the
180 /// escape needs a real symlink, and creating one on Windows requires a
181 /// privilege CI runners do not have. Injecting the predicate lets the
182 /// refusal itself be tested everywhere, while the `#[cfg(unix)]` tests below
183 /// still prove the real filesystem behaviour end to end.
184 pub(crate) fn resolve_within(
185 requested: &str,
186 workdir: &Path,
187 within: fn(&Path, &Path) -> bool,
188 ) -> anyhow::Result<PathBuf> {
189 if is_null_device(requested) {
190 return Ok(PathBuf::from(requested));
191 }
192 let raw = if Path::new(requested).is_absolute() {
193 PathBuf::from(requested)
194 } else {
195 workdir.join(requested)
196 };
197
198 // Normalize by resolving .. and . without requiring the path to exist.
199 let mut normalized = PathBuf::new();
200 for component in raw.components() {
201 match component {
202 Component::ParentDir => {
203 if !normalized.pop() {
204 anyhow::bail!("path '{}' escapes the working directory", requested);
205 }
206 }
207 c => normalized.push(c),
208 }
209 }
210
211 if !normalized.starts_with(workdir) {
212 // Names the workspace and what to do instead. "Denied" on its own
213 // sends an agent looking for a different way out, and it spends
214 // iterations - which the stage's budget is charged for - finding
215 // that there isn't one (#373).
216 anyhow::bail!(
217 "path '{}' would escape the working directory ({}). Use a path \
218 inside the workspace instead - a relative path resolves \
219 against it.",
220 requested,
221 workdir.display()
222 );
223 }
224
225 if !within(&normalized, workdir) {
226 anyhow::bail!(
227 "path '{requested}' resolves outside the working directory through a symlink"
228 );
229 }
230
231 Ok(normalized)
232 }
233
234 pub(crate) async fn read_file(&self, args: &Value) -> String {
235 let path_str = match args.get("path").and_then(|v| v.as_str()) {
236 Some(p) => p,
237 None => return "[error] missing 'path' argument".to_string(),
238 };
239
240 let path = match self.resolve_read(path_str) {
241 Ok(p) => p,
242 Err(e) => return format!("[error] {}", e),
243 };
244
245 match std::fs::read_to_string(&path) {
246 Ok(content) => cap_file_content(&content, MAX_READ_FILE_BYTES),
247 Err(e) => format!("[error] Failed to read '{}': {}", path_str, e),
248 }
249 }
250
251 pub(crate) async fn read_files(&self, args: &Value) -> String {
252 let paths = match args.get("paths").and_then(|v| v.as_array()) {
253 Some(arr) => arr,
254 None => return "[error] missing 'paths' argument (expected array)".to_string(),
255 };
256
257 if paths.is_empty() {
258 return "[error] 'paths' array is empty".to_string();
259 }
260
261 let mut results = Vec::with_capacity(paths.len());
262 for path_val in paths {
263 let path_str = match path_val.as_str() {
264 Some(p) => p,
265 None => {
266 results.push("[error] non-string path in array".to_string());
267 continue;
268 }
269 };
270
271 let path = match self.resolve_read(path_str) {
272 Ok(p) => p,
273 Err(e) => {
274 results.push(format!("### [{}]\n[error] {}", path_str, e));
275 continue;
276 }
277 };
278
279 match std::fs::read_to_string(&path) {
280 Ok(content) => {
281 results.push(format!("### [{}]\n{}", path_str, content));
282 }
283 Err(e) => {
284 results.push(format!("### [{}]\n[error] Failed to read: {}", path_str, e));
285 }
286 }
287 }
288
289 results.join("\n\n")
290 }
291
292 pub(crate) async fn write_file(&self, args: &Value) -> String {
293 let path_str = match args.get("path").and_then(|v| v.as_str()) {
294 Some(p) => p,
295 None => return "[error] missing 'path' argument".to_string(),
296 };
297 let content = match args.get("content").and_then(|v| v.as_str()) {
298 Some(c) => c,
299 None => return "[error] missing 'content' argument".to_string(),
300 };
301 if let Err(e) = self.ensure_workspace() {
302 return e;
303 }
304
305 let path = match self.resolve(path_str) {
306 Ok(p) => p,
307 Err(e) => return format!("[error] {}", e),
308 };
309
310 // Serialize concurrent writes to the same file (fan-out workers).
311 let lock = self.ctx.lock_for(&path);
312 let _guard = lock.lock().await;
313
314 let parent = {
315 let mut p = path.clone();
316 p.pop();
317 p
318 };
319 if let Err(e) = std::fs::create_dir_all(&parent) {
320 return format!(
321 "[error] Failed to create directories for '{}': {}",
322 path_str, e
323 );
324 }
325
326 match std::fs::write(&path, content) {
327 Ok(()) => format!(
328 "Successfully wrote {} bytes to '{}'",
329 content.len(),
330 path_str
331 ),
332 Err(e) => format!("[error] Failed to write '{}': {}", path_str, e),
333 }
334 }
335
336 pub(crate) async fn edit_file(&self, args: &Value) -> String {
337 let path_str = match args.get("path").and_then(|v| v.as_str()) {
338 Some(p) => p,
339 None => return "[error] missing 'path' argument".to_string(),
340 };
341 let old_str = match args.get("old_str").and_then(|v| v.as_str()) {
342 Some(s) => s,
343 None => return "[error] missing 'old_str' argument".to_string(),
344 };
345 let new_str = match args.get("new_str").and_then(|v| v.as_str()) {
346 Some(s) => s,
347 None => return "[error] missing 'new_str' argument".to_string(),
348 };
349 if let Err(e) = self.ensure_workspace() {
350 return e;
351 }
352
353 let path = match self.resolve(path_str) {
354 Ok(p) => p,
355 Err(e) => return format!("[error] {}", e),
356 };
357
358 // Serialize the read-modify-write against concurrent edits/writes to the
359 // same file (fan-out workers), preventing lost updates.
360 let lock = self.ctx.lock_for(&path);
361 let _guard = lock.lock().await;
362
363 let content = match std::fs::read_to_string(&path) {
364 Ok(c) => c,
365 Err(e) => return format!("[error] Failed to read '{}': {}", path_str, e),
366 };
367
368 let count = content.matches(old_str).count();
369 match count {
370 0 => format!(
371 "[error] String not found in '{}'. Ensure old_str matches the file exactly.",
372 path_str
373 ),
374 1 => {
375 let new_content = content.replacen(old_str, new_str, 1);
376 match std::fs::write(&path, &new_content) {
377 Ok(()) => format!("Successfully edited '{}'", path_str),
378 Err(e) => format!("[error] Failed to write '{}': {}", path_str, e),
379 }
380 }
381 n => format!(
382 "[error] Found {} occurrences of the string in '{}'. old_str must be unique.",
383 n, path_str
384 ),
385 }
386 }
387
388 pub(crate) async fn list_dir(&self, args: &Value) -> String {
389 let path_str = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
390
391 let path = match self.resolve_read(path_str) {
392 Ok(p) => p,
393 Err(e) => return format!("[error] {}", e),
394 };
395
396 let entries = match std::fs::read_dir(&path) {
397 Ok(e) => e,
398 Err(e) => return format!("[error] Failed to read directory '{}': {}", path_str, e),
399 };
400
401 let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
402 items.sort_by_key(|e| e.file_name());
403
404 let mut lines = Vec::new();
405 for entry in items {
406 let name = entry.file_name().to_string_lossy().to_string();
407 let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
408 if is_dir {
409 lines.push(format!("{}/", name));
410 } else {
411 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
412 lines.push(format!("{} ({}B)", name, size));
413 }
414 }
415
416 if lines.is_empty() {
417 format!("(empty directory: {})", path_str)
418 } else {
419 lines.join("\n")
420 }
421 }
422
423 /// Detect the best available shell on the system.
424 ///
425 /// Priority:
426 /// - Windows: cmd.exe (always available)
427 /// - Unix: $SHELL env var (user's preferred shell) → bash → zsh → sh
428 pub(crate) fn detect_shell() -> (&'static str, &'static str) {
429 Self::detect_shell_for(
430 std::env::consts::OS,
431 std::env::var("SHELL").ok(),
432 &Self::shell_path_exists,
433 )
434 }
435
436 /// Whether `path` names something on disk.
437 ///
438 /// A named `fn` rather than the closure this used to be. On Windows
439 /// [`Self::detect_shell_for`] returns before probing anything, so a closure
440 /// written at the call site would be a region the Windows coverage leg
441 /// never executes; a `fn` can be handed to the seam directly by a test that
442 /// runs on every platform.
443 pub(crate) fn shell_path_exists(path: &str) -> bool {
444 std::path::Path::new(path).exists()
445 }
446
447 /// Core shell-detection logic with injectable OS, env, and filesystem
448 /// checks for testing.
449 ///
450 /// `os` is a parameter rather than a `#[cfg(windows)]` branch, following
451 /// `leviath_sys::browser::open_command_for` and
452 /// `leviath_runtime::pipeline::shell_guidance_for`: the Windows answer is
453 /// then reachable under test from any platform instead of only on the
454 /// Windows CI leg. Callers pass `std::env::consts::OS`.
455 ///
456 /// `$SHELL` is deliberately ignored on Windows even though Git for Windows
457 /// sets it - the value names an MSYS path (`/usr/bin/bash`) that
458 /// `CreateProcess` cannot run.
459 ///
460 /// `shell_exists` is a trait object (`&dyn Fn(&str) -> bool`) rather
461 /// than `impl Fn(&str) -> bool` so every caller - production's real
462 /// `Path::exists` probe and each test's distinct closure - shares
463 /// exactly ONE monomorphization of this function instead of one per
464 /// closure type (this function was a confirmed generic-monomorphization
465 /// coverage-attribution artifact: every source position had a covered
466 /// instantiation, but the summary table still reported some as missed).
467 pub(crate) fn detect_shell_for(
468 os: &str,
469 env_shell: Option<String>,
470 shell_exists: &dyn Fn(&str) -> bool,
471 ) -> (&'static str, &'static str) {
472 if os == "windows" {
473 return ("cmd.exe", "/C");
474 }
475 if let Some(shell) = env_shell
476 && (shell.ends_with("/zsh") || shell.ends_with("/bash") || shell.ends_with("/sh"))
477 && shell_exists(&shell)
478 {
479 // Only trust `$SHELL` when it actually exists - a stale or
480 // sandbox-missing `$SHELL` (e.g. `/bin/zsh` in an environment that
481 // doesn't ship it) otherwise made every shell call fail to spawn.
482 // When it's missing, fall through to the known-path fallback list.
483 let shell: &'static str = Box::leak(shell.into_boxed_str());
484 return (shell, "-c");
485 }
486 for &shell in &[
487 "/bin/bash",
488 "/usr/bin/bash",
489 "/bin/zsh",
490 "/usr/bin/zsh",
491 "/bin/sh",
492 ] {
493 if shell_exists(shell) {
494 return (shell, "-c");
495 }
496 }
497 ("sh", "-c")
498 }
499
500 pub(crate) async fn shell(&self, args: &Value) -> String {
501 self.shell_with_timeout(args, Duration::from_secs(60)).await
502 }
503
504 /// Same as [`Self::shell`], with an injectable timeout so tests can
505 /// exercise the timeout branch without a real 60-second wait.
506 pub(crate) async fn shell_with_timeout(
507 &self,
508 args: &Value,
509 timeout_duration: Duration,
510 ) -> String {
511 self.shell_with_limits(args, timeout_duration, MAX_CAPTURE_BYTES)
512 .await
513 }
514
515 /// Same again, with the capture cap injectable too.
516 ///
517 /// The truncation wiring is otherwise only reachable by producing a real
518 /// megabyte of output, which needs a shell one-liner that floods stdout -
519 /// and `cmd.exe` and `sh` have no such line in common. A tiny cap and a
520 /// plain `echo` exercise the same arms on every platform.
521 pub(crate) async fn shell_with_limits(
522 &self,
523 args: &Value,
524 timeout_duration: Duration,
525 cap: usize,
526 ) -> String {
527 let command = match args.get("command").and_then(|v| v.as_str()) {
528 Some(c) => c,
529 None => return "[error] missing 'command' argument".to_string(),
530 };
531
532 let workdir = self.ctx.workdir.clone();
533 let (shell, flag) = Self::detect_shell();
534
535 // When a sandbox executor is attached, it builds a command that runs
536 // inside a container / namespace (still targeting `workdir`); otherwise
537 // run the shell directly on the host - the exact prior behavior.
538 let mut cmd = match &self.shell_executor {
539 Some(executor) => executor.build_command(shell, flag, command, &workdir),
540 None => {
541 let mut c = crate::platform::child_command(shell);
542 c.arg(flag).arg(command).current_dir(&workdir);
543 c
544 }
545 };
546 // Reap the whole command on drop, not just the shell.
547 //
548 // Dropping a `Command` future detaches its process by default, so a
549 // cancelled agent (or an elapsed timeout, which drops the future the
550 // same way) left its shell running: the run vanished from every listing
551 // while its command carried on writing to the workspace. `kill_on_drop`
552 // fixes the shell - but only the shell. Anything the shell itself
553 // started (`sleep 400 && …`) is a *grandchild*, gets reparented to init,
554 // and keeps running. Putting the shell in its own process group and
555 // signalling the group on drop takes the whole tree down with it.
556 cmd.kill_on_drop(true);
557 own_process_group(&mut cmd);
558 // Strip the credentials the daemon holds but this command has no use
559 // for.
560 //
561 // After the branch above rather than inside its host arm, so it also
562 // covers the namespace sandbox (which `unshare`s but still inherits the
563 // environment) and the warn-fallback that quietly runs on the host when
564 // namespaces turn out to be unusable - the arm most likely to be
565 // forgotten. A container exec is built with no `-e` flags and so never
566 // inherited the daemon's environment to begin with, which makes this a
567 // no-op there rather than a special case.
568 self.ctx.shell_env.apply(&mut cmd);
569 // An agent runs this dozens of times per run, and on Windows each spawn
570 // would otherwise be given a console window. Applied here rather than in
571 // either branch above so it covers the sandboxed command too.
572 // `spawn` inherits stdio where `output` pipes it; pipe explicitly so the
573 // command's output is still captured.
574 cmd.stdout(std::process::Stdio::piped())
575 .stderr(std::process::Stdio::piped());
576
577 // Spawn *inside* the timed future so the reaper guard lives exactly as
578 // long as the command does: dropping this future (timeout, or the whole
579 // batch dropped because the agent was cancelled) drops the guard, which
580 // signals the group. Keeping spawn and wait in one fallible block also
581 // keeps a single error arm, as `Command::output()` had.
582 let run = async {
583 let mut child = cmd.spawn()?;
584 // The child leads its own group, so its pid is the group id.
585 let _reaper = child.id().map(ProcessGroupReaper);
586 // Taken before the join so both pipes are drained concurrently with
587 // each other and with the wait. `piped()` above guarantees both.
588 let mut out = child.stdout.take().expect("stdout was piped");
589 let mut err = child.stderr.take().expect("stderr was piped");
590 let (stdout, stderr, status) = tokio::join!(
591 capture_capped(&mut out, cap),
592 capture_capped(&mut err, cap),
593 child.wait(),
594 );
595 // The exit status is the only fallible part worth failing on, so it
596 // stays the single error edge this block has - the same shape
597 // `wait_with_output()` presented. A pipe that errors mid-read is
598 // handled inside `capture_capped` as an early end of output.
599 status.map(|status| (stdout, stderr, status))
600 };
601
602 match timeout(timeout_duration, run).await {
603 Err(_) => format!("[timed out] Command exceeded 60s: {}", command),
604 Ok(Err(e)) => format!("[error] Failed to spawn shell '{}': {}", shell, e),
605 Ok(Ok((stdout, stderr, status))) => {
606 let body = Self::format_command_output(
607 &stdout.kept,
608 &stderr.kept,
609 status.success(),
610 status.code().unwrap_or(-1),
611 );
612 match capture_note(&stdout, &stderr, cap) {
613 Some(note) => format!("{body}\n\n{note}"),
614 None => body,
615 }
616 }
617 }
618 }
619
620 /// Format captured command output. Split out (behavior-preserving) from
621 /// [`Self::shell_with_timeout`] so the success / non-zero-exit
622 /// stdout+stderr formatting arms can be exercised deterministically on
623 /// every platform, independent of the host shell's command-chaining and
624 /// redirection syntax (`cmd.exe` and `sh` differ, so an integration test
625 /// that produces stdout+stderr+non-zero-exit in one command is not
626 /// portable).
627 pub(crate) fn format_command_output(
628 stdout: &[u8],
629 stderr: &[u8],
630 success: bool,
631 exit_code: i32,
632 ) -> String {
633 let stdout = String::from_utf8_lossy(stdout);
634 let stderr = String::from_utf8_lossy(stderr);
635
636 if success {
637 if stdout.trim().is_empty() {
638 "(command succeeded with no output)".to_string()
639 } else {
640 stdout.to_string()
641 }
642 } else {
643 let mut result = format!("[exit code {}]\n", exit_code);
644 if !stdout.trim().is_empty() {
645 result.push_str(&format!("stdout:\n{}\n", stdout));
646 }
647 if !stderr.trim().is_empty() {
648 result.push_str(&format!("stderr:\n{}", stderr));
649 }
650 result
651 }
652 }
653}
654
655/// Largest slice of one stream (stdout or stderr) a single shell call keeps.
656///
657/// Issue #252: `wait_with_output()` buffered a child's entire output in the
658/// daemon's memory with nothing to stop it, so a command that printed for its
659/// full 60-second budget was an accidental memory exhaustion - and on a fast
660/// local pipe that is gigabytes.
661///
662/// Sized just above `MAX_SCRIPT_IO_BYTES` (900 KB in `daemon::script_host`),
663/// which caps the same text when it reaches a Rhai tool script, so this one is
664/// the outer bound and that one stays the tighter of the two. A megabyte of
665/// shell output already overruns any region budget an agent has; keeping more
666/// of it helps nobody downstream.
667pub(crate) const MAX_CAPTURE_BYTES: usize = 1024 * 1024;
668
669/// Is `path` the platform's discard device?
670///
671/// The null device is not a location in the filesystem, so a workspace check has
672/// nothing to say about it: writing there writes nowhere, and reading there
673/// reads nothing. Refusing it produced `path '/dev/null' would escape the
674/// working directory`, which is both wrong and, worse, unactionable - an agent
675/// told that spends turns guessing at a path it cannot fix (#373).
676///
677/// Deliberately *only* the null device, not `/dev/stdout` or `/dev/stderr`.
678/// Those are the daemon's own streams once a tool opens them by name, and a
679/// tool writing into them would land in the middle of whatever the CLI is
680/// drawing. A shell *redirect* to them stays allowed, because that redirects
681/// the child's streams rather than opening the daemon's - a different thing
682/// that happens to be spelled the same way.
683pub fn is_null_device(path: &str) -> bool {
684 // `NUL` is the Windows spelling, matched on every platform for the same
685 // reason the redirect classifier does: a command should not depend on who
686 // ran it. On Unix the name resolves to an ordinary file in the workdir, and
687 // treating it as a sink writes nothing rather than creating litter.
688 path == "/dev/null" || path.eq_ignore_ascii_case("nul")
689}
690
691/// Most of a file `read_file` returns.
692///
693/// `shell` has been capped since it existed; `read_file` had no bound at all,
694/// so a large file went whole into the routed region and the ladder in
695/// `tool_results` either truncated it or dropped it as `[result omitted]` -
696/// which of the two you got depended on how full the region already was. That
697/// is an all-or-nothing cliff rather than a limit.
698///
699/// Sized below [`MAX_CAPTURE_BYTES`] on purpose: shell output is usually a
700/// filtered answer, while a file read is raw material and a 256 KiB file is
701/// already far past any region budget an agent has. A stage that genuinely
702/// wants more sets `max_result_tokens` for the tool.
703pub(crate) const MAX_READ_FILE_BYTES: usize = 256 * 1024;
704
705/// `content` truncated to `cap` bytes, with a line saying so when it was.
706///
707/// Said rather than silently dropped, for the reason [`capture_note`] gives: an
708/// agent reading a truncated file as the whole file draws a wrong conclusion
709/// from it, and the conclusion is worse than the gap.
710pub(crate) fn cap_file_content(content: &str, cap: usize) -> String {
711 if content.len() <= cap {
712 return content.to_string();
713 }
714 // On a char boundary, or the result is not a `String` at all.
715 let kept = leviath_core::text::substring(content, 0, cap);
716 format!(
717 "{kept}\n[truncated] The file is {} bytes; the first {} are shown. Read a range, or \
718 narrow with a search, rather than re-reading the whole file.",
719 content.len(),
720 kept.len(),
721 )
722}
723
724/// What one stream produced, and how much of it was kept.
725#[derive(Debug)]
726pub(crate) struct Captured {
727 pub(crate) kept: Vec<u8>,
728 /// Everything the child wrote, including what was discarded.
729 pub(crate) total: u64,
730}
731
732/// Read `stream` to EOF, keeping at most `cap` bytes.
733///
734/// **Keeps reading after the cap is reached** rather than stopping, and that is
735/// the whole design. A reader that walks away leaves the child blocked on a
736/// full pipe, so a command producing more than the cap would stop making
737/// progress and die at the timeout instead of finishing - turning a truncated
738/// result into a failed one. Past the cap the bytes are counted and dropped.
739///
740/// A read error ends the capture rather than failing the call. It means no more
741/// output is coming, which is what EOF means too, and the exit status still
742/// describes what the command did - so reporting "failed to spawn shell" for a
743/// command that ran to completion would be a lie.
744///
745/// `&mut dyn` rather than a generic: a generic here gets one instrumented
746/// monomorphization per call site, and `cargo llvm-cov` reports the ones the
747/// tests do not reach as uncovered.
748pub(crate) async fn capture_capped(
749 stream: &mut (dyn tokio::io::AsyncRead + Unpin + Send),
750 cap: usize,
751) -> Captured {
752 use tokio::io::AsyncReadExt;
753
754 let mut kept: Vec<u8> = Vec::new();
755 let mut total: u64 = 0;
756 let mut buf = [0u8; 8192];
757 loop {
758 let n = match stream.read(&mut buf).await {
759 Ok(0) | Err(_) => return Captured { kept, total },
760 Ok(n) => n,
761 };
762 total += n as u64;
763 if kept.len() < cap {
764 let room = cap - kept.len();
765 kept.extend_from_slice(&buf[..n.min(room)]);
766 }
767 }
768}
769
770/// A line telling the agent its command outproduced the capture cap, or `None`
771/// when everything it wrote is present.
772///
773/// Said rather than silently dropped: an agent that reads a truncated listing
774/// as the whole listing draws a wrong conclusion from it, which is worse than
775/// knowing the answer is incomplete.
776pub(crate) fn capture_note(stdout: &Captured, stderr: &Captured, cap: usize) -> Option<String> {
777 let lost = |c: &Captured| c.total > c.kept.len() as u64;
778 let which = match (lost(stdout), lost(stderr)) {
779 (false, false) => return None,
780 (true, false) => "stdout",
781 (false, true) => "stderr",
782 (true, true) => "stdout and stderr",
783 };
784 let total = stdout.total + stderr.total;
785 Some(format!(
786 "[truncated] The command wrote {total} bytes; {which} exceeded the {cap}-byte capture \
787 limit and only the beginning is shown. Narrow the command (a filter, a line count, a \
788 smaller range) rather than re-running it."
789 ))
790}