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