use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::PathBuf;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxType {
Wasm,
OsSandbox,
Combined,
}
impl Default for SandboxType {
fn default() -> Self {
if cfg!(target_os = "linux") {
Self::OsSandbox
} else {
Self::Wasm
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NetworkPolicy {
#[serde(default)]
pub allow_network: bool,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub blocked_domains: Vec<String>,
#[serde(default = "default_max_connections")]
pub max_connections_per_minute: u32,
}
fn default_max_connections() -> u32 {
30
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FilesystemPolicy {
#[serde(default)]
pub readable_paths: Vec<PathBuf>,
#[serde(default)]
pub writable_paths: Vec<PathBuf>,
#[serde(default)]
pub allow_create: bool,
#[serde(default)]
pub allow_delete: bool,
#[serde(default = "default_max_file_size")]
pub max_file_size: u64,
}
fn default_max_file_size() -> u64 {
8 * 1024 * 1024
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProcessPolicy {
#[serde(default)]
pub allow_shell: bool,
#[serde(default)]
pub allowed_commands: Vec<String>,
#[serde(default)]
pub blocked_commands: Vec<String>,
#[serde(default = "default_max_exec_time")]
pub max_execution_seconds: u32,
}
fn default_max_exec_time() -> u32 {
30
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnvPolicy {
#[serde(default)]
pub allowed_vars: Vec<String>,
#[serde(default)]
pub denied_vars: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxPolicy {
pub agent_id: String,
#[serde(default)]
pub sandbox_type: SandboxType,
#[serde(default)]
pub network: NetworkPolicy,
#[serde(default)]
pub filesystem: FilesystemPolicy,
#[serde(default)]
pub process: ProcessPolicy,
#[serde(default)]
pub env: EnvPolicy,
#[serde(default)]
pub allowed_tools: Vec<String>,
#[serde(default)]
pub denied_tools: Vec<String>,
#[serde(default = "default_true")]
pub audit_logging: bool,
}
fn default_true() -> bool {
true
}
impl Default for SandboxPolicy {
fn default() -> Self {
Self {
agent_id: String::new(),
sandbox_type: SandboxType::default(),
network: NetworkPolicy::default(),
filesystem: FilesystemPolicy::default(),
process: ProcessPolicy::default(),
env: EnvPolicy::default(),
allowed_tools: Vec::new(),
denied_tools: Vec::new(),
audit_logging: true,
}
}
}
impl SandboxPolicy {
pub fn new(agent_id: impl Into<String>) -> Self {
Self {
agent_id: agent_id.into(),
..Default::default()
}
}
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
if self.denied_tools.iter().any(|t| t == tool_name) {
return false;
}
if self.allowed_tools.is_empty() {
return true;
}
self.allowed_tools.iter().any(|t| t == tool_name)
}
pub fn is_domain_allowed(&self, domain: &str) -> bool {
if !self.network.allow_network {
return false;
}
for blocked in &self.network.blocked_domains {
if domain_matches(domain, blocked) {
return false;
}
}
if self.network.allowed_domains.is_empty() {
return true;
}
self.network.allowed_domains.iter().any(|a| domain_matches(domain, a))
}
pub fn is_path_readable(&self, path: &std::path::Path) -> bool {
self.filesystem.readable_paths.iter().any(|allowed| {
path.starts_with(allowed)
})
}
pub fn is_path_writable(&self, path: &std::path::Path) -> bool {
self.filesystem.writable_paths.iter().any(|allowed| {
path.starts_with(allowed)
})
}
pub fn is_command_allowed(&self, command: &str) -> bool {
if !self.process.allow_shell {
return false;
}
if self.process.blocked_commands.iter().any(|b| b == command) {
return false;
}
if self.process.allowed_commands.is_empty() {
return true;
}
self.process.allowed_commands.iter().any(|a| a == command)
}
pub fn effective_tools(&self) -> HashSet<String> {
let mut tools: HashSet<String> = self.allowed_tools.iter().cloned().collect();
for denied in &self.denied_tools {
tools.remove(denied);
}
tools
}
pub fn effective_sandbox_type(&self) -> SandboxType {
if cfg!(target_os = "linux") {
return self.sandbox_type.clone();
}
match &self.sandbox_type {
SandboxType::OsSandbox | SandboxType::Combined => {
tracing::warn!(
agent = %self.agent_id,
"OS sandbox unavailable on this platform; \
falling back to WASM-only sandbox"
);
SandboxType::Wasm
}
other => other.clone(),
}
}
}
fn domain_matches(domain: &str, pattern: &str) -> bool {
let domain_lower = domain.to_lowercase();
let pattern_lower = pattern.to_lowercase();
if pattern_lower == "*" {
return true;
}
if let Some(suffix) = pattern_lower.strip_prefix("*.") {
return domain_lower.ends_with(&format!(".{suffix}"))
|| domain_lower == suffix;
}
domain_lower == pattern_lower
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxAuditEntry {
pub timestamp: String,
pub agent_id: String,
pub action: String,
pub target: String,
pub allowed: bool,
pub reason: Option<String>,
}
impl SandboxAuditEntry {
pub fn allowed(
agent_id: impl Into<String>,
action: impl Into<String>,
target: impl Into<String>,
) -> Self {
Self {
timestamp: chrono::Utc::now().to_rfc3339(),
agent_id: agent_id.into(),
action: action.into(),
target: target.into(),
allowed: true,
reason: None,
}
}
pub fn denied(
agent_id: impl Into<String>,
action: impl Into<String>,
target: impl Into<String>,
reason: impl Into<String>,
) -> Self {
Self {
timestamp: chrono::Utc::now().to_rfc3339(),
agent_id: agent_id.into(),
action: action.into(),
target: target.into(),
allowed: false,
reason: Some(reason.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn default_sandbox_type_is_not_none() {
let st = SandboxType::default();
assert!(matches!(st, SandboxType::OsSandbox | SandboxType::Wasm));
}
#[test]
fn default_policy_has_secure_defaults() {
let policy = SandboxPolicy::default();
assert!(!policy.network.allow_network);
assert!(policy.filesystem.readable_paths.is_empty());
assert!(policy.filesystem.writable_paths.is_empty());
assert!(!policy.process.allow_shell);
assert!(policy.audit_logging);
}
#[test]
fn tool_allowed_when_list_empty() {
let policy = SandboxPolicy::new("test-agent");
assert!(policy.is_tool_allowed("any_tool"));
}
#[test]
fn tool_denied_when_in_denied_list() {
let policy = SandboxPolicy {
agent_id: "test".into(),
denied_tools: vec!["dangerous_tool".into()],
..Default::default()
};
assert!(!policy.is_tool_allowed("dangerous_tool"));
}
#[test]
fn tool_allowed_only_when_in_allowed_list() {
let policy = SandboxPolicy {
agent_id: "test".into(),
allowed_tools: vec!["read_file".into(), "grep".into()],
..Default::default()
};
assert!(policy.is_tool_allowed("read_file"));
assert!(policy.is_tool_allowed("grep"));
assert!(!policy.is_tool_allowed("bash"));
}
#[test]
fn denied_takes_precedence_over_allowed() {
let policy = SandboxPolicy {
agent_id: "test".into(),
allowed_tools: vec!["bash".into()],
denied_tools: vec!["bash".into()],
..Default::default()
};
assert!(!policy.is_tool_allowed("bash"));
}
#[test]
fn domain_not_allowed_when_network_disabled() {
let policy = SandboxPolicy::new("test");
assert!(!policy.is_domain_allowed("example.com"));
}
#[test]
fn domain_allowed_with_exact_match() {
let policy = SandboxPolicy {
agent_id: "test".into(),
network: NetworkPolicy {
allow_network: true,
allowed_domains: vec!["api.example.com".into()],
..Default::default()
},
..Default::default()
};
assert!(policy.is_domain_allowed("api.example.com"));
assert!(!policy.is_domain_allowed("evil.com"));
}
#[test]
fn domain_wildcard_match() {
let policy = SandboxPolicy {
agent_id: "test".into(),
network: NetworkPolicy {
allow_network: true,
allowed_domains: vec!["*.example.com".into()],
..Default::default()
},
..Default::default()
};
assert!(policy.is_domain_allowed("sub.example.com"));
assert!(policy.is_domain_allowed("example.com"));
assert!(!policy.is_domain_allowed("evil.com"));
}
#[test]
fn blocked_domain_takes_precedence() {
let policy = SandboxPolicy {
agent_id: "test".into(),
network: NetworkPolicy {
allow_network: true,
allowed_domains: vec!["*.example.com".into()],
blocked_domains: vec!["evil.example.com".into()],
..Default::default()
},
..Default::default()
};
assert!(!policy.is_domain_allowed("evil.example.com"));
assert!(policy.is_domain_allowed("good.example.com"));
}
#[test]
fn path_readable_check() {
let policy = SandboxPolicy {
agent_id: "test".into(),
filesystem: FilesystemPolicy {
readable_paths: vec![PathBuf::from("/home/user/workspace")],
..Default::default()
},
..Default::default()
};
assert!(policy.is_path_readable(Path::new("/home/user/workspace/file.rs")));
assert!(!policy.is_path_readable(Path::new("/etc/passwd")));
}
#[test]
fn path_writable_check() {
let policy = SandboxPolicy {
agent_id: "test".into(),
filesystem: FilesystemPolicy {
writable_paths: vec![PathBuf::from("/tmp/sandbox")],
..Default::default()
},
..Default::default()
};
assert!(policy.is_path_writable(Path::new("/tmp/sandbox/output.txt")));
assert!(!policy.is_path_writable(Path::new("/etc/config")));
}
#[test]
fn command_not_allowed_when_shell_disabled() {
let policy = SandboxPolicy::new("test");
assert!(!policy.is_command_allowed("ls"));
}
#[test]
fn command_allowed_when_shell_enabled() {
let policy = SandboxPolicy {
agent_id: "test".into(),
process: ProcessPolicy {
allow_shell: true,
..Default::default()
},
..Default::default()
};
assert!(policy.is_command_allowed("ls"));
}
#[test]
fn command_blocked_takes_precedence() {
let policy = SandboxPolicy {
agent_id: "test".into(),
process: ProcessPolicy {
allow_shell: true,
allowed_commands: vec!["rm".into()],
blocked_commands: vec!["rm".into()],
..Default::default()
},
..Default::default()
};
assert!(!policy.is_command_allowed("rm"));
}
#[test]
fn effective_tools_excludes_denied() {
let policy = SandboxPolicy {
agent_id: "test".into(),
allowed_tools: vec!["read".into(), "write".into(), "bash".into()],
denied_tools: vec!["bash".into()],
..Default::default()
};
let effective = policy.effective_tools();
assert!(effective.contains("read"));
assert!(effective.contains("write"));
assert!(!effective.contains("bash"));
}
#[test]
fn audit_entry_allowed() {
let entry = SandboxAuditEntry::allowed("agent-1", "file_read", "/tmp/test.txt");
assert!(entry.allowed);
assert!(entry.reason.is_none());
}
#[test]
fn audit_entry_denied() {
let entry = SandboxAuditEntry::denied(
"agent-1",
"network_connect",
"evil.com",
"domain not in allowlist",
);
assert!(!entry.allowed);
assert_eq!(entry.reason.as_deref(), Some("domain not in allowlist"));
}
#[test]
fn domain_matches_star() {
assert!(domain_matches("anything.com", "*"));
}
#[test]
fn domain_matches_case_insensitive() {
assert!(domain_matches("API.Example.COM", "api.example.com"));
}
#[test]
fn sandbox_policy_serialization_roundtrip() {
let policy = SandboxPolicy {
agent_id: "test-agent".into(),
sandbox_type: SandboxType::Combined,
network: NetworkPolicy {
allow_network: true,
allowed_domains: vec!["*.example.com".into()],
blocked_domains: vec!["evil.example.com".into()],
max_connections_per_minute: 60,
},
filesystem: FilesystemPolicy {
readable_paths: vec![PathBuf::from("/workspace")],
writable_paths: vec![PathBuf::from("/tmp")],
allow_create: true,
allow_delete: false,
max_file_size: 4 * 1024 * 1024,
},
process: ProcessPolicy {
allow_shell: true,
allowed_commands: vec!["git".into(), "cargo".into()],
blocked_commands: vec!["rm".into()],
max_execution_seconds: 60,
},
env: EnvPolicy {
allowed_vars: vec!["HOME".into()],
denied_vars: vec!["AWS_SECRET_ACCESS_KEY".into()],
},
allowed_tools: vec!["read_file".into()],
denied_tools: vec!["bash".into()],
audit_logging: true,
};
let json = serde_json::to_string(&policy).unwrap();
let restored: SandboxPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(restored.agent_id, "test-agent");
assert_eq!(restored.sandbox_type, SandboxType::Combined);
assert!(restored.network.allow_network);
assert!(restored.audit_logging);
}
}