From 362a81a3e501c56acfb450be69169f48db5a3ebd Mon Sep 17 00:00:00 2001 From: xj Date: Sun, 1 Mar 2026 17:04:17 -0800 Subject: [PATCH 1/3] refactor(plugins): add validation profiles with strict runtime defaults --- src/plugins/manifest.rs | 65 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs index ea0f80c92..1784a804a 100644 --- a/src/plugins/manifest.rs +++ b/src/plugins/manifest.rs @@ -15,6 +15,17 @@ const SUPPORTED_WIT_MAJOR: u64 = 1; const SUPPORTED_WIT_PACKAGES: [&str; 3] = ["zeroclaw:hooks", "zeroclaw:tools", "zeroclaw:providers"]; +/// Validation profile for plugin manifests. +/// +/// Runtime uses `RuntimeWasm` today (strict; requires module path). +/// `SchemaOnly` exists so future non-WASM plugin forms can validate metadata +/// without forcing a fake module path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManifestValidationProfile { + RuntimeWasm, + SchemaOnly, +} + /// Filename plugins must use for their manifest. pub const PLUGIN_MANIFEST_FILENAME: &str = "zeroclaw.plugin.toml"; @@ -49,7 +60,8 @@ pub struct PluginManifest { /// Declared capability set for this plugin. #[serde(default)] pub capabilities: Vec, - /// Optional module path used by WASM-oriented plugin runtimes. + /// WASM module path used by runtime execution. + /// Required in runtime validation; optional in schema-only validation. #[serde(default)] pub module_path: String, /// Declared WIT package contracts the plugin expects. @@ -138,7 +150,10 @@ fn required_wit_package_for_capability(capability: &PluginCapability) -> &'stati } } -pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { +pub fn validate_manifest_with_profile( + manifest: &PluginManifest, + profile: ManifestValidationProfile, +) -> anyhow::Result<()> { if manifest.id.trim().is_empty() { anyhow::bail!("plugin id cannot be empty"); } @@ -147,7 +162,9 @@ pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { anyhow::bail!("plugin version cannot be empty"); } } - if manifest.module_path.trim().is_empty() { + if matches!(profile, ManifestValidationProfile::RuntimeWasm) + && manifest.module_path.trim().is_empty() + { anyhow::bail!("plugin module_path cannot be empty"); } let mut declared_wit_packages = HashSet::new(); @@ -204,6 +221,10 @@ pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { Ok(()) } +pub fn validate_manifest(manifest: &PluginManifest) -> anyhow::Result<()> { + validate_manifest_with_profile(manifest, ManifestValidationProfile::RuntimeWasm) +} + impl PluginManifest { pub fn is_valid(&self) -> bool { validate_manifest(self).is_ok() @@ -343,6 +364,27 @@ id = " " assert!(validate_manifest(&manifest).is_err()); } + #[test] + fn schema_only_validation_allows_empty_module_path() { + let manifest = PluginManifest { + id: "demo".into(), + name: None, + description: None, + version: Some("1.0.0".into()), + config_schema: None, + capabilities: vec![], + module_path: " ".into(), + wit_packages: vec![], + tools: vec![], + providers: vec![], + }; + assert!(validate_manifest_with_profile( + &manifest, + ManifestValidationProfile::SchemaOnly + ) + .is_ok()); + } + #[test] fn manifest_rejects_capability_without_matching_wit_package() { let manifest = PluginManifest { @@ -400,4 +442,21 @@ id = " " }; assert!(validate_manifest(&manifest).is_err()); } + + #[test] + fn manifest_rejects_providers_without_providers_wit_package() { + let manifest = PluginManifest { + id: "demo".into(), + name: None, + description: None, + version: Some("1.0.0".into()), + config_schema: None, + capabilities: vec![], + module_path: "plugins/demo.wasm".into(), + wit_packages: vec!["zeroclaw:hooks@1.0.0".into()], + tools: vec![], + providers: vec!["demo_provider".into()], + }; + assert!(validate_manifest(&manifest).is_err()); + } } From 36b047179d483015d0d792a573d05144b15852b5 Mon Sep 17 00:00:00 2001 From: xj Date: Sun, 1 Mar 2026 17:09:58 -0800 Subject: [PATCH 2/3] fix(ci): format manifest profile regression tests --- src/plugins/manifest.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs index 1784a804a..8525f8cc7 100644 --- a/src/plugins/manifest.rs +++ b/src/plugins/manifest.rs @@ -378,11 +378,10 @@ id = " " tools: vec![], providers: vec![], }; - assert!(validate_manifest_with_profile( - &manifest, - ManifestValidationProfile::SchemaOnly - ) - .is_ok()); + assert!( + validate_manifest_with_profile(&manifest, ManifestValidationProfile::SchemaOnly) + .is_ok() + ); } #[test] From e16bc37017ef9f67bb2702280a53cd251eeaa7a6 Mon Sep 17 00:00:00 2001 From: xj Date: Sun, 1 Mar 2026 17:05:55 -0800 Subject: [PATCH 3/3] fix(plugins): block manifest auto-approve spoofing and discovery panic (RMN-270) --- src/channels/mod.rs | 14 +++----------- src/plugins/discovery.rs | 25 +++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 442550825..47f7c4d8e 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -5740,18 +5740,10 @@ pub async fn start_channels(config: Config) -> Result<()> { // Preserve startup perplexity filter config to ensure policy is not weakened // when runtime store lookup misses. startup_perplexity_filter: config.security.perplexity_filter.clone(), - // WASM skill tools are sandboxed by the WASM engine and cannot access the - // host filesystem, network, or shell. Pre-approve them so they are not - // denied on non-CLI channels (which have no interactive stdin to prompt). approval_manager: { - let mut autonomy = config.autonomy.clone(); - let skills_dir = workspace.join("skills"); - for name in tools::wasm_tool::wasm_tool_names_from_skills(&skills_dir) { - if !autonomy.auto_approve.contains(&name) { - autonomy.auto_approve.push(name); - } - } - Arc::new(ApprovalManager::from_config(&autonomy)) + // Keep approval policy provenance-bound to static config. Do not + // auto-approve tool names from untrusted manifest files. + Arc::new(ApprovalManager::from_config(&config.autonomy)) }, safety_heartbeat: if config.agent.safety_heartbeat_interval > 0 { Some(SafetyHeartbeatConfig { diff --git a/src/plugins/discovery.rs b/src/plugins/discovery.rs index a7354f81c..2397282e3 100644 --- a/src/plugins/discovery.rs +++ b/src/plugins/discovery.rs @@ -124,12 +124,15 @@ pub fn discover_plugins(workspace_dir: Option<&Path>, extra_paths: &[PathBuf]) - seen.insert(plugin.manifest.id.clone(), i); } let mut deduped: Vec = Vec::with_capacity(seen.len()); - // Collect in insertion order of the winning index + // Collect in insertion order of the winning index. + // Sort descending for safe `swap_remove` on a shrinking vec, then restore + // ascending order to preserve deterministic winner ordering. let mut indices: Vec = seen.values().copied().collect(); - indices.sort_unstable(); + indices.sort_unstable_by(|a, b| b.cmp(a)); for i in indices { deduped.push(all_plugins.swap_remove(i)); } + deduped.reverse(); DiscoveryResult { plugins: deduped, @@ -185,6 +188,24 @@ version = "0.1.0" assert!(result.plugins.iter().any(|p| p.manifest.id == "custom-one")); } + #[test] + fn discover_handles_multiple_plugins_without_panicking() { + let tmp = tempfile::tempdir().unwrap(); + let ext_dir = tmp.path().join("custom-plugins"); + fs::create_dir_all(&ext_dir).unwrap(); + make_plugin_dir(&ext_dir, "custom-one"); + make_plugin_dir(&ext_dir, "custom-two"); + + let result = discover_plugins(None, &[ext_dir]); + let ids: std::collections::HashSet = result + .plugins + .iter() + .map(|p| p.manifest.id.clone()) + .collect(); + assert!(ids.contains("custom-one")); + assert!(ids.contains("custom-two")); + } + #[test] fn discover_skips_hidden_dirs() { let tmp = tempfile::tempdir().unwrap();