use std::collections::BTreeSet;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tracing::warn;
use roboticus_core::{InputAuthority, PolicyDecision, Result, RoboticusError};
use crate::policy::{PolicyContext, PolicyRule, ToolCallRequest};
const DEFAULT_MAX_DEPTH: u32 = 4;
const DEFAULT_MAX_MEMORY: u64 = 512 * 1024 * 1024;
const DEFAULT_MAX_PROCESSES: u32 = 16;
const DEFAULT_MAX_EXECUTION_SECS: u64 = 300;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum FilesystemPolicy {
#[default]
Full,
WorkspaceOnly { allowed_paths: BTreeSet<PathBuf> },
None,
}
impl FilesystemPolicy {
fn restrict_to(&self, child: &Self) -> Self {
match (self, child) {
(Self::None, _) | (_, Self::None) => Self::None,
(Self::WorkspaceOnly { allowed_paths: pp }, Self::Full) => Self::WorkspaceOnly {
allowed_paths: pp.clone(),
},
(Self::Full, Self::WorkspaceOnly { allowed_paths }) => Self::WorkspaceOnly {
allowed_paths: allowed_paths.clone(),
},
(
Self::WorkspaceOnly { allowed_paths: pp },
Self::WorkspaceOnly { allowed_paths: cp },
) => Self::WorkspaceOnly {
allowed_paths: pp.intersection(cp).cloned().collect(),
},
(Self::Full, Self::Full) => Self::Full,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum NetworkPolicy {
#[default]
Full,
AllowList(BTreeSet<String>),
None,
}
impl NetworkPolicy {
fn restrict_to(&self, child: &Self) -> Self {
match (self, child) {
(Self::None, _) | (_, Self::None) => Self::None,
(Self::AllowList(ph), Self::Full) => Self::AllowList(ph.clone()),
(Self::Full, Self::AllowList(ch)) => Self::AllowList(ch.clone()),
(Self::AllowList(ph), Self::AllowList(ch)) => {
Self::AllowList(ph.intersection(ch).cloned().collect())
}
(Self::Full, Self::Full) => Self::Full,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SandboxInheritance {
pub allowed_paths: Vec<String>,
pub max_memory_bytes: Option<u64>,
pub max_processes: Option<u32>,
pub max_execution_secs: Option<u64>,
pub depth: u32,
pub max_depth: u32,
#[serde(default)]
pub tool_allowlist: BTreeSet<String>,
#[serde(default)]
pub tool_denylist: BTreeSet<String>,
#[serde(default = "default_authority_ceiling")]
pub authority_ceiling: InputAuthority,
#[serde(default)]
pub filesystem_policy: FilesystemPolicy,
#[serde(default)]
pub network_policy: NetworkPolicy,
#[serde(default)]
pub parent_agent_id: String,
}
fn default_authority_ceiling() -> InputAuthority {
InputAuthority::Creator
}
impl Default for SandboxInheritance {
fn default() -> Self {
Self {
allowed_paths: vec![],
max_memory_bytes: Some(DEFAULT_MAX_MEMORY),
max_processes: Some(DEFAULT_MAX_PROCESSES),
max_execution_secs: Some(DEFAULT_MAX_EXECUTION_SECS),
depth: 0,
max_depth: DEFAULT_MAX_DEPTH,
tool_allowlist: BTreeSet::new(),
tool_denylist: BTreeSet::new(),
authority_ceiling: InputAuthority::Creator,
filesystem_policy: FilesystemPolicy::Full,
network_policy: NetworkPolicy::Full,
parent_agent_id: String::new(),
}
}
}
impl SandboxInheritance {
pub fn root(allowed_paths: Vec<String>) -> Self {
Self {
allowed_paths,
..Default::default()
}
}
pub fn root_for_agent(agent_id: &str) -> Self {
Self {
parent_agent_id: agent_id.to_string(),
..Default::default()
}
}
pub fn builder() -> SandboxInheritanceBuilderInit {
SandboxInheritanceBuilderInit {
tool_allowlist: BTreeSet::new(),
tool_denylist: BTreeSet::new(),
filesystem_policy: FilesystemPolicy::Full,
network_policy: NetworkPolicy::Full,
}
}
pub fn can_delegate(&self) -> bool {
self.depth < self.max_depth
}
pub fn restrict(&self, req: &SandboxInheritance) -> Result<SandboxInheritance> {
if !self.can_delegate() {
return Err(RoboticusError::Config(
"parent sandbox is at maximum delegation depth".into(),
));
}
if let (Some(pm), Some(cm)) = (self.max_memory_bytes, req.max_memory_bytes)
&& cm > pm
{
return Err(RoboticusError::Config(format!(
"child requested {cm} bytes memory, parent allows at most {pm}"
)));
}
if let (Some(pp), Some(cp)) = (self.max_processes, req.max_processes)
&& cp > pp
{
return Err(RoboticusError::Config(format!(
"child requested {cp} processes, parent allows at most {pp}"
)));
}
if let (Some(ps), Some(cs)) = (self.max_execution_secs, req.max_execution_secs)
&& cs > ps
{
return Err(RoboticusError::Config(format!(
"child requested {cs}s execution, parent allows at most {ps}s"
)));
}
if !self.child_paths_subset(&req.allowed_paths) {
return Err(RoboticusError::Config(
"child requested filesystem paths outside parent's allowed set".into(),
));
}
let authority_ceiling = if req.authority_ceiling > self.authority_ceiling {
warn!(
parent = %self.parent_agent_id,
attempted = ?req.authority_ceiling,
enforced = ?self.authority_ceiling,
"child attempted to raise authority ceiling -- capped"
);
self.authority_ceiling
} else {
req.authority_ceiling
};
let tool_allowlist = if self.tool_allowlist.is_empty() {
req.tool_allowlist.clone()
} else if req.tool_allowlist.is_empty() {
self.tool_allowlist.clone()
} else {
self.tool_allowlist
.intersection(&req.tool_allowlist)
.cloned()
.collect()
};
let tool_denylist: BTreeSet<String> = self
.tool_denylist
.union(&req.tool_denylist)
.cloned()
.collect();
Ok(SandboxInheritance {
allowed_paths: req.allowed_paths.clone(),
max_memory_bytes: min_option(self.max_memory_bytes, req.max_memory_bytes),
max_processes: min_option(self.max_processes, req.max_processes),
max_execution_secs: min_option(self.max_execution_secs, req.max_execution_secs),
depth: self.depth + 1,
max_depth: self.max_depth,
tool_allowlist,
tool_denylist,
authority_ceiling,
filesystem_policy: self.filesystem_policy.restrict_to(&req.filesystem_policy),
network_policy: self.network_policy.restrict_to(&req.network_policy),
parent_agent_id: self.parent_agent_id.clone(),
})
}
pub fn child_paths_subset(&self, child_paths: &[String]) -> bool {
if self.allowed_paths.is_empty() {
return true;
}
child_paths
.iter()
.all(|cp| self.allowed_paths.iter().any(|pp| cp.starts_with(pp)))
}
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
if self.tool_denylist.contains(tool_name) {
return false;
}
if self.tool_allowlist.is_empty() {
return true;
}
self.tool_allowlist.contains(tool_name)
}
pub fn is_authority_allowed(&self, authority: InputAuthority) -> bool {
authority <= self.authority_ceiling
}
}
fn min_option<T: Ord>(a: Option<T>, b: Option<T>) -> Option<T> {
match (a, b) {
(Some(a), Some(b)) => Some(if a < b { a } else { b }),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
pub struct SandboxInheritanceBuilderInit {
tool_allowlist: BTreeSet<String>,
tool_denylist: BTreeSet<String>,
filesystem_policy: FilesystemPolicy,
network_policy: NetworkPolicy,
}
pub struct SandboxInheritanceBuilderWithCeiling {
tool_allowlist: BTreeSet<String>,
tool_denylist: BTreeSet<String>,
authority_ceiling: InputAuthority,
filesystem_policy: FilesystemPolicy,
network_policy: NetworkPolicy,
}
pub struct SandboxInheritanceBuilderReady {
tool_allowlist: BTreeSet<String>,
tool_denylist: BTreeSet<String>,
authority_ceiling: InputAuthority,
filesystem_policy: FilesystemPolicy,
network_policy: NetworkPolicy,
parent_agent_id: String,
}
impl SandboxInheritanceBuilderInit {
pub fn tool_allowlist(mut self, tools: BTreeSet<String>) -> Self {
self.tool_allowlist = tools;
self
}
pub fn tool_denylist(mut self, tools: BTreeSet<String>) -> Self {
self.tool_denylist = tools;
self
}
pub fn filesystem_policy(mut self, policy: FilesystemPolicy) -> Self {
self.filesystem_policy = policy;
self
}
pub fn network_policy(mut self, policy: NetworkPolicy) -> Self {
self.network_policy = policy;
self
}
pub fn authority_ceiling(
self,
ceiling: InputAuthority,
) -> SandboxInheritanceBuilderWithCeiling {
SandboxInheritanceBuilderWithCeiling {
tool_allowlist: self.tool_allowlist,
tool_denylist: self.tool_denylist,
authority_ceiling: ceiling,
filesystem_policy: self.filesystem_policy,
network_policy: self.network_policy,
}
}
}
impl SandboxInheritanceBuilderWithCeiling {
pub fn parent_agent_id(self, id: impl Into<String>) -> SandboxInheritanceBuilderReady {
SandboxInheritanceBuilderReady {
tool_allowlist: self.tool_allowlist,
tool_denylist: self.tool_denylist,
authority_ceiling: self.authority_ceiling,
filesystem_policy: self.filesystem_policy,
network_policy: self.network_policy,
parent_agent_id: id.into(),
}
}
}
impl SandboxInheritanceBuilderReady {
pub fn build(self) -> SandboxInheritance {
SandboxInheritance {
tool_allowlist: self.tool_allowlist,
tool_denylist: self.tool_denylist,
authority_ceiling: self.authority_ceiling,
filesystem_policy: self.filesystem_policy,
network_policy: self.network_policy,
parent_agent_id: self.parent_agent_id,
..Default::default()
}
}
}
pub struct SandboxInheritanceRule {
sandbox: SandboxInheritance,
}
impl SandboxInheritanceRule {
pub fn new(sandbox: SandboxInheritance) -> Self {
Self { sandbox }
}
}
impl PolicyRule for SandboxInheritanceRule {
fn name(&self) -> &str {
"sandbox-inheritance"
}
fn priority(&self) -> u32 {
0
}
fn evaluate(&self, call: &ToolCallRequest, ctx: &PolicyContext) -> PolicyDecision {
if !self.sandbox.is_tool_allowed(&call.tool_name) {
warn!(tool = %call.tool_name, parent = %self.sandbox.parent_agent_id, "sandbox denied tool");
return PolicyDecision::Deny {
rule: self.name().to_string(),
reason: format!(
"tool '{}' blocked by sandbox from '{}'",
call.tool_name, self.sandbox.parent_agent_id
),
};
}
if !self.sandbox.is_authority_allowed(ctx.authority) {
warn!(
authority = ?ctx.authority,
ceiling = ?self.sandbox.authority_ceiling,
parent = %self.sandbox.parent_agent_id,
"sandbox denied authority"
);
return PolicyDecision::Deny {
rule: self.name().to_string(),
reason: format!(
"authority {:?} exceeds ceiling {:?} from '{}'",
ctx.authority, self.sandbox.authority_ceiling, self.sandbox.parent_agent_id
),
};
}
PolicyDecision::Allow
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_from_defaults() {
let sb = SandboxInheritance::root(vec!["/workspace".into()]);
assert_eq!(sb.depth, 0);
assert_eq!(sb.max_depth, DEFAULT_MAX_DEPTH);
assert_eq!(sb.max_memory_bytes, Some(DEFAULT_MAX_MEMORY));
assert!(sb.can_delegate());
}
#[test]
fn restrict_tightens_memory() {
let parent = SandboxInheritance {
max_memory_bytes: Some(1024),
..Default::default()
};
let req = SandboxInheritance {
max_memory_bytes: Some(512),
..Default::default()
};
let child = parent.restrict(&req).unwrap();
assert_eq!(child.max_memory_bytes, Some(512));
assert_eq!(child.depth, 1);
}
#[test]
fn restrict_rejects_expansion() {
let parent = SandboxInheritance {
max_memory_bytes: Some(512),
..Default::default()
};
let req = SandboxInheritance {
max_memory_bytes: Some(1024),
..Default::default()
};
assert!(parent.restrict(&req).is_err());
}
#[test]
fn restrict_rejects_process_expansion() {
let parent = SandboxInheritance {
max_processes: Some(8),
..Default::default()
};
let req = SandboxInheritance {
max_processes: Some(16),
..Default::default()
};
assert!(parent.restrict(&req).is_err());
}
#[test]
fn restrict_intersects_paths() {
let parent = SandboxInheritance {
allowed_paths: vec!["/workspace".into(), "/tmp".into()],
..Default::default()
};
let req = SandboxInheritance {
allowed_paths: vec!["/workspace/sub".into()],
..Default::default()
};
let child = parent.restrict(&req).unwrap();
assert_eq!(child.allowed_paths, vec!["/workspace/sub".to_string()]);
let bad = SandboxInheritance {
allowed_paths: vec!["/etc/secrets".into()],
..Default::default()
};
assert!(parent.restrict(&bad).is_err());
}
#[test]
fn can_delegate_at_max_depth() {
let sb = SandboxInheritance {
depth: 4,
max_depth: 4,
..Default::default()
};
assert!(!sb.can_delegate());
}
#[test]
fn restrict_at_max_depth_errors() {
let parent = SandboxInheritance {
depth: 4,
max_depth: 4,
..Default::default()
};
assert!(parent.restrict(&SandboxInheritance::default()).is_err());
}
#[test]
fn depth_increments() {
let root = SandboxInheritance::root(vec![]);
assert_eq!(root.depth, 0);
let c1 = root.restrict(&SandboxInheritance::default()).unwrap();
assert_eq!(c1.depth, 1);
let c2 = c1.restrict(&SandboxInheritance::default()).unwrap();
assert_eq!(c2.depth, 2);
}
#[test]
fn restrict_none_memory_inherits_parent() {
let parent = SandboxInheritance {
max_memory_bytes: Some(1024),
..Default::default()
};
let req = SandboxInheritance {
max_memory_bytes: None,
..Default::default()
};
assert_eq!(parent.restrict(&req).unwrap().max_memory_bytes, Some(1024));
}
#[test]
fn builder_typestate_compiles() {
let s = SandboxInheritance::builder()
.authority_ceiling(InputAuthority::Peer)
.parent_agent_id("p")
.build();
assert_eq!(s.authority_ceiling, InputAuthority::Peer);
}
#[test]
fn root_for_agent_is_maximally_permissive() {
let r = SandboxInheritance::root_for_agent("root-agent");
assert!(r.tool_allowlist.is_empty());
assert_eq!(r.authority_ceiling, InputAuthority::Creator);
assert_eq!(r.parent_agent_id, "root-agent");
}
#[test]
fn restrict_caps_authority() {
let parent = SandboxInheritance::builder()
.authority_ceiling(InputAuthority::Peer)
.parent_agent_id("p")
.build();
let req = SandboxInheritance {
authority_ceiling: InputAuthority::Creator,
..Default::default()
};
assert_eq!(
parent.restrict(&req).unwrap().authority_ceiling,
InputAuthority::Peer
);
}
#[test]
fn restrict_denylists_additive() {
let mut parent = SandboxInheritance::builder()
.tool_denylist(BTreeSet::from(["rm".into()]))
.authority_ceiling(InputAuthority::Creator)
.parent_agent_id("p")
.build();
parent.max_depth = 4;
let req = SandboxInheritance {
tool_denylist: BTreeSet::from(["exec".into()]),
..Default::default()
};
let child = parent.restrict(&req).unwrap();
assert!(child.tool_denylist.contains("rm"));
assert!(child.tool_denylist.contains("exec"));
}
#[test]
fn restrict_allowlists_intersect() {
let mut parent = SandboxInheritance::builder()
.tool_allowlist(BTreeSet::from(["read".into(), "write".into()]))
.authority_ceiling(InputAuthority::Creator)
.parent_agent_id("p")
.build();
parent.max_depth = 4;
let req = SandboxInheritance {
tool_allowlist: BTreeSet::from(["write".into(), "delete".into()]),
..Default::default()
};
assert_eq!(
parent.restrict(&req).unwrap().tool_allowlist,
BTreeSet::from(["write".into()])
);
}
#[test]
fn three_level_chain() {
let root = SandboxInheritance::root_for_agent("root");
let l1 = root
.restrict(&SandboxInheritance {
authority_ceiling: InputAuthority::SelfGenerated,
tool_denylist: BTreeSet::from(["rm".into()]),
..Default::default()
})
.unwrap();
let l2 = l1
.restrict(&SandboxInheritance {
authority_ceiling: InputAuthority::Creator,
tool_denylist: BTreeSet::from(["exec".into()]),
..Default::default()
})
.unwrap();
assert_eq!(l2.authority_ceiling, InputAuthority::SelfGenerated);
assert!(l2.tool_denylist.contains("rm") && l2.tool_denylist.contains("exec"));
assert_eq!(l2.depth, 2);
}
#[test]
fn is_tool_allowed_denylist_wins() {
let s = SandboxInheritance {
tool_allowlist: BTreeSet::from(["read".into(), "rm".into()]),
tool_denylist: BTreeSet::from(["rm".into()]),
..Default::default()
};
assert!(s.is_tool_allowed("read"));
assert!(!s.is_tool_allowed("rm"));
}
#[test]
fn policy_rule_denies_tool() {
let rule = SandboxInheritanceRule::new(SandboxInheritance {
tool_denylist: BTreeSet::from(["bad".into()]),
parent_agent_id: "p".into(),
..Default::default()
});
let call = ToolCallRequest {
tool_name: "bad".into(),
params: serde_json::Value::Null,
risk_level: roboticus_core::RiskLevel::Safe,
};
let ctx = PolicyContext {
authority: InputAuthority::Creator,
survival_tier: roboticus_core::SurvivalTier::High,
claim: None,
};
assert!(matches!(
rule.evaluate(&call, &ctx),
PolicyDecision::Deny { .. }
));
}
#[test]
fn policy_rule_allows_ok() {
let rule = SandboxInheritanceRule::new(SandboxInheritance {
tool_allowlist: BTreeSet::from(["read".into()]),
authority_ceiling: InputAuthority::Peer,
parent_agent_id: "p".into(),
..Default::default()
});
let call = ToolCallRequest {
tool_name: "read".into(),
params: serde_json::Value::Null,
risk_level: roboticus_core::RiskLevel::Safe,
};
let ctx = PolicyContext {
authority: InputAuthority::Peer,
survival_tier: roboticus_core::SurvivalTier::High,
claim: None,
};
assert!(matches!(rule.evaluate(&call, &ctx), PolicyDecision::Allow));
}
#[test]
fn filesystem_policy_restrict() {
let p = FilesystemPolicy::WorkspaceOnly {
allowed_paths: BTreeSet::from([PathBuf::from("/a"), PathBuf::from("/b")]),
};
let c = FilesystemPolicy::WorkspaceOnly {
allowed_paths: BTreeSet::from([PathBuf::from("/b"), PathBuf::from("/c")]),
};
assert_eq!(
p.restrict_to(&c),
FilesystemPolicy::WorkspaceOnly {
allowed_paths: BTreeSet::from([PathBuf::from("/b")])
}
);
}
#[test]
fn network_policy_restrict() {
let p = NetworkPolicy::AllowList(BTreeSet::from([
"api.example.com".into(),
"cdn.example.com".into(),
]));
let c = NetworkPolicy::AllowList(BTreeSet::from([
"api.example.com".into(),
"evil.com".into(),
]));
assert_eq!(
p.restrict_to(&c),
NetworkPolicy::AllowList(BTreeSet::from(["api.example.com".into()]))
);
}
}