返回 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 use super::path_identity::metadata_is_link_or_reparse;
11 #[cfg(windows)]
12 use super::path_identity::windows_file_identity;
13 use crate::mcp::{McpServerConfig, is_relative_stdio_path_arg};
14
15 pub const CURRENT_SCHEMA_VERSION: u32 = 1;
16 const MAX_PLUGIN_NAME_CHARS: usize = 64;
17 const MAX_COMPONENT_PATHS: usize = 64;
18 const MAX_MANIFEST_BYTES: u64 = 1024 * 1024;
19 const MAX_HASHED_FILES: usize = 4_096;
20 const MAX_HASHED_BYTES: u64 = 64 * 1024 * 1024;
21 const MAX_MCP_ARGS: usize = 64;
22 const MAX_MCP_ENV: usize = 64;
23 const MAX_MCP_HEADERS: usize = 64;
24 const MAX_MCP_SCOPES: usize = 64;
25 const MAX_MCP_TOOL_FILTERS: usize = 256;
26 const MAX_MCP_TIMEOUT_SECS: u64 = 3_600;
27
28 #[derive(Debug, Clone, Deserialize, Serialize)]
29 #[serde(deny_unknown_fields)]
30 pub struct PluginManifest {
31 /// Missing means the legacy, pre-versioned Codewhale manifest. Legacy
32 /// manifests remain readable, but `/plugin validate` reports the migration.
33 #[serde(default)]
34 pub schema_version: u32,
35 pub plugin: PluginMeta,
36 #[serde(default)]
37 pub skills: Option<PluginPathSpec>,
38 #[serde(default)]
39 pub commands: Option<PluginPathSpec>,
40 #[serde(default, alias = "profiles")]
41 pub agents: Option<PluginPathSpec>,
42 #[serde(default)]
43 pub hooks: Option<PluginPathSpec>,
44 #[serde(default, alias = "lsp_servers")]
45 pub lsp: Option<PluginPathSpec>,
46 #[serde(default, alias = "native_extension")]
47 pub native: Option<PluginPathSpec>,
48 #[serde(default)]
49 pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
50 #[serde(default)]
51 pub capabilities: PluginCapabilities,
52 #[serde(default)]
53 pub when: Option<PluginWhen>,
54 }
55
56 #[derive(Debug, Clone, Deserialize, Serialize)]
57 #[serde(deny_unknown_fields)]
58 pub struct PluginMeta {
59 pub name: String,
60 #[serde(default)]
61 pub description: Option<String>,
62 #[serde(default)]
63 pub version: String,
64 #[serde(default)]
65 pub author: Option<String>,
66 }
67
68 /// A declarative component location. `path` preserves the original manifest
69 /// shape; `paths` lets a bundle split one component kind across directories.
70 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
71 #[serde(deny_unknown_fields)]
72 pub struct PluginPathSpec {
73 #[serde(default)]
74 pub path: Option<String>,
75 #[serde(default)]
76 pub paths: Vec<String>,
77 }
78
79 impl PluginPathSpec {
80 fn declared_paths(&self, default: Option<&str>) -> Result<Vec<String>, String> {
81 let mut paths = Vec::new();
82 if let Some(path) = self.path.as_deref() {
83 paths.push(path.to_string());
84 }
85 paths.extend(self.paths.iter().cloned());
86 if paths.is_empty()
87 && let Some(default) = default
88 {
89 paths.push(default.to_string());
90 }
91 if paths.is_empty() {
92 return Err("component table must declare `path` or `paths`".to_string());
93 }
94 if paths.len() > MAX_COMPONENT_PATHS {
95 return Err(format!(
96 "component declares {} paths; maximum is {MAX_COMPONENT_PATHS}",
97 paths.len()
98 ));
99 }
100 let mut seen = BTreeSet::new();
101 for path in &paths {
102 if !seen.insert(path.clone()) {
103 return Err(format!(
104 "component path `{path}` is declared more than once"
105 ));
106 }
107 }
108 Ok(paths)
109 }
110 }
111
112 #[derive(Debug, Clone, Default, Deserialize, Serialize)]
113 #[serde(deny_unknown_fields)]
114 pub struct PluginCapabilities {
115 /// Requested filesystem roots are inventory-only in v0.9.1. Declaring any
116 /// keeps the bundle inactive until a later permission adapter exists.
117 #[serde(default)]
118 pub filesystem_roots: Vec<String>,
119 /// Requested hosts are inventory-only. MCP URL hosts are added to the
120 /// effective capability inventory automatically.
121 #[serde(default)]
122 pub network_hosts: Vec<String>,
123 /// Lifecycle mutation is inventoried but unsupported in v0.9.1.
124 #[serde(default)]
125 pub lifecycle_mutation: bool,
126 }
127
128 #[derive(Debug, Clone, Deserialize, Serialize)]
129 #[serde(deny_unknown_fields)]
130 pub struct PluginWhen {
131 #[serde(default)]
132 pub os: Option<Vec<String>>,
133 #[serde(default)]
134 pub binaries: Option<Vec<String>>,
135 }
136
137 #[derive(Debug, Clone, Default, PartialEq, Eq)]
138 pub struct ResolvedPluginComponents {
139 pub skills: Vec<PathBuf>,
140 pub commands: Vec<PathBuf>,
141 pub agents: Vec<PathBuf>,
142 pub hooks: Vec<PathBuf>,
143 pub lsp: Vec<PathBuf>,
144 pub native: Vec<PathBuf>,
145 }
146
147 impl ResolvedPluginComponents {
148 pub fn all_paths(&self) -> impl Iterator<Item = &PathBuf> {
149 self.skills
150 .iter()
151 .chain(&self.commands)
152 .chain(&self.agents)
153 .chain(&self.hooks)
154 .chain(&self.lsp)
155 .chain(&self.native)
156 }
157 }
158
159 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
160 #[serde(deny_unknown_fields)]
161 pub struct PluginInventory {
162 pub skills: usize,
163 pub mcp_servers: usize,
164 /// MCP servers that launch a child process under the Codewhale user's
165 /// host permissions. Kept separate from remote MCP so the review screen
166 /// cannot imply that an empty declared filesystem/network list is a
167 /// sandbox boundary.
168 #[serde(default)]
169 pub stdio_mcp_servers: usize,
170 /// MCP servers contacted over HTTP(S) without launching a local child.
171 #[serde(default)]
172 pub remote_mcp_servers: usize,
173 pub commands: usize,
174 pub agents: usize,
175 pub hooks: usize,
176 pub lsp: usize,
177 pub native: usize,
178 pub filesystem_roots: Vec<String>,
179 pub network_hosts: Vec<String>,
180 pub lifecycle_mutation: bool,
181 }
182
183 impl PluginInventory {
184 #[must_use]
185 pub fn unsupported_labels(&self) -> Vec<&'static str> {
186 let mut labels = Vec::new();
187 if self.commands > 0 {
188 labels.push("commands");
189 }
190 if self.agents > 0 {
191 labels.push("agents");
192 }
193 if self.hooks > 0 {
194 labels.push("hooks");
195 }
196 if self.lsp > 0 {
197 labels.push("lsp");
198 }
199 if self.native > 0 {
200 labels.push("native");
201 }
202 if !self.filesystem_roots.is_empty() {
203 labels.push("filesystem-roots");
204 }
205 if self.lifecycle_mutation {
206 labels.push("lifecycle-mutation");
207 }
208 labels
209 }
210
211 #[must_use]
212 pub fn has_unsupported_capabilities(&self) -> bool {
213 !self.unsupported_labels().is_empty()
214 }
215
216 #[must_use]
217 pub fn summary(&self) -> String {
218 format!(
219 "skills={} mcp={} (stdio={} remote={}) commands={} agents={} hooks={} lsp={} native={}",
220 self.skills,
221 self.mcp_servers,
222 self.stdio_mcp_servers,
223 self.remote_mcp_servers,
224 self.commands,
225 self.agents,
226 self.hooks,
227 self.lsp,
228 self.native
229 )
230 }
231 }
232
233 #[derive(Debug, Clone)]
234 pub struct ValidatedManifest {
235 pub manifest: PluginManifest,
236 pub canonical_root: PathBuf,
237 pub components: ResolvedPluginComponents,
238 pub inventory: PluginInventory,
239 pub content_hash: String,
240 /// Digest of the exact bytes read for every regular bundle file, keyed by
241 /// its lossless relative OS path. Runtime adapters use this to bind parsed
242 /// representations to the same bytes that produced `content_hash`.
243 pub(crate) file_hashes: BTreeMap<PathBuf, String>,
244 pub capability_hash: String,
245 pub applicable: bool,
246 pub warnings: Vec<String>,
247 }
248
249 impl PluginManifest {
250 pub fn from_path(path: &Path) -> Result<Self, String> {
251 let metadata = fs::symlink_metadata(path)
252 .map_err(|e| format!("failed to inspect plugin.toml: {e}"))?;
253 if metadata_is_link_or_reparse(&metadata) {
254 return Err("plugin.toml may not be a symbolic link".to_string());
255 }
256 let bytes = read_manifest_bytes(path)?;
257 let content = std::str::from_utf8(&bytes)
258 .map_err(|_| "plugin.toml must be valid UTF-8".to_string())?;
259 validate_nested_mcp_schema(content)?;
260 toml::from_str(content).map_err(|error| safe_toml_parse_error(&error))
261 }
262
263 pub fn validate_from_path(path: &Path) -> Result<ValidatedManifest, String> {
264 let manifest_metadata = fs::symlink_metadata(path)
265 .map_err(|e| format!("failed to inspect plugin.toml: {e}"))?;
266 if metadata_is_link_or_reparse(&manifest_metadata) || !manifest_metadata.is_file() {
267 return Err("plugin.toml must be a regular file, not a symbolic link".to_string());
268 }
269 let root = path
270 .parent()
271 .ok_or_else(|| "plugin.toml has no parent directory".to_string())?;
272 let root_metadata = fs::symlink_metadata(root)
273 .map_err(|e| format!("failed to inspect plugin root: {e}"))?;
274 if metadata_is_link_or_reparse(&root_metadata) || !root_metadata.is_dir() {
275 return Err("plugin root must be a directory, not a symbolic link".to_string());
276 }
277 let canonical_root = root
278 .canonicalize()
279 .map_err(|e| format!("failed to canonicalize plugin root: {e}"))?;
280 if !canonical_root.is_dir() {
281 return Err("plugin root is not a directory".to_string());
282 }
283
284 let manifest_bytes = read_manifest_bytes(path)?;
285 let manifest_text = std::str::from_utf8(&manifest_bytes)
286 .map_err(|_| "plugin.toml must be valid UTF-8".to_string())?;
287 validate_nested_mcp_schema(manifest_text)?;
288 let mut manifest: Self =
289 toml::from_str(manifest_text).map_err(|error| safe_toml_parse_error(&error))?;
290 let warnings = if manifest.schema_version == 0 {
291 let mut warnings = vec![format!(
292 "legacy manifest: add `schema_version = {CURRENT_SCHEMA_VERSION}`"
293 )];
294 if manifest.plugin.version.trim().is_empty() {
295 manifest.plugin.version = "0.0.0".to_string();
296 warnings.push(
297 "legacy manifest: add a semantic `[plugin].version`; displaying `0.0.0`"
298 .to_string(),
299 );
300 }
301 warnings
302 } else {
303 Vec::new()
304 };
305 manifest.validate_metadata()?;
306
307 let components = manifest.resolve_components(&canonical_root)?;
308 manifest.validate_mcp_servers(&canonical_root)?;
309 let inventory = manifest.inventory(&components)?;
310 let (content_hash, file_hashes) = hash_bundle(&canonical_root, &manifest_bytes)?;
311 let capability_hash = hash_inventory(&inventory);
312 let applicable = manifest.check_when();
313 if read_manifest_bytes(path)? != manifest_bytes {
314 return Err(
315 "plugin.toml changed while it was being validated; retry discovery".to_string(),
316 );
317 }
318
319 Ok(ValidatedManifest {
320 manifest,
321 canonical_root,
322 components,
323 inventory,
324 content_hash,
325 file_hashes,
326 capability_hash,
327 applicable,
328 warnings,
329 })
330 }
331
332 fn validate_metadata(&self) -> Result<(), String> {
333 if self.schema_version > CURRENT_SCHEMA_VERSION {
334 return Err(format!(
335 "unsupported schema_version {}; maximum is {CURRENT_SCHEMA_VERSION}",
336 self.schema_version
337 ));
338 }
339 validate_plugin_name(&self.plugin.name)?;
340 Version::parse(self.plugin.version.trim()).map_err(|e| {
341 format!(
342 "plugin version `{}` is not valid semantic versioning: {e}",
343 self.plugin.version
344 )
345 })?;
346 validate_optional_text("description", self.plugin.description.as_deref(), 1_024)?;
347 validate_optional_text("author", self.plugin.author.as_deref(), 256)?;
348 validate_unique_texts("filesystem root", &self.capabilities.filesystem_roots, 512)?;
349 validate_unique_texts("network host", &self.capabilities.network_hosts, 253)?;
350 let declared_network_hosts = self
351 .capabilities
352 .network_hosts
353 .iter()
354 .map(|host| normalize_network_host(host))
355 .collect::<Result<BTreeSet<_>, _>>()?;
356 let remote_network_hosts = self
357 .mcp_servers
358 .as_ref()
359 .into_iter()
360 .flat_map(|servers| servers.values())
361 .filter_map(|server| server.url.as_deref())
362 .map(|url| {
363 reqwest::Url::parse(url)
364 .map_err(|_| "remote MCP URL is invalid".to_string())?
365 .host_str()
366 .map(str::to_string)
367 .ok_or_else(|| "remote MCP URL is missing a host".to_string())
368 })
369 .collect::<Result<BTreeSet<_>, _>>()?;
370 if declared_network_hosts != remote_network_hosts {
371 return Err(
372 "capabilities.network_hosts must exactly match the normalized host set of all remote MCP endpoints"
373 .to_string(),
374 );
375 }
376 if let Some(when) = &self.when {
377 if let Some(os_values) = &when.os {
378 validate_unique_texts("OS", os_values, 32)?;
379 const SUPPORTED: &[&str] = &[
380 "windows", "linux", "macos", "freebsd", "openbsd", "netbsd", "android", "ios",
381 ];
382 for os in os_values {
383 if !SUPPORTED.contains(&os.to_ascii_lowercase().as_str()) {
384 return Err(format!("unsupported OS selector `{os}`"));
385 }
386 }
387 }
388 if let Some(binaries) = &when.binaries {
389 validate_unique_texts("binary", binaries, 128)?;
390 for binary in binaries {
391 if binary.contains('/')
392 || binary.contains('\\')
393 || looks_windows_absolute(binary)
394 {
395 return Err(format!(
396 "binary condition `{binary}` must be a bare executable name"
397 ));
398 }
399 }
400 }
401 }
402 Ok(())
403 }
404
405 fn resolve_components(&self, root: &Path) -> Result<ResolvedPluginComponents, String> {
406 Ok(ResolvedPluginComponents {
407 skills: resolve_spec(root, "skills", self.skills.as_ref(), Some("skills"))?,
408 commands: resolve_spec(root, "commands", self.commands.as_ref(), None)?,
409 agents: resolve_spec(root, "agents", self.agents.as_ref(), None)?,
410 hooks: resolve_spec(root, "hooks", self.hooks.as_ref(), None)?,
411 lsp: resolve_spec(root, "lsp", self.lsp.as_ref(), None)?,
412 native: resolve_spec(root, "native", self.native.as_ref(), None)?,
413 })
414 }
415
416 fn validate_mcp_servers(&self, root: &Path) -> Result<(), String> {
417 let Some(servers) = &self.mcp_servers else {
418 return Ok(());
419 };
420 if servers.len() > MAX_COMPONENT_PATHS {
421 return Err(format!(
422 "manifest declares {} MCP servers; maximum is {MAX_COMPONENT_PATHS}",
423 servers.len()
424 ));
425 }
426 for (name, server) in servers {
427 validate_component_name("MCP server", name)?;
428 if server.args.len() > MAX_MCP_ARGS {
429 return Err(format!(
430 "MCP server `{name}` declares too many arguments; maximum is {MAX_MCP_ARGS}"
431 ));
432 }
433 if server.env.len() > MAX_MCP_ENV {
434 return Err(format!(
435 "MCP server `{name}` declares too many environment mappings; maximum is {MAX_MCP_ENV}"
436 ));
437 }
438 if server.env_headers.len() > MAX_MCP_HEADERS {
439 return Err(format!(
440 "MCP server `{name}` declares too many environment-backed headers; maximum is {MAX_MCP_HEADERS}"
441 ));
442 }
443 if server.scopes.len() > MAX_MCP_SCOPES {
444 return Err(format!(
445 "MCP server `{name}` declares too many OAuth scopes; maximum is {MAX_MCP_SCOPES}"
446 ));
447 }
448 if server.enabled_tools.len() > MAX_MCP_TOOL_FILTERS
449 || server.disabled_tools.len() > MAX_MCP_TOOL_FILTERS
450 {
451 return Err(format!(
452 "MCP server `{name}` declares too many tool filters; maximum is {MAX_MCP_TOOL_FILTERS} per list"
453 ));
454 }
455 for (label, timeout) in [
456 ("connect_timeout", server.connect_timeout),
457 ("execute_timeout", server.execute_timeout),
458 ("read_timeout", server.read_timeout),
459 ] {
460 if timeout.is_some_and(|seconds| !(1..=MAX_MCP_TIMEOUT_SECS).contains(&seconds)) {
461 return Err(format!(
462 "MCP server `{name}` {label} must be 1-{MAX_MCP_TIMEOUT_SECS} seconds"
463 ));
464 }
465 }
466 if server.required && !server.enabled {
467 return Err(format!(
468 "MCP server `{name}` cannot be required while disabled"
469 ));
470 }
471 for arg in &server.args {
472 validate_text("MCP argument", arg, 4_096)?;
473 }
474 validate_unique_texts("enabled MCP tool", &server.enabled_tools, 256)?;
475 validate_unique_texts("disabled MCP tool", &server.disabled_tools, 256)?;
476 if server.enabled_tools.iter().any(|tool| {
477 server
478 .disabled_tools
479 .iter()
480 .any(|disabled| disabled == tool)
481 }) {
482 return Err(format!(
483 "MCP server `{name}` declares a tool in both enabled_tools and disabled_tools"
484 ));
485 }
486 match (server.command.as_deref(), server.url.as_deref()) {
487 (Some(command), None) => {
488 validate_text("MCP command", command, 512)?;
489 if server.transport.is_some()
490 || !server.headers.is_empty()
491 || !server.env_headers.is_empty()
492 || server.bearer_token_env_var.is_some()
493 || !server.scopes.is_empty()
494 || server.oauth.is_some()
495 || server.oauth_resource.is_some()
496 {
497 return Err(format!(
498 "stdio MCP server `{name}` may not declare remote transport or authentication fields"
499 ));
500 }
501 if command.contains('/') || command.contains('\\') {
502 let resolved = resolve_contained_path(root, command, "MCP command")?;
503 if !resolved.is_file() {
504 return Err(format!(
505 "MCP server `{name}` command is not a regular file"
506 ));
507 }
508 } else {
509 validate_bare_executable(command)?;
510 }
511 if let Some(cwd) = server.cwd.as_deref() {
512 let raw = cwd.to_string_lossy();
513 let resolved = resolve_contained_path(root, &raw, "MCP cwd")?;
514 if !resolved.is_dir() {
515 return Err(format!(
516 "MCP server `{name}` cwd is not a directory: {}",
517 resolved.display()
518 ));
519 }
520 }
521 validate_mcp_argv_has_no_literal_credentials(name, &server.args)?;
522 for (index, arg) in server.args.iter().enumerate() {
523 if Path::new(arg).is_absolute() || looks_windows_absolute(arg) {
524 return Err(format!(
525 "MCP server `{name}` argument #{} must not use an absolute path",
526 index + 1
527 ));
528 }
529 if is_relative_stdio_path_arg(arg)
530 && Path::new(arg)
531 .components()
532 .any(|part| matches!(part, Component::ParentDir))
533 {
534 return Err(format!(
535 "MCP server `{name}` argument #{} escapes the plugin root",
536 index + 1
537 ));
538 }
539 }
540 for (destination, source) in &server.env {
541 validate_environment_name("MCP environment destination", destination)?;
542 let source = exact_environment_placeholder(source).ok_or_else(|| {
543 format!(
544 "MCP server `{name}` environment values must be exact `${{SOURCE_ENV}}` references"
545 )
546 })?;
547 validate_environment_name("MCP environment source", source)?;
548 }
549 }
550 (None, Some(url)) => {
551 if !server.scopes.is_empty()
552 || server.oauth.is_some()
553 || server.oauth_resource.is_some()
554 {
555 return Err(format!(
556 "remote MCP server `{name}` may not declare OAuth fields because plugin OAuth is disabled in v0.9.1; use env_headers or bearer_token_env_var"
557 ));
558 }
559 let parsed = reqwest::Url::parse(url)
560 .map_err(|e| format!("MCP server `{name}` URL is invalid: {e}"))?;
561 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
562 return Err(format!(
563 "MCP server `{name}` URL must use http or https and include a host"
564 ));
565 }
566 if parsed.scheme() == "http"
567 && !parsed.host_str().is_some_and(|host| {
568 host.eq_ignore_ascii_case("localhost")
569 || host
570 .parse::<std::net::IpAddr>()
571 .is_ok_and(|address| address.is_loopback())
572 })
573 {
574 return Err(format!(
575 "MCP server `{name}` URL must use HTTPS unless it targets loopback"
576 ));
577 }
578 if !parsed.username().is_empty() || parsed.password().is_some() {
579 return Err(format!(
580 "MCP server `{name}` URL must not embed credentials; use environment-backed authentication"
581 ));
582 }
583 if parsed.query().is_some() || parsed.fragment().is_some() {
584 return Err(format!(
585 "MCP server `{name}` URL may not contain a query or fragment"
586 ));
587 }
588 if server.cwd.is_some() || !server.args.is_empty() || !server.env.is_empty() {
589 return Err(format!(
590 "remote MCP server `{name}` may not declare stdio cwd, args, or env"
591 ));
592 }
593 if !server.headers.is_empty() {
594 return Err(format!(
595 "remote MCP server `{name}` may not contain literal headers; use env_headers or bearer_token_env_var"
596 ));
597 }
598 if let Some(transport) = server.transport.as_deref() {
599 validate_text("MCP transport", transport, 32)?;
600 if !transport.eq_ignore_ascii_case("sse") {
601 return Err(format!(
602 "MCP server `{name}` transport must be `sse` when explicitly set"
603 ));
604 }
605 }
606 for (header, env_var) in &server.env_headers {
607 validate_http_header_name(header)?;
608 validate_environment_name("MCP header environment source", env_var)?;
609 }
610 if let Some(env_var) = server.bearer_token_env_var.as_deref() {
611 validate_environment_name("MCP bearer environment source", env_var)?;
612 }
613 validate_unique_texts("OAuth scope", &server.scopes, 256)?;
614 if let Some(oauth) = &server.oauth
615 && let Some(client_id) = oauth.client_id.as_deref()
616 {
617 validate_text("OAuth client id", client_id, 512)?;
618 }
619 if let Some(resource) = server.oauth_resource.as_deref() {
620 validate_safe_oauth_resource(resource)?;
621 }
622 }
623 (Some(_), Some(_)) => {
624 return Err(format!(
625 "MCP server `{name}` must declare exactly one of command or url"
626 ));
627 }
628 (None, None) => {
629 return Err(format!(
630 "MCP server `{name}` must declare exactly one of command or url"
631 ));
632 }
633 }
634 }
635 Ok(())
636 }
637
638 fn inventory(&self, components: &ResolvedPluginComponents) -> Result<PluginInventory, String> {
639 let stdio_mcp_servers = self.mcp_servers.as_ref().map_or(0, |servers| {
640 servers
641 .values()
642 .filter(|server| server.command.is_some() && server.url.is_none())
643 .count()
644 });
645 let remote_mcp_servers = self.mcp_servers.as_ref().map_or(0, |servers| {
646 servers
647 .values()
648 .filter(|server| server.url.is_some() && server.command.is_none())
649 .count()
650 });
651 let mut network_hosts = self
652 .capabilities
653 .network_hosts
654 .iter()
655 .map(|host| host.to_ascii_lowercase())
656 .collect::<Vec<_>>();
657 if let Some(servers) = &self.mcp_servers {
658 for server in servers.values() {
659 if let Some(url) = server.url.as_deref()
660 && let Ok(url) = reqwest::Url::parse(url)
661 && let Some(host) = url.host_str()
662 {
663 network_hosts.push(host.to_ascii_lowercase());
664 }
665 }
666 }
667 network_hosts.sort();
668 network_hosts.dedup();
669
670 let mut filesystem_roots = self.capabilities.filesystem_roots.clone();
671 filesystem_roots.sort();
672 filesystem_roots.dedup();
673
674 Ok(PluginInventory {
675 skills: components.skills.len(),
676 mcp_servers: self.mcp_servers.as_ref().map_or(0, HashMap::len),
677 stdio_mcp_servers,
678 remote_mcp_servers,
679 commands: components.commands.len(),
680 agents: components.agents.len(),
681 hooks: components.hooks.len(),
682 lsp: components.lsp.len(),
683 native: components.native.len(),
684 filesystem_roots,
685 network_hosts,
686 lifecycle_mutation: self.capabilities.lifecycle_mutation,
687 })
688 }
689
690 #[must_use]
691 pub fn check_when(&self) -> bool {
692 let Some(when) = &self.when else {
693 return true;
694 };
695 if let Some(os_list) = &when.os {
696 let os = std::env::consts::OS;
697 if !os_list
698 .iter()
699 .any(|candidate| candidate.eq_ignore_ascii_case(os))
700 {
701 return false;
702 }
703 }
704 if let Some(binaries) = &when.binaries {
705 for binary in binaries {
706 if !Self::has_binary(binary) {
707 return false;
708 }
709 }
710 }
711 true
712 }
713
714 fn has_binary(name: &str) -> bool {
715 let paths = std::env::var_os("PATH").unwrap_or_default();
716 for path in std::env::split_paths(&paths) {
717 let candidate = path.join(name);
718 if candidate.is_file() {
719 return true;
720 }
721 #[cfg(windows)]
722 if candidate.with_extension("exe").is_file() {
723 return true;
724 }
725 }
726 false
727 }
728 }
729
730 fn safe_toml_parse_error(error: &toml::de::Error) -> String {
731 // `Display` includes source excerpts and can echo a malformed literal
732 // secret. Byte location is enough to repair the file without copying
733 // manifest values into logs, diagnostics, or transcripts.
734 error.span().map_or_else(
735 || "failed to parse plugin.toml; check the v1 schema and field types".to_string(),
736 |span| {
737 format!(
738 "failed to parse plugin.toml near bytes {}..{}; check the v1 schema and field types",
739 span.start, span.end
740 )
741 },
742 )
743 }
744
745 fn read_manifest_bytes(path: &Path) -> Result<Vec<u8>, String> {
746 let file = open_bundle_file(path)
747 .map_err(|e| format!("failed to open plugin.toml without following links: {e}"))?;
748 let mut bytes = Vec::new();
749 file.take(MAX_MANIFEST_BYTES + 1)
750 .read_to_end(&mut bytes)
751 .map_err(|e| format!("failed to read plugin.toml: {e}"))?;
752 if bytes.len() as u64 > MAX_MANIFEST_BYTES {
753 return Err(format!(
754 "plugin.toml exceeds the {MAX_MANIFEST_BYTES}-byte review limit"
755 ));
756 }
757 Ok(bytes)
758 }
759
760 pub fn validate_plugin_name(name: &str) -> Result<(), String> {
761 let count = name.chars().count();
762 let valid = count > 0
763 && count <= MAX_PLUGIN_NAME_CHARS
764 && name
765 .chars()
766 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
767 && name
768 .chars()
769 .next()
770 .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
771 && name
772 .chars()
773 .last()
774 .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit());
775 if valid {
776 Ok(())
777 } else {
778 Err(format!(
779 "plugin name `{name}` must be 1-{MAX_PLUGIN_NAME_CHARS} lowercase ASCII letters, digits, or internal hyphens"
780 ))
781 }
782 }
783
784 fn validate_component_name(kind: &str, name: &str) -> Result<(), String> {
785 let count = name.chars().count();
786 let valid = count > 0
787 && count <= MAX_PLUGIN_NAME_CHARS
788 && name
789 .chars()
790 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
791 && !name.starts_with(['-', '_'])
792 && !name.ends_with(['-', '_']);
793 if valid {
794 Ok(())
795 } else {
796 Err(format!("{kind} name `{name}` is invalid"))
797 }
798 }
799
800 fn validate_optional_text(
801 field: &str,
802 value: Option<&str>,
803 max_chars: usize,
804 ) -> Result<(), String> {
805 if let Some(value) = value {
806 validate_text(field, value, max_chars)?;
807 }
808 Ok(())
809 }
810
811 fn validate_mcp_argv_has_no_literal_credentials(
812 server_name: &str,
813 arguments: &[String],
814 ) -> Result<(), String> {
815 for (index, argument) in arguments.iter().enumerate() {
816 let (key, assigned_value) = argument
817 .split_once('=')
818 .map_or((argument.as_str(), None), |(key, value)| (key, Some(value)));
819 if credential_argument_key(key)
820 && (assigned_value.is_some_and(|value| !value.is_empty())
821 || (assigned_value.is_none() && arguments.get(index + 1).is_some()))
822 {
823 return Err(format!(
824 "MCP server `{server_name}` argument #{} embeds a credential-bearing value; pass credentials through a reviewed environment mapping instead",
825 index + 1
826 ));
827 }
828 if looks_like_literal_credential(argument) {
829 return Err(format!(
830 "MCP server `{server_name}` argument #{} looks like a literal credential; pass credentials through a reviewed environment mapping instead",
831 index + 1
832 ));
833 }
834 }
835 Ok(())
836 }
837
838 fn credential_argument_key(value: &str) -> bool {
839 let key = value
840 .trim_start_matches('-')
841 .replace('_', "-")
842 .to_ascii_lowercase();
843 [
844 "token",
845 "api-key",
846 "apikey",
847 "password",
848 "passwd",
849 "secret",
850 "client-secret",
851 "authorization",
852 "auth-token",
853 "access-key",
854 "private-key",
855 "credential",
856 "credentials",
857 ]
858 .iter()
859 .any(|sensitive| key == *sensitive || key.ends_with(&format!("-{sensitive}")))
860 }
861
862 fn looks_like_literal_credential(value: &str) -> bool {
863 let trimmed = value.trim();
864 trimmed.starts_with("sk-")
865 || trimmed.starts_with("ghp_")
866 || trimmed.starts_with("github_pat_")
867 || trimmed.starts_with("xoxb-")
868 || trimmed.starts_with("xoxp-")
869 || (trimmed.starts_with("AKIA") && trimmed.len() >= 16)
870 }
871
872 fn validate_text(field: &str, value: &str, max_chars: usize) -> Result<(), String> {
873 let trimmed = value.trim();
874 if trimmed.is_empty() || trimmed.chars().count() > max_chars {
875 return Err(format!(
876 "{field} must contain 1-{max_chars} non-whitespace characters"
877 ));
878 }
879 if value.chars().any(char::is_control) {
880 return Err(format!("{field} may not contain control characters"));
881 }
882 if value.chars().any(is_bidi_control) {
883 return Err(format!(
884 "{field} may not contain bidirectional formatting characters"
885 ));
886 }
887 Ok(())
888 }
889
890 fn is_bidi_control(ch: char) -> bool {
891 matches!(
892 ch,
893 '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
894 )
895 }
896
897 fn validate_unique_texts(field: &str, values: &[String], max_chars: usize) -> Result<(), String> {
898 if values.len() > MAX_COMPONENT_PATHS {
899 return Err(format!(
900 "too many {field} values; maximum is {MAX_COMPONENT_PATHS}"
901 ));
902 }
903 let mut seen = BTreeSet::new();
904 for value in values {
905 validate_text(field, value, max_chars)?;
906 let normalized = value.to_ascii_lowercase();
907 if !seen.insert(normalized) {
908 return Err(format!("duplicate {field} value `{value}`"));
909 }
910 }
911 Ok(())
912 }
913
914 fn normalize_network_host(host: &str) -> Result<String, String> {
915 if host.contains("://") || host.contains('/') || host.contains('\\') {
916 return Err(format!(
917 "network host `{host}` must be a host name, not a URL or path"
918 ));
919 }
920 let parsed = reqwest::Url::parse(&format!("https://{host}"))
921 .map_err(|e| format!("network host `{host}` is invalid: {e}"))?;
922 if parsed.port().is_some()
923 || !parsed.username().is_empty()
924 || parsed.password().is_some()
925 || parsed.path() != "/"
926 || parsed.query().is_some()
927 || parsed.fragment().is_some()
928 {
929 return Err(format!(
930 "network host `{host}` must contain only a normalized host name"
931 ));
932 }
933 parsed
934 .host_str()
935 .map(|host| host.to_ascii_lowercase())
936 .ok_or_else(|| format!("network host `{host}` is invalid"))
937 }
938
939 fn validate_bare_executable(command: &str) -> Result<(), String> {
940 if command.len() <= 128
941 && command
942 .chars()
943 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+'))
944 && !matches!(command, "." | "..")
945 {
946 Ok(())
947 } else {
948 Err("MCP command must be a bare executable name or a contained plugin path".to_string())
949 }
950 }
951
952 fn validate_environment_name(field: &str, value: &str) -> Result<(), String> {
953 validate_text(field, value, 128)?;
954 if value
955 .chars()
956 .next()
957 .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_')
958 && value
959 .chars()
960 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
961 {
962 Ok(())
963 } else {
964 Err(format!(
965 "{field} must be an ASCII environment variable name"
966 ))
967 }
968 }
969
970 fn exact_environment_placeholder(value: &str) -> Option<&str> {
971 value.strip_prefix("${")?.strip_suffix('}')
972 }
973
974 fn validate_http_header_name(name: &str) -> Result<(), String> {
975 validate_text("MCP HTTP header name", name, 128)?;
976 reqwest::header::HeaderName::from_bytes(name.as_bytes())
977 .map(|_| ())
978 .map_err(|_| "MCP HTTP header name is invalid".to_string())
979 }
980
981 fn validate_safe_oauth_resource(resource: &str) -> Result<(), String> {
982 validate_text("OAuth resource", resource, 2_048)?;
983 let parsed = reqwest::Url::parse(resource)
984 .map_err(|_| "OAuth resource must be an absolute HTTPS URL".to_string())?;
985 if parsed.scheme() != "https"
986 || parsed.host_str().is_none()
987 || !parsed.username().is_empty()
988 || parsed.password().is_some()
989 || parsed.query().is_some()
990 || parsed.fragment().is_some()
991 {
992 return Err(
993 "OAuth resource must be an HTTPS URL without credentials, query, or fragment"
994 .to_string(),
995 );
996 }
997 Ok(())
998 }
999
1000 fn validate_nested_mcp_schema(content: &str) -> Result<(), String> {
1001 const SERVER_FIELDS: &[&str] = &[
1002 "command",
1003 "args",
1004 "env",
1005 "cwd",
1006 "url",
1007 "transport",
1008 "connect_timeout",
1009 "execute_timeout",
1010 "read_timeout",
1011 "enabled",
1012 "required",
1013 "enabled_tools",
1014 "disabled_tools",
1015 "headers",
1016 "env_headers",
1017 "env_http_headers",
1018 "bearer_token_env_var",
1019 "scopes",
1020 "oauth",
1021 "oauth_resource",
1022 ];
1023 let value: toml::Value =
1024 toml::from_str(content).map_err(|error| safe_toml_parse_error(&error))?;
1025 let Some(servers) = value.get("mcp_servers") else {
1026 return Ok(());
1027 };
1028 let servers = servers
1029 .as_table()
1030 .ok_or_else(|| "mcp_servers must be a table".to_string())?;
1031 for server in servers.values() {
1032 let server = server
1033 .as_table()
1034 .ok_or_else(|| "each MCP server must be a table".to_string())?;
1035 if server
1036 .keys()
1037 .any(|field| !SERVER_FIELDS.contains(&field.as_str()))
1038 {
1039 return Err("plugin MCP server contains an unsupported field".to_string());
1040 }
1041 if let Some(oauth) = server.get("oauth") {
1042 let oauth = oauth
1043 .as_table()
1044 .ok_or_else(|| "plugin MCP oauth must be a table".to_string())?;
1045 if oauth.keys().any(|field| field != "client_id") {
1046 return Err("plugin MCP oauth contains an unsupported field".to_string());
1047 }
1048 }
1049 }
1050 Ok(())
1051 }
1052
1053 fn resolve_spec(
1054 root: &Path,
1055 kind: &str,
1056 spec: Option<&PluginPathSpec>,
1057 default: Option<&str>,
1058 ) -> Result<Vec<PathBuf>, String> {
1059 let Some(spec) = spec else {
1060 return Ok(Vec::new());
1061 };
1062 spec.declared_paths(default)?
1063 .iter()
1064 .map(|path| resolve_contained_path(root, path, kind))
1065 .collect()
1066 }
1067
1068 fn resolve_contained_path(root: &Path, raw: &str, kind: &str) -> Result<PathBuf, String> {
1069 validate_text(&format!("{kind} path"), raw, 1_024)?;
1070 if Path::new(raw).is_absolute() || looks_windows_absolute(raw) {
1071 return Err(format!("{kind} path must be relative: `{raw}`"));
1072 }
1073 if Path::new(raw).components().any(|component| {
1074 matches!(
1075 component,
1076 Component::ParentDir | Component::RootDir | Component::Prefix(_)
1077 )
1078 }) {
1079 return Err(format!("{kind} path escapes the plugin root: `{raw}`"));
1080 }
1081 let joined = root.join(raw);
1082 reject_symlink_components(root, &joined, kind)?;
1083 let canonical = joined
1084 .canonicalize()
1085 .map_err(|e| format!("{kind} path `{raw}` cannot be resolved: {e}"))?;
1086 if !canonical.starts_with(root) {
1087 return Err(format!("{kind} path escapes the plugin root: `{raw}`"));
1088 }
1089 Ok(canonical)
1090 }
1091
1092 fn reject_symlink_components(root: &Path, target: &Path, kind: &str) -> Result<(), String> {
1093 let relative = target
1094 .strip_prefix(root)
1095 .map_err(|_| format!("{kind} path is outside the plugin root"))?;
1096 let mut cursor = root.to_path_buf();
1097 for component in relative.components() {
1098 cursor.push(component.as_os_str());
1099 let metadata = fs::symlink_metadata(&cursor)
1100 .map_err(|e| format!("failed to inspect {kind} path {}: {e}", cursor.display()))?;
1101 if metadata_is_link_or_reparse(&metadata) {
1102 return Err(format!(
1103 "{kind} path may not traverse symbolic link {}",
1104 cursor.display()
1105 ));
1106 }
1107 }
1108 Ok(())
1109 }
1110
1111 fn looks_windows_absolute(raw: &str) -> bool {
1112 let bytes = raw.as_bytes();
1113 bytes
1114 .first()
1115 .is_some_and(|byte| matches!(*byte, b'\\' | b'/'))
1116 || (bytes.len() >= 3
1117 && bytes[0].is_ascii_alphabetic()
1118 && bytes[1] == b':'
1119 && matches!(bytes[2], b'\\' | b'/'))
1120 }
1121
1122 fn hash_bundle(
1123 root: &Path,
1124 manifest_bytes: &[u8],
1125 ) -> Result<(String, BTreeMap<PathBuf, String>), String> {
1126 let mut hasher = Sha256::new();
1127 // v2 length-frames every variable-length field. The v1 delimiter-only
1128 // stream was structurally ambiguous across file-record boundaries.
1129 // Changing the domain invalidates every ambiguous v1 receipt.
1130 hasher.update(b"codewhale-plugin-content-v2\0plugin.toml\0");
1131 hasher.update((manifest_bytes.len() as u64).to_le_bytes());
1132 hasher.update(manifest_bytes);
1133 let mut budget = HashBudget::default();
1134 // Hash the complete bundle, not only declared component roots. Local MCP
1135 // entrypoints and companion assets are security-relevant even when they do
1136 // not have a separate component table.
1137 hash_path(root, root, &mut hasher, &mut budget)?;
1138 Ok((hex_digest(hasher.finalize()), budget.file_hashes))
1139 }
1140
1141 #[derive(Default)]
1142 struct HashBudget {
1143 files: usize,
1144 bytes: u64,
1145 file_hashes: BTreeMap<PathBuf, String>,
1146 }
1147
1148 fn hash_path(
1149 root: &Path,
1150 path: &Path,
1151 hasher: &mut Sha256,
1152 budget: &mut HashBudget,
1153 ) -> Result<(), String> {
1154 let metadata = fs::symlink_metadata(path)
1155 .map_err(|e| format!("failed to inspect component {}: {e}", path.display()))?;
1156 if metadata_is_link_or_reparse(&metadata) {
1157 return Err(format!(
1158 "component trees may not contain symbolic link {}",
1159 path.display()
1160 ));
1161 }
1162 let relative = path
1163 .strip_prefix(root)
1164 .map_err(|_| format!("component {} is outside the plugin root", path.display()))?;
1165 hash_permissions(&metadata, hasher);
1166 if metadata.is_dir() {
1167 #[cfg(windows)]
1168 let directory_guard = open_bundle_directory(path)
1169 .map_err(|e| format!("failed to open component directory safely: {e}"))?;
1170 #[cfg(windows)]
1171 ensure_windows_path_still_opened(path, &directory_guard)?;
1172 hasher.update(b"D\0");
1173 super::path_identity::hash_os_path(hasher, b"bundle-relative-directory", relative);
1174 let mut entries = fs::read_dir(path)
1175 .map_err(|e| format!("failed to read component directory {}: {e}", path.display()))?
1176 .collect::<Result<Vec<_>, _>>()
1177 .map_err(|e| format!("failed to read component directory {}: {e}", path.display()))?;
1178 entries.sort_by_key(fs::DirEntry::file_name);
1179 for entry in entries {
1180 hash_path(root, &entry.path(), hasher, budget)?;
1181 }
1182 hasher.update(b"E\0");
1183 #[cfg(windows)]
1184 ensure_windows_path_still_opened(path, &directory_guard)?;
1185 } else if metadata.is_file() {
1186 budget.files += 1;
1187 if budget.files > MAX_HASHED_FILES {
1188 return Err(format!(
1189 "plugin bundle content exceeds the v0.9.1 review limit ({MAX_HASHED_FILES} files / {MAX_HASHED_BYTES} bytes)"
1190 ));
1191 }
1192 hasher.update(b"F\0");
1193 super::path_identity::hash_os_path(hasher, b"bundle-relative-file", relative);
1194 let mut file = open_bundle_file(path)
1195 .map_err(|e| format!("failed to read component file {}: {e}", path.display()))?;
1196 #[cfg(windows)]
1197 ensure_windows_path_still_opened(path, &file)?;
1198 let expected_len = file
1199 .metadata()
1200 .map_err(|e| format!("failed to inspect component file {}: {e}", path.display()))?
1201 .len();
1202 hasher.update(expected_len.to_le_bytes());
1203 let mut file_hasher = Sha256::new();
1204 file_hasher.update(b"codewhale-plugin-file-bytes-v1\0");
1205 // Keep the read buffer off the stack. `hash_path` is recursive and the
1206 // fixed-size array inflated every directory frame, which could exhaust
1207 // a Tokio worker stack while revalidating a nested plugin bundle.
1208 let mut buffer = vec![0_u8; 64 * 1024];
1209 let mut actual_len = 0_u64;
1210 loop {
1211 let read = file
1212 .read(&mut buffer)
1213 .map_err(|e| format!("failed to read component file {}: {e}", path.display()))?;
1214 if read == 0 {
1215 break;
1216 }
1217 actual_len = actual_len.saturating_add(read as u64);
1218 budget.bytes = budget.bytes.saturating_add(read as u64);
1219 if budget.bytes > MAX_HASHED_BYTES {
1220 return Err(format!(
1221 "plugin bundle content exceeds the v0.9.1 review limit ({MAX_HASHED_FILES} files / {MAX_HASHED_BYTES} bytes)"
1222 ));
1223 }
1224 hasher.update(&buffer[..read]);
1225 file_hasher.update(&buffer[..read]);
1226 }
1227 if actual_len != expected_len {
1228 return Err(format!(
1229 "component file {} changed length while being reviewed",
1230 path.display()
1231 ));
1232 }
1233 budget
1234 .file_hashes
1235 .insert(relative.to_path_buf(), hex_digest(file_hasher.finalize()));
1236 #[cfg(windows)]
1237 ensure_windows_path_still_opened(path, &file)?;
1238 } else {
1239 return Err(format!(
1240 "component {} is neither a regular file nor directory",
1241 path.display()
1242 ));
1243 }
1244 Ok(())
1245 }
1246
1247 #[cfg(unix)]
1248 fn hash_permissions(metadata: &fs::Metadata, hasher: &mut Sha256) {
1249 use std::os::unix::fs::PermissionsExt;
1250
1251 // Runtime snapshots deliberately remove group/other access and write bits.
1252 // Bind identity only to whether a regular file is executable, so the
1253 // owner-only staged representation has the same reviewed content hash.
1254 hasher.update(b"unix-executable\0");
1255 hasher.update([u8::from(
1256 metadata.is_file() && metadata.permissions().mode() & 0o111 != 0,
1257 )]);
1258 }
1259
1260 #[cfg(not(unix))]
1261 fn hash_permissions(metadata: &fs::Metadata, hasher: &mut Sha256) {
1262 let _ = metadata;
1263 // Windows staging marks files read-only as a defense-in-depth hardening
1264 // step; that representation change is not plugin content identity.
1265 hasher.update(b"portable-mode\0");
1266 }
1267
1268 #[cfg(unix)]
1269 pub(crate) fn open_bundle_file(path: &Path) -> std::io::Result<fs::File> {
1270 use std::os::unix::fs::OpenOptionsExt;
1271
1272 fs::OpenOptions::new()
1273 .read(true)
1274 .custom_flags(libc::O_NOFOLLOW)
1275 .open(path)
1276 }
1277
1278 #[cfg(windows)]
1279 pub(crate) fn open_bundle_file(path: &Path) -> std::io::Result<fs::File> {
1280 use std::os::windows::fs::OpenOptionsExt as _;
1281
1282 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1283 let file = fs::OpenOptions::new()
1284 .read(true)
1285 .share_mode(0x0000_0001) // deny concurrent writes and replacement
1286 .custom_flags(0x0020_0000) // FILE_FLAG_OPEN_REPARSE_POINT
1287 .open(path)?;
1288 let metadata = file.metadata()?;
1289 let identity = windows_file_identity(&file)?;
1290 if !metadata.is_file()
1291 || identity.attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0
1292 || identity.links != 1
1293 {
1294 return Err(std::io::Error::new(
1295 std::io::ErrorKind::InvalidData,
1296 "plugin file is a reparse point, hard link, or non-regular file",
1297 ));
1298 }
1299 Ok(file)
1300 }
1301
1302 #[cfg(all(not(unix), not(windows)))]
1303 pub(crate) fn open_bundle_file(path: &Path) -> std::io::Result<fs::File> {
1304 fs::File::open(path)
1305 }
1306
1307 #[cfg(windows)]
1308 fn open_bundle_directory(path: &Path) -> std::io::Result<fs::File> {
1309 use std::os::windows::fs::OpenOptionsExt as _;
1310
1311 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1312 let file = fs::OpenOptions::new()
1313 .read(true)
1314 .share_mode(0x0000_0001)
1315 .custom_flags(0x0220_0000) // BACKUP_SEMANTICS | OPEN_REPARSE_POINT
1316 .open(path)?;
1317 let metadata = file.metadata()?;
1318 let identity = windows_file_identity(&file)?;
1319 if !metadata.is_dir() || identity.attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1320 return Err(std::io::Error::new(
1321 std::io::ErrorKind::InvalidData,
1322 "plugin directory is a reparse point or non-directory",
1323 ));
1324 }
1325 Ok(file)
1326 }
1327
1328 /// Reopen a reviewed path only to compare its current handle identity with a
1329 /// retained authority handle. Desired access is zero and sharing is permissive
1330 /// because the retained handle remains responsible for denying writes and
1331 /// replacement throughout the comparison.
1332 #[cfg(windows)]
1333 pub(crate) fn open_bundle_identity_probe(
1334 path: &Path,
1335 expect_directory: bool,
1336 ) -> std::io::Result<fs::File> {
1337 use std::os::windows::fs::OpenOptionsExt as _;
1338
1339 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1340 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
1341 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
1342 const FILE_SHARE_READ_WRITE_DELETE: u32 = 0x0000_0007;
1343 let flags = FILE_FLAG_OPEN_REPARSE_POINT
1344 | if expect_directory {
1345 FILE_FLAG_BACKUP_SEMANTICS
1346 } else {
1347 0
1348 };
1349 let file = fs::OpenOptions::new()
1350 .access_mode(0)
1351 .share_mode(FILE_SHARE_READ_WRITE_DELETE)
1352 .custom_flags(flags)
1353 .open(path)?;
1354 let metadata = file.metadata()?;
1355 let identity = windows_file_identity(&file)?;
1356 let expected_kind = if expect_directory {
1357 metadata.is_dir()
1358 } else {
1359 metadata.is_file() && identity.links == 1
1360 };
1361 if !expected_kind || identity.attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1362 return Err(std::io::Error::new(
1363 std::io::ErrorKind::InvalidData,
1364 "plugin identity probe found a reparse point, hard link, or unexpected object",
1365 ));
1366 }
1367 Ok(file)
1368 }
1369
1370 #[cfg(windows)]
1371 fn ensure_windows_path_still_opened(path: &Path, opened: &fs::File) -> Result<(), String> {
1372 let after = fs::symlink_metadata(path)
1373 .map_err(|e| format!("failed to re-inspect plugin path after handle open: {e}"))?;
1374 if metadata_is_link_or_reparse(&after) {
1375 return Err("plugin path changed into a reparse point during validation".to_string());
1376 }
1377 let expect_directory = if after.is_dir() {
1378 true
1379 } else if after.is_file() {
1380 false
1381 } else {
1382 return Err("plugin path changed into an unsupported object during validation".to_string());
1383 };
1384 let current = open_bundle_identity_probe(path, expect_directory)
1385 .map_err(|e| format!("failed to reopen plugin path for identity validation: {e}"))?;
1386 let opened = windows_file_identity(opened)
1387 .map_err(|e| format!("failed to identify retained plugin handle: {e}"))?;
1388 let current = windows_file_identity(&current)
1389 .map_err(|e| format!("failed to identify current plugin path: {e}"))?;
1390 if opened.volume != current.volume || opened.index != current.index {
1391 return Err("plugin path identity changed between handle open and validation".to_string());
1392 }
1393 Ok(())
1394 }
1395
1396 fn hash_inventory(inventory: &PluginInventory) -> String {
1397 let mut normalized = BTreeMap::new();
1398 normalized.insert("skills", inventory.skills.to_string());
1399 normalized.insert("mcp", inventory.mcp_servers.to_string());
1400 normalized.insert("mcp-stdio", inventory.stdio_mcp_servers.to_string());
1401 normalized.insert("mcp-remote", inventory.remote_mcp_servers.to_string());
1402 normalized.insert("commands", inventory.commands.to_string());
1403 normalized.insert("agents", inventory.agents.to_string());
1404 normalized.insert("hooks", inventory.hooks.to_string());
1405 normalized.insert("lsp", inventory.lsp.to_string());
1406 normalized.insert("native", inventory.native.to_string());
1407 normalized.insert("filesystem", inventory.filesystem_roots.join("\n"));
1408 normalized.insert("network", inventory.network_hosts.join("\n"));
1409 normalized.insert("lifecycle", inventory.lifecycle_mutation.to_string());
1410 let mut hasher = Sha256::new();
1411 hasher.update(b"codewhale-plugin-capabilities-v1\0");
1412 for (key, value) in normalized {
1413 hasher.update(key.as_bytes());
1414 hasher.update(b"\0");
1415 hasher.update(value.as_bytes());
1416 hasher.update(b"\0");
1417 }
1418 hex_digest(hasher.finalize())
1419 }
1420
1421 fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
1422 let bytes = bytes.as_ref();
1423 let mut output = String::with_capacity(bytes.len() * 2);
1424 for byte in bytes {
1425 use std::fmt::Write as _;
1426 let _ = write!(output, "{byte:02x}");
1427 }
1428 output
1429 }
1430
1431 #[cfg(test)]
1432 mod tests {
1433 use super::*;
1434
1435 fn write_manifest(root: &Path, extra: &str) -> PathBuf {
1436 fs::create_dir_all(root.join("skills/example")).unwrap();
1437 fs::write(
1438 root.join("skills/example/SKILL.md"),
1439 "---\nname: example\ndescription: example\n---\nbody\n",
1440 )
1441 .unwrap();
1442 let path = root.join("plugin.toml");
1443 fs::write(
1444 &path,
1445 format!(
1446 "schema_version = 1\n[plugin]\nname = \"example-plugin\"\nversion = \"1.2.3\"\n[skills]\npath = \"skills\"\n{extra}"
1447 ),
1448 )
1449 .unwrap();
1450 path
1451 }
1452
1453 #[test]
1454 fn validates_versioned_manifest_and_hashes_declared_content() {
1455 let tmp = tempfile::tempdir().unwrap();
1456 let path = write_manifest(tmp.path(), "");
1457 let first = PluginManifest::validate_from_path(&path).unwrap();
1458 assert_eq!(first.inventory.skills, 1);
1459 assert!(first.warnings.is_empty());
1460
1461 fs::write(
1462 tmp.path().join("skills/example/SKILL.md"),
1463 "---\nname: example\ndescription: changed\n---\nbody\n",
1464 )
1465 .unwrap();
1466 let second = PluginManifest::validate_from_path(&path).unwrap();
1467 assert_ne!(first.content_hash, second.content_hash);
1468 assert_eq!(first.capability_hash, second.capability_hash);
1469 }
1470
1471 #[test]
1472 fn validates_deep_bundle_on_small_stack() {
1473 let tmp = tempfile::tempdir().unwrap();
1474 let manifest = write_manifest(tmp.path(), "");
1475 let mut nested = tmp.path().join("nested");
1476 for level in 0..8 {
1477 nested = nested.join(format!("level-{level}"));
1478 }
1479 fs::create_dir_all(&nested).unwrap();
1480 fs::write(nested.join("payload.txt"), "nested bundle payload").unwrap();
1481
1482 let worker = std::thread::Builder::new()
1483 .name("plugin-small-stack-validation".to_string())
1484 .stack_size(512 * 1024)
1485 .spawn(move || PluginManifest::validate_from_path(&manifest))
1486 .unwrap();
1487 let validated = worker
1488 .join()
1489 .expect("nested plugin validation must not overflow a small stack")
1490 .expect("nested plugin bundle must validate");
1491 assert_eq!(validated.inventory.skills, 1);
1492 }
1493
1494 #[test]
1495 fn bundle_hash_is_deterministic_and_covers_undeclared_companion_files() {
1496 let left = tempfile::tempdir().unwrap();
1497 let right = tempfile::tempdir().unwrap();
1498 let left_manifest = write_manifest(left.path(), "");
1499 let right_manifest = write_manifest(right.path(), "");
1500 fs::write(left.path().join("z.txt"), "z").unwrap();
1501 fs::write(left.path().join("a.txt"), "a").unwrap();
1502 fs::write(right.path().join("a.txt"), "a").unwrap();
1503 fs::write(right.path().join("z.txt"), "z").unwrap();
1504
1505 let left_hash = PluginManifest::validate_from_path(&left_manifest).unwrap();
1506 let right_hash = PluginManifest::validate_from_path(&right_manifest).unwrap();
1507 assert_eq!(left_hash.content_hash, right_hash.content_hash);
1508 assert_eq!(left_hash.capability_hash, right_hash.capability_hash);
1509
1510 fs::write(right.path().join("z.txt"), "changed").unwrap();
1511 let changed = PluginManifest::validate_from_path(&right_manifest).unwrap();
1512 assert_ne!(right_hash.content_hash, changed.content_hash);
1513 assert_eq!(right_hash.capability_hash, changed.capability_hash);
1514 }
1515
1516 #[cfg(unix)]
1517 #[test]
1518 fn bundle_hash_frames_adversarial_binary_records() {
1519 let left = tempfile::tempdir().unwrap();
1520 let right = tempfile::tempdir().unwrap();
1521 let manifest = b"schema_version = 1\n[plugin]\nname = \"framing\"\nversion = \"1.0.0\"\n";
1522 for root in [left.path(), right.path()] {
1523 fs::write(root.join("plugin.toml"), manifest).unwrap();
1524 }
1525
1526 fs::write(left.path().join("a.bin"), b"alpha").unwrap();
1527 fs::write(left.path().join("b.bin"), b"omega").unwrap();
1528
1529 let mut adversarial = b"alpha\0unix-executable\0\0F\0codewhale-os-path-v1\0".to_vec();
1530 adversarial.extend_from_slice(&(b"bundle-relative-file".len() as u64).to_le_bytes());
1531 adversarial.extend_from_slice(b"bundle-relative-file");
1532 adversarial.extend_from_slice(b"unix-bytes\0");
1533 adversarial.extend_from_slice(&(b"b.bin".len() as u64).to_le_bytes());
1534 adversarial.extend_from_slice(b"b.bin");
1535 adversarial.extend_from_slice(b"omega");
1536 fs::write(right.path().join("a.bin"), adversarial).unwrap();
1537
1538 let left = PluginManifest::validate_from_path(&left.path().join("plugin.toml")).unwrap();
1539 let right = PluginManifest::validate_from_path(&right.path().join("plugin.toml")).unwrap();
1540 assert_ne!(left.content_hash, right.content_hash);
1541 assert_eq!(left.capability_hash, right.capability_hash);
1542 }
1543
1544 // Darwin rejects these malformed bytes at the filesystem boundary. The
1545 // platform-independent native-path framing is covered in path_identity;
1546 // run this full bundle-walk regression where Unix permits the entries.
1547 #[cfg(all(unix, not(target_os = "macos")))]
1548 #[test]
1549 fn bundle_hash_distinguishes_lossy_colliding_native_file_names() {
1550 use std::ffi::OsString;
1551 use std::os::unix::ffi::OsStringExt as _;
1552
1553 let left = tempfile::tempdir().unwrap();
1554 let right = tempfile::tempdir().unwrap();
1555 let left_manifest = write_manifest(left.path(), "");
1556 let right_manifest = write_manifest(right.path(), "");
1557 let left_name = OsString::from_vec(vec![b'a', 0xff]);
1558 let right_name = OsString::from_vec(vec![b'a', 0xfe]);
1559 assert_eq!(left_name.to_string_lossy(), right_name.to_string_lossy());
1560 fs::write(left.path().join(left_name), "same bytes").unwrap();
1561 fs::write(right.path().join(right_name), "same bytes").unwrap();
1562
1563 let left = PluginManifest::validate_from_path(&left_manifest).unwrap();
1564 let right = PluginManifest::validate_from_path(&right_manifest).unwrap();
1565 assert_ne!(left.content_hash, right.content_hash);
1566 }
1567
1568 #[test]
1569 fn legacy_manifest_is_accepted_with_migration_warning() {
1570 let tmp = tempfile::tempdir().unwrap();
1571 fs::write(
1572 tmp.path().join("plugin.toml"),
1573 "[plugin]\nname = \"legacy\"\n",
1574 )
1575 .unwrap();
1576 let validated =
1577 PluginManifest::validate_from_path(&tmp.path().join("plugin.toml")).unwrap();
1578 assert_eq!(validated.manifest.schema_version, 0);
1579 assert_eq!(validated.manifest.plugin.version, "0.0.0");
1580 assert_eq!(validated.warnings.len(), 2);
1581 }
1582
1583 #[test]
1584 fn rejects_unknown_fields_invalid_names_and_versions() {
1585 let invalid = [
1586 "schema_version = 1\nunknown = true\n[plugin]\nname = \"ok\"\nversion = \"1.0.0\"\n",
1587 "schema_version = 1\n[plugin]\nname = \"Bad_Name\"\nversion = \"1.0.0\"\n",
1588 "schema_version = 1\n[plugin]\nname = \"ok\"\nversion = \"latest\"\n",
1589 "schema_version = 1\n[plugin]\nname = \"ok\"\n",
1590 ];
1591 for source in invalid {
1592 let tmp = tempfile::tempdir().unwrap();
1593 let path = tmp.path().join("plugin.toml");
1594 fs::write(&path, source).unwrap();
1595 assert!(PluginManifest::validate_from_path(&path).is_err());
1596 }
1597 }
1598
1599 #[test]
1600 fn parse_diagnostics_do_not_echo_manifest_values() {
1601 let tmp = tempfile::tempdir().unwrap();
1602 let path = tmp.path().join("plugin.toml");
1603 fs::write(
1604 &path,
1605 "schema_version = 1\n[plugin]\nname = \"safe\"\nversion = \"1.0.0\"\ndescription = [\"sk-sensitive-value\"]\n",
1606 )
1607 .unwrap();
1608 let error = PluginManifest::validate_from_path(&path).unwrap_err();
1609 assert!(!error.contains("sk-sensitive-value"));
1610 }
1611
1612 #[test]
1613 fn rejects_parent_absolute_and_windows_absolute_component_paths() {
1614 for bad in [
1615 "../escape",
1616 "/tmp/escape",
1617 r"C:\\escape",
1618 r"\\\\server\\share",
1619 ] {
1620 let tmp = tempfile::tempdir().unwrap();
1621 let path = write_manifest(tmp.path(), &format!("\n[commands]\npath = {bad:?}\n"));
1622 assert!(
1623 PluginManifest::validate_from_path(&path).is_err(),
1624 "accepted {bad}"
1625 );
1626 }
1627 }
1628
1629 #[cfg(unix)]
1630 #[test]
1631 fn rejects_symlinked_component_and_nested_symlink() {
1632 use std::os::unix::fs::symlink;
1633
1634 let outside = tempfile::tempdir().unwrap();
1635 fs::write(outside.path().join("SKILL.md"), "# outside").unwrap();
1636
1637 let tmp = tempfile::tempdir().unwrap();
1638 let path = write_manifest(tmp.path(), "");
1639 fs::remove_dir_all(tmp.path().join("skills")).unwrap();
1640 symlink(outside.path(), tmp.path().join("skills")).unwrap();
1641 assert!(PluginManifest::validate_from_path(&path).is_err());
1642
1643 fs::remove_file(tmp.path().join("skills")).unwrap();
1644 fs::create_dir_all(tmp.path().join("skills/example")).unwrap();
1645 fs::write(tmp.path().join("skills/example/SKILL.md"), "# safe").unwrap();
1646 symlink(
1647 outside.path().join("SKILL.md"),
1648 tmp.path().join("skills/example/linked.md"),
1649 )
1650 .unwrap();
1651 assert!(PluginManifest::validate_from_path(&path).is_err());
1652 }
1653
1654 #[cfg(unix)]
1655 #[test]
1656 fn rejects_symlinked_manifest() {
1657 use std::os::unix::fs::symlink;
1658
1659 let tmp = tempfile::tempdir().unwrap();
1660 let real = tmp.path().join("real.toml");
1661 fs::write(
1662 &real,
1663 "schema_version = 1\n[plugin]\nname = \"linked\"\nversion = \"1.0.0\"\n",
1664 )
1665 .unwrap();
1666 let linked = tmp.path().join("plugin.toml");
1667 symlink(&real, &linked).unwrap();
1668
1669 assert!(PluginManifest::validate_from_path(&linked).is_err());
1670 }
1671
1672 #[cfg(unix)]
1673 #[test]
1674 fn rejects_symlinked_bundle_root() {
1675 use std::os::unix::fs::symlink;
1676
1677 let tmp = tempfile::tempdir().unwrap();
1678 let real_root = tmp.path().join("real");
1679 fs::create_dir(&real_root).unwrap();
1680 fs::write(
1681 real_root.join("plugin.toml"),
1682 "schema_version = 1\n[plugin]\nname = \"linked-root\"\nversion = \"1.0.0\"\n",
1683 )
1684 .unwrap();
1685 let linked_root = tmp.path().join("linked");
1686 symlink(&real_root, &linked_root).unwrap();
1687
1688 assert!(PluginManifest::validate_from_path(&linked_root.join("plugin.toml")).is_err());
1689 }
1690
1691 #[test]
1692 fn rejects_absolute_mcp_arguments_and_embedded_url_credentials() {
1693 let absolute = tempfile::tempdir().unwrap();
1694 let absolute_path = write_manifest(
1695 absolute.path(),
1696 "\n[mcp_servers.local]\ncommand = \"node\"\nargs = [\"/tmp/server.js\"]\n",
1697 );
1698 assert!(PluginManifest::validate_from_path(&absolute_path).is_err());
1699
1700 let credentialed = tempfile::tempdir().unwrap();
1701 let credentialed_path = write_manifest(
1702 credentialed.path(),
1703 "\n[mcp_servers.remote]\nurl = \"https://user:secret@example.invalid/mcp\"\n",
1704 );
1705 assert!(PluginManifest::validate_from_path(&credentialed_path).is_err());
1706 }
1707
1708 #[test]
1709 fn windows_rooted_path_detection_is_host_independent() {
1710 assert!(looks_windows_absolute(r"C:\plugins\server.js"));
1711 assert!(looks_windows_absolute(r"C:/plugins/server.js"));
1712 assert!(looks_windows_absolute(r"\plugins\server.js"));
1713 assert!(looks_windows_absolute("/plugins/server.js"));
1714 assert!(!looks_windows_absolute("plugins/server.js"));
1715 assert!(!looks_windows_absolute("server.js"));
1716 }
1717
1718 #[cfg(windows)]
1719 #[test]
1720 fn retained_bundle_handle_allows_identity_revalidation_but_denies_writes() {
1721 use std::os::windows::fs::OpenOptionsExt as _;
1722
1723 let directory = tempfile::tempdir().unwrap();
1724 let path = directory.path().join("reviewed.bin");
1725 fs::write(&path, b"reviewed").unwrap();
1726 let retained = open_bundle_file(&path).unwrap();
1727
1728 ensure_windows_path_still_opened(&path, &retained).unwrap();
1729 let denied = fs::OpenOptions::new()
1730 .write(true)
1731 .share_mode(0x0000_0007)
1732 .open(&path);
1733 assert!(denied.is_err(), "retained authority must deny mutation");
1734
1735 drop(retained);
1736 fs::OpenOptions::new().write(true).open(&path).unwrap();
1737 }
1738
1739 #[test]
1740 fn unsupported_capabilities_are_inventoried() {
1741 let tmp = tempfile::tempdir().unwrap();
1742 fs::create_dir_all(tmp.path().join("hooks")).unwrap();
1743 let path = write_manifest(
1744 tmp.path(),
1745 "\n[hooks]\npath = \"hooks\"\n[capabilities]\nfilesystem_roots = [\"workspace\"]\nlifecycle_mutation = true\n",
1746 );
1747 let validated = PluginManifest::validate_from_path(&path).unwrap();
1748 assert!(validated.inventory.has_unsupported_capabilities());
1749 assert!(validated.inventory.unsupported_labels().contains(&"hooks"));
1750 assert!(
1751 validated
1752 .inventory
1753 .unsupported_labels()
1754 .contains(&"filesystem-roots")
1755 );
1756 }
1757
1758 #[test]
1759 fn plugin_mcp_schema_and_transport_combinations_fail_closed() {
1760 let invalid = [
1761 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\nunknown_nested = true\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
1762 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\nargs = [\"secret\"]\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
1763 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp?token=secret\"\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
1764 "\n[mcp_servers.remote]\nurl = \"http://example.invalid/mcp\"\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
1765 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\n[capabilities]\nnetwork_hosts = [\"other.invalid\"]\n",
1766 "\n[mcp_servers.local]\ncommand = \"node\"\ntransport = \"sse\"\n",
1767 "\n[mcp_servers.local]\ncommand = \"node\"\nconnect_timeout = 0\n",
1768 "\n[mcp_servers.local]\ncommand = \"node\"\nenabled_tools = [\"same\"]\ndisabled_tools = [\"same\"]\n",
1769 "\n[mcp_servers.local]\ncommand = \"node\"\n[mcp_servers.local.env]\nTOKEN = \"literal-secret\"\n",
1770 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\n[mcp_servers.remote.headers]\nAuthorization = \"literal-secret\"\n[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n",
1771 "\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",
1772 ];
1773 for extra in invalid {
1774 let tmp = tempfile::tempdir().unwrap();
1775 let path = write_manifest(tmp.path(), extra);
1776 assert!(
1777 PluginManifest::validate_from_path(&path).is_err(),
1778 "accepted invalid plugin MCP manifest: {extra}"
1779 );
1780 }
1781 }
1782
1783 #[test]
1784 fn plugin_mcp_remote_allowlist_and_env_provenance_are_exact() {
1785 let tmp = tempfile::tempdir().unwrap();
1786 let path = write_manifest(
1787 tmp.path(),
1788 r#"
1789 [mcp_servers.remote]
1790 url = "https://Example.Invalid:8443/mcp/v1"
1791 transport = "sse"
1792 connect_timeout = 30
1793 execute_timeout = 120
1794 read_timeout = 180
1795 required = true
1796 enabled_tools = ["read"]
1797 disabled_tools = ["write"]
1798 bearer_token_env_var = "PLUGIN_BEARER"
1799
1800 [mcp_servers.remote.env_headers]
1801 X_Api_Key = "PLUGIN_API_KEY"
1802
1803 [capabilities]
1804 network_hosts = ["example.invalid"]
1805 "#,
1806 );
1807 let validated = PluginManifest::validate_from_path(&path).unwrap();
1808 assert_eq!(
1809 validated.inventory.network_hosts,
1810 vec!["example.invalid".to_string()]
1811 );
1812 assert_eq!(validated.inventory.remote_mcp_servers, 1);
1813 }
1814
1815 #[test]
1816 fn plugin_mcp_oauth_fields_are_rejected_for_v091() {
1817 for oauth_fields in [
1818 "scopes = [\"tools.read\"]\n",
1819 "oauth_resource = \"https://resource.invalid/mcp\"\n",
1820 "[mcp_servers.remote.oauth]\nclient_id = \"public-client-id\"\n",
1821 ] {
1822 let tmp = tempfile::tempdir().unwrap();
1823 let path = write_manifest(
1824 tmp.path(),
1825 &format!(
1826 "\n[mcp_servers.remote]\nurl = \"https://example.invalid/mcp\"\n{oauth_fields}[capabilities]\nnetwork_hosts = [\"example.invalid\"]\n"
1827 ),
1828 );
1829 let error = PluginManifest::validate_from_path(&path)
1830 .expect_err("plugin OAuth authority must remain disabled in v0.9.1");
1831 assert!(error.contains("plugin OAuth is disabled in v0.9.1"));
1832 }
1833 }
1834
1835 #[test]
1836 fn reviewed_stdio_argv_rejects_literal_credentials_but_accepts_exact_safe_values() {
1837 for args in [
1838 r#"["server.js", "--token", "literal-secret"]"#,
1839 r#"["server.js", "--api-key=literal-secret"]"#,
1840 r#"["server.js", "sk-live-literal"]"#,
1841 ] {
1842 let tmp = tempfile::tempdir().unwrap();
1843 fs::write(tmp.path().join("server.js"), "// entrypoint\n").unwrap();
1844 let path = write_manifest(
1845 tmp.path(),
1846 &format!("\n[mcp_servers.local]\ncommand = \"node\"\nargs = {args}\n"),
1847 );
1848 let error = PluginManifest::validate_from_path(&path)
1849 .expect_err("credential-bearing argv must fail closed");
1850 assert!(error.contains("credential"), "{error}");
1851 }
1852
1853 let safe = tempfile::tempdir().unwrap();
1854 fs::write(safe.path().join("server.js"), "// entrypoint\n").unwrap();
1855 let path = write_manifest(
1856 safe.path(),
1857 r#"
1858 [mcp_servers.local]
1859 command = "node"
1860 args = ["server.js", "--mode=worker", "-e", "console.log('ready')"]
1861 "#,
1862 );
1863 PluginManifest::validate_from_path(&path)
1864 .expect("safe interpreter argv should remain reviewable exactly");
1865 }
1866
1867 #[test]
1868 fn manifest_text_rejects_controls_and_bidirectional_spoofing() {
1869 for unsafe_text in ["line\nbreak", "safe\u{202e}lmot.nigulp"] {
1870 let tmp = tempfile::tempdir().unwrap();
1871 let path = tmp.path().join("plugin.toml");
1872 fs::write(
1873 &path,
1874 format!(
1875 "schema_version = 1\n[plugin]\nname = \"safe\"\nversion = \"1.0.0\"\nauthor = {unsafe_text:?}\n"
1876 ),
1877 )
1878 .unwrap();
1879 assert!(PluginManifest::validate_from_path(&path).is_err());
1880 }
1881 }
1882 }
1883
1883 lines RUST