Skip to main content

soma_provider_core/
validation.rs

1use std::collections::BTreeSet;
2
3use jsonschema::Validator;
4use serde_json::Value;
5
6use crate::{HostCapabilities, ProviderManifest};
7
8const SCHEMA: &str = include_str!("../provider-manifest.schema.json");
9
10#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
11#[error("{code}: {message}")]
12pub struct ProviderValidationError {
13    code: &'static str,
14    message: String,
15}
16
17impl ProviderValidationError {
18    pub fn new(code: &'static str, message: impl Into<String>) -> Self {
19        Self {
20            code,
21            message: message.into(),
22        }
23    }
24
25    pub fn code(&self) -> &'static str {
26        self.code
27    }
28
29    pub fn message(&self) -> &str {
30        &self.message
31    }
32}
33
34pub fn validate_provider_manifest_value(
35    value: &Value,
36) -> Result<ProviderManifest, ProviderValidationError> {
37    validate_manifest_schema(value)?;
38    let manifest: ProviderManifest = serde_json::from_value(value.clone()).map_err(|error| {
39        ProviderValidationError::new("manifest_deserialize_failed", error.to_string())
40    })?;
41    validate_provider_manifest(&manifest)?;
42    Ok(manifest)
43}
44
45pub fn validate_manifest_schema(value: &Value) -> Result<(), ProviderValidationError> {
46    let schema: Value = serde_json::from_str(SCHEMA)
47        .map_err(|error| ProviderValidationError::new("schema_parse_failed", error.to_string()))?;
48    let compiled: Validator = jsonschema::options().build(&schema).map_err(|error| {
49        ProviderValidationError::new("schema_compile_failed", error.to_string())
50    })?;
51    let details = compiled
52        .iter_errors(value)
53        .map(|error| format!("{}: {}", error.instance_path(), error))
54        .collect::<Vec<_>>();
55    if !details.is_empty() {
56        return Err(ProviderValidationError::new(
57            "json_schema_failed",
58            details.join("; "),
59        ));
60    }
61    Ok(())
62}
63
64pub fn validate_provider_manifest(
65    manifest: &ProviderManifest,
66) -> Result<(), ProviderValidationError> {
67    let value = serde_json::to_value(manifest).map_err(|error| {
68        ProviderValidationError::new("manifest_serialize_failed", error.to_string())
69    })?;
70    let mut compatibility_value = value;
71    normalize_typed_legacy_nulls(&mut compatibility_value);
72    validate_manifest_schema(&compatibility_value)?;
73
74    let mut tool_names = BTreeSet::new();
75    let mut rest_routes = BTreeSet::new();
76    let mut cli_commands = BTreeSet::new();
77    let mut primitive_names = BTreeSet::new();
78
79    for tool in &manifest.tools {
80        reject_instruction_injection("tool.description", &tool.description)?;
81        if !tool_names.insert(tool.name.as_str()) {
82            return Err(ProviderValidationError::new(
83                "duplicate_tool_name",
84                format!("duplicate tool name `{}`", tool.name),
85            ));
86        }
87        if !matches!(
88            tool.input_schema.get("type").and_then(Value::as_str),
89            Some("object")
90        ) {
91            return Err(ProviderValidationError::new(
92                "missing_input_schema",
93                format!("tool `{}` must declare an object input_schema", tool.name),
94            ));
95        }
96
97        if let Some(rest) = &tool.rest
98            && rest.enabled
99        {
100            let method = rest.method.as_deref().unwrap_or("POST");
101            let path = rest
102                .path
103                .clone()
104                .unwrap_or_else(|| format!("/v1/{}", tool.name));
105            if !rest_routes.insert((method.to_owned(), path.clone())) {
106                return Err(ProviderValidationError::new(
107                    "duplicate_rest_route",
108                    format!("duplicate REST route {method} {path}"),
109                ));
110            }
111        }
112
113        if let Some(cli) = &tool.cli
114            && cli.enabled
115        {
116            let command = cli.command.as_deref().unwrap_or(tool.name.as_str());
117            validate_cli_command(command)?;
118            if !cli_commands.insert(command.to_owned()) {
119                return Err(ProviderValidationError::new(
120                    "duplicate_cli_command",
121                    format!("duplicate CLI command `{command}`"),
122                ));
123            }
124            for alias in &cli.aliases {
125                validate_cli_command(alias)?;
126                if !cli_commands.insert(alias.clone()) {
127                    return Err(ProviderValidationError::new(
128                        "duplicate_cli_command",
129                        format!("duplicate CLI alias `{alias}`"),
130                    ));
131                }
132            }
133        }
134
135        for env in &tool.env {
136            validate_env_name(&env.name)?;
137        }
138    }
139
140    for env in &manifest.env {
141        validate_env_name(&env.name)?;
142    }
143    validate_capabilities(&manifest.capabilities)?;
144
145    for prompt in &manifest.prompts {
146        reject_instruction_injection("prompt.description", &prompt.description)?;
147        insert_primitive(&mut primitive_names, "prompt", &prompt.name)?;
148    }
149    for resource in &manifest.resources {
150        reject_instruction_injection("resource.description", &resource.description)?;
151        insert_primitive(&mut primitive_names, "resource", &resource.name)?;
152    }
153    for task in &manifest.tasks {
154        reject_instruction_injection("task.description", &task.description)?;
155        insert_primitive(&mut primitive_names, "task", &task.name)?;
156        if !matches!(
157            task.input_schema.get("type").and_then(Value::as_str),
158            Some("object")
159        ) {
160            return Err(ProviderValidationError::new(
161                "missing_input_schema",
162                format!("task `{}` must declare an object input_schema", task.name),
163            ));
164        }
165    }
166    for elicitation in &manifest.elicitation {
167        reject_instruction_injection("elicitation.description", &elicitation.description)?;
168        insert_primitive(&mut primitive_names, "elicitation", &elicitation.name)?;
169    }
170
171    if let Some(docs) = &manifest.docs {
172        if let Some(when_to_use) = &docs.when_to_use {
173            reject_instruction_injection("docs.when_to_use", when_to_use)?;
174        }
175        for entry in &docs.troubleshooting {
176            reject_instruction_injection("docs.troubleshooting", entry)?;
177        }
178    }
179
180    Ok(())
181}
182
183fn normalize_typed_legacy_nulls(value: &mut Value) {
184    let Some(root) = value.as_object_mut() else {
185        return;
186    };
187    remove_null_fields(root, &["docs", "plugin", "ui", "meta"]);
188    normalize_object_field(root, "provider", |provider| {
189        remove_null_fields(
190            provider,
191            &[
192                "title",
193                "description",
194                "homepage",
195                "source",
196                "version",
197                "enabled",
198            ],
199        );
200    });
201    normalize_array_field(root, "tools", normalize_tool);
202    normalize_array_field(root, "prompts", normalize_prompt);
203    normalize_array_field(root, "resources", normalize_resource);
204    normalize_array_field(root, "tasks", normalize_task);
205    normalize_array_field(root, "elicitation", normalize_elicitation);
206    normalize_array_field(root, "env", normalize_env_requirement);
207    normalize_object_field(root, "capabilities", normalize_capabilities);
208    normalize_object_field(root, "docs", |docs| {
209        remove_null_fields(docs, &["when_to_use"]);
210        normalize_array_field(docs, "examples", normalize_example);
211    });
212    normalize_object_field(root, "plugin", |plugin| {
213        remove_null_fields(plugin, &["mcp_registration"]);
214    });
215    normalize_object_field(root, "ui", normalize_ui);
216}
217
218fn normalize_tool(tool: &mut serde_json::Map<String, Value>) {
219    remove_null_fields(
220        tool,
221        &[
222            "title",
223            "output_schema",
224            "scope",
225            "cost",
226            "limits",
227            "mcp",
228            "rest",
229            "cli",
230            "palette",
231            "ui",
232            "meta",
233        ],
234    );
235    normalize_array_field(tool, "env", normalize_env_requirement);
236    normalize_object_field(tool, "limits", normalize_limits);
237    normalize_object_field(tool, "mcp", normalize_mcp);
238    normalize_object_field(tool, "rest", normalize_rest);
239    normalize_object_field(tool, "cli", normalize_cli);
240    normalize_object_field(tool, "palette", normalize_palette);
241    normalize_object_field(tool, "ui", normalize_ui);
242    normalize_array_field(tool, "examples", normalize_example);
243}
244
245fn normalize_prompt(prompt: &mut serde_json::Map<String, Value>) {
246    remove_null_fields(prompt, &["template", "arguments_schema", "scope", "mcp"]);
247    normalize_object_field(prompt, "mcp", normalize_mcp);
248    normalize_array_field(prompt, "examples", normalize_example);
249}
250
251fn normalize_resource(resource: &mut serde_json::Map<String, Value>) {
252    remove_null_fields(resource, &["mime_type", "scope", "mcp", "annotations"]);
253    normalize_object_field(resource, "mcp", normalize_mcp);
254}
255
256fn normalize_task(task: &mut serde_json::Map<String, Value>) {
257    remove_null_fields(task, &["output_schema", "scope", "mcp", "limits"]);
258    normalize_object_field(task, "mcp", normalize_mcp);
259    normalize_object_field(task, "limits", normalize_limits);
260}
261
262fn normalize_elicitation(elicitation: &mut serde_json::Map<String, Value>) {
263    remove_null_fields(elicitation, &["scope", "mcp"]);
264    normalize_object_field(elicitation, "mcp", normalize_mcp);
265}
266
267fn normalize_env_requirement(env: &mut serde_json::Map<String, Value>) {
268    remove_null_fields(env, &["description", "default"]);
269}
270
271fn normalize_capabilities(capabilities: &mut serde_json::Map<String, Value>) {
272    remove_null_fields(
273        capabilities,
274        &[
275            "filesystem",
276            "network",
277            "env",
278            "terminal",
279            "browser",
280            "github",
281            "broker",
282        ],
283    );
284    normalize_object_field(capabilities, "terminal", |terminal| {
285        remove_null_fields(terminal, &["working_dir"]);
286    });
287    normalize_object_field(capabilities, "broker", |broker| {
288        remove_null_fields(broker, &["state_namespace"]);
289    });
290}
291
292fn normalize_mcp(mcp: &mut serde_json::Map<String, Value>) {
293    remove_null_fields(mcp, &["title", "annotations"]);
294}
295
296fn normalize_rest(rest: &mut serde_json::Map<String, Value>) {
297    remove_null_fields(
298        rest,
299        &[
300            "method",
301            "path",
302            "summary",
303            "description",
304            "path_params",
305            "query_params",
306            "request_body_schema",
307        ],
308    );
309}
310
311fn normalize_cli(cli: &mut serde_json::Map<String, Value>) {
312    remove_null_fields(cli, &["command", "about", "long_about", "default_output"]);
313}
314
315fn normalize_palette(palette: &mut serde_json::Map<String, Value>) {
316    remove_null_fields(
317        palette,
318        &["category", "icon", "tone", "arg_mode", "result_view"],
319    );
320}
321
322fn normalize_ui(ui: &mut serde_json::Map<String, Value>) {
323    remove_null_fields(ui, &["meta"]);
324}
325
326fn normalize_limits(limits: &mut serde_json::Map<String, Value>) {
327    remove_null_fields(
328        limits,
329        &["timeout_ms", "max_response_bytes", "max_input_bytes"],
330    );
331}
332
333fn normalize_example(example: &mut serde_json::Map<String, Value>) {
334    remove_null_fields(
335        example,
336        &[
337            "title",
338            "description",
339            "input",
340            "output",
341            "cli",
342            "rest",
343            "mcp",
344        ],
345    );
346}
347
348fn remove_null_fields(object: &mut serde_json::Map<String, Value>, fields: &[&str]) {
349    for field in fields {
350        if object.get(*field).is_some_and(Value::is_null) {
351            object.remove(*field);
352        }
353    }
354}
355
356fn normalize_object_field(
357    object: &mut serde_json::Map<String, Value>,
358    field: &str,
359    normalize: impl FnOnce(&mut serde_json::Map<String, Value>),
360) {
361    if let Some(Value::Object(value)) = object.get_mut(field) {
362        normalize(value);
363    }
364}
365
366fn normalize_array_field(
367    object: &mut serde_json::Map<String, Value>,
368    field: &str,
369    normalize: fn(&mut serde_json::Map<String, Value>),
370) {
371    if let Some(Value::Array(values)) = object.get_mut(field) {
372        for value in values {
373            if let Value::Object(value) = value {
374                normalize(value);
375            }
376        }
377    }
378}
379
380fn validate_cli_command(command: &str) -> Result<(), ProviderValidationError> {
381    if command.trim().is_empty() {
382        return Err(ProviderValidationError::new(
383            "invalid_cli_command",
384            "provider command must not be empty",
385        ));
386    }
387    Ok(())
388}
389
390fn validate_env_name(name: &str) -> Result<(), ProviderValidationError> {
391    if name.trim().is_empty() {
392        return Err(ProviderValidationError::new(
393            "invalid_env_declaration",
394            "env declaration name must not be empty",
395        ));
396    }
397    Ok(())
398}
399
400fn validate_capabilities(capabilities: &HostCapabilities) -> Result<(), ProviderValidationError> {
401    if capabilities
402        .filesystem
403        .as_ref()
404        .map(|cap| cap.enabled && cap.read_roots.is_empty() && cap.write_roots.is_empty())
405        .unwrap_or(false)
406    {
407        return Err(ProviderValidationError::new(
408            "empty_capability_scope",
409            "enabled filesystem capability must declare at least one read or write root",
410        ));
411    }
412    if capabilities
413        .network
414        .as_ref()
415        .map(|cap| cap.enabled && cap.allowed_hosts.is_empty())
416        .unwrap_or(false)
417    {
418        return Err(ProviderValidationError::new(
419            "empty_capability_scope",
420            "enabled network capability must declare at least one allowed host",
421        ));
422    }
423    if capabilities
424        .env
425        .as_ref()
426        .map(|cap| cap.enabled && cap.allowed.is_empty())
427        .unwrap_or(false)
428    {
429        return Err(ProviderValidationError::new(
430            "empty_capability_scope",
431            "enabled env capability must declare at least one allowed variable",
432        ));
433    }
434    if capabilities
435        .terminal
436        .as_ref()
437        .map(|cap| cap.enabled && cap.working_dir.is_none() && cap.allowlist.is_empty())
438        .unwrap_or(false)
439    {
440        return Err(ProviderValidationError::new(
441            "empty_capability_scope",
442            "enabled terminal capability must declare a working directory or allowlist",
443        ));
444    }
445    if capabilities
446        .browser
447        .as_ref()
448        .map(|cap| cap.enabled && cap.allowed_origins.is_empty())
449        .unwrap_or(false)
450    {
451        return Err(ProviderValidationError::new(
452            "empty_capability_scope",
453            "enabled browser capability must declare at least one allowed origin",
454        ));
455    }
456    if capabilities
457        .github
458        .as_ref()
459        .map(|cap| cap.enabled && cap.allowed_repos.is_empty())
460        .unwrap_or(false)
461    {
462        return Err(ProviderValidationError::new(
463            "empty_capability_scope",
464            "enabled github capability must declare at least one allowed repo",
465        ));
466    }
467    if capabilities.broker.as_ref().is_some_and(|capability| {
468        capability.enabled
469            && capability.secret_names.is_empty()
470            && capability.state_namespace.is_none()
471            && !capability.logging
472            && !capability.metrics
473            && !capability.progress
474    }) {
475        return Err(ProviderValidationError::new(
476            "empty_capability_scope",
477            "enabled broker capability must request at least one host service",
478        ));
479    }
480    if capabilities
481        .broker
482        .as_ref()
483        .is_some_and(|capability| capability.state_write && capability.state_namespace.is_none())
484    {
485        return Err(ProviderValidationError::new(
486            "invalid_capability_scope",
487            "broker state_write requires a state_namespace",
488        ));
489    }
490    if capabilities.broker.as_ref().is_some_and(|capability| {
491        capability.secret_names.iter().any(|name| {
492            name.is_empty()
493                || !name
494                    .bytes()
495                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
496        })
497    }) {
498        return Err(ProviderValidationError::new(
499            "invalid_capability_scope",
500            "broker secret handles must use lowercase ASCII letters, digits, and hyphens",
501        ));
502    }
503    Ok(())
504}
505
506fn insert_primitive<'a>(
507    names: &mut BTreeSet<&'a str>,
508    kind: &str,
509    name: &'a str,
510) -> Result<(), ProviderValidationError> {
511    if !names.insert(name) {
512        return Err(ProviderValidationError::new(
513            "duplicate_mcp_primitive",
514            format!("duplicate MCP primitive `{name}` in {kind} catalog"),
515        ));
516    }
517    Ok(())
518}
519
520fn reject_instruction_injection(field: &str, value: &str) -> Result<(), ProviderValidationError> {
521    let lower = value.to_ascii_lowercase();
522    let blocked = [
523        "ignore previous instructions",
524        "ignore all previous",
525        "system prompt",
526        "developer message",
527        "you are now",
528    ];
529    if blocked.iter().any(|needle| lower.contains(needle)) {
530        return Err(ProviderValidationError::new(
531            "generated_doc_prompt_injection",
532            format!("{field} contains instruction-like text that generated docs must not elevate"),
533        ));
534    }
535    Ok(())
536}