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(); diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs index ea0f80c92..8525f8cc7 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,26 @@ 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 +441,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()); + } }