1use std::path::{Path, PathBuf};
25use std::process::Command;
26
27use crate::effects::{EffectRow, Resource};
28
29pub const RUNNER_ENV: &str = "WM_SANDBOX_RUNNER";
31
32pub const RUNNER_PROGRAM: &str = "mandala-sandbox";
34
35pub const ENVELOPE_SCHEMA: &str = "wm-sandbox-exec-v1";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum RunnerSource {
41 Env,
43 Path,
45}
46
47impl RunnerSource {
48 #[must_use]
50 pub const fn as_str(self) -> &'static str {
51 match self {
52 Self::Env => "env",
53 Self::Path => "path",
54 }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct RunnerInfo {
61 pub path: PathBuf,
63 pub source: RunnerSource,
65}
66
67const DISABLED_TOKENS: &[&str] = &["0", "false", "off", "none"];
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72enum EnvRunner {
73 Unset,
75 Disabled,
77 Path(PathBuf),
79}
80
81fn classify_env(raw: &str) -> EnvRunner {
82 let trimmed = raw.trim();
83 if trimmed.is_empty()
84 || DISABLED_TOKENS
85 .iter()
86 .any(|v| trimmed.eq_ignore_ascii_case(v))
87 {
88 return EnvRunner::Disabled;
89 }
90 let path = PathBuf::from(trimmed);
91 if path.is_file() {
92 EnvRunner::Path(path)
93 } else {
94 tracing::warn!(
95 value = trimmed,
96 "WM_SANDBOX_RUNNER does not name a file — subprocess sandbox disabled"
97 );
98 EnvRunner::Disabled
99 }
100}
101
102fn env_runner() -> EnvRunner {
103 std::env::var(RUNNER_ENV).map_or(EnvRunner::Unset, |raw| classify_env(&raw))
104}
105
106fn path_runner_in(path_var: &std::ffi::OsStr) -> Option<RunnerInfo> {
107 std::env::split_paths(path_var)
108 .map(|dir| dir.join(RUNNER_PROGRAM))
109 .find(|candidate| candidate.is_file())
110 .map(|path| RunnerInfo {
111 path,
112 source: RunnerSource::Path,
113 })
114}
115
116fn path_runner() -> Option<RunnerInfo> {
117 std::env::var_os("PATH").and_then(|path_var| path_runner_in(&path_var))
118}
119
120#[must_use]
122pub fn detect_runner() -> Option<RunnerInfo> {
123 match env_runner() {
124 EnvRunner::Path(path) => Some(RunnerInfo {
125 path,
126 source: RunnerSource::Env,
127 }),
128 EnvRunner::Disabled => None,
129 EnvRunner::Unset => path_runner(),
130 }
131}
132
133#[must_use]
136pub fn net_grant(effects: &EffectRow) -> bool {
137 effects
138 .reads
139 .iter()
140 .chain(effects.writes.iter())
141 .any(|r| matches!(r, Resource::Network))
142}
143
144#[derive(Debug, Clone, Default)]
150pub struct SpawnPolicy {
151 runner: Option<PathBuf>,
152 allow_net: bool,
153}
154
155impl SpawnPolicy {
156 #[must_use]
158 pub fn disabled() -> Self {
159 Self::default()
160 }
161
162 #[must_use]
164 pub const fn from_runner(runner: Option<PathBuf>, allow_net: bool) -> Self {
165 Self { runner, allow_net }
166 }
167
168 #[must_use]
170 pub fn detected(allow_net: bool) -> Self {
171 Self::from_runner(detect_runner().map(|r| r.path), allow_net)
172 }
173
174 #[must_use]
176 pub fn for_effects(effects: &EffectRow) -> Self {
177 Self::detected(net_grant(effects))
178 }
179
180 #[must_use]
182 pub const fn is_active(&self) -> bool {
183 self.runner.is_some()
184 }
185
186 #[must_use]
188 pub fn runner(&self) -> Option<&Path> {
189 self.runner.as_deref()
190 }
191
192 #[must_use]
194 pub const fn allow_net(&self) -> bool {
195 self.allow_net
196 }
197
198 #[must_use]
200 pub fn envelope(&self, program: &str, args: &[&str]) -> serde_json::Value {
201 serde_json::json!({
202 "schema": ENVELOPE_SCHEMA,
203 "program": program,
204 "args": args,
205 "net": self.allow_net,
206 })
207 }
208
209 #[must_use]
211 pub fn envelope_json(&self, program: &str, args: &[&str]) -> String {
212 self.envelope(program, args).to_string()
213 }
214
215 #[must_use]
221 pub fn command(&self, program: &str, args: &[&str]) -> Command {
222 match &self.runner {
223 None => {
224 let mut cmd = Command::new(program);
225 cmd.args(args);
226 cmd
227 }
228 Some(runner) => {
229 let mut cmd = Command::new(runner);
230 cmd.arg("--exec")
231 .arg(self.envelope_json(program, args));
232 cmd
233 }
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use std::ffi::OsStr;
242
243 fn network_effects() -> EffectRow {
244 EffectRow {
245 reads: vec![Resource::Network],
246 ..Default::default()
247 }
248 }
249
250 #[test]
251 fn env_classification_is_strict() {
252 assert_eq!(classify_env(""), EnvRunner::Disabled);
253 assert_eq!(classify_env("0"), EnvRunner::Disabled);
254 assert_eq!(classify_env("OFF"), EnvRunner::Disabled);
255 assert_eq!(classify_env("none"), EnvRunner::Disabled);
256 assert_eq!(classify_env(" false "), EnvRunner::Disabled);
257 assert_eq!(classify_env("/no/such/runner"), EnvRunner::Disabled);
259 let exe = std::env::current_exe().expect("test exe");
261 assert_eq!(
262 classify_env(exe.to_str().unwrap()),
263 EnvRunner::Path(exe)
264 );
265 }
266
267 #[test]
268 fn path_lookup_finds_runner_only_when_present() {
269 let dir = std::env::temp_dir().join(format!("wm-sandbox-test-{}", std::process::id()));
270 std::fs::create_dir_all(&dir).unwrap();
271 let path_var = dir.as_os_str();
272 assert_eq!(path_runner_in(path_var), None, "empty dir finds nothing");
273 let fake = dir.join(RUNNER_PROGRAM);
274 std::fs::write(&fake, b"#!/bin/sh\n").unwrap();
275 let found = path_runner_in(path_var).expect("runner found");
276 assert_eq!(found.path, fake);
277 assert_eq!(found.source, RunnerSource::Path);
278 std::fs::remove_dir_all(&dir).ok();
279 }
280
281 #[test]
282 fn inactive_policy_builds_plain_command() {
283 let policy = SpawnPolicy::disabled();
284 assert!(!policy.is_active());
285 let cmd = policy.command("gh", &["issue", "list"]);
286 assert_eq!(cmd.get_program(), OsStr::new("gh"));
287 let args: Vec<_> = cmd.get_args().collect();
288 assert_eq!(args, vec![OsStr::new("issue"), OsStr::new("list")]);
289 }
290
291 #[test]
292 fn active_policy_wraps_with_json_envelope() {
293 let policy = SpawnPolicy::from_runner(Some(PathBuf::from("/opt/mandala-sandbox")), true);
294 assert!(policy.is_active());
295 let cmd = policy.command("timeout", &["30", "gh"]);
296 assert_eq!(cmd.get_program(), OsStr::new("/opt/mandala-sandbox"));
297 let args: Vec<_> = cmd.get_args().collect();
298 assert_eq!(args[0], OsStr::new("--exec"));
299 let envelope: serde_json::Value =
300 serde_json::from_str(args[1].to_str().unwrap()).expect("envelope is JSON");
301 assert_eq!(envelope["schema"], ENVELOPE_SCHEMA);
302 assert_eq!(envelope["program"], "timeout");
303 assert_eq!(envelope["args"], serde_json::json!(["30", "gh"]));
304 assert_eq!(envelope["net"], true);
305 }
306
307 #[test]
308 fn net_grant_derives_from_effect_row() {
309 assert!(net_grant(&network_effects()));
310 assert!(!net_grant(&EffectRow::pure()));
311 let write_net = EffectRow {
312 writes: vec![Resource::Network],
313 ..Default::default()
314 };
315 assert!(net_grant(&write_net));
316 }
317
318 #[test]
319 fn for_effects_uses_detection_and_declared_network() {
320 let policy = SpawnPolicy::for_effects(&network_effects());
321 assert!(policy.allow_net());
322 let envelope = policy.envelope("true", &[]);
325 assert_eq!(envelope["net"], true);
326 }
327}