//! Bounded declarative manifest inspection. No includes, environment interpolation or code execution. use crate::{ extension_package::{Inventory, Release}, workspace::{HostError, Result}, }; use serde_json::Value; use std::{ collections::BTreeSet, io::{Cursor, Read}, }; fn invalid() -> HostError { HostError::new("EXTENSION_MANIFEST_INVALID") } pub fn read(release: &Release, archive: &[u8], inventory: &Inventory) -> Result { let mut zip = zip::ZipArchive::new(Cursor::new(archive)).map_err(|_| invalid())?; let file = zip.by_name(&inventory.manifest).map_err(|_| invalid())?; if file.size() > 1024 * 1024 { return Err(HostError::new("EXTENSION_MANIFEST_LIMIT")); } let mut bytes = Vec::new(); file.take(1024 * 1024 + 1) .read_to_end(&mut bytes) .map_err(|_| invalid())?; if bytes.len() > 1024 * 1024 { return Err(HostError::new("EXTENSION_MANIFEST_LIMIT")); } validate(release, &bytes) } pub fn validate(release: &Release, bytes: &[u8]) -> Result { if bytes.len() > 1024 * 1024 { return Err(HostError::new("EXTENSION_MANIFEST_LIMIT")); } let input = std::str::from_utf8(bytes).map_err(|_| invalid())?; let yaml = matches!(release.kind.as_str(), "theme" | "plugin" | "skill"); if !yaml { serde_json::from_str::(input).map_err(|_| invalid())?; } let options = serde_saphyr::options! { duplicate_keys: serde_saphyr::options::DuplicateKeyPolicy::Error, with_snippet: false, budget: serde_saphyr::budget! { max_documents:1, max_depth:32, max_nodes:10000, max_events:20000, max_total_scalar_bytes:1024*1024, max_aliases:128, max_anchors:128, max_recorded_anchor_events:10000, max_recorded_anchor_bytes:1024*1024 }, }; let value: Value = serde_saphyr::from_str_with_options(input, options).map_err(|_| invalid())?; let map = value.as_object().ok_or_else(invalid)?; if map .get("schema_version") .is_some_and(|v| v.as_u64() != Some(1)) { return Err(HostError::new("EXTENSION_MANIFEST_SCHEMA")); } if yaml { let identity = match release.kind.as_str() { "theme" => "theme_id", "plugin" => "plugin_id", _ => "skill_id", }; let typed = map.get(identity); let common = map.get("id"); if (release.kind == "theme" && typed.is_none()) || typed.or(common).and_then(Value::as_str) != Some(release.package_id.as_str()) || typed.zip(common).is_some_and(|(a, b)| a != b) || map.get("version").and_then(Value::as_str) != Some(release.version.as_str()) { return Err(HostError::new("EXTENSION_MANIFEST_IDENTITY")); } } let permissions: Vec = map .get("permissions") .map(|v| serde_json::from_value(v.clone()).map_err(|_| invalid())) .transpose()? .unwrap_or_default(); let actual: BTreeSet<_> = permissions.iter().collect(); let expected: BTreeSet<_> = release.permissions.iter().collect(); if actual != expected || actual.len() != permissions.len() { return Err(HostError::new("EXTENSION_MANIFEST_PERMISSIONS")); } if !yaml { fn safe(v: &Value) -> bool { match v { Value::Object(map) => map.iter().all(|(k, v)| { !matches!( k.to_lowercase().as_str(), "api_key" | "password" | "token" | "secret" | "chat_history" | "messages" ) && safe(v) }), Value::Array(items) => items.iter().all(safe), _ => true, } } if !safe(&value) { return Err(HostError::new("EXTENSION_MANIFEST_SECRET")); } let valid = match release.kind.as_str() { "persona" => value["system_prompt"].is_string(), "template" => { value["markdown"].is_string() && value .get("executable") .is_none_or(|v| v == false || v.is_null()) } "model" => { ["source", "revision", "license"] .iter() .all(|k| value[*k].as_str().is_some_and(|s| !s.is_empty())) && value["resources"] .as_object() .is_some_and(|v| !v.is_empty()) && value["verified_platforms"] .as_array() .is_some_and(|v| !v.is_empty() && v.iter().all(Value::is_string)) } "mcp" => match value["transport"].as_str() { Some("stdio") => { value["args"] .as_array() .is_some_and(|v| v.iter().all(Value::is_string)) && value .get("command") .is_none_or(|v| v.as_str().is_some_and(|s| !s.is_empty())) } Some("streamable_http" | "sse") => true, _ => false, }, _ => false, }; if !valid { return Err(invalid()); } } Ok(value) } #[cfg(test)] mod tests { use super::*; fn release(kind: &str) -> Release { let mut v: Value = serde_json::from_str(include_str!( "../../src/services/fixtures/community-python-vector.json" )) .unwrap(); let value = v["release"].as_object_mut().unwrap(); for key in ["release_id", "withdrawn", "download_path"] { value.remove(key); } value.insert("type".into(), Value::String(kind.into())); serde_json::from_value(v["release"].clone()).unwrap() } #[test] fn real_repository_manifests_match_identity_version_and_signed_permissions() { for (kind, id, bytes, permissions) in [ ( "plugin", "markdown-workbench", include_bytes!( "../../../backend/extensions/community/plugins/markdown-workbench/plugin.yaml" ) .as_slice(), vec![], ), ( "skill", "note-reviewer", include_bytes!( "../../../backend/extensions/community/skills/note-reviewer/skill.yaml" ) .as_slice(), vec!["notes.search".into(), "notes.read".into()], ), ] { let mut r = release(kind); r.package_id = id.into(); r.permissions = permissions; validate(&r, bytes).unwrap(); r.version = "9.0.0".into(); assert!(validate(&r, bytes).is_err()); } assert!(validate(&release("theme"), b"id: test-package\nversion: 1.0.0").is_err()); validate(&release("theme"), b"theme_id: test-package\nversion: 1.0.0").unwrap(); let r = release("plugin"); for bytes in [ b"id: test-package\nplugin_id: other\nversion: 1.0.0".as_slice(), b"id: test-package\nversion: 1.0.0\npermissions: [notes.read]", b"id: test-package\nversion: 1.0.0\npermissions: false", ] { assert!(validate(&r, bytes).is_err()); } } #[test] fn duplicate_keys_documents_depth_and_includes_are_rejected() { let r = release("plugin"); for bytes in [ b"id: test-package\nversion: 1.0.0\nversion: 2.0.0".as_slice(), b"id: test-package\nversion: 1.0.0\n---\nid: other", b"id: !include /etc/passwd\nversion: 1.0.0", b"id: test-package\nversion: 1.0.0\nx: &a [*a]", ] { assert!(validate(&r, bytes).is_err()); } let deep = format!( "id: test-package\nversion: 1.0.0\nx: {}0{}", "[".repeat(40), "]".repeat(40) ); assert!(validate(&r, deep.as_bytes()).is_err()); assert_eq!( validate(&r, &vec![b' '; 1024 * 1024 + 1]).unwrap_err().code, "EXTENSION_MANIFEST_LIMIT" ); assert_eq!( validate(&r, b"id: test-package\nversion: 1.0.0\nschema_version: 2") .unwrap_err() .code, "EXTENSION_MANIFEST_SCHEMA" ); let persona = release("persona"); assert!(validate( &persona, br#"{"system_prompt":"one","system_prompt":"two"}"# ) .is_err()); assert!(validate(&persona, b"system_prompt: YAML is not JSON").is_err()); } #[test] fn declarative_types_enforce_contract_and_nested_secret_exclusion() { for (kind,bytes) in [("persona",br#"{"system_prompt":"hello"}"#.as_slice()),("template",br##"{"markdown":"# title","executable":false}"##),("mcp",br#"{"transport":"stdio","command":"python","args":["server.py"]}"#),("model",br#"{"source":"repository","revision":"fixed","license":"MIT","resources":{"ram_gb":8},"verified_platforms":["windows"]}"#)] { let r=release(kind); let valid=validate(&r,bytes).unwrap(); for field in ["api_key","token","password","secret","messages","chat_history"] { let mut bad=valid.clone(); bad["nested"]=serde_json::json!([{field:"planted"}]); assert_eq!(validate(&r,&serde_json::to_vec(&bad).unwrap()).unwrap_err().code,"EXTENSION_MANIFEST_SECRET"); } } assert!(validate( &release("template"), br#"{"markdown":"x","executable":true}"# ) .is_err()); assert!(validate(&release("mcp"), br#"{"transport":"stdio","args":[42]}"#).is_err()); } }