1use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum Capability {
15 Rag,
17 Memory,
19 Shell {
21 allowed_commands: Vec<String>,
23 },
24 Browser,
26 Inference,
28 FileRead {
30 allowed_paths: Vec<String>,
32 },
33 FileWrite {
35 allowed_paths: Vec<String>,
37 },
38 Compute,
40 Network {
42 allowed_hosts: Vec<String>,
44 },
45 Mcp {
47 server: String,
49 tool: String,
51 },
52 Spawn {
54 max_depth: u32,
56 },
57}
58
59#[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 mod prop {
221 use super::*;
222 use proptest::prelude::*;
223
224 proptest! {
225 #[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 #[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 #[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 #[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 #[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 #[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}