use clawft_plugin::sandbox::{SandboxAuditEntry, SandboxPolicy, SandboxType};
use std::sync::{Arc, Mutex};
pub struct SandboxEnforcer {
policy: SandboxPolicy,
audit_log: Arc<Mutex<Vec<SandboxAuditEntry>>>,
max_audit_entries: usize,
}
impl SandboxEnforcer {
pub fn new(policy: SandboxPolicy) -> Self {
Self {
policy,
audit_log: Arc::new(Mutex::new(Vec::new())),
max_audit_entries: 10_000,
}
}
pub fn policy(&self) -> &SandboxPolicy {
&self.policy
}
pub fn check_tool(&self, tool_name: &str) -> Result<(), String> {
let allowed = self.policy.is_tool_allowed(tool_name);
self.log_decision("tool_invoke", tool_name, allowed, "tool not in allowlist");
self.emit_sandbox_chain_event("tool_invoke", tool_name, allowed);
if allowed {
Ok(())
} else {
Err(format!(
"agent '{}' is not allowed to use tool '{}'",
self.policy.agent_id, tool_name
))
}
}
pub fn check_network(&self, domain: &str) -> Result<(), String> {
let allowed = self.policy.is_domain_allowed(domain);
self.log_decision("network_connect", domain, allowed, "domain not allowed");
self.emit_sandbox_chain_event("network_connect", domain, allowed);
if allowed {
Ok(())
} else {
Err(format!(
"agent '{}' is not allowed to connect to '{}'",
self.policy.agent_id, domain
))
}
}
pub fn check_file_read(&self, path: &std::path::Path) -> Result<(), String> {
let path_str = path.to_string_lossy();
let allowed = self.policy.is_path_readable(path);
self.log_decision("file_read", &path_str, allowed, "path not readable");
self.emit_sandbox_chain_event("file_read", &path_str, allowed);
if allowed {
Ok(())
} else {
Err(format!(
"agent '{}' cannot read '{}'",
self.policy.agent_id, path_str
))
}
}
pub fn check_file_write(&self, path: &std::path::Path) -> Result<(), String> {
let path_str = path.to_string_lossy();
let allowed = self.policy.is_path_writable(path);
self.log_decision("file_write", &path_str, allowed, "path not writable");
self.emit_sandbox_chain_event("file_write", &path_str, allowed);
if allowed {
Ok(())
} else {
Err(format!(
"agent '{}' cannot write '{}'",
self.policy.agent_id, path_str
))
}
}
fn emit_sandbox_chain_event(&self, action: &str, target: &str, allowed: bool) {
crate::chain_event!(
"sandbox",
crate::chain_event::EVENT_KIND_SANDBOX_EXECUTE,
{
agent_id: self.policy.agent_id,
action: action,
target: target,
allowed: allowed
}
);
}
pub fn check_command(&self, command: &str) -> Result<(), String> {
let allowed = self.policy.is_command_allowed(command);
self.log_decision("command_exec", command, allowed, "command not allowed");
crate::chain_event!(
"sandbox",
crate::chain_event::EVENT_KIND_SANDBOX_EXECUTE,
{
"agent_id": self.policy.agent_id,
"action": "command_exec",
"target": command,
"allowed": allowed
}
);
if allowed {
Ok(())
} else {
Err(format!(
"agent '{}' cannot execute command '{}'",
self.policy.agent_id, command
))
}
}
pub fn effective_sandbox_type(&self) -> SandboxType {
self.policy.effective_sandbox_type()
}
pub fn audit_entries(&self) -> Vec<SandboxAuditEntry> {
self.audit_log.lock().unwrap().clone()
}
pub fn clear_audit_log(&self) {
self.audit_log.lock().unwrap().clear();
}
fn log_decision(&self, action: &str, target: &str, allowed: bool, deny_reason: &str) {
if !self.policy.audit_logging {
return;
}
let entry = if allowed {
tracing::debug!(
agent = %self.policy.agent_id,
action = action,
target = target,
"sandbox: allowed"
);
SandboxAuditEntry::allowed(&self.policy.agent_id, action, target)
} else {
tracing::warn!(
agent = %self.policy.agent_id,
action = action,
target = target,
reason = deny_reason,
"sandbox: denied"
);
SandboxAuditEntry::denied(&self.policy.agent_id, action, target, deny_reason)
};
let mut log = self.audit_log.lock().unwrap();
if log.len() >= self.max_audit_entries {
let keep = self.max_audit_entries / 2;
let drain_end = log.len() - keep;
log.drain(..drain_end);
}
log.push(entry);
}
}
pub fn apply_os_sandbox(policy: &SandboxPolicy) -> Result<(), String> {
let effective = policy.effective_sandbox_type();
match effective {
SandboxType::Wasm => {
tracing::debug!(
agent = %policy.agent_id,
"using WASM-only sandbox (no OS sandbox)"
);
Ok(())
}
SandboxType::OsSandbox | SandboxType::Combined => {
#[cfg(target_os = "linux")]
{
if let Err(e) = apply_linux_sandbox(policy) {
tracing::warn!(
agent = %policy.agent_id,
error = %e,
"OS sandbox unavailable, using WASM sandbox only"
);
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
{
tracing::warn!(
agent = %policy.agent_id,
"OS sandbox requested but unavailable on this platform; \
WASM sandbox will be used instead"
);
Ok(())
}
}
_ => {
tracing::warn!(
agent = %policy.agent_id,
"unknown sandbox type variant; falling back to WASM sandbox"
);
Ok(())
}
}
}
#[cfg(target_os = "linux")]
fn apply_linux_sandbox(policy: &SandboxPolicy) -> Result<(), String> {
tracing::warn!(
agent = %policy.agent_id,
"Linux OS sandbox (seccomp + landlock) is not yet implemented; \
falling back to WASM-only sandboxing"
);
Err("Linux OS sandbox not yet implemented: seccomp and landlock support pending".into())
}
#[cfg(test)]
mod tests {
use super::*;
use clawft_plugin::sandbox::{FilesystemPolicy, NetworkPolicy, ProcessPolicy};
use std::path::PathBuf;
fn test_policy() -> SandboxPolicy {
SandboxPolicy {
agent_id: "test-agent".into(),
sandbox_type: SandboxType::Wasm,
network: NetworkPolicy {
allow_network: true,
allowed_domains: vec!["api.example.com".into()],
..Default::default()
},
filesystem: FilesystemPolicy {
readable_paths: vec![PathBuf::from("/workspace")],
writable_paths: vec![PathBuf::from("/tmp/output")],
..Default::default()
},
process: ProcessPolicy {
allow_shell: true,
allowed_commands: vec!["git".into(), "cargo".into()],
blocked_commands: vec!["rm".into()],
..Default::default()
},
allowed_tools: vec!["read_file".into(), "write_file".into()],
denied_tools: vec!["bash_exec".into()],
..Default::default()
}
}
#[test]
fn enforcer_allows_permitted_tool() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer.check_tool("read_file").is_ok());
}
#[test]
fn enforcer_denies_unpermitted_tool() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer.check_tool("bash_exec").is_err());
assert!(enforcer.check_tool("unknown_tool").is_err());
}
#[test]
fn enforcer_allows_permitted_domain() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer.check_network("api.example.com").is_ok());
}
#[test]
fn enforcer_denies_unpermitted_domain() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer.check_network("evil.com").is_err());
}
fn test_policy_with_dirs(
read_dir: &std::path::Path,
write_dir: &std::path::Path,
) -> SandboxPolicy {
let mut policy = test_policy();
policy.filesystem.readable_paths = vec![read_dir.to_path_buf()];
policy.filesystem.writable_paths = vec![write_dir.to_path_buf()];
policy
}
#[test]
fn enforcer_allows_readable_path() {
let read_dir = tempfile::tempdir().unwrap();
let target = read_dir.path().join("main.rs");
std::fs::write(&target, b"fn main() {}").unwrap();
let enforcer =
SandboxEnforcer::new(test_policy_with_dirs(read_dir.path(), read_dir.path()));
assert!(enforcer.check_file_read(&target).is_ok());
}
#[test]
fn enforcer_denies_unreadable_path() {
let read_dir = tempfile::tempdir().unwrap();
let enforcer =
SandboxEnforcer::new(test_policy_with_dirs(read_dir.path(), read_dir.path()));
assert!(
enforcer
.check_file_read(std::path::Path::new("/etc/passwd"))
.is_err()
);
}
#[test]
fn enforcer_allows_writable_path() {
let write_dir = tempfile::tempdir().unwrap();
let target = write_dir.path().join("result.json");
let enforcer =
SandboxEnforcer::new(test_policy_with_dirs(write_dir.path(), write_dir.path()));
assert!(enforcer.check_file_write(&target).is_ok());
}
#[test]
fn enforcer_denies_unwritable_path() {
let write_dir = tempfile::tempdir().unwrap();
let enforcer =
SandboxEnforcer::new(test_policy_with_dirs(write_dir.path(), write_dir.path()));
assert!(
enforcer
.check_file_write(std::path::Path::new("/etc/config"))
.is_err()
);
}
#[test]
fn enforcer_allows_permitted_command() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer.check_command("git").is_ok());
assert!(enforcer.check_command("cargo").is_ok());
}
#[test]
fn enforcer_denies_blocked_command() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer.check_command("rm").is_err());
}
#[test]
fn enforcer_denies_unlisted_command() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer.check_command("wget").is_err());
}
#[test]
fn audit_log_records_decisions() {
let enforcer = SandboxEnforcer::new(test_policy());
let _ = enforcer.check_tool("read_file");
let _ = enforcer.check_tool("bash_exec");
let entries = enforcer.audit_entries();
assert_eq!(entries.len(), 2);
assert!(entries[0].allowed);
assert!(!entries[1].allowed);
}
#[test]
fn sandbox_checks_emit_chain_events() {
crate::chain_event::drain_pending_chain_events();
let enforcer = SandboxEnforcer::new(test_policy());
let _ = enforcer.check_tool("read_file");
let _ = enforcer.check_network("api.example.com");
let _ = enforcer.check_network("evil.com");
let read_dir = tempfile::tempdir().unwrap();
let target = read_dir.path().join("main.rs");
std::fs::write(&target, b"fn main() {}").unwrap();
let enforcer_fs =
SandboxEnforcer::new(test_policy_with_dirs(read_dir.path(), read_dir.path()));
let _ = enforcer_fs.check_file_read(&target);
let _ = enforcer_fs.check_file_write(std::path::Path::new("/etc/config"));
let events = crate::chain_event::drain_pending_chain_events();
let sandbox_events: Vec<_> = events
.iter()
.filter(|e| e.kind == crate::chain_event::EVENT_KIND_SANDBOX_EXECUTE)
.collect();
assert!(
sandbox_events.len() >= 5,
"expected ≥5 sandbox.execute events, got {}: {:?}",
sandbox_events.len(),
sandbox_events
.iter()
.map(|e| e.payload.as_ref().and_then(|p| p.get("action")).cloned())
.collect::<Vec<_>>()
);
for e in &sandbox_events {
assert_eq!(e.source, "sandbox");
assert!(e.payload.is_some());
}
let actions: Vec<String> = sandbox_events
.iter()
.filter_map(|e| {
e.payload
.as_ref()
.and_then(|p| p.get("action"))
.and_then(|v| v.as_str())
.map(String::from)
})
.collect();
assert!(actions.iter().any(|a| a == "tool_invoke"));
assert!(actions.iter().any(|a| a == "network_connect"));
assert!(actions.iter().any(|a| a == "file_read"));
assert!(actions.iter().any(|a| a == "file_write"));
}
#[test]
fn audit_log_can_be_cleared() {
let enforcer = SandboxEnforcer::new(test_policy());
let _ = enforcer.check_tool("read_file");
assert_eq!(enforcer.audit_entries().len(), 1);
enforcer.clear_audit_log();
assert!(enforcer.audit_entries().is_empty());
}
#[test]
fn apply_os_sandbox_wasm_only() {
let policy = SandboxPolicy {
agent_id: "test".into(),
sandbox_type: SandboxType::Wasm,
..Default::default()
};
assert!(apply_os_sandbox(&policy).is_ok());
}
#[test]
fn apply_os_sandbox_os_type() {
let policy = SandboxPolicy {
agent_id: "test".into(),
sandbox_type: SandboxType::OsSandbox,
..Default::default()
};
assert!(apply_os_sandbox(&policy).is_ok());
}
}