Skip to main content

typesec_agent/interop/
guard.rs

1//! The deny-by-default tool-call guard over any [`PolicyEngine`].
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use typesec_core::ResourceId;
7use typesec_core::policy::{PolicyEngine, PolicyResult, RequestContext, SubjectId};
8
9use super::call::{GuardedToolCall, ToolBinding, ToolCallRequest, ToolCallVerdict};
10use crate::tool::ToolRegistry;
11
12/// Evaluates normalized tool calls against a policy engine.
13///
14/// Tools without a [`ToolBinding`] are **denied by default** — a model cannot
15/// reach an action the deployment never declared. The guard is engine-agnostic:
16/// RBAC, ODRL, graph, a provider engine (WorkOS/Arcade), or any composition of
17/// them via [`FallbackEngine`](typesec_core::policy::FallbackEngine) works.
18pub struct ToolCallGuard {
19    engine: Arc<dyn PolicyEngine>,
20    bindings: HashMap<String, ToolBinding>,
21}
22
23impl ToolCallGuard {
24    /// Create a guard with no bindings (every call denied) over `engine`.
25    pub fn new(engine: Arc<dyn PolicyEngine>) -> Self {
26        Self {
27            engine,
28            bindings: HashMap::new(),
29        }
30    }
31
32    /// Add one tool binding. Re-binding a tool name replaces the previous
33    /// binding.
34    #[must_use]
35    pub fn bind(mut self, binding: ToolBinding) -> Self {
36        self.bindings.insert(binding.tool_name.clone(), binding);
37        self
38    }
39
40    /// Bind every tool registered in a typed [`ToolRegistry`], reusing each
41    /// tool's declared permission and resource id.
42    #[must_use]
43    pub fn bind_registry(mut self, registry: &ToolRegistry) -> Self {
44        for spec in registry.list_specs() {
45            let binding = ToolBinding::from_spec(&spec);
46            self.bindings.insert(binding.tool_name.clone(), binding);
47        }
48        self
49    }
50
51    /// Look up the binding for a tool name.
52    pub fn binding(&self, tool_name: &str) -> Option<&ToolBinding> {
53        self.bindings.get(tool_name)
54    }
55
56    /// Whether a tool should be *listed* to the model for `subject`.
57    ///
58    /// Listing is the cheapest control: what the model can't see, it won't
59    /// call (denial stays as the backstop). A tool is listed when it has a
60    /// binding and either its resource comes from an argument (only checkable
61    /// at call time) or the policy allows its fixed `(action, resource)`.
62    /// Unbound tools are never listed.
63    pub fn allows_listing(
64        &self,
65        subject: &SubjectId,
66        tool_name: &str,
67        ctx: &RequestContext,
68    ) -> bool {
69        let Some(binding) = self.bindings.get(tool_name) else {
70            return false;
71        };
72        if binding.resource_arg.is_some() {
73            return true;
74        }
75        matches!(
76            self.engine.check_with_context(
77                subject,
78                &binding.action,
79                &ResourceId::from(binding.resource.as_str()),
80                ctx,
81            ),
82            PolicyResult::Allow
83        )
84    }
85
86    /// Check one tool call for `subject` under `ctx`.
87    pub fn check(
88        &self,
89        subject: &SubjectId,
90        request: ToolCallRequest,
91        ctx: &RequestContext,
92    ) -> GuardedToolCall {
93        let (request, action, resource) = match self.resolve(request) {
94            Ok(bound) => bound,
95            Err(denied) => return denied,
96        };
97        let result = self.engine.check_with_context(
98            subject,
99            &action,
100            &ResourceId::from(resource.as_str()),
101            ctx,
102        );
103        self.finish(subject, request, action, resource, result)
104    }
105
106    /// Check one tool call asynchronously (for IO-bound engines).
107    pub async fn check_async(
108        &self,
109        subject: &SubjectId,
110        request: ToolCallRequest,
111        ctx: &RequestContext,
112    ) -> GuardedToolCall {
113        let (request, action, resource) = match self.resolve(request) {
114            Ok(bound) => bound,
115            Err(denied) => return denied,
116        };
117        let result = self
118            .engine
119            .check_with_context_async(subject, &action, &ResourceId::from(resource.as_str()), ctx)
120            .await;
121        self.finish(subject, request, action, resource, result)
122    }
123
124    /// Check a batch of tool calls (one model turn can request several).
125    pub fn check_all(
126        &self,
127        subject: &SubjectId,
128        requests: impl IntoIterator<Item = ToolCallRequest>,
129        ctx: &RequestContext,
130    ) -> Vec<GuardedToolCall> {
131        requests
132            .into_iter()
133            .map(|request| self.check(subject, request, ctx))
134            .collect()
135    }
136
137    /// Resolve a request to its `(action, resource)`, or finish it immediately
138    /// as a structured deny (unbound tool, missing resource argument).
139    #[allow(clippy::result_large_err)]
140    fn resolve(
141        &self,
142        request: ToolCallRequest,
143    ) -> Result<(ToolCallRequest, String, String), GuardedToolCall> {
144        let Some(binding) = self.bindings.get(&request.tool_name) else {
145            let reason = format!(
146                "tool '{}' has no typesec binding (deny by default)",
147                request.tool_name
148            );
149            return Err(GuardedToolCall {
150                request,
151                action: None,
152                resource: None,
153                verdict: ToolCallVerdict::Deny { reason },
154            });
155        };
156        if let Err(reason) = binding.validate_arguments(&request.arguments) {
157            return Err(GuardedToolCall {
158                action: Some(binding.action.clone()),
159                request,
160                resource: None,
161                verdict: ToolCallVerdict::Deny { reason },
162            });
163        }
164        match binding.resolve_resource(&request.arguments) {
165            Ok(resource) => Ok((request, binding.action.clone(), resource)),
166            Err(reason) => Err(GuardedToolCall {
167                action: Some(binding.action.clone()),
168                request,
169                resource: None,
170                verdict: ToolCallVerdict::Deny { reason },
171            }),
172        }
173    }
174
175    fn finish(
176        &self,
177        subject: &SubjectId,
178        request: ToolCallRequest,
179        action: String,
180        resource: String,
181        result: PolicyResult,
182    ) -> GuardedToolCall {
183        let verdict = match result {
184            PolicyResult::Allow => ToolCallVerdict::Allow,
185            PolicyResult::Deny(reason) => ToolCallVerdict::Deny { reason },
186            PolicyResult::Delegate(reason) => ToolCallVerdict::Delegate {
187                reason: reason.to_string(),
188            },
189            _ => ToolCallVerdict::Deny {
190                reason: "unknown policy result".to_string(),
191            },
192        };
193        tracing::info!(
194            subject = %subject,
195            tool = %request.tool_name,
196            action = %action,
197            resource = %resource,
198            allowed = verdict.is_allowed(),
199            "guarded tool call"
200        );
201        GuardedToolCall {
202            request,
203            action: Some(action),
204            resource: Some(resource),
205            verdict,
206        }
207    }
208}