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        ],
282    );
283    normalize_object_field(capabilities, "terminal", |terminal| {
284        remove_null_fields(terminal, &["working_dir"]);
285    });
286}
287
288fn normalize_mcp(mcp: &mut serde_json::Map<String, Value>) {
289    remove_null_fields(mcp, &["title", "annotations"]);
290}
291
292fn normalize_rest(rest: &mut serde_json::Map<String, Value>) {
293    remove_null_fields(
294        rest,
295        &[
296            "method",
297            "path",
298            "summary",
299            "description",
300            "path_params",
301            "query_params",
302            "request_body_schema",
303        ],
304    );
305}
306
307fn normalize_cli(cli: &mut serde_json::Map<String, Value>) {
308    remove_null_fields(cli, &["command", "about", "long_about", "default_output"]);
309}
310
311fn normalize_palette(palette: &mut serde_json::Map<String, Value>) {
312    remove_null_fields(
313        palette,
314        &["category", "icon", "tone", "arg_mode", "result_view"],
315    );
316}
317
318fn normalize_ui(ui: &mut serde_json::Map<String, Value>) {
319    remove_null_fields(ui, &["meta"]);
320}
321
322fn normalize_limits(limits: &mut serde_json::Map<String, Value>) {
323    remove_null_fields(
324        limits,
325        &["timeout_ms", "max_response_bytes", "max_input_bytes"],
326    );
327}
328
329fn normalize_example(example: &mut serde_json::Map<String, Value>) {
330    remove_null_fields(
331        example,
332        &[
333            "title",
334            "description",
335            "input",
336            "output",
337            "cli",
338            "rest",
339            "mcp",
340        ],
341    );
342}
343
344fn remove_null_fields(object: &mut serde_json::Map<String, Value>, fields: &[&str]) {
345    for field in fields {
346        if object.get(*field).is_some_and(Value::is_null) {
347            object.remove(*field);
348        }
349    }
350}
351
352fn normalize_object_field(
353    object: &mut serde_json::Map<String, Value>,
354    field: &str,
355    normalize: impl FnOnce(&mut serde_json::Map<String, Value>),
356) {
357    if let Some(Value::Object(value)) = object.get_mut(field) {
358        normalize(value);
359    }
360}
361
362fn normalize_array_field(
363    object: &mut serde_json::Map<String, Value>,
364    field: &str,
365    normalize: fn(&mut serde_json::Map<String, Value>),
366) {
367    if let Some(Value::Array(values)) = object.get_mut(field) {
368        for value in values {
369            if let Value::Object(value) = value {
370                normalize(value);
371            }
372        }
373    }
374}
375
376fn validate_cli_command(command: &str) -> Result<(), ProviderValidationError> {
377    if command.trim().is_empty() {
378        return Err(ProviderValidationError::new(
379            "invalid_cli_command",
380            "provider command must not be empty",
381        ));
382    }
383    Ok(())
384}
385
386fn validate_env_name(name: &str) -> Result<(), ProviderValidationError> {
387    if name.trim().is_empty() {
388        return Err(ProviderValidationError::new(
389            "invalid_env_declaration",
390            "env declaration name must not be empty",
391        ));
392    }
393    Ok(())
394}
395
396fn validate_capabilities(capabilities: &HostCapabilities) -> Result<(), ProviderValidationError> {
397    if capabilities
398        .filesystem
399        .as_ref()
400        .map(|cap| cap.enabled && cap.read_roots.is_empty() && cap.write_roots.is_empty())
401        .unwrap_or(false)
402    {
403        return Err(ProviderValidationError::new(
404            "empty_capability_scope",
405            "enabled filesystem capability must declare at least one read or write root",
406        ));
407    }
408    if capabilities
409        .network
410        .as_ref()
411        .map(|cap| cap.enabled && cap.allowed_hosts.is_empty())
412        .unwrap_or(false)
413    {
414        return Err(ProviderValidationError::new(
415            "empty_capability_scope",
416            "enabled network capability must declare at least one allowed host",
417        ));
418    }
419    if capabilities
420        .env
421        .as_ref()
422        .map(|cap| cap.enabled && cap.allowed.is_empty())
423        .unwrap_or(false)
424    {
425        return Err(ProviderValidationError::new(
426            "empty_capability_scope",
427            "enabled env capability must declare at least one allowed variable",
428        ));
429    }
430    if capabilities
431        .terminal
432        .as_ref()
433        .map(|cap| cap.enabled && cap.working_dir.is_none() && cap.allowlist.is_empty())
434        .unwrap_or(false)
435    {
436        return Err(ProviderValidationError::new(
437            "empty_capability_scope",
438            "enabled terminal capability must declare a working directory or allowlist",
439        ));
440    }
441    if capabilities
442        .browser
443        .as_ref()
444        .map(|cap| cap.enabled && cap.allowed_origins.is_empty())
445        .unwrap_or(false)
446    {
447        return Err(ProviderValidationError::new(
448            "empty_capability_scope",
449            "enabled browser capability must declare at least one allowed origin",
450        ));
451    }
452    if capabilities
453        .github
454        .as_ref()
455        .map(|cap| cap.enabled && cap.allowed_repos.is_empty())
456        .unwrap_or(false)
457    {
458        return Err(ProviderValidationError::new(
459            "empty_capability_scope",
460            "enabled github capability must declare at least one allowed repo",
461        ));
462    }
463    Ok(())
464}
465
466fn insert_primitive<'a>(
467    names: &mut BTreeSet<&'a str>,
468    kind: &str,
469    name: &'a str,
470) -> Result<(), ProviderValidationError> {
471    if !names.insert(name) {
472        return Err(ProviderValidationError::new(
473            "duplicate_mcp_primitive",
474            format!("duplicate MCP primitive `{name}` in {kind} catalog"),
475        ));
476    }
477    Ok(())
478}
479
480fn reject_instruction_injection(field: &str, value: &str) -> Result<(), ProviderValidationError> {
481    let lower = value.to_ascii_lowercase();
482    let blocked = [
483        "ignore previous instructions",
484        "ignore all previous",
485        "system prompt",
486        "developer message",
487        "you are now",
488    ];
489    if blocked.iter().any(|needle| lower.contains(needle)) {
490        return Err(ProviderValidationError::new(
491            "generated_doc_prompt_injection",
492            format!("{field} contains instruction-like text that generated docs must not elevate"),
493        ));
494    }
495    Ok(())
496}