1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
4pub struct Entitlements {
5 pub network: NetworkEntitlement,
6 pub filesystem: FilesystemEntitlement,
7 pub processes: ProcessesEntitlement,
8 #[serde(default)]
9 pub syscalls: SyscallsEntitlement,
10 #[serde(default)]
11 pub limits: LimitsEntitlement,
12 #[serde(default)]
15 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
16 #[serde(default, skip_serializing_if = "Vec::is_empty")]
18 pub tools: Vec<ToolRule>,
19 #[serde(default = "default_true")]
24 pub fail_closed_on_sandbox_error: bool,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28pub struct NetworkEntitlement {
29 pub inbound: InboundNetwork,
30 pub outbound: OutboundNetwork,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34pub struct InboundNetwork {
35 #[serde(default)]
36 pub ports: Vec<u16>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40pub struct OutboundNetwork {
41 pub mode: NetworkOutboundMode,
42 #[serde(default)]
43 pub allow_hosts: Vec<String>,
44 #[serde(default, skip_serializing_if = "Vec::is_empty")]
59 pub allow_ports: Vec<u16>,
60 #[serde(default = "default_protocols")]
61 pub protocols: Vec<String>,
62 #[serde(default)]
63 pub resolve_dns: ResolveDnsConfig,
64}
65fn default_protocols() -> Vec<String> {
66 vec!["tcp".to_string()]
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct EgressAuthorization {
74 pub authorized_by: String,
75 pub authorized_at_ms: u64,
76}
77
78#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
79#[serde(rename_all = "lowercase")]
80pub enum NetworkOutboundMode {
81 Unrestricted,
82 Restricted,
83 ProxyOnly,
87 Off,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct ResolveDnsConfig {
92 #[serde(default = "default_dns_mode")]
93 pub mode: String,
94 #[serde(default)]
95 pub servers: Vec<String>,
96}
97impl Default for ResolveDnsConfig {
98 fn default() -> Self {
99 Self {
100 mode: default_dns_mode(),
101 servers: vec![],
102 }
103 }
104}
105fn default_dns_mode() -> String {
106 "system".to_string()
107}
108
109pub const AUTHORING_DIRS: [&str; 3] = ["skills", "workflows", "artifacts"];
128
129#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
130pub struct FilesystemEntitlement {
131 #[serde(default)]
132 pub read: Vec<String>,
133 #[serde(default)]
134 pub write: Vec<String>,
135 #[serde(default)]
136 pub deny: Vec<String>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
140pub struct ProcessesEntitlement {
141 pub spawn: SpawnEntitlement,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145pub struct SpawnEntitlement {
146 pub mode: SpawnMode,
147 #[serde(default)]
148 pub allowed: Vec<String>,
149 #[serde(default)]
164 pub allowed_dirs: Vec<String>,
165}
166
167#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
168#[serde(rename_all = "lowercase")]
169pub enum SpawnMode {
170 Allowlist,
171 Any,
172 None,
173 Strict,
179}
180
181#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
182pub struct SyscallsEntitlement {
183 #[serde(default = "default_syscalls_mode")]
184 pub mode: String,
185 #[serde(default)]
186 pub extra_deny: Vec<String>,
187}
188fn default_syscalls_mode() -> String {
189 "default".to_string()
190}
191
192#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
193pub struct LimitsEntitlement {
194 #[serde(default)]
195 pub cpu_seconds: Option<u64>,
196 #[serde(default = "default_memory_mb")]
197 pub memory_mb: u64,
198 #[serde(default = "default_fds")]
199 pub file_descriptors: u32,
200 #[serde(default = "default_procs")]
201 pub processes: u32,
202}
203fn default_memory_mb() -> u64 {
204 512
205}
206fn default_fds() -> u32 {
207 1024
208}
209fn default_procs() -> u32 {
210 32
211}
212
213#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
214#[serde(rename_all = "lowercase")]
215pub enum ToolPolicy {
216 Allow,
217 #[default]
218 Ask,
219 Deny,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
223pub struct ToolRule {
224 pub pattern: String,
225 pub policy: ToolPolicy,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub risk: Option<crate::hitl::RiskTier>,
230}
231
232pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
236 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
237}
238
239pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
244 for rule in rules {
245 if rule.pattern == tool_name {
246 return Some(rule.policy);
247 }
248 }
249 let mut best: Option<(&ToolRule, usize)> = None;
250 for rule in rules {
251 if let Some(prefix) = rule.pattern.strip_suffix('*')
252 && tool_name.starts_with(prefix)
253 {
254 let len = prefix.len();
255 if best.is_none_or(|(_, best_len)| len > best_len) {
256 best = Some((rule, len));
257 }
258 }
259 }
260 best.map(|(rule, _)| rule.policy)
261}
262
263#[cfg(test)]
264mod tool_policy_tests {
265 use super::*;
266
267 fn rules() -> Vec<ToolRule> {
268 vec![
269 ToolRule {
270 pattern: "mcp__github__merge_pr".into(),
271 policy: ToolPolicy::Ask,
272 risk: None,
273 },
274 ToolRule {
275 pattern: "mcp__github__*".into(),
276 policy: ToolPolicy::Allow,
277 risk: None,
278 },
279 ToolRule {
280 pattern: "mcp__*".into(),
281 policy: ToolPolicy::Deny,
282 risk: None,
283 },
284 ToolRule {
285 pattern: "bash".into(),
286 policy: ToolPolicy::Allow,
287 risk: None,
288 },
289 ]
290 }
291
292 #[test]
293 fn exact_beats_glob() {
294 assert_eq!(
295 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
296 ToolPolicy::Ask
297 );
298 }
299
300 #[test]
301 fn longer_glob_wins() {
302 assert_eq!(
303 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
304 ToolPolicy::Allow
305 );
306 }
307
308 #[test]
309 fn shorter_glob_fallback() {
310 assert_eq!(
311 resolve_tool_policy(&rules(), "mcp__slack__send"),
312 ToolPolicy::Deny
313 );
314 }
315
316 #[test]
317 fn exact_bash() {
318 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
319 }
320
321 #[test]
322 fn unknown_tool_defaults_ask() {
323 assert_eq!(
324 resolve_tool_policy(&rules(), "unknown_tool"),
325 ToolPolicy::Ask
326 );
327 }
328
329 #[test]
330 fn empty_rules_defaults_ask() {
331 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
332 }
333
334 fn minimal_entitlements_yaml() -> &'static str {
335 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
336 }
337
338 #[test]
339 fn entitlements_tools_defaults_empty() {
340 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
341 assert!(e.tools.is_empty());
342 }
343
344 #[test]
345 fn entitlements_tools_roundtrip() {
346 let base = minimal_entitlements_yaml();
347 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
348 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
349 assert_eq!(e.tools.len(), 1);
350 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
351 let y = serde_yaml_ng::to_string(&e).unwrap();
352 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
353 assert_eq!(back.tools.len(), 1);
354 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
355 }
356 #[test]
357 fn denylist_membership_and_mutation() {
358 let mut list: Vec<String> = vec![];
359 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
360
361 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
363 assert_eq!(list, ["a"]);
364
365 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
367
368 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
370 assert!(list.is_empty());
371
372 set_denylist(&mut list, "b", true); assert!(list.is_empty());
374 }
375
376 #[test]
377 fn addon_group_rule_truth_table() {
378 let mut p = crate::agent::AgentProfile::default_for_tests();
379 p.addons.push(AddonRef {
380 id: "grp".into(),
381 source: "claude-local:grp@1.0.0".into(),
382 enabled: false,
383 skills: vec!["g_skill".into()],
384 mcp: vec!["g_mcp".into()],
385 commands: vec!["g_cmd".into()],
386 content_hash: None,
387 fetch_ref: None,
388 fetch_plugin: None,
389 });
390
391 assert!(p.skill_enabled("standalone"));
393 assert!(p.mcp_enabled("standalone_mcp"));
394
395 assert!(!p.skill_enabled("g_skill"));
397 assert!(!p.mcp_enabled("g_mcp"));
398
399 assert!(p.set_addon_enabled("grp", true));
401 assert!(p.skill_enabled("g_skill"));
402 assert!(p.mcp_enabled("g_mcp"));
403
404 p.set_skill_enabled("g_skill", false);
406 assert!(!p.skill_enabled("g_skill"));
407
408 assert!(!p.set_addon_enabled("nope", true));
410
411 p.disable_all_addons();
413 assert!(p.addons.iter().all(|g| !g.enabled));
414 assert!(!p.skill_enabled("g_skill"));
415 assert!(!p.skill_enabled("g_cmd"));
416 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
422 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);
428 assert!(p.skill_enabled("g_skill"));
429 }
430
431 #[test]
432 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
433 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
435 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
436 assert_eq!(r.content_hash, None);
437 assert_eq!(r.fetch_ref, None);
438
439 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
441 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
442 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
443 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
444 let back = serde_yaml_ng::to_string(&r2).unwrap();
445 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
446 assert_eq!(r2, r3);
447 }
448}