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");
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");
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");
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");
if allowed {
Ok(())
} else {
Err(format!(
"agent '{}' cannot write '{}'",
self.policy.agent_id, path_str
))
}
}
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");
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());
}
#[test]
fn enforcer_allows_readable_path() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer
.check_file_read(std::path::Path::new("/workspace/src/main.rs"))
.is_ok());
}
#[test]
fn enforcer_denies_unreadable_path() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer
.check_file_read(std::path::Path::new("/etc/passwd"))
.is_err());
}
#[test]
fn enforcer_allows_writable_path() {
let enforcer = SandboxEnforcer::new(test_policy());
assert!(enforcer
.check_file_write(std::path::Path::new("/tmp/output/result.json"))
.is_ok());
}
#[test]
fn enforcer_denies_unwritable_path() {
let enforcer = SandboxEnforcer::new(test_policy());
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 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());
}
}