1use std::collections::HashSet;
23
24use cortexai_core::ToolSchema;
25use tracing::debug;
26
27use crate::approvals::{
28 ApprovalDecision, ApprovalHandler, ApprovalReason, ApprovalRequest,
29};
30
31pub type Scope = String;
37
38#[derive(Debug, Clone)]
48pub struct ScopePolicy {
49 granted: HashSet<Scope>,
51 pub default_deny: bool,
55}
56
57impl ScopePolicy {
58 pub fn new() -> Self {
60 Self {
61 granted: HashSet::new(),
62 default_deny: false,
63 }
64 }
65
66 pub fn grant(mut self, scope: impl Into<Scope>) -> Self {
68 self.granted.insert(scope.into());
69 self
70 }
71
72 pub fn with_scopes(mut self, scopes: impl IntoIterator<Item = impl Into<Scope>>) -> Self {
74 self.granted = scopes.into_iter().map(Into::into).collect();
75 self
76 }
77
78 pub fn with_default_deny(mut self, value: bool) -> Self {
80 self.default_deny = value;
81 self
82 }
83
84 pub fn is_scope_granted(&self, required: &str) -> bool {
91 if self.granted.contains(required) {
92 return true;
93 }
94 if self.granted.contains("*") {
96 return true;
97 }
98 if let Some(ns) = required.split(':').next() {
100 let wildcard = format!("{}:*", ns);
101 if self.granted.contains(&wildcard) {
102 return true;
103 }
104 }
105 false
106 }
107
108 pub fn missing_scopes<'a>(&self, required: &'a [String]) -> Vec<&'a str> {
110 required
111 .iter()
112 .filter(|s| !self.is_scope_granted(s))
113 .map(String::as_str)
114 .collect()
115 }
116}
117
118impl Default for ScopePolicy {
119 fn default() -> Self {
120 Self::new()
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum ScopeCheckResult {
131 Allowed,
133 Denied { missing: Vec<String> },
135}
136
137impl ScopeCheckResult {
138 pub fn is_allowed(&self) -> bool {
139 matches!(self, Self::Allowed)
140 }
141}
142
143#[derive(Debug, Clone)]
153pub struct ScopeGuard {
154 policy: ScopePolicy,
155}
156
157impl ScopeGuard {
158 pub fn new(policy: ScopePolicy) -> Self {
159 Self { policy }
160 }
161
162 pub fn check(&self, tool_schema: &ToolSchema) -> ScopeCheckResult {
169 if tool_schema.required_scopes.is_empty() {
170 return ScopeCheckResult::Allowed;
171 }
172
173 let missing = self.policy.missing_scopes(&tool_schema.required_scopes);
174 if missing.is_empty() {
175 ScopeCheckResult::Allowed
176 } else {
177 debug!(
178 "Scope check failed for tool '{}': missing {:?}",
179 tool_schema.name, missing
180 );
181 ScopeCheckResult::Denied {
182 missing: missing.into_iter().map(str::to_owned).collect(),
183 }
184 }
185 }
186
187 pub async fn check_with_escalation<H: ApprovalHandler>(
193 &self,
194 tool_schema: &ToolSchema,
195 tool_call: &cortexai_core::ToolCall,
196 handler: &H,
197 ) -> Result<bool, crate::approvals::ApprovalError> {
198 match self.check(tool_schema) {
199 ScopeCheckResult::Allowed => Ok(true),
200 ScopeCheckResult::Denied { missing } => {
201 let reason = format!("Missing required scopes: {}", missing.join(", "));
202 debug!("Escalating scope denial for '{}': {}", tool_call.name, reason);
203
204 let request = ApprovalRequest {
205 tool_call: tool_call.clone(),
206 tool_schema: Some(tool_schema.clone()),
207 reason: ApprovalReason::Custom(reason),
208 timestamp: chrono::Utc::now(),
209 context: None,
210 };
211
212 match handler.request_approval(request).await? {
213 ApprovalDecision::Approved | ApprovalDecision::Modify { .. } => Ok(true),
214 ApprovalDecision::Denied { .. } | ApprovalDecision::Skip => Ok(false),
215 }
216 }
217 }
218 }
219}
220
221#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::approvals::TestApprovalHandler;
229 use cortexai_core::{ToolCall, ToolSchema};
230 use serde_json::json;
231 use std::collections::HashMap;
232
233 fn make_tool_schema(name: &str, scopes: Vec<&str>) -> ToolSchema {
234 ToolSchema {
235 name: name.to_string(),
236 description: "test tool".to_string(),
237 parameters: json!({}),
238 dangerous: false,
239 metadata: HashMap::new(),
240 required_scopes: scopes.into_iter().map(str::to_owned).collect(),
241 }
242 }
243
244 fn make_tool_call(name: &str) -> ToolCall {
245 ToolCall {
246 id: "call-1".to_string(),
247 name: name.to_string(),
248 arguments: json!({}),
249 }
250 }
251
252 #[test]
257 fn test_exact_scope_granted() {
258 let policy = ScopePolicy::new().grant("fs:read");
259 assert!(policy.is_scope_granted("fs:read"));
260 }
261
262 #[test]
263 fn test_exact_scope_not_granted() {
264 let policy = ScopePolicy::new().grant("fs:read");
265 assert!(!policy.is_scope_granted("fs:write"));
266 }
267
268 #[test]
269 fn test_namespace_wildcard_grants_subscopes() {
270 let policy = ScopePolicy::new().grant("fs:*");
271 assert!(policy.is_scope_granted("fs:read"));
272 assert!(policy.is_scope_granted("fs:write"));
273 assert!(policy.is_scope_granted("fs:delete"));
274 }
275
276 #[test]
277 fn test_namespace_wildcard_does_not_grant_other_namespaces() {
278 let policy = ScopePolicy::new().grant("fs:*");
279 assert!(!policy.is_scope_granted("network:external"));
280 }
281
282 #[test]
283 fn test_global_wildcard_grants_all() {
284 let policy = ScopePolicy::new().grant("*");
285 assert!(policy.is_scope_granted("fs:read"));
286 assert!(policy.is_scope_granted("network:external"));
287 assert!(policy.is_scope_granted("finance:write"));
288 }
289
290 #[test]
295 fn test_tool_without_scopes_is_always_allowed() {
296 let guard = ScopeGuard::new(ScopePolicy::new());
297 let schema = make_tool_schema("no_scopes_tool", vec![]);
298 assert_eq!(guard.check(&schema), ScopeCheckResult::Allowed);
299 }
300
301 #[test]
302 fn test_agent_with_required_scope_allowed() {
303 let policy = ScopePolicy::new().grant("fs:read");
304 let guard = ScopeGuard::new(policy);
305 let schema = make_tool_schema("read_file", vec!["fs:read"]);
306 assert_eq!(guard.check(&schema), ScopeCheckResult::Allowed);
307 }
308
309 #[test]
310 fn test_agent_without_required_scope_denied() {
311 let policy = ScopePolicy::new().grant("fs:read");
312 let guard = ScopeGuard::new(policy);
313 let schema = make_tool_schema("write_file", vec!["fs:write"]);
314 assert!(matches!(guard.check(&schema), ScopeCheckResult::Denied { .. }));
315 }
316
317 #[test]
318 fn test_denied_result_lists_missing_scopes() {
319 let policy = ScopePolicy::new().grant("fs:read");
320 let guard = ScopeGuard::new(policy);
321 let schema = make_tool_schema("write_file", vec!["fs:write"]);
322 match guard.check(&schema) {
323 ScopeCheckResult::Denied { missing } => {
324 assert_eq!(missing, vec!["fs:write"]);
325 }
326 ScopeCheckResult::Allowed => panic!("expected Denied"),
327 }
328 }
329
330 #[test]
331 fn test_wildcard_scope_grants_required_subscope() {
332 let policy = ScopePolicy::new().grant("fs:*");
333 let guard = ScopeGuard::new(policy);
334 let schema = make_tool_schema("read_file", vec!["fs:read"]);
335 assert_eq!(guard.check(&schema), ScopeCheckResult::Allowed);
336
337 let write_schema = make_tool_schema("write_file", vec!["fs:write"]);
338 assert_eq!(guard.check(&write_schema), ScopeCheckResult::Allowed);
339 }
340
341 #[test]
342 fn test_multiple_required_scopes_all_must_be_granted() {
343 let policy = ScopePolicy::new().grant("fs:read");
344 let guard = ScopeGuard::new(policy);
345 let schema = make_tool_schema("rw_tool", vec!["fs:read", "fs:write"]);
346 assert!(matches!(guard.check(&schema), ScopeCheckResult::Denied { .. }));
347 }
348
349 #[test]
350 fn test_multiple_required_scopes_all_granted() {
351 let policy = ScopePolicy::new()
352 .grant("fs:read")
353 .grant("fs:write");
354 let guard = ScopeGuard::new(policy);
355 let schema = make_tool_schema("rw_tool", vec!["fs:read", "fs:write"]);
356 assert_eq!(guard.check(&schema), ScopeCheckResult::Allowed);
357 }
358
359 #[tokio::test]
364 async fn test_scope_denial_triggers_approval_escalation() {
365 let policy = ScopePolicy::new(); let guard = ScopeGuard::new(policy);
367 let schema = make_tool_schema("write_file", vec!["fs:write"]);
368 let call = make_tool_call("write_file");
369
370 let handler = TestApprovalHandler::deny_all();
372
373 let result = guard
374 .check_with_escalation(&schema, &call, &handler)
375 .await
376 .unwrap();
377
378 assert!(!result, "should be denied");
379 assert_eq!(handler.request_count(), 1, "escalation should have been called");
380 }
381
382 #[tokio::test]
383 async fn test_scope_denial_escalation_can_be_approved() {
384 let policy = ScopePolicy::new(); let guard = ScopeGuard::new(policy);
386 let schema = make_tool_schema("write_file", vec!["fs:write"]);
387 let call = make_tool_call("write_file");
388
389 let handler = TestApprovalHandler::approve_all();
391
392 let result = guard
393 .check_with_escalation(&schema, &call, &handler)
394 .await
395 .unwrap();
396
397 assert!(result, "escalation approval should allow execution");
398 }
399
400 #[tokio::test]
401 async fn test_allowed_tool_does_not_escalate() {
402 let policy = ScopePolicy::new().grant("fs:read");
403 let guard = ScopeGuard::new(policy);
404 let schema = make_tool_schema("read_file", vec!["fs:read"]);
405 let call = make_tool_call("read_file");
406
407 let handler = TestApprovalHandler::deny_all();
408
409 let result = guard
410 .check_with_escalation(&schema, &call, &handler)
411 .await
412 .unwrap();
413
414 assert!(result, "allowed tool should proceed without escalation");
415 assert_eq!(handler.request_count(), 0, "no escalation for allowed tool");
416 }
417}