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
129pub const DEFAULT_DENY_PATHS: [&str; 3] = ["~/.ssh", "~/.aws", "~/.gnupg"];
139
140#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
141pub struct FilesystemEntitlement {
142 #[serde(default)]
143 pub read: Vec<String>,
144 #[serde(default)]
145 pub write: Vec<String>,
146 #[serde(default)]
147 pub deny: Vec<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
151pub struct ProcessesEntitlement {
152 pub spawn: SpawnEntitlement,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
156pub struct SpawnEntitlement {
157 pub mode: SpawnMode,
158 #[serde(default)]
159 pub allowed: Vec<String>,
160 #[serde(default)]
175 pub allowed_dirs: Vec<String>,
176}
177
178#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
179#[serde(rename_all = "lowercase")]
180pub enum SpawnMode {
181 Allowlist,
182 Any,
183 None,
184 Strict,
190}
191
192#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
193pub struct SyscallsEntitlement {
194 #[serde(default = "default_syscalls_mode")]
195 pub mode: String,
196 #[serde(default)]
197 pub extra_deny: Vec<String>,
198}
199fn default_syscalls_mode() -> String {
200 "default".to_string()
201}
202
203#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
204pub struct LimitsEntitlement {
205 #[serde(default)]
206 pub cpu_seconds: Option<u64>,
207 #[serde(default = "default_memory_mb")]
208 pub memory_mb: u64,
209 #[serde(default = "default_fds")]
210 pub file_descriptors: u32,
211 #[serde(default = "default_procs")]
212 pub processes: u32,
213}
214fn default_memory_mb() -> u64 {
215 512
216}
217fn default_fds() -> u32 {
218 1024
219}
220fn default_procs() -> u32 {
221 32
222}
223
224#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
225#[serde(rename_all = "lowercase")]
226pub enum ToolPolicy {
227 Allow,
228 #[default]
229 Ask,
230 Deny,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234pub struct ToolRule {
235 pub pattern: String,
236 pub policy: ToolPolicy,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub risk: Option<crate::hitl::RiskTier>,
243}
244
245pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
249 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
250}
251
252pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
257 for rule in rules {
258 if rule.pattern == tool_name {
259 return Some(rule.policy);
260 }
261 }
262 let mut best: Option<(&ToolRule, usize)> = None;
263 for rule in rules {
264 if let Some(prefix) = rule.pattern.strip_suffix('*')
265 && tool_name.starts_with(prefix)
266 {
267 let len = prefix.len();
268 if best.is_none_or(|(_, best_len)| len > best_len) {
269 best = Some((rule, len));
270 }
271 }
272 }
273 best.map(|(rule, _)| rule.policy)
274}
275
276pub fn resolve_tool_risk(rules: &[ToolRule], tool_name: &str) -> Option<crate::hitl::RiskTier> {
282 rules
283 .iter()
284 .filter(|rule| {
285 rule.pattern == tool_name
286 || rule
287 .pattern
288 .strip_suffix('*')
289 .is_some_and(|prefix| tool_name.starts_with(prefix))
290 })
291 .filter_map(|rule| rule.risk)
292 .max()
293}
294
295#[cfg(test)]
296mod tool_policy_tests {
297 use super::*;
298
299 fn rules() -> Vec<ToolRule> {
300 vec![
301 ToolRule {
302 pattern: "mcp__github__merge_pr".into(),
303 policy: ToolPolicy::Ask,
304 risk: None,
305 },
306 ToolRule {
307 pattern: "mcp__github__*".into(),
308 policy: ToolPolicy::Allow,
309 risk: None,
310 },
311 ToolRule {
312 pattern: "mcp__*".into(),
313 policy: ToolPolicy::Deny,
314 risk: None,
315 },
316 ToolRule {
317 pattern: "bash".into(),
318 policy: ToolPolicy::Allow,
319 risk: None,
320 },
321 ]
322 }
323
324 fn risky(pattern: &str, policy: ToolPolicy, risk: RiskTier) -> ToolRule {
325 ToolRule {
326 pattern: pattern.into(),
327 policy,
328 risk: Some(risk),
329 }
330 }
331
332 use crate::hitl::RiskTier;
333
334 #[test]
338 fn tool_risk_is_the_strictest_matching_rule() {
339 let rules = vec![
340 risky("mcp__browser__*", ToolPolicy::Allow, RiskTier::Destructive),
341 risky("mcp__browser__click", ToolPolicy::Allow, RiskTier::Write),
342 risky("mcp__other__*", ToolPolicy::Allow, RiskTier::Privileged),
343 ];
344 assert_eq!(
345 resolve_tool_risk(&rules, "mcp__browser__click"),
346 Some(RiskTier::Destructive)
347 );
348 assert_eq!(
349 resolve_tool_risk(&rules, "mcp__browser__navigate"),
350 Some(RiskTier::Destructive)
351 );
352 }
353
354 #[test]
355 fn tool_risk_is_none_without_a_declaring_rule() {
356 assert_eq!(resolve_tool_risk(&rules(), "mcp__github__merge_pr"), None);
357 assert_eq!(resolve_tool_risk(&[], "bash"), None);
358 }
359
360 #[test]
361 fn exact_beats_glob() {
362 assert_eq!(
363 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
364 ToolPolicy::Ask
365 );
366 }
367
368 #[test]
369 fn longer_glob_wins() {
370 assert_eq!(
371 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
372 ToolPolicy::Allow
373 );
374 }
375
376 #[test]
377 fn shorter_glob_fallback() {
378 assert_eq!(
379 resolve_tool_policy(&rules(), "mcp__slack__send"),
380 ToolPolicy::Deny
381 );
382 }
383
384 #[test]
385 fn exact_bash() {
386 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
387 }
388
389 #[test]
390 fn unknown_tool_defaults_ask() {
391 assert_eq!(
392 resolve_tool_policy(&rules(), "unknown_tool"),
393 ToolPolicy::Ask
394 );
395 }
396
397 #[test]
398 fn empty_rules_defaults_ask() {
399 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
400 }
401
402 fn minimal_entitlements_yaml() -> &'static str {
403 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
404 }
405
406 #[test]
407 fn entitlements_tools_defaults_empty() {
408 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
409 assert!(e.tools.is_empty());
410 }
411
412 #[test]
413 fn entitlements_tools_roundtrip() {
414 let base = minimal_entitlements_yaml();
415 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
416 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
417 assert_eq!(e.tools.len(), 1);
418 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
419 let y = serde_yaml_ng::to_string(&e).unwrap();
420 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
421 assert_eq!(back.tools.len(), 1);
422 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
423 }
424 #[test]
425 fn denylist_membership_and_mutation() {
426 let mut list: Vec<String> = vec![];
427 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
428
429 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
431 assert_eq!(list, ["a"]);
432
433 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
435
436 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
438 assert!(list.is_empty());
439
440 set_denylist(&mut list, "b", true); assert!(list.is_empty());
442 }
443
444 #[test]
445 fn addon_group_rule_truth_table() {
446 let mut p = crate::agent::AgentProfile::default_for_tests();
447 p.addons.push(AddonRef {
448 id: "grp".into(),
449 source: "claude-local:grp@1.0.0".into(),
450 enabled: false,
451 skills: vec!["g_skill".into()],
452 mcp: vec!["g_mcp".into()],
453 commands: vec!["g_cmd".into()],
454 content_hash: None,
455 fetch_ref: None,
456 fetch_plugin: None,
457 });
458
459 assert!(p.skill_enabled("standalone"));
461 assert!(p.mcp_enabled("standalone_mcp"));
462
463 assert!(!p.skill_enabled("g_skill"));
465 assert!(!p.mcp_enabled("g_mcp"));
466
467 assert!(p.set_addon_enabled("grp", true));
469 assert!(p.skill_enabled("g_skill"));
470 assert!(p.mcp_enabled("g_mcp"));
471
472 p.set_skill_enabled("g_skill", false);
474 assert!(!p.skill_enabled("g_skill"));
475
476 assert!(!p.set_addon_enabled("nope", true));
478
479 p.disable_all_addons();
481 assert!(p.addons.iter().all(|g| !g.enabled));
482 assert!(!p.skill_enabled("g_skill"));
483 assert!(!p.skill_enabled("g_cmd"));
484 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
490 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);
496 assert!(p.skill_enabled("g_skill"));
497 }
498
499 #[test]
500 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
501 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
503 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
504 assert_eq!(r.content_hash, None);
505 assert_eq!(r.fetch_ref, None);
506
507 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
509 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
510 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
511 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
512 let back = serde_yaml_ng::to_string(&r2).unwrap();
513 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
514 assert_eq!(r2, r3);
515 }
516}