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        // `spawn` inherits stdio where `output` pipes it; pipe explicitly so the
696        // command's output is still captured.
697        cmd.stdout(std::process::Stdio::piped())
698            .stderr(std::process::Stdio::piped());
699        handle.block_on(async move {
700            // Spawn inside the timed future so the reaper guard lives exactly as
701            // long as the command: dropping the future drops the guard, which
702            // signals the group. One fallible block also keeps a single error
703            // arm, as `Command::output()` had.
704            let run = async {
705                let child = cmd.spawn()?;
706                let _reaper = child.id().map(leviath_tools::ProcessGroupReaper);
707                child.wait_with_output().await
708            };
709            match tokio::time::timeout(timeout, run).await {
710                Ok(Ok(output)) => Ok(cap_script_io(combine_shell_output(
711                    &output.stdout,
712                    &output.stderr,
713                ))),
714                Ok(Err(e)) => Err(format!("failed to spawn shell: {e}")),
715                Err(_) => Err(format!(
716                    "shell command timed out after {}s",
717                    timeout.as_secs()
718                )),
719            }
720        })
721    }
722
723    fn read_file(&self, path: &Path) -> Result<String, String> {
724        std::fs::read_to_string(path)
725            .map(cap_script_io)
726            .map_err(|e| format!("read '{}': {e}", path.display()))
727    }
728
729    fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
730        if let Some(parent) = path.parent() {
731            std::fs::create_dir_all(parent)
732                .map_err(|e| format!("create dir '{}': {e}", parent.display()))?;
733        }
734        std::fs::write(path, content).map_err(|e| format!("write '{}': {e}", path.display()))?;
735        Ok(format!(
736            "wrote {} bytes to {}",
737            content.len(),
738            path.display()
739        ))
740    }
741
742    fn env_var(&self, name: &str) -> Result<String, String> {
743        std::env::var(name).map_err(|_| format!("environment variable '{name}' is not set"))
744    }
745}
746
747/// The system shell + command flag for the current platform.
748pub(crate) fn default_shell() -> (&'static str, &'static str) {
749    #[cfg(windows)]
750    {
751        ("cmd.exe", "/C")
752    }
753    #[cfg(not(windows))]
754    {
755        ("/bin/sh", "-c")
756    }
757}
758
759/// Build the host (un-sandboxed) shell command pointed at `workdir` - the
760/// no-sandbox arm of [`DaemonScriptHost::shell`].
761pub(crate) fn host_shell_command(
762    shell: &str,
763    flag: &str,
764    command: &str,
765    workdir: &Path,
766) -> TokioCommand {
767    let mut c = TokioCommand::new(shell);
768    c.arg(flag).arg(command).current_dir(workdir);
769    c
770}
771
772/// Combine a finished command's stdout and (non-empty) stderr into one string,
773/// preserving the prior `shell()` contract.
774pub(crate) fn combine_shell_output(stdout: &[u8], stderr: &[u8]) -> String {
775    let mut out = String::from_utf8_lossy(stdout).into_owned();
776    let err = String::from_utf8_lossy(stderr);
777    if !err.trim().is_empty() {
778        out.push_str(&err);
779    }
780    out
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786    use std::sync::Mutex;
787
788    // ── resolve_script_permissions ──
789
790    fn perms(all: ScriptPermission) -> ScriptToolPermissions {
791        ScriptToolPermissions {
792            http_get: all,
793            http_post: all,
794            shell: all,
795            read_file: all,
796            write_file: all,
797            env_var: all,
798        }
799    }
800
801    #[test]
802    fn resolve_allow_permits_everything() {
803        let a = resolve_script_permissions(&perms(ScriptPermission::Allow), &|_| ToolPolicy::Deny);
804        assert_eq!(
805            a,
806            ScriptAllow {
807                http_get: true,
808                http_post: true,
809                shell: true,
810                read_file: true,
811                write_file: true,
812                env_var: true,
813            }
814        );
815    }
816
817    #[test]
818    fn resolve_deny_blocks_everything() {
819        let a = resolve_script_permissions(&perms(ScriptPermission::Deny), &|_| ToolPolicy::Allow);
820        assert_eq!(
821            a,
822            ScriptAllow {
823                http_get: false,
824                http_post: false,
825                shell: false,
826                read_file: false,
827                write_file: false,
828                env_var: false,
829            }
830        );
831    }
832
833    #[test]
834    fn resolve_inherit_net_true_filelike_follows_builtin() {
835        // Default is Inherit. Builtin resolves read_file→Allow, shell→Ask.
836        let a = resolve_script_permissions(&ScriptToolPermissions::default(), &|name| match name {
837            "read_file" => ToolPolicy::Allow,
838            _ => ToolPolicy::Ask,
839        });
840        assert!(a.http_get && a.http_post && a.env_var);
841        assert!(a.read_file, "read_file inherit → Allow");
842        assert!(!a.write_file, "write_file inherit → Ask ⇒ denied");
843        assert!(!a.shell, "shell inherit → Ask ⇒ denied");
844    }
845
846    // ── effective_script_permissions (per-agent override) ──
847
848    #[test]
849    fn effective_perms_agent_tightens_per_field() {
850        // Global allows everything; the agent's blueprint tightens several
851        // fields (exercising the allow/deny/inherit parse arms) and leaves the
852        // rest at the global value.
853        let global = perms(ScriptPermission::Allow);
854        let manifest = "\
855            [tool_script_permissions]\n\
856            http_get = \"allow\"\n\
857            shell = \"deny\"\n\
858            write_file = \"inherit\"\n";
859        let eff = effective_script_permissions(&global, manifest);
860        assert_eq!(eff.http_get, ScriptPermission::Allow, "allow arm");
861        assert_eq!(eff.shell, ScriptPermission::Deny, "deny arm");
862        assert_eq!(eff.write_file, ScriptPermission::Inherit, "inherit arm");
863        assert_eq!(eff.env_var, ScriptPermission::Allow, "unset keeps global");
864        assert_eq!(eff.read_file, ScriptPermission::Allow);
865        assert_eq!(eff.http_post, ScriptPermission::Allow);
866    }
867
868    /// The manifest may not loosen what the user locked down. The other way
869    /// round - a downloaded agent setting `http_get = "allow"` over a global
870    /// `deny` getting the network back - makes the user's config advisory
871    /// rather than binding.
872    #[test]
873    fn effective_perms_agent_cannot_loosen_global() {
874        let global = perms(ScriptPermission::Deny);
875        let manifest = "\
876            [tool_script_permissions]\n\
877            http_get = \"allow\"\n\
878            shell = \"allow\"\n\
879            env_var = \"inherit\"\n";
880        let eff = effective_script_permissions(&global, manifest);
881        assert_eq!(eff.http_get, ScriptPermission::Deny);
882        assert_eq!(eff.shell, ScriptPermission::Deny);
883        assert_eq!(eff.env_var, ScriptPermission::Deny);
884    }
885
886    /// `Inherit` sits between `Allow` and `Deny`, so a manifest cannot promote an
887    /// inherited file/shell permission to an unconditional allow either.
888    #[test]
889    fn effective_perms_agent_cannot_promote_inherit_to_allow() {
890        let global = perms(ScriptPermission::Inherit);
891        let manifest = "[tool_script_permissions]\nshell = \"allow\"\n";
892        let eff = effective_script_permissions(&global, manifest);
893        assert_eq!(eff.shell, ScriptPermission::Inherit);
894    }
895
896    #[test]
897    fn effective_perms_absent_section_keeps_global() {
898        let global = perms(ScriptPermission::Deny);
899        // No section at all → global unchanged.
900        let eff = effective_script_permissions(&global, "[agent]\nname = \"x\"");
901        assert_eq!(eff.shell, ScriptPermission::Deny);
902        assert_eq!(eff.http_get, ScriptPermission::Deny);
903    }
904
905    #[test]
906    fn effective_perms_malformed_inputs_fall_back_to_global() {
907        let global = perms(ScriptPermission::Allow);
908        // Unparseable TOML → global unchanged.
909        let eff = effective_script_permissions(&global, "not = valid = toml");
910        assert_eq!(eff.shell, ScriptPermission::Allow);
911        // Present-but-not-a-table → global unchanged.
912        let eff2 = effective_script_permissions(&global, "tool_script_permissions = 5");
913        assert_eq!(eff2.shell, ScriptPermission::Allow);
914        // An unrecognized value inside the table → that field keeps the global.
915        let eff3 =
916            effective_script_permissions(&global, "[tool_script_permissions]\nshell = \"maybe\"");
917        assert_eq!(eff3.shell, ScriptPermission::Allow);
918    }
919
920    // ── permission gates on the host ──
921
922    struct RecordingIo {
923        calls: Mutex<Vec<String>>,
924    }
925    impl RecordingIo {
926        fn arc() -> Arc<RecordingIo> {
927            Arc::new(RecordingIo {
928                calls: Mutex::new(Vec::new()),
929            })
930        }
931    }
932    impl ScriptIo for RecordingIo {
933        fn http_get(&self, url: &str, _h: BTreeMap<String, String>) -> Result<String, String> {
934            self.calls.lock().unwrap().push(format!("get:{url}"));
935            Ok("g".into())
936        }
937        fn http_post(
938            &self,
939            url: &str,
940            body: &str,
941            _h: BTreeMap<String, String>,
942        ) -> Result<String, String> {
943            self.calls
944                .lock()
945                .unwrap()
946                .push(format!("post:{url}:{body}"));
947            Ok("p".into())
948        }
949        fn run_shell(&self, cmd: TokioCommand, _timeout: Duration) -> Result<String, String> {
950            // Record the prepared program (host `sh`/`cmd.exe` when un-sandboxed).
951            let prog = cmd.as_std().get_program().to_string_lossy().into_owned();
952            self.calls.lock().unwrap().push(format!("shell:{prog}"));
953            Ok("s".into())
954        }
955        fn read_file(&self, path: &Path) -> Result<String, String> {
956            self.calls
957                .lock()
958                .unwrap()
959                .push(format!("read:{}", path.display()));
960            Ok("r".into())
961        }
962        fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
963            self.calls
964                .lock()
965                .unwrap()
966                .push(format!("write:{}:{content}", path.display()));
967            Ok("w".into())
968        }
969        fn env_var(&self, name: &str) -> Result<String, String> {
970            self.calls.lock().unwrap().push(format!("env:{name}"));
971            Ok("e".into())
972        }
973    }
974
975    fn all_allowed() -> ScriptAllow {
976        ScriptAllow {
977            http_get: true,
978            http_post: true,
979            shell: true,
980            read_file: true,
981            write_file: true,
982            env_var: true,
983        }
984    }
985
986    fn none_allowed() -> ScriptAllow {
987        ScriptAllow {
988            http_get: false,
989            http_post: false,
990            shell: false,
991            read_file: false,
992            write_file: false,
993            env_var: false,
994        }
995    }
996
997    #[test]
998    fn script_write_refuses_a_deleted_workspace() {
999        // Same rule as the built-in write tools (#107): a script may not
1000        // resurrect a workspace that disappeared out from under the run.
1001        let dir = tempfile::tempdir().unwrap();
1002        let workdir = dir.path().join("gone");
1003        let io = RecordingIo::arc();
1004        let host = DaemonScriptHost::with_io(all_allowed(), workdir.clone(), io.clone());
1005        let err = host.write_file("out.txt", "body").unwrap_err();
1006        assert!(err.contains("no longer accessible"), "got: {err}");
1007        assert!(
1008            io.calls.lock().unwrap().is_empty(),
1009            "the io layer never ran"
1010        );
1011        // A live workspace still writes.
1012        std::fs::create_dir(&workdir).unwrap();
1013        assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1014    }
1015
1016    /// A public IP *literal*, not a hostname: the outbound check resolves names,
1017    /// and a unit test must not depend on DNS (or on the network being up) to
1018    /// decide whether the host delegates to its I/O backend.
1019    const PUBLIC_URL: &str = "http://93.184.216.34/";
1020
1021    #[test]
1022    fn allowed_calls_delegate_to_io() {
1023        let io = RecordingIo::arc();
1024        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1025        assert_eq!(host.http_get(PUBLIC_URL, BTreeMap::new()).unwrap(), "g");
1026        assert_eq!(
1027            host.http_post(PUBLIC_URL, "b", BTreeMap::new()).unwrap(),
1028            "p"
1029        );
1030        assert_eq!(host.shell("ls").unwrap(), "s");
1031        assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
1032        assert_eq!(host.env_var("HOME").unwrap(), "e");
1033        let calls = io.calls.lock().unwrap().clone();
1034        assert!(calls.contains(&format!("get:{PUBLIC_URL}")));
1035        assert!(calls.iter().any(|c| c.starts_with("post:")));
1036        // Un-sandboxed → the prepared command runs the host shell.
1037        assert!(calls.iter().any(|c| c.starts_with("shell:")));
1038        assert!(
1039            calls
1040                .iter()
1041                .any(|c| c.starts_with("write:") && c.ends_with(":body"))
1042        );
1043        assert!(calls.contains(&"env:HOME".to_string()));
1044    }
1045
1046    /// The exfiltration/SSRF case: a script tool with `http_get` permission is
1047    /// still not a licence to reach the user's own network. Nothing may touch
1048    /// the I/O backend - the URL is refused before a request is built.
1049    #[test]
1050    fn outbound_check_blocks_local_targets_before_any_io() {
1051        let io = RecordingIo::arc();
1052        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1053        for url in [
1054            // Cloud metadata: returns instance credentials.
1055            "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
1056            // The user's own agent-spawning API.
1057            "http://127.0.0.1:3000/api/agents",
1058            // The LAN.
1059            "http://192.168.1.1/",
1060            // Not an HTTP scheme at all.
1061            "file:///etc/passwd",
1062        ] {
1063            let err = host.http_get(url, BTreeMap::new()).unwrap_err();
1064            assert!(err.starts_with("[denied]"), "{url} → {err}");
1065            let err = host.http_post(url, "leak", BTreeMap::new()).unwrap_err();
1066            assert!(err.starts_with("[denied]"), "{url} → {err}");
1067        }
1068        let calls = io.calls.lock().unwrap().clone();
1069        assert!(
1070            calls.is_empty(),
1071            "a refused URL must never reach the I/O backend: {calls:?}"
1072        );
1073    }
1074
1075    /// The exfiltration half of the chain: a `.rhai` tool that ships inside an
1076    /// installed agent bundle calling `env_var("ANTHROPIC_API_KEY")`. Paired with
1077    /// the SSRF guard above, the two-line "read a key, POST it out" script no
1078    /// longer has either half available to it.
1079    #[test]
1080    fn env_var_refuses_credential_names_by_default() {
1081        let io = RecordingIo::arc();
1082        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1083        for name in [
1084            "ANTHROPIC_API_KEY",
1085            "OPENAI_API_KEY",
1086            "AWS_SECRET_ACCESS_KEY",
1087            "GITHUB_TOKEN",
1088            "LEVIATH_API_TOKEN",
1089        ] {
1090            let err = host.env_var(name).unwrap_err();
1091            assert!(err.starts_with("[denied]"), "{name} → {err}");
1092            assert!(err.contains("allow_env_vars"), "{name} → {err}");
1093        }
1094        assert!(
1095            io.calls.lock().unwrap().is_empty(),
1096            "a refused read must never reach the I/O backend"
1097        );
1098    }
1099
1100    /// Ordinary variables are unaffected - a script reading `PATH` or its own
1101    /// app's setting is normal, and the gate would be useless if it broke that.
1102    #[test]
1103    fn env_var_allows_ordinary_names() {
1104        let io = RecordingIo::arc();
1105        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1106        assert_eq!(host.env_var("PATH").unwrap(), "e");
1107        assert_eq!(host.env_var("MY_APP_REGION").unwrap(), "e");
1108    }
1109
1110    /// The user allowlisting a name is them saying "yes, this agent is meant to
1111    /// have that one" - and only that one.
1112    #[test]
1113    fn env_var_allowlist_permits_exactly_the_named_variable() {
1114        let io = RecordingIo::arc();
1115        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1116            .with_env_allowlist(vec!["MY_PROVIDER_KEY".to_string()]);
1117        assert_eq!(host.env_var("MY_PROVIDER_KEY").unwrap(), "e");
1118        assert!(host.env_var("ANTHROPIC_API_KEY").is_err());
1119    }
1120
1121    /// A malformed URL is refused rather than passed through for the HTTP client
1122    /// to interpret.
1123    #[test]
1124    fn outbound_check_rejects_unparseable_urls() {
1125        let io = RecordingIo::arc();
1126        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1127        let err = host.http_get("not a url", BTreeMap::new()).unwrap_err();
1128        assert!(err.contains("invalid URL"), "{err}");
1129        assert!(io.calls.lock().unwrap().is_empty());
1130    }
1131
1132    /// `[security] allow_local_network = true` is what a user running a local
1133    /// model (Ollama on 11434, say) sets. It is a field on the host, not global
1134    /// state, so this test cannot perturb any other.
1135    #[test]
1136    fn allow_local_network_opens_the_local_path() {
1137        let io = RecordingIo::arc();
1138        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
1139            .with_local_network(true);
1140        assert_eq!(
1141            host.http_get("http://127.0.0.1:11434/api/tags", BTreeMap::new())
1142                .unwrap(),
1143            "g"
1144        );
1145        // The scheme check is not waived by it.
1146        assert!(
1147            host.http_get("file:///etc/passwd", BTreeMap::new())
1148                .is_err()
1149        );
1150    }
1151
1152    /// `ALLOW_LOCAL_REDIRECTS` is process-wide, so every test that writes it
1153    /// races every test that reads it. Tests run in parallel in one process;
1154    /// without this, a test that sets the mirror to `true` makes a concurrent
1155    /// test's redirect refusal silently succeed instead.
1156    static REDIRECT_MIRROR: std::sync::Mutex<()> = std::sync::Mutex::new(());
1157
1158    /// Take the redirect-mirror lock.
1159    fn lock_redirect_mirror() -> std::sync::MutexGuard<'static, ()> {
1160        REDIRECT_MIRROR.lock().expect("redirect mirror lock")
1161    }
1162
1163    /// The redirect mirror is a separate process-wide value; setting it must not
1164    /// change what the host itself decides.
1165    #[test]
1166    fn redirect_switch_is_independent_of_the_host_field() {
1167        let _guard = lock_redirect_mirror();
1168        let io = RecordingIo::arc();
1169        let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
1170        let previous = local_network_allowed();
1171        set_local_network_allowed(true);
1172        let decided = host.http_get("http://127.0.0.1:9/", BTreeMap::new());
1173        set_local_network_allowed(previous);
1174        assert!(
1175            decided.is_err(),
1176            "the host field, not the redirect mirror, decides the initial URL"
1177        );
1178    }
1179
1180    #[test]
1181    fn denied_calls_return_denied_and_skip_io() {
1182        let io = RecordingIo::arc();
1183        let host = DaemonScriptHost::with_io(none_allowed(), std::env::temp_dir(), io.clone());
1184        assert!(
1185            host.http_get("http://x", BTreeMap::new())
1186                .unwrap_err()
1187                .contains("[denied]")
1188        );
1189        assert!(
1190            host.http_post("http://x", "b", BTreeMap::new())
1191                .unwrap_err()
1192                .contains("http_post")
1193        );
1194        assert!(host.shell("ls").unwrap_err().contains("shell"));
1195        assert!(host.read_file("a.txt").unwrap_err().contains("read_file"));
1196        assert!(
1197            host.write_file("a.txt", "b")
1198                .unwrap_err()
1199                .contains("write_file")
1200        );
1201        assert!(host.env_var("X").unwrap_err().contains("env_var"));
1202        assert!(
1203            io.calls.lock().unwrap().is_empty(),
1204            "no I/O on denied calls"
1205        );
1206    }
1207
1208    #[test]
1209    fn read_file_confined_to_workdir() {
1210        let dir = tempfile::tempdir().unwrap();
1211        std::fs::write(dir.path().join("ok.txt"), "hi").unwrap();
1212        let io = RecordingIo::arc();
1213        let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
1214        // Allowed relative path → delegates.
1215        assert_eq!(host.read_file("ok.txt").unwrap(), "r");
1216        assert_eq!(host.write_file("ok.txt", "x").unwrap(), "w");
1217        // Escaping path → rejected before any I/O (both read and write share the
1218        // resolve_in_workdir `?` guard).
1219        let err = host.read_file("../../etc/passwd").unwrap_err();
1220        assert!(err.contains("escape"));
1221        let werr = host.write_file("../../etc/passwd", "x").unwrap_err();
1222        assert!(werr.contains("escape"));
1223        // Only the ok.txt read + write reached the io (the escaping calls did not).
1224        let calls = io.calls.lock().unwrap().clone();
1225        assert_eq!(calls.len(), 2);
1226        assert!(calls.iter().any(|c| c.starts_with("read:")));
1227        assert!(calls.iter().any(|c| c.starts_with("write:")));
1228    }
1229
1230    #[test]
1231    fn read_file_absolute_outside_workdir_rejected() {
1232        let dir = tempfile::tempdir().unwrap();
1233        let host =
1234            DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), RecordingIo::arc());
1235        // A path that is *absolute on the current platform* (a leading `/` is not
1236        // absolute on Windows - it needs a drive/UNC prefix), and outside the
1237        // workdir. `temp_dir()` is absolute everywhere and a sibling of the
1238        // workdir tempdir, so it exercises the `is_absolute()` → true branch.
1239        let outside = std::env::temp_dir().join("leviath-abs-outside-xyz");
1240        assert!(outside.is_absolute(), "test path must be absolute");
1241        let err = host.read_file(outside.to_str().unwrap()).unwrap_err();
1242        assert!(err.contains("would escape"), "got: {err}");
1243    }
1244
1245    #[test]
1246    fn read_file_pop_past_root_rejected() {
1247        // A *relative* workdir keeps the component accumulator free of any root
1248        // prefix, so a second `..` pops an empty accumulator → the "escapes"
1249        // (pop-fail) branch, distinct from the "would escape" (starts_with) one.
1250        let host =
1251            DaemonScriptHost::with_io(all_allowed(), PathBuf::from("wd"), RecordingIo::arc());
1252        let err = host.read_file("../..").unwrap_err();
1253        assert!(err.contains("escapes the working directory"), "got: {err}");
1254    }
1255
1256    // ── RealScriptIo (hermetic, local) ──
1257
1258    async fn mock_http() -> String {
1259        use axum::Router;
1260        use axum::routing::{get, post};
1261        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1262        let base = format!("http://{}", listener.local_addr().unwrap());
1263        let app = Router::new()
1264            .route("/ok", get(|| async { "GET-BODY" }))
1265            .route("/echo", post(|body: String| async move { body }))
1266            .route(
1267                "/boom",
1268                get(|| async {
1269                    (
1270                        axum::http::StatusCode::INTERNAL_SERVER_ERROR,
1271                        "server error",
1272                    )
1273                }),
1274            )
1275            // A binary body: `Response::text` would lossily decode this into
1276            // replacement characters and report success.
1277            .route(
1278                "/png",
1279                get(|| async {
1280                    (
1281                        [(axum::http::header::CONTENT_TYPE, "image/png")],
1282                        // A real PNG signature + IHDR-ish bytes; invalid UTF-8.
1283                        vec![0x89u8, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe],
1284                    )
1285                }),
1286            )
1287            // Declared text in a non-UTF-8 charset - must still decode, which is
1288            // why the guard reads the header rather than testing UTF-8 validity.
1289            .route(
1290                "/shiftjis",
1291                get(|| async {
1292                    (
1293                        [(
1294                            axum::http::header::CONTENT_TYPE,
1295                            "text/html; charset=shift_jis",
1296                        )],
1297                        // "日本語" in Shift-JIS.
1298                        vec![0x93u8, 0xfa, 0x96, 0x7b, 0x8c, 0xea],
1299                    )
1300                }),
1301            );
1302        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1303            listener, app,
1304        )));
1305        base
1306    }
1307
1308    #[test]
1309    fn binary_content_types_are_classified_but_structured_text_is_not() {
1310        for text in [
1311            "",
1312            "text/html; charset=utf-8",
1313            "text/plain",
1314            "application/json",
1315            "application/xml",
1316            "application/xhtml+xml",
1317            "application/ld+json",
1318            "application/javascript",
1319        ] {
1320            assert!(!is_binary_content_type(text), "should be text: {text:?}");
1321        }
1322        for binary in [
1323            "image/png",
1324            "IMAGE/PNG",
1325            "image/jpeg; charset=binary",
1326            "  audio/mpeg  ",
1327            "video/mp4",
1328            "font/woff2",
1329            "application/octet-stream",
1330            "application/pdf",
1331            "application/zip",
1332            "application/gzip",
1333            "application/x-tar",
1334            "application/x-bzip2",
1335            "application/wasm",
1336            "application/vnd.ms-excel",
1337            "application/msword",
1338        ] {
1339            assert!(
1340                is_binary_content_type(binary),
1341                "should be binary: {binary:?}"
1342            );
1343        }
1344    }
1345
1346    #[test]
1347    fn the_non_text_diagnostic_names_the_type_and_size_when_known() {
1348        let with_len = non_text_body_message("image/png", Some(2049));
1349        assert!(with_len.contains("image/png"), "got: {with_len}");
1350        assert!(with_len.contains("3 KB"), "rounds up: {with_len}");
1351        let without_len = non_text_body_message("audio/mpeg", None);
1352        assert!(without_len.contains("audio/mpeg"), "got: {without_len}");
1353        assert!(
1354            !without_len.contains("KB"),
1355            "no size to report: {without_len}"
1356        );
1357    }
1358
1359    #[tokio::test(flavor = "multi_thread")]
1360    async fn binary_bodies_are_refused_and_non_utf8_text_still_decodes() {
1361        let base = mock_http().await;
1362        let (png, sjis) = tokio::task::spawn_blocking(move || {
1363            (
1364                RealScriptIo.http_get(&format!("{base}/png"), BTreeMap::new()),
1365                RealScriptIo.http_get(&format!("{base}/shiftjis"), BTreeMap::new()),
1366            )
1367        })
1368        .await
1369        .unwrap();
1370
1371        // A PNG is refused outright rather than returned as replacement chars.
1372        let err = png.unwrap_err();
1373        assert!(err.contains("non-text content"), "got: {err}");
1374        assert!(err.contains("image/png"), "got: {err}");
1375
1376        // A Shift-JIS page is text: it must still come back decoded. Guarding on
1377        // UTF-8 validity instead of the header would have broken this.
1378        assert_eq!(sjis.unwrap(), "日本語");
1379    }
1380
1381    /// A body declaring itself larger than the cap is refused from the header,
1382    /// before `text()` allocates it. The 900 KB output cap runs *after* the read,
1383    /// so it was never a defence against this.
1384    #[test]
1385    fn oversized_declared_body_is_refused() {
1386        let msg = oversized_body_message(Some(999_999_999), 1_000).expect("should refuse");
1387        assert!(msg.contains("999999999"), "{msg}");
1388        assert!(msg.contains("1000-byte limit"), "{msg}");
1389    }
1390
1391    /// A body at or under the cap proceeds, and so does one with no declared
1392    /// length - a chunked response has none, and refusing every chunked page
1393    /// would break most of the web.
1394    #[test]
1395    fn body_within_cap_or_of_unknown_size_proceeds() {
1396        assert!(oversized_body_message(Some(1_000), 1_000).is_none());
1397        assert!(oversized_body_message(Some(0), 1_000).is_none());
1398        assert!(oversized_body_message(None, 1_000).is_none());
1399    }
1400
1401    /// The cap in the real `send` path, against a small response with the limit
1402    /// lowered - the 32 MiB production value would mean transferring 32 MiB to
1403    /// assert one branch.
1404    #[tokio::test(flavor = "multi_thread")]
1405    async fn send_refuses_a_body_over_the_cap() {
1406        let base = mock_http().await;
1407        let out = tokio::task::spawn_blocking(move || {
1408            let client = RealScriptIo::client();
1409            // `/ok` returns "GET-BODY" (8 bytes) with a Content-Length.
1410            RealScriptIo::send_capped(client.get(format!("{base}/ok")), 4)
1411        })
1412        .await
1413        .unwrap();
1414        let err = out.expect_err("a body over the cap is refused");
1415        assert!(err.contains("over the"), "got: {err}");
1416    }
1417
1418    /// A redirect is a fresh destination the caller's original URL check never
1419    /// saw, so the policy re-checks every hop. Here a public-looking request is
1420    /// bounced to loopback - the shape that turns any redirect-following fetch
1421    /// into an SSRF primitive.
1422    #[tokio::test(flavor = "multi_thread")]
1423    async fn redirects_to_a_local_address_are_refused() {
1424        use axum::Router;
1425        use axum::response::Redirect;
1426        use axum::routing::get;
1427
1428        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1429        let addr = listener.local_addr().unwrap();
1430        // Only `/bounce` is served: if the guard ever fails open, the request
1431        // 404s instead of succeeding, and the test still fails - but no handler
1432        // sits here unreached on the passing path.
1433        let app = Router::new().route(
1434            "/bounce",
1435            get(move || async move { Redirect::temporary(&format!("http://{addr}/ok")) }),
1436        );
1437        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1438            listener, app,
1439        )));
1440
1441        // The mirror is taken, read and restored entirely inside the blocking
1442        // closure: holding a `std` guard across an `.await` is a deadlock the
1443        // scheduler is free to arrange.
1444        let out = tokio::task::spawn_blocking(move || {
1445            let _guard = lock_redirect_mirror();
1446            let previous = local_network_allowed();
1447            set_local_network_allowed(false);
1448            let result = RealScriptIo.http_get(&format!("http://{addr}/bounce"), BTreeMap::new());
1449            set_local_network_allowed(previous);
1450            result
1451        })
1452        .await
1453        .unwrap();
1454        let err = out.expect_err("a redirect to loopback must not be followed");
1455        assert!(err.contains("refused to follow redirect"), "got: {err}");
1456    }
1457
1458    /// A redirect *loop* is bounded even when every hop is permitted, so a
1459    /// server cannot hold a fetch open by bouncing it forever.
1460    #[tokio::test(flavor = "multi_thread")]
1461    async fn a_redirect_loop_is_bounded() {
1462        use axum::Router;
1463        use axum::response::Redirect;
1464        use axum::routing::get;
1465
1466        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1467        let addr = listener.local_addr().unwrap();
1468        let app = Router::new().route(
1469            "/loop",
1470            get(move || async move { Redirect::temporary(&format!("http://{addr}/loop")) }),
1471        );
1472        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
1473            listener, app,
1474        )));
1475
1476        let out = tokio::task::spawn_blocking(move || {
1477            let _guard = lock_redirect_mirror();
1478            // Loopback hops are permitted here, so the *count* is what stops it.
1479            let previous = local_network_allowed();
1480            set_local_network_allowed(true);
1481            let result = RealScriptIo.http_get(&format!("http://{addr}/loop"), BTreeMap::new());
1482            set_local_network_allowed(previous);
1483            result
1484        })
1485        .await
1486        .unwrap();
1487        let err = out.expect_err("an endless redirect must be stopped");
1488        assert!(err.contains("too many redirects"), "got: {err}");
1489    }
1490
1491    /// The containment refusal, driven through the injected predicate so it is
1492    /// exercised on every platform. The `#[cfg(unix)]` test below proves the
1493    /// same refusal against a real symlink; this one proves the arm fires on
1494    /// Windows too, where a test cannot create one.
1495    #[test]
1496    fn resolve_in_refuses_a_path_that_does_not_resolve_within_the_workdir() {
1497        fn escapes(_: &Path, _: &Path) -> bool {
1498            false
1499        }
1500        let dir = tempfile::tempdir().unwrap();
1501        let err = DaemonScriptHost::resolve_in("notes.txt", dir.path(), escapes)
1502            .expect_err("a path that resolves outside must be refused");
1503        assert!(err.contains("symlink"), "{err}");
1504    }
1505
1506    /// The converse, so the test above is not passing merely because everything
1507    /// is refused.
1508    #[test]
1509    fn resolve_in_admits_an_ordinary_path_within_the_workdir() {
1510        let dir = tempfile::tempdir().unwrap();
1511        let resolved =
1512            DaemonScriptHost::resolve_in("notes.txt", dir.path(), leviath_core::resolves_within)
1513                .expect("an ordinary path resolves");
1514        assert!(resolved.ends_with("notes.txt"));
1515    }
1516
1517    /// The script host's own path confinement, mirroring `BuiltinTools`: a
1518    /// symlink inside the workdir that points outside it is refused.
1519    #[cfg(unix)]
1520    #[test]
1521    fn script_host_read_refuses_a_symlink_escape() {
1522        let dir = tempfile::tempdir().unwrap();
1523        let workdir = dir.path().join("workspace");
1524        std::fs::create_dir(&workdir).unwrap();
1525        std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
1526
1527        let host = DaemonScriptHost::with_io(all_allowed(), workdir, RecordingIo::arc());
1528        let err = host.read_file("link/etc/hosts").unwrap_err();
1529        assert!(err.contains("symlink"), "got: {err}");
1530    }
1531
1532    #[tokio::test(flavor = "multi_thread")]
1533    async fn real_http_get_success_and_headers() {
1534        let base = mock_http().await;
1535        let out = tokio::task::spawn_blocking(move || {
1536            let mut h = BTreeMap::new();
1537            h.insert("X-Test".to_string(), "1".to_string());
1538            RealScriptIo.http_get(&format!("{base}/ok"), h)
1539        })
1540        .await
1541        .unwrap();
1542        assert_eq!(out.unwrap(), "GET-BODY");
1543    }
1544
1545    #[tokio::test(flavor = "multi_thread")]
1546    async fn real_http_get_non_success_is_error() {
1547        let base = mock_http().await;
1548        let out = tokio::task::spawn_blocking(move || {
1549            RealScriptIo.http_get(&format!("{base}/boom"), BTreeMap::new())
1550        })
1551        .await
1552        .unwrap();
1553        let err = out.unwrap_err();
1554        assert!(
1555            err.contains("http 500") && err.contains("server error"),
1556            "got: {err}"
1557        );
1558    }
1559
1560    #[tokio::test(flavor = "multi_thread")]
1561    async fn real_http_get_connection_error() {
1562        // Nothing listening on this port → send() fails.
1563        let out = tokio::task::spawn_blocking(|| {
1564            RealScriptIo.http_get("http://127.0.0.1:1/x", BTreeMap::new())
1565        })
1566        .await
1567        .unwrap();
1568        assert!(out.unwrap_err().contains("request failed"));
1569    }
1570
1571    /// A raw TCP server that declares a larger Content-Length than it sends, then
1572    /// closes - so `resp.text()` errors on the incomplete body (mirrors the
1573    /// package-registry truncated-body test).
1574    async fn spawn_truncated_body_server() -> String {
1575        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1576        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1577        let addr = listener.local_addr().unwrap();
1578        let body = b"partial";
1579        let response = format!(
1580            "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1581            body.len() + 4096
1582        )
1583        .into_bytes();
1584        tokio::spawn(async move {
1585            let (mut socket, _) = listener.accept().await.unwrap();
1586            let mut buf = [0u8; 8192];
1587            let _ = socket.read(&mut buf).await;
1588            let _ = socket.write_all(&response).await;
1589            let _ = socket.write_all(body).await;
1590            let _ = socket.flush().await;
1591            let _ = socket.shutdown().await;
1592        });
1593        format!("http://{addr}")
1594    }
1595
1596    #[tokio::test(flavor = "multi_thread")]
1597    async fn real_http_body_read_error() {
1598        let base = spawn_truncated_body_server().await;
1599        let out = tokio::task::spawn_blocking(move || {
1600            RealScriptIo.http_get(&format!("{base}/x"), BTreeMap::new())
1601        })
1602        .await
1603        .unwrap();
1604        let err = out.unwrap_err();
1605        assert!(err.contains("read body"), "got: {err}");
1606    }
1607
1608    #[tokio::test(flavor = "multi_thread")]
1609    async fn real_http_post_echoes_body() {
1610        let base = mock_http().await;
1611        let out = tokio::task::spawn_blocking(move || {
1612            RealScriptIo.http_post(&format!("{base}/echo"), "hello", BTreeMap::new())
1613        })
1614        .await
1615        .unwrap();
1616        assert_eq!(out.unwrap(), "hello");
1617    }
1618
1619    /// Build a host command + run it through `run_shell` on a blocking thread
1620    /// (so its `Handle::block_on` isn't called from a runtime worker).
1621    async fn run_host_shell(
1622        command: &'static str,
1623        workdir: PathBuf,
1624        timeout: Duration,
1625    ) -> Result<String, String> {
1626        tokio::task::spawn_blocking(move || {
1627            let (shell, flag) = default_shell();
1628            let cmd = host_shell_command(shell, flag, command, &workdir);
1629            RealScriptIo.run_shell(cmd, timeout)
1630        })
1631        .await
1632        .unwrap()
1633    }
1634
1635    #[test]
1636    fn real_shell_off_a_runtime_errors_instead_of_panicking() {
1637        // A blocking thread can outlive runtime shutdown; `Handle::current()`
1638        // would panic there, and a panic inside a Rhai native call aborted the
1639        // whole daemon before issue #109 was fixed. A plain `std::thread` is
1640        // the same "no reactor on this thread" condition.
1641        let dir = tempfile::tempdir().unwrap();
1642        let workdir = dir.path().to_path_buf();
1643        let err = std::thread::spawn(move || {
1644            let (shell, flag) = default_shell();
1645            let cmd = host_shell_command(shell, flag, "echo hi", &workdir);
1646            RealScriptIo.run_shell(cmd, Duration::from_secs(5))
1647        })
1648        .join()
1649        .unwrap()
1650        .unwrap_err();
1651        assert!(err.contains("no tokio runtime"), "got: {err}");
1652    }
1653
1654    #[tokio::test(flavor = "multi_thread")]
1655    async fn real_shell_runs_and_captures_output() {
1656        let dir = tempfile::tempdir().unwrap();
1657        // stdout (empty-stderr arm of combine_shell_output)
1658        let out = run_host_shell(
1659            "echo hello",
1660            dir.path().to_path_buf(),
1661            Duration::from_secs(30),
1662        )
1663        .await
1664        .unwrap();
1665        assert!(out.contains("hello"));
1666        // stderr is appended (non-empty stderr arm)
1667        let out2 = run_host_shell(
1668            "echo oops 1>&2",
1669            dir.path().to_path_buf(),
1670            Duration::from_secs(30),
1671        )
1672        .await
1673        .unwrap();
1674        assert!(out2.contains("oops"));
1675    }
1676
1677    #[tokio::test(flavor = "multi_thread")]
1678    async fn real_shell_spawn_failure() {
1679        // A non-existent cwd makes the child fail to spawn → the Ok(Err) arm.
1680        let missing = PathBuf::from("/no/such/workdir/leviath");
1681        let err = run_host_shell("echo hi", missing, Duration::from_secs(30))
1682            .await
1683            .unwrap_err();
1684        assert!(err.contains("failed to spawn shell"), "got: {err}");
1685    }
1686
1687    #[tokio::test(flavor = "multi_thread")]
1688    async fn real_shell_times_out() {
1689        // A slow command against a tiny timeout hits the Err(_) (timeout) arm.
1690        let dir = tempfile::tempdir().unwrap();
1691        let err = run_host_shell(
1692            "sleep 5",
1693            dir.path().to_path_buf(),
1694            Duration::from_millis(50),
1695        )
1696        .await
1697        .unwrap_err();
1698        assert!(err.contains("timed out"), "got: {err}");
1699    }
1700
1701    #[test]
1702    fn combine_shell_output_appends_nonempty_stderr_only() {
1703        // Empty stderr → stdout unchanged; non-empty stderr → appended.
1704        assert_eq!(combine_shell_output(b"out", b"   "), "out");
1705        assert_eq!(combine_shell_output(b"out", b"err"), "outerr");
1706    }
1707
1708    #[test]
1709    fn host_shell_command_targets_workdir() {
1710        let cmd = host_shell_command("sh", "-c", "echo hi", Path::new("/w"));
1711        assert_eq!(cmd.as_std().get_program(), "sh");
1712    }
1713
1714    #[test]
1715    fn shell_routes_through_sandbox_when_present() {
1716        use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
1717        // A namespace sandbox with warn-fallback builds a manager on every
1718        // platform. Attaching it exercises the `Some(sandbox)` arm of `shell()`
1719        // (the command is built via the manager, not `host_shell_command`).
1720        let by_index = vec![ToolSandboxConfig {
1721            kind: SandboxKind::Namespace,
1722            on_unavailable: OnUnavailable::Warn,
1723            ..Default::default()
1724        }];
1725        let sb = SandboxManager::build("r", by_index, "/w", 0)
1726            .unwrap()
1727            .map(Arc::new);
1728        assert!(sb.is_some(), "namespace warn config yields a manager");
1729        let io = RecordingIo::arc();
1730        let host = DaemonScriptHost::with_io(all_allowed(), PathBuf::from("/w"), io.clone())
1731            .with_shell(sb, Duration::from_secs(5));
1732        assert_eq!(host.shell("ls").unwrap(), "s");
1733        assert!(
1734            io.calls
1735                .lock()
1736                .unwrap()
1737                .iter()
1738                .any(|c| c.starts_with("shell:"))
1739        );
1740    }
1741
1742    #[test]
1743    fn real_read_file_success_and_error() {
1744        let dir = tempfile::tempdir().unwrap();
1745        let p = dir.path().join("f.txt");
1746        std::fs::write(&p, "data").unwrap();
1747        assert_eq!(RealScriptIo.read_file(&p).unwrap(), "data");
1748        let err = RealScriptIo
1749            .read_file(&dir.path().join("nope"))
1750            .unwrap_err();
1751        assert!(err.contains("read '"));
1752    }
1753
1754    #[test]
1755    fn real_write_file_creates_parents_and_reports() {
1756        let dir = tempfile::tempdir().unwrap();
1757        // Nested path exercises the create_dir_all(Some(parent)) branch.
1758        let nested = dir.path().join("sub/deep/out.txt");
1759        let msg = RealScriptIo.write_file(&nested, "body").unwrap();
1760        assert!(msg.contains("wrote 4 bytes"), "got: {msg}");
1761        assert_eq!(std::fs::read_to_string(&nested).unwrap(), "body");
1762    }
1763
1764    #[test]
1765    fn real_write_file_create_dir_error() {
1766        let dir = tempfile::tempdir().unwrap();
1767        // A regular file where a parent directory is expected → create_dir_all fails.
1768        let blocker = dir.path().join("afile");
1769        std::fs::write(&blocker, "x").unwrap();
1770        let err = RealScriptIo
1771            .write_file(&blocker.join("child.txt"), "b")
1772            .unwrap_err();
1773        assert!(err.contains("create dir"), "got: {err}");
1774    }
1775
1776    #[test]
1777    fn real_write_file_write_error() {
1778        let dir = tempfile::tempdir().unwrap();
1779        // The path itself is an existing directory → std::fs::write fails.
1780        let err = RealScriptIo.write_file(dir.path(), "b").unwrap_err();
1781        assert!(err.contains("write '"), "got: {err}");
1782    }
1783
1784    #[test]
1785    fn real_write_file_parentless_path() {
1786        // An empty path has no parent → the `if let Some(parent)` None arm is
1787        // taken (no dir creation), then the write itself fails.
1788        let err = RealScriptIo.write_file(Path::new(""), "b").unwrap_err();
1789        assert!(err.contains("write '"), "got: {err}");
1790    }
1791
1792    #[test]
1793    fn real_env_var_set_and_unset() {
1794        temp_env::with_var("LEVIATH_SCRIPT_TEST", Some("v"), || {
1795            assert_eq!(RealScriptIo.env_var("LEVIATH_SCRIPT_TEST").unwrap(), "v");
1796        });
1797        temp_env::with_var_unset("LEVIATH_SCRIPT_TEST_UNSET", || {
1798            assert!(
1799                RealScriptIo
1800                    .env_var("LEVIATH_SCRIPT_TEST_UNSET")
1801                    .unwrap_err()
1802                    .contains("not set")
1803            );
1804        });
1805    }
1806
1807    #[test]
1808    fn default_shell_is_platform_appropriate() {
1809        let (shell, flag) = default_shell();
1810        assert!(!shell.is_empty());
1811        assert!(!flag.is_empty());
1812    }
1813
1814    #[test]
1815    fn new_wires_real_io() {
1816        // Construction path for the real backend (Arc<RealScriptIo>).
1817        let host = DaemonScriptHost::new(all_allowed(), std::env::temp_dir());
1818        // env_var goes through RealScriptIo; a guaranteed-unset var errors.
1819        temp_env::with_var_unset("LEVIATH_DEFINITELY_UNSET_XYZ", || {
1820            assert!(host.env_var("LEVIATH_DEFINITELY_UNSET_XYZ").is_err());
1821        });
1822    }
1823
1824    #[test]
1825    fn cap_script_io_leaves_small_strings_untouched() {
1826        let s = "small".to_string();
1827        assert_eq!(cap_script_io(s.clone()), s);
1828    }
1829
1830    #[test]
1831    fn cap_script_io_truncates_oversized_strings_below_the_rhai_limit() {
1832        let big = "x".repeat(MAX_SCRIPT_IO_BYTES + 5_000);
1833        let capped = cap_script_io(big);
1834        assert!(capped.len() < 1_000_000, "must stay under the 1MB Rhai cap");
1835        assert!(capped.contains("[...truncated by leviath"));
1836    }
1837
1838    #[test]
1839    fn cap_script_io_truncates_on_a_char_boundary() {
1840        // A multi-byte char straddling the cap must not be split mid-codepoint.
1841        let mut s = "a".repeat(MAX_SCRIPT_IO_BYTES - 1);
1842        s.push('é'); // 2 bytes, crossing the boundary
1843        s.push_str(&"b".repeat(10));
1844        let capped = cap_script_io(s);
1845        // Valid UTF-8 (would panic on construction if a codepoint were split).
1846        assert!(capped.contains("[...truncated by leviath"));
1847    }
1848}