use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::Mount;
use crate::error::{Result, SandboxError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SandboxRuntime {
Host,
#[default]
Docker,
Podman,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum NetworkPolicy {
#[default]
None,
Limited(Vec<String>),
Full,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxPolicy {
pub runtime: SandboxRuntime,
pub image: String,
pub network: NetworkPolicy,
pub cpu_limit: Option<f64>,
pub memory_limit_mb: Option<u64>,
pub pid_limit: Option<u64>,
pub read_only_rootfs: bool,
pub workspace_mount: Option<PathBuf>,
pub allowed_mount_sources: Vec<PathBuf>,
pub proxy_image: String,
pub proxy_listen_port: u16,
pub proxy_container_name: Option<String>,
}
impl Default for SandboxPolicy {
fn default() -> Self {
Self {
runtime: SandboxRuntime::default(),
image: "ghcr.io/brainwires/brainclaw-sandbox:latest".to_string(),
network: NetworkPolicy::default(),
cpu_limit: Some(2.0),
memory_limit_mb: Some(1024),
pid_limit: Some(256),
read_only_rootfs: true,
workspace_mount: None,
allowed_mount_sources: Vec::new(),
proxy_image: "ghcr.io/brainwires/brainwires-sandbox-proxy:latest".to_string(),
proxy_listen_port: 3128,
proxy_container_name: None,
}
}
}
impl SandboxPolicy {
pub fn validate_mount(&self, mount: &Mount) -> Result<()> {
if mount
.source
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(SandboxError::PolicyViolation(format!(
"mount source contains parent-dir traversal: {}",
mount.source.display()
)));
}
let allowed = self
.allowed_mount_sources
.iter()
.chain(self.workspace_mount.iter())
.any(|root| is_within(&mount.source, root));
if !allowed {
return Err(SandboxError::PolicyViolation(format!(
"mount source {} is not in any allowed root",
mount.source.display()
)));
}
Ok(())
}
}
fn is_within(candidate: &Path, root: &Path) -> bool {
match (candidate.is_absolute(), root.is_absolute()) {
(true, true) | (false, false) => candidate.starts_with(root),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn policy_with(allowed: Vec<PathBuf>) -> SandboxPolicy {
SandboxPolicy {
allowed_mount_sources: allowed,
..SandboxPolicy::default()
}
}
#[test]
fn allows_paths_inside_whitelist() {
let p = policy_with(vec![PathBuf::from("/workspace")]);
let m = Mount {
source: PathBuf::from("/workspace/project"),
target: PathBuf::from("/mnt/project"),
read_only: true,
};
assert!(p.validate_mount(&m).is_ok());
}
#[test]
fn rejects_etc_passwd() {
let p = policy_with(vec![PathBuf::from("/workspace")]);
let m = Mount {
source: PathBuf::from("/etc/passwd"),
target: PathBuf::from("/mnt/passwd"),
read_only: true,
};
let err = p.validate_mount(&m).unwrap_err();
assert!(matches!(err, SandboxError::PolicyViolation(_)));
}
#[test]
fn rejects_parent_dir_traversal() {
let p = policy_with(vec![PathBuf::from("/workspace")]);
let m = Mount {
source: PathBuf::from("/workspace/../etc"),
target: PathBuf::from("/mnt/etc"),
read_only: true,
};
let err = p.validate_mount(&m).unwrap_err();
assert!(matches!(err, SandboxError::PolicyViolation(_)));
}
#[test]
fn workspace_mount_is_implicitly_allowed() {
let p = SandboxPolicy {
workspace_mount: Some(PathBuf::from("/ws")),
..SandboxPolicy::default()
};
let m = Mount {
source: PathBuf::from("/ws/sub"),
target: PathBuf::from("/mnt/sub"),
read_only: false,
};
assert!(p.validate_mount(&m).is_ok());
}
}