1use std::collections::{BTreeMap, BTreeSet};
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use http::{HeaderName, HeaderValue};
13use serde::Deserialize;
14use serde_json::{Map, Value};
15use thiserror::Error;
16use url::{Host, Url};
17
18pub const PLUGIN_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
20pub const MCP_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
22
23const MANIFEST_FIELDS: &[&str] = &[
24 "$schema",
25 "name",
26 "version",
27 "description",
28 "author",
29 "homepage",
30 "repository",
31 "license",
32 "keywords",
33 "extensions",
34];
35
36#[derive(Clone, Debug)]
42pub struct AgentPlugin {
43 root: PathBuf,
44 manifest: PluginManifest,
45 skills: Vec<PluginSkill>,
46 mcp_servers: Vec<PluginMcpServer>,
47 diagnostics: Vec<PluginDiagnostic>,
48}
49
50impl AgentPlugin {
51 pub fn load(root: impl Into<PathBuf>) -> Result<Self, PluginError> {
57 let requested_root = root.into();
58 let root = fs::canonicalize(&requested_root).map_err(|error| PluginError::RootResolve {
59 path: requested_root,
60 error,
61 })?;
62 if !root.is_dir() {
63 return Err(PluginError::InvalidRoot { path: root });
64 }
65
66 let manifest_path = root.join("plugin.json");
67 let resolved_manifest =
68 canonical_file(&manifest_path).map_err(|error| PluginError::ManifestRead {
69 path: manifest_path.clone(),
70 error,
71 })?;
72 if !is_contained(&root, &resolved_manifest) {
73 return Err(PluginError::ManifestOutsideRoot {
74 path: resolved_manifest,
75 });
76 }
77
78 let manifest_content =
79 fs::read_to_string(&resolved_manifest).map_err(|error| PluginError::ManifestRead {
80 path: manifest_path.clone(),
81 error,
82 })?;
83 let manifest_value: Value = serde_json::from_str(&manifest_content).map_err(|error| {
84 PluginError::ManifestParse {
85 path: manifest_path,
86 error,
87 }
88 })?;
89
90 let mut diagnostics = Vec::new();
91 let manifest = parse_manifest(manifest_value, &resolved_manifest, &mut diagnostics)?;
92 let skills = discover_skills(&root, &mut diagnostics);
93 let mcp_servers = discover_mcp(&root, &mut diagnostics);
94
95 Ok(Self {
96 root,
97 manifest,
98 skills,
99 mcp_servers,
100 diagnostics,
101 })
102 }
103
104 pub fn root(&self) -> &Path {
106 &self.root
107 }
108
109 pub fn manifest(&self) -> &PluginManifest {
111 &self.manifest
112 }
113
114 pub fn skills(&self) -> &[PluginSkill] {
116 &self.skills
117 }
118
119 pub fn skill_directories(&self) -> Vec<PathBuf> {
124 self.skills
125 .iter()
126 .map(|skill| skill.directory.clone())
127 .collect()
128 }
129
130 pub fn mcp_servers(&self) -> &[PluginMcpServer] {
132 &self.mcp_servers
133 }
134
135 pub fn diagnostics(&self) -> &[PluginDiagnostic] {
137 &self.diagnostics
138 }
139
140 pub fn extension_manifest(&self, namespace: &str) -> Option<&Value> {
144 self.manifest.extensions.get(namespace)
145 }
146
147 pub fn extension_dir(&self, namespace: &str) -> Option<PathBuf> {
149 if namespace.is_empty()
150 || namespace == "."
151 || namespace.contains(['/', '\\'])
152 || Path::new(namespace).components().count() != 1
153 || matches!(
154 Path::new(namespace).components().next(),
155 Some(Component::ParentDir | Component::RootDir | Component::Prefix(_))
156 )
157 {
158 return None;
159 }
160 let path = fs::canonicalize(self.root.join(namespace)).ok()?;
161 (path.is_dir() && is_contained(&self.root, &path)).then_some(path)
162 }
163}
164
165#[derive(Clone, Debug)]
167pub struct PluginManifest {
168 pub schema: String,
169 pub name: String,
170 pub version: Option<String>,
171 pub description: Option<String>,
172 pub author: Option<PluginAuthor>,
173 pub homepage: Option<String>,
174 pub repository: Option<String>,
175 pub license: Option<String>,
176 pub keywords: Vec<String>,
177 pub extensions: BTreeMap<String, Value>,
179}
180
181#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct PluginAuthor {
184 pub name: Option<String>,
185 pub email: Option<String>,
186 pub url: Option<String>,
187}
188
189#[derive(Clone, Debug, PartialEq, Eq)]
191pub struct PluginSkill {
192 pub name: String,
193 pub description: String,
194 pub directory: PathBuf,
196 pub skill_file: PathBuf,
198}
199
200#[derive(Clone, Debug, PartialEq, Eq)]
202pub struct PluginMcpServer {
203 pub name: String,
204 pub transport: PluginMcpTransport,
205}
206
207#[derive(Clone, Debug, PartialEq, Eq)]
209pub enum PluginMcpTransport {
210 Stdio {
211 command: String,
212 args: Vec<String>,
213 env: BTreeMap<String, String>,
214 cwd: Option<String>,
215 },
216 StreamableHttp {
217 url: String,
218 headers: BTreeMap<String, String>,
219 },
220 Sse {
222 url: String,
223 headers: BTreeMap<String, String>,
224 },
225}
226
227#[derive(Debug, Error)]
229pub enum PluginError {
230 #[error("failed to resolve plugin root {path}: {error}")]
231 RootResolve {
232 path: PathBuf,
233 #[source]
234 error: std::io::Error,
235 },
236 #[error("plugin root is not a directory: {path}")]
237 InvalidRoot { path: PathBuf },
238 #[error("failed to read plugin manifest {path}: {error}")]
239 ManifestRead {
240 path: PathBuf,
241 #[source]
242 error: std::io::Error,
243 },
244 #[error("plugin manifest resolves outside the plugin root: {path}")]
245 ManifestOutsideRoot { path: PathBuf },
246 #[error("invalid JSON in plugin manifest {path}: {error}")]
247 ManifestParse {
248 path: PathBuf,
249 #[source]
250 error: serde_json::Error,
251 },
252 #[error("unsupported plugin schema {schema}")]
253 UnsupportedSchema { schema: String },
254 #[error("invalid manifest field {field}: {reason}")]
255 ManifestInvalid { field: String, reason: String },
256}
257
258#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct PluginDiagnostic {
261 pub kind: PluginDiagnosticKind,
262 pub path: Option<PathBuf>,
263 pub message: String,
264}
265
266#[non_exhaustive]
268#[derive(Clone, Copy, Debug, PartialEq, Eq)]
269pub enum PluginDiagnosticKind {
270 UnknownManifestField,
271 InvalidExtensionsField,
272 SkillsLocationInvalid,
273 SkillSkipped,
274 McpDisabled,
275 McpServerSkipped,
276}
277
278fn parse_manifest(
283 value: Value,
284 path: &Path,
285 diagnostics: &mut Vec<PluginDiagnostic>,
286) -> Result<PluginManifest, PluginError> {
287 let object = value
288 .as_object()
289 .ok_or_else(|| invalid_manifest("<root>", "expected a JSON object"))?;
290
291 for field in object.keys() {
292 if !MANIFEST_FIELDS.contains(&field.as_str()) {
293 diagnostics.push(diagnostic(
294 PluginDiagnosticKind::UnknownManifestField,
295 Some(path),
296 format!("unknown manifest field `{field}` was ignored"),
297 ));
298 }
299 }
300
301 let schema = required_string(object, "$schema")?;
302 if schema != PLUGIN_SCHEMA_1_0_0 {
303 return Err(PluginError::UnsupportedSchema { schema });
304 }
305
306 let name = required_string(object, "name")?;
307 if !valid_plugin_name(&name) {
308 return Err(invalid_manifest(
309 "name",
310 "must be 1-64 lowercase alphanumeric, hyphen, or period characters; start and end alphanumeric; and contain neither `--` nor `..`",
311 ));
312 }
313
314 let version = optional_string(object, "version")?;
315 let description = optional_string(object, "description")?;
316 let homepage = optional_string(object, "homepage")?;
317 let repository = optional_string(object, "repository")?;
318 let license = optional_string(object, "license")?;
319 let author = parse_author(object.get("author"))?;
320 let keywords = parse_keywords(object.get("keywords"))?;
321 let extensions = match object.get("extensions") {
322 None => BTreeMap::new(),
323 Some(Value::Object(values)) => values
324 .iter()
325 .map(|(key, value)| (key.clone(), value.clone()))
326 .collect(),
327 Some(_) => {
328 diagnostics.push(diagnostic(
329 PluginDiagnosticKind::InvalidExtensionsField,
330 Some(path),
331 "non-object `extensions` field was ignored",
332 ));
333 BTreeMap::new()
334 }
335 };
336
337 Ok(PluginManifest {
338 schema,
339 name,
340 version,
341 description,
342 author,
343 homepage,
344 repository,
345 license,
346 keywords,
347 extensions,
348 })
349}
350
351fn parse_author(value: Option<&Value>) -> Result<Option<PluginAuthor>, PluginError> {
352 let Some(value) = value else {
353 return Ok(None);
354 };
355 let object = value
356 .as_object()
357 .ok_or_else(|| invalid_manifest("author", "expected an object"))?;
358 for key in object.keys() {
359 if !["name", "email", "url"].contains(&key.as_str()) {
360 return Err(invalid_manifest("author", format!("unknown field `{key}`")));
361 }
362 }
363 Ok(Some(PluginAuthor {
364 name: optional_string(object, "name")?,
365 email: optional_string(object, "email")?,
366 url: optional_string(object, "url")?,
367 }))
368}
369
370fn parse_keywords(value: Option<&Value>) -> Result<Vec<String>, PluginError> {
371 let Some(value) = value else {
372 return Ok(Vec::new());
373 };
374 value
375 .as_array()
376 .ok_or_else(|| invalid_manifest("keywords", "expected an array of strings"))?
377 .iter()
378 .map(|item| {
379 item.as_str()
380 .map(str::to_owned)
381 .ok_or_else(|| invalid_manifest("keywords", "expected an array of strings"))
382 })
383 .collect()
384}
385
386fn required_string(object: &Map<String, Value>, field: &str) -> Result<String, PluginError> {
387 object
388 .get(field)
389 .and_then(Value::as_str)
390 .map(str::to_owned)
391 .ok_or_else(|| invalid_manifest(field, "missing or not a string"))
392}
393
394fn optional_string(
395 object: &Map<String, Value>,
396 field: &str,
397) -> Result<Option<String>, PluginError> {
398 match object.get(field) {
399 None => Ok(None),
400 Some(Value::String(value)) => Ok(Some(value.clone())),
401 Some(_) => Err(invalid_manifest(field, "expected a string")),
402 }
403}
404
405fn invalid_manifest(field: impl Into<String>, reason: impl Into<String>) -> PluginError {
406 PluginError::ManifestInvalid {
407 field: field.into(),
408 reason: reason.into(),
409 }
410}
411
412fn valid_plugin_name(name: &str) -> bool {
413 if name.is_empty() || name.len() > 64 || name.contains("--") || name.contains("..") {
414 return false;
415 }
416 let bytes = name.as_bytes();
417 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
418 && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
419 && bytes.iter().all(|byte| {
420 byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-' || *byte == b'.'
421 })
422}
423
424#[derive(Deserialize)]
429struct SkillFrontmatter {
430 name: Option<String>,
431 description: Option<String>,
432 license: Option<String>,
433 compatibility: Option<String>,
434 metadata: Option<BTreeMap<String, String>>,
435 #[serde(rename = "allowed-tools")]
436 allowed_tools: Option<String>,
437}
438
439fn discover_skills(root: &Path, diagnostics: &mut Vec<PluginDiagnostic>) -> Vec<PluginSkill> {
440 let skills_path = root.join("skills");
441 if fs::symlink_metadata(&skills_path).is_err() {
442 return Vec::new();
443 }
444
445 let resolved_skills = match fs::canonicalize(&skills_path) {
446 Ok(path) if path.is_dir() && is_contained(root, &path) => path,
447 _ => {
448 diagnostics.push(diagnostic(
449 PluginDiagnosticKind::SkillsLocationInvalid,
450 Some(&skills_path),
451 "`skills` must resolve to a directory inside the plugin root",
452 ));
453 return Vec::new();
454 }
455 };
456
457 let entries = match fs::read_dir(&resolved_skills) {
458 Ok(entries) => entries,
459 Err(error) => {
460 diagnostics.push(diagnostic(
461 PluginDiagnosticKind::SkillsLocationInvalid,
462 Some(&skills_path),
463 format!("failed to inspect `skills`: {error}"),
464 ));
465 return Vec::new();
466 }
467 };
468
469 let mut children = entries
470 .filter_map(Result::ok)
471 .map(|entry| entry.path())
472 .collect::<Vec<_>>();
473 children.sort();
474
475 let mut skills = Vec::new();
476 for directory in children {
477 let _resolved_directory = match fs::canonicalize(&directory) {
478 Ok(path) if path.is_dir() && is_contained(root, &path) => path,
479 _ => {
480 diagnostics.push(diagnostic(
481 PluginDiagnosticKind::SkillSkipped,
482 Some(&directory),
483 "skill directory does not resolve inside the plugin root",
484 ));
485 continue;
486 }
487 };
488 let logical_name = match directory.file_name().and_then(|name| name.to_str()) {
489 Some(name) => name.to_owned(),
490 None => continue,
491 };
492 let skill_path = directory.join("SKILL.md");
493 if fs::symlink_metadata(&skill_path).is_err() {
494 continue;
495 }
496 let resolved_skill = match canonical_file(&skill_path) {
497 Ok(path) if is_contained(root, &path) => path,
498 _ => {
499 diagnostics.push(diagnostic(
500 PluginDiagnosticKind::SkillSkipped,
501 Some(&skill_path),
502 format!("skill `{logical_name}` does not resolve to a regular file inside the plugin root"),
503 ));
504 continue;
505 }
506 };
507
508 match parse_skill(&resolved_skill, &logical_name) {
509 Some((name, description)) => skills.push(PluginSkill {
510 name,
511 description,
512 directory,
513 skill_file: resolved_skill,
514 }),
515 None => diagnostics.push(diagnostic(
516 PluginDiagnosticKind::SkillSkipped,
517 Some(&skill_path),
518 format!(
519 "skill `{logical_name}` does not conform to the Agent Skills specification"
520 ),
521 )),
522 }
523 }
524 skills
525}
526
527fn parse_skill(path: &Path, parent_name: &str) -> Option<(String, String)> {
528 let content = fs::read_to_string(path).ok()?;
529 let yaml = split_frontmatter(&content)?;
530 let frontmatter = parse_yaml_lenient(yaml)?;
531 let name = frontmatter.name?.trim().to_owned();
532 let description = frontmatter.description?.trim().to_owned();
533 if !valid_skill_name(&name, parent_name)
534 || description.is_empty()
535 || description.len() > 1024
536 || frontmatter
537 .compatibility
538 .as_deref()
539 .is_some_and(|value| value.is_empty() || value.len() > 500)
540 {
541 return None;
542 }
543 let _ = (
548 frontmatter.license,
549 frontmatter.metadata,
550 frontmatter.allowed_tools,
551 );
552 Some((name, description))
553}
554
555fn split_frontmatter(content: &str) -> Option<&str> {
556 let stripped = content
557 .strip_prefix("---\n")
558 .or_else(|| content.strip_prefix("---\r\n"))?;
559 if stripped.starts_with("---") {
560 return None;
561 }
562 stripped
563 .split_once("\n---\n")
564 .map(|(yaml, _)| yaml)
565 .or_else(|| stripped.split_once("\r\n---\r\n").map(|(yaml, _)| yaml))
566}
567
568fn parse_yaml_lenient(yaml: &str) -> Option<SkillFrontmatter> {
569 if let Ok(frontmatter) = serde_saphyr::from_str(yaml) {
570 return Some(frontmatter);
571 }
572 let fixed = yaml
573 .lines()
574 .map(|line| {
575 if let Some((key, value)) = line.split_once(':') {
576 let value = value.trim();
577 if !value.is_empty()
578 && !value.starts_with('"')
579 && !value.starts_with('\'')
580 && value.contains(':')
581 {
582 return format!("{key}: \"{}\"", value.replace('"', "\\\""));
583 }
584 }
585 line.to_owned()
586 })
587 .collect::<Vec<_>>()
588 .join("\n");
589 serde_saphyr::from_str(&fixed).ok()
590}
591
592fn valid_skill_name(name: &str, parent_name: &str) -> bool {
593 !name.is_empty()
594 && name.len() <= 64
595 && name == parent_name
596 && !name.starts_with('-')
597 && !name.ends_with('-')
598 && !name.contains("--")
599 && name
600 .bytes()
601 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
602}
603
604fn discover_mcp(root: &Path, diagnostics: &mut Vec<PluginDiagnostic>) -> Vec<PluginMcpServer> {
609 let mcp_path = root.join("mcp.json");
610 if fs::symlink_metadata(&mcp_path).is_err() {
611 return Vec::new();
612 }
613 let resolved = match canonical_file(&mcp_path) {
614 Ok(path) if is_contained(root, &path) => path,
615 _ => {
616 disable_mcp(
617 diagnostics,
618 &mcp_path,
619 "`mcp.json` must resolve to a regular file inside the plugin root",
620 );
621 return Vec::new();
622 }
623 };
624 let content = match fs::read_to_string(&resolved) {
625 Ok(content) => content,
626 Err(error) => {
627 disable_mcp(
628 diagnostics,
629 &mcp_path,
630 format!("failed to read `mcp.json`: {error}"),
631 );
632 return Vec::new();
633 }
634 };
635 let value: Value = match serde_json::from_str(&content) {
636 Ok(value) => value,
637 Err(error) => {
638 disable_mcp(diagnostics, &mcp_path, format!("invalid JSON: {error}"));
639 return Vec::new();
640 }
641 };
642 let Some(object) = value.as_object() else {
643 disable_mcp(diagnostics, &mcp_path, "expected a JSON object");
644 return Vec::new();
645 };
646 if object
647 .keys()
648 .any(|key| key != "$schema" && key != "mcpServers")
649 {
650 disable_mcp(
651 diagnostics,
652 &mcp_path,
653 "unknown top-level field in `mcp.json`",
654 );
655 return Vec::new();
656 }
657 if object.get("$schema").and_then(Value::as_str) != Some(MCP_SCHEMA_1_0_0) {
658 disable_mcp(
659 diagnostics,
660 &mcp_path,
661 "unsupported or mismatched Agent Plugins MCP schema",
662 );
663 return Vec::new();
664 }
665 let Some(servers) = object.get("mcpServers").and_then(Value::as_object) else {
666 disable_mcp(diagnostics, &mcp_path, "`mcpServers` must be an object");
667 return Vec::new();
668 };
669
670 let mut names = servers.keys().collect::<Vec<_>>();
671 names.sort();
672 names
673 .into_iter()
674 .filter_map(|name| match parse_mcp_server(root, &servers[name]) {
675 Ok(transport) => Some(PluginMcpServer {
676 name: name.clone(),
677 transport,
678 }),
679 Err(reason) => {
680 diagnostics.push(diagnostic(
681 PluginDiagnosticKind::McpServerSkipped,
682 Some(&mcp_path),
683 format!("MCP server `{name}` was skipped: {reason}"),
684 ));
685 None
686 }
687 })
688 .collect()
689}
690
691fn parse_mcp_server(root: &Path, value: &Value) -> Result<PluginMcpTransport, String> {
692 let object = value
693 .as_object()
694 .ok_or_else(|| "expected an object".to_owned())?;
695 let transport = object
696 .get("type")
697 .and_then(Value::as_str)
698 .ok_or_else(|| "missing string field `type`".to_owned())?;
699 match transport {
700 "stdio" => parse_stdio(root, object),
701 "streamable-http" => parse_remote(object, false),
702 "sse" => parse_remote(object, true),
703 other => Err(format!("unsupported transport type `{other}`")),
704 }
705}
706
707fn parse_stdio(root: &Path, object: &Map<String, Value>) -> Result<PluginMcpTransport, String> {
708 reject_unknown_fields(object, &["type", "command", "args", "env", "cwd"])?;
709 let command = object
710 .get("command")
711 .and_then(Value::as_str)
712 .filter(|command| !command.is_empty())
713 .ok_or_else(|| "missing non-empty string field `command`".to_owned())?
714 .to_owned();
715 validate_command(root, &command)?;
716 let args = string_array(object.get("args"), "args")?;
717 let env = string_map(object.get("env"), "env")?;
718 if env.contains_key("PLUGIN_ROOT") || env.contains_key("PLUGIN_DATA") {
719 return Err("`env` may not define PLUGIN_ROOT or PLUGIN_DATA".to_owned());
720 }
721 let cwd = match object.get("cwd") {
722 None => None,
723 Some(Value::String(value)) => {
724 validate_cwd(root, value)?;
725 Some(value.clone())
726 }
727 Some(_) => return Err("`cwd` must be a string".to_owned()),
728 };
729 Ok(PluginMcpTransport::Stdio {
730 command,
731 args,
732 env,
733 cwd,
734 })
735}
736
737fn parse_remote(object: &Map<String, Value>, sse: bool) -> Result<PluginMcpTransport, String> {
738 reject_unknown_fields(object, &["type", "url", "headers"])?;
739 let url = object
740 .get("url")
741 .and_then(Value::as_str)
742 .filter(|url| !url.is_empty())
743 .ok_or_else(|| "missing non-empty string field `url`".to_owned())?
744 .to_owned();
745 validate_remote_url(&url)?;
746 let headers = string_map(object.get("headers"), "headers")?;
747 validate_headers(&headers)?;
748 if sse {
749 Ok(PluginMcpTransport::Sse { url, headers })
750 } else {
751 Ok(PluginMcpTransport::StreamableHttp { url, headers })
752 }
753}
754
755fn reject_unknown_fields(object: &Map<String, Value>, allowed: &[&str]) -> Result<(), String> {
756 if let Some(field) = object
757 .keys()
758 .find(|field| !allowed.contains(&field.as_str()))
759 {
760 return Err(format!("unknown field `{field}`"));
761 }
762 Ok(())
763}
764
765fn string_array(value: Option<&Value>, field: &str) -> Result<Vec<String>, String> {
766 let Some(value) = value else {
767 return Ok(Vec::new());
768 };
769 value
770 .as_array()
771 .ok_or_else(|| format!("`{field}` must be an array of strings"))?
772 .iter()
773 .map(|value| {
774 value
775 .as_str()
776 .map(str::to_owned)
777 .ok_or_else(|| format!("`{field}` must be an array of strings"))
778 })
779 .collect()
780}
781
782fn string_map(value: Option<&Value>, field: &str) -> Result<BTreeMap<String, String>, String> {
783 let Some(value) = value else {
784 return Ok(BTreeMap::new());
785 };
786 value
787 .as_object()
788 .ok_or_else(|| format!("`{field}` must be an object of strings"))?
789 .iter()
790 .map(|(key, value)| {
791 value
792 .as_str()
793 .map(|value| (key.clone(), value.to_owned()))
794 .ok_or_else(|| format!("`{field}` must be an object of strings"))
795 })
796 .collect()
797}
798
799fn validate_command(root: &Path, command: &str) -> Result<(), String> {
800 if command.contains('\0') || command.contains('\n') || command.contains('\r') {
801 return Err("`command` must be one executable token".to_owned());
802 }
803 if let Some(relative) = command.strip_prefix("./") {
804 validate_relative_suffix(relative, "command")?;
805 validate_existing_ancestor(root, relative, "command")?;
806 return Ok(());
807 }
808 if command.contains('/') || command.contains('\\') {
809 return Err("`command` must be a bare executable name or begin with `./`".to_owned());
810 }
811 Ok(())
812}
813
814fn validate_cwd(root: &Path, cwd: &str) -> Result<(), String> {
815 let (suffix, plugin_rooted) = if let Some(suffix) = cwd.strip_prefix("./") {
816 (suffix, true)
817 } else if matches!(cwd, "${PLUGIN_ROOT}" | "${PLUGIN_DATA}") {
818 return Ok(());
819 } else if let Some(suffix) = cwd.strip_prefix("${PLUGIN_ROOT}/") {
820 (suffix, true)
821 } else if let Some(suffix) = cwd.strip_prefix("${PLUGIN_DATA}/") {
822 (suffix, false)
823 } else {
824 return Err(
825 "`cwd` must be plugin-relative or rooted at ${PLUGIN_ROOT} or ${PLUGIN_DATA}"
826 .to_owned(),
827 );
828 };
829 validate_relative_suffix(suffix, "cwd")?;
830 if plugin_rooted {
831 validate_existing_ancestor(root, suffix, "cwd")?;
832 }
833 Ok(())
834}
835
836fn validate_existing_ancestor(root: &Path, relative: &str, field: &str) -> Result<(), String> {
837 let mut candidate = root.join(relative);
838 while fs::symlink_metadata(&candidate).is_err() {
839 if !candidate.pop() || candidate == root {
840 return Ok(());
841 }
842 }
843 let resolved = fs::canonicalize(&candidate)
844 .map_err(|error| format!("failed to resolve `{field}` path: {error}"))?;
845 if !is_contained(root, &resolved) {
846 return Err(format!("`{field}` resolves outside its permitted root"));
847 }
848 Ok(())
849}
850
851fn validate_relative_suffix(value: &str, field: &str) -> Result<(), String> {
852 if value.is_empty() || Path::new(value).is_absolute() {
853 return Err(format!("`{field}` must name a contained path"));
854 }
855 let mut depth = 0usize;
856 for component in Path::new(value).components() {
857 match component {
858 Component::Normal(_) => depth += 1,
859 Component::CurDir => {}
860 Component::ParentDir if depth > 0 => depth -= 1,
861 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
862 return Err(format!("`{field}` escapes its permitted root"));
863 }
864 }
865 }
866 Ok(())
867}
868
869fn validate_remote_url(value: &str) -> Result<(), String> {
870 let url = Url::parse(value).map_err(|error| format!("invalid URL: {error}"))?;
871 if !matches!(url.scheme(), "http" | "https") {
872 return Err("URL scheme must be HTTP or HTTPS".to_owned());
873 }
874 if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
875 return Err("URL must not contain user information or a fragment".to_owned());
876 }
877 let host = url
878 .host()
879 .ok_or_else(|| "URL must include a host".to_owned())?;
880 let loopback = match host {
881 Host::Domain(domain) => domain == "localhost",
882 Host::Ipv4(address) => address.is_loopback(),
883 Host::Ipv6(address) => address.is_loopback(),
884 };
885 if url.scheme() == "http" && !loopback {
886 return Err("non-loopback MCP URLs must use HTTPS".to_owned());
887 }
888 Ok(())
889}
890
891fn validate_headers(headers: &BTreeMap<String, String>) -> Result<(), String> {
892 let mut names = BTreeSet::new();
893 for (name, value) in headers {
894 HeaderName::from_bytes(name.as_bytes())
895 .map_err(|error| format!("invalid HTTP header name `{name}`: {error}"))?;
896 HeaderValue::from_str(value)
897 .map_err(|error| format!("invalid value for HTTP header `{name}`: {error}"))?;
898 if !names.insert(name.to_ascii_lowercase()) {
899 return Err(format!("duplicate case-insensitive HTTP header `{name}`"));
900 }
901 }
902 Ok(())
903}
904
905fn disable_mcp(diagnostics: &mut Vec<PluginDiagnostic>, path: &Path, message: impl Into<String>) {
906 diagnostics.push(diagnostic(
907 PluginDiagnosticKind::McpDisabled,
908 Some(path),
909 message,
910 ));
911}
912
913fn canonical_file(path: &Path) -> std::io::Result<PathBuf> {
918 let resolved = fs::canonicalize(path)?;
919 if !resolved.is_file() {
920 return Err(std::io::Error::new(
921 std::io::ErrorKind::InvalidInput,
922 "path is not a regular file",
923 ));
924 }
925 Ok(resolved)
926}
927
928fn is_contained(root: &Path, candidate: &Path) -> bool {
929 candidate == root || candidate.starts_with(root)
930}
931
932fn diagnostic(
933 kind: PluginDiagnosticKind,
934 path: Option<&Path>,
935 message: impl Into<String>,
936) -> PluginDiagnostic {
937 PluginDiagnostic {
938 kind,
939 path: path.map(Path::to_path_buf),
940 message: message.into(),
941 }
942}
943
944#[cfg(test)]
949mod tests {
950 use super::*;
951 use std::time::{SystemTime, UNIX_EPOCH};
952
953 fn temp_plugin(label: &str) -> PathBuf {
954 let nonce = SystemTime::now()
955 .duration_since(UNIX_EPOCH)
956 .unwrap()
957 .as_nanos();
958 let root = std::env::temp_dir().join(format!("agentkit-plugin-{label}-{nonce}"));
959 fs::create_dir_all(&root).unwrap();
960 root
961 }
962
963 fn write_manifest(root: &Path, extra: &str) {
964 fs::write(
965 root.join("plugin.json"),
966 format!("{{\"$schema\":\"{PLUGIN_SCHEMA_1_0_0}\",\"name\":\"test-plugin\"{extra}}}"),
967 )
968 .unwrap();
969 }
970
971 fn write_skill(root: &Path, directory: &str, name: &str) {
972 let dir = root.join("skills").join(directory);
973 fs::create_dir_all(&dir).unwrap();
974 fs::write(
975 dir.join("SKILL.md"),
976 format!("---\nname: {name}\ndescription: Test skill.\n---\nInstructions."),
977 )
978 .unwrap();
979 }
980
981 #[test]
982 fn loads_manifest_and_preserves_opaque_extensions() {
983 let root = temp_plugin("manifest");
984 write_manifest(
985 &root,
986 ",\"version\":\"not-semver\",\"extensions\":{\"com.example\":42},\"future\":true",
987 );
988
989 let plugin = AgentPlugin::load(&root).unwrap();
990 assert_eq!(plugin.manifest().version.as_deref(), Some("not-semver"));
991 assert_eq!(
992 plugin.extension_manifest("com.example"),
993 Some(&Value::from(42))
994 );
995 assert!(
996 plugin
997 .diagnostics()
998 .iter()
999 .any(|d| d.kind == PluginDiagnosticKind::UnknownManifestField)
1000 );
1001
1002 fs::remove_dir_all(root).unwrap();
1003 }
1004
1005 #[test]
1006 fn rejects_invalid_manifest_name() {
1007 let root = temp_plugin("bad-name");
1008 fs::write(
1009 root.join("plugin.json"),
1010 format!("{{\"$schema\":\"{PLUGIN_SCHEMA_1_0_0}\",\"name\":\"Bad--Name\"}}"),
1011 )
1012 .unwrap();
1013 assert!(matches!(
1014 AgentPlugin::load(&root),
1015 Err(PluginError::ManifestInvalid { .. })
1016 ));
1017 fs::remove_dir_all(root).unwrap();
1018 }
1019
1020 #[test]
1021 fn discovers_only_immediate_valid_skills() {
1022 let root = temp_plugin("skills");
1023 write_manifest(&root, "");
1024 write_skill(&root, "valid", "valid");
1025 write_skill(&root.join("skills/valid"), "nested", "nested");
1026 write_skill(&root, "wrong-dir", "other-name");
1027
1028 let plugin = AgentPlugin::load(&root).unwrap();
1029 assert_eq!(plugin.skills().len(), 1);
1030 assert_eq!(plugin.skills()[0].name, "valid");
1031 assert!(
1032 plugin
1033 .diagnostics()
1034 .iter()
1035 .any(|d| d.kind == PluginDiagnosticKind::SkillSkipped)
1036 );
1037
1038 fs::remove_dir_all(root).unwrap();
1039 }
1040
1041 #[test]
1042 fn isolates_invalid_mcp_entries() {
1043 let root = temp_plugin("mcp");
1044 write_manifest(&root, "");
1045 fs::write(
1046 root.join("mcp.json"),
1047 format!(
1048 r#"{{"$schema":"{MCP_SCHEMA_1_0_0}","mcpServers":{{
1049 "local":{{"type":"stdio","command":"./bin/server","args":["${{PLUGIN_DATA}}/db"]}},
1050 "remote":{{"type":"streamable-http","url":"https://example.com/mcp","headers":{{"X-Tenant":"public"}}}},
1051 "bad":{{"type":"stdio","command":"node","extra":true}}
1052 }}}}"#
1053 ),
1054 )
1055 .unwrap();
1056
1057 let plugin = AgentPlugin::load(&root).unwrap();
1058 assert_eq!(plugin.mcp_servers().len(), 2);
1059 assert!(
1060 plugin
1061 .diagnostics()
1062 .iter()
1063 .any(|d| d.kind == PluginDiagnosticKind::McpServerSkipped)
1064 );
1065
1066 fs::remove_dir_all(root).unwrap();
1067 }
1068
1069 #[test]
1070 fn disables_only_mcp_for_invalid_top_level_document() {
1071 let root = temp_plugin("mcp-disabled");
1072 write_manifest(&root, "");
1073 write_skill(&root, "valid", "valid");
1074 fs::write(
1075 root.join("mcp.json"),
1076 format!("{{\"$schema\":\"{MCP_SCHEMA_1_0_0}\",\"mcpServers\":{{}},\"extra\":true}}"),
1077 )
1078 .unwrap();
1079
1080 let plugin = AgentPlugin::load(&root).unwrap();
1081 assert_eq!(plugin.skills().len(), 1);
1082 assert!(plugin.mcp_servers().is_empty());
1083 assert!(
1084 plugin
1085 .diagnostics()
1086 .iter()
1087 .any(|d| d.kind == PluginDiagnosticKind::McpDisabled)
1088 );
1089
1090 fs::remove_dir_all(root).unwrap();
1091 }
1092
1093 #[test]
1094 fn validates_remote_urls_headers_and_stdio_paths() {
1095 assert!(validate_remote_url("https://example.com/mcp").is_ok());
1096 assert!(validate_remote_url("http://localhost:3000/mcp").is_ok());
1097 assert!(validate_remote_url("http://127.0.0.1/mcp").is_ok());
1098 assert!(validate_remote_url("http://example.com/mcp").is_err());
1099 assert!(validate_remote_url("https://user@example.com/mcp").is_err());
1100 let root = temp_plugin("cwd-validation");
1101 assert!(validate_cwd(&root, "${PLUGIN_ROOT}/work").is_ok());
1102 assert!(validate_cwd(&root, "${PLUGIN_DATA}/work").is_ok());
1103 assert!(validate_cwd(&root, "./work").is_ok());
1104 assert!(validate_cwd(&root, "../work").is_err());
1105
1106 let headers = BTreeMap::from([
1107 ("X-Test".to_owned(), "one".to_owned()),
1108 ("x-test".to_owned(), "two".to_owned()),
1109 ]);
1110 assert!(validate_headers(&headers).is_err());
1111 fs::remove_dir_all(root).unwrap();
1112 }
1113
1114 #[cfg(unix)]
1115 #[test]
1116 fn rejects_plugin_rooted_mcp_paths_through_escaping_symlinks() {
1117 use std::os::unix::fs::symlink;
1118
1119 let root = temp_plugin("mcp-path-escape");
1120 let outside = temp_plugin("mcp-path-outside");
1121 symlink(&outside, root.join("escape")).unwrap();
1122
1123 assert!(validate_cwd(&root, "./escape/work").is_err());
1124 assert!(validate_command(&root, "./escape/server").is_err());
1125
1126 fs::remove_file(root.join("escape")).unwrap();
1127 fs::remove_dir_all(root).unwrap();
1128 fs::remove_dir_all(outside).unwrap();
1129 }
1130
1131 #[cfg(unix)]
1132 #[test]
1133 fn skips_skill_symlink_that_escapes_root() {
1134 use std::os::unix::fs::symlink;
1135
1136 let root = temp_plugin("escape");
1137 let outside = temp_plugin("outside");
1138 write_manifest(&root, "");
1139 fs::create_dir_all(root.join("skills/escaped")).unwrap();
1140 fs::write(
1141 outside.join("SKILL.md"),
1142 "---\nname: escaped\ndescription: Escape.\n---\nBody.",
1143 )
1144 .unwrap();
1145 symlink(
1146 outside.join("SKILL.md"),
1147 root.join("skills/escaped/SKILL.md"),
1148 )
1149 .unwrap();
1150
1151 let plugin = AgentPlugin::load(&root).unwrap();
1152 assert!(plugin.skills().is_empty());
1153 assert!(
1154 plugin
1155 .diagnostics()
1156 .iter()
1157 .any(|d| d.kind == PluginDiagnosticKind::SkillSkipped)
1158 );
1159
1160 fs::remove_dir_all(root).unwrap();
1161 fs::remove_dir_all(outside).unwrap();
1162 }
1163}