use super::*;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Entitlements {
pub network: NetworkEntitlement,
pub filesystem: FilesystemEntitlement,
pub processes: ProcessesEntitlement,
#[serde(default)]
pub syscalls: SyscallsEntitlement,
#[serde(default)]
pub limits: LimitsEntitlement,
#[serde(default)]
pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolRule>,
#[serde(default = "default_true")]
pub fail_closed_on_sandbox_error: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkEntitlement {
pub inbound: InboundNetwork,
pub outbound: OutboundNetwork,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct InboundNetwork {
#[serde(default)]
pub ports: Vec<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OutboundNetwork {
pub mode: NetworkOutboundMode,
#[serde(default)]
pub allow_hosts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow_ports: Vec<u16>,
#[serde(default = "default_protocols")]
pub protocols: Vec<String>,
#[serde(default)]
pub resolve_dns: ResolveDnsConfig,
}
fn default_protocols() -> Vec<String> {
vec!["tcp".to_string()]
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EgressAuthorization {
pub authorized_by: String,
pub authorized_at_ms: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NetworkOutboundMode {
Unrestricted,
Restricted,
ProxyOnly,
Off,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolveDnsConfig {
#[serde(default = "default_dns_mode")]
pub mode: String,
#[serde(default)]
pub servers: Vec<String>,
}
impl Default for ResolveDnsConfig {
fn default() -> Self {
Self {
mode: default_dns_mode(),
servers: vec![],
}
}
}
fn default_dns_mode() -> String {
"system".to_string()
}
pub const AUTHORING_DIRS: [&str; 3] = ["skills", "workflows", "artifacts"];
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FilesystemEntitlement {
#[serde(default)]
pub read: Vec<String>,
#[serde(default)]
pub write: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProcessesEntitlement {
pub spawn: SpawnEntitlement,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpawnEntitlement {
pub mode: SpawnMode,
#[serde(default)]
pub allowed: Vec<String>,
#[serde(default)]
pub allowed_dirs: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SpawnMode {
Allowlist,
Any,
None,
Strict,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SyscallsEntitlement {
#[serde(default = "default_syscalls_mode")]
pub mode: String,
#[serde(default)]
pub extra_deny: Vec<String>,
}
fn default_syscalls_mode() -> String {
"default".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LimitsEntitlement {
#[serde(default)]
pub cpu_seconds: Option<u64>,
#[serde(default = "default_memory_mb")]
pub memory_mb: u64,
#[serde(default = "default_fds")]
pub file_descriptors: u32,
#[serde(default = "default_procs")]
pub processes: u32,
}
fn default_memory_mb() -> u64 {
512
}
fn default_fds() -> u32 {
1024
}
fn default_procs() -> u32 {
32
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum ToolPolicy {
Allow,
#[default]
Ask,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolRule {
pub pattern: String,
pub policy: ToolPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub risk: Option<crate::hitl::RiskTier>,
}
pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
}
pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
for rule in rules {
if rule.pattern == tool_name {
return Some(rule.policy);
}
}
let mut best: Option<(&ToolRule, usize)> = None;
for rule in rules {
if let Some(prefix) = rule.pattern.strip_suffix('*')
&& tool_name.starts_with(prefix)
{
let len = prefix.len();
if best.is_none_or(|(_, best_len)| len > best_len) {
best = Some((rule, len));
}
}
}
best.map(|(rule, _)| rule.policy)
}
#[cfg(test)]
mod tool_policy_tests {
use super::*;
fn rules() -> Vec<ToolRule> {
vec![
ToolRule {
pattern: "mcp__github__merge_pr".into(),
policy: ToolPolicy::Ask,
risk: None,
},
ToolRule {
pattern: "mcp__github__*".into(),
policy: ToolPolicy::Allow,
risk: None,
},
ToolRule {
pattern: "mcp__*".into(),
policy: ToolPolicy::Deny,
risk: None,
},
ToolRule {
pattern: "bash".into(),
policy: ToolPolicy::Allow,
risk: None,
},
]
}
#[test]
fn exact_beats_glob() {
assert_eq!(
resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
ToolPolicy::Ask
);
}
#[test]
fn longer_glob_wins() {
assert_eq!(
resolve_tool_policy(&rules(), "mcp__github__create_issue"),
ToolPolicy::Allow
);
}
#[test]
fn shorter_glob_fallback() {
assert_eq!(
resolve_tool_policy(&rules(), "mcp__slack__send"),
ToolPolicy::Deny
);
}
#[test]
fn exact_bash() {
assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
}
#[test]
fn unknown_tool_defaults_ask() {
assert_eq!(
resolve_tool_policy(&rules(), "unknown_tool"),
ToolPolicy::Ask
);
}
#[test]
fn empty_rules_defaults_ask() {
assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
}
fn minimal_entitlements_yaml() -> &'static str {
"network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
}
#[test]
fn entitlements_tools_defaults_empty() {
let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
assert!(e.tools.is_empty());
}
#[test]
fn entitlements_tools_roundtrip() {
let base = minimal_entitlements_yaml();
let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(e.tools.len(), 1);
assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
let y = serde_yaml_ng::to_string(&e).unwrap();
let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
assert_eq!(back.tools.len(), 1);
assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
}
#[test]
fn denylist_membership_and_mutation() {
let mut list: Vec<String> = vec![];
assert!(name_enabled(&list, "a"), "empty denylist => enabled");
set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
assert_eq!(list, ["a"]);
set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
assert!(list.is_empty());
set_denylist(&mut list, "b", true); assert!(list.is_empty());
}
#[test]
fn addon_group_rule_truth_table() {
let mut p = crate::agent::AgentProfile::default_for_tests();
p.addons.push(AddonRef {
id: "grp".into(),
source: "claude-local:grp@1.0.0".into(),
enabled: false,
skills: vec!["g_skill".into()],
mcp: vec!["g_mcp".into()],
commands: vec!["g_cmd".into()],
content_hash: None,
fetch_ref: None,
fetch_plugin: None,
});
assert!(p.skill_enabled("standalone"));
assert!(p.mcp_enabled("standalone_mcp"));
assert!(!p.skill_enabled("g_skill"));
assert!(!p.mcp_enabled("g_mcp"));
assert!(p.set_addon_enabled("grp", true));
assert!(p.skill_enabled("g_skill"));
assert!(p.mcp_enabled("g_mcp"));
p.set_skill_enabled("g_skill", false);
assert!(!p.skill_enabled("g_skill"));
assert!(!p.set_addon_enabled("nope", true));
p.disable_all_addons();
assert!(p.addons.iter().all(|g| !g.enabled));
assert!(!p.skill_enabled("g_skill"));
assert!(!p.skill_enabled("g_cmd"));
assert!(!p.mcp_enabled("g_mcp"));
assert!(p.set_addon_enabled("grp", true));
assert!(!p.skill_enabled("g_skill")); assert!(p.skill_enabled("g_cmd")); assert!(p.mcp_enabled("g_mcp"));
p.set_skill_enabled("g_skill", true);
assert!(p.skill_enabled("g_skill"));
}
#[test]
fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
assert_eq!(r.content_hash, None);
assert_eq!(r.fetch_ref, None);
let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
let back = serde_yaml_ng::to_string(&r2).unwrap();
let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
assert_eq!(r2, r3);
}
}