Skip to main content

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}