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