use std::collections::{HashSet, VecDeque};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use wm_core::sandbox::{ENVELOPE_SCHEMA, RunnerInfo, SpawnPolicy, detect_runner};
use wm_core::{EffectRow, Sandbox};
const EVENT_RING_CAP: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxEvent {
pub tool: String,
pub kind: SandboxEventKind,
pub ts_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SandboxEventKind {
Degraded,
UnconfinedSpawn,
}
impl SandboxEventKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Degraded => "degraded",
Self::UnconfinedSpawn => "unconfined_spawn",
}
}
}
impl SandboxEvent {
#[must_use]
pub fn to_json(&self, status: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"tool": self.tool,
"kind": self.kind.as_str(),
"counts": {
"dispatches": status["dispatches"],
"degraded": status["degraded"],
"unconfined_spawns": status["unconfined_spawns"],
},
"ts_ms": self.ts_ms,
})
}
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
pub struct SubprocessSandbox {
runner: Option<RunnerInfo>,
dispatches: AtomicU64,
degraded: AtomicU64,
unconfined_spawns: AtomicU64,
warned: Mutex<HashSet<String>>,
events: Mutex<VecDeque<SandboxEvent>>,
}
impl SubprocessSandbox {
#[must_use]
pub fn detect() -> Self {
Self::with_runner(detect_runner())
}
#[must_use]
pub fn with_runner(runner: Option<RunnerInfo>) -> Self {
Self {
runner,
dispatches: AtomicU64::new(0),
degraded: AtomicU64::new(0),
unconfined_spawns: AtomicU64::new(0),
warned: Mutex::new(HashSet::new()),
events: Mutex::new(VecDeque::new()),
}
}
#[must_use]
pub const fn is_active(&self) -> bool {
self.runner.is_some()
}
#[must_use]
pub const fn runner(&self) -> Option<&RunnerInfo> {
self.runner.as_ref()
}
#[must_use]
pub fn declared(effects: &EffectRow) -> bool {
effects.sandbox == Sandbox::Subprocess
}
#[must_use]
pub fn policy_for(&self, effects: &EffectRow) -> SpawnPolicy {
SpawnPolicy::from_runner(
self.runner.as_ref().map(|r| r.path.clone()),
wm_core::sandbox::net_grant(effects),
)
}
pub fn note_confined(&self) {
self.dispatches.fetch_add(1, Ordering::Relaxed);
}
pub fn note_degraded(&self, tool: &str) {
self.dispatches.fetch_add(1, Ordering::Relaxed);
self.degraded.fetch_add(1, Ordering::Relaxed);
self.record_event(tool, SandboxEventKind::Degraded);
self.warn_once(
tool,
"subprocess sandbox: no runner resolved — declared spawn runs unconfined (loud-degrade)",
);
}
pub fn note_unconfined_spawn(&self, tool: &str) {
self.unconfined_spawns.fetch_add(1, Ordering::Relaxed);
self.record_event(tool, SandboxEventKind::UnconfinedSpawn);
self.warn_once(
tool,
"subprocess sandbox: tool declares spawns but not Sandbox::Subprocess — its spawns bypass the runner",
);
}
fn record_event(&self, tool: &str, kind: SandboxEventKind) {
if let Ok(mut events) = self.events.lock() {
if events.len() == EVENT_RING_CAP {
events.pop_front();
}
events.push_back(SandboxEvent {
tool: tool.to_string(),
kind,
ts_ms: now_ms(),
});
}
}
#[must_use]
pub fn drain_events(&self) -> Vec<SandboxEvent> {
self.events
.lock()
.map(|mut events| events.drain(..).collect())
.unwrap_or_default()
}
fn warn_once(&self, tool: &str, message: &str) {
if let Ok(mut warned) = self.warned.lock()
&& warned.insert(tool.to_string())
{
tracing::warn!(tool, "{message}");
}
}
#[must_use]
pub fn status(&self) -> serde_json::Value {
serde_json::json!({
"active": self.is_active(),
"runner": self.runner.as_ref().map(|r| r.path.display().to_string()),
"source": self.runner.as_ref().map(|r| r.source.as_str()),
"envelope": ENVELOPE_SCHEMA,
"dispatches": self.dispatches.load(Ordering::Relaxed),
"degraded": self.degraded.load(Ordering::Relaxed),
"unconfined_spawns": self.unconfined_spawns.load(Ordering::Relaxed),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use wm_core::Resource;
use wm_core::sandbox::RunnerSource;
fn runner() -> RunnerInfo {
RunnerInfo {
path: PathBuf::from("/opt/mandala-sandbox"),
source: RunnerSource::Env,
}
}
fn declared_effects() -> EffectRow {
EffectRow {
reads: vec![Resource::Network, Resource::Process],
spawns: true,
sandbox: Sandbox::Subprocess,
..Default::default()
}
}
#[test]
fn declared_requires_the_subprocess_marker() {
assert!(SubprocessSandbox::declared(&declared_effects()));
let spawns_only = EffectRow {
spawns: true,
..Default::default()
};
assert!(!SubprocessSandbox::declared(&spawns_only));
}
#[test]
fn policy_derives_runner_and_net_grant() {
let sb = SubprocessSandbox::with_runner(Some(runner()));
let policy = sb.policy_for(&declared_effects());
assert!(policy.is_active());
assert!(policy.allow_net());
assert_eq!(
policy.runner(),
Some(std::path::Path::new("/opt/mandala-sandbox"))
);
let local = EffectRow {
reads: vec![Resource::Process],
spawns: true,
sandbox: Sandbox::Subprocess,
..Default::default()
};
assert!(!sb.policy_for(&local).allow_net());
}
#[test]
fn missing_runner_degrades_but_still_builds_policy() {
let sb = SubprocessSandbox::with_runner(None);
let policy = sb.policy_for(&declared_effects());
assert!(!policy.is_active());
assert!(
policy.allow_net(),
"grant is declared, not runner-dependent"
);
sb.note_degraded("oss.bounty.scan");
let status = sb.status();
assert_eq!(status["active"], false);
assert_eq!(status["dispatches"], 1);
assert_eq!(status["degraded"], 1);
}
#[test]
fn counters_are_separate() {
let sb = SubprocessSandbox::with_runner(Some(runner()));
sb.note_confined();
sb.note_unconfined_spawn("session.record");
let status = sb.status();
assert_eq!(status["dispatches"], 1);
assert_eq!(status["degraded"], 0);
assert_eq!(status["unconfined_spawns"], 1);
assert_eq!(status["source"], "env");
}
#[test]
fn drift_events_are_recorded_and_drained_once() {
let sb = SubprocessSandbox::with_runner(None);
sb.note_degraded("oss.bounty.scan");
sb.note_unconfined_spawn("session.record");
let events = sb.drain_events();
assert_eq!(events.len(), 2);
assert_eq!(events[0].tool, "oss.bounty.scan");
assert_eq!(events[0].kind, SandboxEventKind::Degraded);
assert_eq!(events[1].kind, SandboxEventKind::UnconfinedSpawn);
assert!(events[0].ts_ms > 0);
assert!(sb.drain_events().is_empty(), "drain must be exactly-once");
}
#[test]
fn happy_path_records_no_events() {
let sb = SubprocessSandbox::with_runner(Some(runner()));
sb.note_confined();
sb.note_confined();
assert!(sb.drain_events().is_empty());
assert_eq!(sb.status()["dispatches"], 2);
}
#[test]
fn event_ring_is_bounded_oldest_first() {
let sb = SubprocessSandbox::with_runner(None);
for i in 0..(EVENT_RING_CAP + 10) {
sb.note_unconfined_spawn(&format!("t{i}"));
}
let events = sb.drain_events();
assert_eq!(events.len(), EVENT_RING_CAP);
assert_eq!(events[0].tool, "t10", "oldest ten should be dropped");
assert_eq!(
sb.status()["unconfined_spawns"],
(EVENT_RING_CAP + 10) as u64,
"counters stay exact while the ring is bounded"
);
}
}