Skip to main content

batuta/agent/
capability.rs

1//! Capability-based access control for agent tools.
2//!
3//! Implements the Poka-Yoke (mistake-proofing) pattern from Toyota
4//! Production System. Each tool declares its required capability;
5//! the agent manifest grants capabilities. Mismatch → denied.
6//!
7//! See: arXiv:2406.09187 (`GuardAgent`), arXiv:2509.22256 (access control).
8
9use serde::{Deserialize, Serialize};
10
11/// Capability grants for tools (Poka-Yoke pattern).
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum Capability {
15    /// Access RAG pipeline for document retrieval.
16    Rag,
17    /// Read/write agent memory.
18    Memory,
19    /// Execute shell commands (sandboxed).
20    Shell {
21        /// Allowed command prefixes. `["*"]` allows all.
22        allowed_commands: Vec<String>,
23    },
24    /// Launch headless browser via jugar-probar.
25    Browser,
26    /// Invoke sub-inference on a different model.
27    Inference,
28    /// Read files from the filesystem.
29    FileRead {
30        /// Allowed path prefixes. `["*"]` allows all.
31        allowed_paths: Vec<String>,
32    },
33    /// Write or edit files on the filesystem.
34    FileWrite {
35        /// Allowed path prefixes. `["*"]` allows all.
36        allowed_paths: Vec<String>,
37    },
38    /// Submit work to repartir compute pool.
39    Compute,
40    /// Network egress (blocked in Sovereign tier).
41    Network {
42        /// Allowed hostnames. `["*"]` allows all.
43        allowed_hosts: Vec<String>,
44    },
45    /// MCP tool from external server (agents-mcp feature).
46    Mcp {
47        /// MCP server name.
48        server: String,
49        /// Tool name on that server. `"*"` matches all.
50        tool: String,
51    },
52    /// Spawn sub-agents (bounded by `max_depth`).
53    Spawn {
54        /// Maximum recursion depth (0 = no sub-spawning).
55        max_depth: u32,
56    },
57}
58
59/// Check if granted capabilities satisfy a required capability.
60///
61/// Returns `true` if at least one granted capability matches the
62/// required capability. Wildcard (`"*"`) matching is supported
63/// for Shell commands, Network hosts, and MCP tools.
64#[cfg_attr(
65    feature = "agents-contracts",
66    provable_contracts_macros::contract("agent-loop-v1", equation = "capability_match")
67)]
68pub fn capability_matches(granted: &[Capability], required: &Capability) -> bool {
69    granted.iter().any(|g| single_match(g, required))
70}
71
72fn single_match(granted: &Capability, required: &Capability) -> bool {
73    match (granted, required) {
74        (Capability::Rag, Capability::Rag)
75        | (Capability::Memory, Capability::Memory)
76        | (Capability::Browser, Capability::Browser)
77        | (Capability::Inference, Capability::Inference)
78        | (Capability::Compute, Capability::Compute) => true,
79
80        (Capability::FileRead { allowed_paths: g }, Capability::FileRead { allowed_paths: r }) => {
81            r.iter().all(|p| g.contains(p) || g.iter().any(|gp| gp == "*"))
82        }
83
84        (
85            Capability::FileWrite { allowed_paths: g },
86            Capability::FileWrite { allowed_paths: r },
87        ) => r.iter().all(|p| g.contains(p) || g.iter().any(|gp| gp == "*")),
88
89        (Capability::Spawn { max_depth: g }, Capability::Spawn { max_depth: r }) => g >= r,
90
91        (Capability::Shell { allowed_commands: g }, Capability::Shell { allowed_commands: r }) => {
92            r.iter().all(|cmd| g.contains(cmd) || g.iter().any(|p| p == "*"))
93        }
94
95        (Capability::Network { allowed_hosts: g }, Capability::Network { allowed_hosts: r }) => {
96            r.iter().all(|h| g.contains(h) || g.iter().any(|p| p == "*"))
97        }
98
99        (Capability::Mcp { server: gs, tool: gt }, Capability::Mcp { server: rs, tool: rt }) => {
100            (gs == rs || gs == "*") && (gt == rt || gt == "*")
101        }
102
103        _ => false,
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn test_exact_match_simple() {
113        assert!(capability_matches(&[Capability::Rag], &Capability::Rag));
114        assert!(capability_matches(&[Capability::Memory], &Capability::Memory));
115        assert!(capability_matches(&[Capability::Browser], &Capability::Browser));
116        assert!(capability_matches(&[Capability::Inference], &Capability::Inference));
117        assert!(capability_matches(&[Capability::Compute], &Capability::Compute));
118    }
119
120    #[test]
121    fn test_mismatch_denied() {
122        assert!(!capability_matches(&[Capability::Rag], &Capability::Memory));
123        assert!(!capability_matches(&[Capability::Browser], &Capability::Compute));
124        assert!(!capability_matches(&[], &Capability::Rag));
125    }
126
127    #[test]
128    fn test_shell_wildcard() {
129        let granted = Capability::Shell { allowed_commands: vec!["*".into()] };
130        let required = Capability::Shell { allowed_commands: vec!["ls".into(), "cat".into()] };
131        assert!(capability_matches(&[granted], &required));
132    }
133
134    #[test]
135    fn test_shell_specific() {
136        let granted = Capability::Shell { allowed_commands: vec!["ls".into()] };
137        let required = Capability::Shell { allowed_commands: vec!["ls".into()] };
138        assert!(capability_matches(&[granted.clone()], &required));
139
140        let denied = Capability::Shell { allowed_commands: vec!["rm".into()] };
141        assert!(!capability_matches(&[granted], &denied));
142    }
143
144    #[test]
145    fn test_network_wildcard() {
146        let granted = Capability::Network { allowed_hosts: vec!["*".into()] };
147        let required = Capability::Network { allowed_hosts: vec!["api.example.com".into()] };
148        assert!(capability_matches(&[granted], &required));
149    }
150
151    #[test]
152    fn test_network_specific() {
153        let granted = Capability::Network { allowed_hosts: vec!["localhost".into()] };
154        let required = Capability::Network { allowed_hosts: vec!["localhost".into()] };
155        assert!(capability_matches(&[granted.clone()], &required));
156
157        let denied = Capability::Network { allowed_hosts: vec!["evil.com".into()] };
158        assert!(!capability_matches(&[granted], &denied));
159    }
160
161    #[test]
162    fn test_mcp_exact() {
163        let granted = Capability::Mcp { server: "fs".into(), tool: "read".into() };
164        let required = Capability::Mcp { server: "fs".into(), tool: "read".into() };
165        assert!(capability_matches(&[granted], &required));
166    }
167
168    #[test]
169    fn test_mcp_tool_wildcard() {
170        let granted = Capability::Mcp { server: "fs".into(), tool: "*".into() };
171        let required = Capability::Mcp { server: "fs".into(), tool: "read".into() };
172        assert!(capability_matches(&[granted], &required));
173    }
174
175    #[test]
176    fn test_mcp_server_mismatch() {
177        let granted = Capability::Mcp { server: "fs".into(), tool: "*".into() };
178        let required = Capability::Mcp { server: "db".into(), tool: "query".into() };
179        assert!(!capability_matches(&[granted], &required));
180    }
181
182    #[test]
183    fn test_multiple_granted_any_match() {
184        let granted = vec![Capability::Rag, Capability::Memory, Capability::Browser];
185        assert!(capability_matches(&granted, &Capability::Memory));
186        assert!(!capability_matches(&granted, &Capability::Compute));
187    }
188
189    #[test]
190    fn test_spawn_capability() {
191        let granted = Capability::Spawn { max_depth: 3 };
192        let required = Capability::Spawn { max_depth: 2 };
193        assert!(capability_matches(&[granted], &required));
194
195        let too_deep = Capability::Spawn { max_depth: 5 };
196        let shallow = Capability::Spawn { max_depth: 1 };
197        assert!(!capability_matches(&[shallow], &too_deep));
198
199        assert!(!capability_matches(&[Capability::Compute], &Capability::Spawn { max_depth: 1 },));
200    }
201
202    #[test]
203    fn test_serialization_roundtrip() {
204        let caps = vec![
205            Capability::Rag,
206            Capability::Shell { allowed_commands: vec!["ls".into()] },
207            Capability::Mcp { server: "s".into(), tool: "t".into() },
208        ];
209        for cap in &caps {
210            let json = serde_json::to_string(cap).expect("serialize failed");
211            let back: Capability = serde_json::from_str(&json).expect("deserialize failed");
212            assert_eq!(*cap, back);
213        }
214    }
215
216    // ════════════════════════════════════════════
217    // PROPERTY TESTS — capability matching invariants
218    // ════════════════════════════════════════════
219
220    mod prop {
221        use super::*;
222        use proptest::prelude::*;
223
224        proptest! {
225            /// INV-003: Empty grants deny everything.
226            #[test]
227            fn prop_empty_grants_deny_all(
228                depth in 1u32..10,
229            ) {
230                let required = Capability::Spawn { max_depth: depth };
231                prop_assert!(
232                    !capability_matches(&[], &required),
233                    "empty grants must deny all capabilities"
234                );
235            }
236
237            /// A capability always matches itself.
238            #[test]
239            fn prop_self_match(depth in 1u32..10) {
240                let cap = Capability::Spawn { max_depth: depth };
241                prop_assert!(
242                    capability_matches(&[cap.clone()], &cap),
243                    "capability must match itself"
244                );
245            }
246
247            /// Network wildcard matches any host.
248            #[test]
249            fn prop_network_wildcard_matches_all(
250                host in "[a-z]{3,10}\\.[a-z]{2,4}",
251            ) {
252                let granted = Capability::Network {
253                    allowed_hosts: vec!["*".into()],
254                };
255                let required = Capability::Network {
256                    allowed_hosts: vec![host],
257                };
258                prop_assert!(
259                    capability_matches(&[granted], &required),
260                    "wildcard must match any host"
261                );
262            }
263
264            /// Shell wildcard matches any command.
265            #[test]
266            fn prop_shell_wildcard_matches_all(
267                cmd in "[a-z]{2,10}",
268            ) {
269                let granted = Capability::Shell {
270                    allowed_commands: vec!["*".into()],
271                };
272                let required = Capability::Shell {
273                    allowed_commands: vec![cmd],
274                };
275                prop_assert!(
276                    capability_matches(&[granted], &required),
277                    "wildcard must match any command"
278                );
279            }
280
281            /// Spawn depth: granted max_depth must be >= required.
282            #[test]
283            fn prop_spawn_depth_requires_sufficient_grant(
284                granted_depth in 1u32..20,
285                required_depth in 1u32..20,
286            ) {
287                let granted = Capability::Spawn { max_depth: granted_depth };
288                let required = Capability::Spawn { max_depth: required_depth };
289                let result = capability_matches(&[granted], &required);
290
291                if granted_depth >= required_depth {
292                    prop_assert!(result, "depth {granted_depth} >= {required_depth} must match");
293                } else {
294                    prop_assert!(!result, "depth {granted_depth} < {required_depth} must deny");
295                }
296            }
297
298            /// Proof obligation: capability_matches is pure (idempotent).
299            #[test]
300            fn prop_capability_match_idempotent(depth in 1u32..10) {
301                let granted = vec![Capability::Spawn { max_depth: depth }];
302                let required = Capability::Spawn { max_depth: depth };
303                let r1 = capability_matches(&granted, &required);
304                let r2 = capability_matches(&granted, &required);
305                prop_assert_eq!(r1, r2, "capability_matches must be pure");
306            }
307        }
308    }
309}