use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PathPattern(pub PathBuf);
impl PathPattern {
pub fn new(p: impl Into<PathBuf>) -> Self {
Self(p.into())
}
pub fn as_path(&self) -> &Path {
&self.0
}
}
impl<P: Into<PathBuf>> From<P> for PathPattern {
fn from(p: P) -> Self {
Self::new(p)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DomainPattern(pub String);
impl DomainPattern {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct FsPolicy {
pub deny_read: Vec<PathPattern>,
pub allow_read_within_deny: Vec<PathPattern>,
pub allow_write: Vec<PathPattern>,
pub deny_write_within_allow: Vec<PathPattern>,
pub allow_git_config: bool,
#[serde(default = "default_mandatory_deny_search_depth")]
pub mandatory_deny_search_depth: u8,
}
fn default_mandatory_deny_search_depth() -> u8 {
3
}
impl Default for FsPolicy {
fn default() -> Self {
Self {
deny_read: Vec::new(),
allow_read_within_deny: Vec::new(),
allow_write: Vec::new(),
deny_write_within_allow: Vec::new(),
allow_git_config: false,
mandatory_deny_search_depth: default_mandatory_deny_search_depth(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct NetPolicy {
pub allowed_domains: Vec<DomainPattern>,
pub denied_domains: Vec<DomainPattern>,
pub allow_local_binding: bool,
pub mitm: Option<MitmConfig>,
pub weaker_macos_isolation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MitmConfig {
pub ca_bundle: PathBuf,
#[serde(default)]
pub socket_map: Vec<(String, PathBuf)>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct ResourceLimits {
pub cpu_time_secs: Option<u64>,
pub wall_time_secs: Option<u64>,
pub max_rss_bytes: Option<u64>,
pub max_open_fds: Option<u64>,
pub max_output_bytes: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustPreference {
#[default]
Auto,
Require,
Forbid,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct SandboxPolicy {
pub fs: FsPolicy,
pub net: NetPolicy,
pub limits: ResourceLimits,
pub trust: TrustPreference,
}
impl SandboxPolicy {
pub fn strict_default() -> Self {
Self::default()
}
pub fn compose(parent: &Self, child: &Self) -> Self {
Self {
fs: FsPolicy {
deny_read: union(&parent.fs.deny_read, &child.fs.deny_read),
allow_read_within_deny: union(
&parent.fs.allow_read_within_deny,
&child.fs.allow_read_within_deny,
),
allow_write: parent.fs.allow_write.clone(),
deny_write_within_allow: union(
&parent.fs.deny_write_within_allow,
&child.fs.deny_write_within_allow,
),
allow_git_config: parent.fs.allow_git_config && child.fs.allow_git_config,
mandatory_deny_search_depth: parent
.fs
.mandatory_deny_search_depth
.max(child.fs.mandatory_deny_search_depth),
},
net: NetPolicy {
allowed_domains: parent.net.allowed_domains.clone(),
denied_domains: union(&parent.net.denied_domains, &child.net.denied_domains),
allow_local_binding: parent.net.allow_local_binding
&& child.net.allow_local_binding,
mitm: parent.net.mitm.clone(),
weaker_macos_isolation: parent.net.weaker_macos_isolation
&& child.net.weaker_macos_isolation,
},
limits: ResourceLimits {
cpu_time_secs: min_opt(parent.limits.cpu_time_secs, child.limits.cpu_time_secs),
wall_time_secs: min_opt(parent.limits.wall_time_secs, child.limits.wall_time_secs),
max_rss_bytes: min_opt(parent.limits.max_rss_bytes, child.limits.max_rss_bytes),
max_open_fds: min_opt(parent.limits.max_open_fds, child.limits.max_open_fds),
max_output_bytes: min_opt(
parent.limits.max_output_bytes,
child.limits.max_output_bytes,
),
},
trust: strictest_trust(parent.trust, child.trust),
}
}
}
fn union<T: Clone + PartialEq>(a: &[T], b: &[T]) -> Vec<T> {
let mut out = a.to_vec();
for x in b {
if !out.contains(x) {
out.push(x.clone());
}
}
out
}
fn min_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
match (a, b) {
(None, None) => None,
(Some(x), None) | (None, Some(x)) => Some(x),
(Some(x), Some(y)) => Some(x.min(y)),
}
}
fn strictest_trust(a: TrustPreference, b: TrustPreference) -> TrustPreference {
use TrustPreference::*;
match (a, b) {
(Forbid, _) | (_, Forbid) => Forbid,
(Require, _) | (_, Require) => Require,
_ => Auto,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_is_empty_and_auto() {
let p = SandboxPolicy::default();
assert!(p.fs.deny_read.is_empty());
assert!(p.fs.allow_write.is_empty());
assert!(p.net.allowed_domains.is_empty());
assert_eq!(p.trust, TrustPreference::Auto);
}
#[test]
fn strict_default_matches_default() {
assert_eq!(SandboxPolicy::strict_default(), SandboxPolicy::default());
}
#[test]
fn path_pattern_serde_is_transparent() {
let p = PathPattern::new("/etc/passwd");
let json = serde_json::to_string(&p).unwrap();
assert_eq!(json, "\"/etc/passwd\"");
let parsed: PathPattern = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, p);
}
#[test]
fn domain_pattern_serde_is_transparent() {
let d = DomainPattern::new("github.com");
let json = serde_json::to_string(&d).unwrap();
assert_eq!(json, "\"github.com\"");
}
#[test]
fn trust_preference_lowercase_serde() {
assert_eq!(
serde_json::to_string(&TrustPreference::Auto).unwrap(),
"\"auto\""
);
assert_eq!(
serde_json::to_string(&TrustPreference::Require).unwrap(),
"\"require\""
);
assert_eq!(
serde_json::to_string(&TrustPreference::Forbid).unwrap(),
"\"forbid\""
);
}
#[test]
fn policy_round_trips_through_json() {
let p = SandboxPolicy {
fs: FsPolicy {
deny_read: vec!["/etc/shadow".into()],
allow_write: vec!["/tmp".into(), "/work".into()],
allow_git_config: true,
..Default::default()
},
net: NetPolicy {
allowed_domains: vec![DomainPattern::new("github.com")],
..Default::default()
},
..Default::default()
};
let json = serde_json::to_string(&p).unwrap();
let parsed: SandboxPolicy = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, p);
}
#[test]
fn policy_accepts_partial_json_via_serde_default() {
let json = r#"{"fs": {"allow_write": ["/tmp"]}}"#;
let p: SandboxPolicy = serde_json::from_str(json).unwrap();
assert_eq!(p.fs.allow_write, vec![PathPattern::new("/tmp")]);
assert_eq!(p.trust, TrustPreference::Auto);
assert!(p.net.allowed_domains.is_empty());
}
#[test]
fn compose_with_default_child_is_identity_for_parent_denies() {
let parent = SandboxPolicy {
fs: FsPolicy {
deny_read: vec!["/etc/shadow".into()],
allow_write: vec!["/work".into()],
..Default::default()
},
..Default::default()
};
let composed = SandboxPolicy::compose(&parent, &SandboxPolicy::default());
assert_eq!(composed.fs.deny_read, parent.fs.deny_read);
assert_eq!(composed.fs.allow_write, parent.fs.allow_write);
}
#[test]
fn compose_unions_denies_so_child_can_add_restrictions() {
let parent = SandboxPolicy {
fs: FsPolicy {
deny_read: vec!["/etc/shadow".into()],
..Default::default()
},
..Default::default()
};
let child = SandboxPolicy {
fs: FsPolicy {
deny_read: vec!["/etc/passwd".into()],
..Default::default()
},
..Default::default()
};
let composed = SandboxPolicy::compose(&parent, &child);
assert!(composed.fs.deny_read.contains(&"/etc/shadow".into()));
assert!(composed.fs.deny_read.contains(&"/etc/passwd".into()));
assert_eq!(
composed.fs.deny_read.len(),
2,
"union should de-duplicate, not just concat"
);
}
#[test]
fn compose_union_dedupes_overlapping_denies() {
let p = SandboxPolicy {
fs: FsPolicy {
deny_read: vec!["/a".into(), "/b".into()],
..Default::default()
},
..Default::default()
};
let c = SandboxPolicy {
fs: FsPolicy {
deny_read: vec!["/b".into(), "/c".into()],
..Default::default()
},
..Default::default()
};
let composed = SandboxPolicy::compose(&p, &c);
assert_eq!(composed.fs.deny_read.len(), 3, "a, b, c — b appears once");
}
#[test]
fn compose_drops_child_allow_writes_so_child_cannot_widen() {
let parent = SandboxPolicy {
fs: FsPolicy {
allow_write: vec!["/work".into()],
..Default::default()
},
..Default::default()
};
let child = SandboxPolicy {
fs: FsPolicy {
allow_write: vec!["/etc".into(), "/work".into()],
..Default::default()
},
..Default::default()
};
let composed = SandboxPolicy::compose(&parent, &child);
assert_eq!(
composed.fs.allow_write,
vec![PathPattern::new("/work")],
"child cannot smuggle in /etc by listing it in its own allow_write"
);
}
#[test]
fn compose_allow_git_config_is_logical_and() {
for (parent_b, child_b, want) in [
(true, true, true),
(true, false, false),
(false, true, false),
(false, false, false),
] {
let p = SandboxPolicy {
fs: FsPolicy {
allow_git_config: parent_b,
..Default::default()
},
..Default::default()
};
let c = SandboxPolicy {
fs: FsPolicy {
allow_git_config: child_b,
..Default::default()
},
..Default::default()
};
assert_eq!(
SandboxPolicy::compose(&p, &c).fs.allow_git_config,
want,
"parent={parent_b} child={child_b}"
);
}
}
#[test]
fn compose_limits_take_minimum_with_none_meaning_unlimited() {
let parent = SandboxPolicy {
limits: ResourceLimits {
wall_time_secs: Some(60),
max_rss_bytes: None, ..Default::default()
},
..Default::default()
};
let child = SandboxPolicy {
limits: ResourceLimits {
wall_time_secs: Some(30), max_rss_bytes: Some(1024), ..Default::default()
},
..Default::default()
};
let c = SandboxPolicy::compose(&parent, &child);
assert_eq!(c.limits.wall_time_secs, Some(30), "tighter wins");
assert_eq!(
c.limits.max_rss_bytes,
Some(1024),
"presence beats absence — a stated limit always restricts an unlimited side"
);
assert_eq!(c.limits.cpu_time_secs, None, "both unlimited → unlimited");
}
#[test]
fn compose_trust_picks_strictest() {
use TrustPreference::*;
for (a, b, want) in [
(Auto, Auto, Auto),
(Auto, Require, Require),
(Require, Auto, Require),
(Require, Require, Require),
(Auto, Forbid, Forbid),
(Forbid, Auto, Forbid),
(Require, Forbid, Forbid),
(Forbid, Require, Forbid),
(Forbid, Forbid, Forbid),
] {
let p = SandboxPolicy {
trust: a,
..Default::default()
};
let c = SandboxPolicy {
trust: b,
..Default::default()
};
assert_eq!(
SandboxPolicy::compose(&p, &c).trust,
want,
"trust({a:?}, {b:?})"
);
}
}
#[test]
fn compose_idempotent_with_self() {
let p = SandboxPolicy {
fs: FsPolicy {
deny_read: vec!["/a".into()],
allow_write: vec!["/work".into()],
allow_git_config: true,
..Default::default()
},
limits: ResourceLimits {
wall_time_secs: Some(45),
..Default::default()
},
trust: TrustPreference::Require,
..Default::default()
};
assert_eq!(
SandboxPolicy::compose(&p, &p),
p,
"composing a policy with itself must equal the original"
);
}
#[test]
fn compose_mitm_inherited_from_parent_only() {
let mitm = MitmConfig {
ca_bundle: "/cert.pem".into(),
socket_map: vec![],
};
let parent = SandboxPolicy {
net: NetPolicy {
mitm: Some(mitm.clone()),
..Default::default()
},
..Default::default()
};
let child = SandboxPolicy::default(); assert_eq!(
SandboxPolicy::compose(&parent, &child).net.mitm,
Some(mitm),
"parent MITM must survive composing with a no-MITM child"
);
}
#[test]
fn fs_policy_default_has_safe_baseline_depth() {
assert_eq!(
FsPolicy::default().mandatory_deny_search_depth,
3,
"Rust default must match the issue's documented baseline"
);
let from_empty_json: FsPolicy = serde_json::from_str("{}").unwrap();
assert_eq!(
from_empty_json.mandatory_deny_search_depth, 3,
"serde missing-field default must match Rust default"
);
}
#[test]
fn compose_takes_max_mandatory_deny_search_depth() {
let parent = SandboxPolicy {
fs: FsPolicy {
mandatory_deny_search_depth: 3,
..Default::default()
},
..Default::default()
};
let child = SandboxPolicy {
fs: FsPolicy {
mandatory_deny_search_depth: 10,
..Default::default()
},
..Default::default()
};
assert_eq!(
SandboxPolicy::compose(&parent, &child)
.fs
.mandatory_deny_search_depth,
10,
"child must be able to ratchet paranoia UP via deeper checks"
);
}
#[test]
fn compose_keeps_parent_depth_when_child_is_shallower() {
let parent = SandboxPolicy {
fs: FsPolicy {
mandatory_deny_search_depth: 10,
..Default::default()
},
..Default::default()
};
let child = SandboxPolicy {
fs: FsPolicy {
mandatory_deny_search_depth: 3,
..Default::default()
},
..Default::default()
};
assert_eq!(
SandboxPolicy::compose(&parent, &child)
.fs
.mandatory_deny_search_depth,
10,
"child cannot widen by requesting shallower deny-rule checks"
);
}
#[test]
fn sandbox_policy_deserialize_only_used_in_fs_worker_binary() {
const ALLOWED: &[&str] = &[
"src/bin/koda-fs-worker.rs",
];
const PATTERNS: &[&str] = &[
"from_str::<SandboxPolicy>",
"from_slice::<SandboxPolicy>",
"from_str::<crate::policy::SandboxPolicy>",
": SandboxPolicy = serde_json",
": SandboxPolicy = serde",
];
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir
.parent()
.expect("koda-sandbox lives one level under the workspace root");
let mut violations: Vec<String> = Vec::new();
scan_rust_files(workspace_root, &mut |path, contents| {
let rel = path
.strip_prefix(workspace_root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/");
if rel.starts_with("target/") {
return;
}
if rel.ends_with("koda-sandbox/src/policy.rs") {
return;
}
for pat in PATTERNS {
if contents.contains(pat) {
let allowed = ALLOWED.iter().any(|a| rel.ends_with(a) || rel.contains(a));
if !allowed {
violations.push(format!(" {rel}: matched `{pat}`"));
}
}
}
});
assert!(
violations.is_empty(),
"\n\nUnauthorized SandboxPolicy deserialization site(s) detected:\n{}\n\n\
koda is config-free at runtime. Trust mode → policy_for_agent is the\n\
only path. The single allowed deserialization site is the koda-fs-worker\n\
binary (IPC across a process boundary). If you have a legitimate IPC\n\
reason to add another, update the ALLOWED allowlist in this test with\n\
a comment explaining why. If your motivation is \"users want to override\",\n\
extend `policy_for_agent` or add a trust mode instead.\n",
violations.join("\n")
);
}
fn scan_rust_files(root: &std::path::Path, on_file: &mut dyn FnMut(&std::path::Path, &str)) {
let entries = match std::fs::read_dir(root) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') || name == "target" || name == "node_modules" {
continue;
}
let file_type = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if file_type.is_dir() {
scan_rust_files(&path, on_file);
} else if file_type.is_file()
&& path.extension().and_then(|e| e.to_str()) == Some("rs")
&& let Ok(contents) = std::fs::read_to_string(&path)
{
on_file(&path, &contents);
}
}
}
}