Skip to main content

mixtape_core/permission/
authorizer.rs

1//! Tool call authorization.
2
3use super::grant::{hash_params, Grant};
4use super::store::{GrantStore, GrantStoreError, MemoryGrantStore};
5use serde_json::Value;
6
7/// Policy for handling tool calls without matching grants.
8///
9/// This determines what happens when a tool is called and no grant exists
10/// in the store for that tool/parameter combination.
11///
12/// # Security
13///
14/// **`AutoDeny` is the default** - tools without grants are denied immediately.
15/// This is secure by default for automated environments (scripts, CI/CD, agents).
16///
17/// Use `Interactive` only when a human is available to approve tool calls.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum ToolAuthorizationPolicy {
20    /// Deny tools without grants immediately (default, secure).
21    ///
22    /// This is the recommended policy for non-interactive environments
23    /// like scripts, CI/CD pipelines, or automated agents.
24    #[default]
25    AutoDeny,
26
27    /// Prompt the user for authorization via `PermissionRequired` events.
28    ///
29    /// Use this policy for interactive environments like REPLs or CLIs
30    /// where a human can approve or deny tool execution.
31    ///
32    /// Enable via `AgentBuilder::interactive()` or
33    /// [`ToolCallAuthorizer::interactive()`].
34    Interactive,
35}
36
37/// Authorizes tool calls against stored grants.
38///
39/// The authorizer wraps a [`GrantStore`] and provides the logic for:
40/// - Granting permissions (tool-wide or params-specific)
41/// - Checking if a tool call is authorized
42/// - Revoking permissions
43///
44/// # Example
45///
46/// ```rust
47/// use mixtape_core::permission::ToolCallAuthorizer;
48///
49/// # tokio_test::block_on(async {
50/// let auth = ToolCallAuthorizer::new();
51///
52/// // Grant permission to use a tool
53/// auth.grant_tool("echo").await.unwrap();
54///
55/// // Check if a call is authorized
56/// let params = serde_json::json!({"message": "hello"});
57/// let result = auth.check("echo", &params).await;
58/// assert!(result.is_authorized());
59/// # });
60/// ```
61pub struct ToolCallAuthorizer {
62    store: Box<dyn GrantStore>,
63    policy: ToolAuthorizationPolicy,
64}
65
66impl ToolCallAuthorizer {
67    /// Create a new authorizer with an in-memory store and default policy (AutoDeny).
68    pub fn new() -> Self {
69        Self {
70            store: Box::new(MemoryGrantStore::new()),
71            policy: ToolAuthorizationPolicy::default(),
72        }
73    }
74
75    /// Create an authorizer configured for interactive use.
76    ///
77    /// This sets the policy to [`ToolAuthorizationPolicy::Interactive`], which will
78    /// emit `PermissionRequired` events for tools without grants.
79    pub fn interactive() -> Self {
80        Self::new().with_authorization_policy(ToolAuthorizationPolicy::Interactive)
81    }
82
83    /// Create an authorizer with a custom store.
84    pub fn with_store(store: impl GrantStore + 'static) -> Self {
85        Self {
86            store: Box::new(store),
87            policy: ToolAuthorizationPolicy::default(),
88        }
89    }
90
91    /// Create an authorizer with a boxed store.
92    pub fn with_boxed_store(store: Box<dyn GrantStore>) -> Self {
93        Self {
94            store,
95            policy: ToolAuthorizationPolicy::default(),
96        }
97    }
98
99    /// Set the policy for tools without grants.
100    ///
101    /// # Example
102    ///
103    /// ```rust
104    /// use mixtape_core::permission::{ToolCallAuthorizer, ToolAuthorizationPolicy};
105    ///
106    /// // For interactive use (prompts user)
107    /// let auth = ToolCallAuthorizer::new()
108    ///     .with_authorization_policy(ToolAuthorizationPolicy::Interactive);
109    ///
110    /// // For non-interactive use (denies immediately) - this is the default
111    /// let auth = ToolCallAuthorizer::new()
112    ///     .with_authorization_policy(ToolAuthorizationPolicy::AutoDeny);
113    /// ```
114    pub fn with_authorization_policy(mut self, policy: ToolAuthorizationPolicy) -> Self {
115        self.policy = policy;
116        self
117    }
118
119    /// Get the current authorization policy.
120    pub fn policy(&self) -> ToolAuthorizationPolicy {
121        self.policy
122    }
123
124    /// Grant permission to use a tool (any parameters).
125    pub async fn grant_tool(&self, tool: &str) -> Result<(), GrantStoreError> {
126        self.store.save(Grant::tool(tool)).await
127    }
128
129    /// Grant permission for specific parameters.
130    ///
131    /// The params are hashed internally using canonical JSON.
132    pub async fn grant_params(&self, tool: &str, params: &Value) -> Result<(), GrantStoreError> {
133        let hash = hash_params(params);
134        self.store.save(Grant::exact(tool, hash)).await
135    }
136
137    /// Grant permission using a pre-computed hash.
138    pub async fn grant_params_hash(
139        &self,
140        tool: &str,
141        params_hash: &str,
142    ) -> Result<(), GrantStoreError> {
143        self.store.save(Grant::exact(tool, params_hash)).await
144    }
145
146    /// Check if a tool call is authorized.
147    ///
148    /// Returns:
149    /// - [`Authorization::Granted`] if a matching grant exists
150    /// - [`Authorization::Denied`] if no grant and policy is [`ToolAuthorizationPolicy::AutoDeny`]
151    /// - [`Authorization::PendingApproval`] if no grant and policy is [`ToolAuthorizationPolicy::Interactive`]
152    pub async fn check(&self, tool_name: &str, params: &Value) -> Authorization {
153        let params_hash = hash_params(params);
154
155        // Check for existing grant
156        match self.store.load(tool_name).await {
157            Ok(grants) => {
158                for grant in grants {
159                    if grant.matches(&params_hash) {
160                        return Authorization::Granted { grant };
161                    }
162                }
163            }
164            Err(e) => {
165                eprintln!("Warning: Failed to load grants for {}: {}", tool_name, e);
166            }
167        }
168
169        // No grant found - apply policy
170        match self.policy {
171            ToolAuthorizationPolicy::AutoDeny => Authorization::Denied {
172                reason: format!("No grant configured for tool '{}'", tool_name),
173            },
174            ToolAuthorizationPolicy::Interactive => Authorization::PendingApproval { params_hash },
175        }
176    }
177
178    /// Revoke a grant.
179    ///
180    /// Pass `None` for params_hash to revoke a tool-wide grant.
181    pub async fn revoke(
182        &self,
183        tool: &str,
184        params_hash: Option<&str>,
185    ) -> Result<bool, GrantStoreError> {
186        self.store.delete(tool, params_hash).await
187    }
188
189    /// Get all stored grants.
190    pub async fn grants(&self) -> Result<Vec<Grant>, GrantStoreError> {
191        self.store.load_all().await
192    }
193
194    /// Clear all grants.
195    pub async fn clear(&self) -> Result<(), GrantStoreError> {
196        self.store.clear().await
197    }
198}
199
200impl Default for ToolCallAuthorizer {
201    fn default() -> Self {
202        Self::new()
203    }
204}
205
206/// Result of an authorization check.
207#[derive(Debug, Clone)]
208pub enum Authorization {
209    /// The call is authorized by an existing grant.
210    Granted {
211        /// The grant that authorized this call.
212        grant: Grant,
213    },
214    /// The call is denied (no grant and policy is AutoDeny).
215    Denied {
216        /// Reason for denial.
217        reason: String,
218    },
219    /// Authorization is pending user approval (no grant and policy is Interactive).
220    PendingApproval {
221        /// Hash of the parameters (for creating exact-match grants).
222        params_hash: String,
223    },
224}
225
226impl Authorization {
227    /// Check if the call is authorized.
228    pub fn is_authorized(&self) -> bool {
229        matches!(self, Authorization::Granted { .. })
230    }
231
232    /// Check if the call is denied.
233    pub fn is_denied(&self) -> bool {
234        matches!(self, Authorization::Denied { .. })
235    }
236
237    /// Check if authorization is pending user approval.
238    pub fn is_pending(&self) -> bool {
239        matches!(self, Authorization::PendingApproval { .. })
240    }
241}
242
243/// User's response to an authorization request.
244#[derive(Debug, Clone)]
245pub enum AuthorizationResponse {
246    /// Allow this call once, don't save a grant.
247    Once,
248
249    /// Allow and save a grant.
250    Trust {
251        /// The grant to store.
252        grant: Grant,
253    },
254
255    /// Deny the call.
256    Deny {
257        /// Optional reason for denial.
258        reason: Option<String>,
259    },
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    // ===== Policy Tests =====
267
268    #[test]
269    fn test_default_policy_is_auto_deny() {
270        let auth = ToolCallAuthorizer::new();
271        assert_eq!(auth.policy(), ToolAuthorizationPolicy::AutoDeny);
272    }
273
274    #[test]
275    fn test_interactive_constructor_sets_interactive_policy() {
276        let auth = ToolCallAuthorizer::interactive();
277        assert_eq!(auth.policy(), ToolAuthorizationPolicy::Interactive);
278    }
279
280    #[test]
281    fn test_with_authorization_policy() {
282        let auth = ToolCallAuthorizer::new()
283            .with_authorization_policy(ToolAuthorizationPolicy::Interactive);
284        assert_eq!(auth.policy(), ToolAuthorizationPolicy::Interactive);
285    }
286
287    #[tokio::test]
288    async fn test_auto_deny_policy_returns_denied() {
289        let auth = ToolCallAuthorizer::new(); // Default is AutoDeny
290
291        let params = serde_json::json!({"key": "value"});
292        let result = auth.check("test", &params).await;
293
294        assert!(result.is_denied());
295        assert!(!result.is_authorized());
296        assert!(!result.is_pending());
297    }
298
299    #[tokio::test]
300    async fn test_interactive_policy_returns_pending() {
301        let auth = ToolCallAuthorizer::interactive(); // Interactive policy
302
303        let params = serde_json::json!({"key": "value"});
304        let result = auth.check("test", &params).await;
305
306        assert!(result.is_pending());
307        assert!(!result.is_authorized());
308        assert!(!result.is_denied());
309    }
310
311    #[tokio::test]
312    async fn test_grant_overrides_policy() {
313        // Even with Deny policy, a grant should authorize
314        let auth = ToolCallAuthorizer::new();
315        auth.grant_tool("test").await.unwrap();
316
317        let result = auth.check("test", &serde_json::json!({})).await;
318        assert!(result.is_authorized());
319    }
320
321    // ===== Grant Tests =====
322
323    #[tokio::test]
324    async fn test_authorizer_tool_wide_grant() {
325        let auth = ToolCallAuthorizer::new();
326        auth.grant_tool("test").await.unwrap();
327
328        // Any params should be authorized
329        let result = auth.check("test", &serde_json::json!({"a": 1})).await;
330        assert!(result.is_authorized());
331
332        let result = auth.check("test", &serde_json::json!({"b": 2})).await;
333        assert!(result.is_authorized());
334    }
335
336    #[tokio::test]
337    async fn test_authorizer_params_grant() {
338        let auth = ToolCallAuthorizer::new();
339
340        let params = serde_json::json!({"key": "value"});
341        auth.grant_params("test", &params).await.unwrap();
342
343        // Exact params should be authorized
344        let result = auth.check("test", &params).await;
345        assert!(result.is_authorized());
346
347        // Different params should be denied (default policy)
348        let other = serde_json::json!({"key": "other"});
349        let result = auth.check("test", &other).await;
350        assert!(result.is_denied());
351    }
352
353    #[tokio::test]
354    async fn test_authorizer_wrong_tool() {
355        let auth = ToolCallAuthorizer::new();
356        auth.grant_tool("tool_a").await.unwrap();
357
358        let result = auth.check("tool_b", &serde_json::json!({})).await;
359        assert!(result.is_denied());
360    }
361
362    #[tokio::test]
363    async fn test_authorizer_revoke() {
364        let auth = ToolCallAuthorizer::new();
365        auth.grant_tool("test").await.unwrap();
366
367        assert!(auth
368            .check("test", &serde_json::json!({}))
369            .await
370            .is_authorized());
371
372        auth.revoke("test", None).await.unwrap();
373
374        assert!(auth.check("test", &serde_json::json!({})).await.is_denied());
375    }
376
377    #[tokio::test]
378    async fn test_authorizer_grants() {
379        let auth = ToolCallAuthorizer::new();
380        auth.grant_tool("a").await.unwrap();
381        auth.grant_tool("b").await.unwrap();
382
383        let grants = auth.grants().await.unwrap();
384        assert_eq!(grants.len(), 2);
385    }
386
387    #[tokio::test]
388    async fn test_authorizer_clear() {
389        let auth = ToolCallAuthorizer::new();
390        auth.grant_tool("test").await.unwrap();
391
392        auth.clear().await.unwrap();
393
394        assert!(auth.grants().await.unwrap().is_empty());
395    }
396
397    // ===== Authorization Enum Tests =====
398
399    #[test]
400    fn test_authorization_methods() {
401        let granted = Authorization::Granted {
402            grant: Grant::tool("test"),
403        };
404        assert!(granted.is_authorized());
405        assert!(!granted.is_denied());
406        assert!(!granted.is_pending());
407
408        let denied = Authorization::Denied {
409            reason: "test".to_string(),
410        };
411        assert!(!denied.is_authorized());
412        assert!(denied.is_denied());
413        assert!(!denied.is_pending());
414
415        let pending = Authorization::PendingApproval {
416            params_hash: "abc".to_string(),
417        };
418        assert!(!pending.is_authorized());
419        assert!(!pending.is_denied());
420        assert!(pending.is_pending());
421    }
422}