use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SandboxKind {
#[default]
None,
Namespace,
Container,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum OnUnavailable {
#[default]
Error,
Warn,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolSandboxConfig {
#[serde(default)]
pub kind: SandboxKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine: Option<String>,
#[serde(default = "default_true")]
pub network: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mounts: Vec<String>,
#[serde(default)]
pub persist: bool,
#[serde(default)]
pub on_unavailable: OnUnavailable,
}
fn default_true() -> bool {
true
}
fn isolation_rank(kind: SandboxKind) -> u8 {
match kind {
SandboxKind::None => 0,
SandboxKind::Namespace => 1,
SandboxKind::Container => 2,
}
}
fn stronger_of(a: SandboxKind, b: SandboxKind) -> SandboxKind {
match isolation_rank(a) >= isolation_rank(b) {
true => a,
false => b,
}
}
impl Default for ToolSandboxConfig {
fn default() -> Self {
Self {
kind: SandboxKind::None,
image: None,
engine: None,
network: true,
mounts: Vec::new(),
persist: false,
on_unavailable: OnUnavailable::Error,
}
}
}
impl ToolSandboxConfig {
pub fn is_active(&self) -> bool {
self.kind != SandboxKind::None
}
fn without_manifest_engine(config: &Self) -> Self {
Self {
engine: None,
..config.clone()
}
}
fn clamped_by(&self, ceiling: &Self) -> Self {
Self {
kind: stronger_of(self.kind, ceiling.kind),
image: ceiling.image.clone().or_else(|| self.image.clone()),
engine: ceiling.engine.clone(),
network: self.network && ceiling.network,
mounts: self
.mounts
.iter()
.filter(|m| ceiling.mounts.contains(m))
.cloned()
.collect(),
persist: self.persist,
on_unavailable: ceiling.on_unavailable,
}
}
}
pub fn resolve_sandbox(
global: Option<&ToolSandboxConfig>,
agent: Option<&ToolSandboxConfig>,
stage: Option<&ToolSandboxConfig>,
) -> ToolSandboxConfig {
let narrowest = stage
.or(agent)
.map(ToolSandboxConfig::without_manifest_engine);
match (narrowest.as_ref(), global) {
(Some(n), Some(g)) if !n.is_active() && g.is_active() => g.clone(),
(Some(n), Some(g)) if g.is_active() => n.clamped_by(g),
(Some(n), _) => n.clone(),
(None, Some(g)) => g.clone(),
(None, None) => ToolSandboxConfig::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn isolating(kind: SandboxKind) -> ToolSandboxConfig {
ToolSandboxConfig {
kind,
..Default::default()
}
}
#[test]
fn a_manifest_can_never_choose_the_container_engine() {
let manifest = ToolSandboxConfig {
kind: SandboxKind::Container,
engine: Some("/tmp/payload".to_string()),
..Default::default()
};
let user = ToolSandboxConfig {
kind: SandboxKind::Container,
..Default::default()
};
assert_eq!(
resolve_sandbox(Some(&user), Some(&manifest), None).engine,
None,
"an unpinned engine must auto-detect, not take the manifest's"
);
assert_eq!(
resolve_sandbox(None, Some(&manifest), None).engine,
None,
"a manifest engine is refused even with no global sandbox"
);
let pinned = ToolSandboxConfig {
kind: SandboxKind::Container,
engine: Some("podman".to_string()),
..Default::default()
};
assert_eq!(
resolve_sandbox(Some(&pinned), Some(&manifest), None)
.engine
.as_deref(),
Some("podman")
);
}
#[test]
fn a_manifest_cannot_trade_a_container_for_a_namespace() {
let user = ToolSandboxConfig {
kind: SandboxKind::Container,
image: Some("alpine:3".to_string()),
..Default::default()
};
let manifest = ToolSandboxConfig {
kind: SandboxKind::Namespace,
..Default::default()
};
let resolved = resolve_sandbox(Some(&user), None, Some(&manifest));
assert_eq!(
resolved.kind,
SandboxKind::Container,
"the weaker kind must not win"
);
assert_eq!(
resolved.image.as_deref(),
Some("alpine:3"),
"and the user's pinned image stands"
);
let user = ToolSandboxConfig {
kind: SandboxKind::Namespace,
..Default::default()
};
let manifest = ToolSandboxConfig {
kind: SandboxKind::Container,
..Default::default()
};
assert_eq!(
resolve_sandbox(Some(&user), None, Some(&manifest)).kind,
SandboxKind::Container
);
}
#[test]
fn isolation_ranks_container_above_namespace_above_host() {
assert!(isolation_rank(SandboxKind::Container) > isolation_rank(SandboxKind::Namespace));
assert!(isolation_rank(SandboxKind::Namespace) > isolation_rank(SandboxKind::None));
assert_eq!(
stronger_of(SandboxKind::Namespace, SandboxKind::Container),
SandboxKind::Container
);
assert_eq!(
stronger_of(SandboxKind::Container, SandboxKind::Namespace),
SandboxKind::Container
);
}
#[test]
fn a_manifest_cannot_widen_a_sandbox_the_user_configured() {
let user = ToolSandboxConfig {
kind: SandboxKind::Container,
network: false,
mounts: vec!["/srv/data".to_string()],
engine: Some("podman".to_string()),
on_unavailable: OnUnavailable::Error,
..Default::default()
};
let manifest = ToolSandboxConfig {
kind: SandboxKind::Container,
network: true,
mounts: vec!["/home/you".to_string(), "/srv/data".to_string()],
engine: Some("/tmp/evil".to_string()),
on_unavailable: OnUnavailable::Warn,
..Default::default()
};
let resolved = resolve_sandbox(Some(&user), Some(&manifest), None);
assert!(!resolved.network, "network isolation must survive");
assert_eq!(
resolved.mounts,
vec!["/srv/data".to_string()],
"only mounts the user already granted"
);
assert_eq!(
resolved.engine.as_deref(),
Some("podman"),
"a pinned engine is not replaceable: it is spawned before any gate"
);
assert_eq!(
resolved.on_unavailable,
OnUnavailable::Error,
"falling back to the host is the user's call"
);
}
#[test]
fn a_manifest_may_still_choose_its_own_isolation_and_narrow() {
let user = ToolSandboxConfig {
kind: SandboxKind::Container,
network: true,
mounts: vec!["/srv/data".to_string()],
..Default::default()
};
let manifest = ToolSandboxConfig {
kind: SandboxKind::Container,
image: Some("alpine:3".to_string()),
network: false,
mounts: vec![],
persist: true,
..Default::default()
};
let resolved = resolve_sandbox(Some(&user), None, Some(&manifest));
assert_eq!(resolved.kind, SandboxKind::Container, "kind is kept");
assert_eq!(
resolved.image.as_deref(),
Some("alpine:3"),
"its own image, since the user pinned none"
);
assert!(!resolved.network, "narrowing is allowed");
assert!(resolved.mounts.is_empty(), "dropping a mount is allowed");
assert!(resolved.persist, "a warm container is not an escalation");
assert_eq!(resolved.engine, None);
}
#[test]
fn a_manifest_opting_in_from_nothing_is_not_clamped() {
let manifest = ToolSandboxConfig {
kind: SandboxKind::Container,
mounts: vec!["/home/you".to_string()],
..Default::default()
};
let resolved = resolve_sandbox(None, Some(&manifest), None);
assert_eq!(resolved.mounts, vec!["/home/you".to_string()]);
let host = isolating(SandboxKind::None);
let resolved = resolve_sandbox(Some(&host), Some(&manifest), None);
assert_eq!(resolved.mounts, vec!["/home/you".to_string()]);
}
#[test]
fn the_users_engine_survives_a_manifest_that_names_none() {
let user = ToolSandboxConfig {
kind: SandboxKind::Container,
engine: Some("podman".to_string()),
..Default::default()
};
let manifest = isolating(SandboxKind::Container);
let resolved = resolve_sandbox(Some(&user), Some(&manifest), None);
assert_eq!(resolved.engine.as_deref(), Some("podman"));
}
#[test]
fn default_is_host_passthrough() {
let c = ToolSandboxConfig::default();
assert_eq!(c.kind, SandboxKind::None);
assert!(!c.is_active());
assert!(c.network);
assert_eq!(c.on_unavailable, OnUnavailable::Error);
}
#[test]
fn stage_overrides_agent_and_global() {
let global = ToolSandboxConfig {
kind: SandboxKind::Namespace,
..Default::default()
};
let agent = ToolSandboxConfig {
kind: SandboxKind::Container,
image: Some("ubuntu:24.04".into()),
..Default::default()
};
let stage = ToolSandboxConfig {
kind: SandboxKind::Container,
image: Some("node:22-slim".into()),
network: false,
..Default::default()
};
let r = resolve_sandbox(Some(&global), Some(&agent), Some(&stage));
assert_eq!(r.image.as_deref(), Some("node:22-slim"));
assert!(!r.network);
}
#[test]
fn agent_overrides_global_when_no_stage() {
let global = ToolSandboxConfig {
kind: SandboxKind::Namespace,
..Default::default()
};
let agent = ToolSandboxConfig {
kind: SandboxKind::Container,
image: Some("ubuntu:24.04".into()),
..Default::default()
};
let r = resolve_sandbox(Some(&global), Some(&agent), None);
assert_eq!(r.kind, SandboxKind::Container);
assert_eq!(r.image.as_deref(), Some("ubuntu:24.04"));
}
#[test]
fn global_used_when_no_agent_or_stage() {
let global = ToolSandboxConfig {
kind: SandboxKind::Namespace,
..Default::default()
};
let r = resolve_sandbox(Some(&global), None, None);
assert_eq!(r.kind, SandboxKind::Namespace);
}
#[test]
fn nothing_set_is_host() {
let r = resolve_sandbox(None, None, None);
assert_eq!(r.kind, SandboxKind::None);
}
#[test]
fn manifest_cannot_disable_the_users_sandbox() {
let global = ToolSandboxConfig {
kind: SandboxKind::Container,
image: Some("ubuntu:24.04".into()),
..Default::default()
};
let off = ToolSandboxConfig {
kind: SandboxKind::None,
..Default::default()
};
let r = resolve_sandbox(Some(&global), Some(&off), None);
assert_eq!(r.kind, SandboxKind::Container);
assert_eq!(r.image.as_deref(), Some("ubuntu:24.04"));
let r = resolve_sandbox(Some(&global), None, Some(&off));
assert_eq!(r.kind, SandboxKind::Container);
}
#[test]
fn manifest_may_opt_into_a_sandbox_the_user_did_not_set() {
let agent = ToolSandboxConfig {
kind: SandboxKind::Container,
image: Some("node:22-slim".into()),
..Default::default()
};
let r = resolve_sandbox(None, Some(&agent), None);
assert_eq!(r.kind, SandboxKind::Container);
let off = ToolSandboxConfig {
kind: SandboxKind::None,
..Default::default()
};
assert_eq!(
resolve_sandbox(None, Some(&off), None).kind,
SandboxKind::None
);
}
#[test]
fn is_active_true_for_isolating_kinds() {
for kind in [SandboxKind::Namespace, SandboxKind::Container] {
let c = ToolSandboxConfig {
kind,
..Default::default()
};
assert!(c.is_active());
}
}
#[test]
fn deserializes_and_applies_field_defaults() {
let c: ToolSandboxConfig =
toml::from_str("kind = \"container\"\nimage = \"alpine\"").unwrap();
assert!(c.network);
assert!(c.is_active());
assert_eq!(c.on_unavailable, OnUnavailable::Error);
assert!(c.mounts.is_empty());
assert!(!c.persist);
}
}