Skip to main content

aa_core/
capability.rs

1//! Per-level capability types for fine-grained agent permission control.
2//!
3//! A [`Capability`] represents a discrete action category that policy can allow
4//! or deny. A [`CapabilitySet`] aggregates allow and deny sets for a given scope.
5
6use alloc::collections::BTreeSet;
7use alloc::string::{String, ToString};
8use core::str::FromStr;
9
10/// A discrete action category that policy can allow or deny for an agent.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub enum Capability {
14    /// Read access to the filesystem.
15    FileRead,
16    /// Write access to the filesystem.
17    FileWrite,
18    /// Delete/unlink access to the filesystem.
19    ///
20    /// A distinct verb from [`Capability::FileWrite`] so a policy can allow
21    /// writes while denying deletes (and vice versa). See AAASM-4103.
22    FileDelete,
23    /// Outbound network connections.
24    NetworkOutbound,
25    /// Inbound network connections.
26    NetworkInbound,
27    /// Execute commands in a terminal/shell.
28    TerminalExec,
29    /// Use a named MCP tool.
30    McpTool(String),
31    /// Use a named AI model.
32    Model(String),
33    /// Spawn child agents.
34    AgentSpawn,
35}
36
37/// Aggregates allow and deny capability sets for a given policy scope.
38#[derive(Debug, Clone, PartialEq, Default)]
39#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
40pub struct CapabilitySet {
41    /// Capabilities explicitly allowed.
42    pub allow: BTreeSet<Capability>,
43    /// Capabilities explicitly denied.
44    pub deny: BTreeSet<Capability>,
45    /// Whether an allow-list restriction is in force, independent of whether
46    /// `allow` currently lists anything.
47    ///
48    /// Set once any cascade tier contributes a non-empty allow-list. It exists
49    /// to disambiguate the two meanings an empty `allow` would otherwise
50    /// conflate: "no allow-list was ever declared" (unrestricted — only `deny`
51    /// governs) versus "an allow-list was declared but a disjoint multi-tier
52    /// intersection collapsed it to empty" (deny-all). Without this flag,
53    /// merging two disjoint restrictive whitelists produces an empty `allow`
54    /// that the guard reads as "no restriction", failing *open* to allow-all —
55    /// the inverse of most-restrictive-wins (AAASM-4154). `serde(default)` keeps
56    /// older serialized sets (which lack the field) deserializing unchanged.
57    #[cfg_attr(feature = "serde", serde(default))]
58    pub allow_restricted: bool,
59}
60
61impl CapabilitySet {
62    /// Whether an allow-list restriction governs this set.
63    ///
64    /// True when the set whitelists at least one capability, or when a prior
65    /// cascade tier declared a non-empty allow that a disjoint merge collapsed
66    /// to empty (`allow_restricted`). When this is true, the capability guard
67    /// must deny any capability absent from `allow`; an empty `allow` therefore
68    /// means deny-all, never "no restriction" (AAASM-4154).
69    #[must_use]
70    pub fn allow_is_restricted(&self) -> bool {
71        self.allow_restricted || !self.allow.is_empty()
72    }
73}
74
75impl Capability {
76    /// Whether declaring this capability in a policy actually governs anything.
77    ///
78    /// A capability is *enforceable* only if some [`crate::GovernanceAction`]
79    /// maps to it via [`action_to_capability`] — otherwise no action ever routes
80    /// to it, so a declared allow/deny is silently inert and gives the operator a
81    /// false sense of security (AAASM-4099).
82    ///
83    /// Currently inert (no corresponding action variant): [`Capability::Model`],
84    /// [`Capability::NetworkInbound`], and [`Capability::AgentSpawn`]. This
85    /// predicate is the single source of truth policy validation uses to warn
86    /// loudly when a policy references one of them; keep it in lock-step with
87    /// [`action_to_capability`] — when a new action lands that maps to one of
88    /// these, wire the mapping there and drop it from the inert arm below.
89    #[must_use]
90    pub fn is_enforceable(&self) -> bool {
91        !matches!(
92            self,
93            Capability::Model(_) | Capability::NetworkInbound | Capability::AgentSpawn
94        )
95    }
96}
97
98impl FromStr for Capability {
99    type Err = String;
100
101    fn from_str(s: &str) -> Result<Self, Self::Err> {
102        match s {
103            "file_read" => Ok(Capability::FileRead),
104            "file_write" => Ok(Capability::FileWrite),
105            "file_delete" => Ok(Capability::FileDelete),
106            "network_outbound" => Ok(Capability::NetworkOutbound),
107            "network_inbound" => Ok(Capability::NetworkInbound),
108            "terminal_exec" => Ok(Capability::TerminalExec),
109            "agent_spawn" => Ok(Capability::AgentSpawn),
110            _ => {
111                if let Some(name) = s.strip_prefix("mcp_tool:") {
112                    if name.is_empty() {
113                        return Err("mcp_tool: name must not be empty".to_string());
114                    }
115                    Ok(Capability::McpTool(name.to_string()))
116                } else if let Some(name) = s.strip_prefix("model:") {
117                    if name.is_empty() {
118                        return Err("model: name must not be empty".to_string());
119                    }
120                    Ok(Capability::Model(name.to_string()))
121                } else {
122                    Err(alloc::format!("unknown capability: '{s}'"))
123                }
124            }
125        }
126    }
127}
128
129impl core::fmt::Display for Capability {
130    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
131        match self {
132            Capability::FileRead => f.write_str("file_read"),
133            Capability::FileWrite => f.write_str("file_write"),
134            Capability::FileDelete => f.write_str("file_delete"),
135            Capability::NetworkOutbound => f.write_str("network_outbound"),
136            Capability::NetworkInbound => f.write_str("network_inbound"),
137            Capability::TerminalExec => f.write_str("terminal_exec"),
138            Capability::AgentSpawn => f.write_str("agent_spawn"),
139            Capability::McpTool(name) => write!(f, "mcp_tool:{name}"),
140            Capability::Model(name) => write!(f, "model:{name}"),
141        }
142    }
143}
144
145/// Merge a parent [`CapabilitySet`] with a child [`CapabilitySet`] using
146/// parent-deny-wins semantics.
147///
148/// Rules:
149/// - `deny` = union of both deny sets; parent deny always wins over child allow.
150/// - `allow`:
151///   - Both empty → empty (no allow-list restriction).
152///   - Parent empty, child non-empty → `child.allow` minus merged deny.
153///   - Parent non-empty, child empty → `parent.allow` minus merged deny.
154///   - Both non-empty → intersection of `parent.allow` and `child.allow`, minus merged deny.
155/// - `allow_restricted` = set once either input restricts (carries its flag) or
156///   declares a non-empty allow-list. This preserves the restriction across a
157///   disjoint intersection that empties `allow`, so the guard fails *closed*
158///   (deny-all) instead of reading empty as "unrestricted" (AAASM-4154).
159///
160/// Requires the `alloc` feature.
161#[cfg(feature = "alloc")]
162pub fn merge_capabilities(parent: &CapabilitySet, child: &CapabilitySet) -> CapabilitySet {
163    // deny = union of both deny sets
164    let deny: BTreeSet<Capability> = parent.deny.union(&child.deny).cloned().collect();
165
166    let allow: BTreeSet<Capability> = match (parent.allow.is_empty(), child.allow.is_empty()) {
167        // Both empty → no allow-list restriction
168        (true, true) => BTreeSet::new(),
169        // Parent empty, child non-empty → use child.allow
170        (true, false) => child.allow.difference(&deny).cloned().collect(),
171        // Parent non-empty, child empty → use parent.allow
172        (false, true) => parent.allow.difference(&deny).cloned().collect(),
173        // Both non-empty → intersection, then subtract deny
174        (false, false) => parent
175            .allow
176            .intersection(&child.allow)
177            .filter(|c| !deny.contains(c))
178            .cloned()
179            .collect(),
180    };
181
182    // A restriction is in force if either input already carries one or declares
183    // a non-empty allow-list — even when the intersection above empties `allow`.
184    let allow_restricted =
185        parent.allow_restricted || child.allow_restricted || !parent.allow.is_empty() || !child.allow.is_empty();
186
187    CapabilitySet {
188        allow,
189        deny,
190        allow_restricted,
191    }
192}
193
194/// Map a [`crate::GovernanceAction`] to the [`Capability`] it exercises,
195/// or `None` if the action does not map to a known capability.
196///
197/// Requires the `alloc` feature.
198#[cfg(feature = "alloc")]
199pub fn action_to_capability(action: &crate::GovernanceAction) -> Option<Capability> {
200    use crate::policy::FileMode;
201    use crate::GovernanceAction;
202
203    // NOTE: Capability::AgentSpawn, NetworkInbound, and Model variants have no
204    // corresponding GovernanceAction yet — they are flagged by
205    // `Capability::is_enforceable` so policy load warns loudly instead of
206    // presenting a silently-inert control (AAASM-4099). When new action variants
207    // land, add mappings here AND drop the variant from `is_enforceable`'s inert
208    // arm to avoid silent policy bypasses.
209    match action {
210        GovernanceAction::ToolCall { name, .. } => Some(Capability::McpTool(name.clone())),
211        GovernanceAction::ToolResult { tool_name, .. } => Some(Capability::McpTool(tool_name.clone())),
212        GovernanceAction::FileAccess {
213            mode: FileMode::Read, ..
214        } => Some(Capability::FileRead),
215        GovernanceAction::FileAccess {
216            mode: FileMode::Write | FileMode::Append,
217            ..
218        } => Some(Capability::FileWrite),
219        // Delete is a first-class verb (AAASM-4103): it maps to FileDelete, not
220        // FileWrite, so a policy can allow writes yet deny deletes. A pre-4103
221        // `file_write` allow no longer implies delete — delete needs an explicit
222        // `file_delete` grant (fail-closed). See `capability_is_denied` for the
223        // reverse defense-in-depth rule on the deny side.
224        GovernanceAction::FileAccess {
225            mode: FileMode::Delete, ..
226        } => Some(Capability::FileDelete),
227        GovernanceAction::NetworkRequest { .. } => Some(Capability::NetworkOutbound),
228        GovernanceAction::ProcessExec { .. } => Some(Capability::TerminalExec),
229        GovernanceAction::SendMessage { .. } => None,
230    }
231}
232
233/// Whether a policy `deny` set blocks `cap`, honoring superset denies.
234///
235/// A `FileWrite` deny also blocks `FileDelete`: policies authored before
236/// `FileDelete` existed (AAASM-4103) expressed "no mutation" as a single
237/// `file_write` deny, and that intent must keep blocking delete — a stale
238/// write-deny must never leak delete (fail-closed migration). The converse is
239/// deliberately absent: a `FileWrite` *allow* never grants `FileDelete`;
240/// delete requires an explicit `file_delete` allow. Net effect: the new verb
241/// can only ever make delete *more* restricted than before, never less.
242///
243/// Requires the `alloc` feature.
244#[cfg(feature = "alloc")]
245pub fn capability_is_denied(deny: &BTreeSet<Capability>, cap: &Capability) -> bool {
246    if deny.contains(cap) {
247        return true;
248    }
249    // Defense in depth: deny(file_write) ⇒ deny(file_delete).
250    matches!(cap, Capability::FileDelete) && deny.contains(&Capability::FileWrite)
251}
252
253/// Per-scope contribution to an effective permission set.
254///
255/// Carries the `allow` and `deny` capabilities a single policy document declares
256/// at one scope along the cascade chain. The `scope` field is a wire-format
257/// label such as `"global"`, `"org:acme"`, `"team:platform"`, or
258/// `"agent:<uuid>"`; the gateway populates it from the policy's own
259/// `PolicyScope` so the renderer can show provenance without depending on the
260/// gateway's enum.
261#[cfg(feature = "alloc")]
262#[derive(Debug, Clone, PartialEq, Default)]
263#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
264pub struct PermissionSource {
265    /// Wire-format scope label (e.g. `"global"`, `"team:platform"`).
266    pub scope: String,
267    /// Capabilities this scope explicitly allows.
268    pub allow: BTreeSet<Capability>,
269    /// Capabilities this scope explicitly denies.
270    pub deny: BTreeSet<Capability>,
271}
272
273/// Effective capability set for a single agent, with cascade provenance.
274///
275/// `merged` is the result of folding `merge_capabilities` left-to-right over
276/// every policy document that applies to the agent (Global → Org → Team →
277/// Agent → Tool). `sources` records each contributing scope's individual
278/// `allow`/`deny`, in cascade order, so consumers (CLI, dashboard) can show
279/// *where* each capability decision originates.
280///
281/// `sources` may be empty if no policy in the cascade declares a
282/// `capabilities` block, in which case `merged` is also empty (no allow-list
283/// restriction).
284#[cfg(feature = "alloc")]
285#[derive(Debug, Clone, PartialEq, Default)]
286#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
287pub struct EffectivePermissions {
288    /// Merged result after most-restrictive-wins cascade.
289    pub merged: CapabilitySet,
290    /// Per-scope contribution, in cascade order (broadest → narrowest).
291    pub sources: alloc::vec::Vec<PermissionSource>,
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use alloc::collections::BTreeSet;
298
299    #[test]
300    fn capability_variants_are_distinct() {
301        assert_ne!(Capability::FileRead, Capability::FileWrite);
302        assert_ne!(
303            Capability::McpTool("a".to_string()),
304            Capability::McpTool("b".to_string())
305        );
306    }
307
308    #[test]
309    fn mcp_tool_same_name_eq() {
310        assert_eq!(
311            Capability::McpTool("bash".to_string()),
312            Capability::McpTool("bash".to_string())
313        );
314    }
315
316    #[test]
317    fn capability_hashable_in_set() {
318        let mut set: BTreeSet<Capability> = BTreeSet::new();
319        set.insert(Capability::FileRead);
320        set.insert(Capability::FileWrite);
321        set.insert(Capability::McpTool("bash".to_string()));
322        assert_eq!(set.len(), 3);
323    }
324
325    #[test]
326    fn capability_set_default_is_empty() {
327        let cs = CapabilitySet::default();
328        assert!(cs.allow.is_empty());
329        assert!(cs.deny.is_empty());
330    }
331
332    #[test]
333    fn capability_from_str_file_read() {
334        assert_eq!("file_read".parse::<Capability>().unwrap(), Capability::FileRead);
335    }
336
337    #[test]
338    fn capability_from_str_file_write() {
339        assert_eq!("file_write".parse::<Capability>().unwrap(), Capability::FileWrite);
340    }
341
342    #[test]
343    fn capability_from_str_file_delete() {
344        assert_eq!("file_delete".parse::<Capability>().unwrap(), Capability::FileDelete);
345    }
346
347    #[test]
348    fn capability_file_delete_display_round_trips() {
349        let cap = Capability::FileDelete;
350        assert_eq!(cap.to_string(), "file_delete");
351        assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
352    }
353
354    #[test]
355    fn capability_file_delete_distinct_from_file_write() {
356        assert_ne!(Capability::FileDelete, Capability::FileWrite);
357    }
358
359    #[test]
360    fn capability_from_str_network_outbound() {
361        assert_eq!(
362            "network_outbound".parse::<Capability>().unwrap(),
363            Capability::NetworkOutbound
364        );
365    }
366
367    #[test]
368    fn capability_from_str_network_inbound() {
369        assert_eq!(
370            "network_inbound".parse::<Capability>().unwrap(),
371            Capability::NetworkInbound
372        );
373    }
374
375    #[test]
376    fn capability_from_str_terminal_exec() {
377        assert_eq!("terminal_exec".parse::<Capability>().unwrap(), Capability::TerminalExec);
378    }
379
380    #[test]
381    fn capability_from_str_mcp_tool() {
382        assert_eq!(
383            "mcp_tool:bash".parse::<Capability>().unwrap(),
384            Capability::McpTool("bash".to_string())
385        );
386    }
387
388    #[test]
389    fn capability_from_str_model() {
390        assert_eq!(
391            "model:gpt-4o".parse::<Capability>().unwrap(),
392            Capability::Model("gpt-4o".to_string())
393        );
394    }
395
396    #[test]
397    fn capability_from_str_agent_spawn() {
398        assert_eq!("agent_spawn".parse::<Capability>().unwrap(), Capability::AgentSpawn);
399    }
400
401    #[test]
402    fn capability_from_str_unknown_returns_err() {
403        assert!("unknown_cap".parse::<Capability>().is_err());
404    }
405
406    #[test]
407    fn capability_from_str_mcp_tool_empty_name_returns_err() {
408        assert!("mcp_tool:".parse::<Capability>().is_err());
409    }
410
411    #[test]
412    fn capability_from_str_model_empty_name_returns_err() {
413        assert!("model:".parse::<Capability>().is_err());
414    }
415
416    #[test]
417    fn capability_display_round_trips_simple_variant() {
418        let cap = Capability::FileRead;
419        assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
420    }
421
422    #[test]
423    fn capability_display_round_trips_mcp_tool() {
424        let cap = Capability::McpTool("bash".to_string());
425        assert_eq!(cap.to_string().parse::<Capability>().unwrap(), cap);
426    }
427
428    // ------------------------------------------------------------------
429    // merge_capabilities tests
430    // ------------------------------------------------------------------
431
432    fn cap_set(allow: &[Capability], deny: &[Capability]) -> CapabilitySet {
433        CapabilitySet {
434            allow: allow.iter().cloned().collect(),
435            deny: deny.iter().cloned().collect(),
436            allow_restricted: false,
437        }
438    }
439
440    #[test]
441    fn merge_empty_parent_with_child_deny() {
442        let parent = CapabilitySet::default();
443        let child = cap_set(&[], &[Capability::FileWrite]);
444        let result = super::merge_capabilities(&parent, &child);
445        assert!(result.deny.contains(&Capability::FileWrite));
446        assert!(result.allow.is_empty());
447    }
448
449    #[test]
450    fn merge_parent_deny_wins_over_child_allow() {
451        let parent = cap_set(&[], &[Capability::NetworkOutbound]);
452        let child = cap_set(&[Capability::FileRead, Capability::NetworkOutbound], &[]);
453        let result = super::merge_capabilities(&parent, &child);
454        assert!(result.allow.contains(&Capability::FileRead));
455        assert!(!result.allow.contains(&Capability::NetworkOutbound));
456    }
457
458    #[test]
459    fn merge_deny_is_union() {
460        let parent = cap_set(&[], &[Capability::FileWrite]);
461        let child = cap_set(&[], &[Capability::TerminalExec]);
462        let result = super::merge_capabilities(&parent, &child);
463        assert!(result.deny.contains(&Capability::FileWrite));
464        assert!(result.deny.contains(&Capability::TerminalExec));
465    }
466
467    #[test]
468    fn merge_both_allow_nonempty_takes_intersection() {
469        let parent = cap_set(&[Capability::FileRead, Capability::FileWrite], &[]);
470        let child = cap_set(&[Capability::FileRead, Capability::NetworkOutbound], &[]);
471        let result = super::merge_capabilities(&parent, &child);
472        assert_eq!(
473            result.allow,
474            [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
475        );
476    }
477
478    #[test]
479    fn merge_parent_allow_nonempty_child_allow_empty_uses_parent() {
480        let parent = cap_set(&[Capability::FileRead], &[]);
481        let child = cap_set(&[], &[]);
482        let result = super::merge_capabilities(&parent, &child);
483        assert_eq!(
484            result.allow,
485            [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
486        );
487    }
488
489    #[test]
490    fn merge_parent_allow_empty_child_allow_nonempty_uses_child() {
491        let parent = cap_set(&[], &[]);
492        let child = cap_set(&[Capability::FileRead], &[]);
493        let result = super::merge_capabilities(&parent, &child);
494        assert_eq!(
495            result.allow,
496            [Capability::FileRead].iter().cloned().collect::<BTreeSet<_>>()
497        );
498    }
499
500    #[test]
501    fn merge_parent_deny_overrides_intersection_allow() {
502        let parent = cap_set(&[Capability::FileRead, Capability::FileWrite], &[Capability::FileRead]);
503        let child = cap_set(&[Capability::FileRead], &[]);
504        let result = super::merge_capabilities(&parent, &child);
505        assert!(
506            result.allow.is_empty(),
507            "FileRead was denied by parent, should be absent from allow"
508        );
509        assert!(result.deny.contains(&Capability::FileRead));
510    }
511
512    #[test]
513    fn merge_both_empty_returns_empty() {
514        let parent = CapabilitySet::default();
515        let child = CapabilitySet::default();
516        let result = super::merge_capabilities(&parent, &child);
517        assert_eq!(result, CapabilitySet::default());
518    }
519
520    // ------------------------------------------------------------------
521    // action_to_capability tests
522    // ------------------------------------------------------------------
523
524    #[test]
525    fn action_to_capability_tool_call() {
526        let action = crate::GovernanceAction::ToolCall {
527            name: "bash".to_string(),
528            args: "{}".to_string(),
529        };
530        assert_eq!(
531            super::action_to_capability(&action),
532            Some(Capability::McpTool("bash".to_string()))
533        );
534    }
535
536    #[test]
537    fn action_to_capability_file_read() {
538        let action = crate::GovernanceAction::FileAccess {
539            path: "/tmp/f".to_string(),
540            mode: crate::policy::FileMode::Read,
541        };
542        assert_eq!(super::action_to_capability(&action), Some(Capability::FileRead));
543    }
544
545    #[test]
546    fn action_to_capability_file_write() {
547        let action = crate::GovernanceAction::FileAccess {
548            path: "/tmp/f".to_string(),
549            mode: crate::policy::FileMode::Write,
550        };
551        assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
552    }
553
554    #[test]
555    fn action_to_capability_file_append_is_file_write() {
556        let action = crate::GovernanceAction::FileAccess {
557            path: "/tmp/f".to_string(),
558            mode: crate::policy::FileMode::Append,
559        };
560        assert_eq!(super::action_to_capability(&action), Some(Capability::FileWrite));
561    }
562
563    #[test]
564    fn action_to_capability_file_delete_is_file_delete() {
565        // AAASM-4103: Delete is its own capability, not FileWrite.
566        let action = crate::GovernanceAction::FileAccess {
567            path: "/tmp/f".to_string(),
568            mode: crate::policy::FileMode::Delete,
569        };
570        assert_eq!(super::action_to_capability(&action), Some(Capability::FileDelete));
571    }
572
573    #[test]
574    fn action_to_capability_network_request() {
575        let action = crate::GovernanceAction::NetworkRequest {
576            url: "https://example.com".to_string(),
577            method: "GET".to_string(),
578        };
579        assert_eq!(super::action_to_capability(&action), Some(Capability::NetworkOutbound));
580    }
581
582    // ------------------------------------------------------------------
583    // is_enforceable tests (AAASM-4099)
584    // ------------------------------------------------------------------
585
586    #[test]
587    fn is_enforceable_false_for_inert_capabilities() {
588        // These have no GovernanceAction mapping, so declaring them is silently
589        // inert — is_enforceable must flag them so policy load can warn loudly.
590        assert!(!Capability::NetworkInbound.is_enforceable());
591        assert!(!Capability::AgentSpawn.is_enforceable());
592        assert!(!Capability::Model("gpt-4o".to_string()).is_enforceable());
593    }
594
595    #[test]
596    fn is_enforceable_true_for_action_backed_capabilities() {
597        assert!(Capability::FileRead.is_enforceable());
598        assert!(Capability::FileWrite.is_enforceable());
599        assert!(Capability::NetworkOutbound.is_enforceable());
600        assert!(Capability::TerminalExec.is_enforceable());
601        assert!(Capability::McpTool("bash".to_string()).is_enforceable());
602    }
603
604    #[test]
605    fn action_to_capability_only_yields_enforceable_capabilities() {
606        // Invariant: every capability an action can produce must be enforceable,
607        // otherwise stage_capability could deny on a cap load warned was inert.
608        let actions = [
609            crate::GovernanceAction::ToolCall {
610                name: "bash".to_string(),
611                args: "{}".to_string(),
612            },
613            crate::GovernanceAction::FileAccess {
614                path: "/tmp/f".to_string(),
615                mode: crate::policy::FileMode::Read,
616            },
617            crate::GovernanceAction::FileAccess {
618                path: "/tmp/f".to_string(),
619                mode: crate::policy::FileMode::Write,
620            },
621            crate::GovernanceAction::NetworkRequest {
622                url: "https://example.com".to_string(),
623                method: "GET".to_string(),
624            },
625            crate::GovernanceAction::ProcessExec {
626                command: "ls".to_string(),
627            },
628        ];
629        for action in &actions {
630            if let Some(cap) = super::action_to_capability(action) {
631                assert!(
632                    cap.is_enforceable(),
633                    "action {action:?} mapped to inert capability {cap:?}"
634                );
635            }
636        }
637    }
638
639    #[test]
640    fn action_to_capability_process_exec() {
641        let action = crate::GovernanceAction::ProcessExec {
642            command: "ls".to_string(),
643        };
644        assert_eq!(super::action_to_capability(&action), Some(Capability::TerminalExec));
645    }
646
647    // ------------------------------------------------------------------
648    // capability_is_denied tests (AAASM-4103 fail-closed migration)
649    // ------------------------------------------------------------------
650
651    fn deny_set(caps: &[Capability]) -> BTreeSet<Capability> {
652        caps.iter().cloned().collect()
653    }
654
655    #[test]
656    fn capability_is_denied_direct_match() {
657        let deny = deny_set(&[Capability::FileDelete]);
658        assert!(super::capability_is_denied(&deny, &Capability::FileDelete));
659    }
660
661    #[test]
662    fn capability_is_denied_empty_set_is_false() {
663        let deny = deny_set(&[]);
664        assert!(!super::capability_is_denied(&deny, &Capability::FileDelete));
665    }
666
667    #[test]
668    fn capability_is_denied_file_write_deny_also_denies_delete() {
669        // Defense in depth: a pre-4103 `file_write` deny keeps blocking delete.
670        let deny = deny_set(&[Capability::FileWrite]);
671        assert!(super::capability_is_denied(&deny, &Capability::FileDelete));
672    }
673
674    #[test]
675    fn capability_is_denied_file_delete_deny_does_not_deny_write() {
676        // Asymmetric: delete-deny must NOT block writes.
677        let deny = deny_set(&[Capability::FileDelete]);
678        assert!(!super::capability_is_denied(&deny, &Capability::FileWrite));
679    }
680
681    #[test]
682    fn capability_is_denied_unrelated_deny_is_false() {
683        let deny = deny_set(&[Capability::NetworkOutbound]);
684        assert!(!super::capability_is_denied(&deny, &Capability::FileDelete));
685    }
686
687    // ------------------------------------------------------------------
688    // allow_restricted / allow_is_restricted (AAASM-4154)
689    // ------------------------------------------------------------------
690
691    #[test]
692    fn allow_is_restricted_true_when_allow_non_empty() {
693        // A non-empty allow-list is a restriction even without the flag set.
694        let cs = cap_set(&[Capability::FileRead], &[]);
695        assert!(cs.allow_is_restricted());
696    }
697
698    #[test]
699    fn allow_is_restricted_false_when_no_allow_declared() {
700        // Deny-only (or empty) set with no restriction flag → unrestricted.
701        let cs = cap_set(&[], &[Capability::TerminalExec]);
702        assert!(!cs.allow_is_restricted());
703    }
704
705    #[test]
706    fn allow_is_restricted_true_when_flag_set_but_allow_empty() {
707        // The collapsed-cascade shape: empty allow but restriction carried.
708        let cs = CapabilitySet {
709            allow: BTreeSet::new(),
710            deny: BTreeSet::new(),
711            allow_restricted: true,
712        };
713        assert!(cs.allow_is_restricted());
714    }
715
716    #[test]
717    fn merge_disjoint_allow_lists_stays_restricted_with_empty_allow() {
718        // AAASM-4154: two disjoint non-empty allow-lists intersect to empty, but
719        // the restriction must survive so the guard fails closed (deny-all)
720        // rather than reading empty allow as "no restriction" (allow-all).
721        let parent = cap_set(&[Capability::FileRead], &[]);
722        let child = cap_set(&[Capability::FileWrite], &[]);
723        let result = super::merge_capabilities(&parent, &child);
724        assert!(result.allow.is_empty(), "disjoint allow-lists intersect to empty");
725        assert!(result.allow_restricted, "restriction must persist across the collapse");
726        assert!(
727            result.allow_is_restricted(),
728            "guard must treat the collapsed set as restricted (deny-all)"
729        );
730    }
731
732    #[test]
733    fn merge_single_tier_allow_is_restricted() {
734        // A lone declared allow-list is a restriction after merge.
735        let parent = CapabilitySet::default();
736        let child = cap_set(&[Capability::FileRead], &[]);
737        let result = super::merge_capabilities(&parent, &child);
738        assert!(result.allow.contains(&Capability::FileRead));
739        assert!(result.allow_restricted);
740    }
741
742    #[test]
743    fn merge_no_allow_declared_is_unrestricted() {
744        // Deny-only tiers never manufacture an allow-list restriction.
745        let parent = cap_set(&[], &[Capability::FileWrite]);
746        let child = cap_set(&[], &[Capability::TerminalExec]);
747        let result = super::merge_capabilities(&parent, &child);
748        assert!(result.allow.is_empty());
749        assert!(!result.allow_restricted);
750        assert!(!result.allow_is_restricted());
751    }
752}