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}