Skip to main content

open_agent_profile/
validate.rs

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
21/// Runs schema, semantic, and security validation on a parsed OAP document.
22pub 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
92/// Loads and validates an OAP document, returning parse failures as issues.
93pub 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
156/// Returns whether a relative path lexically escapes its workspace root.
157pub 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}