Files
NotesAgentic/frontend/src-tauri/src/extension_manifest.rs
T

242 lines
9.6 KiB
Rust

//! 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());
}
}