1use alloc::collections::BTreeSet;
7use alloc::string::{String, ToString};
8use core::str::FromStr;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum Capability {
14 FileRead,
16 FileWrite,
18 NetworkOutbound,
20 NetworkInbound,
22 TerminalExec,
24 McpTool(String),
26 Model(String),
28 AgentSpawn,
30}
31
32#[derive(Debug, Clone, PartialEq, Default)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub struct CapabilitySet {
36 pub allow: BTreeSet<Capability>,
38 pub deny: BTreeSet<Capability>,
40}
41
42impl FromStr for Capability {
43 type Err = String;
44
45 fn from_str(s: &str) -> Result<Self, Self::Err> {
46 match s {
47 "file_read" => Ok(Capability::FileRead),
48 "file_write" => Ok(Capability::FileWrite),
49 "network_outbound" => Ok(Capability::NetworkOutbound),
50 "network_inbound" => Ok(Capability::NetworkInbound),
51 "terminal_exec" => Ok(Capability::TerminalExec),
52 "agent_spawn" => Ok(Capability::AgentSpawn),
53 _ => {
54 if let Some(name) = s.strip_prefix("mcp_tool:") {
55 if name.is_empty() {
56 return Err("mcp_tool: name must not be empty".to_string());
57 }
58 Ok(Capability::McpTool(name.to_string()))
59 } else if let Some(name) = s.strip_prefix("model:") {
60 if name.is_empty() {
61 return Err("model: name must not be empty".to_string());
62 }
63 Ok(Capability::Model(name.to_string()))
64 } else {
65 Err(alloc::format!("unknown capability: '{s}'"))
66 }
67 }
68 }
69 }
70}
71
72impl core::fmt::Display for Capability {
73 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
74 match self {
75 Capability::FileRead => f.write_str("file_read"),
76 Capability::FileWrite => f.write_str("file_write"),
77 Capability::NetworkOutbound => f.write_str("network_outbound"),
78 Capability::NetworkInbound => f.write_str("network_inbound"),
79 Capability::TerminalExec => f.write_str("terminal_exec"),
80 Capability::AgentSpawn => f.write_str("agent_spawn"),
81 Capability::McpTool(name) => write!(f, "mcp_tool:{name}"),
82 Capability::Model(name) => write!(f, "model:{name}"),
83 }
84 }
85}
86
87#[cfg(feature = "alloc")]
100pub fn merge_capabilities(parent: &CapabilitySet, child: &CapabilitySet) -> CapabilitySet {
101 let deny: BTreeSet<Capability> = parent.deny.union(&child.deny).cloned().collect();
103
104 let allow: BTreeSet<Capability> = match (parent.allow.is_empty(), child.allow.is_empty()) {
105 (true, true) => BTreeSet::new(),
107 (true, false) => child.allow.difference(&deny).cloned().collect(),
109 (false, true) => parent.allow.difference(&deny).cloned().collect(),
111 (false, false) => parent
113 .allow
114 .intersection(&child.allow)
115 .filter(|c| !deny.contains(c))
116 .cloned()
117 .collect(),
118 };
119
120 CapabilitySet { allow, deny }
121}
122
123#[cfg(feature = "alloc")]
128pub fn action_to_capability(action: &crate::GovernanceAction) -> Option<Capability> {
129 use crate::policy::FileMode;
130 use crate::GovernanceAction;
131
132 match action {
136 GovernanceAction::ToolCall { name, .. } => Some(Capability::McpTool(name.clone())),
137 GovernanceAction::ToolResult { tool_name, .. } => Some(Capability::McpTool(tool_name.clone())),
138 GovernanceAction::FileAccess {
139 mode: FileMode::Read, ..
140 } => Some(Capability::FileRead),
141 GovernanceAction::FileAccess {
142 mode: FileMode::Write | FileMode::Append | FileMode::Delete,
143 ..
144 } => Some(Capability::FileWrite),
145 GovernanceAction::NetworkRequest { .. } => Some(Capability::NetworkOutbound),
146 GovernanceAction::ProcessExec { .. } => Some(Capability::TerminalExec),
147 GovernanceAction::SendMessage { .. } => None,
148 }
149}
150
151#[cfg(feature = "alloc")]
160#[derive(Debug, Clone, PartialEq, Default)]
161#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
162pub struct PermissionSource {
163 pub scope: String,
165 pub allow: BTreeSet<Capability>,
167 pub deny: BTreeSet<Capability>,
169}
170
171#[cfg(feature = "alloc")]
183#[derive(Debug, Clone, PartialEq, Default)]
184#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
185pub struct EffectivePermissions {
186 pub merged: CapabilitySet,
188 pub sources: alloc::vec::Vec<PermissionSource>,
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use alloc::collections::BTreeSet;
196
197 #[test]
198 fn capability_variants_are_distinct() {
199 assert_ne!(Capability::FileRead, Capability::FileWrite);
200 assert_ne!(
201 Capability::McpTool("a".to_string()),
202 Capability::McpTool("b".to_string())
203 );
204 }
205
206 #[test]
207 fn mcp_tool_same_name_eq() {
208 assert_eq!(
209 Capability::McpTool("bash".to_string()),
210 Capability::McpTool("bash".to_string())
211 );
212 }
213
214 #[test]
215 fn capability_hashable_in_set() {
216 let mut set: BTreeSet<Capability> = BTreeSet::new();
217 set.insert(Capability::FileRead);
218 set.insert(Capability::FileWrite);
219 set.insert(Capability::McpTool("bash".to_string()));
220 assert_eq!(set.len(), 3);
221 }
222
223 #[test]
224 fn capability_set_default_is_empty() {
225 let cs = CapabilitySet::default();
226 assert!(cs.allow.is_empty());
227 assert!(cs.deny.is_empty());
228 }
229
230 #[test]
231 fn capability_from_str_file_read() {
232 assert_eq!("file_read".parse::<Capability>().unwrap(), Capability::FileRead);
233 }
234
235 #[test]
236 fn capability_from_str_file_write() {
237 assert_eq!("file_write".parse::<Capability>().unwrap(), Capability::FileWrite);
238 }
239
240 #[test]
241 fn capability_from_str_network_outbound() {
242 assert_eq!(
243 "network_outbound".parse::<Capability>().unwrap(),
244 Capability::NetworkOutbound
245 );
246 }
247
248 #[test]
249 fn capability_from_str_network_inbound() {
250 assert_eq!(
251 "network_inbound".parse::<Capability>().unwrap(),
252 Capability::NetworkInbound
253 );
254 }
255
256 #[test]
257 fn capability_from_str_terminal_exec() {
258 assert_eq!("terminal_exec".parse::<Capability>().unwrap(), Capability::TerminalExec);
259 }
260
261 #[test]
262 fn capability_from_str_mcp_tool() {
263 assert_eq!(
264 "mcp_tool:bash".parse::<Capability>().unwrap(),
265 Capability::McpTool("bash".to_string())
266 );
267 }
268
269 #[test]
270 fn capability_from_str_model() {
271 assert_eq!(
272 "model:gpt-4o".parse::<Capability>().unwrap(),
273 Capability::Model("gpt-4o".to_string())
274 );
275 }
276
277 #[test]
278 fn capability_from_str_agent_spawn() {
279 assert_eq!("agent_spawn".parse::<Capability>().unwrap(), Capability::AgentSpawn);
280 }
281
282 #[test]
283 fn capability_from_str_unknown_returns_err() {
284 assert!("unknown_cap".parse::<Capability>().is_err());
285 }
286
287 #[test]
288 fn capability_from_str_mcp_tool_empty_name_returns_err() {
289 assert!("mcp_tool:".parse::<Capability>().is_err());
290 }
291
292 #[test]
293 fn capability_from_str_model_empty_name_returns_err() {
294 assert!("model:".parse::<Capability>().is_err());
295 }
296
297 #[test]
298 fn capability_display_round_trips_simple_variant() {
299 let cap = Capability::FileRead;
300 assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
301 }
302
303 #[test]
304 fn capability_display_round_trips_mcp_tool() {
305 let cap = Capability::McpTool("bash".to_string());
306 assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
307 }
308
309 fn cap_set(allow: &[Capability], deny: &[Capability]) -> CapabilitySet {
314 CapabilitySet {
315 allow: allow.iter().cloned().collect(),
316 deny: deny.iter().cloned().collect(),
317 }
318 }
319
320 #[test]
321 fn merge_empty_parent_with_child_deny() {
322 let parent = CapabilitySet::default();
323 let child = cap_set(&[], &[Capability::FileWrite]);
324 let result = super::merge_capabilities(&parent, &child);
325 assert!(result.deny.contains(&Capability::FileWrite));
326 assert!(result.allow.is_empty());
327 }
328
329 #[test]
330 fn merge_parent_deny_wins_over_child_allow() {
331 let parent = cap_set(&[], &[Capability::NetworkOutbound]);
332 let child = cap_set(&[Capability::FileRead, Capability::NetworkOutbound], &[]);
333 let result = super::merge_capabilities(&parent, &child);
334 assert!(result.allow.contains(&Capability::FileRead));
335 assert!(!result.allow.contains(&Capability::NetworkOutbound));
336 }
337
338 #[test]
339 fn merge_deny_is_union() {
340 let parent = cap_set(&[], &[Capability::FileWrite]);
341 let child = cap_set(&[], &[Capability::TerminalExec]);
342 let result = super::merge_capabilities(&parent, &child);
343 assert!(result.deny.contains(&Capability::FileWrite));
344 assert!(result.deny.contains(&Capability::TerminalExec));
345 }
346
347 #[test]
348 fn merge_both_allow_nonempty_takes_intersection() {
349 let parent = cap_set(&[Capability::FileRead, Capability::FileWrite], &[]);
350 let child = cap_set(&[Capability::FileRead, Capability::NetworkOutbound], &[]);
351 let result = super::merge_capabilities(&parent, &child);
352 assert_eq!(
353 result.allow,
354 [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
355 );
356 }
357
358 #[test]
359 fn merge_parent_allow_nonempty_child_allow_empty_uses_parent() {
360 let parent = cap_set(&[Capability::FileRead], &[]);
361 let child = cap_set(&[], &[]);
362 let result = super::merge_capabilities(&parent, &child);
363 assert_eq!(
364 result.allow,
365 [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
366 );
367 }
368
369 #[test]
370 fn merge_parent_allow_empty_child_allow_nonempty_uses_child() {
371 let parent = cap_set(&[], &[]);
372 let child = cap_set(&[Capability::FileRead], &[]);
373 let result = super::merge_capabilities(&parent, &child);
374 assert_eq!(
375 result.allow,
376 [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
377 );
378 }
379
380 #[test]
381 fn merge_parent_deny_overrides_intersection_allow() {
382 let parent = cap_set(&[Capability::FileRead, Capability::FileWrite], &[Capability::FileRead]);
383 let child = cap_set(&[Capability::FileRead], &[]);
384 let result = super::merge_capabilities(&parent, &child);
385 assert!(
386 result.allow.is_empty(),
387 "FileRead was denied by parent, should be absent from allow"
388 );
389 assert!(result.deny.contains(&Capability::FileRead));
390 }
391
392 #[test]
393 fn merge_both_empty_returns_empty() {
394 let parent = CapabilitySet::default();
395 let child = CapabilitySet::default();
396 let result = super::merge_capabilities(&parent, &child);
397 assert_eq!(result, CapabilitySet::default());
398 }
399
400 #[test]
405 fn action_to_capability_tool_call() {
406 let action = crate::GovernanceAction::ToolCall {
407 name: "bash".to_string(),
408 args: "{}".to_string(),
409 };
410 assert_eq!(
411 super::action_to_capability(&action),
412 Some(Capability::McpTool("bash".to_string()))
413 );
414 }
415
416 #[test]
417 fn action_to_capability_file_read() {
418 let action = crate::GovernanceAction::FileAccess {
419 path: "/tmp/f".to_string(),
420 mode: crate::policy::FileMode::Read,
421 };
422 assert_eq!(super::action_to_capability(&action), Some(Capability::FileRead));
423 }
424
425 #[test]
426 fn action_to_capability_file_write() {
427 let action = crate::GovernanceAction::FileAccess {
428 path: "/tmp/f".to_string(),
429 mode: crate::policy::FileMode::Write,
430 };
431 assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
432 }
433
434 #[test]
435 fn action_to_capability_file_append_is_file_write() {
436 let action = crate::GovernanceAction::FileAccess {
437 path: "/tmp/f".to_string(),
438 mode: crate::policy::FileMode::Append,
439 };
440 assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
441 }
442
443 #[test]
444 fn action_to_capability_file_delete_is_file_write() {
445 let action = crate::GovernanceAction::FileAccess {
446 path: "/tmp/f".to_string(),
447 mode: crate::policy::FileMode::Delete,
448 };
449 assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
450 }
451
452 #[test]
453 fn action_to_capability_network_request() {
454 let action = crate::GovernanceAction::NetworkRequest {
455 url: "https://example.com".to_string(),
456 method: "GET".to_string(),
457 };
458 assert_eq!(super::action_to_capability(&action), Some(Capability::NetworkOutbound));
459 }
460
461 #[test]
462 fn action_to_capability_process_exec() {
463 let action = crate::GovernanceAction::ProcessExec {
464 command: "ls".to_string(),
465 };
466 assert_eq!(super::action_to_capability(&action), Some(Capability::TerminalExec));
467 }
468}