use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use leviath_core::floor_char_boundary;
use leviath_scripting::ScriptHost;
use leviath_tools::ShellExecutor;
use tokio::process::Command as TokioCommand;
use crate::config::{ScriptPermission, ScriptToolPermissions, ToolPolicy};
use crate::daemon::sandbox_manager::SandboxManager;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScriptAllow {
pub http_get: bool,
pub http_post: bool,
pub shell: bool,
pub read_file: bool,
pub write_file: bool,
pub env_var: bool,
}
pub fn resolve_script_permissions(
perms: &ScriptToolPermissions,
resolve_builtin: &dyn Fn(&str) -> ToolPolicy,
) -> ScriptAllow {
let net = |p: ScriptPermission| match p {
ScriptPermission::Allow | ScriptPermission::Inherit => true,
ScriptPermission::Deny => false,
};
let filelike = |p: ScriptPermission, builtin: &str| match p {
ScriptPermission::Allow => true,
ScriptPermission::Deny => false,
ScriptPermission::Inherit => resolve_builtin(builtin) == ToolPolicy::Allow,
};
ScriptAllow {
http_get: net(perms.http_get),
http_post: net(perms.http_post),
env_var: net(perms.env_var),
read_file: filelike(perms.read_file, "read_file"),
write_file: filelike(perms.write_file, "write_file"),
shell: filelike(perms.shell, "shell"),
}
}
fn parse_script_permission_str(s: &str) -> Option<ScriptPermission> {
match s {
"allow" => Some(ScriptPermission::Allow),
"deny" => Some(ScriptPermission::Deny),
"inherit" => Some(ScriptPermission::Inherit),
_ => None,
}
}
fn script_restrictiveness(p: ScriptPermission) -> u8 {
match p {
ScriptPermission::Allow => 0,
ScriptPermission::Inherit => 1,
ScriptPermission::Deny => 2,
}
}
pub fn effective_script_permissions(
global: &ScriptToolPermissions,
manifest_toml: &str,
) -> ScriptToolPermissions {
let mut eff = global.clone();
let Ok(value) = toml::from_str::<toml::Value>(manifest_toml) else {
return eff;
};
let Some(table) = value
.get("tool_script_permissions")
.and_then(|v| v.as_table())
else {
return eff;
};
let apply = |key: &str, slot: &mut ScriptPermission| {
if let Some(p) = table
.get(key)
.and_then(|v| v.as_str())
.and_then(parse_script_permission_str)
&& script_restrictiveness(p) > script_restrictiveness(*slot)
{
*slot = p;
}
};
apply("http_get", &mut eff.http_get);
apply("http_post", &mut eff.http_post);
apply("shell", &mut eff.shell);
apply("read_file", &mut eff.read_file);
apply("write_file", &mut eff.write_file);
apply("env_var", &mut eff.env_var);
eff
}
pub trait ScriptIo: Send + Sync {
fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String>;
fn http_post(
&self,
url: &str,
body: &str,
headers: BTreeMap<String, String>,
) -> Result<String, String>;
fn run_shell(&self, cmd: TokioCommand, timeout: Duration) -> Result<String, String>;
fn read_file(&self, path: &Path) -> Result<String, String>;
fn write_file(&self, path: &Path, content: &str) -> Result<String, String>;
fn env_var(&self, name: &str) -> Result<String, String>;
}
pub struct DaemonScriptHost {
allow: ScriptAllow,
workdir: PathBuf,
io: Arc<dyn ScriptIo>,
sandbox: Option<Arc<SandboxManager>>,
shell_timeout: Duration,
allow_local_network: bool,
allow_env_vars: Vec<String>,
shell_env: leviath_tools::ShellEnvPolicy,
}
impl DaemonScriptHost {
pub fn with_io(allow: ScriptAllow, workdir: PathBuf, io: Arc<dyn ScriptIo>) -> Self {
Self {
allow,
workdir,
io,
sandbox: None,
shell_timeout: Duration::from_secs(60),
allow_local_network: false,
allow_env_vars: Vec::new(),
shell_env: leviath_tools::ShellEnvPolicy::default(),
}
}
pub fn with_local_network(mut self, allow: bool) -> Self {
self.allow_local_network = allow;
self
}
pub fn with_env_allowlist(mut self, names: Vec<String>) -> Self {
self.allow_env_vars = names;
self
}
pub fn new(allow: ScriptAllow, workdir: PathBuf) -> Self {
Self::with_io(allow, workdir, Arc::new(RealScriptIo))
}
pub fn with_shell(
mut self,
sandbox: Option<Arc<SandboxManager>>,
shell_timeout: Duration,
shell_env: leviath_tools::ShellEnvPolicy,
) -> Self {
self.sandbox = sandbox;
self.shell_timeout = shell_timeout;
self.shell_env = shell_env;
self
}
fn resolve_in_workdir(&self, requested: &str) -> Result<PathBuf, String> {
Self::resolve_in(requested, &self.workdir, leviath_core::resolves_within)
}
fn resolve_in(
requested: &str,
workdir: &Path,
within: fn(&Path, &Path) -> bool,
) -> Result<PathBuf, String> {
if leviath_tools::is_null_device(requested) {
return Ok(PathBuf::from(requested));
}
let raw = if Path::new(requested).is_absolute() {
PathBuf::from(requested)
} else {
workdir.join(requested)
};
let mut normalized = PathBuf::new();
for component in raw.components() {
match component {
Component::ParentDir => {
if !normalized.pop() {
return Err(format!("path '{requested}' escapes the working directory"));
}
}
c => normalized.push(c),
}
}
if !normalized.starts_with(workdir) {
return Err(format!(
"path '{requested}' would escape the working directory ({}). \
Use a path inside the workspace instead - a relative path \
resolves against it.",
workdir.display()
));
}
if !within(&normalized, workdir) {
return Err(format!(
"path '{requested}' resolves outside the working directory through a symlink"
));
}
Ok(normalized)
}
}
fn denied(func: &str) -> String {
format!("[denied] script host function '{func}' is denied by tool_script_permissions")
}
fn check_outbound(url: &str, allow_local: bool) -> Result<(), String> {
let parsed = url::Url::parse(url).map_err(|e| format!("[denied] invalid URL '{url}': {e}"))?;
leviath_net::check_url(&parsed, allow_local).map_err(|e| format!("[denied] {e}"))
}
impl ScriptHost for DaemonScriptHost {
fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String> {
if !self.allow.http_get {
return Err(denied("http_get"));
}
check_outbound(url, self.allow_local_network)?;
self.io.http_get(url, headers)
}
fn http_post(
&self,
url: &str,
body: &str,
headers: BTreeMap<String, String>,
) -> Result<String, String> {
if !self.allow.http_post {
return Err(denied("http_post"));
}
check_outbound(url, self.allow_local_network)?;
self.io.http_post(url, body, headers)
}
fn shell(&self, command: &str) -> Result<String, String> {
if !self.allow.shell {
return Err(denied("shell"));
}
if !self.allow.write_file && crate::shell_keys::writes_a_file(command) {
return Err(denied("write_file (a shell redirect writes a file)"));
}
if let Some(refusal) = crate::tools::escaping_write_refusal(
"shell",
&serde_json::json!({ "command": command }),
&self.workdir,
) {
return Err(refusal);
}
let (shell, flag) = default_shell();
let mut cmd = match &self.sandbox {
Some(sb) => sb.build_command(shell, flag, command, &self.workdir),
None => host_shell_command(shell, flag, command, &self.workdir),
};
self.shell_env.apply(&mut cmd);
self.io.run_shell(cmd, self.shell_timeout)
}
fn read_file(&self, path: &str) -> Result<String, String> {
if !self.allow.read_file {
return Err(denied("read_file"));
}
let resolved = self.resolve_in_workdir(path)?;
self.io.read_file(&resolved)
}
fn write_file(&self, path: &str, content: &str) -> Result<String, String> {
if !self.allow.write_file {
return Err(denied("write_file"));
}
if !std::fs::metadata(&self.workdir).is_ok_and(|m| m.is_dir()) {
return Err(format!(
"workspace '{}' is no longer accessible",
self.workdir.display()
));
}
let resolved = self.resolve_in_workdir(path)?;
self.io.write_file(&resolved, content)
}
fn env_var(&self, name: &str) -> Result<String, String> {
if !self.allow.env_var {
return Err(denied("env_var"));
}
if !leviath_core::script_env_allowed(name, &self.allow_env_vars) {
return Err(format!(
"[denied] '{name}' looks like a credential. Add it to `[security] \
allow_env_vars` in ~/.leviath/config.toml if this agent is meant \
to read it."
));
}
self.io.env_var(name)
}
}
pub struct RealScriptIo;
static HTTP_CLIENT: std::sync::LazyLock<reqwest::blocking::Client> =
std::sync::LazyLock::new(|| {
reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 5 {
return attempt.error("too many redirects");
}
match leviath_net::check_url(attempt.url(), local_network_allowed()) {
Ok(()) => attempt.follow(),
Err(e) => attempt.error(format!("refused to follow redirect: {e}")),
}
}))
.build()
.expect("failed to build blocking reqwest client")
});
fn error_chain(e: &dyn std::error::Error) -> String {
let mut parts = vec![e.to_string()];
let mut source = e.source();
while let Some(err) = source {
parts.push(err.to_string());
source = err.source();
}
parts.join(": ")
}
static ALLOW_LOCAL_REDIRECTS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn set_local_network_allowed(allow: bool) {
ALLOW_LOCAL_REDIRECTS.store(allow, std::sync::atomic::Ordering::Relaxed);
}
fn local_network_allowed() -> bool {
ALLOW_LOCAL_REDIRECTS.load(std::sync::atomic::Ordering::Relaxed)
}
impl RealScriptIo {
fn client() -> reqwest::blocking::Client {
HTTP_CLIENT.clone()
}
fn with_headers(
mut req: reqwest::blocking::RequestBuilder,
headers: BTreeMap<String, String>,
) -> reqwest::blocking::RequestBuilder {
for (k, v) in headers {
req = req.header(k, v);
}
req
}
fn send(req: reqwest::blocking::RequestBuilder) -> Result<String, String> {
Self::send_capped(req, MAX_RESPONSE_BYTES)
}
fn send_capped(req: reqwest::blocking::RequestBuilder, max: u64) -> Result<String, String> {
let resp = req
.send()
.map_err(|e| format!("request failed: {}", error_chain(&e)))?;
let status = resp.status();
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
if is_binary_content_type(&content_type) {
let len = resp.content_length();
return Err(non_text_body_message(&content_type, len));
}
if let Some(msg) = oversized_body_message(resp.content_length(), max) {
return Err(msg);
}
let text = cap_script_io(resp.text().map_err(|e| format!("read body: {e}"))?);
if status.is_success() {
Ok(text)
} else {
Err(format!("http {status}: {text}"))
}
}
}
const BINARY_CONTENT_PREFIXES: &[&str] = &[
"image/",
"audio/",
"video/",
"font/",
"application/octet-stream",
"application/pdf",
"application/zip",
"application/gzip",
"application/x-tar",
"application/x-bzip",
"application/wasm",
"application/vnd.",
"application/msword",
];
fn is_binary_content_type(content_type: &str) -> bool {
let essence = content_type
.split(';')
.next()
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
BINARY_CONTENT_PREFIXES
.iter()
.any(|prefix| essence.starts_with(prefix))
}
fn non_text_body_message(content_type: &str, len: Option<u64>) -> String {
let size = match len {
Some(bytes) => format!(", {} KB", bytes.div_ceil(1024)),
None => String::new(),
};
format!("non-text content ({content_type}{size}) - this tool returns text only")
}
const MAX_SCRIPT_IO_BYTES: usize = 900_000;
const MAX_RESPONSE_BYTES: u64 = 32 * 1024 * 1024;
fn oversized_body_message(content_length: Option<u64>, max: u64) -> Option<String> {
match content_length {
Some(len) if len > max => Some(format!(
"response declares {len} bytes, over the {max}-byte limit - \
fetch a more specific page"
)),
_ => None,
}
}
pub(crate) fn cap_script_io(mut s: String) -> String {
if s.len() > MAX_SCRIPT_IO_BYTES {
s.truncate(floor_char_boundary(&s, MAX_SCRIPT_IO_BYTES));
s.push_str("\n[...truncated by leviath: response exceeded 900 KB]");
}
s
}
impl ScriptIo for RealScriptIo {
fn http_get(&self, url: &str, headers: BTreeMap<String, String>) -> Result<String, String> {
let client = Self::client();
Self::send(Self::with_headers(client.get(url), headers))
}
fn http_post(
&self,
url: &str,
body: &str,
headers: BTreeMap<String, String>,
) -> Result<String, String> {
let client = Self::client();
Self::send(Self::with_headers(
client.post(url).body(body.to_string()),
headers,
))
}
fn run_shell(&self, mut cmd: TokioCommand, timeout: Duration) -> Result<String, String> {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return Err("shell is unavailable: no tokio runtime on this thread".to_string());
};
cmd.kill_on_drop(true);
leviath_tools::own_process_group(&mut cmd);
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
handle.block_on(async move {
let run = async {
let child = cmd.spawn()?;
let _reaper = child.id().map(leviath_tools::ProcessGroupReaper);
child.wait_with_output().await
};
match tokio::time::timeout(timeout, run).await {
Ok(Ok(output)) => Ok(cap_script_io(combine_shell_output(
&output.stdout,
&output.stderr,
))),
Ok(Err(e)) => Err(format!("failed to spawn shell: {e}")),
Err(_) => Err(format!(
"shell command timed out after {}s",
timeout.as_secs()
)),
}
})
}
fn read_file(&self, path: &Path) -> Result<String, String> {
std::fs::read_to_string(path)
.map(cap_script_io)
.map_err(|e| format!("read '{}': {e}", path.display()))
}
fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create dir '{}': {e}", parent.display()))?;
}
std::fs::write(path, content).map_err(|e| format!("write '{}': {e}", path.display()))?;
Ok(format!(
"wrote {} bytes to {}",
content.len(),
path.display()
))
}
fn env_var(&self, name: &str) -> Result<String, String> {
std::env::var(name).map_err(|_| format!("environment variable '{name}' is not set"))
}
}
pub(crate) fn default_shell() -> (&'static str, &'static str) {
default_shell_for(std::env::consts::OS)
}
pub(crate) fn default_shell_for(os: &str) -> (&'static str, &'static str) {
match os {
"windows" => ("cmd.exe", "/C"),
_ => ("/bin/sh", "-c"),
}
}
pub(crate) fn host_shell_command(
shell: &str,
flag: &str,
command: &str,
workdir: &Path,
) -> TokioCommand {
let mut c = leviath_sys::child_command_async(shell);
c.arg(flag).arg(command).current_dir(workdir);
c
}
pub(crate) fn combine_shell_output(stdout: &[u8], stderr: &[u8]) -> String {
let mut out = String::from_utf8_lossy(stdout).into_owned();
let err = String::from_utf8_lossy(stderr);
if !err.trim().is_empty() {
out.push_str(&err);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
fn perms(all: ScriptPermission) -> ScriptToolPermissions {
ScriptToolPermissions {
http_get: all,
http_post: all,
shell: all,
read_file: all,
write_file: all,
env_var: all,
}
}
#[test]
fn resolve_allow_permits_everything() {
let a = resolve_script_permissions(&perms(ScriptPermission::Allow), &|_| ToolPolicy::Deny);
assert_eq!(
a,
ScriptAllow {
http_get: true,
http_post: true,
shell: true,
read_file: true,
write_file: true,
env_var: true,
}
);
}
#[test]
fn resolve_deny_blocks_everything() {
let a = resolve_script_permissions(&perms(ScriptPermission::Deny), &|_| ToolPolicy::Allow);
assert_eq!(
a,
ScriptAllow {
http_get: false,
http_post: false,
shell: false,
read_file: false,
write_file: false,
env_var: false,
}
);
}
#[test]
fn resolve_inherit_net_true_filelike_follows_builtin() {
let a = resolve_script_permissions(&ScriptToolPermissions::default(), &|name| match name {
"read_file" => ToolPolicy::Allow,
_ => ToolPolicy::Ask,
});
assert!(a.http_get && a.http_post && a.env_var);
assert!(a.read_file, "read_file inherit → Allow");
assert!(!a.write_file, "write_file inherit → Ask ⇒ denied");
assert!(!a.shell, "shell inherit → Ask ⇒ denied");
}
#[test]
fn effective_perms_agent_tightens_per_field() {
let global = perms(ScriptPermission::Allow);
let manifest = "\
[tool_script_permissions]\n\
http_get = \"allow\"\n\
shell = \"deny\"\n\
write_file = \"inherit\"\n";
let eff = effective_script_permissions(&global, manifest);
assert_eq!(eff.http_get, ScriptPermission::Allow, "allow arm");
assert_eq!(eff.shell, ScriptPermission::Deny, "deny arm");
assert_eq!(eff.write_file, ScriptPermission::Inherit, "inherit arm");
assert_eq!(eff.env_var, ScriptPermission::Allow, "unset keeps global");
assert_eq!(eff.read_file, ScriptPermission::Allow);
assert_eq!(eff.http_post, ScriptPermission::Allow);
}
#[test]
fn effective_perms_agent_cannot_loosen_global() {
let global = perms(ScriptPermission::Deny);
let manifest = "\
[tool_script_permissions]\n\
http_get = \"allow\"\n\
shell = \"allow\"\n\
env_var = \"inherit\"\n";
let eff = effective_script_permissions(&global, manifest);
assert_eq!(eff.http_get, ScriptPermission::Deny);
assert_eq!(eff.shell, ScriptPermission::Deny);
assert_eq!(eff.env_var, ScriptPermission::Deny);
}
#[test]
fn effective_perms_agent_cannot_promote_inherit_to_allow() {
let global = perms(ScriptPermission::Inherit);
let manifest = "[tool_script_permissions]\nshell = \"allow\"\n";
let eff = effective_script_permissions(&global, manifest);
assert_eq!(eff.shell, ScriptPermission::Inherit);
}
#[test]
fn effective_perms_absent_section_keeps_global() {
let global = perms(ScriptPermission::Deny);
let eff = effective_script_permissions(&global, "[agent]\nname = \"x\"");
assert_eq!(eff.shell, ScriptPermission::Deny);
assert_eq!(eff.http_get, ScriptPermission::Deny);
}
#[test]
fn effective_perms_malformed_inputs_fall_back_to_global() {
let global = perms(ScriptPermission::Allow);
let eff = effective_script_permissions(&global, "not = valid = toml");
assert_eq!(eff.shell, ScriptPermission::Allow);
let eff2 = effective_script_permissions(&global, "tool_script_permissions = 5");
assert_eq!(eff2.shell, ScriptPermission::Allow);
let eff3 =
effective_script_permissions(&global, "[tool_script_permissions]\nshell = \"maybe\"");
assert_eq!(eff3.shell, ScriptPermission::Allow);
}
struct RecordingIo {
calls: Mutex<Vec<String>>,
}
impl RecordingIo {
fn arc() -> Arc<RecordingIo> {
Arc::new(RecordingIo {
calls: Mutex::new(Vec::new()),
})
}
}
impl ScriptIo for RecordingIo {
fn http_get(&self, url: &str, _h: BTreeMap<String, String>) -> Result<String, String> {
self.calls.lock().unwrap().push(format!("get:{url}"));
Ok("g".into())
}
fn http_post(
&self,
url: &str,
body: &str,
_h: BTreeMap<String, String>,
) -> Result<String, String> {
self.calls
.lock()
.unwrap()
.push(format!("post:{url}:{body}"));
Ok("p".into())
}
fn run_shell(&self, cmd: TokioCommand, _timeout: Duration) -> Result<String, String> {
let prog = cmd.as_std().get_program().to_string_lossy().into_owned();
self.calls.lock().unwrap().push(format!("shell:{prog}"));
Ok("s".into())
}
fn read_file(&self, path: &Path) -> Result<String, String> {
self.calls
.lock()
.unwrap()
.push(format!("read:{}", path.display()));
Ok("r".into())
}
fn write_file(&self, path: &Path, content: &str) -> Result<String, String> {
self.calls
.lock()
.unwrap()
.push(format!("write:{}:{content}", path.display()));
Ok("w".into())
}
fn env_var(&self, name: &str) -> Result<String, String> {
self.calls.lock().unwrap().push(format!("env:{name}"));
Ok("e".into())
}
}
fn all_allowed() -> ScriptAllow {
ScriptAllow {
http_get: true,
http_post: true,
shell: true,
read_file: true,
write_file: true,
env_var: true,
}
}
fn none_allowed() -> ScriptAllow {
ScriptAllow {
http_get: false,
http_post: false,
shell: false,
read_file: false,
write_file: false,
env_var: false,
}
}
#[test]
fn a_script_shell_redirect_answers_to_the_write_permission() {
let io = RecordingIo::arc();
let allow = ScriptAllow {
write_file: false,
..all_allowed()
};
let host = DaemonScriptHost::with_io(allow, std::env::temp_dir(), io.clone());
let err = host
.shell("echo pwn > /root/.bashrc")
.expect_err("a redirect must answer to the write permission");
assert!(err.contains("write_file"), "got: {err}");
host.shell("echo pwn").expect("a non-writing shell is fine");
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
host.shell("echo pwn > x")
.expect("a permitted write is not clamped");
}
#[test]
fn a_script_shell_redirect_stays_inside_the_workdir() {
let dir = tempfile::tempdir().unwrap();
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
let err = host
.shell("echo pwn > /root/.bashrc")
.expect_err("an escaping redirect is refused even with writes allowed");
assert!(err.contains("outside the working directory"), "got: {err}");
host.shell("echo ok > inside.txt")
.expect("a redirect inside the workdir runs");
}
#[test]
fn script_write_refuses_a_deleted_workspace() {
let dir = tempfile::tempdir().unwrap();
let workdir = dir.path().join("gone");
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), workdir.clone(), io.clone());
let err = host.write_file("out.txt", "body").unwrap_err();
assert!(err.contains("no longer accessible"), "got: {err}");
assert!(
io.calls.lock().unwrap().is_empty(),
"the io layer never ran"
);
std::fs::create_dir(&workdir).unwrap();
assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
}
const PUBLIC_URL: &str = "http://93.184.216.34/";
#[test]
fn allowed_calls_delegate_to_io() {
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
assert_eq!(host.http_get(PUBLIC_URL, BTreeMap::new()).unwrap(), "g");
assert_eq!(
host.http_post(PUBLIC_URL, "b", BTreeMap::new()).unwrap(),
"p"
);
assert_eq!(host.shell("ls").unwrap(), "s");
assert_eq!(host.write_file("out.txt", "body").unwrap(), "w");
assert_eq!(host.env_var("HOME").unwrap(), "e");
let calls = io.calls.lock().unwrap().clone();
assert!(calls.contains(&format!("get:{PUBLIC_URL}")));
assert!(calls.iter().any(|c| c.starts_with("post:")));
assert!(calls.iter().any(|c| c.starts_with("shell:")));
assert!(
calls
.iter()
.any(|c| c.starts_with("write:") && c.ends_with(":body"))
);
assert!(calls.contains(&"env:HOME".to_string()));
}
#[test]
fn outbound_check_blocks_local_targets_before_any_io() {
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
for url in [
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"http://127.0.0.1:3000/api/agents",
"http://192.168.1.1/",
"file:///etc/passwd",
] {
let err = host.http_get(url, BTreeMap::new()).unwrap_err();
assert!(err.starts_with("[denied]"), "{url} → {err}");
let err = host.http_post(url, "leak", BTreeMap::new()).unwrap_err();
assert!(err.starts_with("[denied]"), "{url} → {err}");
}
let calls = io.calls.lock().unwrap().clone();
assert!(
calls.is_empty(),
"a refused URL must never reach the I/O backend: {calls:?}"
);
}
#[test]
fn env_var_refuses_credential_names_by_default() {
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
for name in [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"AWS_SECRET_ACCESS_KEY",
"GITHUB_TOKEN",
"LEVIATH_API_TOKEN",
] {
let err = host.env_var(name).unwrap_err();
assert!(err.starts_with("[denied]"), "{name} → {err}");
assert!(err.contains("allow_env_vars"), "{name} → {err}");
}
assert!(
io.calls.lock().unwrap().is_empty(),
"a refused read must never reach the I/O backend"
);
}
#[test]
fn env_var_allows_ordinary_names() {
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
assert_eq!(host.env_var("PATH").unwrap(), "e");
assert_eq!(host.env_var("MY_APP_REGION").unwrap(), "e");
}
#[test]
fn env_var_allowlist_permits_exactly_the_named_variable() {
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
.with_env_allowlist(vec!["MY_PROVIDER_KEY".to_string()]);
assert_eq!(host.env_var("MY_PROVIDER_KEY").unwrap(), "e");
assert!(host.env_var("ANTHROPIC_API_KEY").is_err());
}
#[test]
fn outbound_check_rejects_unparseable_urls() {
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
let err = host.http_get("not a url", BTreeMap::new()).unwrap_err();
assert!(err.contains("invalid URL"), "{err}");
assert!(io.calls.lock().unwrap().is_empty());
}
#[test]
fn allow_local_network_opens_the_local_path() {
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone())
.with_local_network(true);
assert_eq!(
host.http_get("http://127.0.0.1:11434/api/tags", BTreeMap::new())
.unwrap(),
"g"
);
assert!(
host.http_get("file:///etc/passwd", BTreeMap::new())
.is_err()
);
}
static REDIRECT_MIRROR: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn lock_redirect_mirror() -> std::sync::MutexGuard<'static, ()> {
REDIRECT_MIRROR.lock().expect("redirect mirror lock")
}
#[test]
fn redirect_switch_is_independent_of_the_host_field() {
let _guard = lock_redirect_mirror();
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), std::env::temp_dir(), io.clone());
let previous = local_network_allowed();
set_local_network_allowed(true);
let decided = host.http_get("http://127.0.0.1:9/", BTreeMap::new());
set_local_network_allowed(previous);
assert!(
decided.is_err(),
"the host field, not the redirect mirror, decides the initial URL"
);
}
#[test]
fn denied_calls_return_denied_and_skip_io() {
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(none_allowed(), std::env::temp_dir(), io.clone());
assert!(
host.http_get("http://x", BTreeMap::new())
.unwrap_err()
.contains("[denied]")
);
assert!(
host.http_post("http://x", "b", BTreeMap::new())
.unwrap_err()
.contains("http_post")
);
assert!(host.shell("ls").unwrap_err().contains("shell"));
assert!(host.read_file("a.txt").unwrap_err().contains("read_file"));
assert!(
host.write_file("a.txt", "b")
.unwrap_err()
.contains("write_file")
);
assert!(host.env_var("X").unwrap_err().contains("env_var"));
assert!(
io.calls.lock().unwrap().is_empty(),
"no I/O on denied calls"
);
}
#[test]
fn read_file_confined_to_workdir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("ok.txt"), "hi").unwrap();
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), io.clone());
assert_eq!(host.read_file("ok.txt").unwrap(), "r");
assert_eq!(host.write_file("ok.txt", "x").unwrap(), "w");
let err = host.read_file("../../etc/passwd").unwrap_err();
assert!(err.contains("escape"));
let werr = host.write_file("../../etc/passwd", "x").unwrap_err();
assert!(werr.contains("escape"));
let calls = io.calls.lock().unwrap().clone();
assert_eq!(calls.len(), 2);
assert!(calls.iter().any(|c| c.starts_with("read:")));
assert!(calls.iter().any(|c| c.starts_with("write:")));
}
#[test]
fn read_file_absolute_outside_workdir_rejected() {
let dir = tempfile::tempdir().unwrap();
let host =
DaemonScriptHost::with_io(all_allowed(), dir.path().to_path_buf(), RecordingIo::arc());
let outside = std::env::temp_dir().join("leviath-abs-outside-xyz");
assert!(outside.is_absolute(), "test path must be absolute");
let err = host.read_file(outside.to_str().unwrap()).unwrap_err();
assert!(err.contains("would escape"), "got: {err}");
}
#[test]
fn read_file_pop_past_root_rejected() {
let host =
DaemonScriptHost::with_io(all_allowed(), PathBuf::from("wd"), RecordingIo::arc());
let err = host.read_file("../..").unwrap_err();
assert!(err.contains("escapes the working directory"), "got: {err}");
}
async fn mock_http() -> String {
use axum::Router;
use axum::routing::{get, post};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let app = Router::new()
.route("/ok", get(|| async { "GET-BODY" }))
.route("/echo", post(|body: String| async move { body }))
.route(
"/boom",
get(|| async {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"server error",
)
}),
)
.route(
"/png",
get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "image/png")],
vec![0x89u8, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe],
)
}),
)
.route(
"/shiftjis",
get(|| async {
(
[(
axum::http::header::CONTENT_TYPE,
"text/html; charset=shift_jis",
)],
vec![0x93u8, 0xfa, 0x96, 0x7b, 0x8c, 0xea],
)
}),
);
tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
listener, app,
)));
base
}
#[test]
fn binary_content_types_are_classified_but_structured_text_is_not() {
for text in [
"",
"text/html; charset=utf-8",
"text/plain",
"application/json",
"application/xml",
"application/xhtml+xml",
"application/ld+json",
"application/javascript",
] {
assert!(!is_binary_content_type(text), "should be text: {text:?}");
}
for binary in [
"image/png",
"IMAGE/PNG",
"image/jpeg; charset=binary",
" audio/mpeg ",
"video/mp4",
"font/woff2",
"application/octet-stream",
"application/pdf",
"application/zip",
"application/gzip",
"application/x-tar",
"application/x-bzip2",
"application/wasm",
"application/vnd.ms-excel",
"application/msword",
] {
assert!(
is_binary_content_type(binary),
"should be binary: {binary:?}"
);
}
}
#[test]
fn the_non_text_diagnostic_names_the_type_and_size_when_known() {
let with_len = non_text_body_message("image/png", Some(2049));
assert!(with_len.contains("image/png"), "got: {with_len}");
assert!(with_len.contains("3 KB"), "rounds up: {with_len}");
let without_len = non_text_body_message("audio/mpeg", None);
assert!(without_len.contains("audio/mpeg"), "got: {without_len}");
assert!(
!without_len.contains("KB"),
"no size to report: {without_len}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn binary_bodies_are_refused_and_non_utf8_text_still_decodes() {
let base = mock_http().await;
let (png, sjis) = tokio::task::spawn_blocking(move || {
(
RealScriptIo.http_get(&format!("{base}/png"), BTreeMap::new()),
RealScriptIo.http_get(&format!("{base}/shiftjis"), BTreeMap::new()),
)
})
.await
.unwrap();
let err = png.unwrap_err();
assert!(err.contains("non-text content"), "got: {err}");
assert!(err.contains("image/png"), "got: {err}");
assert_eq!(sjis.unwrap(), "日本語");
}
#[test]
fn oversized_declared_body_is_refused() {
let msg = oversized_body_message(Some(999_999_999), 1_000).expect("should refuse");
assert!(msg.contains("999999999"), "{msg}");
assert!(msg.contains("1000-byte limit"), "{msg}");
}
#[test]
fn body_within_cap_or_of_unknown_size_proceeds() {
assert!(oversized_body_message(Some(1_000), 1_000).is_none());
assert!(oversized_body_message(Some(0), 1_000).is_none());
assert!(oversized_body_message(None, 1_000).is_none());
}
#[tokio::test(flavor = "multi_thread")]
async fn send_refuses_a_body_over_the_cap() {
let base = mock_http().await;
let out = tokio::task::spawn_blocking(move || {
let client = RealScriptIo::client();
RealScriptIo::send_capped(client.get(format!("{base}/ok")), 4)
})
.await
.unwrap();
let err = out.expect_err("a body over the cap is refused");
assert!(err.contains("over the"), "got: {err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn redirects_to_a_local_address_are_refused() {
use axum::Router;
use axum::response::Redirect;
use axum::routing::get;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = Router::new().route(
"/bounce",
get(move || async move { Redirect::temporary(&format!("http://{addr}/ok")) }),
);
tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
listener, app,
)));
let out = tokio::task::spawn_blocking(move || {
let _guard = lock_redirect_mirror();
let previous = local_network_allowed();
set_local_network_allowed(false);
let result = RealScriptIo.http_get(&format!("http://{addr}/bounce"), BTreeMap::new());
set_local_network_allowed(previous);
result
})
.await
.unwrap();
let err = out.expect_err("a redirect to loopback must not be followed");
assert!(err.contains("refused to follow redirect"), "got: {err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn a_redirect_loop_is_bounded() {
use axum::Router;
use axum::response::Redirect;
use axum::routing::get;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = Router::new().route(
"/loop",
get(move || async move { Redirect::temporary(&format!("http://{addr}/loop")) }),
);
tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
listener, app,
)));
let out = tokio::task::spawn_blocking(move || {
let _guard = lock_redirect_mirror();
let previous = local_network_allowed();
set_local_network_allowed(true);
let result = RealScriptIo.http_get(&format!("http://{addr}/loop"), BTreeMap::new());
set_local_network_allowed(previous);
result
})
.await
.unwrap();
let err = out.expect_err("an endless redirect must be stopped");
assert!(err.contains("too many redirects"), "got: {err}");
}
#[test]
fn resolve_in_refuses_a_path_that_does_not_resolve_within_the_workdir() {
fn escapes(_: &Path, _: &Path) -> bool {
false
}
let dir = tempfile::tempdir().unwrap();
let err = DaemonScriptHost::resolve_in("notes.txt", dir.path(), escapes)
.expect_err("a path that resolves outside must be refused");
assert!(err.contains("symlink"), "{err}");
}
#[test]
fn resolve_in_admits_the_null_device() {
let dir = tempfile::tempdir().unwrap();
let resolved =
DaemonScriptHost::resolve_in("/dev/null", dir.path(), leviath_core::resolves_within)
.expect("the null device is not an escape");
assert_eq!(resolved, PathBuf::from("/dev/null"));
}
#[test]
fn resolve_in_admits_an_ordinary_path_within_the_workdir() {
let dir = tempfile::tempdir().unwrap();
let resolved =
DaemonScriptHost::resolve_in("notes.txt", dir.path(), leviath_core::resolves_within)
.expect("an ordinary path resolves");
assert!(resolved.ends_with("notes.txt"));
}
#[cfg(unix)]
#[test]
fn script_host_read_refuses_a_symlink_escape() {
let dir = tempfile::tempdir().unwrap();
let workdir = dir.path().join("workspace");
std::fs::create_dir(&workdir).unwrap();
std::os::unix::fs::symlink("/", workdir.join("link")).unwrap();
let host = DaemonScriptHost::with_io(all_allowed(), workdir, RecordingIo::arc());
let err = host.read_file("link/etc/hosts").unwrap_err();
assert!(err.contains("symlink"), "got: {err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn real_http_get_success_and_headers() {
let base = mock_http().await;
let out = tokio::task::spawn_blocking(move || {
let mut h = BTreeMap::new();
h.insert("X-Test".to_string(), "1".to_string());
RealScriptIo.http_get(&format!("{base}/ok"), h)
})
.await
.unwrap();
assert_eq!(out.unwrap(), "GET-BODY");
}
#[tokio::test(flavor = "multi_thread")]
async fn real_http_get_non_success_is_error() {
let base = mock_http().await;
let out = tokio::task::spawn_blocking(move || {
RealScriptIo.http_get(&format!("{base}/boom"), BTreeMap::new())
})
.await
.unwrap();
let err = out.unwrap_err();
assert!(
err.contains("http 500") && err.contains("server error"),
"got: {err}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn real_http_get_connection_error() {
let out = tokio::task::spawn_blocking(|| {
RealScriptIo.http_get("http://127.0.0.1:1/x", BTreeMap::new())
})
.await
.unwrap();
assert!(out.unwrap_err().contains("request failed"));
}
async fn spawn_truncated_body_server() -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let body = b"partial";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len() + 4096
)
.into_bytes();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 8192];
let _ = socket.read(&mut buf).await;
let _ = socket.write_all(&response).await;
let _ = socket.write_all(body).await;
let _ = socket.flush().await;
let _ = socket.shutdown().await;
});
format!("http://{addr}")
}
#[tokio::test(flavor = "multi_thread")]
async fn real_http_body_read_error() {
let base = spawn_truncated_body_server().await;
let out = tokio::task::spawn_blocking(move || {
RealScriptIo.http_get(&format!("{base}/x"), BTreeMap::new())
})
.await
.unwrap();
let err = out.unwrap_err();
assert!(err.contains("read body"), "got: {err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn real_http_post_echoes_body() {
let base = mock_http().await;
let out = tokio::task::spawn_blocking(move || {
RealScriptIo.http_post(&format!("{base}/echo"), "hello", BTreeMap::new())
})
.await
.unwrap();
assert_eq!(out.unwrap(), "hello");
}
async fn run_host_shell(
command: &'static str,
workdir: PathBuf,
timeout: Duration,
) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
let (shell, flag) = default_shell();
let cmd = host_shell_command(shell, flag, command, &workdir);
RealScriptIo.run_shell(cmd, timeout)
})
.await
.unwrap()
}
#[test]
fn real_shell_off_a_runtime_errors_instead_of_panicking() {
let dir = tempfile::tempdir().unwrap();
let workdir = dir.path().to_path_buf();
let err = std::thread::spawn(move || {
let (shell, flag) = default_shell();
let cmd = host_shell_command(shell, flag, "echo hi", &workdir);
RealScriptIo.run_shell(cmd, Duration::from_secs(5))
})
.join()
.unwrap()
.unwrap_err();
assert!(err.contains("no tokio runtime"), "got: {err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn real_shell_runs_and_captures_output() {
let dir = tempfile::tempdir().unwrap();
let out = run_host_shell(
"echo hello",
dir.path().to_path_buf(),
Duration::from_secs(30),
)
.await
.unwrap();
assert!(out.contains("hello"));
let out2 = run_host_shell(
"echo oops 1>&2",
dir.path().to_path_buf(),
Duration::from_secs(30),
)
.await
.unwrap();
assert!(out2.contains("oops"));
}
#[tokio::test(flavor = "multi_thread")]
async fn real_shell_spawn_failure() {
let missing = PathBuf::from("/no/such/workdir/leviath");
let err = run_host_shell("echo hi", missing, Duration::from_secs(30))
.await
.unwrap_err();
assert!(err.contains("failed to spawn shell"), "got: {err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn real_shell_times_out() {
let dir = tempfile::tempdir().unwrap();
let err = run_host_shell(
"sleep 5",
dir.path().to_path_buf(),
Duration::from_millis(50),
)
.await
.unwrap_err();
assert!(err.contains("timed out"), "got: {err}");
}
#[test]
fn combine_shell_output_appends_nonempty_stderr_only() {
assert_eq!(combine_shell_output(b"out", b" "), "out");
assert_eq!(combine_shell_output(b"out", b"err"), "outerr");
}
#[test]
fn host_shell_command_targets_workdir() {
let cmd = host_shell_command("sh", "-c", "echo hi", Path::new("/w"));
assert_eq!(cmd.as_std().get_program(), "sh");
}
#[test]
fn shell_routes_through_sandbox_when_present() {
use leviath_core::sandbox::{OnUnavailable, SandboxKind, ToolSandboxConfig};
let by_index = vec![ToolSandboxConfig {
kind: SandboxKind::Namespace,
on_unavailable: OnUnavailable::Warn,
..Default::default()
}];
let sb = SandboxManager::build("r", by_index, "/w", 0)
.unwrap()
.map(Arc::new);
assert!(sb.is_some(), "namespace warn config yields a manager");
let io = RecordingIo::arc();
let host = DaemonScriptHost::with_io(all_allowed(), PathBuf::from("/w"), io.clone())
.with_shell(sb, Duration::from_secs(5), Default::default());
assert_eq!(host.shell("ls").unwrap(), "s");
assert!(
io.calls
.lock()
.unwrap()
.iter()
.any(|c| c.starts_with("shell:"))
);
}
#[test]
fn real_read_file_success_and_error() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("f.txt");
std::fs::write(&p, "data").unwrap();
assert_eq!(RealScriptIo.read_file(&p).unwrap(), "data");
let err = RealScriptIo
.read_file(&dir.path().join("nope"))
.unwrap_err();
assert!(err.contains("read '"));
}
#[test]
fn real_write_file_creates_parents_and_reports() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("sub/deep/out.txt");
let msg = RealScriptIo.write_file(&nested, "body").unwrap();
assert!(msg.contains("wrote 4 bytes"), "got: {msg}");
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "body");
}
#[test]
fn real_write_file_create_dir_error() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("afile");
std::fs::write(&blocker, "x").unwrap();
let err = RealScriptIo
.write_file(&blocker.join("child.txt"), "b")
.unwrap_err();
assert!(err.contains("create dir"), "got: {err}");
}
#[test]
fn real_write_file_write_error() {
let dir = tempfile::tempdir().unwrap();
let err = RealScriptIo.write_file(dir.path(), "b").unwrap_err();
assert!(err.contains("write '"), "got: {err}");
}
#[test]
fn real_write_file_parentless_path() {
let err = RealScriptIo.write_file(Path::new(""), "b").unwrap_err();
assert!(err.contains("write '"), "got: {err}");
}
#[test]
fn real_env_var_set_and_unset() {
temp_env::with_var("LEVIATH_SCRIPT_TEST", Some("v"), || {
assert_eq!(RealScriptIo.env_var("LEVIATH_SCRIPT_TEST").unwrap(), "v");
});
temp_env::with_var_unset("LEVIATH_SCRIPT_TEST_UNSET", || {
assert!(
RealScriptIo
.env_var("LEVIATH_SCRIPT_TEST_UNSET")
.unwrap_err()
.contains("not set")
);
});
}
#[test]
fn default_shell_is_platform_appropriate() {
let (shell, flag) = default_shell();
assert!(!shell.is_empty());
assert!(!flag.is_empty());
}
#[test]
fn default_shell_for_answers_per_platform() {
assert_eq!(default_shell_for("windows"), ("cmd.exe", "/C"));
for posix in ["linux", "macos", "freebsd", "haiku"] {
assert_eq!(default_shell_for(posix), ("/bin/sh", "-c"), "{posix}");
}
}
#[test]
fn new_wires_real_io() {
let host = DaemonScriptHost::new(all_allowed(), std::env::temp_dir());
temp_env::with_var_unset("LEVIATH_DEFINITELY_UNSET_XYZ", || {
assert!(host.env_var("LEVIATH_DEFINITELY_UNSET_XYZ").is_err());
});
}
#[test]
fn cap_script_io_leaves_small_strings_untouched() {
let s = "small".to_string();
assert_eq!(cap_script_io(s.clone()), s);
}
#[test]
fn cap_script_io_truncates_oversized_strings_below_the_rhai_limit() {
let big = "x".repeat(MAX_SCRIPT_IO_BYTES + 5_000);
let capped = cap_script_io(big);
assert!(capped.len() < 1_000_000, "must stay under the 1MB Rhai cap");
assert!(capped.contains("[...truncated by leviath"));
}
#[test]
fn cap_script_io_truncates_on_a_char_boundary() {
let mut s = "a".repeat(MAX_SCRIPT_IO_BYTES - 1);
s.push('é'); s.push_str(&"b".repeat(10));
let capped = cap_script_io(s);
assert!(capped.contains("[...truncated by leviath"));
}
}