From a724ea17dfc6e877c1b0063bf395a257484f952d Mon Sep 17 00:00:00 2001 From: KC <79471844+wolfyy970@users.noreply.github.com> Date: Sat, 8 Aug 2026 04:15:08 -0400 Subject: [PATCH 1/2] feat(buzz-persona): define portable Agent Skill content Signed-off-by: KC <79471844+wolfyy970@users.noreply.github.com> --- Cargo.lock | 10 + crates/buzz-persona/Cargo.toml | 4 + crates/buzz-persona/src/lib.rs | 1 + crates/buzz-persona/src/skill_bundle.rs | 667 ++++++++++++++++++ crates/buzz-persona/src/skill_bundle/tests.rs | 424 +++++++++++ desktop/src-tauri/Cargo.lock | 10 + 6 files changed, 1116 insertions(+) create mode 100644 crates/buzz-persona/src/skill_bundle.rs create mode 100644 crates/buzz-persona/src/skill_bundle/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 83d2b9e3e3..64ead98278 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1117,11 +1117,15 @@ dependencies = [ name = "buzz-persona" version = "0.1.0" dependencies = [ + "hex", "serde", "serde_json", "serde_yaml", + "sha2 0.11.0", "tempfile", "thiserror 2.0.18", + "unicode-casefold", + "unicode-normalization", ] [[package]] @@ -10265,6 +10269,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-casefold" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f66b1c8f8caa2ab31dc6d3f35386f16efdab89668f93411e565ac368908e8f" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/crates/buzz-persona/Cargo.toml b/crates/buzz-persona/Cargo.toml index 884965065b..b980e92315 100644 --- a/crates/buzz-persona/Cargo.toml +++ b/crates/buzz-persona/Cargo.toml @@ -7,10 +7,14 @@ license = "Apache-2.0" repository = "https://github.com/block/sprout" [dependencies] +hex = { workspace = true } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +sha2 = { workspace = true } thiserror = "2" +unicode-casefold = "0.2" +unicode-normalization = "0.1" [dev-dependencies] tempfile = "3" diff --git a/crates/buzz-persona/src/lib.rs b/crates/buzz-persona/src/lib.rs index f344242cac..686b83be02 100644 --- a/crates/buzz-persona/src/lib.rs +++ b/crates/buzz-persona/src/lib.rs @@ -3,4 +3,5 @@ pub mod merge; pub mod pack; pub mod persona; pub mod resolve; +pub mod skill_bundle; pub mod validate; diff --git a/crates/buzz-persona/src/skill_bundle.rs b/crates/buzz-persona/src/skill_bundle.rs new file mode 100644 index 0000000000..40f31e857f --- /dev/null +++ b/crates/buzz-persona/src/skill_bundle.rs @@ -0,0 +1,667 @@ +//! Runtime-neutral content model for portable Agent Skills directories. +//! +//! This module preserves exact regular-file bytes, validates the Agent Skills +//! structure, and computes stable content identities. It deliberately defines +//! no archive, publication, installation, permission, or activation behavior. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Deserialize; +use sha2::{Digest as _, Sha256}; +use unicode_casefold::UnicodeCaseFold as _; +use unicode_normalization::UnicodeNormalization as _; + +/// Current portable Skill bundle schema. +pub const SKILL_BUNDLE_SCHEMA_VERSION: u16 = 1; + +const MAX_SKILLS_PER_BUNDLE: usize = 32; +const MAX_FILES_PER_SKILL: usize = 256; +/// Maximum bytes in one file accepted by the portable Skill model. +pub const MAX_PORTABLE_SKILL_FILE_BYTES: usize = 16 * 1024 * 1024; +const MAX_SKILL_BUNDLE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_SKILL_PATH_BYTES: usize = 512; + +/// A validated collection of complete Agent Skills directories. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillBundle { + /// Schema used to validate and hash this bundle. + pub schema_version: u16, + /// Complete Skills. Input order does not affect the canonical digest. + pub skills: Vec, +} + +/// One complete Agent Skills directory. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PortableSkill { + /// Agent Skills name and directory name. + pub name: String, + /// Discovery description parsed from `SKILL.md`. + pub description: String, + /// Every regular file below the Skill root. + pub files: Vec, +} + +/// One exact regular file below a Skill root. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PortableSkillFile { + /// NFC-normalized forward-slash path relative to the Skill root. + pub path: String, + /// Exact bytes, including binary assets and empty files. + pub bytes: Vec, + /// Source executable intent. Validation never executes the file. + pub executable: bool, +} + +/// Parsed metadata and exact file inventory for review UI. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillBundleInspection { + /// Stable digest of the complete bundle. + pub digest: String, + /// Skills in canonical name order. + pub skills: Vec, +} + +/// Review information for one Skill. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PortableSkillInspection { + /// Skill name. + pub name: String, + /// Discovery description. + pub description: String, + /// License name or reference to a bundled license file. + pub license: Option, + /// Declared runtime or product compatibility constraints. + pub compatibility: Option, + /// Arbitrary Agent Skills metadata preserved for exact review. + pub metadata: BTreeMap, + /// Experimental, untrusted Agent Skills request. This is never a Buzz + /// permission or tool grant. + pub requested_allowed_tools: Option, + /// Stable digest of the exact Skill directory. + pub digest: String, + /// Complete canonical file inventory. + pub files: Vec, +} + +/// Review information for one exact file. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PortableSkillFileInspection { + /// Path relative to the Skill root. + pub path: String, + /// Exact byte length. + pub size: u64, + /// SHA-256 of exact bytes. + pub sha256: String, + /// Source executable intent. + pub executable: bool, + /// Whether the complete bytes are valid UTF-8 and can be shown as text. + pub is_utf8: bool, +} + +/// Advisory review finding in UTF-8 Skill content. +/// +/// An empty result never proves that a bundle is free of sensitive data. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkillBundleWarning { + /// Skill containing the finding. + pub skill_name: String, + /// File containing the finding. + pub path: String, + /// Review guidance suitable for import/export UI. + pub message: String, +} + +/// Validated Agent Skills frontmatter for discovery and review. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PortableSkillMetadata { + /// Agent Skills name. It must match the containing directory. + name: String, + /// Discovery description. + description: String, + /// License name or reference to a bundled license file. + license: Option, + /// Declared runtime or product compatibility constraints. + compatibility: Option, + /// Arbitrary Agent Skills metadata. + metadata: BTreeMap, + /// Experimental, untrusted Agent Skills request. This is never a Buzz + /// permission or tool grant. + requested_allowed_tools: Option, +} + +impl PortableSkillMetadata { + /// Agent Skills name matching the containing directory. + pub fn name(&self) -> &str { + &self.name + } + + /// Discovery description. + pub fn description(&self) -> &str { + &self.description + } + + /// License name or bundled license-file reference. + pub fn license(&self) -> Option<&str> { + self.license.as_deref() + } + + /// Declared runtime or product compatibility constraints. + pub fn compatibility(&self) -> Option<&str> { + self.compatibility.as_deref() + } + + /// Arbitrary Agent Skills metadata. + pub fn metadata(&self) -> &BTreeMap { + &self.metadata + } + + /// Experimental, untrusted tool request. This is never a Buzz grant. + pub fn requested_allowed_tools(&self) -> Option<&str> { + self.requested_allowed_tools.as_deref() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +struct AgentSkillFrontmatter { + name: String, + description: String, + #[serde(default)] + license: Option, + #[serde(default)] + compatibility: Option, + #[serde(default)] + metadata: BTreeMap, + #[serde(default)] + allowed_tools: Option, +} + +/// Parse and validate one `SKILL.md` for discovery without installing or +/// activating it. `expected_name` is the containing Skill directory name. +/// Filesystem callers should bounded-read no more than +/// [`MAX_PORTABLE_SKILL_FILE_BYTES`] before constructing this byte slice. +pub fn inspect_skill_md( + expected_name: &str, + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_PORTABLE_SKILL_FILE_BYTES { + return Err(format!( + "Skill {expected_name:?} SKILL.md exceeds {MAX_PORTABLE_SKILL_FILE_BYTES} bytes." + )); + } + validate_skill_name(expected_name)?; + let metadata = parse_skill_frontmatter(expected_name, bytes)?; + if metadata.name != expected_name { + return Err(format!( + "Skill {expected_name:?} name must match its directory." + )); + } + Ok(PortableSkillMetadata { + name: metadata.name, + description: metadata.description, + license: metadata.license, + compatibility: metadata.compatibility, + metadata: metadata.metadata, + requested_allowed_tools: metadata.allowed_tools, + }) +} + +impl SkillBundle { + /// Validate exact contents without publishing, installing, or executing. + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != SKILL_BUNDLE_SCHEMA_VERSION { + return Err(format!( + "Skill bundle schema version {} is unsupported.", + self.schema_version + )); + } + if self.skills.is_empty() || self.skills.len() > MAX_SKILLS_PER_BUNDLE { + return Err(format!( + "A bundle must contain 1 to {MAX_SKILLS_PER_BUNDLE} Skills." + )); + } + + let mut names = BTreeSet::new(); + let mut total_bytes = 0u64; + for skill in &self.skills { + validate_skill_name(&skill.name)?; + validate_skill_description(&skill.name, &skill.description)?; + if !names.insert(skill.name.clone()) { + return Err(format!("Skill {:?} appears more than once.", skill.name)); + } + validate_skill_files(skill, &mut total_bytes)?; + } + Ok(()) + } + + /// Stable SHA-256 identity of every Skill, path, byte, and executable bit. + pub fn canonical_digest(&self) -> Result { + self.validate()?; + let mut skills = self.skills.iter().collect::>(); + skills.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + let mut hasher = Sha256::new(); + hash_field(&mut hasher, b"buzz-portable-skill-bundle-v1"); + for skill in skills { + hash_field(&mut hasher, skill_digest(skill)?.as_bytes()); + } + Ok(hex::encode(hasher.finalize())) + } + + /// Build exact review metadata. `allowed-tools` remains a request and the + /// caller must never treat it as a permission grant. + pub fn inspection(&self) -> Result { + self.validate()?; + let digest = self.canonical_digest()?; + let mut skills = self.skills.iter().collect::>(); + skills.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + let skills = skills + .into_iter() + .map(inspect_skill) + .collect::, _>>()?; + Ok(SkillBundleInspection { digest, skills }) + } + + /// Find review risks in UTF-8 files without claiming that the bundle is + /// safe. Binary files remain opaque and are identified by + /// [`PortableSkillFileInspection::is_utf8`]. Warnings never alter bytes. + pub fn review_warnings(&self) -> Result, String> { + self.validate()?; + let mut warnings = Vec::new(); + for skill in &self.skills { + for file in &skill.files { + let Ok(content) = std::str::from_utf8(&file.bytes) else { + continue; + }; + if file_looks_like_it_contains_a_secret(content) { + warnings.push(SkillBundleWarning { + skill_name: skill.name.clone(), + path: file.path.clone(), + message: "This file may contain a credential. Included files can contain sensitive data and must be reviewed before sharing." + .to_string(), + }); + } + if content.chars().any(is_suspicious_unicode) { + warnings.push(SkillBundleWarning { + skill_name: skill.name.clone(), + path: file.path.clone(), + message: "This file contains invisible or directional Unicode formatting. Review the exact content before sharing or activation." + .to_string(), + }); + } + } + } + Ok(warnings) + } +} + +fn validate_skill_files(skill: &PortableSkill, total_bytes: &mut u64) -> Result<(), String> { + if skill.files.is_empty() || skill.files.len() > MAX_FILES_PER_SKILL { + return Err(format!( + "Skill {:?} must include 1 to {MAX_FILES_PER_SKILL} files.", + skill.name + )); + } + + let mut paths = Vec::with_capacity(skill.files.len()); + let mut skill_md = None; + for file in &skill.files { + validate_portable_skill_path(&file.path)?; + if file.bytes.len() > MAX_PORTABLE_SKILL_FILE_BYTES { + return Err(format!( + "{} in Skill {:?} exceeds {MAX_PORTABLE_SKILL_FILE_BYTES} bytes.", + file.path, skill.name + )); + } + *total_bytes = total_bytes + .checked_add(file.bytes.len() as u64) + .ok_or_else(|| "Skill bundle size overflowed.".to_string())?; + if *total_bytes > MAX_SKILL_BUNDLE_BYTES { + return Err(format!( + "Skill files can contain at most {MAX_SKILL_BUNDLE_BYTES} bytes in total." + )); + } + paths.push(file.path.as_str()); + if file.path == "SKILL.md" { + skill_md = Some(file.bytes.as_slice()); + } + } + validate_portable_path_set(paths.into_iter())?; + let skill_md = + skill_md.ok_or_else(|| format!("Skill {:?} is missing SKILL.md.", skill.name))?; + let metadata = inspect_skill_md(&skill.name, skill_md)?; + if metadata.description != skill.description { + return Err(format!( + "Skill {:?} metadata must match SKILL.md.", + skill.name + )); + } + Ok(()) +} + +fn inspect_skill(skill: &PortableSkill) -> Result { + let skill_md = skill + .files + .iter() + .find(|file| file.path == "SKILL.md") + .ok_or_else(|| format!("Skill {:?} is missing SKILL.md.", skill.name))?; + let metadata = inspect_skill_md(&skill.name, &skill_md.bytes)?; + let mut files = skill + .files + .iter() + .map(|file| PortableSkillFileInspection { + path: file.path.clone(), + size: file.bytes.len() as u64, + sha256: sha256_hex(&file.bytes), + executable: file.executable, + is_utf8: std::str::from_utf8(&file.bytes).is_ok(), + }) + .collect::>(); + files.sort_unstable_by(|left, right| left.path.cmp(&right.path)); + Ok(PortableSkillInspection { + name: skill.name.clone(), + description: skill.description.clone(), + license: metadata.license, + compatibility: metadata.compatibility, + metadata: metadata.metadata, + requested_allowed_tools: metadata.requested_allowed_tools, + digest: skill_digest(skill)?, + files, + }) +} + +fn parse_skill_frontmatter( + skill_name: &str, + bytes: &[u8], +) -> Result { + let content = std::str::from_utf8(bytes) + .map_err(|_| format!("Skill {skill_name:?} SKILL.md is not UTF-8."))?; + let normalized = content.replace("\r\n", "\n"); + let remainder = normalized + .strip_prefix("---\n") + .ok_or_else(|| format!("Skill {skill_name:?} SKILL.md needs YAML frontmatter."))?; + let frontmatter = if let Some(closing) = remainder.find("\n---\n") { + &remainder[..=closing] + } else if let Some(frontmatter) = remainder.strip_suffix("---") { + frontmatter + .ends_with('\n') + .then_some(frontmatter) + .ok_or_else(|| format!("Skill {skill_name:?} SKILL.md frontmatter is incomplete."))? + } else { + return Err(format!( + "Skill {skill_name:?} SKILL.md frontmatter is incomplete." + )); + }; + let raw_metadata: serde_yaml::Value = serde_yaml::from_str(frontmatter) + .map_err(|error| format!("Skill {skill_name:?} frontmatter is invalid: {error}"))?; + validate_frontmatter_value_types(skill_name, &raw_metadata)?; + let metadata: AgentSkillFrontmatter = serde_yaml::from_value(raw_metadata) + .map_err(|error| format!("Skill {skill_name:?} frontmatter is invalid: {error}"))?; + validate_optional_metadata(skill_name, &metadata)?; + Ok(metadata) +} + +fn validate_frontmatter_value_types( + skill_name: &str, + metadata: &serde_yaml::Value, +) -> Result<(), String> { + let mapping = metadata.as_mapping().ok_or_else(|| { + format!("Skill {skill_name:?} SKILL.md frontmatter must be a key-value mapping.") + })?; + for field in [ + "name", + "description", + "license", + "compatibility", + "allowed-tools", + ] { + if mapping + .get(serde_yaml::Value::String(field.to_string())) + .is_some_and(|value| !value.is_string()) + { + return Err(format!( + "Skill {skill_name:?} SKILL.md {field} must be a string." + )); + } + } + if let Some(value) = mapping.get(serde_yaml::Value::String("metadata".to_string())) { + let entries = value + .as_mapping() + .ok_or_else(|| format!("Skill {skill_name:?} SKILL.md metadata must be a mapping."))?; + if entries + .iter() + .any(|(key, value)| !key.is_string() || !value.is_string()) + { + return Err(format!( + "Skill {skill_name:?} SKILL.md metadata keys and values must be strings." + )); + } + } + Ok(()) +} + +fn validate_optional_metadata( + skill_name: &str, + metadata: &AgentSkillFrontmatter, +) -> Result<(), String> { + validate_skill_description(skill_name, &metadata.description)?; + if metadata + .compatibility + .as_ref() + .is_some_and(|value| value.is_empty() || value.chars().count() > 500) + { + return Err(format!( + "Skill {skill_name:?} compatibility must contain 1 to 500 characters." + )); + } + Ok(()) +} + +fn validate_skill_name(name: &str) -> Result<(), String> { + let normalized = name.nfkc().collect::(); + let valid = !name.is_empty() + && name.chars().count() <= 64 + && name.len() <= 255 + && name.encode_utf16().count() <= 255 + && normalized == name + && name.chars().flat_map(char::to_lowercase).eq(name.chars()) + && name + .chars() + .all(|character| character.is_alphanumeric() || character == '-') + && name.chars().next().is_some_and(char::is_alphanumeric) + && name.chars().last().is_some_and(char::is_alphanumeric) + && !name.contains("--"); + if valid && !windows_reserved_segment(name) { + Ok(()) + } else { + Err(format!( + "Skill name {name:?} does not follow the Agent Skills slug format." + )) + } +} + +fn validate_skill_description(skill_name: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() || value.chars().count() > 1_024 { + return Err(format!("Skill {skill_name:?} has an invalid description.")); + } + Ok(()) +} + +fn validate_portable_skill_path(path: &str) -> Result<(), String> { + if path.is_empty() + || path.len() > MAX_SKILL_PATH_BYTES + || path.starts_with('/') + || path.contains('\\') + || path.nfc().collect::() != path + || path.chars().any(|character| { + character.is_control() + || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*') + || is_suspicious_unicode(character) + }) + { + return Err(format!("{path:?} is not a portable Skill path.")); + } + let segments = path.split('/').collect::>(); + if segments.iter().any(|segment| { + segment.is_empty() + || matches!(*segment, "." | "..") + || segment.starts_with(' ') + || segment.ends_with('.') + || segment.ends_with(' ') + || segment.len() > 255 + || segment.encode_utf16().count() > 255 + || windows_reserved_segment(segment) + }) { + return Err(format!("{path:?} is not a portable Skill path.")); + } + Ok(()) +} + +fn validate_portable_path_set<'a>(paths: impl Iterator) -> Result<(), String> { + let mut normalized = BTreeMap::new(); + for path in paths { + let key = portable_path_key(path); + if let Some(other) = normalized.insert(key, path) { + return Err(format!( + "Skill paths {other:?} and {path:?} collide on a portable filesystem." + )); + } + } + for (key, path) in &normalized { + for (index, _) in key.match_indices('/') { + if let Some(ancestor) = normalized.get(&key[..index]) { + return Err(format!( + "Skill paths {ancestor:?} and {path:?} collide on a portable filesystem." + )); + } + } + } + Ok(()) +} + +fn portable_path_key(path: &str) -> String { + let normalized = path.nfc().collect::(); + normalized.as_str().case_fold().nfc().collect() +} + +fn windows_reserved_segment(segment: &str) -> bool { + let stem = segment + .split_once('.') + .map_or(segment, |(candidate, _)| candidate) + .to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || stem + .strip_prefix("COM") + .or_else(|| stem.strip_prefix("LPT")) + .is_some_and(|suffix| { + matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") + || matches!(suffix, "¹" | "²" | "³") + }) +} + +fn is_suspicious_unicode(character: char) -> bool { + matches!( + character as u32, + 0x00AD + | 0x034F + | 0x061C + | 0x180E + | 0x200B + | 0x200E..=0x200F + | 0x202A..=0x202E + | 0x2060..=0x206F + | 0xFEFF + | 0xFFF9..=0xFFFB + ) +} + +fn file_looks_like_it_contains_a_secret(content: &str) -> bool { + let lowercase = content.to_ascii_lowercase(); + if [ + "-----begin private key-----", + "-----begin rsa private key-----", + "-----begin openssh private key-----", + "authorization: bearer ", + "database_url=", + "database_url:", + ] + .iter() + .any(|marker| lowercase.contains(marker)) + { + return true; + } + + content.lines().any(|line| { + let line = line.trim().trim_start_matches("export ").trim(); + let Some((key, value)) = line.split_once('=').or_else(|| line.split_once(':')) else { + return false; + }; + let key = key + .trim_matches(|character: char| { + character == '"' || character == '\'' || character.is_whitespace() + }) + .to_ascii_uppercase(); + let sensitive_key = [ + "API_KEY", + "TOKEN", + "PASSWORD", + "SECRET", + "PRIVATE_KEY", + "AUTHORIZATION", + "DATABASE_URL", + ] + .iter() + .any(|marker| key.contains(marker)); + if !sensitive_key { + return false; + } + let value = value + .trim() + .trim_matches(|character| character == '"' || character == '\''); + value.len() >= 8 && !looks_like_placeholder(value) + }) +} + +fn looks_like_placeholder(value: &str) -> bool { + let lowercase = value.to_ascii_lowercase(); + [ + "example", + "placeholder", + "your-", + "your_", + "replace", + "xxxx", + "<", + "${", + "test-only", + ] + .iter() + .any(|marker| lowercase.contains(marker)) +} + +fn skill_digest(skill: &PortableSkill) -> Result { + let mut files = skill.files.iter().collect::>(); + files.sort_unstable_by(|left, right| left.path.cmp(&right.path)); + let mut hasher = Sha256::new(); + hash_field(&mut hasher, b"buzz-portable-agent-skill-v1"); + hash_field(&mut hasher, skill.name.as_bytes()); + hash_field(&mut hasher, skill.description.as_bytes()); + for file in files { + hash_field(&mut hasher, file.path.as_bytes()); + hash_field(&mut hasher, &file.bytes); + hash_field(&mut hasher, &[u8::from(file.executable)]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn hash_field(hasher: &mut Sha256, value: &[u8]) { + hasher.update((value.len() as u64).to_be_bytes()); + hasher.update(value); +} + +#[cfg(test)] +#[path = "skill_bundle/tests.rs"] +mod tests; diff --git a/crates/buzz-persona/src/skill_bundle/tests.rs b/crates/buzz-persona/src/skill_bundle/tests.rs new file mode 100644 index 0000000000..3ec8066918 --- /dev/null +++ b/crates/buzz-persona/src/skill_bundle/tests.rs @@ -0,0 +1,424 @@ +use super::*; + +fn skill_md(name: &str, description: &str, extra: &str) -> Vec { + format!( + "---\nname: {name}\ndescription: {description}\n{extra}---\n\n# Instructions\n\nInspect production health.\n" + ) + .into_bytes() +} + +fn skill(name: &str, description: &str) -> PortableSkill { + PortableSkill { + name: name.to_string(), + description: description.to_string(), + files: vec![ + PortableSkillFile { + path: "SKILL.md".to_string(), + bytes: skill_md(name, description, "allowed-tools: Bash(vercel:*) Read\n"), + executable: false, + }, + PortableSkillFile { + path: "scripts/check.sh".to_string(), + bytes: b"#!/bin/sh\nexit 0\n".to_vec(), + executable: true, + }, + PortableSkillFile { + path: "assets/status.png".to_string(), + bytes: vec![0, 159, 146, 150, 255], + executable: false, + }, + PortableSkillFile { + path: "references/caf\u{00e9}.md".to_string(), + bytes: Vec::new(), + executable: false, + }, + ], + } +} + +fn bundle(skills: Vec) -> SkillBundle { + SkillBundle { + schema_version: SKILL_BUNDLE_SCHEMA_VERSION, + skills, + } +} + +#[test] +fn preserves_complete_agent_skills_directories_and_review_metadata() { + let bundle = bundle(vec![skill( + "production-health", + "Inspect production health", + )]); + bundle.validate().unwrap(); + let inspection = bundle.inspection().unwrap(); + assert_eq!(inspection.skills.len(), 1); + assert_eq!( + inspection.skills[0].requested_allowed_tools.as_deref(), + Some("Bash(vercel:*) Read") + ); + assert_eq!(inspection.skills[0].files.len(), 4); + assert!(inspection.skills[0] + .files + .iter() + .any(|file| file.path == "scripts/check.sh" && file.executable)); + assert!(inspection.skills[0] + .files + .iter() + .any(|file| file.path == "assets/status.png" && !file.is_utf8)); +} + +#[test] +fn digest_is_deterministic_across_skill_and_file_order() { + let mut alpha = skill("alpha", "Alpha Skill"); + let beta = skill("beta", "Beta Skill"); + let first = bundle(vec![alpha.clone(), beta.clone()]); + alpha.files.reverse(); + let second = bundle(vec![beta, alpha]); + assert_eq!( + first.canonical_digest().unwrap(), + second.canonical_digest().unwrap() + ); +} + +#[test] +fn digest_changes_with_every_identity_bearing_field() { + let original = skill("identity", "Identity Skill"); + let original_digest = bundle(vec![original.clone()]).canonical_digest().unwrap(); + let assert_changed = |candidate: PortableSkill| { + assert_ne!( + bundle(vec![candidate]).canonical_digest().unwrap(), + original_digest + ); + }; + + let mut changed = original.clone(); + changed.files[2].bytes[1] ^= 1; + assert_changed(changed); + + let mut changed = original.clone(); + changed.files[3].path = "references/renamed.md".to_string(); + assert_changed(changed); + + let mut changed = original.clone(); + changed.files[2].executable = true; + assert_changed(changed); + + let mut changed = original.clone(); + changed.name = "renamed".to_string(); + changed.files[0].bytes = skill_md( + "renamed", + "Identity Skill", + "allowed-tools: Bash(vercel:*) Read\n", + ); + assert_changed(changed); + + let mut changed = original; + changed.description = "Changed description".to_string(); + changed.files[0].bytes = skill_md( + "identity", + "Changed description", + "allowed-tools: Bash(vercel:*) Read\n", + ); + assert_changed(changed); + + let inspection = bundle(vec![skill("identity", "Identity Skill")]) + .inspection() + .unwrap(); + assert_eq!( + inspection.skills[0] + .files + .iter() + .find(|file| file.path == "assets/status.png") + .map(|file| file.sha256.as_str()), + Some("e92ad0d01485ac7095ffc21874b70b74f9667cf2c937b8ab2527a96d847fb21e") + ); +} + +#[test] +fn rejects_missing_or_mismatched_skill_md() { + let mut missing = skill("safe-skill", "Safe Skill"); + missing.files.retain(|file| file.path != "SKILL.md"); + assert!(bundle(vec![missing]).validate().is_err()); + + let mut mismatch = skill("safe-skill", "Safe Skill"); + mismatch.files[0].bytes = skill_md("different-skill", "Safe Skill", ""); + assert!(bundle(vec![mismatch]).validate().is_err()); +} + +#[test] +fn preserves_valid_markdown_formatting_and_warns_for_invisible_unicode() { + let mut ordinary = skill("ordinary-emoji", "Ordinary emoji"); + ordinary.files[0] + .bytes + .extend_from_slice("\nFamily: 👩\u{200d}💻\u{fe0f}.".as_bytes()); + let ordinary = bundle(vec![ordinary]); + ordinary.validate().unwrap(); + assert!(ordinary.review_warnings().unwrap().is_empty()); + + let mut candidate = skill("safe-skill", "Safe Skill"); + candidate.files[0] + .bytes + .extend_from_slice("\nRTL: \u{2067}مرحبا\u{2069}.".as_bytes()); + let candidate = bundle(vec![candidate]); + candidate.validate().unwrap(); + let warnings = candidate.review_warnings().unwrap(); + assert!(warnings + .iter() + .any(|warning| warning.message.contains("Unicode formatting"))); +} + +#[test] +fn accepts_normalized_unicode_skill_names() { + bundle(vec![skill("données", "Analyse des données")]) + .validate() + .unwrap(); + + let oversized = "𐐨".repeat(64); + assert!(bundle(vec![skill(&oversized, "Too many path bytes")]) + .validate() + .is_err()); +} + +#[test] +fn accepts_multiline_description_and_frontmatter_at_eof() { + let description = "Inspect production health\nand explain when to intervene.\n"; + let skill = PortableSkill { + name: "production-health".to_string(), + description: description.to_string(), + files: vec![PortableSkillFile { + path: "SKILL.md".to_string(), + bytes: b"---\nname: production-health\ndescription: |\n Inspect production health\n and explain when to intervene.\n---" + .to_vec(), + executable: false, + }], + }; + bundle(vec![skill]).validate().unwrap(); +} + +#[test] +fn rejects_traversal_windows_and_non_normalized_paths() { + for path in [ + "../secret", + "/secret", + "refs\\secret", + "C:secret", + "CON.txt", + "COM\u{00b9}.txt", + "LPT\u{00b2}", + "trailing.", + "cafe\u{0301}.md", + " deceptive.txt", + "safe\u{202e}gnp.txt", + &"a".repeat(256), + ] { + let mut candidate = skill("safe-skill", "Safe Skill"); + candidate.files.push(PortableSkillFile { + path: path.to_string(), + bytes: b"content".to_vec(), + executable: false, + }); + assert!(bundle(vec![candidate]).validate().is_err(), "{path}"); + } +} + +#[test] +fn rejects_windows_reserved_skill_directory_names() { + for name in ["con", "nul", "com1", "lpt9"] { + assert!(bundle(vec![skill(name, "Reserved name")]) + .validate() + .is_err()); + } +} + +#[test] +fn rejects_casefold_and_file_directory_collisions_with_interposers() { + let mut casefold = skill("safe-skill", "Safe Skill"); + for path in ["Guide.md", "guide.md"] { + casefold.files.push(PortableSkillFile { + path: path.to_string(), + bytes: b"content".to_vec(), + executable: false, + }); + } + assert!(bundle(vec![casefold]).validate().is_err()); + + let mut unicode_casefold = skill("safe-skill", "Safe Skill"); + for path in ["οσ.md", "Ος.md"] { + unicode_casefold.files.push(PortableSkillFile { + path: path.to_string(), + bytes: b"content".to_vec(), + executable: false, + }); + } + assert!(bundle(vec![unicode_casefold]).validate().is_err()); + + let mut directory = skill("safe-skill", "Safe Skill"); + for path in ["scripts", "scripts-old", "scripts/extra.sh"] { + directory.files.push(PortableSkillFile { + path: path.to_string(), + bytes: b"content".to_vec(), + executable: false, + }); + } + assert!(bundle(vec![directory]).validate().is_err()); +} + +#[test] +fn inspection_preserves_all_agent_skills_frontmatter() { + let mut candidate = skill("safe-skill", "Safe Skill"); + candidate.files[0].bytes = skill_md( + "safe-skill", + "Safe Skill", + "license: Apache-2.0\ncompatibility: Requires network access\nmetadata:\n author: Buzz\nallowed-tools: Read\n", + ); + let inspection = bundle(vec![candidate]).inspection().unwrap(); + let skill = &inspection.skills[0]; + assert_eq!(skill.license.as_deref(), Some("Apache-2.0")); + assert_eq!( + skill.compatibility.as_deref(), + Some("Requires network access") + ); + assert_eq!( + skill.metadata.get("author").map(String::as_str), + Some("Buzz") + ); + assert_eq!(skill.requested_allowed_tools.as_deref(), Some("Read")); +} + +#[test] +fn single_skill_inspection_validates_the_directory_identity() { + let bytes = skill_md( + "safe-skill", + "Safe Skill", + "license: Apache-2.0\nallowed-tools: Read\n", + ); + let metadata = inspect_skill_md("safe-skill", &bytes).unwrap(); + assert_eq!(metadata.name(), "safe-skill"); + assert_eq!(metadata.description(), "Safe Skill"); + assert_eq!(metadata.license(), Some("Apache-2.0")); + assert_eq!(metadata.compatibility(), None); + assert!(metadata.metadata().is_empty()); + assert_eq!(metadata.requested_allowed_tools(), Some("Read")); + assert!(inspect_skill_md("different-skill", &bytes).is_err()); +} + +#[test] +fn single_skill_inspection_is_bounded_before_parsing() { + let oversized = vec![b'x'; MAX_PORTABLE_SKILL_FILE_BYTES + 1]; + assert!(inspect_skill_md("safe-skill", &oversized) + .unwrap_err() + .contains("exceeds")); +} + +#[test] +fn accepts_spec_valid_empty_and_whitespace_optional_metadata() { + let candidate = PortableSkill { + name: "safe-skill".to_string(), + description: "Safe Skill".to_string(), + files: vec![PortableSkillFile { + path: "SKILL.md".to_string(), + bytes: b"---\nname: safe-skill\ndescription: Safe Skill\nlicense: ' Custom license '\nmetadata:\n '': ''\nallowed-tools: ''\n---\nBody" + .to_vec(), + executable: false, + }], + }; + let inspection = bundle(vec![candidate]).inspection().unwrap(); + assert_eq!( + inspection.skills[0].license.as_deref(), + Some(" Custom license ") + ); + assert_eq!( + inspection.skills[0].metadata.get("").map(String::as_str), + Some("") + ); + assert_eq!( + inspection.skills[0].requested_allowed_tools.as_deref(), + Some("") + ); +} + +#[test] +fn rejects_empty_compatibility_when_present() { + let bytes = b"---\nname: safe-skill\ndescription: Safe Skill\ncompatibility: ''\n---\nBody"; + assert!(inspect_skill_md("safe-skill", bytes) + .unwrap_err() + .contains("1 to 500")); +} + +#[test] +fn accepts_deep_paths_within_the_portable_length_limit() { + let mut candidate = skill("safe-skill", "Safe Skill"); + let path = format!("{}/file.txt", vec!["nested"; 20].join("/")); + candidate.files.push(PortableSkillFile { + path, + bytes: b"content".to_vec(), + executable: false, + }); + bundle(vec![candidate]).validate().unwrap(); +} + +#[test] +fn rejects_unknown_or_malformed_agent_skills_frontmatter() { + let mut unknown = skill("safe-skill", "Safe Skill"); + unknown.files[0].bytes = + b"---\nname: safe-skill\ndescription: Safe Skill\nruntime-policy: unrestricted\n---\nBody" + .to_vec(); + assert!(bundle(vec![unknown]).validate().is_err()); + + let mut non_string_metadata = skill("safe-skill", "Safe Skill"); + non_string_metadata.files[0].bytes = + b"---\nname: safe-skill\ndescription: Safe Skill\nmetadata:\n version: 1\n---\nBody" + .to_vec(); + assert!(bundle(vec![non_string_metadata]).validate().is_err()); + + let mut duplicate = skill("safe-skill", "Safe Skill"); + duplicate.files[0].bytes = + b"---\nname: safe-skill\nname: other\ndescription: Safe Skill\n---\nBody".to_vec(); + assert!(bundle(vec![duplicate]).validate().is_err()); +} + +#[test] +fn duplicate_skills_and_unknown_bundle_versions_fail_closed() { + let duplicate = skill("safe-skill", "Safe Skill"); + assert!(bundle(vec![duplicate.clone(), duplicate]) + .validate() + .is_err()); + + let mut unknown = bundle(vec![skill("safe-skill", "Safe Skill")]); + unknown.schema_version += 1; + assert!(unknown.validate().is_err()); +} + +#[test] +fn bundle_size_is_bounded_before_digest_or_inspection() { + let mut candidate = skill("safe-skill", "Safe Skill"); + for index in 0..4 { + candidate.files.push(PortableSkillFile { + path: format!("assets/large-{index}.bin"), + bytes: vec![index as u8; MAX_PORTABLE_SKILL_FILE_BYTES], + executable: false, + }); + } + let oversized = bundle(vec![candidate]); + assert!(oversized.validate().unwrap_err().contains("in total")); + assert!(oversized.canonical_digest().is_err()); + assert!(oversized.inspection().is_err()); +} + +#[test] +fn secret_scan_is_advisory_and_never_claims_binary_safety() { + let mut risky = skill("database-access", "Database access"); + risky.files.push(PortableSkillFile { + path: "references/connection.txt".to_string(), + bytes: b"DATABASE_URL=postgres://user:real-password@host/database".to_vec(), + executable: false, + }); + let candidate = bundle(vec![risky]); + let warnings = candidate.review_warnings().unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].message.contains("may contain")); + assert!(candidate.inspection().unwrap().skills[0] + .files + .iter() + .any(|file| file.path == "assets/status.png" && !file.is_utf8)); +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 94504f970d..ecb07cce83 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1186,10 +1186,14 @@ dependencies = [ name = "buzz-persona" version = "0.1.0" dependencies = [ + "hex", "serde", "serde_json", "serde_yaml", + "sha2 0.11.0", "thiserror 2.0.18", + "unicode-casefold", + "unicode-normalization", ] [[package]] @@ -11625,6 +11629,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-casefold" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f66b1c8f8caa2ab31dc6d3f35386f16efdab89668f93411e565ac368908e8f" + [[package]] name = "unicode-ident" version = "1.0.24" From 11e111bfc99e3e969f152e5af60e6a9d1d3fd67f Mon Sep 17 00:00:00 2001 From: KC <79471844+wolfyy970@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:54:34 -0400 Subject: [PATCH 2/2] feat(persona): expose portable Skill identity Signed-off-by: KC <79471844+wolfyy970@users.noreply.github.com> --- crates/buzz-persona/src/skill_bundle.rs | 28 +++++++++ crates/buzz-persona/src/skill_bundle/tests.rs | 63 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/crates/buzz-persona/src/skill_bundle.rs b/crates/buzz-persona/src/skill_bundle.rs index 40f31e857f..7960637676 100644 --- a/crates/buzz-persona/src/skill_bundle.rs +++ b/crates/buzz-persona/src/skill_bundle.rs @@ -206,6 +206,34 @@ pub fn inspect_skill_md( }) } +impl PortableSkill { + /// Validate this exact Skill directory without installing or activating it. + /// + /// The fields remain mutable, so callers must revalidate after any change. + pub fn validate(&self) -> Result<(), String> { + validate_skill_name(&self.name)?; + validate_skill_description(&self.name, &self.description)?; + validate_skill_files(self, &mut 0) + } + + /// Stable SHA-256 content identity for this exact Skill directory. + /// + /// This digest identifies reviewed content. It is not proof of trust, + /// permission, installation, or runtime availability. + pub fn canonical_digest(&self) -> Result { + self.validate()?; + skill_digest(self) + } + + /// Build exact review metadata for this Skill after revalidating it. + /// + /// `allowed-tools` remains an untrusted request and is never a Buzz grant. + pub fn inspection(&self) -> Result { + self.validate()?; + inspect_skill(self) + } +} + impl SkillBundle { /// Validate exact contents without publishing, installing, or executing. pub fn validate(&self) -> Result<(), String> { diff --git a/crates/buzz-persona/src/skill_bundle/tests.rs b/crates/buzz-persona/src/skill_bundle/tests.rs index 3ec8066918..19af147007 100644 --- a/crates/buzz-persona/src/skill_bundle/tests.rs +++ b/crates/buzz-persona/src/skill_bundle/tests.rs @@ -80,6 +80,69 @@ fn digest_is_deterministic_across_skill_and_file_order() { ); } +#[test] +fn single_skill_identity_matches_its_bundle_review() { + let mut candidate = skill("production-health", "Inspect production health"); + candidate.validate().unwrap(); + + let bundle_inspection = bundle(vec![candidate.clone()]).inspection().unwrap(); + assert_eq!( + candidate.canonical_digest().unwrap(), + bundle_inspection.skills[0].digest + ); + assert_eq!(candidate.inspection().unwrap(), bundle_inspection.skills[0]); + assert_eq!( + candidate + .inspection() + .unwrap() + .requested_allowed_tools + .as_deref(), + Some("Bash(vercel:*) Read") + ); + + let digest = candidate.canonical_digest().unwrap(); + candidate.files.reverse(); + assert_eq!(candidate.canonical_digest().unwrap(), digest); +} + +#[test] +fn single_skill_identity_methods_fail_closed_on_invalid_content() { + let mut missing_manifest = skill("safe-skill", "Safe Skill"); + missing_manifest + .files + .retain(|file| file.path != "SKILL.md"); + + let mut mismatched_metadata = skill("safe-skill", "Safe Skill"); + mismatched_metadata.files[0].bytes = skill_md("other-skill", "Safe Skill", ""); + + let mut colliding_path = skill("safe-skill", "Safe Skill"); + colliding_path.files.push(PortableSkillFile { + path: "SCRIPTS/check.sh".to_string(), + bytes: b"exit 1\n".to_vec(), + executable: true, + }); + + let mut oversized = skill("safe-skill", "Safe Skill"); + for index in 0..4 { + oversized.files.push(PortableSkillFile { + path: format!("assets/large-{index}.bin"), + bytes: vec![index as u8; MAX_PORTABLE_SKILL_FILE_BYTES], + executable: false, + }); + } + + for candidate in [ + missing_manifest, + mismatched_metadata, + colliding_path, + oversized, + ] { + assert!(candidate.validate().is_err()); + assert!(candidate.canonical_digest().is_err()); + assert!(candidate.inspection().is_err()); + } +} + #[test] fn digest_changes_with_every_identity_bearing_field() { let original = skill("identity", "Identity Skill");