Skip to main content

leviath_cli/daemon/
script_host.rs

1//! The daemon's real [`ScriptHost`] for Rhai script tools (permission Layer 3).
2//!
3//! A registered script tool reaches the outside world only through the host
4//! functions on [`leviath_scripting::ScriptHost`]. This module supplies the real
5//! implementation: it enforces the per-function `[tool_script_permissions]`
6//! (allow / deny / inherit) resolved at agent spawn, confines `read_file` /
7//! `write_file` to the agent workdir, routes `shell()` through the agent's
8//! per-stage sandbox with a wall-clock timeout, and performs the actual I/O.
9//!
10//! The I/O itself lives behind the [`ScriptIo`] seam so the permission and
11//! path-confinement logic is unit-testable with a fake, and the real
12//! network/process/filesystem/env behavior ([`RealScriptIo`]) is exercised with
13//! hermetic, local resources (a mock HTTP server, `echo`, temp files, scoped env
14//! vars) - the same approach the MCP and package-registry tests use.
15
16use std::collections::BTreeMap;
17use std::path::{Component, Path, PathBuf};
18use std::sync::Arc;
19use std::time::Duration;
20
21use leviath_core::floor_char_boundary;
22use leviath_scripting::ScriptHost;
23use leviath_tools::ShellExecutor;
24use tokio::process::Command as TokioCommand;
25
26use crate::config::{ScriptPermission, ScriptToolPermissions, ToolPolicy};
27use crate::daemon::sandbox_manager::SandboxManager;
28
29/// The resolved allow/deny decision for each of the five side-effecting host
30/// functions, computed once at spawn from the config's `[tool_script_permissions]`
31/// and the agent's own tool permissions (for the `inherit` cases).
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct ScriptAllow {
34    /// Whether `http_get` may run.
35    pub http_get: bool,
36    /// Whether `http_post` may run.
37    pub http_post: bool,
38    /// Whether `shell` may run.
39    pub shell: bool,
40    /// Whether `read_file` may run.
41    pub read_file: bool,
42    /// Whether `write_file` may run.
43    pub write_file: bool,
44    /// Whether `env_var` may run.
45    pub env_var: bool,
46}
47
48/// Resolve `[tool_script_permissions]` into concrete allow/deny booleans.
49///
50/// `Allow`/`Deny` map directly. `Inherit` means:
51/// - `read_file` / `write_file` / `shell`: permitted only when the agent's resolved policy for
52///   the equivalent built-in (`resolve_builtin`) is [`ToolPolicy::Allow`]. This
53///   is evaluated once against the entry stage's permission layers; a later
54///   stage's `tool_permissions` do not re-gate a script's host calls.
55/// - `http_get` / `http_post` / `env_var`: permitted (no built-in equivalent to
56///   inherit from, and the tool itself is still gated by Layers 1/2/4).
57///
58/// `resolve_builtin` is a `&dyn Fn` (not `impl Fn`) so this function has a single
59/// monomorphization; otherwise each distinct caller closure type gets its own
60/// copy of the `net`/`filelike` match arms, and coverage is attributed
61/// per-instantiation (each only exercises the arms that caller hits).
62pub fn resolve_script_permissions(
63    perms: &ScriptToolPermissions,
64    resolve_builtin: &dyn Fn(&str) -> ToolPolicy,
65) -> ScriptAllow {
66    let net = |p: ScriptPermission| match p {
67        ScriptPermission::Allow | ScriptPermission::Inherit => true,
68        ScriptPermission::Deny => false,
69    };
70    let filelike = |p: ScriptPermission, builtin: &str| match p {
71        ScriptPermission::Allow => true,
72        ScriptPermission::Deny => false,
73        ScriptPermission::Inherit => resolve_builtin(builtin) == ToolPolicy::Allow,
74    };
75    ScriptAllow {
76        http_get: net(perms.http_get),
77        http_post: net(perms.http_post),
78        env_var: net(perms.env_var),
79        read_file: filelike(perms.read_file, "read_file"),
80        write_file: filelike(perms.write_file, "write_file"),
81        shell: filelike(perms.shell, "shell"),
82    }
83}
84
85/// Map a `[tool_script_permissions]` string to a [`ScriptPermission`]. An
86/// unrecognized value yields `None` (the field is left at the global default) -
87/// parsed by hand (not via `Deserialize`) so every arm is deterministically
88/// covered, without pulling in serde's unexercised visitor machinery.
89fn parse_script_permission_str(s: &str) -> Option<ScriptPermission> {
90    match s {
91        "allow" => Some(ScriptPermission::Allow),
92        "deny" => Some(ScriptPermission::Deny),
93        "inherit" => Some(ScriptPermission::Inherit),
94        _ => None,
95    }
96}
97
98/// How restrictive a script permission is, for clamping.
99///
100/// `Allow` (unconditional) is the loosest; `Inherit` still requires the agent's
101/// own policy for the equivalent built-in to permit the call; `Deny` is the
102/// tightest.
103fn script_restrictiveness(p: ScriptPermission) -> u8 {
104    match p {
105        ScriptPermission::Allow => 0,
106        ScriptPermission::Inherit => 1,
107        ScriptPermission::Deny => 2,
108    }
109}
110
111/// The effective `[tool_script_permissions]` for an agent: the user's global
112/// config with the agent's own blueprint `[tool_script_permissions]` overlaid
113/// per field - but **only where the manifest is more restrictive**.
114///
115/// Agents ship their own `.rhai` tool scripts, so it is reasonable for a
116/// manifest to say "this agent never needs `shell`". It is not reasonable for it
117/// to say the opposite: a manifest that could set `shell = "allow"` over a user's
118/// global `deny` meant installing an agent was enough to overrule the machine's
119/// configuration. So a manifest may tighten a field and never loosen it, the same
120/// rule [`crate::tools::resolve_policy`] applies to `[tool_permissions]`.
121///
122/// Parsed CLI-side (these types live in the CLI config, not `leviath-core`),
123/// mirroring `parse_blueprint_mcp_servers`.
124pub fn effective_script_permissions(
125    global: &ScriptToolPermissions,
126    manifest_toml: &str,
127) -> ScriptToolPermissions {
128    let mut eff = global.clone();
129    // `toml::from_str`, not `manifest_toml.parse::<toml::Value>()`. In toml 1.x
130    // `FromStr for Value` parses a single *value*, not a document - so a real
131    // manifest starting with `[agent]` reads as an array literal followed by
132    // junk and fails. It still compiles, so the change is silent; the tests are
133    // what caught it.
134    let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
135        return eff;
136    };
137    let Some(table) = value
138        .get("tool_script_permissions")
139        .and_then(|v| v.as_table())
140    else {
141        return eff;
142    };
143    // For each key the agent set to a recognized value, keep whichever of the
144    // two is stricter.
145    let apply = |key: &str, slot: &mut ScriptPermission| {
146        if let Some(p) = table
147            .get(key)
148            .and_then(|v| v.as_str())
149            .and_then(parse_script_permission_str)
150            && script_restrictiveness(p) > script_restrictiveness(*slot)
151        {
152            *slot = p;
153        }
154    };
155    apply("http_get", &mut eff.http_get);
156    apply("http_post", &mut eff.http_post);
157    apply("shell", &mut eff.shell);
158    apply("read_file", &mut eff.read_file);
159    apply("write_file", &mut eff.write_file);
160    apply("env_var", &mut eff.env_var);
161    eff
162}
163
164/// The raw I/O a [`DaemonScriptHost`] performs, behind a seam so the host's
165/// permission/confinement logic is testable without real side effects.
166pub trait ScriptIo: Send + Sync {
167    /// Perform an HTTP GET, returning the response body (or an error message).
168    fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String>;
169    /// Perform an HTTP POST, returning the response body (or an error message).
170    fn http_post(
171        &self,
172        url: &str,
173        body: &str,
174        headers: BTreeMap<String, String>,
175    ) -> Result<String, String>;
176    /// Run a prepared shell command (already sandbox-wrapped and pointed at the
177    /// workdir by the host), enforcing `timeout`, and return its combined output.
178    fn run_shell(&self, cmd: TokioCommand, timeout: Duration) -> Result<String, String>;
179    /// Read the file at an already-confined absolute `path`.
180    fn read_file(&self, path: &Path) -> Result<String, String>;
181    /// Write `content` to an already-confined absolute `path`, creating parent
182    /// directories as needed. Returns a short confirmation.
183    fn write_file(&self, path: &Path, content: &str) -> Result<String, String>;
184    /// Read environment variable `name`.
185    fn env_var(&self, name: &str) -> Result<String, String>;
186}
187
188/// The daemon's script host: enforces permissions + workdir confinement, then
189/// delegates the actual work to a [`ScriptIo`].
190pub struct DaemonScriptHost {
191    allow: ScriptAllow,
192    workdir: PathBuf,
193    io: Arc<dyn ScriptIo>,
194    /// The agent's sandbox manager, if any. When present, a script `shell()`
195    /// call runs inside the *current* stage's sandbox (container / namespace),
196    /// exactly like the built-in `shell` tool - a script can't escape the
197    /// isolation the agent's stage declared. `None` runs on the host.
198    sandbox: Option<Arc<SandboxManager>>,
199    /// Wall-clock cap on a single `shell()` call, so a runaway command can't hang
200    /// the agent (mirrors the built-in shell tool's timeout).
201    shell_timeout: Duration,
202    /// `[security] allow_local_network`: whether this agent's fetches may reach
203    /// loopback / private / link-local addresses. Off unless the user turned it
204    /// on - see [`check_outbound`].
205    allow_local_network: bool,
206    /// `[security] allow_env_vars`: credential-shaped environment variables this
207    /// agent's scripts may read. Empty by default.
208    allow_env_vars: Vec<String>,
209    /// `[security] shell_env`: which of the daemon's variables a script's
210    /// `shell()` hands to the child. The same policy the built-in shell tool
211    /// applies, so `shell()` is not a way around the `env_var` gate.
212    shell_env: leviath_tools::ShellEnvPolicy,
213}
214
215impl DaemonScriptHost {
216    /// Build a host with an explicit I/O backend (used by tests). Defaults to no
217    /// sandbox and the built-in shell tool's 60-second timeout; override with
218    /// [`with_shell`](Self::with_shell).
219    pub fn with_io(allow: ScriptAllow, workdir: PathBuf, io: Arc<dyn ScriptIo>) -> Self {
220        Self {
221            allow,
222            workdir,
223            io,
224            sandbox: None,
225            shell_timeout: Duration::from_secs(60),
226            allow_local_network: false,
227            allow_env_vars: Vec::new(),
228            shell_env: leviath_tools::ShellEnvPolicy::default(),
229        }
230    }
231
232    /// Permit fetches to loopback / private / link-local addresses, from
233    /// `[security] allow_local_network`. Consuming builder used at spawn.
234    pub fn with_local_network(mut self, allow: bool) -> Self {
235        self.allow_local_network = allow;
236        self
237    }
238
239    /// Permit scripts to read these credential-shaped environment variables,
240    /// from `[security] allow_env_vars`. Consuming builder used at spawn.
241    pub fn with_env_allowlist(mut self, names: Vec<String>) -> Self {
242        self.allow_env_vars = names;
243        self
244    }
245
246    /// Build a host wired to the real network/process/filesystem/env backend.
247    pub fn new(allow: ScriptAllow, workdir: PathBuf) -> Self {
248        Self::with_io(allow, workdir, Arc::new(RealScriptIo))
249    }
250
251    /// Route `shell()` through `sandbox` (the agent's per-stage isolation) and cap
252    /// each call at `shell_timeout`. Consuming builder used at spawn.
253    pub fn with_shell(
254        mut self,
255        sandbox: Option<Arc<SandboxManager>>,
256        shell_timeout: Duration,
257        shell_env: leviath_tools::ShellEnvPolicy,
258    ) -> Self {
259        self.sandbox = sandbox;
260        self.shell_timeout = shell_timeout;
261        self.shell_env = shell_env;
262        self
263    }
264
265    /// Resolve a script-supplied file path against the workdir, rejecting both a
266    /// `..` escape and a symlink that leaves the directory (mirrors
267    /// `BuiltinTools::resolve`, which documents the reasoning).
268    fn resolve_in_workdir(&self, requested: &str) -> Result<PathBuf, String> {
269        Self::resolve_in(requested, &self.workdir, leviath_core::resolves_within)
270    }
271
272    /// Core of [`resolve_in_workdir`](Self::resolve_in_workdir) with the
273    /// containment check injected.
274    ///
275    /// A `fn` pointer (not `impl Fn`) so there is one monomorphization, matching
276    /// the seam idiom used for the browser opener and the socket peer lookup.
277    /// The seam exists because the refusal cannot be reached otherwise on every
278    /// platform: producing the escape needs a real symlink, and creating one on
279    /// Windows requires a privilege CI runners do not have. The `#[cfg(unix)]`
280    /// test still proves the real filesystem behaviour end to end.
281    fn resolve_in(
282        requested: &str,
283        workdir: &Path,
284        within: fn(&Path, &Path) -> bool,
285    ) -> Result<PathBuf, String> {
286        // The null device is not a place, so containment has nothing to say
287        // about it - same reasoning as the built-in tools, which share the
288        // predicate rather than each carrying their own idea of it (#373).
289        if leviath_tools::is_null_device(requested) {
290            return Ok(PathBuf::from(requested));
291        }
292        let raw = if Path::new(requested).is_absolute() {
293            PathBuf::from(requested)
294        } else {
295            workdir.join(requested)
296        };
297        let mut normalized = PathBuf::new();
298        for component in raw.components() {
299            match component {
300                Component::ParentDir => {
301                    if !normalized.pop() {
302                        return Err(format!("path '{requested}' escapes the working directory"));
303                    }
304                }
305                c => normalized.push(c),
306            }
307        }
308        if !normalized.starts_with(workdir) {
309            return Err(format!(
310                "path '{requested}' would escape the working directory ({}). \
311                 Use a path inside the workspace instead - a relative path \
312                 resolves against it.",
313                workdir.display()
314            ));
315        }
316        // The lexical check above is textual only: a symlink inside the workdir
317        // pointing outside it satisfies `starts_with` while reading anywhere.
318        if !within(&normalized, workdir) {
319            return Err(format!(
320                "path '{requested}' resolves outside the working directory through a symlink"
321            ));
322        }
323        Ok(normalized)
324    }
325}
326
327/// The standard `[denied]` message for a host function blocked by
328/// `[tool_script_permissions]`.
329fn denied(func: &str) -> String {
330    format!("[denied] script host function '{func}' is denied by tool_script_permissions")
331}
332
333/// Check a script-supplied URL against the outbound policy before it is sent.
334///
335/// The URL came from the model, and the model picked it out of context an
336/// attacker can influence - so this is the boundary between "the agent browsing
337/// the web" and "the agent probing the user's own network on someone else's
338/// behalf". See [`leviath_net`] for what is refused and why.
339///
340/// Lives on the host (the permission/confinement layer) rather than in
341/// [`RealScriptIo`], so a test double is subject to the same rule as the real
342/// backend and the check cannot be skipped by swapping the I/O out.
343fn check_outbound(url: &str, allow_local: bool) -> Result<(), String> {
344    let parsed = url::Url::parse(url).map_err(|e| format!("[denied] invalid URL '{url}': {e}"))?;
345    leviath_net::check_url(&parsed, allow_local).map_err(|e| format!("[denied] {e}"))
346}
347
348impl ScriptHost for DaemonScriptHost {
349    fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String> {
350        if !self.allow.http_get {
351            return Err(denied("http_get"));
352        }
353        check_outbound(url, self.allow_local_network)?;
354        self.io.http_get(url, headers)
355    }
356
357    fn http_post(
358        &self,
359        url: &str,
360        body: &str,
361        headers: BTreeMap<String, String>,
362    ) -> Result<String, String> {
363        if !self.allow.http_post {
364            return Err(denied("http_post"));
365        }
366        check_outbound(url, self.allow_local_network)?;
367        self.io.http_post(url, body, headers)
368    }
369
370    fn shell(&self, command: &str) -> Result<String, String> {
371        if !self.allow.shell {
372            return Err(denied("shell"));
373        }
374        // The same clamp `clamp_by_effect` applies to a model's `shell` tool
375        // call. Without it this is the hole that clamp exists to close, just
376        // reached from a script instead of a tool call: an agent shipping its
377        // own `.rhai` tools could write through a redirect while `write_file`
378        // was denied. Resolved at spawn like the rest of `allow`, so this is a
379        // boolean check rather than a second policy lookup.
380        if !self.allow.write_file && crate::shell_keys::writes_a_file(command) {
381            return Err(denied("write_file (a shell redirect writes a file)"));
382        }
383        // And the containment half, which no `allow` lifts: this host's own
384        // `write_file` is workdir-confined, so its `shell()` redirects are too.
385        if let Some(refusal) = crate::tools::escaping_write_refusal(
386            "shell",
387            &serde_json::json!({ "command": command }),
388            &self.workdir,
389        ) {
390            return Err(refusal);
391        }
392        let (shell, flag) = default_shell();
393        // With a sandbox, build the command that runs inside the current stage's
394        // container / namespace; otherwise run the shell directly on the host
395        // (both target the agent workdir). Same routing as the built-in shell tool.
396        let mut cmd = match &self.sandbox {
397            Some(sb) => sb.build_command(shell, flag, command, &self.workdir),
398            None => host_shell_command(shell, flag, command, &self.workdir),
399        };
400        // Same withholding the built-in shell tool applies. A script that has
401        // `shell` would otherwise be the way around the `env_var` gate above.
402        self.shell_env.apply(&mut cmd);
403        self.io.run_shell(cmd, self.shell_timeout)
404    }
405
406    fn read_file(&self, path: &str) -> Result<String, String> {
407        if !self.allow.read_file {
408            return Err(denied("read_file"));
409        }
410        let resolved = self.resolve_in_workdir(path)?;
411        self.io.read_file(&resolved)
412    }
413
414    fn write_file(&self, path: &str, content: &str) -> Result<String, String> {
415        if !self.allow.write_file {
416            return Err(denied("write_file"));
417        }
418        // Same rule as the built-in write tools: never let `create_dir_all`
419        // resurrect a workspace that disappeared mid-run (issue #107).
420        if !std::fs::metadata(&self.workdir).is_ok_and(|m| m.is_dir()) {
421            return Err(format!(
422                "workspace '{}' is no longer accessible",
423                self.workdir.display()
424            ));
425        }
426        let resolved = self.resolve_in_workdir(path)?;
427        self.io.write_file(&resolved, content)
428    }
429
430    fn env_var(&self, name: &str) -> Result<String, String> {
431        if !self.allow.env_var {
432            return Err(denied("env_var"));
433        }
434        // A script tool ships inside the agent bundle, so this call is
435        // attacker-authored in exactly the case that matters. Ordinary variables
436        // pass; a credential-shaped name needs the user to have listed it. Two
437        // lines - `env_var("ANTHROPIC_API_KEY")` then `http_post(...)` - was
438        // otherwise a working exfiltration path with no prompt in it anywhere.
439        if !leviath_core::script_env_allowed(name, &self.allow_env_vars) {
440            return Err(format!(
441                "[denied] '{name}' looks like a credential. Add it to `[security] \
442                 allow_env_vars` in ~/.leviath/config.toml if this agent is meant \
443                 to read it."
444            ));
445        }
446        self.io.env_var(name)
447    }
448}
449
450/// The real I/O backend: blocking HTTP, host shell, filesystem, and env access.
451///
452/// Every method runs synchronously (the script engine is driven from a
453/// `spawn_blocking` context), so a blocking `reqwest` client and `std::process`
454/// are safe here.
455pub struct RealScriptIo;
456
457/// The one process-wide blocking HTTP client for script tools.
458///
459/// Built once, then cloned per request. A `reqwest::blocking::Client` owns a
460/// dedicated OS thread running a current-thread tokio runtime, so a
461/// build-one-per-request shape spawns (and tears down) a thread plus a runtime
462/// plus a TLS root-store load for *every* `http_get` - a researcher agent
463/// fanning out over dozens of pages can exhaust thread/FD limits, at which
464/// point `build()` fails and the `.expect` panics inside a Rhai native call.
465/// One shared client also gives connection reuse across calls.
466///
467/// The builder can still only fail on TLS-backend init, and that failure is
468/// contained: `leviath_scripting`'s native-function guards turn a panic here
469/// into an ordinary script error instead of aborting the daemon.
470static HTTP_CLIENT: std::sync::LazyLock<reqwest::blocking::Client> =
471    std::sync::LazyLock::new(|| {
472        reqwest::blocking::Client::builder()
473            .timeout(Duration::from_secs(30))
474            // Re-check every redirect hop. Validating only the URL the script
475            // passed is not enough: a perfectly public page answering `302
476            // Location: http://169.254.169.254/` lands on the cloud metadata
477            // service just the same, and reqwest follows up to 10 hops by
478            // default. `limited(5)` also bounds redirect loops.
479            .redirect(reqwest::redirect::Policy::custom(|attempt| {
480                if attempt.previous().len() >= 5 {
481                    return attempt.error("too many redirects");
482                }
483                match leviath_net::check_url(attempt.url(), local_network_allowed()) {
484                    Ok(()) => attempt.follow(),
485                    Err(e) => attempt.error(format!("refused to follow redirect: {e}")),
486                }
487            }))
488            .build()
489            .expect("failed to build blocking reqwest client")
490    });
491
492/// Flatten an error and its `source` chain into one `": "`-joined line.
493///
494/// reqwest's own `Display` for a refused redirect is "error following redirect
495/// for url (…)" - it never mentions the reason, which for us is the whole point:
496/// "refused to follow redirect: private address" and "too many redirects" are
497/// different problems with different fixes, and both were reaching the script
498/// author as the same opaque sentence.
499fn error_chain(e: &dyn std::error::Error) -> String {
500    let mut parts = vec![e.to_string()];
501    let mut source = e.source();
502    while let Some(err) = source {
503        parts.push(err.to_string());
504        source = err.source();
505    }
506    parts.join(": ")
507}
508
509/// Whether *redirect hops* may land on loopback / private / link-local
510/// addresses.
511///
512/// The authoritative check is [`DaemonScriptHost::allow_local_network`], a plain
513/// field on the host. This atomic exists only because [`HTTP_CLIENT`] is
514/// process-wide and its redirect callback runs inside reqwest with no access to
515/// the host that started the request. `[security] allow_local_network` is a
516/// machine-wide switch, so one value per process is the right granularity -
517/// but keep the field authoritative and this a mirror of it, not the reverse:
518/// global mutable state read by the main check would make every test that
519/// touches it race with every test that doesn't.
520///
521/// Defaults to `false`, so a path that forgets to initialize it is the safe one.
522static ALLOW_LOCAL_REDIRECTS: std::sync::atomic::AtomicBool =
523    std::sync::atomic::AtomicBool::new(false);
524
525/// Apply `[security] allow_local_network` to redirect following for this process.
526pub fn set_local_network_allowed(allow: bool) {
527    ALLOW_LOCAL_REDIRECTS.store(allow, std::sync::atomic::Ordering::Relaxed);
528}
529
530/// The current value of the [`ALLOW_LOCAL_REDIRECTS`] switch.
531fn local_network_allowed() -> bool {
532    ALLOW_LOCAL_REDIRECTS.load(std::sync::atomic::Ordering::Relaxed)
533}
534
535impl RealScriptIo {
536    /// A handle on the shared [`HTTP_CLIENT`] (cloning a `Client` shares its
537    /// connection pool; it does not build a new one).
538    fn client() -> reqwest::blocking::Client {
539        HTTP_CLIENT.clone()
540    }
541
542    /// Apply a header map to a blocking request builder.
543    fn with_headers(
544        mut req: reqwest::blocking::RequestBuilder,
545        headers: BTreeMap<String, String>,
546    ) -> reqwest::blocking::RequestBuilder {
547        for (k, v) in headers {
548            req = req.header(k, v);
549        }
550        req
551    }
552
553    /// Send a built request and read its body as text.
554    ///
555    /// A body the `Content-Type` marks as binary is refused rather than decoded.
556    /// `Response::text` decodes *anything* lossily, so a PNG or MP3 came back as
557    /// a page of U+FFFD replacement characters reported as a **successful**
558    /// fetch - no error, no signal, straight into the model's context.
559    fn send(req: reqwest::blocking::RequestBuilder) -> Result<String, String> {
560        Self::send_capped(req, MAX_RESPONSE_BYTES)
561    }
562
563    /// [`send`](Self::send) with the body cap injected, so the oversized-body
564    /// refusal is testable against a small response instead of a 32 MiB one.
565    fn send_capped(req: reqwest::blocking::RequestBuilder, max: u64) -> Result<String, String> {
566        let resp = req
567            .send()
568            .map_err(|e| format!("request failed: {}", error_chain(&e)))?;
569        let status = resp.status();
570        let content_type = resp
571            .headers()
572            .get(reqwest::header::CONTENT_TYPE)
573            .and_then(|v| v.to_str().ok())
574            .unwrap_or_default()
575            .to_string();
576        if is_binary_content_type(&content_type) {
577            let len = resp.content_length();
578            return Err(non_text_body_message(&content_type, len));
579        }
580        // Refuse an oversized body before reading a byte of it. `text()` buffers
581        // the whole response, so a server advertising a multi-gigabyte
582        // `text/plain` is a memory-exhaustion DoS the 900 KB output cap below
583        // does nothing about - that cap runs *after* the allocation.
584        //
585        // Residual: a chunked response sends no `Content-Length`, so a body that
586        // lies about its size is still buffered. The client's 30-second timeout
587        // is what bounds that case; closing it properly needs a streaming decoder
588        // that preserves `text()`'s charset handling (it decodes Shift-JIS and
589        // Latin-1 pages correctly, which a raw `Read` + `from_utf8` would not).
590        if let Some(msg) = oversized_body_message(resp.content_length(), max) {
591            return Err(msg);
592        }
593        let text = cap_script_io(resp.text().map_err(|e| format!("read body: {e}"))?);
594        if status.is_success() {
595            Ok(text)
596        } else {
597            Err(format!("http {status}: {text}"))
598        }
599    }
600}
601
602/// Media types that are never text, so decoding them would only produce noise.
603///
604/// The check is on the declared type, deliberately **not** on UTF-8 validity of
605/// the bytes: `Response::text` is charset-aware and decodes Shift-JIS,
606/// ISO-8859-1 and Windows-1252 pages *correctly*, and a strict `from_utf8` test
607/// would misclassify exactly those as binary - the non-English pages a
608/// researcher agent is most likely to fetch. Anything unrecognised (including a
609/// missing header) falls through to the existing text path.
610const BINARY_CONTENT_PREFIXES: &[&str] = &[
611    "image/",
612    "audio/",
613    "video/",
614    "font/",
615    "application/octet-stream",
616    "application/pdf",
617    "application/zip",
618    "application/gzip",
619    "application/x-tar",
620    "application/x-bzip",
621    "application/wasm",
622    "application/vnd.",
623    "application/msword",
624];
625
626/// Whether a `Content-Type` header names content this tool cannot render as text.
627fn is_binary_content_type(content_type: &str) -> bool {
628    // Trim parameters (`image/png; charset=binary`) and normalise case.
629    let essence = content_type
630        .split(';')
631        .next()
632        .unwrap_or_default()
633        .trim()
634        .to_ascii_lowercase();
635    // `application/xml`, `+json`, `+xml` etc. are structured *text* despite the
636    // `application/` prefix, so match on the concrete list rather than the tree.
637    BINARY_CONTENT_PREFIXES
638        .iter()
639        .any(|prefix| essence.starts_with(prefix))
640}
641
642/// The diagnostic a script tool sees for a binary body. Phrased for the model:
643/// it names the type and size so the agent can pick a different source.
644fn non_text_body_message(content_type: &str, len: Option<u64>) -> String {
645    let size = match len {
646        Some(bytes) => format!(", {} KB", bytes.div_ceil(1024)),
647        None => String::new(),
648    };
649    format!("non-text content ({content_type}{size}) - this tool returns text only")
650}
651
652/// Cap a host-I/O string below the tool engine's 1 MB `max_string_size`
653/// (`build_tool_engine`) so an oversized fetch/read/shell result can't raise the
654/// NON-CATCHABLE `ErrorDataTooLarge` inside a Rhai tool script (it aborts the tool
655/// even inside try/catch). This is only a crash guard - context-size truncation is
656/// handled downstream by region budgets and any in-script truncation.
657const MAX_SCRIPT_IO_BYTES: usize = 900_000;
658
659/// Largest response body [`RealScriptIo::send`] will read, checked against the
660/// declared `Content-Length` *before* buffering.
661///
662/// Well above [`MAX_SCRIPT_IO_BYTES`] on purpose: a page a little larger than the
663/// output cap should still be fetched and truncated (that is the normal case for
664/// a long article), while a body two orders of magnitude larger is refused
665/// outright as a resource-exhaustion attempt rather than allocated first.
666const MAX_RESPONSE_BYTES: u64 = 32 * 1024 * 1024;
667
668/// The refusal message for an over-large declared body, or `None` to proceed.
669///
670/// Split out as a pure function with an injectable `max` so the threshold is
671/// testable without a 32 MB HTTP round trip - and because a mock server cannot
672/// help here anyway: hyper panics rather than send a `Content-Length` that
673/// disagrees with the body it is writing, so the lying-header case that motivates
674/// the check is unreachable from an honest test server.
675fn oversized_body_message(content_length: Option<u64>, max: u64) -> Option<String> {
676    match content_length {
677        Some(len) if len > max => Some(format!(
678            "response declares {len} bytes, over the {max}-byte limit - \
679             fetch a more specific page"
680        )),
681        _ => None,
682    }
683}
684
685pub(crate) fn cap_script_io(mut s: String) -> String {
686    if s.len() > MAX_SCRIPT_IO_BYTES {
687        // Cut on a char boundary - a raw byte cut-off lands mid-character on
688        // multi-byte text and panics (the shape of issue #109).
689        s.truncate(floor_char_boundary(&s, MAX_SCRIPT_IO_BYTES));
690        s.push_str("\n[...truncated by leviath: response exceeded 900 KB]");
691    }
692    s
693}
694
695impl ScriptIo for RealScriptIo {
696    fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String> {
697        let client = Self::client();
698        Self::send(Self::with_headers(client.get(url), headers))
699    }
700
701    fn http_post(
702        &self,
703        url: &str,
704        body: &str,
705        headers: BTreeMap<String, String>,
706    ) -> Result<String, String> {
707        let client = Self::client();
708        Self::send(Self::with_headers(
709            client.post(url).body(body.to_string()),
710            headers,
711        ))
712    }
713
714    fn run_shell(&self, mut cmd: TokioCommand, timeout: Duration) -> Result<String, String> {
715        // The script engine drives this from a `spawn_blocking` thread (not a
716        // runtime worker), so blocking on the current runtime is safe here and
717        // lets us reuse tokio's timeout - the same mechanism the built-in shell
718        // tool uses. `try_current` rather than `current`: a blocking thread can
719        // outlive runtime shutdown, and `current` would *panic* there - and a
720        // panic inside a Rhai native call is the shape that can abort the
721        // daemon (issue #109).
722        let Ok(handle) = tokio::runtime::Handle::try_current() else {
723            return Err("shell is unavailable: no tokio runtime on this thread".to_string());
724        };
725        // Reap the whole command tree if the future is dropped (timeout, or the
726        // batch dropped because the agent was cancelled) rather than detaching
727        // it. `kill_on_drop` covers the shell; its own children are reparented
728        // to init unless the group is signalled - see `leviath_tools`' shell
729        // tool, which does the same.
730        cmd.kill_on_drop(true);
731        leviath_tools::own_process_group(&mut cmd);
732        // `spawn` inherits stdio where `output` pipes it; pipe explicitly so the
733        // command's output is still captured.
734        cmd.stdout(std::process::Stdio::piped())
735            .stderr(std::process::Stdio::piped());
736        handle.block_on(async move {
737            // Spawn inside the timed future so the reaper guard lives exactly as
738            // long as the command: dropping the future drops the guard, which
739            // signals the group. One fallible block also keeps a single error
740            // arm, as `Command::output()` had.
741            let run = async {
742                let child = cmd.spawn()?;
743                let _reaper = child.id().map(leviath_tools::ProcessGroupReaper);
744                child.wait_with_output().await
745            };
746            match tokio::time::timeout(timeout, run).await {
747                Ok(Ok(output)) => Ok(cap_script_io(combine_shell_output(
748                    &output.stdout,
749                    &output.stderr,
750                ))),
751                Ok(Err(e)) => Err(format!("failed to spawn shell: {e}")),
752                Err(_) => Err(format!(
753                    "shell command timed out after {}s",
754                    timeout.as_secs()
755                )),
756            }
757        })
758    }
759
760    fn read_file(&self, path: &Path) -> Result<String, String> {
761        std::fs::read_to_string(path)
762            .map(cap_script_io)
763            .map_err(|e| format!("read '{}': {e}", path.display()))
764    }
765
766    fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
767        if let Some(parent) = path.parent() {
768            std::fs::create_dir_all(parent)
769                .map_err(|e| format!("create dir '{}': {e}", parent.display()))?;
770        }
771        std::fs::write(path, content).map_err(|e| format!("write '{}': {e}", path.display()))?;
772        Ok(format!(
773            "wrote {} bytes to {}",
774            content.len(),
775            path.display()
776        ))
777    }
778
779    fn env_var(&self, name: &str) -> Result<String, String> {
780        std::env::var(name).map_err(|_| format!("environment variable '{name}' is not set"))
781    }
782}
783
784/// The system shell + command flag for the current platform.
785///
786/// Deliberately `/bin/sh` on Unix rather than the user's `$SHELL`, unlike the
787/// `shell` tool's `BuiltinTools::detect_shell`: a Rhai tool script is authored
788/// once and run on every machine, so it gets the POSIX shell it can count on
789/// instead of whatever interactive shell the operator happens to prefer.
790pub(crate) fn default_shell() -> (&'static str, &'static str) {
791    default_shell_for(std::env::consts::OS)
792}
793
794/// [`default_shell`] with the platform as a parameter.
795///
796/// Pure over the OS string rather than `#[cfg(windows)]`-switched, following
797/// `leviath_sys::browser::open_command_for`, so the Windows answer is reachable
798/// under test on every platform instead of only on the Windows CI leg.
799pub(crate) fn default_shell_for(os: &str) -> (&'static str, &'static str) {
800    match os {
801        "windows" => ("cmd.exe", "/C"),
802        _ => ("/bin/sh", "-c"),
803    }
804}
805
806/// Build the host (un-sandboxed) shell command pointed at `workdir` - the
807/// no-sandbox arm of [`DaemonScriptHost::shell`].
808pub(crate) fn host_shell_command(
809    shell: &str,
810    flag: &str,
811    command: &str,
812    workdir: &Path,
813) -> TokioCommand {
814    let mut c = leviath_sys::child_command_async(shell);
815    c.arg(flag).arg(command).current_dir(workdir);
816    c
817}
818
819/// Combine a finished command's stdout and (non-empty) stderr into one string,
820/// preserving the prior `shell()` contract.
821pub(crate) fn combine_shell_output(stdout: &[u8], stderr: &[u8]) -> String {
822    let mut out = String::from_utf8_lossy(stdout).into_owned();
823    let err = String::from_utf8_lossy(stderr);
824    if !err.trim().is_empty() {
825        out.push_str(&err);
826    }
827    out
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833    use std::sync::Mutex;
834
835    // ── resolve_script_permissions ──
836
837    fn perms(all: ScriptPermission) -> ScriptToolPermissions {
838        ScriptToolPermissions {
839            http_get: all,
840            http_post: all,
841            shell: all,
842            read_file: all,
843            write_file: all,
844            env_var: all,
845        }
846    }
847
848    #[test]
849    fn resolve_allow_permits_everything() {
850        let a = resolve_script_permissions(&perms(ScriptPermission::Allow), &|_| ToolPolicy::Deny);
851        assert_eq!(
852            a,
853            ScriptAllow {
854                http_get: true,
855                http_post: true,
856                shell: true,
857                read_file: true,
858                write_file: true,
859                env_var: true,
860            }
861        );
862    }
863
864    #[test]
865    fn resolve_deny_blocks_everything() {
866        let a = resolve_script_permissions(&perms(ScriptPermission::Deny), &|_| ToolPolicy::Allow);
867        assert_eq!(
868            a,
869            ScriptAllow {
870                http_get: false,
871                http_post: false,
872                shell: false,
873                read_file: false,
874                write_file: false,
875                env_var: false,
876            }
877        );
878    }
879
880    #[test]
881    fn resolve_inherit_net_true_filelike_follows_builtin() {
882        // Default is Inherit. Builtin resolves read_file→Allow, shell→Ask.
883        let a = resolve_script_permissions(&ScriptToolPermissions::default(), &|name| match name {
884            "read_file" => ToolPolicy::Allow,
885            _ => ToolPolicy::Ask,
886        });
887        assert!(a.http_get && a.http_post && a.env_var);
888        assert!(a.read_file, "read_file inherit → Allow");
889        assert!(!a.write_file, "write_file inherit → Ask ⇒ denied");
890        assert!(!a.shell, "shell inherit → Ask ⇒ denied");
891    }
892
893    // ── effective_script_permissions (per-agent override) ──
894
895    #[test]
896    fn effective_perms_agent_tightens_per_field() {
897        // Global allows everything; the agent's blueprint tightens several
898        // fields (exercising the allow/deny/inherit parse arms) and leaves the
899        // rest at the global value.
900        let global = perms(ScriptPermission::Allow);
901        let manifest = "\
902            [tool_script_permissions]\n\
903            http_get = \"allow\"\n\
904            shell = \"deny\"\n\
905            write_file = \"inherit\"\n";
906        let eff = effective_script_permissions(&global, manifest);
907        assert_eq!(eff.http_get, ScriptPermission::Allow, "allow arm");
908        assert_eq!(eff.shell, ScriptPermission::Deny, "deny arm");
909        assert_eq!(eff.write_file, ScriptPermission::Inherit, "inherit arm");
910        assert_eq!(eff.env_var, ScriptPermission::Allow, "unset keeps global");
911        assert_eq!(eff.read_file, ScriptPermission::Allow);
912        assert_eq!(eff.http_post, ScriptPermission::Allow);
913    }
914
915    /// The manifest may not loosen what the user locked down. The other way
916    /// round - a downloaded agent setting `http_get = "allow"` over a global
917    /// `deny` getting the network back - makes the user's config advisory
918    /// rather than binding.
919    #[test]
920    fn effective_perms_agent_cannot_loosen_global() {
921        let global = perms(ScriptPermission::Deny);
922        let manifest = "\
923            [tool_script_permissions]\n\
924            http_get = \"allow\"\n\
925            shell = \"allow\"\n\
926            env_var = \"inherit\"\n";
927        let eff = effective_script_permissions(&global, manifest);
928        assert_eq!(eff.http_get, ScriptPermission::Deny);
929        assert_eq!(eff.shell, ScriptPermission::Deny);
930        assert_eq!(eff.env_var, ScriptPermission::Deny);
931    }
932
933    /// `Inherit` sits between `Allow` and `Deny`, so a manifest cannot promote an
934    /// inherited file/shell permission to an unconditional allow either.
935    #[test]
936    fn effective_perms_agent_cannot_promote_inherit_to_allow() {
937        let global = perms(ScriptPermission::Inherit);
938        let manifest = "[tool_script_permissions]\nshell = \"allow\"\n";
939        let eff = effective_script_permissions(&global, manifest);
940        assert_eq!(eff.shell, ScriptPermission::Inherit);
941    }
942
943    #[test]
944    fn effective_perms_absent_section_keeps_global() {
945        let global = perms(ScriptPermission::Deny);
946        // No section at all → global unchanged.
947        let eff = effective_script_permissions(&global, "[agent]\nname = \"x\"");
948        assert_eq!(eff.shell, ScriptPermission::Deny);
949        assert_eq!(eff.http_get, ScriptPermission::Deny);
950    }
951
952    #[test]
953    fn effective_perms_malformed_inputs_fall_back_to_global() {
954        let global = perms(ScriptPermission::Allow);
955        // Unparseable TOML → global unchanged.
956        let eff = effective_script_permissions(&global, "not = valid = toml");
957        assert_eq!(eff.shell, ScriptPermission::Allow);
958        // Present-but-not-a-table → global unchanged.
959        let eff2 = effective_script_permissions(&global, "tool_script_permissions = 5");
960        assert_eq!(eff2.shell, ScriptPermission::Allow);
961        // An unrecognized value inside the table → that field keeps the global.
962        let eff3 =
963            effective_script_permissions(&global, "[tool_script_permissions]\nshell = \"maybe\"");
964        assert_eq!(eff3.shell, ScriptPermission::Allow);
965    }
966
967    // ── permission gates on the host ──
968
969    struct RecordingIo {
970        calls: Mutex<Vec<String>>,
971    }
972    impl RecordingIo {
973        fn arc() -> Arc<RecordingIo> {
974            Arc::new(RecordingIo {
975                calls: Mutex::new(Vec::new()),
976            })
977        }
978    }
979    impl ScriptIo for RecordingIo {
980        fn http_get(&self, url: &str, _h: BTreeMap<String, String>) -> Result<String, String> {
981            self.calls.lock().unwrap().push(format!("get:{url}"));
982            Ok("g".into())
983        }
984        fn http_post(
985            &self,
986            url: &str,
987            body: &str,
988            _h: BTreeMap<String, String>,
989        ) -> Result<String, String> {
990            self.calls
991                .lock()
992                .unwrap()
993                .push(format!("post:{url}:{body}"));
994            Ok("p".into())
995        }
996        fn run_shell(&self, cmd: TokioCommand, _timeout: Duration) -> Result<String, String> {
997            // Record the prepared program (host `sh`/`cmd.exe` when un-sandboxed).
998            let prog = cmd.as_std().get_program().to_string_lossy().into_owned();
999            self.calls.lock().unwrap().push(format!("shell:{prog}"));
1000            Ok("s".into())
1001        }
1002        fn read_file(&self, path: &Path) -> Result<String, String> {
1003            self.calls
1004                .lock()
1005                .unwrap()
1006                .push(format!("read:{}", path.display()));
1007            Ok("r".into())
1008        }
1009        fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
1010            self.calls
1011                .lock()
1012                .unwrap()
1013                .push(format!("write:{}:{content}", path.display()));
1014            Ok("w".into())
1015        }
1016        fn env_var(&self, name: &str) -> Result<String, String> {
1017            self.calls.lock().unwrap().push(format!("env:{name}"));
1018            Ok("e".into())
1019        }
1020    }
1021
1022    fn all_allowed() -> ScriptAllow {
1023        ScriptAllow {
1024            http_get: true,
1025            http_post: true,
1026            shell: true,
1027            read_file: true,
1028            write_file: true,
1029            env_var: true,
1030        }
1031    }
1032
1033    fn none_allowed() -> ScriptAllow {
1034        ScriptAllow {
1035            http_get: false,
1036            http_post: false,
1037            shell: false,
1038            read_file: false,
1039            write_file: false,
1040            env_var: false,
1041        }
1042    }
1043
1044    /// A script tool is the other spelling of "run a shell command", and it
1045    /// bypassed `clamp_by_effect` entirely - that clamp lives in the tool
1046    /// dispatcher, which a Rhai `shell()` never goes through. So an agent
1047    /// shipping its own `.rhai` tools could write through a redirect while
1048    /// `write_file` was denied, which is exactly what the clamp exists to stop.
1049    #[test]
1050    fn a_script_shell_redirect_answers_to_the_write_permission() {
1051        let io = RecordingIo::arc();
1052        let allow = ScriptAllow {
1053            write_file: false,
1054            ..all_allowed()
1055        };
1056        let host = DaemonScriptHost::with_io(allow, std::env::temp_dir(), io.clone());
1057
1058        let err = host
1059            .shell("echo pwn > /root/.bashrc")
1060            .expect_err("a redirect must answer to the write permission");
1061        assert!(err.contains("write_file"), "got: {err}");
1062
1063        // The same command without the redirect still runs, so this is the
1064        // write being refused rather than the shell.
1065        host.shell("echo pwn").expect("a non-writing shell is fine");
1066
1067        // And with writes permitted, a redirect *inside the workdir* runs.
1068        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1069        host.shell("echo pwn > x")
1070            .expect("a permitted write is not clamped");
1071    }
1072
1073    /// Issue #289. `allow.write_file` answers "may this write at all"; it does
1074    /// not answer "may it write *there*". This host's `write_file` is
1075    /// workdir-confined, so its `shell()` redirects are too - otherwise a script
1076    /// with writes permitted could put a file anywhere on the host.
1077    #[test]
1078    fn a_script_shell_redirect_stays_inside_the_workdir() {
1079        let dir = tempfile::tempdir().unwrap();
1080        let io = RecordingIo::arc();
1081        let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
1082
1083        let err = host
1084            .shell("echo pwn > /root/.bashrc")
1085            .expect_err("an escaping redirect is refused even with writes allowed");
1086        assert!(err.contains("outside the working directory"), "got: {err}");
1087
1088        // The control: inside the workdir it still runs, so this is the path
1089        // being refused rather than every redirect.
1090        host.shell("echo ok > inside.txt")
1091            .expect("a redirect inside the workdir runs");
1092    }
1093
1094    #[test]
1095    fn script_write_refuses_a_deleted_workspace() {
1096        // Same rule as the built-in write tools (#107): a script may not
1097        // resurrect a workspace that disappeared out from under the run.
1098        let dir = tempfile::tempdir().unwrap();
1099        let workdir = dir.path().join("gone");
1100        let io = RecordingIo::arc();
1101        let host = DaemonScriptHost::with_io(all_allowed(), workdir.clone(), io.clone());
1102        let err = host.write_file("out.txt", "body").unwrap_err();
1103        assert!(err.contains("no longer accessible"), "got: {err}");
1104        assert!(
1105            io.calls.lock().unwrap().is_empty(),
1106            "the io layer never ran"
1107        );
1108        // A live workspace still writes.
1109        std::fs::create_dir(&workdir).unwrap();
1110        assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1111    }
1112
1113    /// A public IP *literal*, not a hostname: the outbound check resolves names,
1114    /// and a unit test must not depend on DNS (or on the network being up) to
1115    /// decide whether the host delegates to its I/O backend.
1116    const PUBLIC_URL: &str = "http://93.184.216.34/";
1117
1118    #[test]
1119    fn allowed_calls_delegate_to_io() {
1120        let io = RecordingIo::arc();
1121        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1122        assert_eq!(host.http_get(PUBLIC_URL, BTreeMap::new()).unwrap(), "g");
1123        assert_eq!(
1124            host.http_post(PUBLIC_URL, "b", BTreeMap::new()).unwrap(),
1125            "p"
1126        );
1127        assert_eq!(host.shell("ls").unwrap(), "s");
1128        assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1129        assert_eq!(host.env_var("HOME").unwrap(), "e");
1130        let calls = io.calls.lock().unwrap().clone();
1131        assert!(calls.contains(&format!("get:{PUBLIC_URL}")));
1132        assert!(calls.iter().any(|c| c.starts_with("post:")));
1133        // Un-sandboxed → the prepared command runs the host shell.
1134        assert!(calls.iter().any(|c| c.starts_with("shell:")));
1135        assert!(
1136            calls
1137                .iter()
1138                .any(|c| c.starts_with("write:") && c.ends_with(":body"))
1139        );
1140        assert!(calls.contains(&"env:HOME".to_string()));
1141    }
1142
1143    /// The exfiltration/SSRF case: a script tool with `http_get` permission is
1144    /// still not a licence to reach the user's own network. Nothing may touch
1145    /// the I/O backend - the URL is refused before a request is built.
1146    #[test]
1147    fn outbound_check_blocks_local_targets_before_any_io() {
1148        let io = RecordingIo::arc();
1149        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1150        for url in [
1151            // Cloud metadata: returns instance credentials.
1152            "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
1153            // The user's own agent-spawning API.
1154            "http://127.0.0.1:3000/api/agents",
1155            // The LAN.
1156            "http://192.168.1.1/",
1157            // Not an HTTP scheme at all.
1158            "file:///etc/passwd",
1159        ] {
1160            let err = host.http_get(url, BTreeMap::new()).unwrap_err();
1161            assert!(err.starts_with("[denied]"), "{url} → {err}");
1162            let err = host.http_post(url, "leak", BTreeMap::new()).unwrap_err();
1163            assert!(err.starts_with("[denied]"), "{url} → {err}");
1164        }
1165        let calls = io.calls.lock().unwrap().clone();
1166        assert!(
1167            calls.is_empty(),
1168            "a refused URL must never reach the I/O backend: {calls:?}"
1169        );
1170    }
1171
1172    /// The exfiltration half of the chain: a `.rhai` tool that ships inside an
1173    /// installed agent bundle calling `env_var("ANTHROPIC_API_KEY")`. Paired with
1174    /// the SSRF guard above, the two-line "read a key, POST it out" script no
1175    /// longer has either half available to it.
1176    #[test]
1177    fn env_var_refuses_credential_names_by_default() {
1178        let io = RecordingIo::arc();
1179        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1180        for name in [
1181            "ANTHROPIC_API_KEY",
1182            "OPENAI_API_KEY",
1183            "AWS_SECRET_ACCESS_KEY",
1184            "GITHUB_TOKEN",
1185            "LEVIATH_API_TOKEN",
1186        ] {
1187            let err = host.env_var(name).unwrap_err();
1188            assert!(err.starts_with("[denied]"), "{name} → {err}");
1189            assert!(err.contains("allow_env_vars"), "{name} → {err}");
1190        }
1191        assert!(
1192            io.calls.lock().unwrap().is_empty(),
1193            "a refused read must never reach the I/O backend"
1194        );
1195    }
1196
1197    /// Ordinary variables are unaffected - a script reading `PATH` or its own
1198    /// app's setting is normal, and the gate would be useless if it broke that.
1199    #[test]
1200    fn env_var_allows_ordinary_names() {
1201        let io = RecordingIo::arc();
1202        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1203        assert_eq!(host.env_var("PATH").unwrap(), "e");
1204        assert_eq!(host.env_var("MY_APP_REGION").unwrap(), "e");
1205    }
1206
1207    /// The user allowlisting a name is them saying "yes, this agent is meant to
1208    /// have that one" - and only that one.
1209    #[test]
1210    fn env_var_allowlist_permits_exactly_the_named_variable() {
1211        let io = RecordingIo::arc();
1212        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1213            .with_env_allowlist(vec!["MY_PROVIDER_KEY".to_string()]);
1214        assert_eq!(host.env_var("MY_PROVIDER_KEY").unwrap(), "e");
1215        assert!(host.env_var("ANTHROPIC_API_KEY").is_err());
1216    }
1217
1218    /// A malformed URL is refused rather than passed through for the HTTP client
1219    /// to interpret.
1220    #[test]
1221    fn outbound_check_rejects_unparseable_urls() {
1222        let io = RecordingIo::arc();
1223        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1224        let err = host.http_get("not a url", BTreeMap::new()).unwrap_err();
1225        assert!(err.contains("invalid URL"), "{err}");
1226        assert!(io.calls.lock().unwrap().is_empty());
1227    }
1228
1229    /// `[security] allow_local_network = true` is what a user running a local
1230    /// model (Ollama on 11434, say) sets. It is a field on the host, not global
1231    /// state, so this test cannot perturb any other.
1232    #[test]
1233    fn allow_local_network_opens_the_local_path() {
1234        let io = RecordingIo::arc();
1235        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1236            .with_local_network(true);
1237        assert_eq!(
1238            host.http_get("http://127.0.0.1:11434/api/tags", BTreeMap::new())
1239                .unwrap(),
1240            "g"
1241        );
1242        // The scheme check is not waived by it.
1243        assert!(
1244            host.http_get("file:///etc/passwd", BTreeMap::new())
1245                .is_err()
1246        );
1247    }
1248
1249    /// `ALLOW_LOCAL_REDIRECTS` is process-wide, so every test that writes it
1250    /// races every test that reads it. Tests run in parallel in one process;
1251    /// without this, a test that sets the mirror to `true` makes a concurrent
1252    /// test's redirect refusal silently succeed instead.
1253    static REDIRECT_MIRROR: std::sync::Mutex<()> = std::sync::Mutex::new(());
1254
1255    /// Take the redirect-mirror lock.
1256    fn lock_redirect_mirror() -> std::sync::MutexGuard<'static, ()> {
1257        REDIRECT_MIRROR.lock().expect("redirect mirror lock")
1258    }
1259
1260    /// The redirect mirror is a separate process-wide value; setting it must not
1261    /// change what the host itself decides.
1262    #[test]
1263    fn redirect_switch_is_independent_of_the_host_field() {
1264        let _guard = lock_redirect_mirror();
1265        let io = RecordingIo::arc();
1266        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1267        let previous = local_network_allowed();
1268        set_local_network_allowed(true);
1269        let decided = host.http_get("http://127.0.0.1:9/", BTreeMap::new());
1270        set_local_network_allowed(previous);
1271        assert!(
1272            decided.is_err(),
1273            "the host field, not the redirect mirror, decides the initial URL"
1274        );
1275    }
1276
1277    #[test]
1278    fn denied_calls_return_denied_and_skip_io() {
1279        let io = RecordingIo::arc();
1280        let host = DaemonScriptHost::with_io(none_allowed(), std::env::temp_dir(), io.clone());
1281        assert!(
1282            host.http_get("http://x", BTreeMap::new())
1283                .unwrap_err()
1284                .contains("[denied]")
1285        );
1286        assert!(
1287            host.http_post("http://x", "b", BTreeMap::new())
1288                .unwrap_err()
1289                .contains("http_post")
1290        );
1291        assert!(host.shell("ls").unwrap_err().contains("shell"));
1292        assert!(host.read_file("a.txt").unwrap_err().contains("read_file"));
1293        assert!(
1294            host.write_file("a.txt", "b")
1295                .unwrap_err()
1296                .contains("write_file")
1297        );
1298        assert!(host.env_var("X").unwrap_err().contains("env_var"));
1299        assert!(
1300            io.calls.lock().unwrap().is_empty(),
1301            "no I/O on denied calls"
1302        );
1303    }
1304
1305    #[test]
1306    fn read_file_confined_to_workdir() {
1307        let dir = tempfile::tempdir().unwrap();
1308        std::fs::write(dir.path().join("ok.txt"), "hi").unwrap();
1309        let io = RecordingIo::arc();
1310        let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
1311        // Allowed relative path → delegates.
1312        assert_eq!(host.read_file("ok.txt").unwrap(), "r");
1313        assert_eq!(host.write_file("ok.txt", "x").unwrap(), "w");
1314        // Escaping path → rejected before any I/O (both read and write share the
1315        // resolve_in_workdir `?` guard).
1316        let err = host.read_file("../../etc/passwd").unwrap_err();
1317        assert!(err.contains("escape"));
1318        let werr = host.write_file("../../etc/passwd", "x").unwrap_err();
1319        assert!(werr.contains("escape"));
1320        // Only the ok.txt read + write reached the io (the escaping calls did not).
1321        let calls = io.calls.lock().unwrap().clone();
1322        assert_eq!(calls.len(), 2);
1323        assert!(calls.iter().any(|c| c.starts_with("read:")));
1324        assert!(calls.iter().any(|c| c.starts_with("write:")));
1325    }
1326
1327    #[test]
1328    fn read_file_absolute_outside_workdir_rejected() {
1329        let dir = tempfile::tempdir().unwrap();
1330        let host =
1331            DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), RecordingIo::arc());
1332        // A path that is *absolute on the current platform* (a leading `/` is not
1333        // absolute on Windows - it needs a drive/UNC prefix), and outside the
1334        // workdir. `temp_dir()` is absolute everywhere and a sibling of the
1335        // workdir tempdir, so it exercises the `is_absolute()` → true branch.
1336        let outside = std::env::temp_dir().join("leviath-abs-outside-xyz");
1337        assert!(outside.is_absolute(), "test path must be absolute");
1338        let err = host.read_file(outside.to_str().unwrap()).unwrap_err();
1339        assert!(err.contains("would escape"), "got: {err}");
1340    }
1341
1342    #[test]
1343    fn read_file_pop_past_root_rejected() {
1344        // A *relative* workdir keeps the component accumulator free of any root
1345        // prefix, so a second `..` pops an empty accumulator → the "escapes"
1346        // (pop-fail) branch, distinct from the "would escape" (starts_with) one.
1347        let host =
1348            DaemonScriptHost::with_io(all_allowed(), PathBuf::from("wd"), RecordingIo::arc());
1349        let err = host.read_file("../..").unwrap_err();
1350        assert!(err.contains("escapes the working directory"), "got: {err}");
1351    }
1352
1353    // ── RealScriptIo (hermetic, local) ──
1354
1355    async fn mock_http() -> String {
1356        use axum::Router;
1357        use axum::routing::{get, post};
1358        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1359        let base = format!("http://{}", listener.local_addr().unwrap());
1360        let app = Router::new()
1361            .route("/ok", get(|| async { "GET-BODY" }))
1362            .route("/echo", post(|body: String| async move { body }))
1363            .route(
1364                "/boom",
1365                get(|| async {
1366                    (
1367                        axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1368                        "server error",
1369                    )
1370                }),
1371            )
1372            // A binary body: `Response::text` would lossily decode this into
1373            // replacement characters and report success.
1374            .route(
1375                "/png",
1376                get(|| async {
1377                    (
1378                        [(axum::http::header::CONTENT_TYPE, "image/png")],
1379                        // A real PNG signature + IHDR-ish bytes; invalid UTF-8.
1380                        vec![0x89u8, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe],
1381                    )
1382                }),
1383            )
1384            // Declared text in a non-UTF-8 charset - must still decode, which is
1385            // why the guard reads the header rather than testing UTF-8 validity.
1386            .route(
1387                "/shiftjis",
1388                get(|| async {
1389                    (
1390                        [(
1391                            axum::http::header::CONTENT_TYPE,
1392                            "text/html; charset=shift_jis",
1393                        )],
1394                        // "日本語" in Shift-JIS.
1395                        vec![0x93u8, 0xfa, 0x96, 0x7b, 0x8c, 0xea],
1396                    )
1397                }),
1398            );
1399        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1400            listener, app,
1401        )));
1402        base
1403    }
1404
1405    #[test]
1406    fn binary_content_types_are_classified_but_structured_text_is_not() {
1407        for text in [
1408            "",
1409            "text/html; charset=utf-8",
1410            "text/plain",
1411            "application/json",
1412            "application/xml",
1413            "application/xhtml+xml",
1414            "application/ld+json",
1415            "application/javascript",
1416        ] {
1417            assert!(!is_binary_content_type(text), "should be text: {text:?}");
1418        }
1419        for binary in [
1420            "image/png",
1421            "IMAGE/PNG",
1422            "image/jpeg; charset=binary",
1423            "  audio/mpeg  ",
1424            "video/mp4",
1425            "font/woff2",
1426            "application/octet-stream",
1427            "application/pdf",
1428            "application/zip",
1429            "application/gzip",
1430            "application/x-tar",
1431            "application/x-bzip2",
1432            "application/wasm",
1433            "application/vnd.ms-excel",
1434            "application/msword",
1435        ] {
1436            assert!(
1437                is_binary_content_type(binary),
1438                "should be binary: {binary:?}"
1439            );
1440        }
1441    }
1442
1443    #[test]
1444    fn the_non_text_diagnostic_names_the_type_and_size_when_known() {
1445        let with_len = non_text_body_message("image/png", Some(2049));
1446        assert!(with_len.contains("image/png"), "got: {with_len}");
1447        assert!(with_len.contains("3 KB"), "rounds up: {with_len}");
1448        let without_len = non_text_body_message("audio/mpeg", None);
1449        assert!(without_len.contains("audio/mpeg"), "got: {without_len}");
1450        assert!(
1451            !without_len.contains("KB"),
1452            "no size to report: {without_len}"
1453        );
1454    }
1455
1456    #[tokio::test(flavor = "multi_thread")]
1457    async fn binary_bodies_are_refused_and_non_utf8_text_still_decodes() {
1458        let base = mock_http().await;
1459        let (png, sjis) = tokio::task::spawn_blocking(move || {
1460            (
1461                RealScriptIo.http_get(&format!("{base}/png"), BTreeMap::new()),
1462                RealScriptIo.http_get(&format!("{base}/shiftjis"), BTreeMap::new()),
1463            )
1464        })
1465        .await
1466        .unwrap();
1467
1468        // A PNG is refused outright rather than returned as replacement chars.
1469        let err = png.unwrap_err();
1470        assert!(err.contains("non-text content"), "got: {err}");
1471        assert!(err.contains("image/png"), "got: {err}");
1472
1473        // A Shift-JIS page is text: it must still come back decoded. Guarding on
1474        // UTF-8 validity instead of the header would have broken this.
1475        assert_eq!(sjis.unwrap(), "日本語");
1476    }
1477
1478    /// A body declaring itself larger than the cap is refused from the header,
1479    /// before `text()` allocates it. The 900 KB output cap runs *after* the read,
1480    /// so it was never a defence against this.
1481    #[test]
1482    fn oversized_declared_body_is_refused() {
1483        let msg = oversized_body_message(Some(999_999_999), 1_000).expect("should refuse");
1484        assert!(msg.contains("999999999"), "{msg}");
1485        assert!(msg.contains("1000-byte limit"), "{msg}");
1486    }
1487
1488    /// A body at or under the cap proceeds, and so does one with no declared
1489    /// length - a chunked response has none, and refusing every chunked page
1490    /// would break most of the web.
1491    #[test]
1492    fn body_within_cap_or_of_unknown_size_proceeds() {
1493        assert!(oversized_body_message(Some(1_000), 1_000).is_none());
1494        assert!(oversized_body_message(Some(0), 1_000).is_none());
1495        assert!(oversized_body_message(None, 1_000).is_none());
1496    }
1497
1498    /// The cap in the real `send` path, against a small response with the limit
1499    /// lowered - the 32 MiB production value would mean transferring 32 MiB to
1500    /// assert one branch.
1501    #[tokio::test(flavor = "multi_thread")]
1502    async fn send_refuses_a_body_over_the_cap() {
1503        let base = mock_http().await;
1504        let out = tokio::task::spawn_blocking(move || {
1505            let client = RealScriptIo::client();
1506            // `/ok` returns "GET-BODY" (8 bytes) with a Content-Length.
1507            RealScriptIo::send_capped(client.get(format!("{base}/ok")), 4)
1508        })
1509        .await
1510        .unwrap();
1511        let err = out.expect_err("a body over the cap is refused");
1512        assert!(err.contains("over the"), "got: {err}");
1513    }
1514
1515    /// A redirect is a fresh destination the caller's original URL check never
1516    /// saw, so the policy re-checks every hop. Here a public-looking request is
1517    /// bounced to loopback - the shape that turns any redirect-following fetch
1518    /// into an SSRF primitive.
1519    #[tokio::test(flavor = "multi_thread")]
1520    async fn redirects_to_a_local_address_are_refused() {
1521        use axum::Router;
1522        use axum::response::Redirect;
1523        use axum::routing::get;
1524
1525        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1526        let addr = listener.local_addr().unwrap();
1527        // Only `/bounce` is served: if the guard ever fails open, the request
1528        // 404s instead of succeeding, and the test still fails - but no handler
1529        // sits here unreached on the passing path.
1530        let app = Router::new().route(
1531            "/bounce",
1532            get(move || async move { Redirect::temporary(&format!("http://{addr}/ok")) }),
1533        );
1534        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1535            listener, app,
1536        )));
1537
1538        // The mirror is taken, read and restored entirely inside the blocking
1539        // closure: holding a `std` guard across an `.await` is a deadlock the
1540        // scheduler is free to arrange.
1541        let out = tokio::task::spawn_blocking(move || {
1542            let _guard = lock_redirect_mirror();
1543            let previous = local_network_allowed();
1544            set_local_network_allowed(false);
1545            let result = RealScriptIo.http_get(&format!("http://{addr}/bounce"), BTreeMap::new());
1546            set_local_network_allowed(previous);
1547            result
1548        })
1549        .await
1550        .unwrap();
1551        let err = out.expect_err("a redirect to loopback must not be followed");
1552        assert!(err.contains("refused to follow redirect"), "got: {err}");
1553    }
1554
1555    /// A redirect *loop* is bounded even when every hop is permitted, so a
1556    /// server cannot hold a fetch open by bouncing it forever.
1557    #[tokio::test(flavor = "multi_thread")]
1558    async fn a_redirect_loop_is_bounded() {
1559        use axum::Router;
1560        use axum::response::Redirect;
1561        use axum::routing::get;
1562
1563        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1564        let addr = listener.local_addr().unwrap();
1565        let app = Router::new().route(
1566            "/loop",
1567            get(move || async move { Redirect::temporary(&format!("http://{addr}/loop")) }),
1568        );
1569        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1570            listener, app,
1571        )));
1572
1573        let out = tokio::task::spawn_blocking(move || {
1574            let _guard = lock_redirect_mirror();
1575            // Loopback hops are permitted here, so the *count* is what stops it.
1576            let previous = local_network_allowed();
1577            set_local_network_allowed(true);
1578            let result = RealScriptIo.http_get(&format!("http://{addr}/loop"), BTreeMap::new());
1579            set_local_network_allowed(previous);
1580            result
1581        })
1582        .await
1583        .unwrap();
1584        let err = out.expect_err("an endless redirect must be stopped");
1585        assert!(err.contains("too many redirects"), "got: {err}");
1586    }
1587
1588    /// The containment refusal, driven through the injected predicate so it is
1589    /// exercised on every platform. The `#[cfg(unix)]` test below proves the
1590    /// same refusal against a real symlink; this one proves the arm fires on
1591    /// Windows too, where a test cannot create one.
1592    #[test]
1593    fn resolve_in_refuses_a_path_that_does_not_resolve_within_the_workdir() {
1594        fn escapes(_: &Path, _: &Path) -> bool {
1595            false
1596        }
1597        let dir = tempfile::tempdir().unwrap();
1598        let err = DaemonScriptHost::resolve_in("notes.txt", dir.path(), escapes)
1599            .expect_err("a path that resolves outside must be refused");
1600        assert!(err.contains("symlink"), "{err}");
1601    }
1602
1603    /// The null device is not a place, so containment has nothing to refuse.
1604    /// It is returned as written rather than joined onto the workdir, which is
1605    /// what makes it a sink instead of a file called `null` in the workspace.
1606    #[test]
1607    fn resolve_in_admits_the_null_device() {
1608        let dir = tempfile::tempdir().unwrap();
1609        let resolved =
1610            DaemonScriptHost::resolve_in("/dev/null", dir.path(), leviath_core::resolves_within)
1611                .expect("the null device is not an escape");
1612        assert_eq!(resolved, PathBuf::from("/dev/null"));
1613    }
1614
1615    /// The converse, so the test above is not passing merely because everything
1616    /// is refused.
1617    #[test]
1618    fn resolve_in_admits_an_ordinary_path_within_the_workdir() {
1619        let dir = tempfile::tempdir().unwrap();
1620        let resolved =
1621            DaemonScriptHost::resolve_in("notes.txt", dir.path(), leviath_core::resolves_within)
1622                .expect("an ordinary path resolves");
1623        assert!(resolved.ends_with("notes.txt"));
1624    }
1625
1626    /// The script host's own path confinement, mirroring `BuiltinTools`: a
1627    /// symlink inside the workdir that points outside it is refused.
1628    #[cfg(unix)]
1629    #[test]
1630    fn script_host_read_refuses_a_symlink_escape() {
1631        let dir = tempfile::tempdir().unwrap();
1632        let workdir = dir.path().join("workspace");
1633        std::fs::create_dir(&workdir).unwrap();
1634        std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
1635
1636        let host = DaemonScriptHost::with_io(all_allowed(), workdir, RecordingIo::arc());
1637        let err = host.read_file("link/etc/hosts").unwrap_err();
1638        assert!(err.contains("symlink"), "got: {err}");
1639    }
1640
1641    #[tokio::test(flavor = "multi_thread")]
1642    async fn real_http_get_success_and_headers() {
1643        let base = mock_http().await;
1644        let out = tokio::task::spawn_blocking(move || {
1645            let mut h = BTreeMap::new();
1646            h.insert("X-Test".to_string(), "1".to_string());
1647            RealScriptIo.http_get(&format!("{base}/ok"), h)
1648        })
1649        .await
1650        .unwrap();
1651        assert_eq!(out.unwrap(), "GET-BODY");
1652    }
1653
1654    #[tokio::test(flavor = "multi_thread")]
1655    async fn real_http_get_non_success_is_error() {
1656        let base = mock_http().await;
1657        let out = tokio::task::spawn_blocking(move || {
1658            RealScriptIo.http_get(&format!("{base}/boom"), BTreeMap::new())
1659        })
1660        .await
1661        .unwrap();
1662        let err = out.unwrap_err();
1663        assert!(
1664            err.contains("http 500") && err.contains("server error"),
1665            "got: {err}"
1666        );
1667    }
1668
1669    #[tokio::test(flavor = "multi_thread")]
1670    async fn real_http_get_connection_error() {
1671        // Nothing listening on this port → send() fails.
1672        let out = tokio::task::spawn_blocking(|| {
1673            RealScriptIo.http_get("http://127.0.0.1:1/x", BTreeMap::new())
1674        })
1675        .await
1676        .unwrap();
1677        assert!(out.unwrap_err().contains("request failed"));
1678    }
1679
1680    /// A raw TCP server that declares a larger Content-Length than it sends, then
1681    /// closes - so `resp.text()` errors on the incomplete body (mirrors the
1682    /// package-registry truncated-body test).
1683    async fn spawn_truncated_body_server() -> String {
1684        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1685        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1686        let addr = listener.local_addr().unwrap();
1687        let body = b"partial";
1688        let response = format!(
1689            "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1690            body.len() + 4096
1691        )
1692        .into_bytes();
1693        tokio::spawn(async move {
1694            let (mut socket, _) = listener.accept().await.unwrap();
1695            let mut buf = [0u8; 8192];
1696            let _ = socket.read(&mut buf).await;
1697            let _ = socket.write_all(&response).await;
1698            let _ = socket.write_all(body).await;
1699            let _ = socket.flush().await;
1700            let _ = socket.shutdown().await;
1701        });
1702        format!("http://{addr}")
1703    }
1704
1705    #[tokio::test(flavor = "multi_thread")]
1706    async fn real_http_body_read_error() {
1707        let base = spawn_truncated_body_server().await;
1708        let out = tokio::task::spawn_blocking(move || {
1709            RealScriptIo.http_get(&format!("{base}/x"), BTreeMap::new())
1710        })
1711        .await
1712        .unwrap();
1713        let err = out.unwrap_err();
1714        assert!(err.contains("read body"), "got: {err}");
1715    }
1716
1717    #[tokio::test(flavor = "multi_thread")]
1718    async fn real_http_post_echoes_body() {
1719        let base = mock_http().await;
1720        let out = tokio::task::spawn_blocking(move || {
1721            RealScriptIo.http_post(&format!("{base}/echo"), "hello", BTreeMap::new())
1722        })
1723        .await
1724        .unwrap();
1725        assert_eq!(out.unwrap(), "hello");
1726    }
1727
1728    /// Build a host command + run it through `run_shell` on a blocking thread
1729    /// (so its `Handle::block_on` isn't called from a runtime worker).
1730    async fn run_host_shell(
1731        command: &'static str,
1732        workdir: PathBuf,
1733        timeout: Duration,
1734    ) -> Result<String, String> {
1735        tokio::task::spawn_blocking(move || {
1736            let (shell, flag) = default_shell();
1737            let cmd = host_shell_command(shell, flag, command, &workdir);
1738            RealScriptIo.run_shell(cmd, timeout)
1739        })
1740        .await
1741        .unwrap()
1742    }
1743
1744    #[test]
1745    fn real_shell_off_a_runtime_errors_instead_of_panicking() {
1746        // A blocking thread can outlive runtime shutdown; `Handle::current()`
1747        // would panic there, and a panic inside a Rhai native call aborted the
1748        // whole daemon before issue #109 was fixed. A plain `std::thread` is
1749        // the same "no reactor on this thread" condition.
1750        let dir = tempfile::tempdir().unwrap();
1751        let workdir = dir.path().to_path_buf();
1752        let err = std::thread::spawn(move || {
1753            let (shell, flag) = default_shell();
1754            let cmd = host_shell_command(shell, flag, "echo hi", &workdir);
1755            RealScriptIo.run_shell(cmd, Duration::from_secs(5))
1756        })
1757        .join()
1758        .unwrap()
1759        .unwrap_err();
1760        assert!(err.contains("no tokio runtime"), "got: {err}");
1761    }
1762
1763    #[tokio::test(flavor = "multi_thread")]
1764    async fn real_shell_runs_and_captures_output() {
1765        let dir = tempfile::tempdir().unwrap();
1766        // stdout (empty-stderr arm of combine_shell_output)
1767        let out = run_host_shell(
1768            "echo hello",
1769            dir.path().to_path_buf(),
1770            Duration::from_secs(30),
1771        )
1772        .await
1773        .unwrap();
1774        assert!(out.contains("hello"));
1775        // stderr is appended (non-empty stderr arm)
1776        let out2 = run_host_shell(
1777            "echo oops 1>&2",
1778            dir.path().to_path_buf(),
1779            Duration::from_secs(30),
1780        )
1781        .await
1782        .unwrap();
1783        assert!(out2.contains("oops"));
1784    }
1785
1786    #[tokio::test(flavor = "multi_thread")]
1787    async fn real_shell_spawn_failure() {
1788        // A non-existent cwd makes the child fail to spawn → the Ok(Err) arm.
1789        let missing = PathBuf::from("/no/such/workdir/leviath");
1790        let err = run_host_shell("echo hi", missing, Duration::from_secs(30))
1791            .await
1792            .unwrap_err();
1793        assert!(err.contains("failed to spawn shell"), "got: {err}");
1794    }
1795
1796    #[tokio::test(flavor = "multi_thread")]
1797    async fn real_shell_times_out() {
1798        // A slow command against a tiny timeout hits the Err(_) (timeout) arm.
1799        let dir = tempfile::tempdir().unwrap();
1800        let err = run_host_shell(
1801            "sleep 5",
1802            dir.path().to_path_buf(),
1803            Duration::from_millis(50),
1804        )
1805        .await
1806        .unwrap_err();
1807        assert!(err.contains("timed out"), "got: {err}");
1808    }
1809
1810    #[test]
1811    fn combine_shell_output_appends_nonempty_stderr_only() {
1812        // Empty stderr → stdout unchanged; non-empty stderr → appended.
1813        assert_eq!(combine_shell_output(b"out", b"   "), "out");
1814        assert_eq!(combine_shell_output(b"out", b"err"), "outerr");
1815    }
1816
1817    #[test]
1818    fn host_shell_command_targets_workdir() {
1819        let cmd = host_shell_command("sh", "-c", "echo hi", Path::new("/w"));
1820        assert_eq!(cmd.as_std().get_program(), "sh");
1821    }
1822
1823    #[test]
1824    fn shell_routes_through_sandbox_when_present() {
1825        use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
1826        // A namespace sandbox with warn-fallback builds a manager on every
1827        // platform. Attaching it exercises the `Some(sandbox)` arm of `shell()`
1828        // (the command is built via the manager, not `host_shell_command`).
1829        let by_index = vec![ToolSandboxConfig {
1830            kind: SandboxKind::Namespace,
1831            on_unavailable: OnUnavailable::Warn,
1832            ..Default::default()
1833        }];
1834        let sb = SandboxManager::build("r", by_index, "/w", 0)
1835            .unwrap()
1836            .map(Arc::new);
1837        assert!(sb.is_some(), "namespace warn config yields a manager");
1838        let io = RecordingIo::arc();
1839        let host = DaemonScriptHost::with_io(all_allowed(), PathBuf::from("/w"), io.clone())
1840            .with_shell(sb, Duration::from_secs(5), Default::default());
1841        assert_eq!(host.shell("ls").unwrap(), "s");
1842        assert!(
1843            io.calls
1844                .lock()
1845                .unwrap()
1846                .iter()
1847                .any(|c| c.starts_with("shell:"))
1848        );
1849    }
1850
1851    #[test]
1852    fn real_read_file_success_and_error() {
1853        let dir = tempfile::tempdir().unwrap();
1854        let p = dir.path().join("f.txt");
1855        std::fs::write(&p, "data").unwrap();
1856        assert_eq!(RealScriptIo.read_file(&p).unwrap(), "data");
1857        let err = RealScriptIo
1858            .read_file(&dir.path().join("nope"))
1859            .unwrap_err();
1860        assert!(err.contains("read '"));
1861    }
1862
1863    #[test]
1864    fn real_write_file_creates_parents_and_reports() {
1865        let dir = tempfile::tempdir().unwrap();
1866        // Nested path exercises the create_dir_all(Some(parent)) branch.
1867        let nested = dir.path().join("sub/deep/out.txt");
1868        let msg = RealScriptIo.write_file(&nested, "body").unwrap();
1869        assert!(msg.contains("wrote 4 bytes"), "got: {msg}");
1870        assert_eq!(std::fs::read_to_string(&nested).unwrap(), "body");
1871    }
1872
1873    #[test]
1874    fn real_write_file_create_dir_error() {
1875        let dir = tempfile::tempdir().unwrap();
1876        // A regular file where a parent directory is expected → create_dir_all fails.
1877        let blocker = dir.path().join("afile");
1878        std::fs::write(&blocker, "x").unwrap();
1879        let err = RealScriptIo
1880            .write_file(&blocker.join("child.txt"), "b")
1881            .unwrap_err();
1882        assert!(err.contains("create dir"), "got: {err}");
1883    }
1884
1885    #[test]
1886    fn real_write_file_write_error() {
1887        let dir = tempfile::tempdir().unwrap();
1888        // The path itself is an existing directory → std::fs::write fails.
1889        let err = RealScriptIo.write_file(dir.path(), "b").unwrap_err();
1890        assert!(err.contains("write '"), "got: {err}");
1891    }
1892
1893    #[test]
1894    fn real_write_file_parentless_path() {
1895        // An empty path has no parent → the `if let Some(parent)` None arm is
1896        // taken (no dir creation), then the write itself fails.
1897        let err = RealScriptIo.write_file(Path::new(""), "b").unwrap_err();
1898        assert!(err.contains("write '"), "got: {err}");
1899    }
1900
1901    #[test]
1902    fn real_env_var_set_and_unset() {
1903        temp_env::with_var("LEVIATH_SCRIPT_TEST", Some("v"), || {
1904            assert_eq!(RealScriptIo.env_var("LEVIATH_SCRIPT_TEST").unwrap(), "v");
1905        });
1906        temp_env::with_var_unset("LEVIATH_SCRIPT_TEST_UNSET", || {
1907            assert!(
1908                RealScriptIo
1909                    .env_var("LEVIATH_SCRIPT_TEST_UNSET")
1910                    .unwrap_err()
1911                    .contains("not set")
1912            );
1913        });
1914    }
1915
1916    #[test]
1917    fn default_shell_is_platform_appropriate() {
1918        let (shell, flag) = default_shell();
1919        assert!(!shell.is_empty());
1920        assert!(!flag.is_empty());
1921    }
1922
1923    /// Both answers, from whichever platform is running the test. A script tool
1924    /// gets `/bin/sh` everywhere it exists and `cmd.exe` where it does not -
1925    /// never the operator's `$SHELL`, which is what makes a Rhai tool behave
1926    /// the same on every machine.
1927    #[test]
1928    fn default_shell_for_answers_per_platform() {
1929        assert_eq!(default_shell_for("windows"), ("cmd.exe", "/C"));
1930        for posix in ["linux", "macos", "freebsd", "haiku"] {
1931            assert_eq!(default_shell_for(posix), ("/bin/sh", "-c"), "{posix}");
1932        }
1933    }
1934
1935    #[test]
1936    fn new_wires_real_io() {
1937        // Construction path for the real backend (Arc<RealScriptIo>).
1938        let host = DaemonScriptHost::new(all_allowed(), std::env::temp_dir());
1939        // env_var goes through RealScriptIo; a guaranteed-unset var errors.
1940        temp_env::with_var_unset("LEVIATH_DEFINITELY_UNSET_XYZ", || {
1941            assert!(host.env_var("LEVIATH_DEFINITELY_UNSET_XYZ").is_err());
1942        });
1943    }
1944
1945    #[test]
1946    fn cap_script_io_leaves_small_strings_untouched() {
1947        let s = "small".to_string();
1948        assert_eq!(cap_script_io(s.clone()), s);
1949    }
1950
1951    #[test]
1952    fn cap_script_io_truncates_oversized_strings_below_the_rhai_limit() {
1953        let big = "x".repeat(MAX_SCRIPT_IO_BYTES + 5_000);
1954        let capped = cap_script_io(big);
1955        assert!(capped.len() < 1_000_000, "must stay under the 1MB Rhai cap");
1956        assert!(capped.contains("[...truncated by leviath"));
1957    }
1958
1959    #[test]
1960    fn cap_script_io_truncates_on_a_char_boundary() {
1961        // A multi-byte char straddling the cap must not be split mid-codepoint.
1962        let mut s = "a".repeat(MAX_SCRIPT_IO_BYTES - 1);
1963        s.push('é'); // 2 bytes, crossing the boundary
1964        s.push_str(&"b".repeat(10));
1965        let capped = cap_script_io(s);
1966        // Valid UTF-8 (would panic on construction if a codepoint were split).
1967        assert!(capped.contains("[...truncated by leviath"));
1968    }
1969}