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