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}