1use crate::protocol::{
2 DeliveryBlobDigestV1, DeliveryBundleManifestV2, DeliveryManifestDigestV2,
3 ResolvedBundleReceipt, SpawnBundleDigest, SpawnBundleId, SpawnBundleRevision,
4 MAX_LAUNCH_BUNDLES,
5};
6use ring::digest::{Context, SHA256};
7use serde_json::Value;
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::fs::{self, File, OpenOptions};
11use std::io::{self, Read};
12use std::path::{Component, Path, PathBuf};
13use thiserror::Error;
14
15pub const MAX_BUNDLE_CATALOG_ENTRIES: usize = MAX_LAUNCH_BUNDLES;
16pub const MAX_BUNDLE_FILES: usize = 128;
17pub const MAX_BUNDLE_FILE_BYTES: usize = 1024 * 1024;
18pub const MAX_BUNDLE_TOTAL_BYTES: usize = 32 * 1024 * 1024;
19pub const MAX_BUNDLE_PATH_BYTES: usize = 512;
20
21const AGENT_PLUGINS_SCHEMA: &str =
22 "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
23const MAX_SKILL_FRONTMATTER_BYTES: usize = 16 * 1024;
24const MAX_SKILL_NAME_CHARS: usize = 64;
25const MAX_SKILL_DESCRIPTION_CHARS: usize = 1024;
26const DIGEST_DOMAIN: &[u8] = b"g4a-bundle-v1\0";
27
28#[derive(Clone, Eq, PartialEq)]
30pub struct NodeBundleFile {
31 path: String,
32 bytes: Vec<u8>,
33}
34
35impl fmt::Debug for NodeBundleFile {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 formatter
38 .debug_struct("NodeBundleFile")
39 .field("path", &self.path)
40 .field("byte_length", &self.bytes.len())
41 .finish()
42 }
43}
44
45impl NodeBundleFile {
46 pub fn path(&self) -> &str {
47 &self.path
48 }
49
50 pub fn bytes(&self) -> &[u8] {
51 &self.bytes
52 }
53}
54
55#[derive(Clone, Eq, PartialEq)]
57pub struct NodeBundle {
58 id: SpawnBundleId,
59 revision: SpawnBundleRevision,
60 digest: SpawnBundleDigest,
61 files: Vec<NodeBundleFile>,
62 delivery_manifest: Option<DeliveryBundleManifestV2>,
63}
64
65impl fmt::Debug for NodeBundle {
66 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67 formatter
68 .debug_struct("NodeBundle")
69 .field("id", &self.id)
70 .field("revision", &self.revision)
71 .field("digest", &self.digest)
72 .field("files", &self.files)
73 .field(
74 "delivery_manifest_digest",
75 &self
76 .delivery_manifest
77 .as_ref()
78 .map(|manifest| &manifest.manifest_digest),
79 )
80 .finish()
81 }
82}
83
84impl NodeBundle {
85 pub fn new(
86 id: SpawnBundleId,
87 revision: SpawnBundleRevision,
88 expected_digest: SpawnBundleDigest,
89 root: impl AsRef<Path>,
90 ) -> Result<Self, NodeBundleError> {
91 let root = ProtectedBundleRoot::open(root.as_ref())?;
92
93 let mut scanner = BundleScanner::default();
94 scanner.scan_directory(&root, root.canonical(), &[])?;
95 root.verify_stable()?;
96 scanner.files.sort_by(|left, right| left.path.cmp(&right.path));
97 validate_bundle_contract(&scanner.files, &scanner.directories)?;
98
99 let actual_digest = digest_files(&scanner.files);
100 if actual_digest != expected_digest {
101 return Err(NodeBundleError::DigestMismatch {
102 expected: expected_digest,
103 actual: actual_digest,
104 });
105 }
106
107 Ok(Self {
108 id,
109 revision,
110 digest: actual_digest,
111 files: scanner.files,
112 delivery_manifest: None,
113 })
114 }
115
116 pub(crate) fn from_delivery(
117 manifest: DeliveryBundleManifestV2,
118 blobs: &BTreeMap<DeliveryBlobDigestV1, Vec<u8>>,
119 ) -> Result<Self, NodeBundleError> {
120 manifest
121 .validate()
122 .map_err(|_| NodeBundleError::InvalidDeliveryManifest)?;
123 let actual_manifest_digest = digest_delivery_manifest(&manifest);
124 if actual_manifest_digest != manifest.manifest_digest {
125 return Err(NodeBundleError::DeliveryManifestDigestMismatch);
126 }
127
128 let mut files = Vec::with_capacity(manifest.components.len());
129 for component in &manifest.components {
130 let bytes = blobs
131 .get(&component.blob.digest)
132 .ok_or(NodeBundleError::DeliveryBlobMissing)?;
133 if bytes.len() as u64 != component.blob.byte_len {
134 return Err(NodeBundleError::DeliveryBlobLengthMismatch);
135 }
136 if digest_delivery_blob(bytes) != component.blob.digest {
137 return Err(NodeBundleError::DeliveryBlobDigestMismatch);
138 }
139 if has_executable_shape(component.relative_path.as_str(), bytes) {
140 return Err(NodeBundleError::ExecutableFile {
141 path: component.relative_path.as_str().to_owned(),
142 });
143 }
144 files.push(NodeBundleFile {
145 path: component.relative_path.as_str().to_owned(),
146 bytes: bytes.clone(),
147 });
148 }
149 let actual_bundle_digest = digest_files(&files);
150 if actual_bundle_digest != manifest.bundle_digest {
151 return Err(NodeBundleError::DigestMismatch {
152 expected: manifest.bundle_digest.clone(),
153 actual: actual_bundle_digest,
154 });
155 }
156
157 Ok(Self {
158 id: manifest.bundle_id.clone(),
159 revision: manifest.revision.clone(),
160 digest: manifest.bundle_digest.clone(),
161 files,
162 delivery_manifest: Some(manifest),
163 })
164 }
165
166 pub fn id(&self) -> &SpawnBundleId {
167 &self.id
168 }
169
170 pub fn revision(&self) -> &SpawnBundleRevision {
171 &self.revision
172 }
173
174 pub fn digest(&self) -> &SpawnBundleDigest {
175 &self.digest
176 }
177
178 pub fn files(&self) -> &[NodeBundleFile] {
179 &self.files
180 }
181
182 pub(crate) fn delivery_manifest(&self) -> Option<&DeliveryBundleManifestV2> {
183 self.delivery_manifest.as_ref()
184 }
185
186 pub(crate) fn validate_skill_bundle_contract(&self) -> Result<(), NodeBundleError> {
187 let directories = self
188 .files
189 .iter()
190 .filter_map(|file| Path::new(&file.path).parent())
191 .flat_map(|parent| {
192 let mut current = PathBuf::new();
193 parent
194 .components()
195 .filter_map(move |component| match component {
196 Component::Normal(name) => {
197 current.push(name);
198 Some(current.to_string_lossy().replace('\\', "/"))
199 }
200 _ => None,
201 })
202 })
203 .collect::<BTreeSet<_>>();
204 validate_bundle_contract(&self.files, &directories)
205 }
206
207 pub fn receipt(&self) -> ResolvedBundleReceipt {
208 ResolvedBundleReceipt {
209 id: self.id.clone(),
210 revision: self.revision.clone(),
211 digest: self.digest.clone(),
212 }
213 }
214}
215
216#[derive(Clone, Default, Eq, PartialEq)]
218pub struct BundleCatalog {
219 bundles: BTreeMap<SpawnBundleId, NodeBundle>,
220}
221
222impl fmt::Debug for BundleCatalog {
223 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224 formatter
225 .debug_struct("BundleCatalog")
226 .field("bundles", &self.bundles.values().collect::<Vec<_>>())
227 .finish()
228 }
229}
230
231impl BundleCatalog {
232 pub fn new(
233 bundles: impl IntoIterator<Item = NodeBundle>,
234 ) -> Result<Self, BundleCatalogError> {
235 let mut catalog = BTreeMap::new();
236 for bundle in bundles {
237 if catalog.len() == MAX_BUNDLE_CATALOG_ENTRIES {
238 return Err(BundleCatalogError::TooMany {
239 max: MAX_BUNDLE_CATALOG_ENTRIES,
240 });
241 }
242 let id = bundle.id.clone();
243 if catalog.insert(id.clone(), bundle).is_some() {
244 return Err(BundleCatalogError::Duplicate { id });
245 }
246 }
247 Ok(Self { bundles: catalog })
248 }
249
250 pub fn get(&self, id: &SpawnBundleId) -> Option<&NodeBundle> {
251 self.bundles.get(id)
252 }
253
254 pub fn iter(&self) -> impl ExactSizeIterator<Item = &NodeBundle> {
255 self.bundles.values()
256 }
257
258 pub(crate) fn insert_idempotent(
259 &mut self,
260 bundle: NodeBundle,
261 ) -> Result<bool, BundleCatalogError> {
262 if let Some(existing) = self.bundles.get(bundle.id()) {
263 if existing == &bundle {
264 return Ok(false);
265 }
266 return Err(BundleCatalogError::Conflict {
267 id: bundle.id().clone(),
268 });
269 }
270 if self.bundles.len() == MAX_BUNDLE_CATALOG_ENTRIES {
271 return Err(BundleCatalogError::TooMany {
272 max: MAX_BUNDLE_CATALOG_ENTRIES,
273 });
274 }
275 self.bundles.insert(bundle.id().clone(), bundle);
276 Ok(true)
277 }
278}
279
280#[cfg(feature = "fixture")]
284pub fn protect_bundle_source_tree_fixture(root: &Path) -> io::Result<()> {
285 protect_fixture_path(root)
286}
287
288#[cfg(feature = "fixture")]
289fn protect_fixture_path(path: &Path) -> io::Result<()> {
290 let metadata = fs::symlink_metadata(path)?;
291 if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
292 return Err(io::Error::new(
293 io::ErrorKind::PermissionDenied,
294 "fixture bundle source cannot contain links or reparse points",
295 ));
296 }
297 if metadata.is_dir() {
298 protect_fixture_permissions(path, true)?;
299 for entry in fs::read_dir(path)? {
300 protect_fixture_path(&entry?.path())?;
301 }
302 return Ok(());
303 }
304 if !metadata.is_file() {
305 return Err(io::Error::new(
306 io::ErrorKind::PermissionDenied,
307 "fixture bundle source must contain only regular files and directories",
308 ));
309 }
310 protect_fixture_permissions(path, false)
311}
312
313#[cfg(all(feature = "fixture", unix))]
314fn protect_fixture_permissions(path: &Path, directory: bool) -> io::Result<()> {
315 use std::os::unix::fs::{MetadataExt, PermissionsExt};
316 let metadata = fs::symlink_metadata(path)?;
317 if metadata.uid() != unsafe { libc::geteuid() } {
318 return Err(io::Error::new(
319 io::ErrorKind::PermissionDenied,
320 "fixture bundle source is not owned by the current user",
321 ));
322 }
323 let mode = if directory { 0o700 } else { 0o600 };
324 fs::set_permissions(path, fs::Permissions::from_mode(mode))
325}
326
327#[cfg(all(feature = "fixture", windows))]
328fn protect_fixture_permissions(path: &Path, _: bool) -> io::Result<()> {
329 windows_bundle_security::protect_owner_only(path)
330}
331
332#[cfg(all(feature = "fixture", not(any(unix, windows))))]
333fn protect_fixture_permissions(_: &Path, _: bool) -> io::Result<()> {
334 Err(io::Error::new(
335 io::ErrorKind::Unsupported,
336 "fixture bundle source protection is unsupported on this platform",
337 ))
338}
339
340#[derive(Clone, Debug, Eq, Error, PartialEq)]
341pub enum BundleCatalogError {
342 #[error("bundle catalog exceeds the {max}-bundle limit")]
343 TooMany { max: usize },
344 #[error("bundle catalog contains duplicate bundle {id}")]
345 Duplicate { id: SpawnBundleId },
346 #[error("bundle catalog contains conflicting content for bundle {id}")]
347 Conflict { id: SpawnBundleId },
348}
349
350#[derive(Debug, Error)]
351pub enum NodeBundleError {
352 #[error("bundle root must be an absolute path without dot or parent components")]
353 RootNotAbsolute,
354 #[error("bundle root is not a regular directory or is a symlink/reparse point")]
355 UnsafeRoot,
356 #[error("bundle root or entry is not protected from untrusted writes: {path:?}")]
357 InsecurePermissions { path: PathBuf },
358 #[error("bundle source changed identity while it was being captured: {path:?}")]
359 SourceChanged { path: PathBuf },
360 #[error("bundle source resolves outside its protected canonical root: {path:?}")]
361 EscapedRoot { path: PathBuf },
362 #[error("bundle path is not valid UTF-8: {path:?}")]
363 PathNotUtf8 { path: PathBuf },
364 #[error("bundle path exceeds the {max}-byte limit: {path}")]
365 PathTooLong { path: String, max: usize },
366 #[error("bundle path is not portable: {path}")]
367 UnsafePath { path: String },
368 #[error("bundle contains a case-folded path collision: {first} and {second}")]
369 CaseFoldCollision { first: String, second: String },
370 #[error("bundle contains a symlink, reparse point, or special file: {path}")]
371 UnsafeFileType { path: String },
372 #[error("bundle contains an executable file unsupported by the skills-only floor: {path}")]
373 ExecutableFile { path: String },
374 #[error("bundle exceeds the {max}-file limit")]
375 TooManyFiles { max: usize },
376 #[error("bundle file {path} exceeds the {max}-byte limit")]
377 FileTooLarge { path: String, max: usize },
378 #[error("bundle exceeds the {max}-byte total limit")]
379 TotalTooLarge { max: usize },
380 #[error("bundle root mcp.json is unsupported in the F6.1 skills-only floor")]
381 McpUnsupported,
382 #[error("bundle contains an unsupported root entry: {path}")]
383 UnsupportedRoot { path: String },
384 #[error("bundle requires a regular UTF-8 root plugin.json")]
385 MissingManifest,
386 #[error("bundle plugin.json is invalid: {reason}")]
387 InvalidManifest { reason: &'static str },
388 #[error("bundle .claude-plugin/plugin.json is invalid: {reason}")]
389 InvalidClaudeManifest { reason: &'static str },
390 #[error("bundle requires at least one skills/<name>/SKILL.md component")]
391 MissingSkills,
392 #[error("bundle skill {path} is invalid: {reason}")]
393 InvalidSkill { path: String, reason: &'static str },
394 #[error("bundle digest mismatch: expected {expected}, captured {actual}")]
395 DigestMismatch {
396 expected: SpawnBundleDigest,
397 actual: SpawnBundleDigest,
398 },
399 #[error("delivery manifest is invalid")]
400 InvalidDeliveryManifest,
401 #[error("delivery manifest digest mismatch")]
402 DeliveryManifestDigestMismatch,
403 #[error("delivery blob is missing")]
404 DeliveryBlobMissing,
405 #[error("delivery blob length mismatch")]
406 DeliveryBlobLengthMismatch,
407 #[error("delivery blob digest mismatch")]
408 DeliveryBlobDigestMismatch,
409 #[error("bundle filesystem operation failed at {path:?}: {source}")]
410 Io {
411 path: PathBuf,
412 #[source]
413 source: io::Error,
414 },
415}
416
417#[derive(Default)]
418struct BundleScanner {
419 files: Vec<NodeBundleFile>,
420 directories: BTreeSet<String>,
421 folded_paths: BTreeMap<String, String>,
422 total_bytes: usize,
423}
424
425impl BundleScanner {
426 fn scan_directory(
427 &mut self,
428 root: &ProtectedBundleRoot,
429 absolute: &Path,
430 relative_components: &[String],
431 ) -> Result<(), NodeBundleError> {
432 let directory = open_verified_path(root.canonical(), absolute, true)?;
433 let entries = fs::read_dir(absolute).map_err(|source| NodeBundleError::Io {
434 path: absolute.to_path_buf(),
435 source,
436 })?;
437 for entry in entries {
438 let entry = entry.map_err(|source| NodeBundleError::Io {
439 path: absolute.to_path_buf(),
440 source,
441 })?;
442 let component = entry.file_name().into_string().map_err(|name| {
443 NodeBundleError::PathNotUtf8 {
444 path: PathBuf::from(name),
445 }
446 })?;
447 validate_component(&component)?;
448
449 let mut components = relative_components.to_vec();
450 components.push(component);
451 let relative = components.join("/");
452 if relative.as_bytes().len() > MAX_BUNDLE_PATH_BYTES {
453 return Err(NodeBundleError::PathTooLong {
454 path: relative,
455 max: MAX_BUNDLE_PATH_BYTES,
456 });
457 }
458 self.record_folded_path(&relative)?;
459
460 let absolute_entry = entry.path();
461 let metadata = fs::symlink_metadata(&absolute_entry).map_err(|source| {
462 NodeBundleError::Io {
463 path: absolute_entry.clone(),
464 source,
465 }
466 })?;
467 if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
468 return Err(NodeBundleError::UnsafeFileType { path: relative });
469 }
470 if metadata.is_dir() {
471 validate_directory_location(&relative, &components)?;
472 self.directories.insert(relative);
473 self.scan_directory(root, &absolute_entry, &components)?;
474 } else if metadata.is_file() {
475 validate_file_location(&relative, &components)?;
476 if self.files.len() == MAX_BUNDLE_FILES {
477 return Err(NodeBundleError::TooManyFiles {
478 max: MAX_BUNDLE_FILES,
479 });
480 }
481 if metadata.len() > MAX_BUNDLE_FILE_BYTES as u64 {
482 return Err(NodeBundleError::FileTooLarge {
483 path: relative,
484 max: MAX_BUNDLE_FILE_BYTES,
485 });
486 }
487 let captured = read_regular_no_follow(
488 root.canonical(),
489 &absolute_entry,
490 &relative,
491 )?;
492 if is_executable(&captured.metadata, &relative, &captured.bytes) {
493 return Err(NodeBundleError::ExecutableFile { path: relative });
494 }
495 self.total_bytes = self.total_bytes.checked_add(captured.bytes.len()).ok_or(
496 NodeBundleError::TotalTooLarge {
497 max: MAX_BUNDLE_TOTAL_BYTES,
498 },
499 )?;
500 if self.total_bytes > MAX_BUNDLE_TOTAL_BYTES {
501 return Err(NodeBundleError::TotalTooLarge {
502 max: MAX_BUNDLE_TOTAL_BYTES,
503 });
504 }
505 self.files.push(NodeBundleFile {
506 path: relative,
507 bytes: captured.bytes,
508 });
509 } else {
510 return Err(NodeBundleError::UnsafeFileType { path: relative });
511 }
512 }
513 verify_opened_path(root.canonical(), absolute, &directory)?;
514 Ok(())
515 }
516
517 fn record_folded_path(&mut self, path: &str) -> Result<(), NodeBundleError> {
518 let folded = path.chars().flat_map(char::to_lowercase).collect::<String>();
519 if let Some(existing) = self.folded_paths.insert(folded, path.to_owned()) {
520 if existing != path {
521 return Err(NodeBundleError::CaseFoldCollision {
522 first: existing,
523 second: path.to_owned(),
524 });
525 }
526 }
527 Ok(())
528 }
529}
530
531struct ProtectedBundleRoot {
532 canonical: PathBuf,
533 opened: OpenedPath,
534}
535
536impl ProtectedBundleRoot {
537 fn open(root: &Path) -> Result<Self, NodeBundleError> {
538 validate_absolute_root(root)?;
539 reject_reparse_ancestors(root)?;
540 let canonical = fs::canonicalize(root).map_err(|source| NodeBundleError::Io {
541 path: root.to_path_buf(),
542 source,
543 })?;
544 reject_reparse_ancestors(&canonical)?;
545 let opened = open_verified_path(&canonical, &canonical, true)?;
546 let protected = Self { canonical, opened };
547 protected.verify_stable()?;
548 Ok(protected)
549 }
550
551 fn canonical(&self) -> &Path {
552 &self.canonical
553 }
554
555 fn verify_stable(&self) -> Result<(), NodeBundleError> {
556 verify_opened_path(&self.canonical, &self.canonical, &self.opened)
557 }
558}
559
560#[derive(Clone, Copy, Debug, Eq, PartialEq)]
561struct PathIdentity {
562 first: u64,
563 second: u64,
564}
565
566struct OpenedPath {
567 file: File,
568 metadata: fs::Metadata,
569 identity: PathIdentity,
570}
571
572fn validate_absolute_root(root: &Path) -> Result<(), NodeBundleError> {
573 if !root.is_absolute()
574 || root.components().any(|component| {
575 matches!(component, Component::CurDir | Component::ParentDir)
576 })
577 {
578 return Err(NodeBundleError::RootNotAbsolute);
579 }
580 Ok(())
581}
582
583fn reject_reparse_ancestors(path: &Path) -> Result<(), NodeBundleError> {
584 for ancestor in path.ancestors() {
585 let metadata = fs::symlink_metadata(ancestor).map_err(|source| NodeBundleError::Io {
586 path: ancestor.to_path_buf(),
587 source,
588 })?;
589 if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
590 return Err(NodeBundleError::UnsafeRoot);
591 }
592 }
593 Ok(())
594}
595
596fn open_verified_path(
597 canonical_root: &Path,
598 path: &Path,
599 directory: bool,
600) -> Result<OpenedPath, NodeBundleError> {
601 let path_metadata = fs::symlink_metadata(path).map_err(|source| NodeBundleError::Io {
602 path: path.to_path_buf(),
603 source,
604 })?;
605 if path_metadata.file_type().is_symlink() || is_reparse_point(&path_metadata) {
606 return Err(NodeBundleError::UnsafeFileType {
607 path: path.to_string_lossy().into_owned(),
608 });
609 }
610
611 let file = open_path_no_follow(path, directory).map_err(|source| NodeBundleError::Io {
612 path: path.to_path_buf(),
613 source,
614 })?;
615 let metadata = file.metadata().map_err(|source| NodeBundleError::Io {
616 path: path.to_path_buf(),
617 source,
618 })?;
619 if metadata.file_type().is_symlink()
620 || is_reparse_point(&metadata)
621 || metadata.is_dir() != directory
622 || metadata.is_file() == directory
623 {
624 return Err(NodeBundleError::UnsafeFileType {
625 path: path.to_string_lossy().into_owned(),
626 });
627 }
628 validate_source_permissions(&file, &metadata, path)?;
629 let final_path = fs::canonicalize(path).map_err(|source| NodeBundleError::Io {
630 path: path.to_path_buf(),
631 source,
632 })?;
633 if final_path != canonical_root && !final_path.starts_with(canonical_root) {
634 return Err(NodeBundleError::EscapedRoot {
635 path: path.to_path_buf(),
636 });
637 }
638 let identity = path_identity(&file, &metadata).map_err(|source| NodeBundleError::Io {
639 path: path.to_path_buf(),
640 source,
641 })?;
642 Ok(OpenedPath {
643 file,
644 metadata,
645 identity,
646 })
647}
648
649fn verify_opened_path(
650 canonical_root: &Path,
651 path: &Path,
652 original: &OpenedPath,
653) -> Result<(), NodeBundleError> {
654 let current = open_verified_path(canonical_root, path, original.metadata.is_dir())?;
655 if current.identity != original.identity {
656 return Err(NodeBundleError::SourceChanged {
657 path: path.to_path_buf(),
658 });
659 }
660 Ok(())
661}
662
663fn validate_component(component: &str) -> Result<(), NodeBundleError> {
664 let invalid_character = component.chars().any(|character| {
665 character <= '\u{1f}'
666 || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
667 });
668 let stem = component.split('.').next().unwrap_or(component);
669 let uppercase_stem = stem.to_ascii_uppercase();
670 let reserved = matches!(uppercase_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
671 || reserved_numbered_name(&uppercase_stem, "COM")
672 || reserved_numbered_name(&uppercase_stem, "LPT");
673 if component.is_empty()
674 || component == "."
675 || component == ".."
676 || component.ends_with('.')
677 || component.ends_with(' ')
678 || invalid_character
679 || reserved
680 {
681 return Err(NodeBundleError::UnsafePath {
682 path: component.to_owned(),
683 });
684 }
685 Ok(())
686}
687
688fn reserved_numbered_name(value: &str, prefix: &str) -> bool {
689 value.strip_prefix(prefix).is_some_and(|suffix| {
690 suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9')
691 })
692}
693
694fn validate_directory_location(
695 relative: &str,
696 components: &[String],
697) -> Result<(), NodeBundleError> {
698 match components {
699 [root] if root == "skills" || root == ".claude-plugin" => Ok(()),
700 [root, skill] if root == "skills" && valid_skill_name(skill) => Ok(()),
701 [root, _, _, ..] if root == "skills" => Ok(()),
702 [root, ..] if root == ".claude-plugin" => {
703 Err(NodeBundleError::UnsupportedRoot {
704 path: relative.to_owned(),
705 })
706 }
707 _ => Err(NodeBundleError::UnsupportedRoot {
708 path: relative.to_owned(),
709 }),
710 }
711}
712
713fn validate_file_location(
714 relative: &str,
715 components: &[String],
716) -> Result<(), NodeBundleError> {
717 match components {
718 [file] if file == "plugin.json" => Ok(()),
719 [file] if file == "mcp.json" => Err(NodeBundleError::McpUnsupported),
720 [root, file] if root == ".claude-plugin" && file == "plugin.json" => Ok(()),
721 [root, _, _, ..] if root == "skills" => Ok(()),
722 _ => Err(NodeBundleError::UnsupportedRoot {
723 path: relative.to_owned(),
724 }),
725 }
726}
727
728fn validate_bundle_contract(
729 files: &[NodeBundleFile],
730 directories: &BTreeSet<String>,
731) -> Result<(), NodeBundleError> {
732 let by_path = files
733 .iter()
734 .map(|file| (file.path.as_str(), file))
735 .collect::<BTreeMap<_, _>>();
736 let manifest = by_path
737 .get("plugin.json")
738 .ok_or(NodeBundleError::MissingManifest)?;
739 validate_manifest(&manifest.bytes)?;
740
741 if let Some(manifest) = by_path.get(".claude-plugin/plugin.json") {
742 validate_claude_manifest(&manifest.bytes)?;
743 } else if directories.contains(".claude-plugin") {
744 return Err(NodeBundleError::InvalidClaudeManifest {
745 reason: "manifest file is missing",
746 });
747 }
748
749 let skill_directories = directories
750 .iter()
751 .filter_map(|path| path.strip_prefix("skills/"))
752 .filter(|path| !path.contains('/'))
753 .collect::<Vec<_>>();
754 if skill_directories.is_empty() {
755 return Err(NodeBundleError::MissingSkills);
756 }
757 for skill_name in skill_directories {
758 let path = format!("skills/{skill_name}/SKILL.md");
759 let skill = by_path.get(path.as_str()).ok_or_else(|| {
760 NodeBundleError::InvalidSkill {
761 path: path.clone(),
762 reason: "required SKILL.md file is missing",
763 }
764 })?;
765 validate_skill(&path, skill_name, &skill.bytes)?;
766 }
767 Ok(())
768}
769
770fn validate_manifest(bytes: &[u8]) -> Result<(), NodeBundleError> {
771 let text = std::str::from_utf8(bytes).map_err(|_| NodeBundleError::InvalidManifest {
772 reason: "manifest is not UTF-8",
773 })?;
774 let value: Value = serde_json::from_str(text).map_err(|_| {
775 NodeBundleError::InvalidManifest {
776 reason: "manifest is not valid JSON",
777 }
778 })?;
779 let object = value.as_object().ok_or(NodeBundleError::InvalidManifest {
780 reason: "manifest must be an object",
781 })?;
782 if object.len() != 2 || !object.contains_key("$schema") || !object.contains_key("name") {
783 return Err(NodeBundleError::InvalidManifest {
784 reason: "skills-only manifest permits exactly $schema and name",
785 });
786 }
787 if object.get("$schema").and_then(Value::as_str) != Some(AGENT_PLUGINS_SCHEMA) {
788 return Err(NodeBundleError::InvalidManifest {
789 reason: "unsupported Agent Plugins schema",
790 });
791 }
792 let name = object
793 .get("name")
794 .and_then(Value::as_str)
795 .ok_or(NodeBundleError::InvalidManifest {
796 reason: "name must be a string",
797 })?;
798 if !valid_plugin_name(name) {
799 return Err(NodeBundleError::InvalidManifest {
800 reason: "name violates Agent Plugins 1.0.0 constraints",
801 });
802 }
803 Ok(())
804}
805
806fn validate_claude_manifest(bytes: &[u8]) -> Result<(), NodeBundleError> {
807 let text = std::str::from_utf8(bytes).map_err(|_| {
808 NodeBundleError::InvalidClaudeManifest {
809 reason: "manifest is not UTF-8",
810 }
811 })?;
812 let value: Value = serde_json::from_str(text).map_err(|_| {
813 NodeBundleError::InvalidClaudeManifest {
814 reason: "manifest is not valid JSON",
815 }
816 })?;
817 let object = value.as_object().ok_or(NodeBundleError::InvalidClaudeManifest {
818 reason: "manifest must be an object",
819 })?;
820 if !object.contains_key("name")
821 || object.keys().any(|key| {
822 !matches!(key.as_str(), "name" | "version" | "description")
823 })
824 {
825 return Err(NodeBundleError::InvalidClaudeManifest {
826 reason: "manifest permits only name, version, and description",
827 });
828 }
829 for value in object.values() {
830 let value = value.as_str().ok_or(NodeBundleError::InvalidClaudeManifest {
831 reason: "manifest fields must be strings",
832 })?;
833 if value.is_empty() {
834 return Err(NodeBundleError::InvalidClaudeManifest {
835 reason: "manifest fields must not be empty",
836 });
837 }
838 }
839 Ok(())
840}
841
842fn valid_plugin_name(name: &str) -> bool {
843 let bytes = name.as_bytes();
844 !bytes.is_empty()
845 && name.chars().count() <= 64
846 && bytes[0].is_ascii_alphanumeric()
847 && bytes[bytes.len() - 1].is_ascii_alphanumeric()
848 && bytes
849 .iter()
850 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.'))
851 && !name.contains("--")
852 && !name.contains("..")
853}
854
855fn valid_skill_name(name: &str) -> bool {
856 let bytes = name.as_bytes();
857 !bytes.is_empty()
858 && name.chars().count() <= MAX_SKILL_NAME_CHARS
859 && bytes[0] != b'-'
860 && bytes[bytes.len() - 1] != b'-'
861 && bytes
862 .iter()
863 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
864 && !name.contains("--")
865}
866
867fn validate_skill(path: &str, directory_name: &str, bytes: &[u8]) -> Result<(), NodeBundleError> {
868 let text = std::str::from_utf8(bytes).map_err(|_| NodeBundleError::InvalidSkill {
869 path: path.to_owned(),
870 reason: "SKILL.md is not UTF-8",
871 })?;
872 let frontmatter = extract_frontmatter(text).ok_or_else(|| NodeBundleError::InvalidSkill {
873 path: path.to_owned(),
874 reason: "bounded YAML frontmatter is missing",
875 })?;
876 let fields = parse_frontmatter_fields(frontmatter).map_err(|reason| {
877 NodeBundleError::InvalidSkill {
878 path: path.to_owned(),
879 reason,
880 }
881 })?;
882 let name = fields.name.ok_or_else(|| NodeBundleError::InvalidSkill {
883 path: path.to_owned(),
884 reason: "frontmatter name is missing",
885 })?;
886 let description = fields.description.ok_or_else(|| NodeBundleError::InvalidSkill {
887 path: path.to_owned(),
888 reason: "frontmatter description is missing",
889 })?;
890 if !valid_skill_name(&name) || name != directory_name {
891 return Err(NodeBundleError::InvalidSkill {
892 path: path.to_owned(),
893 reason: "frontmatter name must be valid and match its directory",
894 });
895 }
896 if description.trim().is_empty()
897 || description.chars().count() > MAX_SKILL_DESCRIPTION_CHARS
898 {
899 return Err(NodeBundleError::InvalidSkill {
900 path: path.to_owned(),
901 reason: "description must contain 1-1024 characters",
902 });
903 }
904 Ok(())
905}
906
907fn extract_frontmatter(text: &str) -> Option<&str> {
908 let (opening, remainder) = next_line(text)?;
909 if opening != "---" {
910 return None;
911 }
912 let mut consumed = 0usize;
913 let mut cursor = remainder;
914 loop {
915 if cursor.is_empty() {
916 return None;
917 }
918 let (line, rest) = next_line(cursor)?;
919 if line == "---" {
920 return (consumed <= MAX_SKILL_FRONTMATTER_BYTES).then_some(
921 &remainder[..remainder.len() - cursor.len()],
922 );
923 }
924 consumed = consumed.checked_add(cursor.len() - rest.len())?;
925 if consumed > MAX_SKILL_FRONTMATTER_BYTES {
926 return None;
927 }
928 cursor = rest;
929 }
930}
931
932fn next_line(text: &str) -> Option<(&str, &str)> {
933 if let Some(index) = text.find('\n') {
934 let line = text[..index].strip_suffix('\r').unwrap_or(&text[..index]);
935 Some((line, &text[index + 1..]))
936 } else {
937 Some((text.strip_suffix('\r').unwrap_or(text), ""))
938 }
939}
940
941#[derive(Default)]
942struct SkillFields {
943 name: Option<String>,
944 description: Option<String>,
945}
946
947fn parse_frontmatter_fields(frontmatter: &str) -> Result<SkillFields, &'static str> {
948 let lines = frontmatter.lines().collect::<Vec<_>>();
949 let mut fields = SkillFields::default();
950 let mut index = 0usize;
951 while index < lines.len() {
952 let line = lines[index].strip_suffix('\r').unwrap_or(lines[index]);
953 if line.contains('\t') {
954 return Err("frontmatter tabs are unsupported");
955 }
956 if line.trim().is_empty() || line.trim_start().starts_with('#') || line.starts_with(' ') {
957 index += 1;
958 continue;
959 }
960 let (key, raw_value) = line
961 .split_once(':')
962 .ok_or("frontmatter top-level entries must be mappings")?;
963 let key = key.trim();
964 let raw_value = raw_value.trim();
965 if key == "name" || key == "description" {
966 let (value, consumed_lines) = if matches!(raw_value, "|" | "|-" | "|+" | ">" | ">-" | ">+") {
967 parse_block_scalar(&lines[index + 1..], raw_value.starts_with('>'))
968 } else {
969 (parse_yaml_scalar(raw_value)?, 0)
970 };
971 let destination = if key == "name" {
972 &mut fields.name
973 } else {
974 &mut fields.description
975 };
976 if destination.replace(value).is_some() {
977 return Err("frontmatter contains a duplicate required field");
978 }
979 index += consumed_lines;
980 }
981 index += 1;
982 }
983 Ok(fields)
984}
985
986fn parse_block_scalar(lines: &[&str], folded: bool) -> (String, usize) {
987 let mut values = Vec::new();
988 for line in lines {
989 if !line.starts_with(' ') && !line.trim().is_empty() {
990 break;
991 }
992 values.push(line.trim().to_owned());
993 }
994 let value = if folded {
995 values.join(" ")
996 } else {
997 values.join("\n")
998 };
999 (value, values.len())
1000}
1001
1002fn parse_yaml_scalar(value: &str) -> Result<String, &'static str> {
1003 if value.is_empty() {
1004 return Ok(String::new());
1005 }
1006 if value.starts_with('"') {
1007 return serde_json::from_str::<String>(value)
1008 .map_err(|_| "frontmatter contains an invalid quoted scalar");
1009 }
1010 if value.starts_with('\'') {
1011 if value.len() < 2 || !value.ends_with('\'') {
1012 return Err("frontmatter contains an invalid quoted scalar");
1013 }
1014 return Ok(value[1..value.len() - 1].replace("''", "'"));
1015 }
1016 let without_comment = value.split_once(" #").map_or(value, |(value, _)| value);
1017 Ok(without_comment.trim().to_owned())
1018}
1019
1020struct CapturedFile {
1021 bytes: Vec<u8>,
1022 metadata: fs::Metadata,
1023}
1024
1025fn read_regular_no_follow(
1026 canonical_root: &Path,
1027 path: &Path,
1028 relative: &str,
1029) -> Result<CapturedFile, NodeBundleError> {
1030 let mut opened = open_verified_path(canonical_root, path, false)?;
1031 if opened.metadata.len() > MAX_BUNDLE_FILE_BYTES as u64 {
1032 return Err(NodeBundleError::FileTooLarge {
1033 path: relative.to_owned(),
1034 max: MAX_BUNDLE_FILE_BYTES,
1035 });
1036 }
1037 let mut bytes = Vec::with_capacity(opened.metadata.len() as usize);
1038 (&mut opened.file)
1039 .take(MAX_BUNDLE_FILE_BYTES as u64 + 1)
1040 .read_to_end(&mut bytes)
1041 .map_err(|source| NodeBundleError::Io {
1042 path: path.to_path_buf(),
1043 source,
1044 })?;
1045 if bytes.len() > MAX_BUNDLE_FILE_BYTES {
1046 return Err(NodeBundleError::FileTooLarge {
1047 path: relative.to_owned(),
1048 max: MAX_BUNDLE_FILE_BYTES,
1049 });
1050 }
1051 if bytes.len() as u64 != opened.metadata.len() {
1052 return Err(NodeBundleError::SourceChanged {
1053 path: path.to_path_buf(),
1054 });
1055 }
1056 verify_opened_path(canonical_root, path, &opened)?;
1057 Ok(CapturedFile {
1058 bytes,
1059 metadata: opened.metadata,
1060 })
1061}
1062
1063fn open_path_no_follow(path: &Path, directory: bool) -> io::Result<File> {
1064 let mut options = OpenOptions::new();
1065 options.read(true);
1066 set_no_follow(&mut options, directory);
1067 options.open(path)
1068}
1069
1070#[cfg(unix)]
1071fn set_no_follow(options: &mut OpenOptions, directory: bool) {
1072 use std::os::unix::fs::OpenOptionsExt;
1073 let directory_flag = if directory { libc::O_DIRECTORY } else { 0 };
1074 options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | directory_flag);
1075}
1076
1077#[cfg(windows)]
1078fn set_no_follow(options: &mut OpenOptions, directory: bool) {
1079 use std::os::windows::fs::OpenOptionsExt;
1080 use windows_sys::Win32::Storage::FileSystem::{
1081 FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ,
1082 };
1083 let directory_flag = if directory { FILE_FLAG_BACKUP_SEMANTICS } else { 0 };
1084 options
1085 .share_mode(FILE_SHARE_READ)
1086 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | directory_flag);
1087}
1088
1089#[cfg(not(any(unix, windows)))]
1090fn set_no_follow(_: &mut OpenOptions, _: bool) {}
1091
1092#[cfg(unix)]
1093fn path_identity(_: &File, metadata: &fs::Metadata) -> io::Result<PathIdentity> {
1094 use std::os::unix::fs::MetadataExt;
1095 Ok(PathIdentity {
1096 first: metadata.dev(),
1097 second: metadata.ino(),
1098 })
1099}
1100
1101#[cfg(windows)]
1102fn path_identity(file: &File, _: &fs::Metadata) -> io::Result<PathIdentity> {
1103 use std::os::windows::io::AsRawHandle;
1104 use windows_sys::Win32::Storage::FileSystem::{
1105 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
1106 };
1107 let mut information = BY_HANDLE_FILE_INFORMATION::default();
1108 if unsafe {
1109 GetFileInformationByHandle(file.as_raw_handle() as _, &mut information)
1110 } == 0
1111 {
1112 return Err(io::Error::last_os_error());
1113 }
1114 Ok(PathIdentity {
1115 first: information.dwVolumeSerialNumber as u64,
1116 second: ((information.nFileIndexHigh as u64) << 32)
1117 | information.nFileIndexLow as u64,
1118 })
1119}
1120
1121#[cfg(not(any(unix, windows)))]
1122fn path_identity(_: &File, metadata: &fs::Metadata) -> io::Result<PathIdentity> {
1123 Ok(PathIdentity {
1124 first: metadata.len(),
1125 second: 0,
1126 })
1127}
1128
1129#[cfg(unix)]
1130fn validate_source_permissions(
1131 _: &File,
1132 metadata: &fs::Metadata,
1133 path: &Path,
1134) -> Result<(), NodeBundleError> {
1135 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1136 if metadata.uid() != unsafe { libc::geteuid() }
1137 || metadata.permissions().mode() & 0o022 != 0
1138 {
1139 return Err(NodeBundleError::InsecurePermissions {
1140 path: path.to_path_buf(),
1141 });
1142 }
1143 Ok(())
1144}
1145
1146#[cfg(windows)]
1147fn validate_source_permissions(
1148 file: &File,
1149 _: &fs::Metadata,
1150 path: &Path,
1151) -> Result<(), NodeBundleError> {
1152 windows_bundle_security::validate_owner_only(file).map_err(|_| {
1153 NodeBundleError::InsecurePermissions {
1154 path: path.to_path_buf(),
1155 }
1156 })
1157}
1158
1159#[cfg(not(any(unix, windows)))]
1160fn validate_source_permissions(
1161 _: &File,
1162 _: &fs::Metadata,
1163 path: &Path,
1164) -> Result<(), NodeBundleError> {
1165 Err(NodeBundleError::InsecurePermissions {
1166 path: path.to_path_buf(),
1167 })
1168}
1169
1170#[cfg(windows)]
1171mod windows_bundle_security {
1172 use super::*;
1173 use std::os::windows::io::AsRawHandle;
1174 use windows_sys::Win32::Foundation::LocalFree;
1175 use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
1176 use windows_sys::Win32::Security::{
1177 CreateWellKnownSid, EqualSid, GetAce, GetAclInformation,
1178 GetSecurityDescriptorControl, ACCESS_ALLOWED_ACE, ACL_SIZE_INFORMATION,
1179 AclSizeInformation, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION,
1180 SECURITY_MAX_SID_SIZE, SE_DACL_PROTECTED, WinCreatorOwnerRightsSid,
1181 };
1182 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
1183
1184 pub(super) fn validate_owner_only(file: &File) -> io::Result<()> {
1185 let mut owner = std::ptr::null_mut();
1186 let mut dacl = std::ptr::null_mut();
1187 let mut descriptor = std::ptr::null_mut();
1188 let status = unsafe {
1189 GetSecurityInfo(
1190 file.as_raw_handle() as _,
1191 SE_FILE_OBJECT,
1192 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
1193 &mut owner,
1194 std::ptr::null_mut(),
1195 &mut dacl,
1196 std::ptr::null_mut(),
1197 &mut descriptor,
1198 )
1199 };
1200 if status != 0 {
1201 return Err(io::Error::from_raw_os_error(status as i32));
1202 }
1203 let result = (|| {
1204 if owner.is_null() || dacl.is_null() {
1205 return Err(insecure("bundle DACL is missing"));
1206 }
1207 let mut control = 0u16;
1208 let mut revision = 0u32;
1209 if unsafe {
1210 GetSecurityDescriptorControl(descriptor, &mut control, &mut revision)
1211 } == 0
1212 || control & SE_DACL_PROTECTED == 0
1213 {
1214 return Err(insecure("bundle DACL is not protected"));
1215 }
1216 let mut information = ACL_SIZE_INFORMATION::default();
1217 if unsafe {
1218 GetAclInformation(
1219 dacl,
1220 &mut information as *mut _ as *mut _,
1221 std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
1222 AclSizeInformation,
1223 )
1224 } == 0
1225 || information.AceCount != 1
1226 {
1227 return Err(insecure("bundle DACL is not owner-only"));
1228 }
1229 let mut ace = std::ptr::null_mut();
1230 if unsafe { GetAce(dacl, 0, &mut ace) } == 0 || ace.is_null() {
1231 return Err(io::Error::last_os_error());
1232 }
1233 let allowed = unsafe { &*(ace as *const ACCESS_ALLOWED_ACE) };
1234 let sid = &allowed.SidStart as *const u32 as *mut _;
1235 let mut owner_rights = [0u8; SECURITY_MAX_SID_SIZE as usize];
1236 let mut owner_rights_len = owner_rights.len() as u32;
1237 if unsafe {
1238 CreateWellKnownSid(
1239 WinCreatorOwnerRightsSid,
1240 std::ptr::null_mut(),
1241 owner_rights.as_mut_ptr() as *mut _,
1242 &mut owner_rights_len,
1243 )
1244 } == 0
1245 {
1246 return Err(io::Error::last_os_error());
1247 }
1248 if allowed.Header.AceType != 0
1249 || allowed.Header.AceFlags != 0
1250 || allowed.Mask != FILE_ALL_ACCESS
1251 || (unsafe { EqualSid(owner, sid) } == 0
1252 && unsafe {
1253 EqualSid(owner_rights.as_mut_ptr() as *mut _, sid)
1254 } == 0)
1255 {
1256 return Err(insecure("bundle DACL is not owner-only"));
1257 }
1258 Ok(())
1259 })();
1260 unsafe {
1261 LocalFree(descriptor);
1262 }
1263 result
1264 }
1265
1266 fn insecure(message: &'static str) -> io::Error {
1267 io::Error::new(io::ErrorKind::PermissionDenied, message)
1268 }
1269
1270 #[cfg(any(test, feature = "fixture"))]
1271 pub(super) fn protect_owner_only(path: &Path) -> io::Result<()> {
1272 set_dacl(path, "D:P(A;;FA;;;OW)")
1273 }
1274
1275 #[cfg(test)]
1276 pub(super) fn make_world_writable_for_test(path: &Path) -> io::Result<()> {
1277 set_dacl(path, "D:P(A;;FA;;;WD)")
1278 }
1279
1280 #[cfg(any(test, feature = "fixture"))]
1281 fn set_dacl(path: &Path, descriptor_text: &str) -> io::Result<()> {
1282 use std::ffi::OsStr;
1283 use std::os::windows::ffi::OsStrExt;
1284 use windows_sys::Win32::Security::Authorization::{
1285 ConvertStringSecurityDescriptorToSecurityDescriptorW,
1286 SetNamedSecurityInfoW, SDDL_REVISION_1,
1287 };
1288 use windows_sys::Win32::Security::{
1289 GetSecurityDescriptorDacl, PROTECTED_DACL_SECURITY_INFORMATION,
1290 };
1291
1292 let sddl = OsStr::new(descriptor_text)
1293 .encode_wide()
1294 .chain(std::iter::once(0))
1295 .collect::<Vec<_>>();
1296 let mut descriptor = std::ptr::null_mut();
1297 if unsafe {
1298 ConvertStringSecurityDescriptorToSecurityDescriptorW(
1299 sddl.as_ptr(),
1300 SDDL_REVISION_1,
1301 &mut descriptor,
1302 std::ptr::null_mut(),
1303 )
1304 } == 0
1305 {
1306 return Err(io::Error::last_os_error());
1307 }
1308 let result = (|| {
1309 let mut present = 0i32;
1310 let mut defaulted = 0i32;
1311 let mut dacl = std::ptr::null_mut();
1312 if unsafe {
1313 GetSecurityDescriptorDacl(
1314 descriptor,
1315 &mut present,
1316 &mut dacl,
1317 &mut defaulted,
1318 )
1319 } == 0
1320 || present == 0
1321 || dacl.is_null()
1322 {
1323 return Err(io::Error::last_os_error());
1324 }
1325 let mut wide = path
1326 .as_os_str()
1327 .encode_wide()
1328 .chain(std::iter::once(0))
1329 .collect::<Vec<_>>();
1330 let status = unsafe {
1331 SetNamedSecurityInfoW(
1332 wide.as_mut_ptr(),
1333 SE_FILE_OBJECT,
1334 DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
1335 std::ptr::null_mut(),
1336 std::ptr::null_mut(),
1337 dacl,
1338 std::ptr::null_mut(),
1339 )
1340 };
1341 if status != 0 {
1342 return Err(io::Error::from_raw_os_error(status as i32));
1343 }
1344 Ok(())
1345 })();
1346 unsafe {
1347 LocalFree(descriptor);
1348 }
1349 result
1350 }
1351}
1352
1353#[cfg(windows)]
1354fn is_reparse_point(metadata: &fs::Metadata) -> bool {
1355 use std::os::windows::fs::MetadataExt;
1356 metadata.file_attributes()
1357 & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT
1358 != 0
1359}
1360
1361#[cfg(not(windows))]
1362fn is_reparse_point(_: &fs::Metadata) -> bool {
1363 false
1364}
1365
1366fn is_executable(metadata: &fs::Metadata, path: &str, bytes: &[u8]) -> bool {
1367 if unix_executable(metadata) {
1368 return true;
1369 }
1370 has_executable_shape(path, bytes)
1371}
1372
1373fn has_executable_shape(path: &str, bytes: &[u8]) -> bool {
1374 let extension = path.rsplit_once('.').map(|(_, extension)| extension.to_ascii_lowercase());
1375 if extension.as_deref().is_some_and(|extension| {
1376 matches!(
1377 extension,
1378 "exe" | "com" | "bat" | "cmd" | "ps1" | "sh" | "bash" | "zsh" | "fish"
1379 | "py" | "rb" | "pl" | "js" | "mjs" | "cjs"
1380 )
1381 }) {
1382 return true;
1383 }
1384 bytes.starts_with(b"#!")
1385 || bytes.starts_with(b"MZ")
1386 || bytes.starts_with(b"\x7fELF")
1387 || matches!(bytes.get(..4), Some([0xfe, 0xed, 0xfa, 0xce])
1388 | Some([0xfe, 0xed, 0xfa, 0xcf])
1389 | Some([0xce, 0xfa, 0xed, 0xfe])
1390 | Some([0xcf, 0xfa, 0xed, 0xfe])
1391 | Some([0xca, 0xfe, 0xba, 0xbe]))
1392}
1393
1394#[cfg(unix)]
1395fn unix_executable(metadata: &fs::Metadata) -> bool {
1396 use std::os::unix::fs::PermissionsExt;
1397 metadata.permissions().mode() & 0o111 != 0
1398}
1399
1400#[cfg(not(unix))]
1401fn unix_executable(_: &fs::Metadata) -> bool {
1402 false
1403}
1404
1405fn digest_files(files: &[NodeBundleFile]) -> SpawnBundleDigest {
1406 let mut context = Context::new(&SHA256);
1407 context.update(DIGEST_DOMAIN);
1408 for file in files {
1409 let path = file.path.as_bytes();
1410 context.update(&(path.len() as u32).to_be_bytes());
1411 context.update(path);
1412 context.update(&(file.bytes.len() as u64).to_be_bytes());
1413 context.update(&file.bytes);
1414 }
1415 let digest = context.finish();
1416 let mut value = String::with_capacity(71);
1417 value.push_str("sha256:");
1418 for byte in digest.as_ref() {
1419 use std::fmt::Write;
1420 write!(&mut value, "{byte:02x}").expect("writing to String cannot fail");
1421 }
1422 SpawnBundleDigest::new(value).expect("SHA-256 formatting is valid")
1423}
1424
1425pub(crate) fn digest_delivery_blob(bytes: &[u8]) -> DeliveryBlobDigestV1 {
1426 let actual = ring::digest::digest(&SHA256, bytes);
1427 DeliveryBlobDigestV1::new(format!("sha256:{}", hex_digest(actual.as_ref())))
1428 .expect("SHA-256 formatting is valid")
1429}
1430
1431pub(crate) fn digest_delivery_manifest(
1432 manifest: &DeliveryBundleManifestV2,
1433) -> DeliveryManifestDigestV2 {
1434 let actual = ring::digest::digest(
1435 &SHA256,
1436 &manifest.canonical_manifest_digest_material(),
1437 );
1438 DeliveryManifestDigestV2::new(format!("sha256:{}", hex_digest(actual.as_ref())))
1439 .expect("SHA-256 formatting is valid")
1440}
1441
1442fn hex_digest(bytes: &[u8]) -> String {
1443 let mut value = String::with_capacity(bytes.len() * 2);
1444 for byte in bytes {
1445 use std::fmt::Write as _;
1446 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
1447 }
1448 value
1449}
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454 use std::sync::atomic::{AtomicU64, Ordering};
1455
1456 static NEXT_TEMP: AtomicU64 = AtomicU64::new(1);
1457
1458 struct TestRoot(PathBuf);
1459
1460 impl TestRoot {
1461 fn new() -> Self {
1462 let target = std::env::var_os("CARGO_TARGET_DIR")
1463 .map(PathBuf::from)
1464 .filter(|path| path.is_absolute())
1465 .unwrap_or_else(|| std::env::current_dir().unwrap().join("target"));
1466 let base = target.join("bundle-catalog-tests");
1467 fs::create_dir_all(&base).unwrap();
1468 let path = base.join(format!(
1469 "gate4agent-bundle-catalog-{}-{}",
1470 std::process::id(),
1471 NEXT_TEMP.fetch_add(1, Ordering::Relaxed),
1472 ));
1473 fs::create_dir(&path).unwrap();
1474 Self(path)
1475 }
1476
1477 fn path(&self) -> &Path {
1478 &self.0
1479 }
1480
1481 fn write(&self, relative: &str, bytes: &[u8]) {
1482 let path = self.0.join(relative.replace('/', std::path::MAIN_SEPARATOR_STR));
1483 fs::create_dir_all(path.parent().unwrap()).unwrap();
1484 fs::write(path, bytes).unwrap();
1485 }
1486
1487 fn valid() -> Self {
1488 let root = Self::new();
1489 root.write(
1490 "plugin.json",
1491 br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#,
1492 );
1493 root.write(
1494 "skills/review-code/SKILL.md",
1495 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
1496 );
1497 root
1498 }
1499
1500 fn expected_digest(&self) -> SpawnBundleDigest {
1501 self.protect();
1502 let root = ProtectedBundleRoot::open(&self.0).unwrap();
1503 let mut scanner = BundleScanner::default();
1504 scanner.scan_directory(&root, root.canonical(), &[]).unwrap();
1505 scanner.files.sort_by(|left, right| left.path.cmp(&right.path));
1506 digest_files(&scanner.files)
1507 }
1508
1509 fn protect(&self) {
1510 #[cfg(windows)]
1511 protect_tree(&self.0).unwrap();
1512 }
1513 }
1514
1515 impl Drop for TestRoot {
1516 fn drop(&mut self) {
1517 let _ = fs::remove_dir_all(&self.0);
1518 }
1519 }
1520
1521 fn bundle(root: &TestRoot, digest: SpawnBundleDigest) -> Result<NodeBundle, NodeBundleError> {
1522 root.protect();
1523 NodeBundle::new(
1524 SpawnBundleId::new("review-tools").unwrap(),
1525 SpawnBundleRevision::new("review-tools-r1").unwrap(),
1526 digest,
1527 root.path(),
1528 )
1529 }
1530
1531 #[test]
1532 fn node_bundle_rejects_changed_digest() {
1533 let root = TestRoot::valid();
1534 let expected = root.expected_digest();
1535 root.write(
1536 "skills/review-code/SKILL.md",
1537 b"---\nname: review-code\ndescription: Changed after the digest was pinned.\n---\n",
1538 );
1539
1540 let error = bundle(&root, expected).unwrap_err();
1541 assert!(matches!(error, NodeBundleError::DigestMismatch { .. }));
1542 }
1543
1544 #[test]
1545 fn node_bundle_rejects_unsafe_paths_and_symlinks() {
1546 let traversal = TestRoot::valid();
1547 let error = NodeBundle::new(
1548 SpawnBundleId::new("review-tools").unwrap(),
1549 SpawnBundleRevision::new("review-tools-r1").unwrap(),
1550 zero_digest(),
1551 traversal.path().join("skills/.."),
1552 )
1553 .unwrap_err();
1554 assert!(matches!(error, NodeBundleError::RootNotAbsolute));
1555
1556 let reserved = TestRoot::valid();
1557 reserved.write("skills/review-code/CON.txt", b"unsafe");
1558 let error = bundle(&reserved, zero_digest()).unwrap_err();
1559 assert!(matches!(error, NodeBundleError::UnsafePath { .. }));
1560
1561 let executable = TestRoot::valid();
1562 executable.write("skills/review-code/scripts/run.sh", b"exit 0\n");
1563 let error = bundle(&executable, zero_digest()).unwrap_err();
1564 assert!(matches!(error, NodeBundleError::ExecutableFile { .. }));
1565
1566 let mcp = TestRoot::valid();
1567 mcp.write("mcp.json", b"{}");
1568 let error = bundle(&mcp, zero_digest()).unwrap_err();
1569 assert!(matches!(error, NodeBundleError::McpUnsupported));
1570
1571 let linked = TestRoot::valid();
1572 let linked_target = TestRoot::new();
1573 let target = linked_target.path().join("target.txt");
1574 fs::write(&target, b"target").unwrap();
1575 let link = linked.path().join("skills/review-code/link.txt");
1576 if create_file_symlink(&target, &link).is_ok() {
1577 let error = bundle(&linked, zero_digest()).unwrap_err();
1578 assert!(
1579 matches!(&error, NodeBundleError::UnsafeFileType { .. }),
1580 "symlink must fail closed as UnsafeFileType, got {error:?}",
1581 );
1582 }
1583
1584 #[cfg(unix)]
1585 {
1586 use std::os::unix::fs::PermissionsExt;
1587
1588 let writable = TestRoot::valid();
1589 fs::set_permissions(writable.path(), fs::Permissions::from_mode(0o777)).unwrap();
1590 let error = NodeBundle::new(
1591 SpawnBundleId::new("review-tools").unwrap(),
1592 SpawnBundleRevision::new("review-tools-r1").unwrap(),
1593 zero_digest(),
1594 writable.path(),
1595 )
1596 .unwrap_err();
1597 assert!(matches!(error, NodeBundleError::InsecurePermissions { .. }));
1598
1599 let swapped = TestRoot::valid();
1600 let expected = swapped.expected_digest();
1601 let swapped_target = TestRoot::new();
1602 let target = swapped_target.path().join("replacement.md");
1603 fs::write(&target, b"replacement").unwrap();
1604 let skill = swapped.path().join("skills/review-code/SKILL.md");
1605 fs::remove_file(&skill).unwrap();
1606 std::os::unix::fs::symlink(&target, &skill).unwrap();
1607 let error = bundle(&swapped, expected).unwrap_err();
1608 assert!(
1609 matches!(&error, NodeBundleError::UnsafeFileType { .. }),
1610 "swapped skill must fail closed as UnsafeFileType, got {error:?}",
1611 );
1612 }
1613
1614 #[cfg(windows)]
1615 {
1616 let insecure = TestRoot::valid();
1617 insecure.protect();
1618 windows_bundle_security::make_world_writable_for_test(insecure.path()).unwrap();
1619 let error = NodeBundle::new(
1620 SpawnBundleId::new("review-tools").unwrap(),
1621 SpawnBundleRevision::new("review-tools-r1").unwrap(),
1622 zero_digest(),
1623 insecure.path(),
1624 )
1625 .unwrap_err();
1626 assert!(matches!(error, NodeBundleError::InsecurePermissions { .. }));
1627 }
1628 }
1629
1630 #[test]
1631 fn node_bundle_rejects_case_fold_collision_when_source_can_represent_it() {
1632 let collision = TestRoot::valid();
1633 let references = collision.path().join("skills/review-code/references");
1634 collision.write("skills/review-code/references/A.txt", b"upper");
1635 collision.write("skills/review-code/references/a.txt", b"lower");
1636
1637 let names = fs::read_dir(references)
1638 .unwrap()
1639 .map(|entry| entry.unwrap().file_name())
1640 .collect::<BTreeSet<_>>();
1641 if !names.contains(std::ffi::OsStr::new("A.txt"))
1642 || !names.contains(std::ffi::OsStr::new("a.txt"))
1643 {
1644 return;
1645 }
1646
1647 let error = bundle(&collision, zero_digest()).unwrap_err();
1648 assert!(matches!(error, NodeBundleError::CaseFoldCollision { .. }));
1649 }
1650
1651 #[test]
1652 fn node_bundle_validates_schema_manifest_and_skill_contract() {
1653 let root = TestRoot::valid();
1654 root.write(
1655 ".claude-plugin/plugin.json",
1656 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#,
1657 );
1658 let digest = root.expected_digest();
1659 let valid = bundle(&root, digest).unwrap();
1660 assert_eq!(valid.files().len(), 3);
1661 assert_eq!(valid.receipt().id.as_str(), "review-tools");
1662 let error = BundleCatalog::new([valid.clone(), valid]).unwrap_err();
1663 assert!(matches!(error, BundleCatalogError::Duplicate { .. }));
1664
1665 root.write(
1666 "plugin.json",
1667 br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools","version":"1.0.0"}"#,
1668 );
1669 let error = bundle(&root, root.expected_digest()).unwrap_err();
1670 assert!(matches!(error, NodeBundleError::InvalidManifest { .. }));
1671
1672 root.write(
1673 "plugin.json",
1674 br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#,
1675 );
1676 root.write(
1677 "skills/review-code/SKILL.md",
1678 b"---\nname: another-name\ndescription: Wrong directory binding.\n---\n",
1679 );
1680 let error = bundle(&root, root.expected_digest()).unwrap_err();
1681 assert!(matches!(error, NodeBundleError::InvalidSkill { .. }));
1682 }
1683
1684 #[test]
1685 fn node_bundle_digest_is_stable() {
1686 let first = TestRoot::valid();
1687 first.write("skills/review-code/references/z.txt", b"last");
1688 first.write("skills/review-code/references/a.txt", b"first");
1689 let expected = first.expected_digest();
1690
1691 let second = TestRoot::new();
1692 second.write("skills/review-code/references/a.txt", b"first");
1693 second.write("skills/review-code/references/z.txt", b"last");
1694 second.write(
1695 "skills/review-code/SKILL.md",
1696 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
1697 );
1698 second.write(
1699 "plugin.json",
1700 br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#,
1701 );
1702
1703 assert_eq!(expected, second.expected_digest());
1704 let captured = bundle(&first, expected.clone()).unwrap();
1705 first.write("skills/review-code/references/a.txt", b"mutated later");
1706 assert_eq!(captured.digest(), &expected);
1707 assert_eq!(
1708 captured
1709 .files()
1710 .iter()
1711 .find(|file| file.path() == "skills/review-code/references/a.txt")
1712 .unwrap()
1713 .bytes(),
1714 b"first",
1715 );
1716
1717 let marker = "G4A_DEBUG_MUST_NOT_LEAK_MARKER";
1718 let debug_root = TestRoot::valid();
1719 debug_root.write(
1720 "skills/review-code/references/private.txt",
1721 marker.as_bytes(),
1722 );
1723 let debug_bundle = bundle(&debug_root, debug_root.expected_digest()).unwrap();
1724 let debug_file = debug_bundle
1725 .files()
1726 .iter()
1727 .find(|file| file.path().ends_with("private.txt"))
1728 .unwrap();
1729 let debug_catalog = BundleCatalog::new([debug_bundle.clone()]).unwrap();
1730 assert!(!format!("{debug_file:?}").contains(marker));
1731 assert!(!format!("{debug_bundle:?}").contains(marker));
1732 assert!(!format!("{debug_catalog:?}").contains(marker));
1733 }
1734
1735 #[cfg(feature = "fixture")]
1736 #[test]
1737 fn protect_bundle_source_tree_fixture_establishes_exact_loader_boundary() {
1738 let root = TestRoot::valid();
1739 #[cfg(unix)]
1740 {
1741 use std::os::unix::fs::PermissionsExt;
1742 fs::set_permissions(root.path(), fs::Permissions::from_mode(0o777)).unwrap();
1743 fs::set_permissions(
1744 root.path().join("plugin.json"),
1745 fs::Permissions::from_mode(0o666),
1746 )
1747 .unwrap();
1748 }
1749 #[cfg(windows)]
1750 windows_bundle_security::make_world_writable_for_test(root.path()).unwrap();
1751
1752 protect_bundle_source_tree_fixture(root.path()).unwrap();
1753 let protected = ProtectedBundleRoot::open(root.path()).unwrap();
1754 let mut scanner = BundleScanner::default();
1755 scanner
1756 .scan_directory(&protected, protected.canonical(), &[])
1757 .unwrap();
1758 scanner.files.sort_by(|left, right| left.path.cmp(&right.path));
1759 let digest = digest_files(&scanner.files);
1760 let loaded = NodeBundle::new(
1761 SpawnBundleId::new("review-tools").unwrap(),
1762 SpawnBundleRevision::new("review-tools-r1").unwrap(),
1763 digest,
1764 root.path(),
1765 )
1766 .unwrap();
1767 assert_eq!(loaded.files().len(), 2);
1768 }
1769
1770 #[cfg(unix)]
1771 fn create_file_symlink(target: &Path, link: &Path) -> io::Result<()> {
1772 std::os::unix::fs::symlink(target, link)
1773 }
1774
1775 #[cfg(windows)]
1776 fn create_file_symlink(target: &Path, link: &Path) -> io::Result<()> {
1777 std::os::windows::fs::symlink_file(target, link)
1778 }
1779
1780 fn zero_digest() -> SpawnBundleDigest {
1781 SpawnBundleDigest::new(format!("sha256:{}", "0".repeat(64))).unwrap()
1782 }
1783
1784 #[cfg(windows)]
1785 fn protect_tree(path: &Path) -> io::Result<()> {
1786 let metadata = fs::symlink_metadata(path)?;
1787 if metadata.is_dir() && !metadata.file_type().is_symlink() && !is_reparse_point(&metadata) {
1788 for entry in fs::read_dir(path)? {
1789 protect_tree(&entry?.path())?;
1790 }
1791 }
1792 if !metadata.file_type().is_symlink() && !is_reparse_point(&metadata) {
1793 windows_bundle_security::protect_owner_only(path)?;
1794 }
1795 Ok(())
1796 }
1797}