1use std::{
2 collections::BTreeSet,
3 path::{Component, Path, PathBuf},
4};
5
6use regex::Regex;
7use serde_json::Value;
8
9use crate::{Digests, Document, Issue, ValidationReport, load, object, profile_digests, strings};
10
11const PROFILE_SCHEMA: &str = include_str!("../schema/agent-profile.schema.json");
12const DELTA_SCHEMA: &str = include_str!("../schema/agent-state-delta.schema.json");
13
14fn issue(target: &mut Vec<Issue>, pointer: impl Into<String>, message: impl Into<String>) {
15 target.push(Issue {
16 pointer: pointer.into(),
17 message: message.into(),
18 });
19}
20
21pub fn validate(document: &Document, filename: Option<&Path>) -> ValidationReport {
23 let mut errors = vec![];
24 let mut warnings = vec![];
25 let version = document
26 .get("oap")
27 .and_then(Value::as_str)
28 .unwrap_or_default();
29 let pieces: Vec<&str> = version.split('.').collect();
30 if pieces.len() != 2 || pieces.iter().any(|part| part.parse::<u64>().is_err()) {
31 issue(
32 &mut errors,
33 "/oap",
34 "missing or malformed spec version string",
35 );
36 } else if pieces != ["1", "0"] {
37 issue(
38 &mut errors,
39 "/oap",
40 format!("unsupported OAP version {version}; unsupported versions fail closed"),
41 );
42 }
43 let kind = document
44 .get("kind")
45 .and_then(Value::as_str)
46 .unwrap_or_default();
47 if !matches!(kind, "AgentProfile" | "AgentStateDelta") {
48 issue(
49 &mut errors,
50 "/kind",
51 format!("{kind:?} is not a known 1.x kind"),
52 );
53 }
54 let schema_text = if kind == "AgentStateDelta" {
55 DELTA_SCHEMA
56 } else {
57 PROFILE_SCHEMA
58 };
59 if let Ok(schema) = serde_json::from_str(schema_text) {
60 if let Ok(validator) = jsonschema::validator_for(&schema) {
61 let instance = Value::Object(document.clone());
62 for error in validator.iter_errors(&instance) {
63 issue(
64 &mut errors,
65 error.instance_path().to_string(),
66 error.to_string(),
67 );
68 }
69 }
70 }
71 check_literal_secrets(Value::Object(document.clone()), "", &mut errors);
72 if kind == "AgentProfile" {
73 check_profile(document, filename, &mut errors, &mut warnings);
74 } else if kind == "AgentStateDelta" {
75 check_delta(document, &mut errors, &mut warnings);
76 }
77 let digests: Option<Digests> = if kind == "AgentProfile" && errors.is_empty() {
78 profile_digests(document).ok()
79 } else {
80 None
81 };
82 ValidationReport {
83 kind: kind.into(),
84 document: Some(document.clone()),
85 ok: errors.is_empty(),
86 errors,
87 warnings,
88 digests,
89 }
90}
91
92pub fn validate_path(path: impl AsRef<Path>) -> ValidationReport {
94 let path = path.as_ref();
95 match load(path) {
96 Ok(document) => validate(&document, Some(path)),
97 Err(error) => ValidationReport {
98 kind: String::new(),
99 document: None,
100 errors: vec![Issue {
101 pointer: String::new(),
102 message: error.to_string(),
103 }],
104 warnings: vec![],
105 digests: None,
106 ok: false,
107 },
108 }
109}
110
111fn walk_strings(value: &Value, pointer: &str, visit: &mut impl FnMut(&str, &str)) {
112 match value {
113 Value::Object(map) => {
114 for (key, child) in map {
115 walk_strings(
116 child,
117 &format!("{pointer}/{}", key.replace('~', "~0").replace('/', "~1")),
118 visit,
119 );
120 }
121 }
122 Value::Array(items) => {
123 for (index, child) in items.iter().enumerate() {
124 walk_strings(child, &format!("{pointer}/{index}"), visit);
125 }
126 }
127 Value::String(text) => visit(text, pointer),
128 _ => {}
129 }
130}
131
132fn check_literal_secrets(value: Value, pointer: &str, errors: &mut Vec<Issue>) {
133 let patterns = [
134 (r"AKIA[0-9A-Z]{16}", "AWS access key"),
135 (r"gh[pousr]_[A-Za-z0-9_]{20,}", "GitHub token"),
136 (r"sk-[A-Za-z0-9]{20,}", "API key"),
137 (r"-----BEGIN [A-Z ]*PRIVATE KEY-----", "private key"),
138 ];
139 let compiled: Vec<_> = patterns
140 .into_iter()
141 .map(|(pattern, label)| (Regex::new(pattern).unwrap(), label))
142 .collect();
143 walk_strings(&value, pointer, &mut |text, location| {
144 for (pattern, label) in &compiled {
145 if pattern.is_match(text) {
146 issue(
147 errors,
148 location,
149 format!("looks like a literal {label}; use a ${{VARIABLE}} reference"),
150 );
151 }
152 }
153 });
154}
155
156pub fn escapes_workspace(path: &str) -> bool {
158 let candidate = PathBuf::from(path);
159 if candidate.is_absolute() {
160 return true;
161 }
162 let mut depth = 0_i64;
163 for component in candidate.components() {
164 match component {
165 Component::ParentDir => {
166 depth -= 1;
167 if depth < 0 {
168 return true;
169 }
170 }
171 Component::Normal(_) => depth += 1,
172 _ => {}
173 }
174 }
175 false
176}
177
178fn check_profile(
179 document: &Document,
180 filename: Option<&Path>,
181 errors: &mut Vec<Issue>,
182 warnings: &mut Vec<Issue>,
183) {
184 let spec = object(document.get("spec"));
185 let context = object(spec.get("context"));
186 let env = Regex::new(r"^\$\{[A-Z][A-Z0-9_]{0,63}\}$").unwrap();
187 let header = Regex::new(r"^(Bearer )?\$\{[A-Z][A-Z0-9_]{0,63}\}$").unwrap();
188 for (index, raw) in object(spec.get("tools"))
189 .get("mcp_servers")
190 .and_then(Value::as_array)
191 .into_iter()
192 .flatten()
193 .enumerate()
194 {
195 let server = object(Some(raw));
196 for (key, value) in object(server.get("env")) {
197 let text = value.as_str().unwrap_or_default();
198 if !env.is_match(text) || text != format!("${{{key}}}") {
199 issue(
200 errors,
201 format!("/spec/tools/mcp_servers/{index}/env/{key}"),
202 "must be a same-name ${VARIABLE} reference, not a literal",
203 );
204 }
205 }
206 for (key, value) in object(server.get("headers")) {
207 if !value.as_str().is_some_and(|text| header.is_match(text)) {
208 issue(
209 errors,
210 format!("/spec/tools/mcp_servers/{index}/headers/{key}"),
211 "must be '${VARIABLE}' or 'Bearer ${VARIABLE}'",
212 );
213 }
214 }
215 }
216 for (index, raw) in context
217 .get("files")
218 .and_then(Value::as_array)
219 .into_iter()
220 .flatten()
221 .enumerate()
222 {
223 if let Some(path) = object(Some(raw)).get("path").and_then(Value::as_str) {
224 if escapes_workspace(path) {
225 issue(
226 errors,
227 format!("/spec/context/files/{index}/path"),
228 format!("{path:?} resolves outside the workspace"),
229 );
230 }
231 }
232 }
233 if let Some(directory) = context.get("working_directory").and_then(Value::as_str) {
234 if escapes_workspace(directory) {
235 issue(
236 errors,
237 "/spec/context/working_directory",
238 format!("{directory:?} resolves outside the workspace"),
239 );
240 }
241 }
242 let variables: BTreeSet<String> = object(context.get("variables")).keys().cloned().collect();
243 let variable = Regex::new(r"\$\{\{\s*vars\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}").unwrap();
244 walk_strings(
245 &Value::Object(document.clone()),
246 "",
247 &mut |text, pointer| {
248 for capture in variable.captures_iter(text) {
249 if !variables.contains(&capture[1]) {
250 issue(
251 errors,
252 pointer,
253 format!("references undefined variable {:?}", &capture[1]),
254 );
255 }
256 if pointer.starts_with("/state") {
257 issue(
258 warnings,
259 pointer,
260 "contains a ${{ vars.* }} template; substitution never runs inside state",
261 );
262 }
263 }
264 },
265 );
266 let state = object(document.get("state"));
267 for collection in ["facts", "preferences", "open_threads", "glossary"] {
268 let mut ids = BTreeSet::new();
269 for (index, raw) in state
270 .get(collection)
271 .and_then(Value::as_array)
272 .into_iter()
273 .flatten()
274 .enumerate()
275 {
276 if let Some(id) = object(Some(raw)).get("id").and_then(Value::as_str) {
277 if !ids.insert(id) {
278 issue(
279 errors,
280 format!("/state/{collection}/{index}/id"),
281 format!("duplicate id {id:?}"),
282 );
283 }
284 }
285 }
286 }
287 let metadata = object(document.get("metadata"));
288 if metadata.contains_key("trust") {
289 issue(
290 warnings,
291 "/metadata/trust",
292 "trust in the file must be discarded and recomputed from the discovery root",
293 );
294 }
295 if let (Some(path), Some(name)) = (filename, metadata.get("name").and_then(Value::as_str)) {
296 let base = path
297 .file_name()
298 .and_then(|name| name.to_str())
299 .unwrap_or_default()
300 .split('.')
301 .next()
302 .unwrap_or_default();
303 if base != name {
304 issue(
305 warnings,
306 "/metadata/name",
307 format!("{name:?} does not match file name {base:?}; metadata.name wins"),
308 );
309 }
310 }
311 let history = document
312 .get("history")
313 .and_then(Value::as_array)
314 .cloned()
315 .unwrap_or_default();
316 let mut previous = 0;
317 for entry in &history {
318 let revision = object(Some(entry))
319 .get("revision")
320 .and_then(Value::as_u64)
321 .unwrap_or(0);
322 if revision < previous {
323 issue(
324 errors,
325 "/history",
326 "entries must be ordered oldest first by revision",
327 );
328 break;
329 }
330 previous = revision;
331 }
332 if previous
333 > metadata
334 .get("revision")
335 .and_then(Value::as_u64)
336 .unwrap_or(0)
337 {
338 issue(
339 errors,
340 "/history",
341 "newest history revision exceeds metadata.revision",
342 );
343 }
344 let tools = object(spec.get("tools"));
345 let policy = tools
346 .get("policy")
347 .and_then(Value::as_str)
348 .unwrap_or_default();
349 if policy == "inherit"
350 && (!strings(tools.get("allow")).is_empty() || !strings(tools.get("deny")).is_empty())
351 {
352 issue(
353 warnings,
354 "/spec/tools",
355 "policy is 'inherit', so allow and deny are ignored",
356 );
357 }
358 if policy == "allowlist" && strings(tools.get("allow")).is_empty() {
359 issue(
360 warnings,
361 "/spec/tools",
362 "allowlist has an empty allow list, so the agent gets no tools",
363 );
364 }
365}
366
367fn check_delta(document: &Document, errors: &mut Vec<Issue>, warnings: &mut Vec<Issue>) {
368 for (index, raw) in document
369 .get("operations")
370 .and_then(Value::as_array)
371 .into_iter()
372 .flatten()
373 .enumerate()
374 {
375 let path = object(Some(raw))
376 .get("path")
377 .and_then(Value::as_str)
378 .unwrap_or_default();
379 if path != "/state" && !path.starts_with("/state/") {
380 issue(
381 errors,
382 format!("/operations/{index}/path"),
383 "operation is outside /state; contract changes belong in proposals",
384 );
385 }
386 }
387 for (index, raw) in document
388 .get("proposals")
389 .and_then(Value::as_array)
390 .into_iter()
391 .flatten()
392 .enumerate()
393 {
394 let proposal = object(Some(raw));
395 let path = proposal
396 .get("path")
397 .and_then(Value::as_str)
398 .unwrap_or_default();
399 if [
400 "/spec/tools",
401 "/spec/permissions",
402 "/spec/memory",
403 "/spec/runtime/subagents",
404 ]
405 .iter()
406 .any(|prefix| path.starts_with(prefix))
407 && proposal.get("risk").and_then(Value::as_str) != Some("high")
408 {
409 issue(
410 warnings,
411 format!("/proposals/{index}"),
412 format!("{path} must be treated as high risk regardless of its declared risk"),
413 );
414 }
415 }
416}