1use std::collections::{HashSet, VecDeque};
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
33const EVENT_RING_CAP: usize = 64;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct SandboxEvent {
44 pub tool: String,
46 pub kind: SandboxEventKind,
48 pub ts_ms: u64,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum SandboxEventKind {
55 Degraded,
57 UnconfinedSpawn,
59}
60
61impl SandboxEventKind {
62 #[must_use]
64 pub const fn as_str(self) -> &'static str {
65 match self {
66 Self::Degraded => "degraded",
67 Self::UnconfinedSpawn => "unconfined_spawn",
68 }
69 }
70}
71
72impl SandboxEvent {
73 #[must_use]
77 pub fn to_json(&self, status: &serde_json::Value) -> serde_json::Value {
78 serde_json::json!({
79 "tool": self.tool,
80 "kind": self.kind.as_str(),
81 "counts": {
82 "dispatches": status["dispatches"],
83 "degraded": status["degraded"],
84 "unconfined_spawns": status["unconfined_spawns"],
85 },
86 "ts_ms": self.ts_ms,
87 })
88 }
89}
90
91fn now_ms() -> u64 {
93 std::time::SystemTime::now()
94 .duration_since(std::time::UNIX_EPOCH)
95 .map_or(0, |d| d.as_millis() as u64)
96}
97
98pub struct SubprocessSandbox {
100 runner: Option<RunnerInfo>,
101 dispatches: AtomicU64,
102 degraded: AtomicU64,
103 unconfined_spawns: AtomicU64,
104 warned: Mutex<HashSet<String>>,
106 events: Mutex<VecDeque<SandboxEvent>>,
108}
109
110impl SubprocessSandbox {
111 #[must_use]
113 pub fn detect() -> Self {
114 Self::with_runner(detect_runner())
115 }
116
117 #[must_use]
119 pub fn with_runner(runner: Option<RunnerInfo>) -> Self {
120 Self {
121 runner,
122 dispatches: AtomicU64::new(0),
123 degraded: AtomicU64::new(0),
124 unconfined_spawns: AtomicU64::new(0),
125 warned: Mutex::new(HashSet::new()),
126 events: Mutex::new(VecDeque::new()),
127 }
128 }
129
130 #[must_use]
132 pub const fn is_active(&self) -> bool {
133 self.runner.is_some()
134 }
135
136 #[must_use]
138 pub const fn runner(&self) -> Option<&RunnerInfo> {
139 self.runner.as_ref()
140 }
141
142 #[must_use]
144 pub fn declared(effects: &EffectRow) -> bool {
145 effects.sandbox == Sandbox::Subprocess
146 }
147
148 #[must_use]
151 pub fn policy_for(&self, effects: &EffectRow) -> SpawnPolicy {
152 SpawnPolicy::from_runner(
153 self.runner.as_ref().map(|r| r.path.clone()),
154 wm_core::sandbox::net_grant(effects),
155 )
156 }
157
158 pub fn note_confined(&self) {
160 self.dispatches.fetch_add(1, Ordering::Relaxed);
161 }
162
163 pub fn note_degraded(&self, tool: &str) {
165 self.dispatches.fetch_add(1, Ordering::Relaxed);
166 self.degraded.fetch_add(1, Ordering::Relaxed);
167 self.record_event(tool, SandboxEventKind::Degraded);
168 self.warn_once(
169 tool,
170 "subprocess sandbox: no runner resolved — declared spawn runs unconfined (loud-degrade)",
171 );
172 }
173
174 pub fn note_unconfined_spawn(&self, tool: &str) {
177 self.unconfined_spawns.fetch_add(1, Ordering::Relaxed);
178 self.record_event(tool, SandboxEventKind::UnconfinedSpawn);
179 self.warn_once(
180 tool,
181 "subprocess sandbox: tool declares spawns but not Sandbox::Subprocess — its spawns bypass the runner",
182 );
183 }
184
185 fn record_event(&self, tool: &str, kind: SandboxEventKind) {
187 if let Ok(mut events) = self.events.lock() {
188 if events.len() == EVENT_RING_CAP {
189 events.pop_front();
190 }
191 events.push_back(SandboxEvent {
192 tool: tool.to_string(),
193 kind,
194 ts_ms: now_ms(),
195 });
196 }
197 }
198
199 #[must_use]
202 pub fn drain_events(&self) -> Vec<SandboxEvent> {
203 self.events
204 .lock()
205 .map(|mut events| events.drain(..).collect())
206 .unwrap_or_default()
207 }
208
209 fn warn_once(&self, tool: &str, message: &str) {
210 if let Ok(mut warned) = self.warned.lock()
211 && warned.insert(tool.to_string())
212 {
213 tracing::warn!(tool, "{message}");
214 }
215 }
216
217 #[must_use]
219 pub fn status(&self) -> serde_json::Value {
220 serde_json::json!({
221 "active": self.is_active(),
222 "runner": self.runner.as_ref().map(|r| r.path.display().to_string()),
223 "source": self.runner.as_ref().map(|r| r.source.as_str()),
224 "envelope": ENVELOPE_SCHEMA,
225 "dispatches": self.dispatches.load(Ordering::Relaxed),
226 "degraded": self.degraded.load(Ordering::Relaxed),
227 "unconfined_spawns": self.unconfined_spawns.load(Ordering::Relaxed),
228 })
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use std::path::PathBuf;
236 use wm_core::Resource;
237 use wm_core::sandbox::RunnerSource;
238
239 fn runner() -> RunnerInfo {
240 RunnerInfo {
241 path: PathBuf::from("/opt/mandala-sandbox"),
242 source: RunnerSource::Env,
243 }
244 }
245
246 fn declared_effects() -> EffectRow {
247 EffectRow {
248 reads: vec![Resource::Network, Resource::Process],
249 spawns: true,
250 sandbox: Sandbox::Subprocess,
251 ..Default::default()
252 }
253 }
254
255 #[test]
256 fn declared_requires_the_subprocess_marker() {
257 assert!(SubprocessSandbox::declared(&declared_effects()));
258 let spawns_only = EffectRow {
259 spawns: true,
260 ..Default::default()
261 };
262 assert!(!SubprocessSandbox::declared(&spawns_only));
263 }
264
265 #[test]
266 fn policy_derives_runner_and_net_grant() {
267 let sb = SubprocessSandbox::with_runner(Some(runner()));
268 let policy = sb.policy_for(&declared_effects());
269 assert!(policy.is_active());
270 assert!(policy.allow_net());
271 assert_eq!(
272 policy.runner(),
273 Some(std::path::Path::new("/opt/mandala-sandbox"))
274 );
275
276 let local = EffectRow {
277 reads: vec![Resource::Process],
278 spawns: true,
279 sandbox: Sandbox::Subprocess,
280 ..Default::default()
281 };
282 assert!(!sb.policy_for(&local).allow_net());
283 }
284
285 #[test]
286 fn missing_runner_degrades_but_still_builds_policy() {
287 let sb = SubprocessSandbox::with_runner(None);
288 let policy = sb.policy_for(&declared_effects());
289 assert!(!policy.is_active());
290 assert!(
291 policy.allow_net(),
292 "grant is declared, not runner-dependent"
293 );
294 sb.note_degraded("oss.bounty.scan");
295 let status = sb.status();
296 assert_eq!(status["active"], false);
297 assert_eq!(status["dispatches"], 1);
298 assert_eq!(status["degraded"], 1);
299 }
300
301 #[test]
302 fn counters_are_separate() {
303 let sb = SubprocessSandbox::with_runner(Some(runner()));
304 sb.note_confined();
305 sb.note_unconfined_spawn("session.record");
306 let status = sb.status();
307 assert_eq!(status["dispatches"], 1);
308 assert_eq!(status["degraded"], 0);
309 assert_eq!(status["unconfined_spawns"], 1);
310 assert_eq!(status["source"], "env");
311 }
312
313 #[test]
314 fn drift_events_are_recorded_and_drained_once() {
315 let sb = SubprocessSandbox::with_runner(None);
316 sb.note_degraded("oss.bounty.scan");
317 sb.note_unconfined_spawn("session.record");
318
319 let events = sb.drain_events();
320 assert_eq!(events.len(), 2);
321 assert_eq!(events[0].tool, "oss.bounty.scan");
322 assert_eq!(events[0].kind, SandboxEventKind::Degraded);
323 assert_eq!(events[1].kind, SandboxEventKind::UnconfinedSpawn);
324 assert!(events[0].ts_ms > 0);
325 assert!(sb.drain_events().is_empty(), "drain must be exactly-once");
326 }
327
328 #[test]
329 fn happy_path_records_no_events() {
330 let sb = SubprocessSandbox::with_runner(Some(runner()));
331 sb.note_confined();
332 sb.note_confined();
333 assert!(sb.drain_events().is_empty());
334 assert_eq!(sb.status()["dispatches"], 2);
335 }
336
337 #[test]
338 fn event_ring_is_bounded_oldest_first() {
339 let sb = SubprocessSandbox::with_runner(None);
340 for i in 0..(EVENT_RING_CAP + 10) {
341 sb.note_unconfined_spawn(&format!("t{i}"));
342 }
343 let events = sb.drain_events();
344 assert_eq!(events.len(), EVENT_RING_CAP);
345 assert_eq!(events[0].tool, "t10", "oldest ten should be dropped");
346 assert_eq!(
347 sb.status()["unconfined_spawns"],
348 (EVENT_RING_CAP + 10) as u64,
349 "counters stay exact while the ring is bounded"
350 );
351 }
352}