返回 CodeWhale
manifest.rs
根目录 / crates / tui / src / plugins / manifest.rs
1 use std::collections::{BTreeMap, BTreeSet, HashMap};
2 use std::fs;
3 use std::io::Read;
4 use std::path::{Component, Path, PathBuf};
5
6 use semver::Version;
7 use serde::{Deserialize, Serialize};
8 use sha2::{Digest, Sha256};
9
10 #[cfg(test)]
11 use super::activation::CAPABILITY_HASH_DOMAIN_V2;
12 use super::activation::{
13 CAPABILITY_HASH_DOMAIN_V1, PluginActivationCapability, PluginActivationPolicy,
14 };
15 use super::path_identity::metadata_is_link_or_reparse;
16 #[cfg(windows)]
17 use super::path_identity::windows_file_identity;
18 use crate::mcp::{McpServerConfig, is_relative_stdio_path_arg};
19
20 pub const CURRENT_SCHEMA_VERSION: u32 = 1;
21 pub(crate) const MAX_PLUGIN_NAME_CHARS: usize = 64;
22 const MAX_COMPONENT_PATHS: usize = 64;
23 const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
24 const MAX_HASHED_FILES: usize = 4_096;
25 const MAX_HASHED_BYTES: u64 = 64 * 1024 * 1024;
26 const MAX_MCP_ARGS: usize = 64;
27 const MAX_MCP_ENV: usize = 64;
28 const MAX_MCP_HEADERS: usize = 64;
29 const MAX_MCP_SCOPES: usize = 64;
30 const MAX_MCP_TOOL_FILTERS: usize = 256;
31 const MAX_MCP_TIMEOUT_SECS: u64 = 3_600;
32
33 #[derive(Debug, Clone, Deserialize, Serialize)]
34 #[serde(deny_unknown_fields)]
35 pub struct PluginManifest {
36 /// Missing means the legacy, pre-versioned Codewhale manifest. Legacy
37 /// manifests remain readable, but `/plugin validate` reports the migration.
38 #[serde(default)]
39 pub schema_version: u32,
40 pub plugin: PluginMeta,
41 #[serde(default)]
42 pub skills: Option<PluginPathSpec>,
43 #[serde(default)]
44 pub commands: Option<PluginPathSpec>,
45 #[serde(default, alias = "profiles")]
46 pub agents: Option<PluginPathSpec>,
47 #[serde(default)]
48 pub hooks: Option<PluginPathSpec>,
49 #[serde(default, alias = "lsp_servers")]
50 pub lsp: Option<PluginPathSpec>,
51 #[serde(default, alias = "native_extension")]
52 pub native: Option<PluginPathSpec>,
53 #[serde(default)]
54 pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
55 #[serde(default)]
56 pub capabilities: PluginCapabilities,
57 #[serde(default)]
58 pub when: Option<PluginWhen>,
59 }
60
61 #[derive(Debug, Clone, Deserialize, Serialize)]
62 #[serde(deny_unknown_fields)]
63 pub struct PluginMeta {
64 pub name: String,
65 #[serde(default)]
66 pub description: Option<String>,
67 #[serde(default)]
68 pub version: String,
69 #[serde(default)]
70 pub author: Option<String>,
71 /// Human-facing name preserved when the published `name` had to be
72 /// slugified to satisfy the Agent Plugins name rule.
73 #[serde(default)]
74 pub display_name: Option<String>,
75 /// Bounded inline PNG artwork. Never a remote fetch or executable SVG.
76 #[serde(default)]
77 pub icon: Option<String>,
78 #[serde(default)]
79 pub homepage: Option<String>,
80 #[serde(default)]
81 pub repository: Option<String>,
82 #[serde(default)]
83 pub license: Option<String>,
84 #[serde(default)]
85 pub keywords: Vec<String>,
86 }
87
88 /// A declarative component location. `path` preserves the original manifest
89 /// shape; `paths` lets a bundle split one component kind across directories.
90 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
91 #[serde(deny_unknown_fields)]
92 pub struct PluginPathSpec {
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub path: Option<String>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub paths: Vec<String>,
97 }
98
99 impl PluginPathSpec {
100 fn declared_paths(&self, default: Option<&str>) -> Result<Vec<String>, String> {
101 let mut paths = Vec::new();
102 if let Some(path) = self.path.as_deref() {
103 paths.push(path.to_string());
104 }
105 paths.extend(self.paths.iter().cloned());
106 if paths.is_empty()
107 && let Some(default) = default
108 {
109 paths.push(default.to_string());
110 }
111 if paths.is_empty() {
112 return Err("component table must declare `path` or `paths`".to_string());
113 }
114 if paths.len() > MAX_COMPONENT_PATHS {
115 return Err(format!(
116 "component declares {} paths; maximum is {MAX_COMPONENT_PATHS}",
117 paths.len()
118 ));
119 }
120 let mut seen = BTreeSet::new();
121 for path in &paths {
122 if !seen.insert(path.clone()) {
123 return Err(format!(
124 "component path `{path}` is declared more than once"
125 ));
126 }
127 }
128 Ok(paths)
129 }
130 }
131
132 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
133 #[serde(deny_unknown_fields)]
134 pub struct PluginCapabilities {
135 /// Requested filesystem roots are inventoried and stay inactive. They do
136 /// not block the bundle's supported declarative adapters from activating.
137 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub filesystem_roots: Vec<String>,
139 /// Requested hosts are inventory-only. MCP URL hosts are added to the
140 /// effective capability inventory automatically.
141 #[serde(default, skip_serializing_if = "Vec::is_empty")]
142 pub network_hosts: Vec<String>,
143 /// Lifecycle mutation is inventoried but unsupported. It does not block
144 /// the bundle's supported declarative adapters from activating.
145 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
146 pub lifecycle_mutation: bool,
147 }
148
149 #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
150 #[serde(deny_unknown_fields)]
151 pub struct PluginWhen {
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub os: Option<Vec<String>>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub binaries: Option<Vec<String>>,
156 }
157
158 #[derive(Debug, Clone, Default, PartialEq, Eq)]
159 pub struct ResolvedPluginComponents {
160 pub skills: Vec<PathBuf>,
161 pub commands: Vec<PathBuf>,
162 pub agents: Vec<PathBuf>,
163 pub hooks: Vec<PathBuf>,
164 pub lsp: Vec<PathBuf>,
165 pub native: Vec<PathBuf>,
166 }
167
168 impl ResolvedPluginComponents {
169 pub fn all_paths(&self) -> impl Iterator<Item = &PathBuf> {
170 self.skills
171 .iter()
172 .chain(&self.commands)
173 .chain(&self.agents)
174 .chain(&self.hooks)
175 .chain(&self.lsp)
176 .chain(&self.native)
177 }
178 }
179
180 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
181 #[serde(deny_unknown_fields)]
182 pub struct PluginInventory {
183 pub skills: usize,
184 pub mcp_servers: usize,
185 /// MCP servers that launch a child process under the Codewhale user's
186 /// host permissions. Kept separate from remote MCP so the review screen
187 /// cannot imply that an empty declared filesystem/network list is a
188 /// sandbox boundary.
189 #[serde(default)]
190 pub stdio_mcp_servers: usize,
191 /// MCP servers contacted over HTTP(S) without launching a local child.
192 #[serde(default)]
193 pub remote_mcp_servers: usize,
194 pub commands: usize,
195 pub agents: usize,
196 pub hooks: usize,
197 pub lsp: usize,
198 pub native: usize,
199 pub filesystem_roots: Vec<String>,
200 pub network_hosts: Vec<String>,
201 pub lifecycle_mutation: bool,
202 }
203
204 /// Host compatibility of a reviewed bundle. This is independent of trust,
205 /// enablement, and staging: it names whether Codewhale can activate the
206 /// declared surfaces, not whether the operator has turned the bundle on.
207 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208 #[serde(rename_all = "snake_case")]
209 pub enum PluginCompatibility {
210 /// Every declared surface has an adapter, or the bundle is empty.
211 Full,
212 /// Supported adapters can activate; other declared surfaces stay inactive.
213 Partial,
214 /// The bundle only declares surfaces Codewhale cannot activate yet.
215 Unsupported,
216 }
217
218 impl PluginCompatibility {
219 #[must_use]
220 pub fn as_str(self) -> &'static str {
221 match self {
222 Self::Full => "full",
223 Self::Partial => "partial",
224 Self::Unsupported => "unsupported",
225 }
226 }
227 }
228
229 impl PluginInventory {
230 #[must_use]
231 pub fn declared_capabilities(&self) -> Vec<PluginActivationCapability> {
232 let mut capabilities = Vec::new();
233 if self.skills > 0 {
234 capabilities.push(PluginActivationCapability::Skills);
235 }
236 if self.stdio_mcp_servers > 0 {
237 capabilities.push(PluginActivationCapability::McpStdio);
238 }
239 if self.remote_mcp_servers > 0 {
240 capabilities.push(PluginActivationCapability::McpRemote);
241 }
242 if self.commands > 0 {
243 capabilities.push(PluginActivationCapability::Commands);
244 }
245 if self.agents > 0 {
246 capabilities.push(PluginActivationCapability::Agents);
247 }
248 if self.hooks > 0 {
249 capabilities.push(PluginActivationCapability::Hooks);
250 }
251 if self.lsp > 0 {
252 capabilities.push(PluginActivationCapability::Lsp);
253 }
254 if self.native > 0 {
255 capabilities.push(PluginActivationCapability::Native);
256 }
257 if !self.filesystem_roots.is_empty() {
258 capabilities.push(PluginActivationCapability::FilesystemRoots);
259 }
260 if self.lifecycle_mutation {
261 capabilities.push(PluginActivationCapability::LifecycleMutation);
262 }
263 capabilities
264 }
265
266 #[must_use]
267 pub fn unsupported_labels(&self) -> Vec<&'static str> {
268 let policy = PluginActivationPolicy::current();
269 self.declared_capabilities()
270 .into_iter()
271 .filter(|capability| !policy.is_supported(*capability))
272 .map(PluginActivationCapability::as_str)
273 .collect()
274 }
275
276 #[must_use]
277 pub fn has_unsupported_capabilities(&self) -> bool {
278 !self.unsupported_labels().is_empty()
279 }
280
281 /// True when the bundle declares at least one adapter this build activates.
282 #[must_use]
283 pub fn has_supported_components(&self) -> bool {
284 let policy = PluginActivationPolicy::current();
285 self.declared_capabilities()
286 .into_iter()
287 .any(|capability| policy.is_supported(capability))
288 }
289
290 #[must_use]
291 pub fn supported_labels(&self) -> Vec<&'static str> {
292 let policy = PluginActivationPolicy::current();
293 let mut labels = Vec::new();
294 let mut saw_mcp = false;
295 for capability in self.declared_capabilities() {
296 if !policy.is_supported(capability) {
297 continue;
298 }
299 match capability {
300 PluginActivationCapability::McpStdio | PluginActivationCapability::McpRemote => {
301 if !saw_mcp {
302 labels.push("mcp");
303 saw_mcp = true;
304 }
305 }
306 other => labels.push(other.as_str()),
307 }
308 }
309 labels
310 }
311
312 #[must_use]
313 pub fn compatibility(&self) -> PluginCompatibility {
314 if !self.has_unsupported_capabilities() {
315 PluginCompatibility::Full
316 } else if self.has_supported_components() {
317 PluginCompatibility::Partial
318 } else {
319 PluginCompatibility::Unsupported
320 }
321 }
322
323 /// Empty or fully-supported bundles can activate; mixed bundles can
324 /// activate their supported adapters; all-unsupported bundles cannot.
325 #[must_use]
326 pub fn can_activate_supported_components(&self) -> bool {
327 !matches!(self.compatibility(), PluginCompatibility::Unsupported)
328 }
329
330 #[must_use]
331 pub fn summary(&self) -> String {
332 format!(
333 "skills={} mcp={} (stdio={} remote={}) commands={} agents={} hooks={} lsp={} native={}",
334 self.skills,
335 self.mcp_servers,
336 self.stdio_mcp_servers,
337 self.remote_mcp_servers,
338 self.commands,
339 self.agents,
340 self.hooks,
341 self.lsp,
342 self.native
343 )
344 }
345 }
346
347 #[derive(Debug, Clone)]
348 pub struct ValidatedManifest {
349 pub manifest: PluginManifest,
350 pub canonical_root: PathBuf,
351 pub components: ResolvedPluginComponents,
352 pub inventory: PluginInventory,
353 pub content_hash: String,
354 /// Digest of the exact bytes read for every regular bundle file, keyed by
355 /// its lossless relative OS path. Runtime adapters use this to bind parsed
356 /// representations to the same bytes that produced `content_hash`.
357 pub(crate) file_hashes: BTreeMap<PathBuf, String>,
358 /// Plain SHA256 for declared regular Native entry files, captured from
359 /// the same anchored, bounded read as the domain-separated bundle hashes.
360 /// EntryRef binds these digests; the bundle file inventory keeps its domain.
361 pub(crate) native_entry_hashes: BTreeMap<PathBuf, String>,
362 pub capability_hash: String,
363 pub applicable: bool,
364 pub warnings: Vec<String>,
365 }
366
367 /// On-disk manifest encoding, detected from the file name. `plugin.json`
368 /// (Agent Plugins v1.0.0) is the native format; `plugin.toml` stays readable
369 /// as the legacy Codewhale format. Both parse into the same
370 /// [`PluginManifest`], so nothing downstream of discovery changes.
371 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
372 enum ManifestFormat {
373 Json,
374 KimiJson,
375 ClaudeJson,
376 Toml,
377 }
378
379 impl ManifestFormat {
380 fn from_path(path: &Path) -> Result<Self, String> {
381 match path.file_name().and_then(|name| name.to_str()) {
382 Some(super::agent_plugin::PLUGIN_JSON_NAME)
383 if path
384 .parent()
385 .and_then(Path::file_name)
386 .is_some_and(|name| name == ".claude-plugin") =>
387 {
388 Ok(Self::ClaudeJson)
389 }
390 Some(super::agent_plugin::PLUGIN_JSON_NAME) => Ok(Self::Json),
391 Some(super::agent_plugin::KIMI_PLUGIN_JSON_NAME) => Ok(Self::KimiJson),
392 Some(super::agent_plugin::PLUGIN_TOML_NAME) => Ok(Self::Toml),
393 _ => Err(format!(
394 "plugin manifest must be named plugin.json, kimi.plugin.json, or plugin.toml: {}",
395 path.display()
396 )),
397 }
398 }
399
400 fn label(self) -> &'static str {
401 match self {
402 Self::Json => super::agent_plugin::PLUGIN_JSON_NAME,
403 Self::KimiJson => super::agent_plugin::KIMI_PLUGIN_JSON_NAME,
404 Self::ClaudeJson => ".claude-plugin/plugin.json",
405 Self::Toml => super::agent_plugin::PLUGIN_TOML_NAME,
406 }
407 }
408 }
409
410 /// Parse manifest text in either encoding. For `plugin.json` this also reads
411 /// the sibling `mcp.json` (whose bytes are returned so callers can re-read and
412 /// detect mid-validation drift); Codewhale-specific data arrives through
413 /// `extensions["net.codewhale"]` and unknown namespaces are ignored.
414 fn parse_manifest(
415 format: ManifestFormat,
416 text: &str,
417 root: &Path,
418 ) -> Result<(PluginManifest, Option<Vec<u8>>), String> {
419 match format {
420 ManifestFormat::Toml => {
421 validate_nested_mcp_schema(text)?;
422 let manifest = toml::from_str(text).map_err(|error| safe_toml_parse_error(&error))?;
423 Ok((manifest, None))
424 }
425 ManifestFormat::Json => {
426 let standard = super::agent_plugin::parse_plugin_json(text)?;
427 let mcp_bytes = read_sibling_mcp_json(root, super::agent_plugin::MCP_JSON_NAME)?;
428 let mcp_servers = match &mcp_bytes {
429 Some(bytes) => {
430 let text = std::str::from_utf8(bytes)
431 .map_err(|_| "mcp.json must be valid UTF-8".to_string())?;
432 Some(super::agent_plugin::parse_mcp_json(text)?)
433 }
434 None => None,
435 };
436 let manifest = super::agent_plugin::standard_to_manifest(standard, mcp_servers, root)?;
437 Ok((manifest, mcp_bytes))
438 }
439 ManifestFormat::ClaudeJson => {
440 let bytes = read_sibling_mcp_json(root, ".mcp.json")?;
441 let manifest =
442 super::agent_plugin::parse_claude_plugin_json(text, root, bytes.as_deref())?;
443 Ok((manifest, bytes))
444 }
445 ManifestFormat::KimiJson => Ok((
446 super::agent_plugin::parse_kimi_plugin_json(text, root)?,
447 None,
448 )),
449 }
450 }
451
452 /// Read a `plugin.json` bundle's sibling `mcp.json` under the same rules as
453 /// the manifest itself: a regular file, never a link, size-bounded.
454 fn read_sibling_mcp_json(root: &Path, name: &str) -> Result<Option<Vec<u8>>, String> {
455 let path = root.join(name);
456 let metadata = match fs::symlink_metadata(&path) {
457 Ok(metadata) => metadata,
458 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
459 Err(error) => return Err(format!("failed to inspect mcp.json: {error}")),
460 };
461 if metadata_is_link_or_reparse(&metadata) || !metadata.is_file() {
462 return Err("mcp.json must be a regular file, not a symbolic link".to_string());
463 }
464 let file = open_bundle_file(&path)
465 .map_err(|e| format!("failed to open mcp.json without following links: {e}"))?;
466 let mut bytes = Vec::new();
467 file.take(MAX_MANIFEST_BYTES + 1)
468 .read_to_end(&mut bytes)
469 .map_err(|e| format!("failed to read mcp.json: {e}"))?;
470 if bytes.len() as u64 > MAX_MANIFEST_BYTES {
471 return Err(format!(
472 "mcp.json exceeds the {MAX_MANIFEST_BYTES}-byte review limit"
473 ));
474 }
475 Ok(Some(bytes))
476 }
477
478 impl PluginManifest {
479 pub fn from_path(path: &Path) -> Result<Self, String> {
480 let format = ManifestFormat::from_path(path)?;
481 let label = format.label();
482 let metadata =
483 fs::symlink_metadata(path).map_err(|e| format!("failed to inspect {label}: {e}"))?;
484 if metadata_is_link_or_reparse(&metadata) {
485 return Err(format!("{label} may not be a symbolic link"));
486 }
487 let bytes = read_manifest_bytes(path, label)?;
488 let content =
489 std::str::from_utf8(&bytes).map_err(|_| format!("{label} must be valid UTF-8"))?;
490 let root = super::agent_plugin::plugin_root_for_manifest(path)
491 .ok_or_else(|| format!("{label} has no parent directory"))?;
492 Ok(parse_manifest(format, content, root)?.0)
493 }
494
495 pub fn validate_from_path(path: &Path) -> Result<ValidatedManifest, String> {
496 let format = ManifestFormat::from_path(path)?;
497 let label = format.label();
498 let manifest_metadata =
499 fs::symlink_metadata(path).map_err(|e| format!("failed to inspect {label}: {e}"))?;
500 if metadata_is_link_or_reparse(&manifest_metadata) || !manifest_metadata.is_file() {
501 return Err(format!(
502 "{label} must be a regular file, not a symbolic link"
503 ));
504 }
505 let root = super::agent_plugin::plugin_root_for_manifest(path)
506 .ok_or_else(|| format!("{label} has no parent directory"))?;
507 let root_metadata = fs::symlink_metadata(root)
508 .map_err(|e| format!("failed to inspect plugin root: {e}"))?;
509 if metadata_is_link_or_reparse(&root_metadata) || !root_metadata.is_dir() {
510 return Err("plugin root must be a directory, not a symbolic link".to_string());
511 }
512 let canonical_root = root
513 .canonicalize()
514 .map_err(|e| format!("failed to canonicalize plugin root: {e}"))?;
515 if !canonical_root.is_dir() {
516 return Err("plugin root is not a directory".to_string());
517 }
518
519 let manifest_bytes = read_manifest_bytes(path, label)?;
520 let manifest_text = std::str::from_utf8(&manifest_bytes)
521 .map_err(|_| format!("{label} must be valid UTF-8"))?;
522 let (mut manifest, mcp_bytes) = parse_manifest(format, manifest_text, &canonical_root)?;
523 let warnings = if manifest.schema_version == 0 && format == ManifestFormat::Toml {
524 let mut warnings = vec![format!(
525 "legacy manifest: add `schema_version = {CURRENT_SCHEMA_VERSION}`"
526 )];
527 if manifest.plugin.version.trim().is_empty() {
528 manifest.plugin.version = "0.0.0".to_string();
529 warnings.push(
530 "legacy manifest: add a semantic `[plugin].version`; displaying `0.0.0`"
531 .to_string(),
532 );
533 }
534 warnings
535 } else {
536 Vec::new()
537 };
538 manifest.validate_metadata(format)?;
539
540 let components = manifest.resolve_components(&canonical_root)?;
541 manifest.validate_mcp_servers(&canonical_root)?;
542 let inventory = manifest.inventory(&components)?;
543 let (content_hash, bundle_hashes) =
544 hash_bundle(&canonical_root, &manifest_bytes, label, &components.native)?;
545 let capability_hash = hash_inventory(&inventory);
546 let applicable = manifest.check_when();
547 if read_manifest_bytes(path, label)? != manifest_bytes {
548 return Err(format!(
549 "{label} changed while it was being validated; retry discovery"
550 ));
551 }
552 let mcp_name = match format {
553 ManifestFormat::Json => Some(super::agent_plugin::MCP_JSON_NAME),
554 ManifestFormat::ClaudeJson => Some(".mcp.json"),
555 _ => None,
556 };
557 if let Some(name) = mcp_name
558 && read_sibling_mcp_json(&canonical_root, name)? != mcp_bytes
559 {
560 return Err(
561 "mcp.json changed while it was being validated; retry discovery".to_string(),
562 );
563 }
564
565 Ok(ValidatedManifest {
566 manifest,
567 canonical_root,
568 components,
569 inventory,
570 content_hash,
571 file_hashes: bundle_hashes.file_hashes,
572 native_entry_hashes: bundle_hashes.native_entry_hashes,
573 capability_hash,
574 applicable,
575 warnings,
576 })
577 }
578
579 fn validate_metadata(&self, format: ManifestFormat) -> Result<(), String> {
580 if self.schema_version > CURRENT_SCHEMA_VERSION {
581 return Err(format!(
582 "unsupported schema_version {}; maximum is {CURRENT_SCHEMA_VERSION}",
583 self.schema_version
584 ));
585 }
586 match format {
587 ManifestFormat::Json | ManifestFormat::ClaudeJson => {
588 if !super::agent_plugin::is_standard_plugin_name(&self.plugin.name) {
589 return Err(format!(
590 "plugin name `{}` violates the Agent Plugins name rule (1-{MAX_PLUGIN_NAME_CHARS} lowercase ASCII letters, digits, or internal single hyphens or dots; never `--` or `..`)",
591 self.plugin.name
592 ));
593 }
594 }
595 ManifestFormat::KimiJson => {
596 if !super::agent_plugin::is_kimi_plugin_name(&self.plugin.name) {
597 return Err(format!(
598 "plugin name `{}` violates the Kimi plugin name rule",
599 self.plugin.name
600 ));
601 }
602 }
603 ManifestFormat::Toml => validate_plugin_name(&self.plugin.name)?,
604 }
605 Version::parse(self.plugin.version.trim()).map_err(|e| {
606 format!(
607 "plugin version `{}` is not valid semantic versioning: {e}",
608 self.plugin.version
609 )
610 })?;
611 validate_optional_text("description", self.plugin.description.as_deref(), 1_024)?;
612 validate_optional_text("author", self.plugin.author.as_deref(), 256)?;
613 validate_optional_text("display name", self.plugin.display_name.as_deref(), 128)?;
614 if let Some(icon) = &self.plugin.icon {
615 validate_icon(icon)?;
616 }
617 validate_optional_text("homepage", self.plugin.homepage.as_deref(), 2_048)?;
618 validate_optional_text("repository", self.plugin.repository.as_deref(), 2_048)?;
619 validate_optional_text("license", self.plugin.license.as_deref(), 128)?;
620 validate_unique_texts("keyword", &self.plugin.keywords, 128)?;
621 validate_unique_texts("filesystem root", &self.capabilities.filesystem_roots, 512)?;
622 validate_unique_texts("network host", &self.capabilities.network_hosts, 253)?;
623 let declared_network_hosts = self
624 .capabilities
625 .network_hosts
626 .iter()
627 .map(|host| normalize_network_host(host))
628 .collect::<Result<BTreeSet<_>, _>>()?;
629 let remote_network_hosts = self
630 .mcp_servers
631 .as_ref()
632 .into_iter()
633 .flat_map(|servers| servers.values())
634 .filter_map(|server| server.url.as_deref())
635 .map(|url| {
636 reqwest::Url::parse(url)
637 .map_err(|_| "remote MCP URL is invalid".to_string())?
638 .host_str()
639 .map(str::to_string)
640 .ok_or_else(|| "remote MCP URL is missing a host".to_string())
641 })
642 .collect::<Result<BTreeSet<_>, _>>()?;
643 if declared_network_hosts != remote_network_hosts {
644 return Err(
645 "capabilities.network_hosts must exactly match the normalized host set of all remote MCP endpoints"
646 .to_string(),
647 );
648 }
649 if let Some(when) = &self.when {
650 if let Some(os_values) = &when.os {
651 validate_unique_texts("OS", os_values, 32)?;
652 const SUPPORTED: &[&str] = &[
653 "windows", "linux", "macos", "freebsd", "openbsd", "netbsd", "android", "ios",
654 ];
655 for os in os_values {
656 if !SUPPORTED.contains(&os.to_ascii_lowercase().as_str()) {
657 return Err(format!("unsupported OS selector `{os}`"));
658 }
659 }
660 }
661 if let Some(binaries) = &when.binaries {
662 validate_unique_texts("binary", binaries, 128)?;
663 for binary in binaries {
664 if binary.contains('/')
665 || binary.contains('\\')
666 || looks_windows_absolute(binary)
667 {
668 return Err(format!(
669 "binary condition `{binary}` must be a bare executable name"
670 ));
671 }
672 }
673 }
674 }
675 Ok(())
676 }
677
678 fn resolve_components(&self, root: &Path) -> Result<ResolvedPluginComponents, String> {
679 Ok(ResolvedPluginComponents {
680 skills: resolve_spec(root, "skills", self.skills.as_ref(), Some("skills"))?,
681 commands: resolve_spec(root, "commands", self.commands.as_ref(), None)?,
682 agents: resolve_spec(root, "agents", self.agents.as_ref(), None)?,
683 hooks: resolve_spec(root, "hooks", self.hooks.as_ref(), None)?,
684 lsp: resolve_spec(root, "lsp", self.lsp.as_ref(), None)?,
685 native: resolve_spec(root, "native", self.native.as_ref(), None)?,
686 })
687 }
688
689 pub(crate) fn validate_mcp_servers(&self, root: &Path) -> Result<(), String> {
690 let Some(servers) = &self.mcp_servers else {
691 return Ok(());
692 };
693 if servers.len() > MAX_COMPONENT_PATHS {
694 return Err(format!(
695 "manifest declares {} MCP servers; maximum is {MAX_COMPONENT_PATHS}",
696 servers.len()
697 ));
698 }
699 for (name, server) in servers {
700 validate_component_name("MCP server", name)?;
701 if server.args.len() > MAX_MCP_ARGS {
702 return Err(format!(
703 "MCP server `{name}` declares too many arguments; maximum is {MAX_MCP_ARGS}"
704 ));
705 }
706 if server.env.len() > MAX_MCP_ENV {
707 return Err(format!(
708 "MCP server `{name}` declares too many environment mappings; maximum is {MAX_MCP_ENV}"
709 ));
710 }
711 if server.env_headers.len() > MAX_MCP_HEADERS {
712 return Err(format!(
713 "MCP server `{name}` declares too many environment-backed headers; maximum is {MAX_MCP_HEADERS}"
714 ));
715 }
716 if server.scopes.len() > MAX_MCP_SCOPES {
717 return Err(format!(
718 "MCP server `{name}` declares too many OAuth scopes; maximum is {MAX_MCP_SCOPES}"
719 ));
720 }
721 if server.enabled_tools.len() > MAX_MCP_TOOL_FILTERS
722 || server.disabled_tools.len() > MAX_MCP_TOOL_FILTERS
723 {
724 return Err(format!(
725 "MCP server `{name}` declares too many tool filters; maximum is {MAX_MCP_TOOL_FILTERS} per list"
726 ));
727 }
728 for (label, timeout) in [
729 ("connect_timeout", server.connect_timeout),
730 ("execute_timeout", server.execute_timeout),
731 ("read_timeout", server.read_timeout),
732 ] {
733 if timeout.is_some_and(|seconds| !(1..=MAX_MCP_TIMEOUT_SECS).contains(&seconds)) {
734 return Err(format!(
735 "MCP server `{name}` {label} must be 1-{MAX_MCP_TIMEOUT_SECS} seconds"
736 ));
737 }
738 }
739 if server.required && !server.enabled {
740 return Err(format!(
741 "MCP server `{name}` cannot be required while disabled"
742 ));
743 }
744 for arg in &server.args {
745 validate_text("MCP argument", arg, 4_096)?;
746 }
747 validate_unique_texts("enabled MCP tool", &server.enabled_tools, 256)?;
748 validate_unique_texts("disabled MCP tool", &server.disabled_tools, 256)?;
749 if server.enabled_tools.iter().any(|tool| {
750 server
751 .disabled_tools
752 .iter()
753 .any(|disabled| disabled == tool)
754 }) {
755 return Err(format!(
756 "MCP server `{name}` declares a tool in both enabled_tools and disabled_tools"
757 ));
758 }
759 match (server.command.as_deref(), server.url.as_deref()) {
760 (Some(command), None) => {
761 validate_text("MCP command", command, 512)?;
762 if server.transport.is_some()
763 || !server.headers.is_empty()
764 || !server.env_headers.is_empty()
765 || server.bearer_token_env_var.is_some()
766 || !server.scopes.is_empty()
767 || server.oauth.is_some()
768 || server.oauth_resource.is_some()
769 {
770 return Err(format!(
771 "stdio MCP server `{name}` may not declare remote transport or authentication fields"
772 ));
773 }
774 if command.contains('/') || command.contains('\\') {
775 let resolved = resolve_contained_path(root, command, "MCP command")?;
776 if !resolved.is_file() {
777 return Err(format!(
778 "MCP server `{name}` command is not a regular file"
779 ));
780 }
781 } else {
782 validate_bare_executable(command)?;
783 }
784 if let Some(cwd) = server.cwd.as_deref() {
785 let raw = cwd.to_string_lossy();
786 let resolved = resolve_contained_path(root, &raw, "MCP cwd")?;
787 if !resolved.is_dir() {
788 return Err(format!(
789 "MCP server `{name}` cwd is not a directory: {}",
790 resolved.display()
791 ));
792 }
793 }
794 validate_mcp_argv_has_no_literal_credentials(name, &server.args)?;
795 for (index, arg) in server.args.iter().enumerate() {
796 if Path::new(arg).is_absolute() || looks_windows_absolute(arg) {
797 return Err(format!(
798 "MCP server `{name}` argument #{} must not use an absolute path",
799 index + 1
800 ));
801 }
802 if is_relative_stdio_path_arg(arg)
803 && Path::new(arg)
804 .components()
805 .any(|part| matches!(part, Component::ParentDir))
806 {
807 return Err(format!(
808 "MCP server `{name}` argument #{} escapes the plugin root",
809 index + 1
810 ));
811 }
812 }
813 for (destination, source) in &server.env {
814 validate_environment_name("MCP environment destination", destination)?;
815 let source = exact_environment_placeholder(source).ok_or_else(|| {
816 format!(
817 "MCP server `{name}` environment values must be exact `${{SOURCE_ENV}}` references"
818 )
819 })?;
820 validate_environment_name("MCP environment source", source)?;
821 }
822 }
823 (None, Some(url)) => {
824 if !server.scopes.is_empty()
825 || server.oauth.is_some()
826 || server.oauth_resource.is_some()
827 {
828 return Err(format!(
829 "remote MCP server `{name}` may not declare OAuth fields because plugin OAuth is disabled; use env_headers or bearer_token_env_var"
830 ));
831 }
832 let parsed = reqwest::Url::parse(url)
833 .map_err(|e| format!("MCP server `{name}` URL is invalid: {e}"))?;
834 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
835 return Err(format!(
836 "MCP server `{name}` URL must use http or https and include a host"
837 ));
838 }
839 if parsed.scheme() == "http"
840 && !parsed.host_str().is_some_and(|host| {
841 host.eq_ignore_ascii_case("localhost")
842 || host
843 .parse::<std::net::IpAddr>()
844 .is_ok_and(|address| address.is_loopback())
845 })
846 {
847 return Err(format!(
848 "MCP server `{name}` URL must use HTTPS unless it targets loopback"
849 ));
850 }
851 if !parsed.username().is_empty() || parsed.password().is_some() {
852 return Err(format!(
853 "MCP server `{name}` URL must not embed credentials; use environment-backed authentication"
854 ));
855 }
856 if parsed.query().is_some() || parsed.fragment().is_some() {
857 return Err(format!(
858 "MCP server `{name}` URL may not contain a query or fragment"
859 ));
860 }
861 if server.cwd.is_some() || !server.args.is_empty() || !server.env.is_empty() {
862 return Err(format!(
863 "remote MCP server `{name}` may not declare stdio cwd, args, or env"
864 ));
865 }
866 if !server.headers.is_empty() {
867 return Err(format!(
868 "remote MCP server `{name}` may not contain literal headers; use env_headers or bearer_token_env_var"
869 ));
870 }
871 if let Some(transport) = server.transport.as_deref() {
872 validate_text("MCP transport", transport, 32)?;
873 if !transport.eq_ignore_ascii_case("sse") {
874 return Err(format!(
875 "MCP server `{name}` transport must be `sse` when explicitly set"
876 ));
877 }
878 }
879 for (header, env_var) in &server.env_headers {
880 validate_http_header_name(header)?;
881 validate_environment_name("MCP header environment source", env_var)?;
882 }
883 if let Some(env_var) = server.bearer_token_env_var.as_deref() {
884 validate_environment_name("MCP bearer environment source", env_var)?;
885 }
886 validate_unique_texts("OAuth scope", &server.scopes, 256)?;
887 if let Some(oauth) = &server.oauth
888 && let Some(client_id) = oauth.client_id.as_deref()
889 {
890 validate_text("OAuth client id", client_id, 512)?;
891 }
892 if let Some(resource) = server.oauth_resource.as_deref() {
893 validate_safe_oauth_resource(resource)?;
894 }
895 }
896 (Some(_), Some(_)) => {
897 return Err(format!(
898 "MCP server `{name}` must declare exactly one of command or url"
899 ));
900 }
901 (None, None) => {
902 return Err(format!(
903 "MCP server `{name}` must declare exactly one of command or url"
904 ));
905 }
906 }
907 }
908 Ok(())
909 }
910
911 fn inventory(&self, components: &ResolvedPluginComponents) -> Result<PluginInventory, String> {
912 let stdio_mcp_servers = self.mcp_servers.as_ref().map_or(0, |servers| {
913 servers
914 .values()
915 .filter(|server| server.command.is_some() && server.url.is_none())
916 .count()
917 });
918 let remote_mcp_servers = self.mcp_servers.as_ref().map_or(0, |servers| {
919 servers
920 .values()
921 .filter(|server| server.url.is_some() && server.command.is_none())
922 .count()
923 });
924 let mut network_hosts = self
925 .capabilities
926 .network_hosts
927 .iter()
928 .map(|host| host.to_ascii_lowercase())
929 .collect::<Vec<_>>();
930 if let Some(servers) = &self.mcp_servers {
931 for server in servers.values() {
932 if let Some(url) = server.url.as_deref()
933 && let Ok(url) = reqwest::Url::parse(url)
934 && let Some(host) = url.host_str()
935 {
936 network_hosts.push(host.to_ascii_lowercase());
937 }
938 }
939 }
940 network_hosts.sort();
941 network_hosts.dedup();
942
943 let mut filesystem_roots = self.capabilities.filesystem_roots.clone();
944 filesystem_roots.sort();
945 filesystem_roots.dedup();
946
947 Ok(PluginInventory {
948 skills: components.skills.len(),
949 mcp_servers: self.mcp_servers.as_ref().map_or(0, HashMap::len),
950 stdio_mcp_servers,
951 remote_mcp_servers,
952 commands: components.commands.len(),
953 agents: components.agents.len(),
954 hooks: components.hooks.len(),
955 lsp: components.lsp.len(),
956 native: components.native.len(),
957 filesystem_roots,
958 network_hosts,
959 lifecycle_mutation: self.capabilities.lifecycle_mutation,
960 })
961 }
962
963 #[must_use]
964 pub fn check_when(&self) -> bool {
965 let Some(when) = &self.when else {
966 return true;
967 };
968 if let Some(os_list) = &when.os {
969 let os = std::env::consts::OS;
970 if !os_list
971 .iter()
972 .any(|candidate| candidate.eq_ignore_ascii_case(os))
973 {
974 return false;
975 }
976 }
977 if let Some(binaries) = &when.binaries {
978 for binary in binaries {
979 if !Self::has_binary(binary) {
980 return false;
981 }
982 }
983 }
984 true
985 }
986
987 fn has_binary(name: &str) -> bool {
988 let paths = std::env::var_os("PATH").unwrap_or_default();
989 for path in std::env::split_paths(&paths) {
990 let candidate = path.join(name);
991 if candidate.is_file() {
992 return true;
993 }
994 #[cfg(windows)]
995 if candidate.with_extension("exe").is_file() {
996 return true;
997 }
998 }
999 false
1000 }
1001 }
1002
1003 fn safe_toml_parse_error(error: &toml::de::Error) -> String {
1004 // `Display` includes source excerpts and can echo a malformed literal
1005 // secret. Byte location is enough to repair the file without copying
1006 // manifest values into logs, diagnostics, or transcripts.
1007 error.span().map_or_else(
1008 || "failed to parse plugin.toml; check the v1 schema and field types".to_string(),
1009 |span| {
1010 format!(
1011 "failed to parse plugin.toml near bytes {}..{}; check the v1 schema and field types",
1012 span.start, span.end
1013 )
1014 },
1015 )
1016 }
1017
1018 fn read_manifest_bytes(path: &Path, label: &str) -> Result<Vec<u8>, String> {
1019 let file = open_bundle_file(path)
1020 .map_err(|e| format!("failed to open {label} without following links: {e}"))?;
1021 let mut bytes = Vec::new();
1022 file.take(MAX_MANIFEST_BYTES + 1)
1023 .read_to_end(&mut bytes)
1024 .map_err(|e| format!("failed to read {label}: {e}"))?;
1025 if bytes.len() as u64 > MAX_MANIFEST_BYTES {
1026 return Err(format!(
1027 "{label} exceeds the {MAX_MANIFEST_BYTES}-byte review limit"
1028 ));
1029 }
1030 Ok(bytes)
1031 }
1032
1033 /// The historical Codewhale `plugin.toml` name rule: lowercase ASCII letters,
1034 /// digits, and internal hyphens (including `--` runs). Kept byte-for-byte for
1035 /// legacy manifests; `/plugin export` slugifies names that are invalid under
1036 /// the Agent Plugins standard. `plugin.json` manifests are held to the
1037 /// standard's rule instead ([`super::agent_plugin::is_standard_plugin_name`]):
1038 /// it also allows internal dots but bans `--` and `..`.
1039 pub fn validate_plugin_name(name: &str) -> Result<(), String> {
1040 let count = name.chars().count();
1041 let valid = count > 0
1042 && count <= MAX_PLUGIN_NAME_CHARS
1043 && name
1044 .chars()
1045 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
1046 && name
1047 .chars()
1048 .next()
1049 .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
1050 && name
1051 .chars()
1052 .last()
1053 .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit());
1054 if valid {
1055 Ok(())
1056 } else {
1057 Err(format!(
1058 "plugin name `{name}` must be 1-{MAX_PLUGIN_NAME_CHARS} lowercase ASCII letters, digits, or internal hyphens"
1059 ))
1060 }
1061 }
1062
1063 fn validate_component_name(kind: &str, name: &str) -> Result<(), String> {
1064 let count = name.chars().count();
1065 let valid = count > 0
1066 && count <= MAX_PLUGIN_NAME_CHARS
1067 && name
1068 .chars()
1069 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
1070 && !name.starts_with(['-', '_'])
1071 && !name.ends_with(['-', '_']);
1072 if valid {
1073 Ok(())
1074 } else {
1075 Err(format!("{kind} name `{name}` is invalid"))
1076 }
1077 }
1078
1079 fn validate_optional_text(
1080 field: &str,
1081 value: Option<&str>,
1082 max_chars: usize,
1083 ) -> Result<(), String> {
1084 if let Some(value) = value {
1085 validate_text(field, value, max_chars)?;
1086 }
1087 Ok(())
1088 }
1089
1090 fn validate_mcp_argv_has_no_literal_credentials(
1091 server_name: &str,
1092 arguments: &[String],
1093 ) -> Result<(), String> {
1094 for (index, argument) in arguments.iter().enumerate() {
1095 let (key, assigned_value) = argument
1096 .split_once('=')
1097 .map_or((argument.as_str(), None), |(key, value)| (key, Some(value)));
1098 if credential_argument_key(key)
1099 && (assigned_value.is_some_and(|value| !value.is_empty())
1100 || (assigned_value.is_none() && arguments.get(index + 1).is_some()))
1101 {
1102 return Err(format!(
1103 "MCP server `{server_name}` argument #{} embeds a credential-bearing value; pass credentials through a reviewed environment mapping instead",
1104 index + 1
1105 ));
1106 }
1107 if looks_like_literal_credential(argument) {
1108 return Err(format!(
1109 "MCP server `{server_name}` argument #{} looks like a literal credential; pass credentials through a reviewed environment mapping instead",
1110 index + 1
1111 ));
1112 }
1113 }
1114 Ok(())
1115 }
1116
1117 fn credential_argument_key(value: &str) -> bool {
1118 let key = value
1119 .trim_start_matches('-')
1120 .replace('_', "-")
1121 .to_ascii_lowercase();
1122 [
1123 "token",
1124 "api-key",
1125 "apikey",
1126 "password",
1127 "passwd",
1128 "secret",
1129 "client-secret",
1130 "authorization",
1131 "auth-token",
1132 "access-key",
1133 "private-key",
1134 "credential",
1135 "credentials",
1136 ]
1137 .iter()
1138 .any(|sensitive| key == *sensitive || key.ends_with(&format!("-{sensitive}")))
1139 }
1140
1141 fn looks_like_literal_credential(value: &str) -> bool {
1142 let trimmed = value.trim();
1143 trimmed.starts_with("sk-")
1144 || trimmed.starts_with("ghp_")
1145 || trimmed.starts_with("github_pat_")
1146 || trimmed.starts_with("xoxb-")
1147 || trimmed.starts_with("xoxp-")
1148 || (trimmed.starts_with("AKIA") && trimmed.len() >= 16)
1149 }
1150
1151 fn validate_text(field: &str, value: &str, max_chars: usize) -> Result<(), String> {
1152 let trimmed = value.trim();
1153 if trimmed.is_empty() || trimmed.chars().count() > max_chars {
1154 return Err(format!(
1155 "{field} must contain 1-{max_chars} non-whitespace characters"
1156 ));
1157 }
1158 if value.chars().any(char::is_control) {
1159 return Err(format!("{field} may not contain control characters"));
1160 }
1161 if value.chars().any(is_bidi_control) {
1162 return Err(format!(
1163 "{field} may not contain bidirectional formatting characters"
1164 ));
1165 }
1166 Ok(())
1167 }
1168
1169 fn is_bidi_control(ch: char) -> bool {
1170 matches!(
1171 ch,
1172 '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
1173 )
1174 }
1175
1176 fn validate_unique_texts(field: &str, values: &[String], max_chars: usize) -> Result<(), String> {
1177 if values.len() > MAX_COMPONENT_PATHS {
1178 return Err(format!(
1179 "too many {field} values; maximum is {MAX_COMPONENT_PATHS}"
1180 ));
1181 }
1182 let mut seen = BTreeSet::new();
1183 for value in values {
1184 validate_text(field, value, max_chars)?;
1185 let normalized = value.to_ascii_lowercase();
1186 if !seen.insert(normalized) {
1187 return Err(format!("duplicate {field} value `{value}`"));
1188 }
1189 }
1190 Ok(())
1191 }
1192
1193 fn normalize_network_host(host: &str) -> Result<String, String> {
1194 if host.contains("://") || host.contains('/') || host.contains('\\') {
1195 return Err(format!(
1196 "network host `{host}` must be a host name, not a URL or path"
1197 ));
1198 }
1199 let parsed = reqwest::Url::parse(&format!("https://{host}"))
1200 .map_err(|e| format!("network host `{host}` is invalid: {e}"))?;
1201 if parsed.port().is_some()
1202 || !parsed.username().is_empty()
1203 || parsed.password().is_some()
1204 || parsed.path() != "/"
1205 || parsed.query().is_some()
1206 || parsed.fragment().is_some()
1207 {
1208 return Err(format!(
1209 "network host `{host}` must contain only a normalized host name"
1210 ));
1211 }
1212 parsed
1213 .host_str()
1214 .map(|host| host.to_ascii_lowercase())
1215 .ok_or_else(|| format!("network host `{host}` is invalid"))
1216 }
1217
1218 fn validate_bare_executable(command: &str) -> Result<(), String> {
1219 if command.len() <= 128
1220 && command
1221 .chars()
1222 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+'))
1223 && !matches!(command, "." | "..")
1224 {
1225 Ok(())
1226 } else {
1227 Err("MCP command must be a bare executable name or a contained plugin path".to_string())
1228 }
1229 }
1230
1231 fn validate_environment_name(field: &str, value: &str) -> Result<(), String> {
1232 validate_text(field, value, 128)?;
1233 if value
1234 .chars()
1235 .next()
1236 .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_')
1237 && value
1238 .chars()
1239 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
1240 {
1241 Ok(())
1242 } else {
1243 Err(format!(
1244 "{field} must be an ASCII environment variable name"
1245 ))
1246 }
1247 }
1248
1249 pub(super) fn exact_environment_placeholder(value: &str) -> Option<&str> {
1250 value.strip_prefix("${")?.strip_suffix('}')
1251 }
1252
1253 fn validate_http_header_name(name: &str) -> Result<(), String> {
1254 validate_text("MCP HTTP header name", name, 128)?;
1255 reqwest::header::HeaderName::from_bytes(name.as_bytes())
1256 .map(|_| ())
1257 .map_err(|_| "MCP HTTP header name is invalid".to_string())
1258 }
1259
1260 fn validate_safe_oauth_resource(resource: &str) -> Result<(), String> {
1261 validate_text("OAuth resource", resource, 2_048)?;
1262 let parsed = reqwest::Url::parse(resource)
1263 .map_err(|_| "OAuth resource must be an absolute HTTPS URL".to_string())?;
1264 if parsed.scheme() != "https"
1265 || parsed.host_str().is_none()
1266 || !parsed.username().is_empty()
1267 || parsed.password().is_some()
1268 || parsed.query().is_some()
1269 || parsed.fragment().is_some()
1270 {
1271 return Err(
1272 "OAuth resource must be an HTTPS URL without credentials, query, or fragment"
1273 .to_string(),
1274 );
1275 }
1276 Ok(())
1277 }
1278
1279 fn validate_nested_mcp_schema(content: &str) -> Result<(), String> {
1280 const SERVER_FIELDS: &[&str] = &[
1281 "command",
1282 "args",
1283 "env",
1284 "cwd",
1285 "url",
1286 "transport",
1287 "connect_timeout",
1288 "execute_timeout",
1289 "read_timeout",
1290 "enabled",
1291 "required",
1292 "enabled_tools",
1293 "disabled_tools",
1294 "headers",
1295 "env_headers",
1296 "env_http_headers",
1297 "bearer_token_env_var",
1298 "scopes",
1299 "oauth",
1300 "oauth_resource",
1301 ];
1302 let value: toml::Value =
1303 toml::from_str(content).map_err(|error| safe_toml_parse_error(&error))?;
1304 let Some(servers) = value.get("mcp_servers") else {
1305 return Ok(());
1306 };
1307 let servers = servers
1308 .as_table()
1309 .ok_or_else(|| "mcp_servers must be a table".to_string())?;
1310 for server in servers.values() {
1311 let server = server
1312 .as_table()
1313 .ok_or_else(|| "each MCP server must be a table".to_string())?;
1314 if server
1315 .keys()
1316 .any(|field| !SERVER_FIELDS.contains(&field.as_str()))
1317 {
1318 return Err("plugin MCP server contains an unsupported field".to_string());
1319 }
1320 if let Some(oauth) = server.get("oauth") {
1321 let oauth = oauth
1322 .as_table()
1323 .ok_or_else(|| "plugin MCP oauth must be a table".to_string())?;
1324 if oauth.keys().any(|field| field != "client_id") {
1325 return Err("plugin MCP oauth contains an unsupported field".to_string());
1326 }
1327 }
1328 }
1329 Ok(())
1330 }
1331
1332 fn resolve_spec(
1333 root: &Path,
1334 kind: &str,
1335 spec: Option<&PluginPathSpec>,
1336 default: Option<&str>,
1337 ) -> Result<Vec<PathBuf>, String> {
1338 let Some(spec) = spec else {
1339 return Ok(Vec::new());
1340 };
1341 spec.declared_paths(default)?
1342 .iter()
1343 .map(|path| resolve_contained_path(root, path, kind))
1344 .collect()
1345 }
1346
1347 fn resolve_contained_path(root: &Path, raw: &str, kind: &str) -> Result<PathBuf, String> {
1348 validate_text(&format!("{kind} path"), raw, 1_024)?;
1349 if Path::new(raw).is_absolute() || looks_windows_absolute(raw) {
1350 return Err(format!("{kind} path must be relative: `{raw}`"));
1351 }
1352 if Path::new(raw).components().any(|component| {
1353 matches!(
1354 component,
1355 Component::ParentDir | Component::RootDir | Component::Prefix(_)
1356 )
1357 }) {
1358 return Err(format!("{kind} path escapes the plugin root: `{raw}`"));
1359 }
1360 let joined = root.join(raw);
1361 reject_symlink_components(root, &joined, kind)?;
1362 let canonical = joined
1363 .canonicalize()
1364 .map_err(|e| format!("{kind} path `{raw}` cannot be resolved: {e}"))?;
1365 if !canonical.starts_with(root) {
1366 return Err(format!("{kind} path escapes the plugin root: `{raw}`"));
1367 }
1368 Ok(canonical)
1369 }
1370
1371 fn reject_symlink_components(root: &Path, target: &Path, kind: &str) -> Result<(), String> {
1372 let relative = target
1373 .strip_prefix(root)
1374 .map_err(|_| format!("{kind} path is outside the plugin root"))?;
1375 let mut cursor = root.to_path_buf();
1376 for component in relative.components() {
1377 cursor.push(component.as_os_str());
1378 let metadata = fs::symlink_metadata(&cursor)
1379 .map_err(|e| format!("failed to inspect {kind} path {}: {e}", cursor.display()))?;
1380 if metadata_is_link_or_reparse(&metadata) {
1381 return Err(format!(
1382 "{kind} path may not traverse symbolic link {}",
1383 cursor.display()
1384 ));
1385 }
1386 }
1387 Ok(())
1388 }
1389
1390 fn looks_windows_absolute(raw: &str) -> bool {
1391 let bytes = raw.as_bytes();
1392 bytes
1393 .first()
1394 .is_some_and(|byte| matches!(*byte, b'\\' | b'/'))
1395 || (bytes.len() >= 3
1396 && bytes[0].is_ascii_alphabetic()
1397 && bytes[1] == b':'
1398 && matches!(bytes[2], b'\\' | b'/'))
1399 }
1400
1401 fn hash_bundle(
1402 root: &Path,
1403 manifest_bytes: &[u8],
1404 manifest_label: &str,
1405 native_entries: &[PathBuf],
1406 ) -> Result<(String, HashBudget), String> {
1407 let mut hasher = Sha256::new();
1408 // v2 length-frames every variable-length field. The v1 delimiter-only
1409 // stream was structurally ambiguous across file-record boundaries.
1410 // Changing the domain invalidates every ambiguous v1 receipt.
1411 // The manifest file name is part of the domain: `plugin.toml` produces the
1412 // exact v2 byte stream existing receipts bind to, while `plugin.json`
1413 // starts a fresh receipt family.
1414 hasher.update(b"codewhale-plugin-content-v2\0");
1415 hasher.update(manifest_label.as_bytes());
1416 hasher.update(b"\0");
1417 hasher.update((manifest_bytes.len() as u64).to_le_bytes());
1418 hasher.update(manifest_bytes);
1419 let native_paths = native_entries
1420 .iter()
1421 .map(|entry| {
1422 entry
1423 .strip_prefix(root)
1424 .map(Path::to_path_buf)
1425 .map_err(|_| "Native entry escaped the reviewed bundle".to_string())
1426 })
1427 .collect::<Result<BTreeSet<_>, _>>()?;
1428 let mut budget = HashBudget {
1429 native_paths,
1430 ..Default::default()
1431 };
1432 // Hash the complete bundle, not only declared component roots. Local MCP
1433 // entrypoints and companion assets are security-relevant even when they do
1434 // not have a separate component table.
1435 hash_path(root, root, &mut hasher, &mut budget)?;
1436 Ok((hex_digest(hasher.finalize()), budget))
1437 }
1438
1439 #[derive(Default)]
1440 struct HashBudget {
1441 files: usize,
1442 bytes: u64,
1443 file_hashes: BTreeMap<PathBuf, String>,
1444 native_paths: BTreeSet<PathBuf>,
1445 native_entry_hashes: BTreeMap<PathBuf, String>,
1446 }
1447
1448 fn hash_path(
1449 root: &Path,
1450 path: &Path,
1451 hasher: &mut Sha256,
1452 budget: &mut HashBudget,
1453 ) -> Result<(), String> {
1454 let metadata = fs::symlink_metadata(path)
1455 .map_err(|e| format!("failed to inspect component {}: {e}", path.display()))?;
1456 if metadata_is_link_or_reparse(&metadata) {
1457 return Err(format!(
1458 "component trees may not contain symbolic link {}",
1459 path.display()
1460 ));
1461 }
1462 let relative = path
1463 .strip_prefix(root)
1464 .map_err(|_| format!("component {} is outside the plugin root", path.display()))?;
1465 hash_permissions(&metadata, hasher);
1466 if metadata.is_dir() {
1467 #[cfg(windows)]
1468 let directory_guard = open_bundle_directory(path)
1469 .map_err(|e| format!("failed to open component directory safely: {e}"))?;
1470 #[cfg(windows)]
1471 ensure_windows_path_still_opened(path, &directory_guard)?;
1472 hasher.update(b"D\0");
1473 super::path_identity::hash_os_path(hasher, b"bundle-relative-directory", relative);
1474 let mut entries = fs::read_dir(path)
1475 .map_err(|e| format!("failed to read component directory {}: {e}", path.display()))?
1476 .collect::<Result<Vec<_>, _>>()
1477 .map_err(|e| format!("failed to read component directory {}: {e}", path.display()))?;
1478 entries.sort_by_key(fs::DirEntry::file_name);
1479 for entry in entries {
1480 hash_path(root, &entry.path(), hasher, budget)?;
1481 }
1482 hasher.update(b"E\0");
1483 #[cfg(windows)]
1484 ensure_windows_path_still_opened(path, &directory_guard)?;
1485 } else if metadata.is_file() {
1486 budget.files += 1;
1487 if budget.files > MAX_HASHED_FILES {
1488 return Err(format!(
1489 "plugin bundle content exceeds the plugin review limit ({MAX_HASHED_FILES} files / {MAX_HASHED_BYTES} bytes)"
1490 ));
1491 }
1492 hasher.update(b"F\0");
1493 super::path_identity::hash_os_path(hasher, b"bundle-relative-file", relative);
1494 let mut file = open_bundle_file(path)
1495 .map_err(|e| format!("failed to read component file {}: {e}", path.display()))?;
1496 #[cfg(windows)]
1497 ensure_windows_path_still_opened(path, &file)?;
1498 let expected_len = file
1499 .metadata()
1500 .map_err(|e| format!("failed to inspect component file {}: {e}", path.display()))?
1501 .len();
1502 hasher.update(expected_len.to_le_bytes());
1503 let mut file_hasher = Sha256::new();
1504 file_hasher.update(b"codewhale-plugin-file-bytes-v1\0");
1505 let mut native_entry_hasher = budget.native_paths.contains(relative).then(Sha256::new);
1506 // Keep the read buffer off the stack. `hash_path` is recursive and the
1507 // fixed-size array inflated every directory frame, which could exhaust
1508 // a Tokio worker stack while revalidating a nested plugin bundle.
1509 let mut buffer = vec![0_u8; 64 * 1024];
1510 let mut actual_len = 0_u64;
1511 loop {
1512 let read = file
1513 .read(&mut buffer)
1514 .map_err(|e| format!("failed to read component file {}: {e}", path.display()))?;
1515 if read == 0 {
1516 break;
1517 }
1518 actual_len = actual_len.saturating_add(read as u64);
1519 budget.bytes = budget.bytes.saturating_add(read as u64);
1520 if budget.bytes > MAX_HASHED_BYTES {
1521 return Err(format!(
1522 "plugin bundle content exceeds the plugin review limit ({MAX_HASHED_FILES} files / {MAX_HASHED_BYTES} bytes)"
1523 ));
1524 }
1525 hasher.update(&buffer[..read]);
1526 file_hasher.update(&buffer[..read]);
1527 if let Some(entry_hasher) = &mut native_entry_hasher {
1528 entry_hasher.update(&buffer[..read]);
1529 }
1530 }
1531 if actual_len != expected_len {
1532 return Err(format!(
1533 "component file {} changed length while being reviewed",
1534 path.display()
1535 ));
1536 }
1537 budget
1538 .file_hashes
1539 .insert(relative.to_path_buf(), hex_digest(file_hasher.finalize()));
1540 if let Some(entry_hasher) = native_entry_hasher {
1541 budget
1542 .native_entry_hashes
1543 .insert(relative.to_path_buf(), hex_digest(entry_hasher.finalize()));
1544 }
1545 #[cfg(windows)]
1546 ensure_windows_path_still_opened(path, &file)?;
1547 } else {
1548 return Err(format!(
1549 "component {} is neither a regular file nor directory",
1550 path.display()
1551 ));
1552 }
1553 Ok(())
1554 }
1555
1556 #[cfg(unix)]
1557 fn hash_permissions(metadata: &fs::Metadata, hasher: &mut Sha256) {
1558 use std::os::unix::fs::PermissionsExt;
1559
1560 // Runtime snapshots deliberately remove group/other access and write bits.
1561 // Bind identity only to whether a regular file is executable, so the
1562 // owner-only staged representation has the same reviewed content hash.
1563 hasher.update(b"unix-executable\0");
1564 hasher.update([u8::from(
1565 metadata.is_file() && metadata.permissions().mode() & 0o111 != 0,
1566 )]);
1567 }
1568
1569 #[cfg(not(unix))]
1570 fn hash_permissions(metadata: &fs::Metadata, hasher: &mut Sha256) {
1571 let _ = metadata;
1572 // Windows staging marks files read-only as a defense-in-depth hardening
1573 // step; that representation change is not plugin content identity.
1574 hasher.update(b"portable-mode\0");
1575 }
1576
1577 #[cfg(unix)]
1578 pub(crate) fn open_bundle_file(path: &Path) -> std::io::Result<fs::File> {
1579 use std::os::unix::fs::OpenOptionsExt;
1580
1581 fs::OpenOptions::new()
1582 .read(true)
1583 .custom_flags(libc::O_NOFOLLOW)
1584 .open(path)
1585 }
1586
1587 #[cfg(windows)]
1588 pub(crate) fn open_bundle_file(path: &Path) -> std::io::Result<fs::File> {
1589 open_bundle_file_with_access(
1590 path,
1591 windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ,
1592 )
1593 }
1594
1595 /// Same protected reader, retaining the right to move this exact opened object.
1596 #[cfg(windows)]
1597 pub(crate) fn open_bundle_file_for_retirement(path: &Path) -> std::io::Result<fs::File> {
1598 use windows_sys::Win32::Storage::FileSystem::{DELETE, FILE_GENERIC_READ};
1599 open_bundle_file_with_access(path, FILE_GENERIC_READ | DELETE)
1600 }
1601
1602 #[cfg(windows)]
1603 fn open_bundle_file_with_access(path: &Path, access: u32) -> std::io::Result<fs::File> {
1604 use std::os::windows::fs::OpenOptionsExt as _;
1605
1606 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1607 let file = fs::OpenOptions::new()
1608 .access_mode(access)
1609 .share_mode(0x0000_0001) // deny concurrent writes and replacement
1610 .custom_flags(0x0020_0000) // FILE_FLAG_OPEN_REPARSE_POINT
1611 .open(path)?;
1612 let metadata = file.metadata()?;
1613 let identity = windows_file_identity(&file)?;
1614 if !metadata.is_file()
1615 || identity.attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
1616 || identity.links != 1
1617 {
1618 return Err(std::io::Error::new(
1619 std::io::ErrorKind::InvalidData,
1620 "plugin file is a reparse point, hard link, or non-regular file",
1621 ));
1622 }
1623 Ok(file)
1624 }
1625
1626 #[cfg(all(not(unix), not(windows)))]
1627 pub(crate) fn open_bundle_file(path: &Path) -> std::io::Result<fs::File> {
1628 fs::File::open(path)
1629 }
1630
1631 #[cfg(windows)]
1632 fn open_bundle_directory(path: &Path) -> std::io::Result<fs::File> {
1633 use std::os::windows::fs::OpenOptionsExt as _;
1634
1635 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1636 let file = fs::OpenOptions::new()
1637 .read(true)
1638 .share_mode(0x0000_0001)
1639 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
1640 .open(path)?;
1641 let metadata = file.metadata()?;
1642 let identity = windows_file_identity(&file)?;
1643 if !metadata.is_dir() || identity.attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1644 return Err(std::io::Error::new(
1645 std::io::ErrorKind::InvalidData,
1646 "plugin directory is a reparse point or non-directory",
1647 ));
1648 }
1649 Ok(file)
1650 }
1651
1652 /// Reopen a reviewed path only to compare its current handle identity with a
1653 /// retained authority handle. Desired access is zero and sharing is permissive
1654 /// because the retained handle remains responsible for denying writes and
1655 /// replacement throughout the comparison.
1656 #[cfg(windows)]
1657 pub(crate) fn open_bundle_identity_probe(
1658 path: &Path,
1659 expect_directory: bool,
1660 ) -> std::io::Result<fs::File> {
1661 use std::os::windows::fs::OpenOptionsExt as _;
1662
1663 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1664 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1665 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
1666 const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x0000_0007;
1667 let flags = FILE_FLAG_OPEN_REPARSE_POINT
1668 | if expect_directory {
1669 FILE_FLAG_BACKUP_SEMANTICS
1670 } else {
1671 0
1672 };
1673 let file = fs::OpenOptions::new()
1674 .access_mode(0)
1675 .share_mode(FILE_SHARE_READ_WRITE_DELETE)
1676 .custom_flags(flags)
1677 .open(path)?;
1678 let metadata = file.metadata()?;
1679 let identity = windows_file_identity(&file)?;
1680 let expected_kind = if expect_directory {
1681 metadata.is_dir()
1682 } else {
1683 metadata.is_file() && identity.links == 1
1684 };
1685 if !expected_kind || identity.attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1686 return Err(std::io::Error::new(
1687 std::io::ErrorKind::InvalidData,
1688 "plugin identity probe found a reparse point, hard link, or unexpected object",
1689 ));
1690 }
1691 Ok(file)
1692 }
1693
1694 #[cfg(windows)]
1695 fn ensure_windows_path_still_opened(path: &Path, opened: &fs::File) -> Result<(), String> {
1696 let after = fs::symlink_metadata(path)
1697 .map_err(|e| format!("failed to re-inspect plugin path after handle open: {e}"))?;
1698 if metadata_is_link_or_reparse(&after) {
1699 return Err("plugin path changed into a reparse point during validation".to_string());
1700 }
1701 let expect_directory = if after.is_dir() {
1702 true
1703 } else if after.is_file() {
1704 false
1705 } else {
1706 return Err("plugin path changed into an unsupported object during validation".to_string());
1707 };
1708 let current = open_bundle_identity_probe(path, expect_directory)
1709 .map_err(|e| format!("failed to reopen plugin path for identity validation: {e}"))?;
1710 let opened = windows_file_identity(opened)
1711 .map_err(|e| format!("failed to identify retained plugin handle: {e}"))?;
1712 let current = windows_file_identity(&current)
1713 .map_err(|e| format!("failed to identify current plugin path: {e}"))?;
1714 if opened.volume != current.volume || opened.index != current.index {
1715 return Err("plugin path identity changed between handle open and validation".to_string());
1716 }
1717 Ok(())
1718 }
1719
1720 fn hash_inventory(inventory: &PluginInventory) -> String {
1721 hash_inventory_with_policy(inventory, PluginActivationPolicy::current())
1722 }
1723
1724 fn hash_inventory_counts(inventory: &PluginInventory) -> BTreeMap<&'static str, String> {
1725 let mut normalized = BTreeMap::new();
1726 normalized.insert("skills", inventory.skills.to_string());
1727 normalized.insert("mcp", inventory.mcp_servers.to_string());
1728 normalized.insert("mcp-stdio", inventory.stdio_mcp_servers.to_string());
1729 normalized.insert("mcp-remote", inventory.remote_mcp_servers.to_string());
1730 normalized.insert("commands", inventory.commands.to_string());
1731 normalized.insert("agents", inventory.agents.to_string());
1732 normalized.insert("hooks", inventory.hooks.to_string());
1733 normalized.insert("lsp", inventory.lsp.to_string());
1734 normalized.insert("native", inventory.native.to_string());
1735 normalized.insert("filesystem", inventory.filesystem_roots.join("\n"));
1736 normalized.insert("network", inventory.network_hosts.join("\n"));
1737 normalized.insert("lifecycle", inventory.lifecycle_mutation.to_string());
1738 normalized
1739 }
1740
1741 fn hash_inventory_with_policy(
1742 inventory: &PluginInventory,
1743 policy: PluginActivationPolicy,
1744 ) -> String {
1745 let mut hasher = Sha256::new();
1746 policy.write_hash_material(&mut hasher);
1747 for (key, value) in hash_inventory_counts(inventory) {
1748 hasher.update(key.as_bytes());
1749 hasher.update(b"\0");
1750 hasher.update(value.as_bytes());
1751 hasher.update(b"\0");
1752 }
1753 hex_digest(hasher.finalize())
1754 }
1755
1756 /// Pre-policy capability digest. Tests use this to prove that a v1 trust
1757 /// receipt cannot match the current capability hash.
1758 pub(crate) fn capability_hash_v1(inventory: &PluginInventory) -> String {
1759 let mut hasher = Sha256::new();
1760 hasher.update(CAPABILITY_HASH_DOMAIN_V1);
1761 for (key, value) in hash_inventory_counts(inventory) {
1762 hasher.update(key.as_bytes());
1763 hasher.update(b"\0");
1764 hasher.update(value.as_bytes());
1765 hasher.update(b"\0");
1766 }
1767 hex_digest(hasher.finalize())
1768 }
1769
1770 /// Historical v2 capability digest. Kept only to prove that receipts from the
1771 /// Skills/MCP-only activation policy fail closed when v3 enables additional
1772 /// declarative adapters.
1773 /// Catalog and manifest artwork follows one inert, bounded wire format.
1774 pub fn validate_icon(value: &str) -> Result<(), String> {
1775 use base64::Engine;
1776 if value.len() > 32_768 {
1777 return Err("plugin icon exceeds 32 KiB".into());
1778 }
1779 let encoded = value
1780 .strip_prefix("data:image/png;base64,")
1781 .ok_or("plugin icon must be an inline PNG")?;
1782 let bytes = base64::engine::general_purpose::STANDARD
1783 .decode(encoded)
1784 .map_err(|_| "plugin icon has invalid base64")?;
1785 if bytes.len() < 33 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
1786 return Err("plugin icon has an invalid PNG header".into());
1787 }
1788 let width = u32::from_be_bytes(bytes[16..20].try_into().map_err(|_| "invalid PNG width")?);
1789 let height = u32::from_be_bytes(bytes[20..24].try_into().map_err(|_| "invalid PNG height")?);
1790 if width == 0 || height == 0 || width > 256 || height > 256 {
1791 return Err("plugin icon must fit within 256 by 256 pixels".into());
1792 }
1793 Ok(())
1794 }
1795
1796 #[cfg(test)]
1797 pub(crate) fn capability_hash_v2(inventory: &PluginInventory) -> String {
1798 let mut hasher = Sha256::new();
1799 hasher.update(CAPABILITY_HASH_DOMAIN_V2);
1800 hasher.update(b"policy-version\0");
1801 hasher.update(b"2\0");
1802 for capability in [
1803 PluginActivationCapability::Skills,
1804 PluginActivationCapability::McpStdio,
1805 PluginActivationCapability::McpRemote,
1806 ] {
1807 hasher.update(b"supported\0");
1808 hasher.update(capability.as_str().as_bytes());
1809 hasher.update(b"\0");
1810 }
1811 for capability in [
1812 PluginActivationCapability::Commands,
1813 PluginActivationCapability::Agents,
1814 PluginActivationCapability::Hooks,
1815 PluginActivationCapability::Lsp,
1816 PluginActivationCapability::Native,
1817 PluginActivationCapability::FilesystemRoots,
1818 PluginActivationCapability::LifecycleMutation,
1819 ] {
1820 hasher.update(b"inactive\0");
1821 hasher.update(capability.as_str().as_bytes());
1822 hasher.update(b"\0");
1823 }
1824 for (key, value) in hash_inventory_counts(inventory) {
1825 hasher.update(key.as_bytes());
1826 hasher.update(b"\0");
1827 hasher.update(value.as_bytes());
1828 hasher.update(b"\0");
1829 }
1830 hex_digest(hasher.finalize())
1831 }
1832
1833 #[cfg(test)]
1834 pub(crate) fn capability_hash_with_policy(
1835 inventory: &PluginInventory,
1836 policy: PluginActivationPolicy,
1837 ) -> String {
1838 hash_inventory_with_policy(inventory, policy)
1839 }
1840
1841 pub(super) fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
1842 let bytes = bytes.as_ref();
1843 let mut output = String::with_capacity(bytes.len() * 2);
1844 for byte in bytes {
1845 use std::fmt::Write as _;
1846 let _ = write!(output, "{byte:02x}");
1847 }
1848 output
1849 }
1850
1851 #[cfg(test)]
1852 mod tests {
1853 use super::*;
1854
1855 fn write_manifest(root: &Path, extra: &str) -> PathBuf {
1856 fs::create_dir_all(root.join("skills/example")).unwrap();
1857 fs::write(
1858 root.join("skills/example/SKILL.md"),
1859 "---\nname: example\ndescription: example\n---\nbody\n",
1860 )
1861 .unwrap();
1862 let path = root.join("plugin.toml");
1863 fs::write(
1864 &path,
1865 format!(
1866 "schema_version = 1\n[plugin]\nname = \"example-plugin\"\nversion = \"1.2.3\"\n[skills]\npath = \"skills\"\n{extra}"
1867 ),
1868 )
1869 .unwrap();
1870 path
1871 }
1872
1873 #[test]
1874 fn validates_versioned_manifest_and_hashes_declared_content() {
1875 let tmp = tempfile::tempdir().unwrap();
1876 let path = write_manifest(tmp.path(), "");
1877 let first = PluginManifest::validate_from_path(&path).unwrap();
1878 assert_eq!(first.inventory.skills, 1);
1879 assert!(first.warnings.is_empty());
1880
1881 fs::write(
1882 tmp.path().join("skills/example/SKILL.md"),
1883 "---\nname: example\ndescription: changed\n---\nbody\n",
1884 )
1885 .unwrap();
1886 let second = PluginManifest::validate_from_path(&path).unwrap();
1887 assert_ne!(first.content_hash, second.content_hash);
1888 assert_eq!(first.capability_hash, second.capability_hash);
1889 }
1890
1891 #[test]
1892 fn native_entry_receipt_is_plain_same_read_digest_without_changing_bundle_hashes() {
1893 let tmp = tempfile::tempdir().unwrap();
1894 fs::create_dir_all(tmp.path().join("native")).unwrap();
1895 let entry_bytes = b"export function apply() {}\n";
1896 fs::write(tmp.path().join("native/index.mjs"), entry_bytes).unwrap();
1897 fs::write(
1898 tmp.path().join("native/undeclared.mjs"),
1899 b"export const sidecar = 1",
1900 )
1901 .unwrap();
1902 let manifest = write_manifest(tmp.path(), "[native]\npath = \"native/index.mjs\"\n");
1903 let first = PluginManifest::validate_from_path(&manifest).unwrap();
1904 let relative = PathBuf::from("native/index.mjs");
1905 let plain = hex_digest(Sha256::digest(entry_bytes));
1906 let mut domain = Sha256::new();
1907 domain.update(b"codewhale-plugin-file-bytes-v1\0");
1908 domain.update(entry_bytes);
1909 assert_eq!(first.native_entry_hashes.len(), 1);
1910 assert_eq!(first.native_entry_hashes[&relative], plain);
1911 assert_eq!(first.file_hashes[&relative], hex_digest(domain.finalize()));
1912 assert_ne!(
1913 first.file_hashes[&relative],
1914 first.native_entry_hashes[&relative]
1915 );
1916 assert!(
1917 !first
1918 .native_entry_hashes
1919 .contains_key(Path::new("native/undeclared.mjs"))
1920 );
1921
1922 // The purpose-specific capture adds no bytes to the established
1923 // bundle domain or its complete regular-file inventory.
1924 let manifest_bytes = fs::read(&manifest).unwrap();
1925 let (without_entries, without_entry_hashes) =
1926 hash_bundle(&first.canonical_root, &manifest_bytes, "plugin.toml", &[]).unwrap();
1927 assert_eq!(first.content_hash, without_entries);
1928 assert_eq!(first.file_hashes, without_entry_hashes.file_hashes);
1929 assert!(without_entry_hashes.native_entry_hashes.is_empty());
1930
1931 // Undeclared companions remain whole-bundle authority even though
1932 // they receive no Native EntryRef digest.
1933 fs::write(
1934 tmp.path().join("native/undeclared.mjs"),
1935 b"export const sidecar = 2",
1936 )
1937 .unwrap();
1938 let changed_sidecar = PluginManifest::validate_from_path(&manifest).unwrap();
1939 assert_ne!(first.content_hash, changed_sidecar.content_hash);
1940 assert_eq!(
1941 first.native_entry_hashes,
1942 changed_sidecar.native_entry_hashes
1943 );
1944 fs::write(
1945 tmp.path().join("native/index.mjs"),
1946 b"export function changed() {}\n",
1947 )
1948 .unwrap();
1949 let changed_entry = PluginManifest::validate_from_path(&manifest).unwrap();
1950 assert_ne!(changed_sidecar.content_hash, changed_entry.content_hash);
1951 assert_ne!(
1952 first.native_entry_hashes[&relative],
1953 changed_entry.native_entry_hashes[&relative]
1954 );
1955 }
1956
1957 #[test]
1958 fn ordinary_native_entry_receipt_keeps_bundle_limit_beyond_preset_metadata_cap() {
1959 let tmp = tempfile::tempdir().unwrap();
1960 fs::create_dir_all(tmp.path().join("native")).unwrap();
1961 let bytes = format!(
1962 "// {}\nexport function apply() {{}}\n",
1963 "x".repeat(32 * 1024)
1964 );
1965 fs::write(tmp.path().join("native/index.mjs"), &bytes).unwrap();
1966 let manifest = write_manifest(tmp.path(), "[native]\npath = \"native/index.mjs\"\n");
1967 let validated = PluginManifest::validate_from_path(&manifest).unwrap();
1968 assert_eq!(validated.inventory.native, 1);
1969 assert_eq!(
1970 validated.native_entry_hashes[Path::new("native/index.mjs")],
1971 hex_digest(Sha256::digest(bytes.as_bytes()))
1972 );
1973 }
1974
1975 #[test]
1976 fn validates_deep_bundle_on_small_stack() {
1977 let tmp = tempfile::tempdir().unwrap();
1978 let manifest = write_manifest(tmp.path(), "");
1979 let mut nested = tmp.path().join("nested");
1980 for level in 0..8 {
1981 nested = nested.join(format!("level-{level}"));
1982 }
1983 fs::create_dir_all(&nested).unwrap();
1984 fs::write(nested.join("payload.txt"), "nested bundle payload").unwrap();
1985
1986 let worker = std::thread::Builder::new()
1987 .name("plugin-small-stack-validation".to_string())
1988 .stack_size(512 * 1024)
1989 .spawn(move || PluginManifest::validate_from_path(&manifest))
1990 .unwrap();
1991 let validated = worker
1992 .join()
1993 .expect("nested plugin validation must not overflow a small stack")
1994 .expect("nested plugin bundle must validate");
1995 assert_eq!(validated.inventory.skills, 1);
1996 }
1997
1998 #[test]
1999 fn bundle_hash_is_deterministic_and_covers_undeclared_companion_files() {
2000 let left = tempfile::tempdir().unwrap();
2001 let right = tempfile::tempdir().unwrap();
2002 let left_manifest = write_manifest(left.path(), "");
2003 let right_manifest = write_manifest(right.path(), "");
2004 fs::write(left.path().join("z.txt"), "z").unwrap();
2005 fs::write(left.path().join("a.txt"), "a").unwrap();
2006 fs::write(right.path().join("a.txt"), "a").unwrap();
2007 fs::write(right.path().join("z.txt"), "z").unwrap();
2008
2009 let left_hash = PluginManifest::validate_from_path(&left_manifest).unwrap();
2010 let right_hash = PluginManifest::validate_from_path(&right_manifest).unwrap();
2011 assert_eq!(left_hash.content_hash, right_hash.content_hash);
2012 assert_eq!(left_hash.capability_hash, right_hash.capability_hash);
2013
2014 fs::write(right.path().join("z.txt"), "changed").unwrap();
2015 let changed = PluginManifest::validate_from_path(&right_manifest).unwrap();
2016 assert_ne!(right_hash.content_hash, changed.content_hash);
2017 assert_eq!(right_hash.capability_hash, changed.capability_hash);
2018 }
2019
2020 #[cfg(unix)]
2021 #[test]
2022 fn bundle_hash_frames_adversarial_binary_records() {
2023 let left = tempfile::tempdir().unwrap();
2024 let right = tempfile::tempdir().unwrap();
2025 let manifest = b"schema_version = 1\n[plugin]\nname = \"framing\"\nversion = \"1.0.0\"\n";
2026 for root in [left.path(), right.path()] {
2027 fs::write(root.join("plugin.toml"), manifest).unwrap();
2028 }
2029
2030 fs::write(left.path().join("a.bin"), b"alpha").unwrap();
2031 fs::write(left.path().join("b.bin"), b"omega").unwrap();
2032
2033 let mut adversarial = b"alpha\0unix-executable\0\0F\0codewhale-os-path-v1\0".to_vec();
2034 adversarial.extend_from_slice(&(b"bundle-relative-file".len() as u64).to_le_bytes());
2035 adversarial.extend_from_slice(b"bundle-relative-file");
2036 adversarial.extend_from_slice(b"unix-bytes\0");
2037 adversarial.extend_from_slice(&(b"b.bin".len() as u64).to_le_bytes());
2038 adversarial.extend_from_slice(b"b.bin");
2039 adversarial.extend_from_slice(b"omega");
2040 fs::write(right.path().join("a.bin"), adversarial).unwrap();
2041
2042 let left = PluginManifest::validate_from_path(&left.path().join("plugin.toml")).unwrap();
2043 let right = PluginManifest::validate_from_path(&right.path().join("plugin.toml")).unwrap();
2044 assert_ne!(left.content_hash, right.content_hash);
2045 assert_eq!(left.capability_hash, right.capability_hash);
2046 }
2047
2048 // Darwin rejects these malformed bytes at the filesystem boundary. The
2049 // platform-independent native-path framing is covered in path_identity;
2050 // run this full bundle-walk regression where Unix permits the entries.
2051 #[cfg(all(unix, not(target_os = "macos")))]
2052 #[test]
2053 fn bundle_hash_distinguishes_lossy_colliding_native_file_names() {
2054 use std::ffi::OsString;
2055 use std::os::unix::ffi::OsStringExt as _;
2056
2057 let left = tempfile::tempdir().unwrap();
2058 let right = tempfile::tempdir().unwrap();
2059 let left_manifest = write_manifest(left.path(), "");
2060 let right_manifest = write_manifest(right.path(), "");
2061 let left_name = OsString::from_vec(vec![b'a', 0xff]);
2062 let right_name = OsString::from_vec(vec![b'a', 0xfe]);
2063 assert_eq!(left_name.to_string_lossy(), right_name.to_string_lossy());
2064 fs::write(left.path().join(left_name), "same bytes").unwrap();
2065 fs::write(right.path().join(right_name), "same bytes").unwrap();
2066
2067 let left = PluginManifest::validate_from_path(&left_manifest).unwrap();
2068 let right = PluginManifest::validate_from_path(&right_manifest).unwrap();
2069 assert_ne!(left.content_hash, right.content_hash);
2070 }
2071
2072 #[test]
2073 fn legacy_manifest_is_accepted_with_migration_warning() {
2074 let tmp = tempfile::tempdir().unwrap();
2075 fs::write(
2076 tmp.path().join("plugin.toml"),
2077 "[plugin]\nname = \"legacy\"\n",
2078 )
2079 .unwrap();
2080 let validated =
2081 PluginManifest::validate_from_path(&tmp.path().join("plugin.toml")).unwrap();
2082 assert_eq!(validated.manifest.schema_version, 0);
2083 assert_eq!(validated.manifest.plugin.version, "0.0.0");
2084 assert_eq!(validated.warnings.len(), 2);
2085 }
2086
2087 #[test]
2088 fn rejects_unknown_fields_invalid_names_and_versions() {
2089 let invalid = [
2090 "schema_version = 1\nunknown = true\n[plugin]\nname = \"ok\"\nversion = \"1.0.0\"\n",
2091 "schema_version = 1\n[plugin]\nname = \"Bad_Name\"\nversion = \"1.0.0\"\n",
2092 "schema_version = 1\n[plugin]\nname = \"ok\"\nversion = \"latest\"\n",
2093 "schema_version = 1\n[plugin]\nname = \"ok\"\n",
2094 ];
2095 for source in invalid {
2096 let tmp = tempfile::tempdir().unwrap();
2097 let path = tmp.path().join("plugin.toml");
2098 fs::write(&path, source).unwrap();
2099 assert!(PluginManifest::validate_from_path(&path).is_err());
2100 }
2101 }
2102
2103 #[test]
2104 fn parse_diagnostics_do_not_echo_manifest_values() {
2105 let tmp = tempfile::tempdir().unwrap();
2106 let path = tmp.path().join("plugin.toml");
2107 fs::write(
2108 &path,
2109 "schema_version = 1\n[plugin]\nname = \"safe\"\nversion = \"1.0.0\"\ndescription = [\"sk-sensitive-value\"]\n",
2110 )
2111 .unwrap();
2112 let error = PluginManifest::validate_from_path(&path).unwrap_err();
2113 assert!(!error.contains("sk-sensitive-value"));
2114 }
2115
2116 #[test]
2117 fn rejects_parent_absolute_and_windows_absolute_component_paths() {
2118 for bad in [
2119 "../escape",
2120 "/tmp/escape",
2121 r"C:\\escape",
2122 r"\\\\server\\share",
2123 ] {
2124 let tmp = tempfile::tempdir().unwrap();
2125 let path = write_manifest(tmp.path(), &format!("\n[commands]\npath = {bad:?}\n"));
2126 assert!(
2127 PluginManifest::validate_from_path(&path).is_err(),
2128 "accepted {bad}"
2129 );
2130 }
2131 }
2132
2133 #[cfg(unix)]
2134 #[test]
2135 fn rejects_symlinked_component_and_nested_symlink() {
2136 use std::os::unix::fs::symlink;
2137
2138 let outside = tempfile::tempdir().unwrap();
2139 fs::write(outside.path().join("SKILL.md"), "# outside").unwrap();
2140
2141 let tmp = tempfile::tempdir().unwrap();
2142 let path = write_manifest(tmp.path(), "");
2143 fs::remove_dir_all(tmp.path().join("skills")).unwrap();
2144 symlink(outside.path(), tmp.path().join("skills")).unwrap();
2145 assert!(PluginManifest::validate_from_path(&path).is_err());
2146
2147 fs::remove_file(tmp.path().join("skills")).unwrap();
2148 fs::create_dir_all(tmp.path().join("skills/example")).unwrap();
2149 fs::write(tmp.path().join("skills/example/SKILL.md"), "# safe").unwrap();
2150 symlink(
2151 outside.path().join("SKILL.md"),
2152 tmp.path().join("skills/example/linked.md"),
2153 )
2154 .unwrap();
2155 assert!(PluginManifest::validate_from_path(&path).is_err());
2156 }
2157
2158 #[cfg(unix)]
2159 #[test]
2160 fn rejects_symlinked_manifest() {
2161 use std::os::unix::fs::symlink;
2162
2163 let tmp = tempfile::tempdir().unwrap();
2164 let real = tmp.path().join("real.toml");
2165 fs::write(
2166 &real,
2167 "schema_version = 1\n[plugin]\nname = \"linked\"\nversion = \"1.0.0\"\n",
2168 )
2169 .unwrap();
2170 let linked = tmp.path().join("plugin.toml");
2171 symlink(&real, &linked).unwrap();
2172
2173 assert!(PluginManifest::validate_from_path(&linked).is_err());
2174 }
2175
2176 #[cfg(unix)]
2177 #[test]
2178 fn rejects_symlinked_bundle_root() {
2179 use std::os::unix::fs::symlink;
2180
2181 let tmp = tempfile::tempdir().unwrap();
2182 let real_root = tmp.path().join("real");
2183 fs::create_dir(&real_root).unwrap();
2184 fs::write(
2185 real_root.join("plugin.toml"),
2186 "schema_version = 1\n[plugin]\nname = \"linked-root\"\nversion = \"1.0.0\"\n",
2187 )
2188 .unwrap();
2189 let linked_root = tmp.path().join("linked");
2190 symlink(&real_root, &linked_root).unwrap();
2191
2192 assert!(PluginManifest::validate_from_path(&linked_root.join("plugin.toml")).is_err());
2193 }
2194
2195 #[test]
2196 fn rejects_absolute_mcp_arguments_and_embedded_url_credentials() {
2197 let absolute = tempfile::tempdir().unwrap();
2198 let absolute_path = write_manifest(
2199 absolute.path(),
2200 "\n[mcp_servers.local]\ncommand = \"node\"\nargs = [\"/tmp/server.js\"]\n",
2201 );
2202 assert!(PluginManifest::validate_from_path(&absolute_path).is_err());
2203
2204 let credentialed = tempfile::tempdir().unwrap();
2205 let credentialed_path = write_manifest(
2206 credentialed.path(),
2207 "\n[mcp_servers.remote]\nurl = \"https://user:secret@example.invalid/mcp\"\n",
2208 );
2209 assert!(PluginManifest::validate_from_path(&credentialed_path).is_err());
2210 }
2211
2212 #[test]
2213 fn windows_rooted_path_detection_is_host_independent() {
2214 assert!(looks_windows_absolute(r"C:\plugins\server.js"));
2215 assert!(looks_windows_absolute(r"C:/plugins/server.js"));
2216 assert!(looks_windows_absolute(r"\plugins\server.js"));
2217 assert!(looks_windows_absolute("/plugins/server.js"));
2218 assert!(!looks_windows_absolute("plugins/server.js"));
2219 assert!(!looks_windows_absolute("server.js"));
2220 }
2221
2222 #[cfg(windows)]
2223 #[test]
2224 fn retained_bundle_handle_allows_identity_revalidation_but_denies_writes() {
2225 use std::os::windows::fs::OpenOptionsExt as _;
2226
2227 let directory = tempfile::tempdir().unwrap();
2228 let path = directory.path().join("reviewed.bin");
2229 fs::write(&path, b"reviewed").unwrap();
2230 let retained = open_bundle_file(&path).unwrap();
2231
2232 ensure_windows_path_still_opened(&path, &retained).unwrap();
2233 let denied = fs::OpenOptions::new()
2234 .write(true)
2235 .share_mode(0x0000_0007)
2236 .open(&path);
2237 assert!(denied.is_err(), "retained authority must deny mutation");
2238
2239 drop(retained);
2240 fs::OpenOptions::new().write(true).open(&path).unwrap();
2241 }
2242
2243 #[test]
2244 fn unsupported_capabilities_are_inventoried() {
2245 let tmp = tempfile::tempdir().unwrap();
2246 fs::create_dir_all(tmp.path().join("hooks")).unwrap();
2247 let path = write_manifest(
2248 tmp.path(),
2249 "\n[hooks]\npath = \"hooks\"\n[capabilities]\nfilesystem_roots = [\"workspace\"]\nlifecycle_mutation = true\n",
2250 );
2251 let validated = PluginManifest::validate_from_path(&path).unwrap();
2252 assert!(validated.inventory.has_unsupported_capabilities());
2253 assert!(validated.inventory.supported_labels().contains(&"hooks"));
2254 assert!(
2255 validated
2256 .inventory
2257 .unsupported_labels()
2258 .contains(&"filesystem-roots")
2259 );
2260 assert_eq!(
2261 validated.inventory.compatibility(),
2262 PluginCompatibility::Partial,
2263 "Skills and Hooks activate while explicit filesystem/lifecycle capabilities stay inactive"
2264 );
2265 assert!(validated.inventory.can_activate_supported_components());
2266 }
2267
2268 #[test]
2269 fn mixed_supported_and_unsupported_components_are_partial() {
2270 let tmp = tempfile::tempdir().unwrap();
2271 fs::create_dir_all(tmp.path().join("commands")).unwrap();
2272 fs::create_dir_all(tmp.path().join("lsp")).unwrap();
2273 let path = write_manifest(
2274 tmp.path(),
2275 "\n[commands]\npath = \"commands\"\n[lsp]\npath = \"lsp\"\n",
2276 );
2277 let validated = PluginManifest::validate_from_path(&path).unwrap();
2278 assert!(validated.inventory.has_supported_components());
2279 assert!(validated.inventory.has_unsupported_capabilities());
2280 assert_eq!(
2281 validated.inventory.supported_labels(),
2282 vec!["skills", "commands"]
2283 );
2284 assert_eq!(validated.inventory.unsupported_labels(), vec!["lsp"]);
2285 assert_eq!(
2286 validated.inventory.compatibility(),
2287 PluginCompatibility::Partial
2288 );
2289 assert!(validated.inventory.can_activate_supported_components());
2290 }
2291
2292 #[test]
2293 fn activation_policy_change_changes_the_capability_hash() {
2294 let inventory = PluginInventory {
2295 skills: 1,
2296 ..PluginInventory::default()
2297 };
2298 let current_policy = PluginActivationPolicy::current();
2299 let current = capability_hash_with_policy(&inventory, current_policy);
2300 let hooks_inactive = PluginActivationPolicy {
2301 version: current_policy.version,
2302 supported: &[
2303 PluginActivationCapability::Skills,
2304 PluginActivationCapability::McpStdio,
2305 PluginActivationCapability::McpRemote,
2306 PluginActivationCapability::Commands,
2307 PluginActivationCapability::Agents,
2308 ],
2309 inactive: &[
2310 PluginActivationCapability::Hooks,
2311 PluginActivationCapability::Lsp,
2312 PluginActivationCapability::Native,
2313 PluginActivationCapability::FilesystemRoots,
2314 PluginActivationCapability::LifecycleMutation,
2315 ],
2316 };
2317 assert_ne!(
2318 current,
2319 capability_hash_with_policy(&inventory, hooks_inactive),
2320 "changing the executable adapter set must move the capability hash"
2321 );
2322 let bumped = PluginActivationPolicy {
2323 version: current_policy.version + 1,
2324 supported: current_policy.supported,
2325 inactive: current_policy.inactive,
2326 };
2327 assert_ne!(
2328 current,
2329 capability_hash_with_policy(&inventory, bumped),
2330 "a policy version bump must move the capability hash"
2331 );
2332 assert_eq!(
2333 current,
2334 capability_hash_with_policy(&inventory, current_policy)
2335 );
2336 assert_ne!(current, capability_hash_v1(&inventory));
2337 }
2338
2339 #[test]
2340 fn all_unsupported_inventory_cannot_activate() {
2341 let tmp = tempfile::tempdir().unwrap();
2342 fs::create_dir_all(tmp.path().join("lsp")).unwrap();
2343 let path = tmp.path().join("plugin.toml");
2344 fs::write(
2345 &path,
2346 "schema_version = 1\n[plugin]\nname = \"lsp-only\"\nversion = \"1.0.0\"\n[lsp]\npath = \"lsp\"\n",
2347 )
2348 .unwrap();
2349 let validated = PluginManifest::validate_from_path(&path).unwrap();
2350 assert!(!validated.inventory.has_supported_components());
2351 assert_eq!(validated.inventory.unsupported_labels(), vec!["lsp"]);
2352 assert_eq!(
2353 validated.inventory.compatibility(),
2354 PluginCompatibility::Unsupported
2355 );
2356 assert!(!validated.inventory.can_activate_supported_components());
2357 }
2358
2359 #[test]
2360 fn plugin_mcp_schema_and_transport_combinations_fail_closed() {
2361 let invalid = [
2362 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\nunknown_nested = true\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
2363 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\nargs = [\"secret\"]\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
2364 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp?token=secret\"\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
2365 "\n[mcp_servers.remote]\nurl = \"http://example.invalid/mcp\"\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
2366 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\n[capabilities]\nnetwork_hosts = [\"other.invalid\"]\n",
2367 "\n[mcp_servers.local]\ncommand = \"node\"\ntransport = \"sse\"\n",
2368 "\n[mcp_servers.local]\ncommand = \"node\"\nconnect_timeout = 0\n",
2369 "\n[mcp_servers.local]\ncommand = \"node\"\nenabled_tools = [\"same\"]\ndisabled_tools = [\"same\"]\n",
2370 "\n[mcp_servers.local]\ncommand = \"node\"\n[mcp_servers.local.env]\nTOKEN = \"literal-secret\"\n",
2371 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\n[mcp_servers.remote.headers]\nAuthorization = \"literal-secret\"\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
2372 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\n[mcp_servers.remote.oauth]\nclient_id = \"public\"\nsecret = \"must-not-parse\"\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
2373 ];
2374 for extra in invalid {
2375 let tmp = tempfile::tempdir().unwrap();
2376 let path = write_manifest(tmp.path(), extra);
2377 assert!(
2378 PluginManifest::validate_from_path(&path).is_err(),
2379 "accepted invalid plugin MCP manifest: {extra}"
2380 );
2381 }
2382 }
2383
2384 #[test]
2385 fn plugin_mcp_remote_allowlist_and_env_provenance_are_exact() {
2386 let tmp = tempfile::tempdir().unwrap();
2387 let path = write_manifest(
2388 tmp.path(),
2389 r#"
2390 [mcp_servers.remote]
2391 url = "https://Example.Invalid:8443/mcp/v1"
2392 transport = "sse"
2393 connect_timeout = 30
2394 execute_timeout = 120
2395 read_timeout = 180
2396 required = true
2397 enabled_tools = ["read"]
2398 disabled_tools = ["write"]
2399 bearer_token_env_var = "PLUGIN_BEARER"
2400
2401 [mcp_servers.remote.env_headers]
2402 X_Api_Key = "PLUGIN_API_KEY"
2403
2404 [capabilities]
2405 network_hosts = ["example.invalid"]
2406 "#,
2407 );
2408 let validated = PluginManifest::validate_from_path(&path).unwrap();
2409 assert_eq!(
2410 validated.inventory.network_hosts,
2411 vec!["example.invalid".to_string()]
2412 );
2413 assert_eq!(validated.inventory.remote_mcp_servers, 1);
2414 }
2415
2416 #[test]
2417 fn plugin_mcp_oauth_fields_are_rejected_for_v091() {
2418 for oauth_fields in [
2419 "scopes = [\"tools.read\"]\n",
2420 "oauth_resource = \"https://resource.invalid/mcp\"\n",
2421 "[mcp_servers.remote.oauth]\nclient_id = \"public-client-id\"\n",
2422 ] {
2423 let tmp = tempfile::tempdir().unwrap();
2424 let path = write_manifest(
2425 tmp.path(),
2426 &format!(
2427 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\n{oauth_fields}[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n"
2428 ),
2429 );
2430 let error = PluginManifest::validate_from_path(&path)
2431 .expect_err("plugin OAuth authority must remain disabled");
2432 assert!(error.contains("plugin OAuth is disabled"));
2433 }
2434 }
2435
2436 #[test]
2437 fn reviewed_stdio_argv_rejects_literal_credentials_but_accepts_exact_safe_values() {
2438 for args in [
2439 r#"["server.js", "--token", "literal-secret"]"#,
2440 r#"["server.js", "--api-key=literal-secret"]"#,
2441 r#"["server.js", "sk-live-literal"]"#,
2442 ] {
2443 let tmp = tempfile::tempdir().unwrap();
2444 fs::write(tmp.path().join("server.js"), "// entrypoint\n").unwrap();
2445 let path = write_manifest(
2446 tmp.path(),
2447 &format!("\n[mcp_servers.local]\ncommand = \"node\"\nargs = {args}\n"),
2448 );
2449 let error = PluginManifest::validate_from_path(&path)
2450 .expect_err("credential-bearing argv must fail closed");
2451 assert!(error.contains("credential"), "{error}");
2452 }
2453
2454 let safe = tempfile::tempdir().unwrap();
2455 fs::write(safe.path().join("server.js"), "// entrypoint\n").unwrap();
2456 let path = write_manifest(
2457 safe.path(),
2458 r#"
2459 [mcp_servers.local]
2460 command = "node"
2461 args = ["server.js", "--mode=worker", "-e", "console.log('ready')"]
2462 "#,
2463 );
2464 PluginManifest::validate_from_path(&path)
2465 .expect("safe interpreter argv should remain reviewable exactly");
2466 }
2467
2468 #[test]
2469 fn manifest_text_rejects_controls_and_bidirectional_spoofing() {
2470 for unsafe_text in ["line\nbreak", "safe\u{202e}lmot.nigulp"] {
2471 let tmp = tempfile::tempdir().unwrap();
2472 let path = tmp.path().join("plugin.toml");
2473 fs::write(
2474 &path,
2475 format!(
2476 "schema_version = 1\n[plugin]\nname = \"safe\"\nversion = \"1.0.0\"\nauthor = {unsafe_text:?}\n"
2477 ),
2478 )
2479 .unwrap();
2480 assert!(PluginManifest::validate_from_path(&path).is_err());
2481 }
2482 }
2483
2484 #[test]
2485 fn bundled_computer_use_plugin_validates() {
2486 use crate::plugins::agent_plugin;
2487 let root = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/plugins/computer-use"));
2488 let validated = PluginManifest::validate_from_path(&root.join("plugin.json"))
2489 .expect("in-repo computer-use bundle must validate");
2490 assert_eq!(validated.manifest.plugin.name, "computer-use");
2491 // One declared skills *root* (`skills/`), which holds both skills.
2492 assert_eq!(validated.inventory.skills, 1);
2493 let skills_root = validated.components.skills.first().expect("skills root");
2494 let mut skill_dirs: Vec<String> = fs::read_dir(skills_root)
2495 .unwrap()
2496 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
2497 .collect();
2498 skill_dirs.sort();
2499 assert_eq!(skill_dirs, ["computer-use", "recording"]);
2500 for skill in &skill_dirs {
2501 assert!(skills_root.join(skill).join("SKILL.md").is_file());
2502 }
2503 assert_eq!(validated.components.commands.len(), 1);
2504 assert!(validated.warnings.is_empty(), "{:?}", validated.warnings);
2505
2506 let mcp_text = fs::read_to_string(root.join("mcp.json")).unwrap();
2507 let servers =
2508 agent_plugin::parse_mcp_json(&mcp_text).expect("computer-use mcp.json must parse");
2509 let computer = servers.get("computer").expect("computer server");
2510 assert_eq!(computer.command.as_deref(), Some("node"));
2511 assert!(
2512 computer.args.iter().any(|arg| arg.ends_with("server.mjs")),
2513 "{:?}",
2514 computer.args
2515 );
2516 // The entrypoint must stay inside the bundle: the engine launches it
2517 // with `cwd` set to the plugin root.
2518 for arg in &computer.args {
2519 assert!(
2520 !Path::new(arg).is_absolute() && !arg.split('/').any(|part| part == ".."),
2521 "{arg} must stay inside the bundle"
2522 );
2523 assert!(root.join(arg).is_file(), "{arg} must exist in the bundle");
2524 }
2525 }
2526 }
2527
2528 #[cfg(test)]
2529 mod icon_tests {
2530 use super::validate_icon;
2531 use base64::Engine;
2532
2533 #[test]
2534 fn artwork_is_inline_bounded_png_only() {
2535 let manifest: serde_json::Value =
2536 serde_json::from_str(include_str!("../../plugins/computer-use/plugin.json")).unwrap();
2537 let icon = manifest["extensions"]["net.codewhale"]["icon"]
2538 .as_str()
2539 .unwrap();
2540 assert!(validate_icon(icon).is_ok());
2541 for invalid in [
2542 "https://publisher.example/tracker.png",
2543 "data:image/svg+xml,<svg/>",
2544 "data:image/png;base64,invalid",
2545 ] {
2546 assert!(validate_icon(invalid).is_err());
2547 }
2548 let mut png = base64::engine::general_purpose::STANDARD
2549 .decode(icon.strip_prefix("data:image/png;base64,").unwrap())
2550 .unwrap();
2551 png[16..20].copy_from_slice(&100_000_u32.to_be_bytes());
2552 assert!(
2553 validate_icon(&format!(
2554 "data:image/png;base64,{}",
2555 base64::engine::general_purpose::STANDARD.encode(png)
2556 ))
2557 .is_err()
2558 );
2559 assert!(validate_icon(&"x".repeat(32_769)).is_err());
2560 }
2561 }
2562
2562 lines RUST