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 #[serde(default, skip_serializing_if = "SshEntitlement::is_empty")]
28 pub ssh: SshEntitlement,
29}
30
31#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
34pub struct SshEntitlement {
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub keys: Vec<SshKeyRule>,
37}
38
39impl SshEntitlement {
40 pub fn is_empty(&self) -> bool {
41 self.keys.is_empty()
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct SshKeyRule {
47 pub fingerprint: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub label: Option<String>,
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub allow_hosts: Vec<SshHostRule>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct SshHostRule {
56 pub host_key: String,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub label: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct NetworkEntitlement {
63 pub inbound: InboundNetwork,
64 pub outbound: OutboundNetwork,
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
68pub struct InboundNetwork {
69 #[serde(default)]
70 pub ports: Vec<u16>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
74pub struct OutboundNetwork {
75 pub mode: NetworkOutboundMode,
76 #[serde(default)]
77 pub allow_hosts: Vec<String>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub allow_ports: Vec<u16>,
94 #[serde(default = "default_protocols")]
95 pub protocols: Vec<String>,
96 #[serde(default)]
97 pub resolve_dns: ResolveDnsConfig,
98}
99fn default_protocols() -> Vec<String> {
100 vec!["tcp".to_string()]
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct EgressAuthorization {
108 pub authorized_by: String,
109 pub authorized_at_ms: u64,
110}
111
112#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
113#[serde(rename_all = "lowercase")]
114pub enum NetworkOutboundMode {
115 Unrestricted,
116 Restricted,
117 ProxyOnly,
121 Off,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
125pub struct ResolveDnsConfig {
126 #[serde(default = "default_dns_mode")]
127 pub mode: String,
128 #[serde(default)]
129 pub servers: Vec<String>,
130}
131impl Default for ResolveDnsConfig {
132 fn default() -> Self {
133 Self {
134 mode: default_dns_mode(),
135 servers: vec![],
136 }
137 }
138}
139fn default_dns_mode() -> String {
140 "system".to_string()
141}
142
143pub const AUTHORING_DIRS: [&str; 3] = ["skills", "workflows", "artifacts"];
162
163pub const DEFAULT_DENY_PATHS: [&str; 3] = ["~/.ssh", "~/.aws", "~/.gnupg"];
173
174#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
175pub struct FilesystemEntitlement {
176 #[serde(default)]
177 pub read: Vec<String>,
178 #[serde(default)]
179 pub write: Vec<String>,
180 #[serde(default)]
181 pub deny: Vec<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185pub struct ProcessesEntitlement {
186 pub spawn: SpawnEntitlement,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct SpawnEntitlement {
191 pub mode: SpawnMode,
192 #[serde(default)]
193 pub allowed: Vec<String>,
194 #[serde(default)]
209 pub allowed_dirs: Vec<String>,
210}
211
212#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
213#[serde(rename_all = "lowercase")]
214pub enum SpawnMode {
215 Allowlist,
216 Any,
217 None,
218 Strict,
224}
225
226#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
227pub struct SyscallsEntitlement {
228 #[serde(default = "default_syscalls_mode")]
229 pub mode: String,
230 #[serde(default)]
231 pub extra_deny: Vec<String>,
232}
233fn default_syscalls_mode() -> String {
234 "default".to_string()
235}
236
237#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
238pub struct LimitsEntitlement {
239 #[serde(default)]
240 pub cpu_seconds: Option<u64>,
241 #[serde(default = "default_memory_mb")]
242 pub memory_mb: u64,
243 #[serde(default = "default_fds")]
244 pub file_descriptors: u32,
245 #[serde(default = "default_procs")]
246 pub processes: u32,
247}
248fn default_memory_mb() -> u64 {
249 512
250}
251fn default_fds() -> u32 {
252 1024
253}
254fn default_procs() -> u32 {
255 32
256}
257
258#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
259#[serde(rename_all = "lowercase")]
260pub enum ToolPolicy {
261 Allow,
262 #[default]
263 Ask,
264 Deny,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
268pub struct ToolRule {
269 pub pattern: String,
270 pub policy: ToolPolicy,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub risk: Option<crate::hitl::RiskTier>,
277 #[serde(
285 default,
286 skip_serializing_if = "crate::hitl::floor_waiver::FloorWaiver::is_off"
287 )]
288 pub bypass_builtin_tier_floor: crate::hitl::floor_waiver::FloorWaiver,
289}
290
291pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
295 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
296}
297
298pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
303 resolve_tool_rule(rules, tool_name).map(|rule| rule.policy)
304}
305
306pub fn resolve_tool_rule<'a>(rules: &'a [ToolRule], tool_name: &str) -> Option<&'a ToolRule> {
310 for rule in rules {
311 if rule.pattern == tool_name {
312 return Some(rule);
313 }
314 }
315 let mut best: Option<(&ToolRule, usize)> = None;
316 for rule in rules {
317 if let Some(prefix) = rule.pattern.strip_suffix('*')
318 && tool_name.starts_with(prefix)
319 {
320 let len = prefix.len();
321 if best.is_none_or(|(_, best_len)| len > best_len) {
322 best = Some((rule, len));
323 }
324 }
325 }
326 best.map(|(rule, _)| rule)
327}
328
329pub fn resolve_tool_risk(rules: &[ToolRule], tool_name: &str) -> Option<crate::hitl::RiskTier> {
335 rules
336 .iter()
337 .filter(|rule| {
338 rule.pattern == tool_name
339 || rule
340 .pattern
341 .strip_suffix('*')
342 .is_some_and(|prefix| tool_name.starts_with(prefix))
343 })
344 .filter_map(|rule| rule.risk)
345 .max()
346}
347
348#[cfg(test)]
349mod tool_policy_tests {
350 use super::*;
351
352 fn rules() -> Vec<ToolRule> {
353 vec![
354 ToolRule {
355 pattern: "mcp__github__merge_pr".into(),
356 policy: ToolPolicy::Ask,
357 risk: None,
358 bypass_builtin_tier_floor: Default::default(),
359 },
360 ToolRule {
361 pattern: "mcp__github__*".into(),
362 policy: ToolPolicy::Allow,
363 risk: None,
364 bypass_builtin_tier_floor: Default::default(),
365 },
366 ToolRule {
367 pattern: "mcp__*".into(),
368 policy: ToolPolicy::Deny,
369 risk: None,
370 bypass_builtin_tier_floor: Default::default(),
371 },
372 ToolRule {
373 pattern: "bash".into(),
374 policy: ToolPolicy::Allow,
375 risk: None,
376 bypass_builtin_tier_floor: Default::default(),
377 },
378 ]
379 }
380
381 fn risky(pattern: &str, policy: ToolPolicy, risk: RiskTier) -> ToolRule {
382 ToolRule {
383 pattern: pattern.into(),
384 policy,
385 risk: Some(risk),
386 bypass_builtin_tier_floor: Default::default(),
387 }
388 }
389
390 use crate::hitl::RiskTier;
391
392 #[test]
396 fn tool_risk_is_the_strictest_matching_rule() {
397 let rules = vec![
398 risky("mcp__browser__*", ToolPolicy::Allow, RiskTier::Destructive),
399 risky("mcp__browser__click", ToolPolicy::Allow, RiskTier::Write),
400 risky("mcp__other__*", ToolPolicy::Allow, RiskTier::Privileged),
401 ];
402 assert_eq!(
403 resolve_tool_risk(&rules, "mcp__browser__click"),
404 Some(RiskTier::Destructive)
405 );
406 assert_eq!(
407 resolve_tool_risk(&rules, "mcp__browser__navigate"),
408 Some(RiskTier::Destructive)
409 );
410 }
411
412 #[test]
413 fn tool_risk_is_none_without_a_declaring_rule() {
414 assert_eq!(resolve_tool_risk(&rules(), "mcp__github__merge_pr"), None);
415 assert_eq!(resolve_tool_risk(&[], "bash"), None);
416 }
417
418 #[test]
419 fn exact_beats_glob() {
420 assert_eq!(
421 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
422 ToolPolicy::Ask
423 );
424 }
425
426 #[test]
427 fn longer_glob_wins() {
428 assert_eq!(
429 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
430 ToolPolicy::Allow
431 );
432 }
433
434 #[test]
435 fn shorter_glob_fallback() {
436 assert_eq!(
437 resolve_tool_policy(&rules(), "mcp__slack__send"),
438 ToolPolicy::Deny
439 );
440 }
441
442 #[test]
443 fn exact_bash() {
444 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
445 }
446
447 #[test]
448 fn unknown_tool_defaults_ask() {
449 assert_eq!(
450 resolve_tool_policy(&rules(), "unknown_tool"),
451 ToolPolicy::Ask
452 );
453 }
454
455 #[test]
456 fn empty_rules_defaults_ask() {
457 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
458 }
459
460 fn minimal_entitlements_yaml() -> &'static str {
461 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
462 }
463
464 #[test]
465 fn entitlements_tools_defaults_empty() {
466 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
467 assert!(e.tools.is_empty());
468 }
469
470 #[test]
471 fn entitlements_tools_roundtrip() {
472 let base = minimal_entitlements_yaml();
473 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
474 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
475 assert_eq!(e.tools.len(), 1);
476 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
477 let y = serde_yaml_ng::to_string(&e).unwrap();
478 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
479 assert_eq!(back.tools.len(), 1);
480 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
481 }
482 #[test]
483 fn denylist_membership_and_mutation() {
484 let mut list: Vec<String> = vec![];
485 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
486
487 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
489 assert_eq!(list, ["a"]);
490
491 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
493
494 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
496 assert!(list.is_empty());
497
498 set_denylist(&mut list, "b", true); assert!(list.is_empty());
500 }
501
502 #[test]
503 fn addon_group_rule_truth_table() {
504 let mut p = crate::agent::AgentProfile::default_for_tests();
505 p.addons.push(AddonRef {
506 id: "grp".into(),
507 source: "claude-local:grp@1.0.0".into(),
508 enabled: false,
509 skills: vec!["g_skill".into()],
510 mcp: vec!["g_mcp".into()],
511 commands: vec!["g_cmd".into()],
512 content_hash: None,
513 fetch_ref: None,
514 fetch_plugin: None,
515 });
516
517 assert!(p.skill_enabled("standalone"));
519 assert!(p.mcp_enabled("standalone_mcp"));
520
521 assert!(!p.skill_enabled("g_skill"));
523 assert!(!p.mcp_enabled("g_mcp"));
524
525 assert!(p.set_addon_enabled("grp", true));
527 assert!(p.skill_enabled("g_skill"));
528 assert!(p.mcp_enabled("g_mcp"));
529
530 p.set_skill_enabled("g_skill", false);
532 assert!(!p.skill_enabled("g_skill"));
533
534 assert!(!p.set_addon_enabled("nope", true));
536
537 p.disable_all_addons();
539 assert!(p.addons.iter().all(|g| !g.enabled));
540 assert!(!p.skill_enabled("g_skill"));
541 assert!(!p.skill_enabled("g_cmd"));
542 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
548 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);
554 assert!(p.skill_enabled("g_skill"));
555 }
556
557 #[test]
558 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
559 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
561 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
562 assert_eq!(r.content_hash, None);
563 assert_eq!(r.fetch_ref, None);
564
565 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
567 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
568 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
569 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
570 let back = serde_yaml_ng::to_string(&r2).unwrap();
571 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
572 assert_eq!(r2, r3);
573 }
574}