1use std::fmt;
4use std::sync::Arc;
5
6use serde_json::Value;
7use thiserror::Error;
8use typesec_core::GlobPattern;
9
10use crate::tool::ToolSpec;
11
12#[derive(Clone)]
15pub(crate) struct ArgsSchema {
16 schema: Value,
17 validator: Arc<jsonschema::Validator>,
18}
19
20impl fmt::Debug for ArgsSchema {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 f.debug_struct("ArgsSchema")
23 .field("schema", &self.schema)
24 .finish_non_exhaustive()
25 }
26}
27
28#[derive(Debug, Error)]
30pub enum InteropError {
31 #[error("malformed {dialect} tool-call payload: {detail}")]
33 Malformed {
34 dialect: &'static str,
36 detail: String,
38 },
39}
40
41impl InteropError {
42 pub(crate) fn malformed(dialect: &'static str, detail: impl Into<String>) -> Self {
43 Self::Malformed {
44 dialect,
45 detail: detail.into(),
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ToolCallRequest {
53 pub call_id: Option<String>,
55 pub tool_name: String,
57 pub arguments: Value,
59}
60
61impl ToolCallRequest {
62 pub fn new(tool_name: impl Into<String>, arguments: Value) -> Self {
64 Self {
65 call_id: None,
66 tool_name: tool_name.into(),
67 arguments,
68 }
69 }
70
71 #[must_use]
73 pub fn with_call_id(mut self, call_id: impl Into<String>) -> Self {
74 self.call_id = Some(call_id.into());
75 self
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct ToolBinding {
82 pub tool_name: String,
84 pub action: String,
86 pub resource: String,
88 pub resource_arg: Option<String>,
94 pub required_args: Vec<String>,
96 arg_globs: Vec<(String, GlobPattern)>,
101 args_schema: Option<ArgsSchema>,
103}
104
105impl ToolBinding {
106 pub fn new(
108 tool_name: impl Into<String>,
109 action: impl Into<String>,
110 resource: impl Into<String>,
111 ) -> Self {
112 Self {
113 tool_name: tool_name.into(),
114 action: action.into(),
115 resource: resource.into(),
116 resource_arg: None,
117 required_args: Vec::new(),
118 arg_globs: Vec::new(),
119 args_schema: None,
120 }
121 }
122
123 #[must_use]
125 pub fn resource_from_arg(mut self, arg: impl Into<String>) -> Self {
126 self.resource_arg = Some(arg.into());
127 self
128 }
129
130 #[must_use]
132 pub fn require_args<I, S>(mut self, args: I) -> Self
133 where
134 I: IntoIterator<Item = S>,
135 S: Into<String>,
136 {
137 self.required_args.extend(args.into_iter().map(Into::into));
138 self
139 }
140
141 pub fn arg_glob(mut self, arg: impl Into<String>, pattern: &str) -> Result<Self, InteropError> {
144 let arg = arg.into();
145 let compiled =
146 GlobPattern::compile(pattern, "argument").map_err(|err| InteropError::Malformed {
147 dialect: "binding",
148 detail: err,
149 })?;
150 self.arg_globs.push((arg, compiled));
151 Ok(self)
152 }
153
154 pub fn args_schema(mut self, schema: Value) -> Result<Self, InteropError> {
158 let validator =
159 jsonschema::validator_for(&schema).map_err(|err| InteropError::Malformed {
160 dialect: "binding",
161 detail: format!("invalid args schema: {err}"),
162 })?;
163 self.args_schema = Some(ArgsSchema {
164 schema,
165 validator: Arc::new(validator),
166 });
167 Ok(self)
168 }
169
170 pub(crate) fn validate_arguments(&self, arguments: &Value) -> Result<(), String> {
173 if let Some(args_schema) = &self.args_schema
174 && let Err(err) = args_schema.validator.validate(arguments)
175 {
176 return Err(format!(
177 "tool '{}' arguments failed schema validation: {err}",
178 self.tool_name
179 ));
180 }
181 for required in &self.required_args {
182 if arguments.get(required).is_none() {
183 return Err(format!(
184 "tool '{}' requires argument '{required}'",
185 self.tool_name
186 ));
187 }
188 }
189 for (arg, glob) in &self.arg_globs {
190 let Some(value) = arguments.get(arg).and_then(Value::as_str) else {
191 return Err(format!(
192 "tool '{}' requires string argument '{arg}' matching its declared pattern",
193 self.tool_name
194 ));
195 };
196 if !glob.matches(value) {
197 return Err(format!(
198 "tool '{}' argument '{arg}' value '{value}' does not match the allowed pattern",
199 self.tool_name
200 ));
201 }
202 }
203 Ok(())
204 }
205
206 pub fn from_spec(spec: &ToolSpec) -> Self {
209 Self::new(&spec.name, spec.required_permission, &spec.resource_id)
210 }
211
212 pub(crate) fn resolve_resource(&self, arguments: &Value) -> Result<String, String> {
215 match &self.resource_arg {
216 None => Ok(self.resource.clone()),
217 Some(arg) => arguments
218 .get(arg)
219 .and_then(Value::as_str)
220 .map(str::to_owned)
221 .ok_or_else(|| {
222 format!(
223 "tool '{}' requires string argument '{arg}' to name the resource",
224 self.tool_name
225 )
226 }),
227 }
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum ToolCallVerdict {
234 Allow,
236 Deny {
239 reason: String,
241 },
242 Delegate {
245 reason: String,
247 },
248}
249
250impl ToolCallVerdict {
251 pub fn is_allowed(&self) -> bool {
253 matches!(self, Self::Allow)
254 }
255
256 pub fn reason(&self) -> Option<&str> {
258 match self {
259 Self::Allow => None,
260 Self::Deny { reason } | Self::Delegate { reason } => Some(reason),
261 }
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct GuardedToolCall {
268 pub request: ToolCallRequest,
270 pub action: Option<String>,
272 pub resource: Option<String>,
275 pub verdict: ToolCallVerdict,
277}
278
279impl GuardedToolCall {
280 pub fn denial_message(&self) -> Option<String> {
283 match &self.verdict {
284 ToolCallVerdict::Allow => None,
285 ToolCallVerdict::Deny { reason } => Some(format!(
286 "Tool call '{}' was denied by security policy: {reason}",
287 self.request.tool_name
288 )),
289 ToolCallVerdict::Delegate { reason } => Some(format!(
290 "Tool call '{}' was not authorized (no policy engine decided): {reason}",
291 self.request.tool_name
292 )),
293 }
294 }
295}