wm_dispatch/
subprocess_sandbox.rs1use std::collections::HashSet;
27use std::sync::Mutex;
28use std::sync::atomic::{AtomicU64, Ordering};
29
30use wm_core::sandbox::{ENVELOPE_SCHEMA, RunnerInfo, SpawnPolicy, detect_runner};
31use wm_core::{EffectRow, Sandbox};
32
33pub struct SubprocessSandbox {
35 runner: Option<RunnerInfo>,
36 dispatches: AtomicU64,
37 degraded: AtomicU64,
38 unconfined_spawns: AtomicU64,
39 warned: Mutex<HashSet<String>>,
41}
42
43impl SubprocessSandbox {
44 #[must_use]
46 pub fn detect() -> Self {
47 Self::with_runner(detect_runner())
48 }
49
50 #[must_use]
52 pub fn with_runner(runner: Option<RunnerInfo>) -> Self {
53 Self {
54 runner,
55 dispatches: AtomicU64::new(0),
56 degraded: AtomicU64::new(0),
57 unconfined_spawns: AtomicU64::new(0),
58 warned: Mutex::new(HashSet::new()),
59 }
60 }
61
62 #[must_use]
64 pub const fn is_active(&self) -> bool {
65 self.runner.is_some()
66 }
67
68 #[must_use]
70 pub const fn runner(&self) -> Option<&RunnerInfo> {
71 self.runner.as_ref()
72 }
73
74 #[must_use]
76 pub fn declared(effects: &EffectRow) -> bool {
77 effects.sandbox == Sandbox::Subprocess
78 }
79
80 #[must_use]
83 pub fn policy_for(&self, effects: &EffectRow) -> SpawnPolicy {
84 SpawnPolicy::from_runner(
85 self.runner.as_ref().map(|r| r.path.clone()),
86 wm_core::sandbox::net_grant(effects),
87 )
88 }
89
90 pub fn note_confined(&self) {
92 self.dispatches.fetch_add(1, Ordering::Relaxed);
93 }
94
95 pub fn note_degraded(&self, tool: &str) {
97 self.dispatches.fetch_add(1, Ordering::Relaxed);
98 self.degraded.fetch_add(1, Ordering::Relaxed);
99 self.warn_once(
100 tool,
101 "subprocess sandbox: no runner resolved — declared spawn runs unconfined (loud-degrade)",
102 );
103 }
104
105 pub fn note_unconfined_spawn(&self, tool: &str) {
108 self.unconfined_spawns.fetch_add(1, Ordering::Relaxed);
109 self.warn_once(
110 tool,
111 "subprocess sandbox: tool declares spawns but not Sandbox::Subprocess — its spawns bypass the runner",
112 );
113 }
114
115 fn warn_once(&self, tool: &str, message: &str) {
116 if let Ok(mut warned) = self.warned.lock()
117 && warned.insert(tool.to_string())
118 {
119 tracing::warn!(tool, "{message}");
120 }
121 }
122
123 #[must_use]
125 pub fn status(&self) -> serde_json::Value {
126 serde_json::json!({
127 "active": self.is_active(),
128 "runner": self.runner.as_ref().map(|r| r.path.display().to_string()),
129 "source": self.runner.as_ref().map(|r| r.source.as_str()),
130 "envelope": ENVELOPE_SCHEMA,
131 "dispatches": self.dispatches.load(Ordering::Relaxed),
132 "degraded": self.degraded.load(Ordering::Relaxed),
133 "unconfined_spawns": self.unconfined_spawns.load(Ordering::Relaxed),
134 })
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use std::path::PathBuf;
142 use wm_core::Resource;
143 use wm_core::sandbox::RunnerSource;
144
145 fn runner() -> RunnerInfo {
146 RunnerInfo {
147 path: PathBuf::from("/opt/mandala-sandbox"),
148 source: RunnerSource::Env,
149 }
150 }
151
152 fn declared_effects() -> EffectRow {
153 EffectRow {
154 reads: vec![Resource::Network, Resource::Process],
155 spawns: true,
156 sandbox: Sandbox::Subprocess,
157 ..Default::default()
158 }
159 }
160
161 #[test]
162 fn declared_requires_the_subprocess_marker() {
163 assert!(SubprocessSandbox::declared(&declared_effects()));
164 let spawns_only = EffectRow {
165 spawns: true,
166 ..Default::default()
167 };
168 assert!(!SubprocessSandbox::declared(&spawns_only));
169 }
170
171 #[test]
172 fn policy_derives_runner_and_net_grant() {
173 let sb = SubprocessSandbox::with_runner(Some(runner()));
174 let policy = sb.policy_for(&declared_effects());
175 assert!(policy.is_active());
176 assert!(policy.allow_net());
177 assert_eq!(
178 policy.runner(),
179 Some(std::path::Path::new("/opt/mandala-sandbox"))
180 );
181
182 let local = EffectRow {
183 reads: vec![Resource::Process],
184 spawns: true,
185 sandbox: Sandbox::Subprocess,
186 ..Default::default()
187 };
188 assert!(!sb.policy_for(&local).allow_net());
189 }
190
191 #[test]
192 fn missing_runner_degrades_but_still_builds_policy() {
193 let sb = SubprocessSandbox::with_runner(None);
194 let policy = sb.policy_for(&declared_effects());
195 assert!(!policy.is_active());
196 assert!(
197 policy.allow_net(),
198 "grant is declared, not runner-dependent"
199 );
200 sb.note_degraded("oss.bounty.scan");
201 let status = sb.status();
202 assert_eq!(status["active"], false);
203 assert_eq!(status["dispatches"], 1);
204 assert_eq!(status["degraded"], 1);
205 }
206
207 #[test]
208 fn counters_are_separate() {
209 let sb = SubprocessSandbox::with_runner(Some(runner()));
210 sb.note_confined();
211 sb.note_unconfined_spawn("session.record");
212 let status = sb.status();
213 assert_eq!(status["dispatches"], 1);
214 assert_eq!(status["degraded"], 0);
215 assert_eq!(status["unconfined_spawns"], 1);
216 assert_eq!(status["source"], "env");
217 }
218}