use std::path::{Path, PathBuf};
use std::process::Command;
use crate::effects::{EffectRow, Resource};
pub const RUNNER_ENV: &str = "WM_SANDBOX_RUNNER";
pub const RUNNER_PROGRAM: &str = "mandala-sandbox";
pub const ENVELOPE_SCHEMA: &str = "wm-sandbox-exec-v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunnerSource {
Env,
Path,
}
impl RunnerSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Env => "env",
Self::Path => "path",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunnerInfo {
pub path: PathBuf,
pub source: RunnerSource,
}
const DISABLED_TOKENS: &[&str] = &["0", "false", "off", "none"];
#[derive(Debug, Clone, PartialEq, Eq)]
enum EnvRunner {
Unset,
Disabled,
Path(PathBuf),
}
fn classify_env(raw: &str) -> EnvRunner {
let trimmed = raw.trim();
if trimmed.is_empty()
|| DISABLED_TOKENS
.iter()
.any(|v| trimmed.eq_ignore_ascii_case(v))
{
return EnvRunner::Disabled;
}
let path = PathBuf::from(trimmed);
if path.is_file() {
EnvRunner::Path(path)
} else {
tracing::warn!(
value = trimmed,
"WM_SANDBOX_RUNNER does not name a file — subprocess sandbox disabled"
);
EnvRunner::Disabled
}
}
fn env_runner() -> EnvRunner {
std::env::var(RUNNER_ENV).map_or(EnvRunner::Unset, |raw| classify_env(&raw))
}
fn path_runner_in(path_var: &std::ffi::OsStr) -> Option<RunnerInfo> {
std::env::split_paths(path_var)
.map(|dir| dir.join(RUNNER_PROGRAM))
.find(|candidate| candidate.is_file())
.map(|path| RunnerInfo {
path,
source: RunnerSource::Path,
})
}
fn path_runner() -> Option<RunnerInfo> {
std::env::var_os("PATH").and_then(|path_var| path_runner_in(&path_var))
}
#[must_use]
pub fn detect_runner() -> Option<RunnerInfo> {
match env_runner() {
EnvRunner::Path(path) => Some(RunnerInfo {
path,
source: RunnerSource::Env,
}),
EnvRunner::Disabled => None,
EnvRunner::Unset => path_runner(),
}
}
#[must_use]
pub fn net_grant(effects: &EffectRow) -> bool {
effects
.reads
.iter()
.chain(effects.writes.iter())
.any(|r| matches!(r, Resource::Network))
}
#[derive(Debug, Clone, Default)]
pub struct SpawnPolicy {
runner: Option<PathBuf>,
allow_net: bool,
}
impl SpawnPolicy {
#[must_use]
pub fn disabled() -> Self {
Self::default()
}
#[must_use]
pub const fn from_runner(runner: Option<PathBuf>, allow_net: bool) -> Self {
Self { runner, allow_net }
}
#[must_use]
pub fn detected(allow_net: bool) -> Self {
Self::from_runner(detect_runner().map(|r| r.path), allow_net)
}
#[must_use]
pub fn for_effects(effects: &EffectRow) -> Self {
Self::detected(net_grant(effects))
}
#[must_use]
pub const fn is_active(&self) -> bool {
self.runner.is_some()
}
#[must_use]
pub fn runner(&self) -> Option<&Path> {
self.runner.as_deref()
}
#[must_use]
pub const fn allow_net(&self) -> bool {
self.allow_net
}
#[must_use]
pub fn envelope(&self, program: &str, args: &[&str]) -> serde_json::Value {
serde_json::json!({
"schema": ENVELOPE_SCHEMA,
"program": program,
"args": args,
"net": self.allow_net,
})
}
#[must_use]
pub fn envelope_json(&self, program: &str, args: &[&str]) -> String {
self.envelope(program, args).to_string()
}
#[must_use]
pub fn command(&self, program: &str, args: &[&str]) -> Command {
match &self.runner {
None => {
let mut cmd = Command::new(program);
cmd.args(args);
cmd
}
Some(runner) => {
let mut cmd = Command::new(runner);
cmd.arg("--exec").arg(self.envelope_json(program, args));
cmd
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
fn network_effects() -> EffectRow {
EffectRow {
reads: vec![Resource::Network],
..Default::default()
}
}
#[test]
fn env_classification_is_strict() {
assert_eq!(classify_env(""), EnvRunner::Disabled);
assert_eq!(classify_env("0"), EnvRunner::Disabled);
assert_eq!(classify_env("OFF"), EnvRunner::Disabled);
assert_eq!(classify_env("none"), EnvRunner::Disabled);
assert_eq!(classify_env(" false "), EnvRunner::Disabled);
assert_eq!(classify_env("/no/such/runner"), EnvRunner::Disabled);
let exe = std::env::current_exe().expect("test exe");
assert_eq!(classify_env(exe.to_str().unwrap()), EnvRunner::Path(exe));
}
#[test]
fn path_lookup_finds_runner_only_when_present() {
let dir = std::env::temp_dir().join(format!("wm-sandbox-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path_var = dir.as_os_str();
assert_eq!(path_runner_in(path_var), None, "empty dir finds nothing");
let fake = dir.join(RUNNER_PROGRAM);
std::fs::write(&fake, b"#!/bin/sh\n").unwrap();
let found = path_runner_in(path_var).expect("runner found");
assert_eq!(found.path, fake);
assert_eq!(found.source, RunnerSource::Path);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn inactive_policy_builds_plain_command() {
let policy = SpawnPolicy::disabled();
assert!(!policy.is_active());
let cmd = policy.command("gh", &["issue", "list"]);
assert_eq!(cmd.get_program(), OsStr::new("gh"));
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args, vec![OsStr::new("issue"), OsStr::new("list")]);
}
#[test]
fn active_policy_wraps_with_json_envelope() {
let policy = SpawnPolicy::from_runner(Some(PathBuf::from("/opt/mandala-sandbox")), true);
assert!(policy.is_active());
let cmd = policy.command("timeout", &["30", "gh"]);
assert_eq!(cmd.get_program(), OsStr::new("/opt/mandala-sandbox"));
let args: Vec<_> = cmd.get_args().collect();
assert_eq!(args[0], OsStr::new("--exec"));
let envelope: serde_json::Value =
serde_json::from_str(args[1].to_str().unwrap()).expect("envelope is JSON");
assert_eq!(envelope["schema"], ENVELOPE_SCHEMA);
assert_eq!(envelope["program"], "timeout");
assert_eq!(envelope["args"], serde_json::json!(["30", "gh"]));
assert_eq!(envelope["net"], true);
}
#[test]
fn net_grant_derives_from_effect_row() {
assert!(net_grant(&network_effects()));
assert!(!net_grant(&EffectRow::pure()));
let write_net = EffectRow {
writes: vec![Resource::Network],
..Default::default()
};
assert!(net_grant(&write_net));
}
#[test]
fn for_effects_uses_detection_and_declared_network() {
let policy = SpawnPolicy::for_effects(&network_effects());
assert!(policy.allow_net());
let envelope = policy.envelope("true", &[]);
assert_eq!(envelope["net"], true);
}
}