Skip to main content

agentkit_plugins/
lib.rs

1//! Parse and validate portable [Agent Plugins](https://agent-plugins.org/) packages.
2//!
3//! This crate is deliberately information-first: it discovers standard assets
4//! and exposes their metadata, paths, and portable declarations. Runtime
5//! concerns such as skill activation, MCP process launch, `PLUGIN_DATA`, and
6//! installation remain with the consuming application and satellite crates.
7
8use 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
18/// Canonical Agent Plugins 1.0 manifest schema identifier.
19pub const PLUGIN_SCHEMA_1_0_0: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
20/// Canonical Agent Plugins 1.0 MCP schema identifier.
21pub 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// ---------------------------------------------------------------------------
37// Public package model
38// ---------------------------------------------------------------------------
39
40/// A validated Agent Plugins package and its discovered portable assets.
41#[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    /// Load an Agent Plugins package from a directory.
52    ///
53    /// Manifest failures reject the package. Invalid component locations,
54    /// skills, MCP documents, and individual MCP entries are isolated and
55    /// reported through [`Self::diagnostics`] as required by the standard.
56    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    /// Filesystem-resolved plugin root.
105    pub fn root(&self) -> &Path {
106        &self.root
107    }
108
109    /// Validated portable manifest.
110    pub fn manifest(&self) -> &PluginManifest {
111        &self.manifest
112    }
113
114    /// Valid Agent Skills discovered as immediate children of `skills/`.
115    pub fn skills(&self) -> &[PluginSkill] {
116        &self.skills
117    }
118
119    /// Exact directories containing the discovered skills.
120    ///
121    /// Pass these to `agentkit_tool_skills::SkillRegistry::from_skill_dirs`
122    /// to compose the package with agentkit's existing skill runtime.
123    pub fn skill_directories(&self) -> Vec<PathBuf> {
124        self.skills
125            .iter()
126            .map(|skill| skill.directory.clone())
127            .collect()
128    }
129
130    /// Valid portable MCP server declarations.
131    pub fn mcp_servers(&self) -> &[PluginMcpServer] {
132        &self.mcp_servers
133    }
134
135    /// Non-fatal validation and discovery diagnostics.
136    pub fn diagnostics(&self) -> &[PluginDiagnostic] {
137        &self.diagnostics
138    }
139
140    /// Opaque manifest data for a client extension namespace.
141    ///
142    /// The core loader intentionally does not validate namespace-owned data.
143    pub fn extension_manifest(&self, namespace: &str) -> Option<&Value> {
144        self.manifest.extensions.get(namespace)
145    }
146
147    /// Resolve an existing extension directory while enforcing containment.
148    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/// Portable fields from `plugin.json`.
166#[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    /// Opaque client-extension values keyed by namespace.
178    pub extensions: BTreeMap<String, Value>,
179}
180
181/// Author metadata from a plugin manifest.
182#[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/// A validated skill location inside a plugin.
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub struct PluginSkill {
192    pub name: String,
193    pub description: String,
194    /// Absolute logical skill directory beneath the plugin's `skills/` path.
195    pub directory: PathBuf,
196    /// Filesystem-resolved absolute `SKILL.md` path.
197    pub skill_file: PathBuf,
198}
199
200/// One valid server declaration from `mcp.json`.
201#[derive(Clone, Debug, PartialEq, Eq)]
202pub struct PluginMcpServer {
203    pub name: String,
204    pub transport: PluginMcpTransport,
205}
206
207/// Portable MCP transport data. Placeholder strings remain unexpanded.
208#[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    /// Deprecated MCP HTTP+SSE transport. Runtime support is optional.
221    Sse {
222        url: String,
223        headers: BTreeMap<String, String>,
224    },
225}
226
227/// Fatal package-level load errors.
228#[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/// A non-fatal issue scoped to the narrowest affected package component.
259#[derive(Clone, Debug, PartialEq, Eq)]
260pub struct PluginDiagnostic {
261    pub kind: PluginDiagnosticKind,
262    pub path: Option<PathBuf>,
263    pub message: String,
264}
265
266/// Stable diagnostic categories for programmatic handling.
267#[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
278// ---------------------------------------------------------------------------
279// Manifest validation
280// ---------------------------------------------------------------------------
281
282fn 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// ---------------------------------------------------------------------------
425// Skill discovery
426// ---------------------------------------------------------------------------
427
428#[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    // Deserializing the complete standard frontmatter above validates the
544    // optional field types even though package discovery only exposes catalog
545    // metadata. Keep the bindings used so future compiler lints do not obscure
546    // that validation boundary.
547    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
604// ---------------------------------------------------------------------------
605// MCP discovery and validation
606// ---------------------------------------------------------------------------
607
608fn 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
913// ---------------------------------------------------------------------------
914// Filesystem and diagnostics helpers
915// ---------------------------------------------------------------------------
916
917fn 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// ---------------------------------------------------------------------------
945// Tests
946// ---------------------------------------------------------------------------
947
948#[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}