Skip to main content

loopctl/middleware/
permission.rs

1//! Middleware that checks tool permissions before execution.
2
3use super::{ToolDispatchContext, ToolDispatchResult, ToolMiddleware, ToolPipeline};
4use crate::tool::PermissionCheck;
5use std::future::Future;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::time::Duration;
9
10/// Permission check function type.
11pub type PermissionCheckFn = Arc<dyn Fn(&ToolDispatchContext) -> PermissionCheck + Send + Sync>;
12
13/// Async resolver for [`PermissionCheck::Ask`].
14///
15/// Receives the prompt string and the tool name, returns `true` to allow
16/// the tool call or `false` to deny it. Called by [`PermissionMiddleware`]
17/// when the permission check resolves to [`PermissionCheck::Ask`].
18pub type AskResolverFn =
19    Arc<dyn Fn(&str, &str) -> Pin<Box<dyn Future<Output = bool> + Send>> + Send + Sync>;
20
21/// Middleware that checks tool permissions before execution.
22///
23/// Inspects the [`PermissionCheck`] in the dispatch context:
24///
25/// - `Allow` — passes through to the next layer.
26/// - `Deny` — short-circuits with an error result.
27/// - `Modify` — replaces `ctx.input` with the modified input, then proceeds.
28/// - `Ask` — if an [`AskResolverFn`] is configured, calls it to prompt the
29///   user; the tool proceeds on `true` or is denied on `false`. Without a
30///   resolver, `Ask` is denied (headless mode).
31///
32/// # Example
33///
34/// ```rust,ignore
35/// // Deny all by default
36/// let mw = PermissionMiddleware::deny_all();
37///
38/// // Custom logic
39/// let mw = PermissionMiddleware::with_check(|ctx| {
40///     if ctx.tool_name == "safe_read" {
41///         PermissionCheck::Allow
42///     } else {
43///         PermissionCheck::Deny { reason: "not on allowlist".into() }
44///     }
45/// });
46/// ```
47pub struct PermissionMiddleware {
48    /// When `Some`, overrides [`ToolDispatchContext::permission`].
49    check_fn: Option<PermissionCheckFn>,
50    /// When `Some`, called to resolve [`PermissionCheck::Ask`] interactively.
51    ask_resolver: Option<AskResolverFn>,
52}
53
54impl PermissionMiddleware {
55    /// Create a permission middleware that denies all calls.
56    ///
57    /// Every tool call will be short-circuited with a permission-denied
58    /// error. Useful as a safety default in restricted environments.
59    #[must_use]
60    pub fn deny_all() -> Self {
61        Self {
62            check_fn: Some(Arc::new(|_| PermissionCheck::Deny {
63                reason: "blocked by policy".into(),
64            })),
65            ask_resolver: None,
66        }
67    }
68
69    /// Create a permission middleware that allows all calls.
70    ///
71    /// No permission checks are performed — every tool call passes
72    /// through to the next layer. Equivalent to having no permission
73    /// middleware, but can be used for logging or metrics in permissive
74    /// environments.
75    #[must_use]
76    pub fn allow_all() -> Self {
77        Self {
78            check_fn: Some(Arc::new(|_| PermissionCheck::Allow)),
79            ask_resolver: None,
80        }
81    }
82
83    /// Set a custom permission check function.
84    ///
85    /// The function receives a reference to the dispatch context and
86    /// returns the appropriate [`PermissionCheck`] for that call.
87    #[must_use]
88    pub fn with_check(
89        mut self,
90        f: impl Fn(&ToolDispatchContext) -> PermissionCheck + Send + Sync + 'static,
91    ) -> Self {
92        self.check_fn = Some(Arc::new(f));
93        self
94    }
95
96    /// Create a permission middleware that reads from the context.
97    ///
98    /// The middleware reads `ctx.permission` directly, without
99    /// applying any override. Use when the permission is set by the
100    /// framework or a prior middleware.
101    #[must_use]
102    pub fn from_context() -> Self {
103        Self {
104            check_fn: None,
105            ask_resolver: None,
106        }
107    }
108
109    /// Attach an async resolver for [`PermissionCheck::Ask`].
110    ///
111    /// When the permission check returns `Ask`, the resolver is called with
112    /// the prompt and tool name. The tool call proceeds if the resolver
113    /// returns `true`, and is denied if it returns `false`.
114    ///
115    /// Without a resolver, `Ask` is denied (headless mode).
116    #[must_use]
117    pub fn with_ask_resolver(mut self, resolver: AskResolverFn) -> Self {
118        self.ask_resolver = Some(resolver);
119        self
120    }
121
122    fn resolve_permission(&self, ctx: &ToolDispatchContext) -> PermissionCheck {
123        match &self.check_fn {
124            Some(f) => f(ctx),
125            None => ctx.permission.clone(),
126        }
127    }
128}
129
130impl ToolMiddleware for PermissionMiddleware {
131    fn name(&self) -> &'static str {
132        "permission"
133    }
134
135    fn dispatch<'a>(
136        &'a self,
137        ctx: &'a mut ToolDispatchContext,
138        next: &'a ToolPipeline,
139    ) -> Pin<Box<dyn Future<Output = ToolDispatchResult> + Send + 'a>> {
140        let permission = self.resolve_permission(ctx);
141        match permission {
142            PermissionCheck::Allow => next.dispatch(ctx),
143            PermissionCheck::Modify { modified_input } => {
144                ctx.input = modified_input;
145                next.dispatch(ctx)
146            }
147            PermissionCheck::Deny { reason } => Self::deny(ctx, &reason),
148            PermissionCheck::Ask { prompt } => {
149                if let Some(resolver) = &self.ask_resolver {
150                    let resolver = Arc::clone(resolver);
151                    Box::pin(async move {
152                        let tool_name = ctx.tool_name.clone();
153                        let approved = resolver(&prompt, &tool_name);
154                        if approved.await {
155                            next.dispatch(ctx).await
156                        } else {
157                            ToolDispatchResult::err(
158                                &tool_name,
159                                format!("Permission denied by user for tool '{tool_name}'"),
160                                Duration::ZERO,
161                            )
162                        }
163                    })
164                } else {
165                    tracing::warn!(
166                        tool = %ctx.tool_name,
167                        prompt = %prompt,
168                        "permission Ask denied: no resolver configured"
169                    );
170                    Self::deny(ctx, &format!("permission required: {prompt}"))
171                }
172            }
173        }
174    }
175}
176
177impl PermissionMiddleware {
178    /// Build a denied result with tracing.
179    fn deny<'a>(
180        ctx: &'a mut ToolDispatchContext,
181        reason: &str,
182    ) -> Pin<Box<dyn Future<Output = ToolDispatchResult> + Send + 'a>> {
183        let tool_name = ctx.tool_name.clone();
184        let reason = reason.to_string();
185        tracing::warn!(
186            tool = %tool_name,
187            permission = %reason,
188            "tool call blocked by permission middleware"
189        );
190        Box::pin(std::future::ready(ToolDispatchResult::err(
191            &tool_name,
192            format!("Permission {reason} for tool '{tool_name}'"),
193            Duration::ZERO,
194        )))
195    }
196}