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            // 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}