act_runtime/validate.rs
1//! JSON Schema validation of what an agent sends, before it reaches a
2//! component (`ACT-SPEC.md` §6.4, `ACT-SESSIONS.md` §2.1).
3//!
4//! Two call sites: `call-tool` arguments against the tool's
5//! `parameters-schema`, and `open-session` args against
6//! `get-open-session-args-schema`. Both spell the failure the same way — an
7//! `std:invalid-args` error that never reaches the guest — so a component
8//! cannot tell a host that rejected the call from one that never received it.
9//!
10//! ## Who this protects
11//!
12//! The component. Arguments are composed by an agent, which is a language
13//! model reading a schema and guessing; the schema is the component's own
14//! statement of what it accepts. Checking it here means a component's tool
15//! body starts from arguments that match its declaration, rather than from
16//! whatever a model produced.
17//!
18//! That is why an unusable schema is not fatal (see [`Validator::compile`]):
19//! a component that ships one it cannot compile has opted out of a protection
20//! that exists for its benefit, and nothing else is harmed.
21//!
22//! ## No remote `$ref`
23//!
24//! A schema arrives from the component, so a `$ref` in it is guest-controlled
25//! text. `boon` resolves only resources registered with the compiler, and this
26//! module registers none — so an external `$ref` fails to compile rather than
27//! becoming an outbound request the component did not have to declare
28//! `wasi:http` for. That is the whole reason for the choice of validator.
29
30use crate::act::core::types::{Error as ToolError, LocalizedString};
31
32/// The error kind a rejected call carries (`ACT-CONSTANTS.md` §9).
33const INVALID_ARGS: &str = "std:invalid-args";
34
35/// A compiled schema, or the reason there is none.
36pub struct Validator {
37 schema: Option<boon::Schemas>,
38 index: boon::SchemaIndex,
39}
40
41impl Validator {
42 /// Compile a schema, or decide there is nothing to check against.
43 ///
44 /// `Ok(None)` — deliberately not an error — when the text is not a usable
45 /// schema. The alternative is refusing every call to a component whose
46 /// packaging is wrong, which converts a build-time mistake into a total
47 /// outage for something whose arguments may be perfectly fine. The
48 /// component's own SDK validates too; this layer is the second of two, and
49 /// the one that can afford to be absent.
50 ///
51 /// The reason is logged at `warn` with the tool named, once per compile,
52 /// because a component silently running unvalidated is exactly the state
53 /// an operator would want to know about.
54 pub fn compile(what: &str, schema_text: &str) -> Option<Self> {
55 let value: serde_json::Value = match serde_json::from_str(schema_text) {
56 Ok(v) => v,
57 Err(e) => {
58 tracing::warn!(%what, error = %e, "schema is not JSON; arguments will not be validated");
59 return None;
60 }
61 };
62
63 let mut schemas = boon::Schemas::new();
64 let mut compiler = boon::Compiler::new();
65 // One synthetic URL, and no resources registered beyond it: an external
66 // `$ref` has nowhere to resolve to and fails here, which is the point.
67 let url = "act:///schema";
68 if let Err(e) = compiler.add_resource(url, value) {
69 tracing::warn!(%what, error = %e, "schema could not be added; arguments will not be validated");
70 return None;
71 }
72 match compiler.compile(url, &mut schemas) {
73 Ok(index) => Some(Self {
74 schema: Some(schemas),
75 index,
76 }),
77 Err(e) => {
78 tracing::warn!(%what, error = %e, "schema did not compile; arguments will not be validated");
79 None
80 }
81 }
82 }
83
84 /// Check one value. `Err` carries the message the agent sees.
85 pub fn check(&self, value: &serde_json::Value) -> Result<(), String> {
86 let Some(schemas) = &self.schema else {
87 return Ok(());
88 };
89 schemas.validate(value, self.index).map_err(|e| {
90 // `boon`'s display walks the whole failure tree, naming each
91 // location — which is what an agent needs to fix its own call. A
92 // bare "invalid" would make the next attempt a guess.
93 e.to_string()
94 })
95 }
96}
97
98/// The error a rejected call answers with.
99///
100/// Shaped exactly like the one a guest would have produced for the same
101/// arguments, so no transport has to know which side refused.
102pub fn invalid_args(message: String) -> ToolError {
103 ToolError {
104 kind: INVALID_ARGS.to_string(),
105 message: LocalizedString::Plain(message),
106 metadata: Vec::new(),
107 }
108}
109
110/// Decode CBOR arguments into the shape a schema is written against.
111///
112/// A decode failure is itself invalid arguments: the guest could not have read
113/// them either, and saying so here beats letting it trap on the far side.
114pub fn arguments_as_json(arguments: &[u8]) -> Result<serde_json::Value, String> {
115 if arguments.is_empty() {
116 // No arguments at all is an empty object, not a missing document: a
117 // schema requiring nothing must accept it, and one requiring a
118 // property must reject it by naming that property.
119 return Ok(serde_json::Value::Object(serde_json::Map::new()));
120 }
121 act_types::cbor::cbor_to_json(arguments)
122 .map_err(|e| format!("arguments are not decodable CBOR: {e}"))
123}
124
125/// Session args arrive as named CBOR values rather than one document.
126pub fn session_args_as_json(args: &[(String, Vec<u8>)]) -> Result<serde_json::Value, String> {
127 let mut map = serde_json::Map::with_capacity(args.len());
128 for (name, value) in args {
129 let decoded = act_types::cbor::cbor_to_json(value)
130 .map_err(|e| format!("session argument '{name}' is not decodable CBOR: {e}"))?;
131 map.insert(name.clone(), decoded);
132 }
133 Ok(serde_json::Value::Object(map))
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use serde_json::json;
140
141 const SCHEMA: &str = r#"{
142 "type": "object",
143 "properties": {
144 "path": { "type": "string" },
145 "limit": { "type": "integer" }
146 },
147 "required": ["path"]
148 }"#;
149
150 #[test]
151 fn arguments_matching_the_schema_pass() {
152 let v = Validator::compile("read", SCHEMA).expect("compiles");
153 assert!(v.check(&json!({"path": "/tmp/x", "limit": 3})).is_ok());
154 }
155
156 #[test]
157 fn a_missing_required_property_is_named() {
158 let v = Validator::compile("read", SCHEMA).expect("compiles");
159 let err = v.check(&json!({"limit": 3})).expect_err("must reject");
160 assert!(
161 err.contains("path"),
162 "an agent has to learn which property to add: {err}"
163 );
164 }
165
166 #[test]
167 fn a_wrong_type_is_named() {
168 let v = Validator::compile("read", SCHEMA).expect("compiles");
169 let err = v
170 .check(&json!({"path": "/tmp/x", "limit": "three"}))
171 .expect_err("must reject");
172 assert!(err.contains("limit"), "{err}");
173 }
174
175 /// The reason this module exists: a model guessing from a schema produces
176 /// exactly this, and the component's body should never see it.
177 #[test]
178 fn no_arguments_at_all_still_fails_a_required_property() {
179 let v = Validator::compile("read", SCHEMA).expect("compiles");
180 let value = arguments_as_json(&[]).expect("empty is an empty object");
181 assert_eq!(value, json!({}));
182 assert!(v.check(&value).is_err());
183 }
184
185 #[test]
186 fn a_schema_that_is_not_json_disables_validation_rather_than_failing() {
187 // A packaging defect must not become an outage for arguments that may
188 // be perfectly fine. The component's own SDK still validates.
189 assert!(Validator::compile("broken", "not json at all").is_none());
190 }
191
192 #[test]
193 fn a_schema_that_does_not_compile_disables_validation() {
194 assert!(Validator::compile("broken", r#"{"type": 7}"#).is_none());
195 }
196
197 /// A `$ref` is guest-authored text. Resolving it over the network would be
198 /// an outbound request the component never declared `wasi:http` for — so
199 /// it must fail to compile, which disables validation for that tool and
200 /// reaches the network not at all.
201 #[test]
202 fn a_remote_ref_does_not_resolve() {
203 let hostile = r#"{"$ref": "https://evil.example.com/schema.json"}"#;
204 assert!(
205 Validator::compile("hostile", hostile).is_none(),
206 "an external $ref must not be fetched, so it must not compile"
207 );
208 }
209
210 #[test]
211 fn undecodable_arguments_are_invalid_arguments() {
212 let err = arguments_as_json(&[0xff, 0xff, 0xff]).expect_err("not CBOR");
213 assert!(err.contains("CBOR"), "{err}");
214 }
215
216 #[test]
217 fn session_args_become_one_object_keyed_by_name() {
218 let args = vec![
219 (
220 "std:bearer-token".to_string(),
221 act_types::cbor::to_cbor(&"t"),
222 ),
223 ("acme:tenant".to_string(), act_types::cbor::to_cbor(&"42")),
224 ];
225 let value = session_args_as_json(&args).expect("decodes");
226 assert_eq!(value["std:bearer-token"], "t");
227 assert_eq!(value["acme:tenant"], "42");
228 }
229
230 #[test]
231 fn the_error_is_shaped_like_a_guests_own() {
232 let e = invalid_args("nope".into());
233 assert_eq!(e.kind, INVALID_ARGS);
234 assert!(matches!(e.message, LocalizedString::Plain(ref m) if m == "nope"));
235 assert!(
236 e.metadata.is_empty(),
237 "a host-authored error carries no guest metadata"
238 );
239 }
240}