Skip to main content

typesec_agent/interop/
call.rs

1//! Normalized tool-call types shared by every framework dialect.
2
3use 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/// A compiled JSON Schema for tool arguments, kept alongside its source so
13/// bindings stay `Debug`/`Clone` (the validator itself is neither).
14#[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/// A framework payload could not be interpreted as tool calls.
29#[derive(Debug, Error)]
30pub enum InteropError {
31    /// The payload did not match the dialect's expected wire shape.
32    #[error("malformed {dialect} tool-call payload: {detail}")]
33    Malformed {
34        /// Which dialect codec rejected the payload.
35        dialect: &'static str,
36        /// What was wrong with it.
37        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/// One tool invocation requested by a model, normalized across frameworks.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ToolCallRequest {
53    /// Framework-assigned call id (`tool_call_id`, `tool_use_id`, …), if any.
54    pub call_id: Option<String>,
55    /// Tool name as the model addressed it.
56    pub tool_name: String,
57    /// Parsed JSON arguments (an empty object when the model sent none).
58    pub arguments: Value,
59}
60
61impl ToolCallRequest {
62    /// Create a normalized tool call.
63    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    /// Attach the framework-assigned call id.
72    #[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/// Declares how one tool maps onto the Typesec `(action, resource)` plane.
80#[derive(Debug, Clone)]
81pub struct ToolBinding {
82    /// Tool name as exposed to the model.
83    pub tool_name: String,
84    /// Typesec action (permission name) required to run the tool.
85    pub action: String,
86    /// Resource the action applies to when no argument supplies one.
87    pub resource: String,
88    /// Name of a string tool argument that carries the resource id.
89    ///
90    /// When set, the resource is taken from the call's arguments and the call
91    /// is **denied** if the argument is missing or not a string — a binding
92    /// that promises per-argument resources must not silently widen.
93    pub resource_arg: Option<String>,
94    /// Arguments that must be present on every call (any JSON type).
95    pub required_args: Vec<String>,
96    /// Per-argument glob constraints: the named argument must be present, be
97    /// a string, and match the pattern — otherwise the call is denied. A
98    /// constrained argument is implicitly required (fail closed: what is
99    /// absent cannot be verified).
100    arg_globs: Vec<(String, GlobPattern)>,
101    /// Full JSON-Schema validation of the arguments object, when declared.
102    args_schema: Option<ArgsSchema>,
103}
104
105impl ToolBinding {
106    /// Bind a tool to a fixed action and resource.
107    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    /// Take the resource id from the named string argument of each call.
124    #[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    /// Require the named arguments to be present on every call.
131    #[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    /// Constrain the named string argument to a glob pattern (compiled once,
142    /// here). The argument becomes required. Fails on an invalid pattern.
143    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    /// Validate the whole arguments object against a JSON Schema (compiled
155    /// once, here). Malformed or out-of-range arguments are denied before any
156    /// policy evaluation. Fails on an invalid schema.
157    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    /// Check the args schema, required-argument presence, and per-argument
171    /// glob constraints, returning a denial reason on the first violation.
172    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    /// Derive a binding from a typed [`ToolSpec`], reusing its declared
207    /// permission and resource id.
208    pub fn from_spec(spec: &ToolSpec) -> Self {
209        Self::new(&spec.name, spec.required_permission, &spec.resource_id)
210    }
211
212    /// Resolve the effective resource for one call, failing closed when a
213    /// promised resource argument is absent.
214    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/// The guard's verdict on one tool call.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum ToolCallVerdict {
234    /// The policy engine allowed the call.
235    Allow,
236    /// The call is denied (unbound tool, missing resource argument, or an
237    /// explicit policy deny).
238    Deny {
239        /// Why the call was denied.
240        reason: String,
241    },
242    /// No engine could decide; treat as not-allowed unless a fallback engine
243    /// resolves it.
244    Delegate {
245        /// Why the engine delegated.
246        reason: String,
247    },
248}
249
250impl ToolCallVerdict {
251    /// `true` only for an explicit allow — delegation is *not* permission.
252    pub fn is_allowed(&self) -> bool {
253        matches!(self, Self::Allow)
254    }
255
256    /// The deny/delegate rationale, if any.
257    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/// A tool call together with its resolved binding and verdict.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct GuardedToolCall {
268    /// The normalized call that was checked.
269    pub request: ToolCallRequest,
270    /// The Typesec action the call was checked as (absent for unbound tools).
271    pub action: Option<String>,
272    /// The resolved resource id (absent for unbound tools or failed
273    /// resource-argument resolution).
274    pub resource: Option<String>,
275    /// The verdict.
276    pub verdict: ToolCallVerdict,
277}
278
279impl GuardedToolCall {
280    /// Human-readable denial text for feeding back to the model, or `None`
281    /// when the call is allowed.
282    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}