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}