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