Skip to main content

cortexai_agents/
scope_guard.rs

1//! Scope-based security guards for tool execution.
2//!
3//! Tools declare required scopes (e.g. "fs:read", "network:external").
4//! Agents have a set of granted scopes. The scope guard checks that all
5//! required scopes are satisfied before allowing tool execution.
6//!
7//! # Scope Syntax
8//!
9//! Scopes follow a `namespace:action` pattern:
10//! - `"fs:read"` — read from the filesystem
11//! - `"fs:write"` — write to the filesystem
12//! - `"fs:*"` — wildcard: grants all `fs:` sub-scopes
13//! - `"network:external"` — call external network endpoints
14//! - `"finance:write"` — modify financial records
15//!
16//! # Default Behaviour
17//!
18//! A tool with no `required_scopes` is always allowed regardless of the policy.
19//! If no `ScopeGuard` is configured on the executor, all tools are allowed
20//! (backward compatible).
21
22use std::collections::HashSet;
23
24use cortexai_core::ToolSchema;
25use tracing::debug;
26
27use crate::approvals::{
28    ApprovalDecision, ApprovalHandler, ApprovalReason, ApprovalRequest,
29};
30
31// ---------------------------------------------------------------------------
32// Scope type
33// ---------------------------------------------------------------------------
34
35/// A scope string such as `"fs:read"` or `"network:*"`.
36pub type Scope = String;
37
38// ---------------------------------------------------------------------------
39// ScopePolicy
40// ---------------------------------------------------------------------------
41
42/// Per-agent scope policy: the set of scopes that an agent has been granted.
43///
44/// An empty `granted` set combined with `default_deny = true` means the agent
45/// cannot execute any tool that requires a scope.  With `default_deny = false`
46/// (the default) all tools are allowed unless they declare required scopes.
47#[derive(Debug, Clone)]
48pub struct ScopePolicy {
49    /// Scopes granted to the agent.
50    granted: HashSet<Scope>,
51    /// When `true`, a tool with no required scopes is still blocked unless
52    /// the policy explicitly grants a wildcard `"*"`.  Defaults to `false`
53    /// (no restriction on scope-free tools).
54    pub default_deny: bool,
55}
56
57impl ScopePolicy {
58    /// Create a policy with an empty granted set and `default_deny = false`.
59    pub fn new() -> Self {
60        Self {
61            granted: HashSet::new(),
62            default_deny: false,
63        }
64    }
65
66    /// Add a granted scope.
67    pub fn grant(mut self, scope: impl Into<Scope>) -> Self {
68        self.granted.insert(scope.into());
69        self
70    }
71
72    /// Replace the granted set.
73    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    /// Set `default_deny`.
79    pub fn with_default_deny(mut self, value: bool) -> Self {
80        self.default_deny = value;
81        self
82    }
83
84    /// Check whether a single required scope is satisfied by this policy.
85    ///
86    /// Matching rules (first match wins):
87    /// 1. Exact match: `"fs:read"` satisfies `"fs:read"`.
88    /// 2. Namespace wildcard: `"fs:*"` satisfies `"fs:read"`, `"fs:write"`, etc.
89    /// 3. Global wildcard: `"*"` satisfies any scope.
90    pub fn is_scope_granted(&self, required: &str) -> bool {
91        if self.granted.contains(required) {
92            return true;
93        }
94        // Global wildcard
95        if self.granted.contains("*") {
96            return true;
97        }
98        // Namespace wildcard: "ns:*" satisfies "ns:anything"
99        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    /// Return the list of required scopes that are NOT granted.
109    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// ---------------------------------------------------------------------------
125// ScopeCheckResult
126// ---------------------------------------------------------------------------
127
128/// Outcome of a scope check.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum ScopeCheckResult {
131    /// All required scopes are satisfied — proceed.
132    Allowed,
133    /// One or more required scopes are missing.
134    Denied { missing: Vec<String> },
135}
136
137impl ScopeCheckResult {
138    pub fn is_allowed(&self) -> bool {
139        matches!(self, Self::Allowed)
140    }
141}
142
143// ---------------------------------------------------------------------------
144// ScopeGuard
145// ---------------------------------------------------------------------------
146
147/// Checks tool scope requirements against an agent's `ScopePolicy`.
148///
149/// Integrate with the executor by calling [`ScopeGuard::check`] before
150/// executing a tool.  If the check fails and an `ApprovalHandler` is provided,
151/// the guard escalates to the handler so a human operator can override.
152#[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    /// Check whether `tool_schema` may be executed under the current policy.
163    ///
164    /// - If the schema has no `required_scopes`, the result is always `Allowed`
165    ///   (unless `default_deny` is set — that case is handled separately).
166    /// - If all required scopes are granted, `Allowed` is returned.
167    /// - Otherwise, `Denied` is returned with the list of missing scopes.
168    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    /// Check scope and, on denial, escalate to the provided `ApprovalHandler`.
188    ///
189    /// Returns `Ok(true)` if execution should proceed (allowed or overridden by
190    /// the approval handler), `Ok(false)` if denied, or an error if the handler
191    /// itself failed.
192    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// ---------------------------------------------------------------------------
222// Tests
223// ---------------------------------------------------------------------------
224
225#[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    // ------------------------------------------------------------------
253    // ScopePolicy unit tests
254    // ------------------------------------------------------------------
255
256    #[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    // ------------------------------------------------------------------
291    // ScopeGuard check() tests
292    // ------------------------------------------------------------------
293
294    #[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    // ------------------------------------------------------------------
360    // Escalation tests
361    // ------------------------------------------------------------------
362
363    #[tokio::test]
364    async fn test_scope_denial_triggers_approval_escalation() {
365        let policy = ScopePolicy::new(); // no scopes granted
366        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        // Handler that denies the escalation
371        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(); // no scopes granted
385        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        // Handler that approves the escalation
390        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}