mentra/tool/authorization.rs
1use std::path::PathBuf;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::{
9 runtime::RuntimeError,
10 tool::{
11 ToolApprovalCategory, ToolCapability, ToolDurability, ToolExecutionCategory,
12 ToolSideEffectLevel,
13 },
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ToolAuthorizationOutcome {
19 Allow,
20 Prompt,
21 Deny,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ToolAuthorizationPreview {
26 pub working_directory: PathBuf,
27 pub capabilities: Vec<ToolCapability>,
28 pub side_effect_level: ToolSideEffectLevel,
29 pub durability: ToolDurability,
30 pub execution_category: ToolExecutionCategory,
31 pub approval_category: ToolApprovalCategory,
32 pub raw_input: Value,
33 pub structured_input: Value,
34}
35
36impl ToolAuthorizationPreview {
37 /// What this call was classified as, without its input.
38 ///
39 /// The classification is the part a policy reasons about, and it is the
40 /// part that survives past the authorizer — see [`ToolClassification`].
41 pub fn classification(&self) -> ToolClassification {
42 self.into()
43 }
44}
45
46/// What the runtime worked out about a tool call before anyone was asked to
47/// approve it: what it can touch, how far its effects reach, whether it can be
48/// replayed, which scheduler lane it will run in, and which approval group it
49/// falls under.
50///
51/// [`execution_category`](Self::execution_category) is the lane the scheduler
52/// will actually use, asked of the tool with this call's input and carrying
53/// the coercion a terminal tool gets -- not the category the tool's descriptor
54/// declares. The two differ: `files` declares an exclusive mutation and
55/// reports a parallel read for a batch that only reads.
56///
57/// This is [`ToolAuthorizationPreview`] with the call's input left off. The
58/// input already travels beside it wherever this type is carried, and the
59/// classification alone is what a policy matches on: "allow edits, refuse the
60/// network" is [`ToolSideEffectLevel::LocalState`] against
61/// [`ToolSideEffectLevel::External`], a distinction that no amount of reading
62/// tool names and parsing arguments recovers reliably.
63///
64/// Deliberately not [`Default`]: the two fields a policy leans on hardest
65/// zero out permissively — [`capabilities`](Self::capabilities) to empty and
66/// [`side_effect_level`](Self::side_effect_level) to
67/// [`ToolSideEffectLevel::None`] — so a classification conjured from nothing
68/// would read as a call that touches nothing, the one answer a policy must
69/// never be handed by accident. Where a classification may be absent, say so
70/// with an [`Option`].
71///
72/// # Wire format
73///
74/// The enums inside serialize in their derived form — PascalCase, externally
75/// tagged: `"ReplaySafe"`, `"External"`, `{"Custom":"network"}` — even though
76/// this type rides inside
77/// [`SessionEvent`](crate::session::SessionEvent), whose own tags and fields
78/// are snake_case. The mismatch is kept deliberately. The same five enums are
79/// shared with [`ToolAuthorizationPreview`], which has serialized this way
80/// since it shipped and is written verbatim into audit rows by the audit
81/// hook, so renaming their representation would change a released wire format
82/// and orphan recorded data — while reserializing them differently for this
83/// one field would have the same value print two ways in one API. One
84/// representation, consistent with itself, is the smaller surprise; a host
85/// parsing the event stream as JSON should expect PascalCase inside this
86/// field alone.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct ToolClassification {
89 pub capabilities: Vec<ToolCapability>,
90 pub side_effect_level: ToolSideEffectLevel,
91 pub durability: ToolDurability,
92 pub execution_category: ToolExecutionCategory,
93 pub approval_category: ToolApprovalCategory,
94}
95
96impl From<&ToolAuthorizationPreview> for ToolClassification {
97 fn from(preview: &ToolAuthorizationPreview) -> Self {
98 Self {
99 capabilities: preview.capabilities.clone(),
100 side_effect_level: preview.side_effect_level,
101 durability: preview.durability,
102 execution_category: preview.execution_category,
103 approval_category: preview.approval_category,
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct ToolAuthorizationDecision {
110 pub outcome: ToolAuthorizationOutcome,
111 pub reason: Option<String>,
112}
113
114impl ToolAuthorizationDecision {
115 pub fn allow() -> Self {
116 Self {
117 outcome: ToolAuthorizationOutcome::Allow,
118 reason: None,
119 }
120 }
121
122 pub fn prompt(reason: impl Into<String>) -> Self {
123 Self {
124 outcome: ToolAuthorizationOutcome::Prompt,
125 reason: Some(reason.into()),
126 }
127 }
128
129 pub fn deny(reason: impl Into<String>) -> Self {
130 Self {
131 outcome: ToolAuthorizationOutcome::Deny,
132 reason: Some(reason.into()),
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ToolAuthorizationRequest {
139 pub agent_id: String,
140 pub agent_name: String,
141 pub model: String,
142 pub history_len: usize,
143 pub tool_call_id: String,
144 pub tool_name: String,
145 pub preview: ToolAuthorizationPreview,
146}
147
148/// Consulted after the pre-execution hooks and the schema check, and able to
149/// stop a call — but not to rewrite it.
150///
151/// The request's [`structured_input`](ToolAuthorizationPreview::structured_input)
152/// is the input the tool will execute with: every
153/// [`PreExecutionHook`](crate::runtime::control::PreExecutionHook) has already
154/// run, any [`HookDecision::Modify`](crate::runtime::control::HookDecision::Modify)
155/// has been applied and validated against the tool's `input_schema`, and
156/// nothing rewrites the call between this answer and execution. A host that
157/// remembers a decision as a rule keyed on that input can therefore replay it
158/// against future calls and know the rule describes what ran.
159///
160/// The authorizer is not consulted for a call a hook denied, nor for one whose
161/// input — the model's or a hook's — does not fit the schema.
162///
163/// A [`Session`](crate::session::Session) consults its current authorizer before
164/// considering remembered permission answers. `Allow` and `Deny` are
165/// authoritative; only `Prompt` may be answered by a remembered rule or sent
166/// through the session's live permission flow. With no authorizer installed,
167/// the effective answer remains `Allow` and remembered rules are not a separate
168/// policy layer.
169#[async_trait]
170pub trait ToolAuthorizer: Send + Sync {
171 async fn authorize(
172 &self,
173 request: &ToolAuthorizationRequest,
174 ) -> Result<ToolAuthorizationDecision, RuntimeError>;
175
176 fn timeout(&self) -> Option<Duration> {
177 None
178 }
179}
180
181/// Forwards to the authorizer inside.
182///
183/// Lets a caller hold an authorizer it chose at runtime — one of several, or
184/// none — and still hand it to anything taking `impl ToolAuthorizer`, without
185/// each caller writing this impl itself.
186#[async_trait]
187impl<T: ToolAuthorizer + ?Sized> ToolAuthorizer for Box<T> {
188 async fn authorize(
189 &self,
190 request: &ToolAuthorizationRequest,
191 ) -> Result<ToolAuthorizationDecision, RuntimeError> {
192 (**self).authorize(request).await
193 }
194
195 fn timeout(&self) -> Option<Duration> {
196 (**self).timeout()
197 }
198}
199
200/// Forwards to the authorizer inside, for a shared one.
201#[async_trait]
202impl<T: ToolAuthorizer + ?Sized> ToolAuthorizer for std::sync::Arc<T> {
203 async fn authorize(
204 &self,
205 request: &ToolAuthorizationRequest,
206 ) -> Result<ToolAuthorizationDecision, RuntimeError> {
207 (**self).authorize(request).await
208 }
209
210 fn timeout(&self) -> Option<Duration> {
211 (**self).timeout()
212 }
213}