feat(extensions): 根据签名发行包验证有界清单
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
//! 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<Value> {
|
||||
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<Value> {
|
||||
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::<Value>(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<String> = 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());
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,21 @@ fn version(s: &str) -> bool {
|
||||
})
|
||||
}
|
||||
impl Release {
|
||||
/// Full offline package check. Online revocation freshness and runtime permissions remain Host responsibilities.
|
||||
pub fn verify_package(
|
||||
&self,
|
||||
pinned: &[u8; 32],
|
||||
key_id: &str,
|
||||
namespace: &str,
|
||||
revoked: bool,
|
||||
withdrawn: bool,
|
||||
archive: &[u8],
|
||||
) -> Result<(Inventory, Value)> {
|
||||
self.verify(pinned, key_id, namespace, revoked, withdrawn, archive)?;
|
||||
let inventory = inspect(self, archive)?;
|
||||
let manifest = crate::extension_manifest::read(self, archive, &inventory)?;
|
||||
Ok((inventory, manifest))
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let valid = self.schema_version == 1
|
||||
&& identity(&self.namespace)
|
||||
@@ -470,6 +485,10 @@ mod tests {
|
||||
.unwrap();
|
||||
let inventory = inspect(&release, &bytes).unwrap();
|
||||
assert_eq!(inventory.manifest, "persona.json");
|
||||
let (_, manifest) = release
|
||||
.verify_package(&key, "test-key", "examples", false, false, &bytes)
|
||||
.unwrap();
|
||||
assert!(manifest["system_prompt"].is_string());
|
||||
let value = serde_json::to_value(&release).unwrap();
|
||||
for field in value
|
||||
.as_object()
|
||||
|
||||
@@ -27,3 +27,6 @@ pub mod sync_retry;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extension_package;
|
||||
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extension_manifest;
|
||||
|
||||
Reference in New Issue
Block a user