From e3e38564914878825713c51a60f066f3a63b1a60 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Wed, 24 Jun 2026 21:05:31 -0700 Subject: [PATCH 01/10] feat: implement dynamic plugin host policy and attestation gates Signed-off-by: Alex Fournier --- Cargo.lock | 3 + crates/cli/Cargo.toml | 3 + crates/cli/src/config.rs | 57 +- crates/cli/src/plugins.rs | 1 + crates/cli/src/plugins/lifecycle.rs | 730 +++++++++++++++-- crates/cli/src/plugins/lifecycle/responses.rs | 70 +- crates/cli/src/plugins/lifecycle/state.rs | 4 +- crates/cli/src/plugins/policy.rs | 274 +++++++ crates/cli/tests/cli_tests.rs | 500 +++++++++++- crates/cli/tests/coverage/config_tests.rs | 738 ++++++++++++++++- crates/cli/tests/coverage/launcher_tests.rs | 1 + .../tests/coverage/plugins_lifecycle_tests.rs | 747 +++++++++++++++++- crates/core/src/plugin/dynamic.rs | 20 +- crates/core/src/plugin/dynamic/manifest.rs | 8 +- crates/core/src/plugin/dynamic/registry.rs | 24 +- .../core/tests/unit/plugin_dynamic_tests.rs | 73 +- 16 files changed, 3131 insertions(+), 122 deletions(-) create mode 100644 crates/cli/src/plugins/policy.rs diff --git a/Cargo.lock b/Cargo.lock index 066876da2..2bc7d15c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1388,6 +1388,7 @@ version = "0.5.0" dependencies = [ "async-stream", "axum", + "base64", "bytes", "clap", "clap_complete", @@ -1402,10 +1403,12 @@ dependencies = [ "opentelemetry", "opentelemetry_sdk", "reqwest", + "ring", "rustls", "serde", "serde_json", "serde_yaml", + "sha2", "strum", "tempfile", "thiserror 2.0.18", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index a494bde4c..d3ef689d1 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -30,6 +30,7 @@ nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] } nemo-relay-pii-redaction.workspace = true async-stream = "0.3" axum = "0.8" +base64 = "0.22" bytes = "1" clap = { version = "4", features = ["derive", "env"] } clap_complete = "4" @@ -39,10 +40,12 @@ http = "1" http-body-util = "0.1" dialoguer = { version = "0.11", default-features = false } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots-no-provider", "stream"] } +ring = "0.17" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +sha2 = "0.11" strum = { version = "0.27", features = ["derive"] } thiserror = "2" tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 33857ae06..effbbcd19 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -11,10 +11,12 @@ use nemo_relay::plugin::dynamic::DynamicPluginManifest; use nemo_relay::plugin::{PluginError, merge_plugin_config_documents}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use strum::Display; +use strum::{Display, IntoStaticStr}; use crate::error::CliError; use crate::plugin_shim::PluginShimCommand; +use crate::plugins::lifecycle::enforce_required_dynamic_plugin_startup; +use crate::plugins::policy::DynamicPluginHostPolicy; #[derive(Debug, Clone, Parser)] #[command(name = "nemo-relay")] @@ -590,6 +592,7 @@ pub(crate) struct ResolvedConfig { pub(crate) gateway: GatewayConfig, pub(crate) agents: AgentConfigs, pub(crate) dynamic_plugins: Vec, + pub(crate) dynamic_plugin_policy: DynamicPluginHostPolicy, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -601,7 +604,7 @@ pub(crate) struct ResolvedDynamicPluginConfig { pub(crate) source: PathBuf, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Display)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Display, IntoStaticStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub(crate) enum DynamicPluginHostConfigStatus { @@ -731,6 +734,7 @@ impl Default for GatewayConfig { pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result { let mut resolved = load_shared_config(args.config.as_ref())?; apply_server_overrides(&mut resolved.gateway, args)?; + enforce_required_dynamic_plugin_startup(args.config.as_ref(), &resolved)?; Ok(resolved) } @@ -771,6 +775,7 @@ pub(crate) fn resolve_run_config( resolved.gateway.bind = "127.0.0.1:0" .parse() .expect("valid transparent bind address"); + enforce_required_dynamic_plugin_startup(config, &resolved)?; Ok(resolved) } @@ -1041,6 +1046,7 @@ fn apply_file_plugins_config(gateway: &mut GatewayConfig, plugins: Option, dynamic_plugins: Vec, + dynamic_plugin_policy: DynamicPluginHostPolicy, sources: Vec, } @@ -1048,6 +1054,8 @@ struct PluginTomlConfig { struct PluginTomlPluginsSection { #[serde(default)] dynamic: Vec, + #[serde(default)] + policy: Option, } #[derive(Debug, Clone, Deserialize)] @@ -1070,6 +1078,7 @@ where { let paths = paths.into_iter().collect::>(); let mut dynamic_plugins = Vec::new(); + let mut dynamic_plugin_policy = DynamicPluginHostPolicy::default(); let mut seen_plugin_ids = HashSet::new(); let mut runtime_documents = Vec::new(); @@ -1087,14 +1096,13 @@ where path.display() )) })?; - dynamic_plugins.extend(resolve_dynamic_plugin_refs( - path, - &mut parsed, - &mut seen_plugin_ids, - )?); + let resolved_plugins = + resolve_dynamic_plugin_refs(path, &mut parsed, &mut seen_plugin_ids)?; + dynamic_plugins.extend(resolved_plugins.dynamic_plugins); + dynamic_plugin_policy.merge(resolved_plugins.dynamic_plugin_policy); runtime_documents.push(( path.clone(), - serde_json::to_value(remove_dynamic_plugin_section(parsed)) + serde_json::to_value(remove_dynamic_plugin_sections(parsed)) .expect("toml value serializes to JSON"), )); } @@ -1109,11 +1117,15 @@ where Some((value, sources)) => Ok(Some(PluginTomlConfig { value: plugin_toml_runtime_value(value), dynamic_plugins, + dynamic_plugin_policy, sources, })), - None => Ok((!dynamic_plugins.is_empty()).then_some(PluginTomlConfig { + None => Ok((!dynamic_plugins.is_empty() + || dynamic_plugin_policy != DynamicPluginHostPolicy::default()) + .then_some(PluginTomlConfig { value: None, dynamic_plugins, + dynamic_plugin_policy, sources: Vec::new(), })), } @@ -1140,21 +1152,33 @@ fn apply_plugin_toml_config( resolved.gateway.plugin_config = Some(value); } resolved.dynamic_plugins = plugin_toml.dynamic_plugins; + resolved.dynamic_plugin_policy = plugin_toml.dynamic_plugin_policy; Ok(()) } +struct ResolvedDynamicPluginRefs { + dynamic_plugins: Vec, + dynamic_plugin_policy: DynamicPluginHostPolicy, +} + fn resolve_dynamic_plugin_refs( source: &Path, value: &mut toml::Value, seen_plugin_ids: &mut HashSet, -) -> Result, CliError> { +) -> Result { let Some(root) = value.as_table_mut() else { - return Ok(Vec::new()); + return Ok(ResolvedDynamicPluginRefs { + dynamic_plugins: Vec::new(), + dynamic_plugin_policy: DynamicPluginHostPolicy::default(), + }); }; let plugins_value = root.get("plugins").cloned(); let Some(plugins_value) = plugins_value else { - return Ok(Vec::new()); + return Ok(ResolvedDynamicPluginRefs { + dynamic_plugins: Vec::new(), + dynamic_plugin_policy: DynamicPluginHostPolicy::default(), + }); }; let plugins: PluginTomlPluginsSection = plugins_value.try_into().map_err(|error| { @@ -1166,6 +1190,7 @@ fn resolve_dynamic_plugin_refs( if let Some(toml::Value::Table(plugins_table)) = root.get_mut("plugins") { plugins_table.remove("dynamic"); + plugins_table.remove("policy"); if plugins_table.is_empty() { root.remove("plugins"); } @@ -1191,7 +1216,10 @@ fn resolve_dynamic_plugin_refs( source: source.to_path_buf(), }); } - Ok(resolved) + Ok(ResolvedDynamicPluginRefs { + dynamic_plugins: resolved, + dynamic_plugin_policy: plugins.policy.map(Into::into).unwrap_or_default(), + }) } fn resolve_dynamic_manifest_path(source: &Path, manifest: &str) -> PathBuf { @@ -1213,11 +1241,12 @@ fn plugin_toml_runtime_value(value: Value) -> Option { } } -fn remove_dynamic_plugin_section(mut value: toml::Value) -> toml::Value { +fn remove_dynamic_plugin_sections(mut value: toml::Value) -> toml::Value { if let Some(root) = value.as_table_mut() && let Some(toml::Value::Table(plugins)) = root.get_mut("plugins") { plugins.remove("dynamic"); + plugins.remove("policy"); if plugins.is_empty() { root.remove("plugins"); } diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index 708fc94d4..b3108bc51 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -23,6 +23,7 @@ use crate::error::CliError; pub(crate) mod config_io; mod editor_model; pub(crate) mod lifecycle; +pub(crate) mod policy; use self::config_io::*; use self::editor_model::*; diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index ce6160c98..e2c5b01b2 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -1,16 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::fmt; +use std::fs; +use std::path::Path; use std::path::PathBuf; use std::process::ExitCode; +use base64::Engine; use nemo_relay::plugin::dynamic::{ - DynamicPluginCheckState, DynamicPluginCompatibility, DynamicPluginLoadContract, + DynamicPluginAttestationMode, DynamicPluginCheckState, DynamicPluginCompatibility, + DynamicPluginFailure, DynamicPluginFailurePhase, DynamicPluginLoadContract, DynamicPluginManifest, DynamicPluginRecord, DynamicPluginValidationStatus, }; +use ring::signature::{ED25519, UnparsedPublicKey}; use serde_json::Value; +use sha2::{Digest, Sha256}; use crate::config::{ PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, @@ -18,6 +24,9 @@ use crate::config::{ ResolvedDynamicPluginConfig, ServerArgs, resolve_plugins_config, }; use crate::error::{CliError, PluginLifecycleFailureKind}; +use crate::plugins::policy::{ + EvaluatedDynamicPluginHostPolicy, evaluate_dynamic_plugin_host_policy, +}; use super::config_io::{ append_dynamic_plugin_reference, remove_dynamic_plugin_reference, target_scope, @@ -37,6 +46,8 @@ use self::state::{ }; use self::target::PluginTarget; +const VALIDATION_MESSAGE: &str = "validated by CLI"; + pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), CliError> { let resolved = resolve_plugins_config(server.config.as_ref())?; let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; @@ -63,7 +74,23 @@ pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), let (plugins_toml_path, state_path, scope) = scoped_paths_for_add(target_scope(&command.scope)?, server.config.as_ref())?; let scope_index = ensure_scope(&mut scopes, scope, plugins_toml_path.clone(), state_path); - let record = validated_record_from_manifest(manifest, manifest_ref.clone())?; + let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + if !policy.policy_satisfied { + return Err(plugin_refused( + "plugins add", + Some(plugin_id.clone()), + policy.refusal_message(&plugin_id), + )); + } + if let Some(message) = trust.message.as_ref() { + return Err(plugin_refused( + "plugins add", + Some(plugin_id.clone()), + message.clone(), + )); + } + let record = validated_record_from_manifest(manifest, manifest_ref.clone(), &policy, &trust)?; let original_plugins_toml = std::fs::read(&plugins_toml_path).ok(); scopes[scope_index] @@ -84,6 +111,30 @@ pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), Ok(()) } +pub(crate) fn enforce_required_dynamic_plugin_startup( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, +) -> Result<(), CliError> { + let (scopes, touched_scope_indices) = load_and_hydrate_scopes_with_updates(explicit, resolved)?; + for scope_index in touched_scope_indices { + scopes[scope_index].save()?; + } + let required_failures = collect_records(&scopes, false) + .into_iter() + .filter(|entry| entry.record.spec.enabled) + .filter_map(|entry| required_startup_failure(&entry, resolved.dynamic_plugins.as_slice())) + .collect::>(); + + if required_failures.is_empty() { + return Ok(()); + } + + Err(CliError::Config(format!( + "required dynamic plugin startup preflight failed:\n{}", + required_failures.join("\n") + ))) +} + pub(crate) fn validate( command: PluginsValidateCommand, server: &ServerArgs, @@ -97,7 +148,11 @@ pub(crate) fn validate( format!("dynamic plugin target '{}' does not exist", command.target), )); } + let resolved = resolve_plugins_config(server.config.as_ref())?; let (manifest, manifest_ref) = load_manifest_for_action("validate", &path)?; + let policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); if command.json { print_response_json(&validate_success(ValidateResponseInput { command: "plugins validate", @@ -108,6 +163,8 @@ pub(crate) fn validate( manifest_ref: &manifest_ref, entry: None, host_config: None, + policy: &policy, + trust: &trust, }))?; } else { println!( @@ -117,6 +174,8 @@ pub(crate) fn validate( manifest_ref: &manifest_ref, entry: None, host_config: None, + policy: &policy, + trust: &trust, } ); } @@ -129,6 +188,9 @@ pub(crate) fn validate( let entry = find_registered_entry(&scopes, "plugins validate", &plugin_id)?; let manifest_ref = manifest_ref_from_record(&entry.record)?; let (manifest, manifest_ref) = load_manifest_for_action("validate", &manifest_ref)?; + let policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); scopes[entry.scope_index] .registry .update_validation_status( @@ -136,15 +198,16 @@ pub(crate) fn validate( DynamicPluginValidationStatus { manifest: DynamicPluginCheckState::Valid, compatibility: DynamicPluginCheckState::Valid, - integrity: DynamicPluginCheckState::Unknown, + integrity: trust.integrity, environment: DynamicPluginCheckState::Unknown, - authenticity: DynamicPluginCheckState::Unknown, - policy_satisfied: DynamicPluginCheckState::Unknown, + authenticity: trust.authenticity, + policy_satisfied: policy.check_state(), checked_at: None, - message: Some("validated by CLI".into()), + message: Some(VALIDATION_MESSAGE.into()), }, ) .map_err(|error| CliError::Config(error.to_string()))?; + update_registry_policy_status(&mut scopes[entry.scope_index], &plugin_id, &policy)?; scopes[entry.scope_index].save()?; let refreshed = find_record_by_id(&scopes, &plugin_id)? .expect("validated registry record should still exist"); @@ -158,6 +221,8 @@ pub(crate) fn validate( manifest_ref: &manifest_ref, entry: Some(&refreshed), host_config: host_config_by_id.get(&plugin_id), + policy: &policy, + trust: &trust, }))?; } else { println!( @@ -167,6 +232,8 @@ pub(crate) fn validate( manifest_ref: &manifest_ref, entry: Some(&refreshed), host_config: host_config_by_id.get(&plugin_id), + policy: &policy, + trust: &trust, } ); } @@ -301,6 +368,27 @@ fn mutate_enabled_state( ), )); } + let manifest_ref = manifest_ref_from_record(&entry.record)?; + let (manifest, manifest_ref) = load_manifest_for_action(command, &manifest_ref)?; + let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + update_registry_validation_status(&mut scopes[entry.scope_index], &plugin_id, &policy, &trust)?; + if enabled && !policy.policy_satisfied { + scopes[entry.scope_index].save()?; + return Err(plugin_refused( + command, + Some(plugin_id.clone()), + policy.refusal_message(&plugin_id), + )); + } + if enabled && let Some(message) = trust.message.as_ref() { + scopes[entry.scope_index].save()?; + return Err(plugin_refused( + command, + Some(plugin_id.clone()), + message.clone(), + )); + } if enabled { scopes[entry.scope_index] .registry @@ -326,11 +414,16 @@ fn load_and_hydrate_scopes( explicit: Option<&PathBuf>, resolved: &ResolvedConfig, ) -> Result, CliError> { + Ok(load_and_hydrate_scopes_with_updates(explicit, resolved)?.0) +} + +fn load_and_hydrate_scopes_with_updates( + explicit: Option<&PathBuf>, + resolved: &ResolvedConfig, +) -> Result<(Vec, Vec), CliError> { let mut scopes = load_scoped_registries(explicit)?; + let mut touched_scope_indices = BTreeSet::new(); for plugin in &resolved.dynamic_plugins { - if find_record_by_id(&scopes, &plugin.plugin_id)?.is_some() { - continue; - } let scope_index = scopes .iter() .position(|scope| scope.plugins_toml_path == plugin.source) @@ -341,18 +434,38 @@ fn load_and_hydrate_scopes( plugin.source.display() )) })?; + touched_scope_indices.insert(scope_index); let (manifest, manifest_ref) = load_manifest_for_action("hydrate", &plugin.manifest_ref)?; - scopes[scope_index] - .registry - .add(validated_record_from_manifest(manifest, manifest_ref)?) - .map_err(|error| CliError::Config(error.to_string()))?; + let policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + if find_record_by_id(&scopes, &plugin.plugin_id)?.is_some() { + update_registry_validation_status( + &mut scopes[scope_index], + &plugin.plugin_id, + &policy, + &trust, + )?; + } else { + scopes[scope_index] + .registry + .add(validated_record_from_manifest( + manifest, + manifest_ref, + &policy, + &trust, + )?) + .map_err(|error| CliError::Config(error.to_string()))?; + } } - Ok(scopes) + Ok((scopes, touched_scope_indices.into_iter().collect())) } fn validated_record_from_manifest( manifest: DynamicPluginManifest, manifest_ref: String, + policy: &EvaluatedDynamicPluginHostPolicy, + trust: &EvaluatedDynamicPluginTrust, ) -> Result { let mut record = manifest .into_record(Some(manifest_ref)) @@ -360,13 +473,18 @@ fn validated_record_from_manifest( record.status.validation = DynamicPluginValidationStatus { manifest: DynamicPluginCheckState::Valid, compatibility: DynamicPluginCheckState::Valid, - integrity: DynamicPluginCheckState::Unknown, + integrity: trust.integrity, environment: DynamicPluginCheckState::Unknown, - authenticity: DynamicPluginCheckState::Unknown, - policy_satisfied: DynamicPluginCheckState::Unknown, + authenticity: trust.authenticity, + policy_satisfied: policy.check_state(), checked_at: None, - message: Some("validated by CLI".into()), + message: Some(VALIDATION_MESSAGE.into()), }; + record.status.startup_class = Some(policy.startup_class); + record.status.attestation_mode = Some(policy.attestation_mode); + record.status.last_error = policy + .last_error() + .or_else(|| trust.last_error(policy.attestation_mode)); Ok(record) } @@ -379,6 +497,57 @@ fn host_config_by_id(resolved: &ResolvedConfig) -> HashMap Result<(), CliError> { + scope + .registry + .update_policy_status( + plugin_id, + policy.check_state(), + policy.startup_class, + policy.attestation_mode, + policy.last_error(), + ) + .map_err(|error| CliError::Config(error.to_string())) +} + +fn update_registry_validation_status( + scope: &mut ScopedRegistry, + plugin_id: &str, + policy: &EvaluatedDynamicPluginHostPolicy, + trust: &EvaluatedDynamicPluginTrust, +) -> Result<(), CliError> { + scope + .registry + .update_validation_status( + plugin_id, + DynamicPluginValidationStatus { + manifest: DynamicPluginCheckState::Valid, + compatibility: DynamicPluginCheckState::Valid, + integrity: trust.integrity, + environment: DynamicPluginCheckState::Unknown, + authenticity: trust.authenticity, + policy_satisfied: policy.check_state(), + checked_at: None, + message: Some(VALIDATION_MESSAGE.into()), + }, + ) + .map_err(|error| CliError::Config(error.to_string()))?; + update_registry_policy_status(scope, plugin_id, policy)?; + scope + .registry + .update_last_error( + plugin_id, + policy + .last_error() + .or_else(|| trust.last_error(policy.attestation_mode)), + ) + .map_err(|error| CliError::Config(error.to_string())) +} + fn find_registered_entry( scopes: &[ScopedRegistry], command: &'static str, @@ -449,6 +618,375 @@ fn restore_plugins_toml(path: &std::path::Path, original: Option<&[u8]>) -> Resu Ok(()) } +fn required_startup_failure( + entry: &ScopedDynamicPluginRecord, + resolved_plugins: &[ResolvedDynamicPluginConfig], +) -> Option { + if entry.record.status.startup_class + != Some(nemo_relay::plugin::dynamic::DynamicPluginStartupClass::Required) + { + return None; + } + + if entry.record.status.validation.policy_satisfied == DynamicPluginCheckState::Invalid { + return Some(format!( + "- {}: {}", + entry.record.metadata.id, + entry + .record + .status + .last_error + .as_ref() + .map(|error| error.message.as_str()) + .unwrap_or("blocked by host policy") + )); + } + if entry.record.status.validation.integrity == DynamicPluginCheckState::Invalid + || entry.record.status.validation.authenticity == DynamicPluginCheckState::Invalid + { + return Some(format!( + "- {}: {}", + entry.record.metadata.id, + entry + .record + .status + .last_error + .as_ref() + .map(|error| error.message.as_str()) + .unwrap_or("required dynamic plugin trust verification failed") + )); + } + + let manifest_ref = entry + .record + .source + .manifest_ref + .as_deref() + .map(Path::new) + .map(Path::to_path_buf); + if manifest_ref.is_none() { + return Some(format!( + "- {}: required dynamic plugin has no manifest_ref in lifecycle state", + entry.record.metadata.id + )); + } + + let manifest_ref = manifest_ref.expect("manifest_ref checked above"); + if !resolved_plugins + .iter() + .any(|plugin| plugin.plugin_id == entry.record.metadata.id) + { + if !manifest_ref.exists() { + return Some(format!( + "- {}: required dynamic plugin manifest is no longer available at {}", + entry.record.metadata.id, + manifest_ref.display() + )); + } + + if let Err(error) = DynamicPluginManifest::load_from_path(&manifest_ref) { + return Some(format!( + "- {}: required dynamic plugin manifest at {} is unreadable: {}", + entry.record.metadata.id, + manifest_ref.display(), + error + )); + } + } + + None +} + +#[derive(Debug)] +struct EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState, + authenticity: DynamicPluginCheckState, + message: Option, +} + +impl EvaluatedDynamicPluginTrust { + fn last_error( + &self, + attestation_mode: DynamicPluginAttestationMode, + ) -> Option { + self.message.as_ref().map(|message| DynamicPluginFailure { + phase: DynamicPluginFailurePhase::Validation, + code: match attestation_mode { + DynamicPluginAttestationMode::IntegrityOnly => "integrity_verification_failed", + DynamicPluginAttestationMode::SignatureIfPresent + | DynamicPluginAttestationMode::SignatureRequired => { + "attestation_verification_failed" + } + } + .into(), + message: message.clone(), + }) + } +} + +fn evaluate_dynamic_plugin_trust( + manifest: &DynamicPluginManifest, + manifest_ref: &str, + policy: &EvaluatedDynamicPluginHostPolicy, +) -> EvaluatedDynamicPluginTrust { + let Some(artifact) = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + else { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + message: Some(format!( + "dynamic plugin '{}' is missing source.artifact required for integrity verification", + manifest.plugin.id + )), + }; + }; + + let Some(expected_digest) = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + else { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + message: Some(format!( + "dynamic plugin '{}' is missing integrity.sha256 required for host trust verification", + manifest.plugin.id + )), + }; + }; + + let artifact_path = resolve_artifact_path(manifest_ref, artifact); + let actual_digest = match file_sha256(&artifact_path) { + Ok(digest) => digest, + Err(error) => { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + message: Some(format!( + "dynamic plugin '{}' artifact {} could not be read for integrity verification: {}", + manifest.plugin.id, + artifact_path.display(), + error + )), + }; + } + }; + + if actual_digest != expected_digest.trim() { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + message: Some(format!( + "dynamic plugin '{}' failed integrity verification for {}: expected {}, got {}", + manifest.plugin.id, + artifact_path.display(), + expected_digest.trim(), + actual_digest + )), + }; + } + + evaluate_authenticity(manifest, manifest_ref, artifact_path.as_path(), policy) +} + +fn evaluate_authenticity( + manifest: &DynamicPluginManifest, + manifest_ref: &str, + artifact_path: &Path, + policy: &EvaluatedDynamicPluginHostPolicy, +) -> EvaluatedDynamicPluginTrust { + let signature_ref = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + match policy.attestation_mode { + DynamicPluginAttestationMode::IntegrityOnly => EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Unknown, + message: None, + }, + DynamicPluginAttestationMode::SignatureIfPresent => match signature_ref { + Some(signature_ref) => verify_signature( + manifest, + manifest_ref, + artifact_path, + signature_ref, + &policy.trusted_public_keys, + ), + None => EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Unknown, + message: None, + }, + }, + DynamicPluginAttestationMode::SignatureRequired => match signature_ref { + Some(signature_ref) => verify_signature( + manifest, + manifest_ref, + artifact_path, + signature_ref, + &policy.trusted_public_keys, + ), + None => EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' requires integrity.signature under host policy", + manifest.plugin.id + )), + }, + }, + } +} + +fn verify_signature( + manifest: &DynamicPluginManifest, + manifest_ref: &str, + artifact_path: &Path, + signature_ref: &str, + trusted_public_keys: &[String], +) -> EvaluatedDynamicPluginTrust { + if trusted_public_keys.is_empty() { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' requires signature verification, but no trusted_public_keys are configured in host policy", + manifest.plugin.id + )), + }; + } + + let signature_path = resolve_artifact_path(manifest_ref, signature_ref); + let signature_bytes = match read_signature_bytes(&signature_path) { + Ok(signature_bytes) => signature_bytes, + Err(error) => { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' signature {} could not be read: {}", + manifest.plugin.id, + signature_path.display(), + error + )), + }; + } + }; + + let artifact_bytes = match fs::read(artifact_path) { + Ok(artifact_bytes) => artifact_bytes, + Err(error) => { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' artifact {} could not be read for signature verification: {}", + manifest.plugin.id, + artifact_path.display(), + error + )), + }; + } + }; + + let mut parse_errors = Vec::new(); + for trusted_public_key in trusted_public_keys { + let public_key_bytes = match parse_ed25519_public_key(trusted_public_key) { + Ok(public_key_bytes) => public_key_bytes, + Err(error) => { + parse_errors.push(error); + continue; + } + }; + + let verifier = UnparsedPublicKey::new(&ED25519, public_key_bytes); + if verifier.verify(&artifact_bytes, &signature_bytes).is_ok() { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Valid, + message: None, + }; + } + } + + let parse_error_suffix = if parse_errors.is_empty() { + String::new() + } else { + format!("; key parse errors: {}", parse_errors.join("; ")) + }; + + EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' failed signature verification for {} against configured host policy keys{}", + manifest.plugin.id, + signature_path.display(), + parse_error_suffix + )), + } +} + +fn read_signature_bytes(path: &Path) -> Result, String> { + let raw = fs::read(path).map_err(|error| error.to_string())?; + let trimmed = String::from_utf8_lossy(&raw).trim().to_owned(); + if trimmed.is_empty() { + return Err("signature file is empty".into()); + } + + let encoded = trimmed + .strip_prefix("ed25519:") + .unwrap_or(trimmed.as_str()) + .trim(); + base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| format!("invalid base64 signature: {error}")) +} + +fn parse_ed25519_public_key(value: &str) -> Result, String> { + let encoded = value + .trim() + .strip_prefix("ed25519:") + .ok_or_else(|| format!("unsupported trusted public key format '{value}'"))?; + base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|error| format!("invalid ed25519 trusted public key '{value}': {error}")) +} + +fn resolve_artifact_path(manifest_ref: &str, artifact_ref: &str) -> PathBuf { + let artifact_path = PathBuf::from(artifact_ref); + if artifact_path.is_absolute() { + artifact_path + } else { + Path::new(manifest_ref) + .parent() + .map(|parent| parent.join(&artifact_path)) + .unwrap_or(artifact_path) + } +} + +fn file_sha256(path: &Path) -> Result { + let bytes = fs::read(path)?; + let mut digest = Sha256::new(); + digest.update(&bytes); + Ok(format!( + "sha256:{}", + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + pub(crate) fn render_plugin_error( error: &CliError, json: bool, @@ -513,82 +1051,106 @@ struct PluginListView<'a> { impl fmt::Display for PluginListView<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let rows = self - .records - .iter() - .map(|entry| PluginListRow { - id: entry.record.metadata.id.as_str(), - scope: entry.scope.to_string(), - enabled: entry.record.spec.enabled.to_string(), - state: lifecycle_state_label(&entry.record).into(), - validation: <&'static str>::from(entry.record.status.validation.manifest).into(), - host_config: host_config_status( - self.host_config_by_id.get(&entry.record.metadata.id), - ), - }) - .collect::>(); - let widths = PluginListWidths::from_rows(&rows); + let widths = PluginListWidths::from_records(self.records); write!( f, - "{: { - id: &'a str, - scope: String, - enabled: String, - state: String, - validation: String, - host_config: String, -} - struct PluginListWidths { id: usize, scope: usize, enabled: usize, state: usize, validation: usize, + policy: usize, } impl PluginListWidths { - fn from_rows(rows: &[PluginListRow<'_>]) -> Self { + fn from_records(records: &[ScopedDynamicPluginRecord]) -> Self { Self { - id: column_width("ID", rows.iter().map(|row| row.id)), - scope: column_width("SCOPE", rows.iter().map(|row| row.scope.as_str())), - enabled: column_width("ENABLED", rows.iter().map(|row| row.enabled.as_str())), - state: column_width("STATE", rows.iter().map(|row| row.state.as_str())), - validation: column_width("VALIDATION", rows.iter().map(|row| row.validation.as_str())), + id: column_width( + "ID", + records + .iter() + .map(|entry| entry.record.metadata.id.as_str()), + ), + scope: column_width( + "SCOPE", + records.iter().map(|entry| { + let scope: &'static str = entry.scope.into(); + scope + }), + ), + enabled: column_width( + "ENABLED", + records.iter().map(|entry| { + if entry.record.spec.enabled { + "true" + } else { + "false" + } + }), + ), + state: column_width( + "STATE", + records + .iter() + .map(|entry| lifecycle_state_label(&entry.record)), + ), + validation: column_width( + "VALIDATION", + records.iter().map(|entry| { + let validation: &'static str = entry.record.status.validation.manifest.into(); + validation + }), + ), + policy: column_width( + "POLICY", + records.iter().map(|entry| { + let policy: &'static str = + entry.record.status.validation.policy_satisfied.into(); + policy + }), + ), } } } @@ -626,18 +1188,57 @@ struct PluginValidationSummaryView<'a> { manifest_ref: &'a str, entry: Option<&'a ScopedDynamicPluginRecord>, host_config: Option<&'a ResolvedDynamicPluginConfig>, + policy: &'a EvaluatedDynamicPluginHostPolicy, + trust: &'a EvaluatedDynamicPluginTrust, } impl fmt::Display for PluginValidationSummaryView<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Dynamic plugin '{}' is valid.", self.manifest.plugin.id)?; + if self.policy.policy_satisfied && self.trust.message.is_none() { + writeln!(f, "Dynamic plugin '{}' is valid.", self.manifest.plugin.id)?; + } else if self.policy.policy_satisfied { + writeln!( + f, + "Dynamic plugin '{}' manifest is valid, but trust verification blocks it.", + self.manifest.plugin.id + )?; + } else { + writeln!( + f, + "Dynamic plugin '{}' manifest is valid, but host policy blocks it.", + self.manifest.plugin.id + )?; + } writeln!(f, "kind: {}", self.manifest.plugin.kind)?; + writeln!( + f, + "policy_state: {}", + <&'static str>::from(self.policy.check_state()) + )?; + writeln!( + f, + "integrity_state: {}", + <&'static str>::from(self.trust.integrity) + )?; + writeln!( + f, + "authenticity_state: {}", + <&'static str>::from(self.trust.authenticity) + )?; + writeln!(f, "startup_class: {}", self.policy.startup_class)?; + writeln!(f, "attestation_mode: {}", self.policy.attestation_mode)?; + if let Some(message) = &self.policy.message { + writeln!(f, "policy_error: {message}")?; + } + if let Some(message) = &self.trust.message { + writeln!(f, "trust_error: {message}")?; + } if let Some(entry) = self.entry { writeln!(f, "manifest: {}", self.manifest_ref)?; writeln!(f, "scope: {}", entry.scope)?; writeln!(f, "lifecycle_state_path: {}", entry.state_path.display())?; writeln!(f, "desired.enabled: {}", entry.record.spec.enabled)?; - write!(f, "host_config: {}", host_config_status(self.host_config))?; + write!(f, "host_config: {}", host_config_label(self.host_config))?; } else { write!(f, "manifest: {}", self.manifest_ref)?; } @@ -653,10 +1254,13 @@ fn lifecycle_state_label(record: &DynamicPluginRecord) -> &'static str { } } -fn host_config_status(host_config: Option<&ResolvedDynamicPluginConfig>) -> String { +fn host_config_label(host_config: Option<&ResolvedDynamicPluginConfig>) -> &'static str { host_config - .map(|plugin| plugin.host_config_status().to_string()) - .unwrap_or_else(|| "missing".into()) + .map(|plugin| { + let status: &'static str = plugin.host_config_status().into(); + status + }) + .unwrap_or("missing") } fn redacted_host_config_json(host_config: &ResolvedDynamicPluginConfig) -> Value { diff --git a/crates/cli/src/plugins/lifecycle/responses.rs b/crates/cli/src/plugins/lifecycle/responses.rs index a01eecd42..d1cee8660 100644 --- a/crates/cli/src/plugins/lifecycle/responses.rs +++ b/crates/cli/src/plugins/lifecycle/responses.rs @@ -11,17 +11,19 @@ use std::collections::HashMap; use nemo_relay::plugin::dynamic::{ - DynamicPluginCheckState, DynamicPluginKind, DynamicPluginManifest, + DynamicPluginAttestationMode, DynamicPluginCheckState, DynamicPluginFailurePhase, + DynamicPluginKind, DynamicPluginManifest, DynamicPluginStartupClass, }; use serde::Serialize; use serde_json::{Map, Value}; -use crate::config::ResolvedDynamicPluginConfig; +use crate::config::{DynamicPluginHostConfigStatus, ResolvedDynamicPluginConfig}; use crate::error::{CliError, PluginLifecycleFailureKind}; +use crate::plugins::policy::EvaluatedDynamicPluginHostPolicy; use super::state::ScopedDynamicPluginRecord; use super::{ - host_config_status, inspect_compat_data, inspect_load_data, redacted_host_config_json, + EvaluatedDynamicPluginTrust, inspect_compat_data, inspect_load_data, redacted_host_config_json, }; #[derive(Debug)] @@ -34,6 +36,8 @@ pub(super) struct ValidateResponseInput<'a> { pub(super) manifest_ref: &'a str, pub(super) entry: Option<&'a ScopedDynamicPluginRecord>, pub(super) host_config: Option<&'a ResolvedDynamicPluginConfig>, + pub(super) policy: &'a EvaluatedDynamicPluginHostPolicy, + pub(super) trust: &'a EvaluatedDynamicPluginTrust, } #[derive(Debug, Serialize)] @@ -65,15 +69,17 @@ pub(super) struct ListEntryResponse { enabled: bool, tombstoned: bool, validation_state: DynamicPluginCheckState, + policy_state: DynamicPluginCheckState, runtime_state: String, - startup: Option, + startup_class: Option, + attestation_mode: Option, last_error: Option, - host_config: String, + host_config: DynamicPluginHostConfigStatus, } #[derive(Debug, Serialize)] pub(super) struct LastErrorResponse { - phase: String, + phase: DynamicPluginFailurePhase, code: String, message: String, } @@ -94,7 +100,10 @@ pub(super) struct InspectResponse { source: Value, spec: Value, status: Value, - host_config_status: String, + policy_state: DynamicPluginCheckState, + startup_class: Option, + attestation_mode: Option, + host_config_status: DynamicPluginHostConfigStatus, host_config: Value, } @@ -108,8 +117,13 @@ pub(super) struct ValidateResponse { notes: Vec, manifest_ref: String, kind: DynamicPluginKind, + policy_state: DynamicPluginCheckState, + integrity_state: DynamicPluginCheckState, + authenticity_state: DynamicPluginCheckState, + startup_class: DynamicPluginStartupClass, + attestation_mode: DynamicPluginAttestationMode, desired_enabled: Option, - host_config_status: String, + host_config_status: DynamicPluginHostConfigStatus, } pub(super) fn print_response_json(value: &T) -> Result<(), CliError> { @@ -140,22 +154,27 @@ pub(super) fn list_success( enabled: record.spec.enabled, tombstoned: record.is_tombstoned(), validation_state: record.status.validation.manifest, + policy_state: record.status.validation.policy_satisfied, runtime_state: if record.is_tombstoned() { "tombstoned".into() } else { <&'static str>::from(record.status.runtime.state).into() }, - startup: record.status.startup_class.map(|value| value.to_string()), + startup_class: record.status.startup_class, + attestation_mode: record.status.attestation_mode, last_error: record .status .last_error .as_ref() .map(|error| LastErrorResponse { - phase: error.phase.to_string(), + phase: error.phase, code: error.code.clone(), message: error.message.clone(), }), - host_config: host_config_status(host_config_by_id.get(&record.metadata.id)), + host_config: host_config_by_id + .get(&record.metadata.id) + .map(ResolvedDynamicPluginConfig::host_config_status) + .unwrap_or(DynamicPluginHostConfigStatus::Absent), } }) .collect(), @@ -207,7 +226,12 @@ pub(super) fn inspect_data( spec: serde_json::to_value(&record.spec).expect("dynamic plugin spec serializes to JSON"), status: serde_json::to_value(&record.status) .expect("dynamic plugin status serializes to JSON"), - host_config_status: host_config_status(host_config), + policy_state: record.status.validation.policy_satisfied, + startup_class: record.status.startup_class, + attestation_mode: record.status.attestation_mode, + host_config_status: host_config + .map(ResolvedDynamicPluginConfig::host_config_status) + .unwrap_or(DynamicPluginHostConfigStatus::Absent), host_config: host_config .map(redacted_host_config_json) .unwrap_or(Value::Null), @@ -222,6 +246,14 @@ pub(super) fn validate_success( .and_then(|entry| entry.record.status.validation.message.clone()) .into_iter() .collect::>(); + let valid = input.policy.policy_satisfied && input.trust.message.is_none(); + let errors = input + .policy + .message + .iter() + .chain(input.trust.message.iter()) + .cloned() + .collect::>(); success( input.command, @@ -232,14 +264,22 @@ pub(super) fn validate_success( .resolved_plugin_id .unwrap_or(input.manifest.plugin.id.as_str()) .to_owned(), - valid: true, - errors: Vec::new(), + valid, + errors, warnings: Vec::new(), notes, manifest_ref: input.manifest_ref.into(), kind: input.manifest.plugin.kind, + policy_state: input.policy.check_state(), + integrity_state: input.trust.integrity, + authenticity_state: input.trust.authenticity, + startup_class: input.policy.startup_class, + attestation_mode: input.policy.attestation_mode, desired_enabled: input.entry.map(|entry| entry.record.spec.enabled), - host_config_status: host_config_status(input.host_config), + host_config_status: input + .host_config + .map(ResolvedDynamicPluginConfig::host_config_status) + .unwrap_or(DynamicPluginHostConfigStatus::Absent), }, ) } diff --git a/crates/cli/src/plugins/lifecycle/state.rs b/crates/cli/src/plugins/lifecycle/state.rs index f487692e7..6d047d514 100644 --- a/crates/cli/src/plugins/lifecycle/state.rs +++ b/crates/cli/src/plugins/lifecycle/state.rs @@ -7,7 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use nemo_relay::plugin::dynamic::{DynamicPluginRecord, DynamicPluginRegistry}; use serde::{Deserialize, Serialize}; -use strum::Display; +use strum::{Display, IntoStaticStr}; use crate::config::{ PLUGINS_TOML, global_plugin_config_path, project_plugin_config_path, user_config_dir, @@ -21,7 +21,7 @@ use super::super::config_io::TargetScope; const DYNAMIC_PLUGIN_STATE_FILENAME: &str = ".dynamic-plugins.json"; const DYNAMIC_PLUGIN_STATE_SCHEMA_VERSION: u32 = 1; -#[derive(Display, Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Display, IntoStaticStr, Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub(super) enum RegistryScope { diff --git a/crates/cli/src/plugins/policy.rs b/crates/cli/src/plugins/policy.rs new file mode 100644 index 000000000..cd0656cd7 --- /dev/null +++ b/crates/cli/src/plugins/policy.rs @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeMap; + +use nemo_relay::plugin::dynamic::{ + DynamicPluginAttestationMode, DynamicPluginCapability, DynamicPluginCheckState, + DynamicPluginFailure, DynamicPluginFailurePhase, DynamicPluginKind, DynamicPluginManifest, + DynamicPluginStartupClass, +}; +use serde::Deserialize; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct DynamicPluginHostPolicy { + pub(crate) defaults: DynamicPluginHostPolicyEffect, + pub(crate) rules: Vec, + pub(crate) overrides: BTreeMap, +} + +impl DynamicPluginHostPolicy { + pub(crate) fn merge(&mut self, other: Self) { + self.defaults.merge(other.defaults); + self.rules.extend(other.rules); + for (plugin_id, effect) in other.overrides { + self.overrides.entry(plugin_id).or_default().merge(effect); + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct DynamicPluginHostPolicyEffect { + pub(crate) allowed: Option, + pub(crate) startup: Option, + pub(crate) attestation: Option, + pub(crate) allowed_capabilities: Option>, + pub(crate) trusted_public_keys: Option>, +} + +impl DynamicPluginHostPolicyEffect { + fn merge(&mut self, other: Self) { + if let Some(value) = other.allowed { + self.allowed = Some(value); + } + if let Some(value) = other.startup { + self.startup = Some(value); + } + if let Some(value) = other.attestation { + self.attestation = Some(value); + } + if let Some(value) = other.allowed_capabilities { + self.allowed_capabilities = Some(value); + } + if let Some(value) = other.trusted_public_keys { + self.trusted_public_keys = Some(value); + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct DynamicPluginHostPolicyRule { + pub(crate) match_kind: Option, + pub(crate) match_capability: Option, + pub(crate) match_plugin_id: Option, + pub(crate) effect: DynamicPluginHostPolicyEffect, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EvaluatedDynamicPluginHostPolicy { + pub(crate) policy_satisfied: bool, + pub(crate) startup_class: DynamicPluginStartupClass, + pub(crate) attestation_mode: DynamicPluginAttestationMode, + pub(crate) trusted_public_keys: Vec, + pub(crate) message: Option, +} + +impl EvaluatedDynamicPluginHostPolicy { + pub(crate) fn check_state(&self) -> DynamicPluginCheckState { + if self.policy_satisfied { + DynamicPluginCheckState::Valid + } else { + DynamicPluginCheckState::Invalid + } + } + + pub(crate) fn last_error(&self) -> Option { + self.message.as_ref().map(|message| DynamicPluginFailure { + phase: DynamicPluginFailurePhase::Policy, + code: "policy_blocked".into(), + message: message.clone(), + }) + } + + pub(crate) fn refusal_message(&self, plugin_id: &str) -> String { + self.message + .clone() + .unwrap_or_else(|| format!("dynamic plugin '{}' is blocked by host policy", plugin_id)) + } +} + +pub(crate) fn evaluate_dynamic_plugin_host_policy( + policy: &DynamicPluginHostPolicy, + manifest: &DynamicPluginManifest, +) -> EvaluatedDynamicPluginHostPolicy { + let mut effect = DynamicPluginHostPolicyEffect { + allowed: Some(true), + startup: Some(DynamicPluginStartupClass::Optional), + attestation: Some(DynamicPluginAttestationMode::IntegrityOnly), + allowed_capabilities: None, + trusted_public_keys: None, + }; + effect.merge(policy.defaults.clone()); + + for rule in &policy.rules { + if !policy_rule_matches(rule, manifest) { + continue; + } + effect.merge(rule.effect.clone()); + } + + if let Some(override_effect) = policy.overrides.get(manifest.plugin.id.trim()) { + effect.merge(override_effect.clone()); + } + + let startup_class = effect + .startup + .unwrap_or(DynamicPluginStartupClass::Optional); + let attestation_mode = effect + .attestation + .unwrap_or(DynamicPluginAttestationMode::IntegrityOnly); + let trusted_public_keys = effect.trusted_public_keys.unwrap_or_default(); + + if effect.allowed == Some(false) { + return EvaluatedDynamicPluginHostPolicy { + policy_satisfied: false, + startup_class, + attestation_mode, + trusted_public_keys, + message: Some(format!( + "dynamic plugin '{}' is blocked by host policy", + manifest.plugin.id + )), + }; + } + + if let Some(allowed_capabilities) = effect.allowed_capabilities + && let Some(blocked_capability) = manifest + .capabilities + .items + .iter() + .find(|capability| !allowed_capabilities.contains(capability)) + { + return EvaluatedDynamicPluginHostPolicy { + policy_satisfied: false, + startup_class, + attestation_mode, + trusted_public_keys, + message: Some(format!( + "dynamic plugin '{}' is blocked by host policy: capability '{}' is not allowed", + manifest.plugin.id, blocked_capability + )), + }; + } + + EvaluatedDynamicPluginHostPolicy { + policy_satisfied: true, + startup_class, + attestation_mode, + trusted_public_keys, + message: None, + } +} + +fn policy_rule_matches( + rule: &DynamicPluginHostPolicyRule, + manifest: &DynamicPluginManifest, +) -> bool { + if let Some(match_kind) = rule.match_kind + && manifest.plugin.kind != match_kind + { + return false; + } + if let Some(match_capability) = rule.match_capability + && !manifest.capabilities.items.contains(&match_capability) + { + return false; + } + if let Some(match_plugin_id) = &rule.match_plugin_id + && manifest.plugin.id.trim() != match_plugin_id + { + return false; + } + true +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FileDynamicPluginHostPolicy { + #[serde(default)] + pub(crate) defaults: FileDynamicPluginHostPolicyEffect, + #[serde(default)] + pub(crate) rules: Vec, + #[serde(default)] + pub(crate) overrides: BTreeMap, +} + +impl From for DynamicPluginHostPolicy { + fn from(value: FileDynamicPluginHostPolicy) -> Self { + Self { + defaults: value.defaults.into(), + rules: value.rules.into_iter().map(Into::into).collect(), + overrides: value + .overrides + .into_iter() + .map(|(plugin_id, effect)| (plugin_id.trim().to_owned(), effect.into())) + .collect(), + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FileDynamicPluginHostPolicyEffect { + allowed: Option, + startup: Option, + attestation: Option, + allowed_capabilities: Option>, + trusted_public_keys: Option>, +} + +impl From for DynamicPluginHostPolicyEffect { + fn from(value: FileDynamicPluginHostPolicyEffect) -> Self { + Self { + allowed: value.allowed, + startup: value.startup, + attestation: value.attestation, + allowed_capabilities: value.allowed_capabilities, + trusted_public_keys: value + .trusted_public_keys + .map(|keys| keys.into_iter().map(|key| key.trim().to_owned()).collect()), + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FileDynamicPluginHostPolicyRule { + match_kind: Option, + match_capability: Option, + match_plugin_id: Option, + allowed: Option, + startup: Option, + attestation: Option, + allowed_capabilities: Option>, + trusted_public_keys: Option>, +} + +impl From for DynamicPluginHostPolicyRule { + fn from(value: FileDynamicPluginHostPolicyRule) -> Self { + Self { + match_kind: value.match_kind, + match_capability: value.match_capability, + match_plugin_id: value.match_plugin_id.map(|value| value.trim().to_owned()), + effect: DynamicPluginHostPolicyEffect { + allowed: value.allowed, + startup: value.startup, + attestation: value.attestation, + allowed_capabilities: value.allowed_capabilities, + trusted_public_keys: value + .trusted_public_keys + .map(|keys| keys.into_iter().map(|key| key.trim().to_owned()).collect()), + }, + } + } +} diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 934f795d0..c9207d460 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -9,6 +9,11 @@ use std::process::{Command, Stdio}; use std::sync::mpsc; use std::thread; +use base64::Engine; +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use sha2::{Digest, Sha256}; + fn gateway_bin() -> &'static str { env!("CARGO_BIN_EXE_nemo-relay") } @@ -34,7 +39,41 @@ fn toml_basic_string(value: &str) -> String { } fn write_dynamic_plugin_manifest(dir: &std::path::Path, plugin_id: &str) { + write_dynamic_plugin_manifest_with_options(dir, plugin_id, &["plugin.worker"], None); +} + +fn write_dynamic_plugin_manifest_with_capabilities( + dir: &std::path::Path, + plugin_id: &str, + capabilities: &[&str], +) { + write_dynamic_plugin_manifest_with_options(dir, plugin_id, capabilities, None); +} + +fn write_dynamic_plugin_manifest_with_options( + dir: &std::path::Path, + plugin_id: &str, + capabilities: &[&str], + signature_ref: Option<&str>, +) { std::fs::create_dir_all(dir).unwrap(); + let artifact_body = format!("def register():\n return {plugin_id:?}\n"); + std::fs::write(dir.join("plugin.py"), &artifact_body).unwrap(); + let digest = format!( + "sha256:{}", + Sha256::digest(artifact_body.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + let capabilities = capabilities + .iter() + .map(|capability| toml_basic_string(capability)) + .collect::>() + .join(", "); + let signature_line = signature_ref + .map(|signature_ref| format!("signature = {}\n", toml_basic_string(signature_ref))) + .unwrap_or_default(); std::fs::write( dir.join("relay-plugin.toml"), format!( @@ -52,12 +91,22 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker"] +items = [{capabilities}] + +[source] +artifact = "plugin.py" + +[integrity] +sha256 = {digest} +{signature_line} [load] runtime = "python" entrypoint = {entrypoint} "#, + capabilities = capabilities, + signature_line = signature_line, + digest = toml_basic_string(&digest), plugin_id = toml_basic_string(plugin_id), entrypoint = toml_basic_string(&format!("{plugin_id}.plugin:register")), ), @@ -65,6 +114,34 @@ entrypoint = {entrypoint} .unwrap(); } +fn write_detached_ed25519_signature(dir: &std::path::Path, signature_name: &str) -> String { + std::fs::create_dir_all(dir).unwrap(); + let artifact = std::fs::read(dir.join("plugin.py")).unwrap(); + let pkcs8 = + Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).expect("generate ed25519 keypair"); + let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).expect("parse ed25519 keypair"); + let signature = key_pair.sign(&artifact); + let signature_text = format!( + "ed25519:{}\n", + base64::engine::general_purpose::STANDARD.encode(signature.as_ref()) + ); + std::fs::write(dir.join(signature_name), signature_text).unwrap(); + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(key_pair.public_key().as_ref()) + ) +} + +fn generate_ed25519_public_key() -> String { + let pkcs8 = + Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).expect("generate ed25519 keypair"); + let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).expect("parse ed25519 keypair"); + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(key_pair.public_key().as_ref()) + ) +} + #[test] fn toml_basic_string_escapes_toml_control_characters() { assert_eq!( @@ -156,6 +233,10 @@ fn cli_plugins_validate_json_emits_versioned_success_output() { assert_eq!(parsed["command"], "plugins validate"); assert_eq!(parsed["data"]["target_kind"], "path"); assert_eq!(parsed["data"]["resolved_plugin_id"], "acme.cli-json"); + assert_eq!(parsed["data"]["valid"], true); + assert_eq!(parsed["data"]["policy_state"], "valid"); + assert_eq!(parsed["data"]["startup_class"], "optional"); + assert_eq!(parsed["data"]["attestation_mode"], "integrity_only"); } #[test] @@ -250,6 +331,341 @@ fn cli_plugins_list_all_json_includes_tombstoned_records() { assert_eq!(parsed["data"][0]["id"], "acme.tombstoned"); assert_eq!(parsed["data"][0]["tombstoned"], true); assert_eq!(parsed["data"][0]["runtime_state"], "tombstoned"); + assert_eq!(parsed["data"][0]["policy_state"], "valid"); + assert_eq!(parsed["data"][0]["startup_class"], "optional"); + assert_eq!(parsed["data"][0]["attestation_mode"], "integrity_only"); +} + +#[test] +fn cli_plugins_validate_json_reports_blocked_policy_for_path_target() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins").join("acme"); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + std::fs::create_dir_all(&user_config_dir).unwrap(); + write_dynamic_plugin_manifest(&plugin_dir, "acme.cli-blocked-path"); + std::fs::write( + user_config_dir.join("plugins.toml"), + r#" +[plugins.policy.defaults] +allowed_capabilities = ["config.schema"] +"#, + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["plugins", "validate"]) + .arg(&plugin_dir) + .arg("--json") + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["data"]["target_kind"], "path"); + assert_eq!(parsed["data"]["valid"], false); + assert_eq!(parsed["data"]["policy_state"], "invalid"); + assert_eq!(parsed["data"]["startup_class"], "optional"); + assert_eq!(parsed["data"]["attestation_mode"], "integrity_only"); + assert!( + parsed["data"]["errors"][0] + .as_str() + .unwrap() + .contains("blocked by host policy") + ); +} + +#[test] +fn cli_plugins_validate_json_reports_blocked_functional_surface_capability_for_path_target() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins").join("acme"); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + std::fs::create_dir_all(&user_config_dir).unwrap(); + write_dynamic_plugin_manifest_with_capabilities( + &plugin_dir, + "acme.cli-guardrail-path", + &["plugin.worker", "middleware.guardrail"], + ); + std::fs::write( + user_config_dir.join("plugins.toml"), + r#" +[plugins.policy.defaults] +allowed_capabilities = ["plugin.worker"] +"#, + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["plugins", "validate"]) + .arg(&plugin_dir) + .arg("--json") + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["data"]["target_kind"], "path"); + assert_eq!(parsed["data"]["valid"], false); + assert_eq!(parsed["data"]["policy_state"], "invalid"); + assert!( + parsed["data"]["errors"][0] + .as_str() + .unwrap() + .contains("middleware.guardrail") + ); +} + +#[test] +fn cli_plugins_validate_json_reports_verified_signature_for_path_target() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins").join("acme"); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + std::fs::create_dir_all(&user_config_dir).unwrap(); + write_dynamic_plugin_manifest_with_options( + &plugin_dir, + "acme.cli-signed-path", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + let trusted_public_key = write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); + std::fs::write( + user_config_dir.join("plugins.toml"), + format!( + concat!( + "[plugins.policy.defaults]\n", + "attestation = \"signature_required\"\n", + "trusted_public_keys = [{}]\n" + ), + toml_basic_string(&trusted_public_key) + ), + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["plugins", "validate"]) + .arg(&plugin_dir) + .arg("--json") + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["data"]["valid"], true); + assert_eq!(parsed["data"]["attestation_mode"], "signature_required"); + assert_eq!(parsed["data"]["authenticity_state"], "valid"); +} + +#[test] +fn cli_plugins_validate_json_reports_invalid_signature_for_wrong_trusted_key() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins").join("acme"); + let xdg = temp.path().join("xdg"); + let user_config_dir = xdg.join("nemo-relay"); + std::fs::create_dir_all(&user_config_dir).unwrap(); + write_dynamic_plugin_manifest_with_options( + &plugin_dir, + "acme.cli-signed-wrong-key", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); + let wrong_public_key = generate_ed25519_public_key(); + std::fs::write( + user_config_dir.join("plugins.toml"), + format!( + concat!( + "[plugins.policy.defaults]\n", + "attestation = \"signature_required\"\n", + "trusted_public_keys = [{}]\n" + ), + toml_basic_string(&wrong_public_key) + ), + ) + .unwrap(); + + let output = Command::new(gateway_bin()) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["plugins", "validate"]) + .arg(&plugin_dir) + .arg("--json") + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["data"]["valid"], false); + assert_eq!(parsed["data"]["attestation_mode"], "signature_required"); + assert_eq!(parsed["data"]["authenticity_state"], "invalid"); + assert!( + parsed["data"]["errors"] + .as_array() + .unwrap() + .iter() + .any(|value| value + .as_str() + .unwrap() + .contains("failed signature verification")) + ); +} + +#[test] +fn cli_plugins_list_json_reports_blocked_policy_for_installed_plugin() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("workdir"); + let plugin_dir = cwd.join("plugins").join("acme"); + let config_dir = cwd.join(".nemo-relay"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + write_dynamic_plugin_manifest(&plugin_dir, "acme.cli-blocked-list"); + + let add = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "add", "--project"]) + .arg(&plugin_dir) + .output() + .unwrap(); + assert!( + add.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&add.stderr) + ); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {}\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_required\"\n", + "allowed_capabilities = [\"config.schema\"]\n" + ), + toml_basic_string(plugin_dir.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + + let list = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "list", "--json"]) + .output() + .unwrap(); + + assert!( + list.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&list.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&list.stdout).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["data"][0]["id"], "acme.cli-blocked-list"); + assert_eq!(parsed["data"][0]["policy_state"], "invalid"); + assert_eq!(parsed["data"][0]["startup_class"], "required"); + assert_eq!(parsed["data"][0]["attestation_mode"], "signature_required"); + assert_eq!(parsed["data"][0]["last_error"]["phase"], "policy"); +} + +#[test] +fn cli_plugins_validate_json_reports_blocked_policy_for_installed_id_target() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("workdir"); + let plugin_dir = cwd.join("plugins").join("acme"); + let config_dir = cwd.join(".nemo-relay"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + write_dynamic_plugin_manifest(&plugin_dir, "acme.cli-blocked-id"); + + let add = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "add", "--project"]) + .arg(&plugin_dir) + .output() + .unwrap(); + assert!( + add.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&add.stderr) + ); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {}\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_required\"\n", + "allowed_capabilities = [\"config.schema\"]\n" + ), + toml_basic_string(plugin_dir.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + + let validate = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "validate", "acme.cli-blocked-id", "--json"]) + .output() + .unwrap(); + + assert!( + validate.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&validate.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&validate.stdout).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["data"]["target_kind"], "plugin_id"); + assert_eq!(parsed["data"]["valid"], false); + assert_eq!(parsed["data"]["policy_state"], "invalid"); + assert_eq!(parsed["data"]["startup_class"], "required"); + assert_eq!(parsed["data"]["attestation_mode"], "signature_required"); + assert_eq!(parsed["data"]["desired_enabled"], false); + assert!( + parsed["data"]["errors"][0] + .as_str() + .unwrap() + .contains("blocked by host policy") + ); } #[test] @@ -295,10 +711,87 @@ fn cli_plugins_inspect_json_emits_installed_plugin_details() { assert_eq!(parsed["data"]["id"], "acme.inspect-json"); assert_eq!(parsed["data"]["kind"], "worker"); assert_eq!(parsed["data"]["scope"], "project"); + assert_eq!(parsed["data"]["policy_state"], "valid"); + assert_eq!(parsed["data"]["startup_class"], "optional"); + assert_eq!(parsed["data"]["attestation_mode"], "integrity_only"); assert_eq!(parsed["data"]["host_config_status"], "absent"); assert!(parsed["data"]["source"]["manifest_ref"].is_string()); } +#[test] +fn cli_plugins_inspect_json_reports_blocked_policy_for_installed_plugin() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("workdir"); + let plugin_dir = cwd.join("plugins").join("acme"); + let config_dir = cwd.join(".nemo-relay"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + write_dynamic_plugin_manifest(&plugin_dir, "acme.inspect-blocked"); + + let add = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "add", "--project"]) + .arg(&plugin_dir) + .output() + .unwrap(); + assert!( + add.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&add.stderr) + ); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {}\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_required\"\n", + "allowed_capabilities = [\"config.schema\"]\n" + ), + toml_basic_string(plugin_dir.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + + let validate = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "validate", "acme.inspect-blocked"]) + .output() + .unwrap(); + assert!( + validate.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&validate.stderr) + ); + + let inspect = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "inspect", "acme.inspect-blocked", "--json"]) + .output() + .unwrap(); + + assert!( + inspect.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&inspect.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&inspect.stdout).unwrap(); + assert_eq!(parsed["data"]["id"], "acme.inspect-blocked"); + assert_eq!(parsed["data"]["policy_state"], "invalid"); + assert_eq!(parsed["data"]["startup_class"], "required"); + assert_eq!(parsed["data"]["attestation_mode"], "signature_required"); + assert_eq!(parsed["data"]["status"]["last_error"]["phase"], "policy"); +} + #[test] fn cli_plugins_mutation_commands_emit_terse_confirmation_output() { let temp = tempfile::tempdir().unwrap(); @@ -884,6 +1377,8 @@ fn cli_bare_invocation_reports_invalid_config_resolution() { fn cli_run_dry_run_resolves_config_and_command() { let temp = tempfile::tempdir().unwrap(); let config = temp.path().join("config.toml"); + let xdg = temp.path().join("xdg"); + std::fs::create_dir_all(&xdg).unwrap(); std::fs::write( &config, r#" @@ -898,6 +1393,8 @@ command = "hermes --yolo chat" .unwrap(); let output = Command::new(gateway_bin()) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) .args([ "--config", config.to_str().unwrap(), @@ -948,6 +1445,7 @@ command = "codex --full-auto" let output = Command::new(gateway_bin()) .current_dir(&nested) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) .env("NEMO_RELAY_GATEWAY_BIND", "127.0.0.1:0") .env("NEMO_RELAY_OPENAI_BASE_URL", "http://env-openai") .env("NEMO_RELAY_ANTHROPIC_BASE_URL", "http://env-anthropic") diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index 3baebadcd..18b218f5b 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -3,7 +3,20 @@ use super::*; use axum::http::HeaderValue; +use base64::Engine; +use nemo_relay::plugin::dynamic::{ + DynamicPluginAttestationMode, DynamicPluginCapability, DynamicPluginCheckState, + DynamicPluginKind, DynamicPluginManifest, DynamicPluginStartupClass, +}; +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::plugins::policy::{ + DynamicPluginHostPolicy, DynamicPluginHostPolicyEffect, DynamicPluginHostPolicyRule, + evaluate_dynamic_plugin_host_policy, +}; fn config() -> GatewayConfig { GatewayConfig { @@ -23,6 +36,40 @@ fn isolated_config_path(temp: &tempfile::TempDir) -> std::path::PathBuf { } fn write_dynamic_manifest(dir: &std::path::Path, plugin_id: &str) -> std::path::PathBuf { + write_dynamic_manifest_with_options(dir, plugin_id, &["plugin.worker"], None) +} + +fn write_dynamic_manifest_with_capabilities( + dir: &std::path::Path, + plugin_id: &str, + capabilities: &[&str], +) -> std::path::PathBuf { + write_dynamic_manifest_with_options(dir, plugin_id, capabilities, None) +} + +fn write_dynamic_manifest_with_options( + dir: &std::path::Path, + plugin_id: &str, + capabilities: &[&str], + signature_ref: Option<&str>, +) -> std::path::PathBuf { + let artifact_body = format!("def register():\n return {plugin_id:?}\n"); + std::fs::write(dir.join("plugin.py"), &artifact_body).unwrap(); + let digest = format!( + "sha256:{}", + Sha256::digest(artifact_body.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + let capabilities = capabilities + .iter() + .map(|capability| format!("\"{capability}\"")) + .collect::>() + .join(", "); + let signature_line = signature_ref + .map(|signature_ref| format!("signature = \"{signature_ref}\"\n")) + .unwrap_or_default(); let manifest_path = dir.join("relay-plugin.toml"); std::fs::write( &manifest_path, @@ -42,18 +89,94 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker"] +items = [{capabilities}] + +[source] +artifact = "plugin.py" + +[integrity] +sha256 = "{digest}" +{signature_line} [load] runtime = "python" entrypoint = "{plugin_id}.plugin:register" -"# +"#, + capabilities = capabilities, + signature_line = signature_line, ), ) .unwrap(); manifest_path } +fn write_detached_ed25519_signature(dir: &std::path::Path, signature_name: &str) -> String { + let artifact = std::fs::read(dir.join("plugin.py")).unwrap(); + let pkcs8 = + Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).expect("generate ed25519 keypair"); + let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).expect("parse ed25519 keypair"); + let signature = key_pair.sign(&artifact); + let signature_text = format!( + "ed25519:{}\n", + base64::engine::general_purpose::STANDARD.encode(signature.as_ref()) + ); + std::fs::write(dir.join(signature_name), signature_text).unwrap(); + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(key_pair.public_key().as_ref()) + ) +} + +fn generate_ed25519_public_key() -> String { + let pkcs8 = + Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).expect("generate ed25519 keypair"); + let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).expect("parse ed25519 keypair"); + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(key_pair.public_key().as_ref()) + ) +} + +fn write_dynamic_plugin_state(plugins_toml_path: &std::path::Path, plugin_id: &str, enabled: bool) { + let manifest_ref = plugins_toml_path + .parent() + .unwrap() + .join("plugins/acme/relay-plugin.toml"); + let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_ref).unwrap(); + let mut record = manifest.into_record(Some(manifest_ref)).unwrap(); + assert_eq!(record.metadata.id, plugin_id); + record.spec.enabled = enabled; + record.status.validation.policy_satisfied = DynamicPluginCheckState::Unknown; + std::fs::write( + plugins_toml_path + .parent() + .unwrap() + .join(".dynamic-plugins.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "records": [record], + })) + .unwrap(), + ) + .unwrap(); +} + +fn read_dynamic_plugin_state( + plugins_toml_path: &std::path::Path, +) -> nemo_relay::plugin::dynamic::DynamicPluginRecord { + let persisted: serde_json::Value = serde_json::from_slice( + &std::fs::read( + plugins_toml_path + .parent() + .unwrap() + .join(".dynamic-plugins.json"), + ) + .unwrap(), + ) + .unwrap(); + serde_json::from_value(persisted["records"][0].clone()).unwrap() +} + #[test] fn session_config_prefers_headers_and_parses_json() { let mut headers = HeaderMap::new(); @@ -664,6 +787,264 @@ manifest = '{}' ); } +#[test] +fn plugins_toml_resolves_dynamic_plugin_host_policy_without_polluting_runtime_plugin_config() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let plugins_path = temp.path().join("plugins.toml"); + std::fs::write( + &plugins_path, + r#" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 1 + +[plugins.policy.defaults] +startup = "optional" +attestation = "integrity_only" +trusted_public_keys = ["ed25519:ZmFrZS1rZXk="] + +[[plugins.policy.rules]] +match_capability = "plugin.worker" +startup = "required" + +[plugins.policy.overrides."acme.worker"] +attestation = "signature_required" +"#, + ) + .unwrap(); + + let resolved = load_plugin_toml_config_from_paths(vec![plugins_path]) + .unwrap() + .unwrap(); + + assert_eq!( + resolved.value, + Some(json!({ + "version": 1, + "components": [ + { + "kind": "observability", + "enabled": true, + "config": { + "version": 1 + } + } + ] + })) + ); + assert_eq!( + resolved.dynamic_plugin_policy.defaults.startup, + Some(DynamicPluginStartupClass::Optional) + ); + assert_eq!( + resolved.dynamic_plugin_policy.defaults.attestation, + Some(DynamicPluginAttestationMode::IntegrityOnly) + ); + assert_eq!( + resolved.dynamic_plugin_policy.defaults.trusted_public_keys, + Some(vec!["ed25519:ZmFrZS1rZXk=".into()]) + ); + assert_eq!(resolved.dynamic_plugin_policy.rules.len(), 1); + assert_eq!( + resolved.dynamic_plugin_policy.rules[0].match_capability, + Some(DynamicPluginCapability::PluginWorker) + ); + assert_eq!( + resolved.dynamic_plugin_policy.rules[0].effect.startup, + Some(DynamicPluginStartupClass::Required) + ); + assert_eq!( + resolved + .dynamic_plugin_policy + .overrides + .get("acme.worker") + .and_then(|effect| effect.attestation), + Some(DynamicPluginAttestationMode::SignatureRequired) + ); +} + +#[test] +fn dynamic_plugin_host_policy_evaluator_applies_rules_before_plugin_overrides() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.worker"); + let (manifest, _) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); + let policy = DynamicPluginHostPolicy { + defaults: DynamicPluginHostPolicyEffect { + allowed: Some(true), + startup: Some(DynamicPluginStartupClass::Optional), + attestation: Some(DynamicPluginAttestationMode::IntegrityOnly), + allowed_capabilities: None, + trusted_public_keys: None, + }, + rules: vec![DynamicPluginHostPolicyRule { + match_kind: Some(DynamicPluginKind::Worker), + match_capability: Some(DynamicPluginCapability::PluginWorker), + match_plugin_id: None, + effect: DynamicPluginHostPolicyEffect { + allowed: None, + startup: Some(DynamicPluginStartupClass::Required), + attestation: None, + allowed_capabilities: Some(vec![DynamicPluginCapability::PluginWorker]), + trusted_public_keys: None, + }, + }], + overrides: std::iter::once(( + "acme.worker".into(), + DynamicPluginHostPolicyEffect { + allowed: Some(false), + startup: None, + attestation: Some(DynamicPluginAttestationMode::SignatureRequired), + allowed_capabilities: None, + trusted_public_keys: None, + }, + )) + .collect(), + }; + + let evaluated = evaluate_dynamic_plugin_host_policy(&policy, &manifest); + + assert!(!evaluated.policy_satisfied); + assert_eq!(evaluated.startup_class, DynamicPluginStartupClass::Required); + assert_eq!( + evaluated.attestation_mode, + DynamicPluginAttestationMode::SignatureRequired + ); + assert!( + evaluated + .message + .as_deref() + .unwrap() + .contains("blocked by host policy") + ); +} + +#[test] +fn dynamic_plugin_host_policy_evaluator_matches_functional_surface_capabilities() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_dynamic_manifest_with_capabilities( + &plugin_dir, + "acme.guardrail", + &["plugin.worker", "middleware.guardrail"], + ); + let (manifest, _) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); + let policy = DynamicPluginHostPolicy { + defaults: DynamicPluginHostPolicyEffect { + allowed: Some(true), + startup: Some(DynamicPluginStartupClass::Optional), + attestation: Some(DynamicPluginAttestationMode::IntegrityOnly), + allowed_capabilities: Some(vec![ + DynamicPluginCapability::PluginWorker, + DynamicPluginCapability::MiddlewareGuardrail, + ]), + trusted_public_keys: None, + }, + rules: vec![DynamicPluginHostPolicyRule { + match_kind: Some(DynamicPluginKind::Worker), + match_capability: Some(DynamicPluginCapability::MiddlewareGuardrail), + match_plugin_id: None, + effect: DynamicPluginHostPolicyEffect { + allowed: None, + startup: Some(DynamicPluginStartupClass::Required), + attestation: None, + allowed_capabilities: None, + trusted_public_keys: None, + }, + }], + overrides: Default::default(), + }; + + let evaluated = evaluate_dynamic_plugin_host_policy(&policy, &manifest); + + assert!(evaluated.policy_satisfied); + assert_eq!(evaluated.startup_class, DynamicPluginStartupClass::Required); + assert_eq!( + evaluated.attestation_mode, + DynamicPluginAttestationMode::IntegrityOnly + ); + assert!(evaluated.message.is_none()); +} + +#[test] +fn plugins_toml_layers_dynamic_plugin_host_policy_across_sources() { + let temp = tempfile::tempdir().unwrap(); + let project_plugins = temp.path().join("project-plugins.toml"); + let user_plugins = temp.path().join("user-plugins.toml"); + std::fs::write( + &project_plugins, + r#" +[plugins.policy.defaults] +startup = "required" + +[[plugins.policy.rules]] +match_kind = "worker" +allowed_capabilities = ["plugin.worker"] + +[plugins.policy.overrides."acme.worker"] +attestation = "signature_if_present" +"#, + ) + .unwrap(); + std::fs::write( + &user_plugins, + r#" +[plugins.policy.defaults] +attestation = "signature_required" + +[[plugins.policy.rules]] +match_capability = "plugin.worker" +allowed = false + +[plugins.policy.overrides."acme.worker"] +allowed = true +"#, + ) + .unwrap(); + + let resolved = load_plugin_toml_config_from_paths(vec![project_plugins, user_plugins]) + .unwrap() + .unwrap(); + + assert_eq!( + resolved.dynamic_plugin_policy.defaults.startup, + Some(DynamicPluginStartupClass::Required) + ); + assert_eq!( + resolved.dynamic_plugin_policy.defaults.attestation, + Some(DynamicPluginAttestationMode::SignatureRequired) + ); + assert_eq!(resolved.dynamic_plugin_policy.rules.len(), 2); + assert_eq!( + resolved.dynamic_plugin_policy.rules[0].match_kind, + Some(DynamicPluginKind::Worker) + ); + assert_eq!( + resolved.dynamic_plugin_policy.rules[1].match_capability, + Some(DynamicPluginCapability::PluginWorker) + ); + let override_effect = resolved + .dynamic_plugin_policy + .overrides + .get("acme.worker") + .expect("merged override"); + assert_eq!( + override_effect.attestation, + Some(DynamicPluginAttestationMode::SignatureIfPresent) + ); + assert_eq!(override_effect.allowed, Some(true)); +} + #[test] fn plugins_toml_rejects_duplicate_dynamic_plugin_ids_across_sources() { let temp = tempfile::tempdir().unwrap(); @@ -1038,6 +1419,318 @@ fn server_resolution_applies_all_server_overrides() { assert!(args.requested_daemon_mode()); } +#[test] +fn server_resolution_fails_when_required_enabled_dynamic_plugin_is_blocked_by_policy() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let config_path = temp.path().join("config.toml"); + let plugins_toml_path = temp.path().join("plugins.toml"); + std::fs::write(&config_path, "").unwrap(); + std::fs::write( + &plugins_toml_path, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" + +[plugins.policy.defaults] +startup = "required" +allowed = false +"#, + ) + .unwrap(); + write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); + let args = ServerArgs { + config: Some(config_path), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("required dynamic plugin startup preflight failed")); + assert!(error.contains("acme.worker")); + assert!(error.contains("blocked by host policy")); +} + +#[test] +fn server_resolution_fails_when_required_enabled_dynamic_plugin_fails_integrity() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + std::fs::write( + plugin_dir.join("plugin.py"), + "def register():\n return 'tampered'\n", + ) + .unwrap(); + let config_path = temp.path().join("config.toml"); + let plugins_toml_path = temp.path().join("plugins.toml"); + std::fs::write(&config_path, "").unwrap(); + std::fs::write( + &plugins_toml_path, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" + +[plugins.policy.defaults] +startup = "required" +"#, + ) + .unwrap(); + write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); + + let args = ServerArgs { + config: Some(config_path), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("required dynamic plugin startup preflight failed")); + assert!(error.contains("acme.worker")); + assert!(error.contains("integrity verification")); + + let record = read_dynamic_plugin_state(&plugins_toml_path); + assert_eq!( + record.status.validation.integrity, + DynamicPluginCheckState::Invalid + ); + assert_eq!( + record.status.validation.policy_satisfied, + DynamicPluginCheckState::Valid + ); + assert_eq!( + record.status.startup_class, + Some(DynamicPluginStartupClass::Required) + ); + assert_eq!( + record.status.attestation_mode, + Some(DynamicPluginAttestationMode::IntegrityOnly) + ); + assert!( + record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("integrity verification") + ); +} + +#[test] +fn server_resolution_fails_when_required_enabled_dynamic_plugin_lacks_trusted_keys() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest_with_options( + &plugin_dir, + "acme.worker", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); + let config_path = temp.path().join("config.toml"); + let plugins_toml_path = temp.path().join("plugins.toml"); + std::fs::write(&config_path, "").unwrap(); + std::fs::write( + &plugins_toml_path, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" + +[plugins.policy.defaults] +startup = "required" +attestation = "signature_required" +"#, + ) + .unwrap(); + write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); + + let args = ServerArgs { + config: Some(config_path), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("required dynamic plugin startup preflight failed")); + assert!(error.contains("acme.worker")); + assert!(error.contains("no trusted_public_keys")); + + let record = read_dynamic_plugin_state(&plugins_toml_path); + assert_eq!( + record.status.validation.authenticity, + DynamicPluginCheckState::Invalid + ); + assert_eq!( + record.status.validation.policy_satisfied, + DynamicPluginCheckState::Valid + ); + assert_eq!( + record.status.startup_class, + Some(DynamicPluginStartupClass::Required) + ); + assert_eq!( + record.status.attestation_mode, + Some(DynamicPluginAttestationMode::SignatureRequired) + ); + assert!( + record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("no trusted_public_keys") + ); +} + +#[test] +fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_wrong_trusted_key() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest_with_options( + &plugin_dir, + "acme.worker", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); + let wrong_public_key = generate_ed25519_public_key(); + let config_path = temp.path().join("config.toml"); + let plugins_toml_path = temp.path().join("plugins.toml"); + std::fs::write(&config_path, "").unwrap(); + std::fs::write( + &plugins_toml_path, + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = \"plugins/acme/relay-plugin.toml\"\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_required\"\n", + "trusted_public_keys = [{:?}]\n" + ), + wrong_public_key + ), + ) + .unwrap(); + write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); + + let args = ServerArgs { + config: Some(config_path), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("required dynamic plugin startup preflight failed")); + assert!(error.contains("acme.worker")); + assert!(error.contains("failed signature verification")); + + let record = read_dynamic_plugin_state(&plugins_toml_path); + assert_eq!( + record.status.validation.authenticity, + DynamicPluginCheckState::Invalid + ); + assert_eq!( + record.status.validation.policy_satisfied, + DynamicPluginCheckState::Valid + ); + assert_eq!( + record.status.startup_class, + Some(DynamicPluginStartupClass::Required) + ); + assert_eq!( + record.status.attestation_mode, + Some(DynamicPluginAttestationMode::SignatureRequired) + ); + assert!( + record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("failed signature verification") + ); +} + +#[test] +fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_malformed_signature() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest_with_options( + &plugin_dir, + "acme.worker", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + std::fs::write(plugin_dir.join("plugin.py.sig"), "ed25519:not-base64\n").unwrap(); + let trusted_public_key = generate_ed25519_public_key(); + let config_path = temp.path().join("config.toml"); + let plugins_toml_path = temp.path().join("plugins.toml"); + std::fs::write(&config_path, "").unwrap(); + std::fs::write( + &plugins_toml_path, + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = \"plugins/acme/relay-plugin.toml\"\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_if_present\"\n", + "trusted_public_keys = [{:?}]\n" + ), + trusted_public_key + ), + ) + .unwrap(); + write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); + + let args = ServerArgs { + config: Some(config_path), + ..ServerArgs::default() + }; + + let error = resolve_server_config(&args).unwrap_err().to_string(); + + assert!(error.contains("required dynamic plugin startup preflight failed")); + assert!(error.contains("acme.worker")); + assert!(error.contains("invalid base64 signature")); + + let record = read_dynamic_plugin_state(&plugins_toml_path); + assert_eq!( + record.status.validation.authenticity, + DynamicPluginCheckState::Invalid + ); + assert_eq!( + record.status.validation.policy_satisfied, + DynamicPluginCheckState::Valid + ); + assert_eq!( + record.status.startup_class, + Some(DynamicPluginStartupClass::Required) + ); + assert_eq!( + record.status.attestation_mode, + Some(DynamicPluginAttestationMode::SignatureIfPresent) + ); + assert!( + record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("invalid base64 signature") + ); +} + #[test] fn gateway_body_limit_defaults_are_stable() { let gateway = GatewayConfig::default(); @@ -1102,6 +1795,47 @@ fn run_resolution_applies_all_run_overrides() { ); } +#[test] +fn run_resolution_fails_when_required_enabled_dynamic_plugin_is_blocked_by_policy() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins/acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.worker"); + let config_path = temp.path().join("config.toml"); + let plugins_toml_path = temp.path().join("plugins.toml"); + std::fs::write(&config_path, "").unwrap(); + std::fs::write( + &plugins_toml_path, + r#" +[[plugins.dynamic]] +manifest = "plugins/acme/relay-plugin.toml" + +[plugins.policy.defaults] +startup = "required" +allowed = false +"#, + ) + .unwrap(); + write_dynamic_plugin_state(&plugins_toml_path, "acme.worker", true); + let command = RunCommand { + agent: Some(CodingAgent::Codex), + config: Some(config_path), + openai_base_url: None, + anthropic_base_url: None, + session_metadata: None, + plugin_config: None, + dry_run: false, + print: false, + command: vec!["codex".into()], + }; + + let error = resolve_run_config(&command, None).unwrap_err().to_string(); + + assert!(error.contains("required dynamic plugin startup preflight failed")); + assert!(error.contains("acme.worker")); + assert!(error.contains("blocked by host policy")); +} + #[test] fn malformed_shared_config_reports_context() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/launcher_tests.rs b/crates/cli/tests/coverage/launcher_tests.rs index 846c3b9a2..f81468164 100644 --- a/crates/cli/tests/coverage/launcher_tests.rs +++ b/crates/cli/tests/coverage/launcher_tests.rs @@ -575,6 +575,7 @@ fn prepares_hermes_hook_environment() { ..AgentConfigs::default() }, dynamic_plugins: Vec::new(), + ..ResolvedConfig::default() }; let prepared = PreparedRun::new( CodingAgent::Hermes, diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index e5c269706..f3ffdf597 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -10,6 +10,10 @@ use crate::config::{ PluginsListCommand, PluginsRemoveCommand, PluginsScopeArgs, PluginsValidateCommand, ServerArgs, }; use crate::error::PluginLifecycleFailureKind; +use base64::Engine; +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use sha2::{Digest, Sha256}; struct CurrentDirGuard { original: PathBuf, @@ -81,6 +85,40 @@ impl Drop for EnvScope { } fn write_dynamic_manifest(dir: &Path, plugin_id: &str) -> PathBuf { + write_dynamic_manifest_with_options(dir, plugin_id, &["plugin.worker"], None) +} + +fn write_dynamic_manifest_with_capabilities( + dir: &Path, + plugin_id: &str, + capabilities: &[&str], +) -> PathBuf { + write_dynamic_manifest_with_options(dir, plugin_id, capabilities, None) +} + +fn write_dynamic_manifest_with_options( + dir: &Path, + plugin_id: &str, + capabilities: &[&str], + signature_ref: Option<&str>, +) -> PathBuf { + let artifact_body = format!("def register():\n return {plugin_id:?}\n"); + std::fs::write(dir.join("plugin.py"), &artifact_body).unwrap(); + let digest = format!( + "sha256:{}", + Sha256::digest(artifact_body.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + let capabilities = capabilities + .iter() + .map(|capability| format!("\"{capability}\"")) + .collect::>() + .join(", "); + let signature_line = signature_ref + .map(|signature_ref| format!("signature = \"{signature_ref}\"\n")) + .unwrap_or_default(); let manifest_path = dir.join("relay-plugin.toml"); std::fs::write( &manifest_path, @@ -100,18 +138,55 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker"] +items = [{capabilities}] + +[source] +artifact = "plugin.py" + +[integrity] +sha256 = "{digest}" +{signature_line} [load] runtime = "python" entrypoint = "{plugin_id}.plugin:register" -"# +"#, + capabilities = capabilities, + signature_line = signature_line, ), ) .unwrap(); manifest_path } +fn write_detached_ed25519_signature(dir: &Path, signature_name: &str) -> String { + std::fs::create_dir_all(dir).unwrap(); + let artifact = std::fs::read(dir.join("plugin.py")).unwrap(); + let pkcs8 = + Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).expect("generate ed25519 keypair"); + let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).expect("parse ed25519 keypair"); + let signature = key_pair.sign(&artifact); + let signature_text = format!( + "ed25519:{}\n", + base64::engine::general_purpose::STANDARD.encode(signature.as_ref()) + ); + std::fs::write(dir.join(signature_name), signature_text).unwrap(); + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(key_pair.public_key().as_ref()) + ) +} + +fn generate_ed25519_public_key() -> String { + let pkcs8 = + Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).expect("generate ed25519 keypair"); + let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).expect("parse ed25519 keypair"); + format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(key_pair.public_key().as_ref()) + ) +} + #[test] fn add_registers_dynamic_plugin_in_project_plugins_toml() { let temp = tempfile::tempdir().unwrap(); @@ -179,6 +254,46 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { assert!(error.contains("already registered")); } +#[test] +fn add_refuses_functional_surface_capabilities_blocked_by_host_policy() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + write_dynamic_manifest_with_capabilities( + &plugin_dir, + "acme.guardrail-surface", + &["plugin.worker", "middleware.guardrail"], + ); + std::fs::write( + config_dir.join("plugins.toml"), + r#" +[plugins.policy.defaults] +allowed_capabilities = ["plugin.worker"] +"#, + ) + .unwrap(); + + let error = add( + PluginsAddCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + path: plugin_dir, + }, + &crate::config::ServerArgs::default(), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("middleware.guardrail")); + assert!(error.contains("blocked by host policy")); +} + #[test] fn add_rejects_scope_flags_when_explicit_config_is_set() { let temp = tempfile::tempdir().unwrap(); @@ -209,6 +324,90 @@ fn add_rejects_scope_flags_when_explicit_config_is_set() { assert!(error.contains("--config cannot be combined")); } +#[test] +fn add_refuses_dynamic_plugins_blocked_by_host_policy() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.blocked"); + std::fs::write( + config_dir.join("plugins.toml"), + r#" +[plugins.policy.defaults] +allowed_capabilities = ["config.schema"] +"#, + ) + .unwrap(); + + let error = add( + PluginsAddCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + path: plugin_dir, + }, + &crate::config::ServerArgs::default(), + ) + .unwrap_err(); + + match error { + CliError::PluginLifecycle { + kind: PluginLifecycleFailureKind::Refused, + message, + .. + } => assert!(message.contains("capability 'plugin.worker' is not allowed")), + other => panic!("unexpected policy add error: {other}"), + } + + let rendered = std::fs::read_to_string(config_dir.join("plugins.toml")).unwrap(); + assert!(!rendered.contains("[[plugins.dynamic]]")); +} + +#[test] +fn validate_path_reports_integrity_mismatch() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.integrity"); + std::fs::write( + plugin_dir.join("plugin.py"), + "def register():\n return 'tampered'\n", + ) + .unwrap(); + let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path) + .map_err(|error| CliError::Config(error.to_string())) + .unwrap(); + let policy = evaluate_dynamic_plugin_host_policy( + &ResolvedConfig::default().dynamic_plugin_policy, + &manifest, + ); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + let summary = PluginValidationSummaryView { + manifest: &manifest, + manifest_ref: &manifest_ref, + entry: None, + host_config: None, + policy: &policy, + trust: &trust, + } + .to_string(); + + assert_eq!(trust.integrity, DynamicPluginCheckState::Invalid); + assert!(summary.contains("trust verification blocks it")); + assert!(summary.contains("integrity_state: invalid")); + assert!( + summary + .contains("trust_error: dynamic plugin 'acme.integrity' failed integrity verification") + ); +} + #[test] fn list_and_inspect_render_discovered_dynamic_plugins() { let temp = tempfile::tempdir().unwrap(); @@ -239,9 +438,11 @@ fn list_and_inspect_render_discovered_dynamic_plugins() { host_config_by_id: &host_config_by_id, } .to_string(); + assert!(list.contains("POLICY")); assert!(list.contains("acme.guardrail")); assert!(list.contains("absent")); assert!(list.contains("false")); + assert!(list.contains("valid")); let entry = find_record_by_id(&scopes, "acme.guardrail") .unwrap() @@ -300,14 +501,22 @@ fn validate_renders_summary_for_path_and_id_targets() { let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path) .map_err(|error| CliError::Config(error.to_string())) .unwrap(); + let default_policy = evaluate_dynamic_plugin_host_policy( + &ResolvedConfig::default().dynamic_plugin_policy, + &manifest, + ); + let default_trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &default_policy); let path_summary = PluginValidationSummaryView { manifest: &manifest, manifest_ref: &manifest_ref, entry: None, host_config: None, + policy: &default_policy, + trust: &default_trust, } .to_string(); assert!(path_summary.contains("Dynamic plugin 'acme.guardrail' is valid.")); + assert!(path_summary.contains("policy_state: valid")); let resolved = resolve_plugins_config(None).unwrap(); let host_config_by_id = host_config_by_id(&resolved); @@ -315,11 +524,15 @@ fn validate_renders_summary_for_path_and_id_targets() { let entry = find_record_by_id(&scopes, "acme.guardrail") .unwrap() .expect("plugin record"); + let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); let id_summary = PluginValidationSummaryView { manifest: &manifest, manifest_ref: &manifest_ref, entry: Some(&entry), host_config: host_config_by_id.get("acme.guardrail"), + policy: &policy, + trust: &trust, } .to_string(); assert!(id_summary.contains("host_config: absent")); @@ -530,6 +743,519 @@ fn hydrate_bootstraps_registry_records_from_existing_dynamic_plugin_refs() { ); } +#[test] +fn hydrate_applies_host_policy_status_to_discovered_dynamic_plugins() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.policy"); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {:?}\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_required\"\n" + ), + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + + let resolved = resolve_plugins_config(None).unwrap(); + let scopes = load_and_hydrate_scopes(None, &resolved).unwrap(); + let entry = find_record_by_id(&scopes, "acme.policy") + .unwrap() + .expect("hydrated record"); + + assert_eq!( + entry.record.status.validation.policy_satisfied, + DynamicPluginCheckState::Valid + ); + assert_eq!( + entry + .record + .status + .startup_class + .map(|value| value.to_string()), + Some("required".into()) + ); + assert_eq!( + entry + .record + .status + .attestation_mode + .map(|value| value.to_string()), + Some("signature_required".into()) + ); + assert_eq!( + entry.record.status.validation.authenticity, + DynamicPluginCheckState::Invalid + ); + assert!( + entry + .record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("signature verification") + || entry + .record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("integrity.signature") + ); +} + +#[test] +fn hydrate_verifies_signatures_when_host_policy_provides_trusted_keys() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + let manifest_path = write_dynamic_manifest_with_options( + &plugin_dir, + "acme.signed", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + let trusted_public_key = write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {:?}\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_required\"\n", + "trusted_public_keys = [{:?}]\n" + ), + manifest_path.to_string_lossy(), + trusted_public_key + ), + ) + .unwrap(); + + let resolved = resolve_plugins_config(None).unwrap(); + let scopes = load_and_hydrate_scopes(None, &resolved).unwrap(); + let entry = find_record_by_id(&scopes, "acme.signed") + .unwrap() + .expect("hydrated signed record"); + + assert_eq!( + entry.record.status.validation.integrity, + DynamicPluginCheckState::Valid + ); + assert_eq!( + entry.record.status.validation.authenticity, + DynamicPluginCheckState::Valid + ); + assert!(entry.record.status.last_error.is_none()); +} + +#[test] +fn hydrate_marks_signature_required_plugins_invalid_without_trusted_keys() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + let manifest_path = write_dynamic_manifest_with_options( + &plugin_dir, + "acme.signed-without-trust", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {:?}\n\n", + "[plugins.policy.defaults]\n", + "attestation = \"signature_required\"\n" + ), + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + + let resolved = resolve_plugins_config(None).unwrap(); + let scopes = load_and_hydrate_scopes(None, &resolved).unwrap(); + let entry = find_record_by_id(&scopes, "acme.signed-without-trust") + .unwrap() + .expect("hydrated signed record"); + + assert_eq!( + entry.record.status.validation.authenticity, + DynamicPluginCheckState::Invalid + ); + assert!( + entry + .record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("no trusted_public_keys") + ); +} + +#[test] +fn hydrate_marks_signature_required_plugins_invalid_with_wrong_trusted_key() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + let manifest_path = write_dynamic_manifest_with_options( + &plugin_dir, + "acme.signed-wrong-key", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); + let wrong_public_key = generate_ed25519_public_key(); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {:?}\n\n", + "[plugins.policy.defaults]\n", + "attestation = \"signature_required\"\n", + "trusted_public_keys = [{:?}]\n" + ), + manifest_path.to_string_lossy(), + wrong_public_key + ), + ) + .unwrap(); + + let resolved = resolve_plugins_config(None).unwrap(); + let scopes = load_and_hydrate_scopes(None, &resolved).unwrap(); + let entry = find_record_by_id(&scopes, "acme.signed-wrong-key") + .unwrap() + .expect("hydrated signed record"); + + assert_eq!( + entry.record.status.validation.authenticity, + DynamicPluginCheckState::Invalid + ); + assert!( + entry + .record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("failed signature verification") + ); +} + +#[test] +fn hydrate_marks_malformed_signature_files_invalid_when_signature_is_present() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + let manifest_path = write_dynamic_manifest_with_options( + &plugin_dir, + "acme.signed-malformed", + &["plugin.worker"], + Some("plugin.py.sig"), + ); + std::fs::write(plugin_dir.join("plugin.py.sig"), "ed25519:not-base64\n").unwrap(); + let trusted_public_key = generate_ed25519_public_key(); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {:?}\n\n", + "[plugins.policy.defaults]\n", + "attestation = \"signature_if_present\"\n", + "trusted_public_keys = [{:?}]\n" + ), + manifest_path.to_string_lossy(), + trusted_public_key + ), + ) + .unwrap(); + + let resolved = resolve_plugins_config(None).unwrap(); + let scopes = load_and_hydrate_scopes(None, &resolved).unwrap(); + let entry = find_record_by_id(&scopes, "acme.signed-malformed") + .unwrap() + .expect("hydrated signed record"); + + assert_eq!( + entry.record.status.validation.authenticity, + DynamicPluginCheckState::Invalid + ); + assert!( + entry + .record + .status + .last_error + .as_ref() + .unwrap() + .message + .contains("invalid base64 signature") + ); +} + +#[test] +fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.enable-blocked"); + let server = crate::config::ServerArgs::default(); + + add( + PluginsAddCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + path: plugin_dir, + }, + &server, + ) + .unwrap(); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {:?}\n\n", + "[plugins.policy.defaults]\n", + "allowed_capabilities = [\"config.schema\"]\n" + ), + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + + let error = enable( + PluginsEnableCommand { + id: "acme.enable-blocked".into(), + }, + &server, + ) + .unwrap_err(); + + match error { + CliError::PluginLifecycle { + kind: PluginLifecycleFailureKind::Refused, + message, + .. + } => assert!(message.contains("capability 'plugin.worker' is not allowed")), + other => panic!("unexpected enable policy error: {other}"), + } + + let resolved = resolve_plugins_config(None).unwrap(); + let scopes = load_and_hydrate_scopes(None, &resolved).unwrap(); + let entry = find_record_by_id(&scopes, "acme.enable-blocked") + .unwrap() + .expect("policy-updated record"); + assert!(!entry.record.spec.enabled); + assert_eq!( + entry.record.status.validation.policy_satisfied, + DynamicPluginCheckState::Invalid + ); + assert_eq!( + entry + .record + .status + .last_error + .as_ref() + .map(|error| error.phase.to_string()), + Some("policy".into()) + ); +} + +#[test] +fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.validate-blocked"); + let server = crate::config::ServerArgs::default(); + + add( + PluginsAddCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + path: plugin_dir, + }, + &server, + ) + .unwrap(); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {:?}\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_required\"\n", + "allowed_capabilities = [\"config.schema\"]\n" + ), + manifest_path.to_string_lossy() + ), + ) + .unwrap(); + + validate( + PluginsValidateCommand { + target: "acme.validate-blocked".into(), + json: false, + }, + &server, + ) + .unwrap(); + + let resolved = resolve_plugins_config(None).unwrap(); + let scopes = load_and_hydrate_scopes(None, &resolved).unwrap(); + let entry = find_record_by_id(&scopes, "acme.validate-blocked") + .unwrap() + .expect("policy-updated record"); + + assert_eq!( + entry.record.status.validation.policy_satisfied, + DynamicPluginCheckState::Invalid + ); + assert_eq!( + entry.record.status.validation.message.as_deref(), + Some("validated by CLI") + ); + let (blocked_manifest, blocked_manifest_ref) = + DynamicPluginManifest::load_from_path(&manifest_path) + .map_err(|error| CliError::Config(error.to_string())) + .unwrap(); + let blocked_policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &blocked_manifest); + let blocked_trust = + evaluate_dynamic_plugin_trust(&blocked_manifest, &blocked_manifest_ref, &blocked_policy); + let blocked_summary = PluginValidationSummaryView { + manifest: &blocked_manifest, + manifest_ref: &blocked_manifest_ref, + entry: Some(&entry), + host_config: None, + policy: &blocked_policy, + trust: &blocked_trust, + } + .to_string(); + assert!(blocked_summary.contains("host policy blocks it")); + assert!(blocked_summary.contains("policy_state: invalid")); + let blocked_list = PluginListView { + records: std::slice::from_ref(&entry), + host_config_by_id: &std::collections::HashMap::new(), + } + .to_string(); + assert!(blocked_list.contains("POLICY")); + assert!(blocked_list.contains("invalid")); + let blocked_validate_value = serde_json::to_value(responses::validate_success( + responses::ValidateResponseInput { + command: "plugins validate", + target: Some("acme.validate-blocked"), + target_kind: "plugin_id", + resolved_plugin_id: Some("acme.validate-blocked"), + manifest: &blocked_manifest, + manifest_ref: &blocked_manifest_ref, + entry: Some(&entry), + host_config: None, + policy: &blocked_policy, + trust: &blocked_trust, + }, + )) + .unwrap(); + assert_eq!( + blocked_validate_value["data"]["valid"], + serde_json::json!(false) + ); + assert_eq!( + blocked_validate_value["data"]["policy_state"], + serde_json::json!("invalid") + ); + assert!( + blocked_validate_value["data"]["errors"][0] + .as_str() + .unwrap() + .contains("blocked by host policy") + ); + assert_eq!( + entry + .record + .status + .startup_class + .map(|value| value.to_string()), + Some("required".into()) + ); + assert_eq!( + entry + .record + .status + .attestation_mode + .map(|value| value.to_string()), + Some("signature_required".into()) + ); + assert_eq!( + entry + .record + .status + .last_error + .as_ref() + .map(|error| error.phase.to_string()), + Some("policy".into()) + ); +} + #[test] fn add_can_revive_tombstoned_records() { let temp = tempfile::tempdir().unwrap(); @@ -644,6 +1370,9 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { serde_json::Value::Null ); + let validate_policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let validate_trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &validate_policy); let validate_value = serde_json::to_value(responses::validate_success( responses::ValidateResponseInput { command: "plugins validate", @@ -654,6 +1383,8 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { manifest_ref: &manifest_ref, entry: Some(&entry), host_config: host_config_by_id.get("acme.json"), + policy: &validate_policy, + trust: &validate_trust, }, )) .unwrap(); @@ -662,6 +1393,18 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { serde_json::json!("plugin_id") ); assert_eq!(validate_value["data"]["valid"], serde_json::json!(true)); + assert_eq!( + validate_value["data"]["policy_state"], + serde_json::json!("valid") + ); + assert_eq!( + validate_value["data"]["startup_class"], + serde_json::json!("optional") + ); + assert_eq!( + validate_value["data"]["attestation_mode"], + serde_json::json!("integrity_only") + ); let failure = serde_json::to_value(responses::failure( "plugins inspect", diff --git a/crates/core/src/plugin/dynamic.rs b/crates/core/src/plugin/dynamic.rs index 639714538..9db3c9908 100644 --- a/crates/core/src/plugin/dynamic.rs +++ b/crates/core/src/plugin/dynamic.rs @@ -49,14 +49,30 @@ pub enum WorkerRuntime { /// Relay-enforced capability declared by a dynamic plugin. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] pub enum DynamicPluginCapability { /// Trusted in-process native extension capability. + #[serde(rename = "plugin.native")] + #[strum(serialize = "plugin.native")] PluginNative, /// Isolated worker-based extension capability. + #[serde(rename = "plugin.worker")] + #[strum(serialize = "plugin.worker")] PluginWorker, + /// Middleware guardrail registration capability. + #[serde(rename = "middleware.guardrail")] + #[strum(serialize = "middleware.guardrail")] + MiddlewareGuardrail, + /// Middleware interceptor registration capability. + #[serde(rename = "middleware.interceptor")] + #[strum(serialize = "middleware.interceptor")] + MiddlewareInterceptor, + /// Observability exporter registration capability. + #[serde(rename = "telemetry.exporter")] + #[strum(serialize = "telemetry.exporter")] + TelemetryExporter, /// Typed configuration schema contribution capability. + #[serde(rename = "config.schema")] + #[strum(serialize = "config.schema")] ConfigSchema, } diff --git a/crates/core/src/plugin/dynamic/manifest.rs b/crates/core/src/plugin/dynamic/manifest.rs index f71bf8daf..a85d0c311 100644 --- a/crates/core/src/plugin/dynamic/manifest.rs +++ b/crates/core/src/plugin/dynamic/manifest.rs @@ -465,26 +465,26 @@ fn validate_capability_shape( DynamicPluginKind::RustDynamic => { if !has_native { return Err(PluginError::InvalidConfig( - "rust_dynamic plugins must declare capabilities.items containing plugin_native" + "rust_dynamic plugins must declare capabilities.items containing plugin.native" .into(), )); } if has_worker { return Err(PluginError::InvalidConfig( - "rust_dynamic plugins must not declare plugin_worker".into(), + "rust_dynamic plugins must not declare plugin.worker".into(), )); } } DynamicPluginKind::Worker => { if !has_worker { return Err(PluginError::InvalidConfig( - "worker plugins must declare capabilities.items containing plugin_worker" + "worker plugins must declare capabilities.items containing plugin.worker" .into(), )); } if has_native { return Err(PluginError::InvalidConfig( - "worker plugins must not declare plugin_native".into(), + "worker plugins must not declare plugin.native".into(), )); } } diff --git a/crates/core/src/plugin/dynamic/registry.rs b/crates/core/src/plugin/dynamic/registry.rs index 42177d812..52d1c13fa 100644 --- a/crates/core/src/plugin/dynamic/registry.rs +++ b/crates/core/src/plugin/dynamic/registry.rs @@ -4,9 +4,10 @@ use std::collections::BTreeMap; use super::{ - DynamicPluginFailure, DynamicPluginId, DynamicPluginManifest, DynamicPluginMetadata, - DynamicPluginRecord, DynamicPluginRuntimeStatus, DynamicPluginValidationStatus, - bump_generation, stamp_creation_metadata, + DynamicPluginAttestationMode, DynamicPluginCheckState, DynamicPluginFailure, DynamicPluginId, + DynamicPluginManifest, DynamicPluginMetadata, DynamicPluginRecord, DynamicPluginRuntimeStatus, + DynamicPluginStartupClass, DynamicPluginValidationStatus, bump_generation, + stamp_creation_metadata, }; use crate::plugin::{PluginError, Result}; @@ -169,6 +170,23 @@ impl DynamicPluginRegistry { Ok(()) } + /// Replaces the current host-policy outcome without mutating desired state. + pub fn update_policy_status( + &mut self, + plugin_id: &str, + policy_satisfied: DynamicPluginCheckState, + startup_class: DynamicPluginStartupClass, + attestation_mode: DynamicPluginAttestationMode, + last_error: Option, + ) -> Result<()> { + let record = self.lookup_mut(plugin_id)?; + record.status.validation.policy_satisfied = policy_satisfied; + record.status.startup_class = Some(startup_class); + record.status.attestation_mode = Some(attestation_mode); + record.status.last_error = last_error; + Ok(()) + } + fn lookup_mut(&mut self, plugin_id: &str) -> Result<&mut DynamicPluginRecord> { self.records.get_mut(plugin_id).ok_or_else(|| { PluginError::NotFound(format!("dynamic plugin '{plugin_id}' is not registered")) diff --git a/crates/core/tests/unit/plugin_dynamic_tests.rs b/crates/core/tests/unit/plugin_dynamic_tests.rs index c2445157f..1345dcb36 100644 --- a/crates/core/tests/unit/plugin_dynamic_tests.rs +++ b/crates/core/tests/unit/plugin_dynamic_tests.rs @@ -484,7 +484,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker", "config_schema"] +items = ["plugin.worker", "config.schema"] [load] runtime = "python" @@ -515,7 +515,7 @@ native_api = "1" enabled = false [capabilities] -items = ["plugin_native"] +items = ["plugin.native"] [load] library = "target/release/libswitchyard.dylib" @@ -575,6 +575,51 @@ fn manifest_parse_and_conversion_supports_worker_lane() { ); } +#[test] +fn manifest_supports_extended_functional_surface_capabilities() { + let manifest = DynamicPluginManifest::parse_toml( + r#" +manifest_version = 1 + +[plugin] +id = "acme.guardrails.rich" +kind = "worker" + +[compat] +relay = ">=0.1.0,<0.2.0" +worker_protocol = "1" + +[defaults] +enabled = false + +[capabilities] +items = [ + "plugin.worker", + "middleware.guardrail", + "middleware.interceptor", + "telemetry.exporter", + "config.schema", +] + +[load] +runtime = "python" +entrypoint = "acme_guardrails.plugin:register" +"#, + ) + .expect("parse manifest with functional surface capabilities"); + + assert_eq!( + manifest.capabilities.items, + vec![ + DynamicPluginCapability::PluginWorker, + DynamicPluginCapability::MiddlewareGuardrail, + DynamicPluginCapability::MiddlewareInterceptor, + DynamicPluginCapability::TelemetryExporter, + DynamicPluginCapability::ConfigSchema, + ] + ); +} + #[test] fn manifest_conversion_canonicalizes_required_strings_in_record_state() { let manifest = DynamicPluginManifest::parse_toml( @@ -593,7 +638,7 @@ worker_protocol = " 1 " enabled = false [capabilities] -items = ["plugin_worker"] +items = ["plugin.worker"] [load] runtime = "python" @@ -663,7 +708,7 @@ relay = ">=0.1.0,<0.2.0" enabled = false [capabilities] -items = ["plugin_worker"] +items = ["plugin.worker"] [load] runtime = "python" @@ -700,7 +745,7 @@ native_api = "1" enabled = false [capabilities] -items = ["plugin_worker"] +items = ["plugin.worker"] [load] library = "target/release/libbad.dylib" @@ -711,7 +756,7 @@ symbol = "nemo_relay_register_plugin" match err { PluginError::InvalidConfig(message) => { - assert!(message.contains("plugin_native"), "{message}"); + assert!(message.contains("plugin.native"), "{message}"); } other => panic!("unexpected capability validation error: {other}"), } @@ -735,7 +780,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker"] +items = ["plugin.worker"] [load] runtime = "python" @@ -769,7 +814,7 @@ relay = ">=0.1.0,<0.2.0" enabled = false [capabilities] -items = ["plugin_native"] +items = ["plugin.native"] [load] library = "target/release/libmissing.dylib" @@ -804,7 +849,7 @@ native_api = "1" enabled = false [capabilities] -items = ["plugin_native"] +items = ["plugin.native"] [load] runtime = "python" @@ -948,7 +993,7 @@ worker_protocol = "1" enabled = true [capabilities] -items = ["plugin_worker"] +items = ["plugin.worker"] [load] runtime = "python" @@ -983,7 +1028,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker"] +items = ["plugin.worker"] [load] runtime = "python" @@ -1117,7 +1162,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker"] +items = ["plugin.worker"] [load] runtime = "python" @@ -1152,7 +1197,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker", "plugin_worker"] +items = ["plugin.worker", "plugin.worker"] [load] runtime = "python" @@ -1190,7 +1235,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin_worker"] +items = ["plugin.worker"] [load] runtime = "python" From 913aac2aeba5a5544ec6236dc7705c4eef5f7096 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 08:20:10 -0700 Subject: [PATCH 02/10] refactor: tighten lifecycle refusal reporting Signed-off-by: Alex Fournier --- crates/cli/src/error.rs | 12 +++++- crates/cli/src/plugins/lifecycle.rs | 41 ++++++++++++++++--- crates/cli/src/plugins/lifecycle/responses.rs | 11 ++++- .../tests/coverage/plugins_lifecycle_tests.rs | 14 +++++++ 4 files changed, 68 insertions(+), 10 deletions(-) diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 0fd38e3d9..89db57452 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -43,6 +43,7 @@ pub(crate) enum CliError { command: &'static str, target: Option, kind: PluginLifecycleFailureKind, + code: Option<&'static str>, message: String, }, #[error("NeMo Relay runtime error: {0}")] @@ -62,14 +63,21 @@ impl CliError { pub(crate) fn plugin_lifecycle( &self, - ) -> Option<(&'static str, Option<&str>, PluginLifecycleFailureKind, &str)> { + ) -> Option<( + &'static str, + Option<&str>, + PluginLifecycleFailureKind, + Option<&'static str>, + &str, + )> { match self { Self::PluginLifecycle { command, target, kind, + code, message, - } => Some((command, target.as_deref(), *kind, message.as_str())), + } => Some((command, target.as_deref(), *kind, *code, message.as_str())), _ => None, } } diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index e2c5b01b2..b5b00ba86 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -49,6 +49,8 @@ use self::target::PluginTarget; const VALIDATION_MESSAGE: &str = "validated by CLI"; pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), CliError> { + const COMMAND: &str = "plugins add"; + let resolved = resolve_plugins_config(server.config.as_ref())?; let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; let (manifest, manifest_ref) = load_manifest_for_action("add", &command.path)?; @@ -77,16 +79,18 @@ pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); if !policy.policy_satisfied { - return Err(plugin_refused( - "plugins add", + return Err(plugin_refused_with_code( + COMMAND, Some(plugin_id.clone()), + "policy_blocked", policy.refusal_message(&plugin_id), )); } if let Some(message) = trust.message.as_ref() { - return Err(plugin_refused( - "plugins add", + return Err(plugin_refused_with_code( + COMMAND, Some(plugin_id.clone()), + trust_refusal_code(&trust), message.clone(), )); } @@ -991,7 +995,7 @@ pub(crate) fn render_plugin_error( error: &CliError, json: bool, ) -> Result, CliError> { - let Some((command, target, kind, message)) = error.plugin_lifecycle() else { + let Some((command, target, kind, code, message)) = error.plugin_lifecycle() else { return Ok(None); }; @@ -1002,7 +1006,7 @@ pub(crate) fn render_plugin_error( }; if json { - print_response_json(&failure(command, target, kind, message))?; + print_response_json(&failure(command, target, kind, code, message))?; } else { eprintln!("{message}"); } @@ -1027,6 +1031,7 @@ fn plugin_not_found( command, target, kind: PluginLifecycleFailureKind::NotFound, + code: None, message: message.into(), } } @@ -1035,15 +1040,39 @@ fn plugin_refused( command: &'static str, target: Option, message: impl Into, +) -> CliError { + plugin_refused_with_code(command, target, "refused", message) +} + +fn plugin_refused_with_code( + command: &'static str, + target: Option, + code: &'static str, + message: impl Into, ) -> CliError { CliError::PluginLifecycle { command, target, kind: PluginLifecycleFailureKind::Refused, + code: Some(code), message: message.into(), } } +fn trust_refusal_code(trust: &EvaluatedDynamicPluginTrust) -> &'static str { + match trust { + EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + .. + } => "integrity_failed", + EvaluatedDynamicPluginTrust { + authenticity: DynamicPluginCheckState::Invalid, + .. + } => "attestation_failed", + _ => "refused", + } +} + struct PluginListView<'a> { records: &'a [ScopedDynamicPluginRecord], host_config_by_id: &'a HashMap, diff --git a/crates/cli/src/plugins/lifecycle/responses.rs b/crates/cli/src/plugins/lifecycle/responses.rs index d1cee8660..b229fac89 100644 --- a/crates/cli/src/plugins/lifecycle/responses.rs +++ b/crates/cli/src/plugins/lifecycle/responses.rs @@ -288,6 +288,7 @@ pub(super) fn failure( command: &'static str, target: Option<&str>, kind: PluginLifecycleFailureKind, + code: Option<&'static str>, message: &str, ) -> ResponseEnvelope { ResponseEnvelope { @@ -298,7 +299,7 @@ pub(super) fn failure( warnings: Vec::new(), data: None, error: Some(ResponseError { - code: failure_code(kind), + code: code.unwrap_or_else(|| failure_code(kind)), kind, message: message.to_owned(), details: Map::new(), @@ -311,7 +312,13 @@ pub(super) fn generic_failure( target: Option<&str>, message: &str, ) -> ResponseEnvelope { - failure(command, target, PluginLifecycleFailureKind::Failed, message) + failure( + command, + target, + PluginLifecycleFailureKind::Failed, + None, + message, + ) } fn success( diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index f3ffdf597..1bbfcacb0 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -1410,11 +1410,25 @@ fn json_helpers_emit_stable_success_and_failure_shapes() { "plugins inspect", Some("missing.plugin"), PluginLifecycleFailureKind::NotFound, + None, "missing plugin", )) .unwrap(); assert_eq!(failure["ok"], serde_json::json!(false)); assert_eq!(failure["error"]["code"], serde_json::json!("not_found")); + + let refused = serde_json::to_value(responses::failure( + "plugins add", + Some("acme.blocked"), + PluginLifecycleFailureKind::Refused, + Some("policy_blocked"), + "blocked by host policy", + )) + .unwrap(); + assert_eq!( + refused["error"]["code"], + serde_json::json!("policy_blocked") + ); } #[test] From 6eef3fab647e85d3e3fff83a7d2ecd3f97a96dde Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 08:42:50 -0700 Subject: [PATCH 03/10] refactor: extract dynamic plugin trust evaluation Signed-off-by: Alex Fournier --- crates/cli/src/error.rs | 18 +- crates/cli/src/plugins/lifecycle.rs | 299 +---------------- crates/cli/src/plugins/lifecycle/responses.rs | 5 +- crates/cli/src/plugins/lifecycle/trust.rs | 305 ++++++++++++++++++ 4 files changed, 319 insertions(+), 308 deletions(-) create mode 100644 crates/cli/src/plugins/lifecycle/trust.rs diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 89db57452..35e7887f6 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -18,6 +18,14 @@ pub(crate) enum PluginLifecycleFailureKind { Refused, } +pub(crate) type PluginLifecycleErrorContext<'a> = ( + &'static str, + Option<&'a str>, + PluginLifecycleFailureKind, + Option<&'static str>, + &'a str, +); + #[derive(Debug, thiserror::Error)] pub(crate) enum CliError { #[error("guardrail rejected: {0}")] @@ -61,15 +69,7 @@ impl CliError { } } - pub(crate) fn plugin_lifecycle( - &self, - ) -> Option<( - &'static str, - Option<&str>, - PluginLifecycleFailureKind, - Option<&'static str>, - &str, - )> { + pub(crate) fn plugin_lifecycle(&self) -> Option> { match self { Self::PluginLifecycle { command, diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index b5b00ba86..8a0bee1e9 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -3,20 +3,15 @@ use std::collections::{BTreeSet, HashMap}; use std::fmt; -use std::fs; use std::path::Path; use std::path::PathBuf; use std::process::ExitCode; -use base64::Engine; use nemo_relay::plugin::dynamic::{ - DynamicPluginAttestationMode, DynamicPluginCheckState, DynamicPluginCompatibility, - DynamicPluginFailure, DynamicPluginFailurePhase, DynamicPluginLoadContract, + DynamicPluginCheckState, DynamicPluginCompatibility, DynamicPluginLoadContract, DynamicPluginManifest, DynamicPluginRecord, DynamicPluginValidationStatus, }; -use ring::signature::{ED25519, UnparsedPublicKey}; use serde_json::Value; -use sha2::{Digest, Sha256}; use crate::config::{ PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand, @@ -35,6 +30,7 @@ use super::config_io::{ mod responses; mod state; mod target; +mod trust; use self::responses::{ ValidateResponseInput, failure, generic_failure, inspect_data, inspect_success, list_success, @@ -45,6 +41,7 @@ use self::state::{ load_scoped_registries, scoped_paths_for_add, }; use self::target::PluginTarget; +use self::trust::{EvaluatedDynamicPluginTrust, evaluate_dynamic_plugin_trust}; const VALIDATION_MESSAGE: &str = "validated by CLI"; @@ -701,296 +698,6 @@ fn required_startup_failure( None } -#[derive(Debug)] -struct EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState, - authenticity: DynamicPluginCheckState, - message: Option, -} - -impl EvaluatedDynamicPluginTrust { - fn last_error( - &self, - attestation_mode: DynamicPluginAttestationMode, - ) -> Option { - self.message.as_ref().map(|message| DynamicPluginFailure { - phase: DynamicPluginFailurePhase::Validation, - code: match attestation_mode { - DynamicPluginAttestationMode::IntegrityOnly => "integrity_verification_failed", - DynamicPluginAttestationMode::SignatureIfPresent - | DynamicPluginAttestationMode::SignatureRequired => { - "attestation_verification_failed" - } - } - .into(), - message: message.clone(), - }) - } -} - -fn evaluate_dynamic_plugin_trust( - manifest: &DynamicPluginManifest, - manifest_ref: &str, - policy: &EvaluatedDynamicPluginHostPolicy, -) -> EvaluatedDynamicPluginTrust { - let Some(artifact) = manifest - .source - .as_ref() - .and_then(|source| source.artifact.as_deref()) - else { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - authenticity: DynamicPluginCheckState::Unknown, - message: Some(format!( - "dynamic plugin '{}' is missing source.artifact required for integrity verification", - manifest.plugin.id - )), - }; - }; - - let Some(expected_digest) = manifest - .integrity - .as_ref() - .and_then(|integrity| integrity.sha256.as_deref()) - else { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - authenticity: DynamicPluginCheckState::Unknown, - message: Some(format!( - "dynamic plugin '{}' is missing integrity.sha256 required for host trust verification", - manifest.plugin.id - )), - }; - }; - - let artifact_path = resolve_artifact_path(manifest_ref, artifact); - let actual_digest = match file_sha256(&artifact_path) { - Ok(digest) => digest, - Err(error) => { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - authenticity: DynamicPluginCheckState::Unknown, - message: Some(format!( - "dynamic plugin '{}' artifact {} could not be read for integrity verification: {}", - manifest.plugin.id, - artifact_path.display(), - error - )), - }; - } - }; - - if actual_digest != expected_digest.trim() { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - authenticity: DynamicPluginCheckState::Unknown, - message: Some(format!( - "dynamic plugin '{}' failed integrity verification for {}: expected {}, got {}", - manifest.plugin.id, - artifact_path.display(), - expected_digest.trim(), - actual_digest - )), - }; - } - - evaluate_authenticity(manifest, manifest_ref, artifact_path.as_path(), policy) -} - -fn evaluate_authenticity( - manifest: &DynamicPluginManifest, - manifest_ref: &str, - artifact_path: &Path, - policy: &EvaluatedDynamicPluginHostPolicy, -) -> EvaluatedDynamicPluginTrust { - let signature_ref = manifest - .integrity - .as_ref() - .and_then(|integrity| integrity.signature.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()); - - match policy.attestation_mode { - DynamicPluginAttestationMode::IntegrityOnly => EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Unknown, - message: None, - }, - DynamicPluginAttestationMode::SignatureIfPresent => match signature_ref { - Some(signature_ref) => verify_signature( - manifest, - manifest_ref, - artifact_path, - signature_ref, - &policy.trusted_public_keys, - ), - None => EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Unknown, - message: None, - }, - }, - DynamicPluginAttestationMode::SignatureRequired => match signature_ref { - Some(signature_ref) => verify_signature( - manifest, - manifest_ref, - artifact_path, - signature_ref, - &policy.trusted_public_keys, - ), - None => EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' requires integrity.signature under host policy", - manifest.plugin.id - )), - }, - }, - } -} - -fn verify_signature( - manifest: &DynamicPluginManifest, - manifest_ref: &str, - artifact_path: &Path, - signature_ref: &str, - trusted_public_keys: &[String], -) -> EvaluatedDynamicPluginTrust { - if trusted_public_keys.is_empty() { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' requires signature verification, but no trusted_public_keys are configured in host policy", - manifest.plugin.id - )), - }; - } - - let signature_path = resolve_artifact_path(manifest_ref, signature_ref); - let signature_bytes = match read_signature_bytes(&signature_path) { - Ok(signature_bytes) => signature_bytes, - Err(error) => { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' signature {} could not be read: {}", - manifest.plugin.id, - signature_path.display(), - error - )), - }; - } - }; - - let artifact_bytes = match fs::read(artifact_path) { - Ok(artifact_bytes) => artifact_bytes, - Err(error) => { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' artifact {} could not be read for signature verification: {}", - manifest.plugin.id, - artifact_path.display(), - error - )), - }; - } - }; - - let mut parse_errors = Vec::new(); - for trusted_public_key in trusted_public_keys { - let public_key_bytes = match parse_ed25519_public_key(trusted_public_key) { - Ok(public_key_bytes) => public_key_bytes, - Err(error) => { - parse_errors.push(error); - continue; - } - }; - - let verifier = UnparsedPublicKey::new(&ED25519, public_key_bytes); - if verifier.verify(&artifact_bytes, &signature_bytes).is_ok() { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Valid, - message: None, - }; - } - } - - let parse_error_suffix = if parse_errors.is_empty() { - String::new() - } else { - format!("; key parse errors: {}", parse_errors.join("; ")) - }; - - EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' failed signature verification for {} against configured host policy keys{}", - manifest.plugin.id, - signature_path.display(), - parse_error_suffix - )), - } -} - -fn read_signature_bytes(path: &Path) -> Result, String> { - let raw = fs::read(path).map_err(|error| error.to_string())?; - let trimmed = String::from_utf8_lossy(&raw).trim().to_owned(); - if trimmed.is_empty() { - return Err("signature file is empty".into()); - } - - let encoded = trimmed - .strip_prefix("ed25519:") - .unwrap_or(trimmed.as_str()) - .trim(); - base64::engine::general_purpose::STANDARD - .decode(encoded) - .map_err(|error| format!("invalid base64 signature: {error}")) -} - -fn parse_ed25519_public_key(value: &str) -> Result, String> { - let encoded = value - .trim() - .strip_prefix("ed25519:") - .ok_or_else(|| format!("unsupported trusted public key format '{value}'"))?; - base64::engine::general_purpose::STANDARD - .decode(encoded.trim()) - .map_err(|error| format!("invalid ed25519 trusted public key '{value}': {error}")) -} - -fn resolve_artifact_path(manifest_ref: &str, artifact_ref: &str) -> PathBuf { - let artifact_path = PathBuf::from(artifact_ref); - if artifact_path.is_absolute() { - artifact_path - } else { - Path::new(manifest_ref) - .parent() - .map(|parent| parent.join(&artifact_path)) - .unwrap_or(artifact_path) - } -} - -fn file_sha256(path: &Path) -> Result { - let bytes = fs::read(path)?; - let mut digest = Sha256::new(); - digest.update(&bytes); - Ok(format!( - "sha256:{}", - digest - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::() - )) -} - pub(crate) fn render_plugin_error( error: &CliError, json: bool, diff --git a/crates/cli/src/plugins/lifecycle/responses.rs b/crates/cli/src/plugins/lifecycle/responses.rs index b229fac89..430ab00d1 100644 --- a/crates/cli/src/plugins/lifecycle/responses.rs +++ b/crates/cli/src/plugins/lifecycle/responses.rs @@ -22,9 +22,8 @@ use crate::error::{CliError, PluginLifecycleFailureKind}; use crate::plugins::policy::EvaluatedDynamicPluginHostPolicy; use super::state::ScopedDynamicPluginRecord; -use super::{ - EvaluatedDynamicPluginTrust, inspect_compat_data, inspect_load_data, redacted_host_config_json, -}; +use super::trust::EvaluatedDynamicPluginTrust; +use super::{inspect_compat_data, inspect_load_data, redacted_host_config_json}; #[derive(Debug)] pub(super) struct ValidateResponseInput<'a> { diff --git a/crates/cli/src/plugins/lifecycle/trust.rs b/crates/cli/src/plugins/lifecycle/trust.rs new file mode 100644 index 000000000..2e968f6e3 --- /dev/null +++ b/crates/cli/src/plugins/lifecycle/trust.rs @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::path::{Path, PathBuf}; + +use base64::Engine; +use nemo_relay::plugin::dynamic::{ + DynamicPluginAttestationMode, DynamicPluginCheckState, DynamicPluginFailure, + DynamicPluginFailurePhase, DynamicPluginManifest, +}; +use ring::signature::{ED25519, UnparsedPublicKey}; +use sha2::{Digest, Sha256}; + +use crate::plugins::policy::EvaluatedDynamicPluginHostPolicy; + +#[derive(Debug)] +pub(super) struct EvaluatedDynamicPluginTrust { + pub(super) integrity: DynamicPluginCheckState, + pub(super) authenticity: DynamicPluginCheckState, + pub(super) message: Option, +} + +impl EvaluatedDynamicPluginTrust { + pub(super) fn last_error( + &self, + attestation_mode: DynamicPluginAttestationMode, + ) -> Option { + self.message.as_ref().map(|message| DynamicPluginFailure { + phase: DynamicPluginFailurePhase::Validation, + code: match attestation_mode { + DynamicPluginAttestationMode::IntegrityOnly => "integrity_verification_failed", + DynamicPluginAttestationMode::SignatureIfPresent + | DynamicPluginAttestationMode::SignatureRequired => { + "attestation_verification_failed" + } + } + .into(), + message: message.clone(), + }) + } +} + +pub(super) fn evaluate_dynamic_plugin_trust( + manifest: &DynamicPluginManifest, + manifest_ref: &str, + policy: &EvaluatedDynamicPluginHostPolicy, +) -> EvaluatedDynamicPluginTrust { + let Some(artifact) = manifest + .source + .as_ref() + .and_then(|source| source.artifact.as_deref()) + else { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + message: Some(format!( + "dynamic plugin '{}' is missing source.artifact required for integrity verification", + manifest.plugin.id + )), + }; + }; + + let Some(expected_digest) = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.sha256.as_deref()) + else { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + message: Some(format!( + "dynamic plugin '{}' is missing integrity.sha256 required for host trust verification", + manifest.plugin.id + )), + }; + }; + + let artifact_path = resolve_artifact_path(manifest_ref, artifact); + let actual_digest = match file_sha256(&artifact_path) { + Ok(digest) => digest, + Err(error) => { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + message: Some(format!( + "dynamic plugin '{}' artifact {} could not be read for integrity verification: {}", + manifest.plugin.id, + artifact_path.display(), + error + )), + }; + } + }; + + if actual_digest != expected_digest.trim() { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + message: Some(format!( + "dynamic plugin '{}' failed integrity verification for {}: expected {}, got {}", + manifest.plugin.id, + artifact_path.display(), + expected_digest.trim(), + actual_digest + )), + }; + } + + evaluate_authenticity(manifest, manifest_ref, artifact_path.as_path(), policy) +} + +fn evaluate_authenticity( + manifest: &DynamicPluginManifest, + manifest_ref: &str, + artifact_path: &Path, + policy: &EvaluatedDynamicPluginHostPolicy, +) -> EvaluatedDynamicPluginTrust { + let signature_ref = manifest + .integrity + .as_ref() + .and_then(|integrity| integrity.signature.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + match policy.attestation_mode { + DynamicPluginAttestationMode::IntegrityOnly => EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Unknown, + message: None, + }, + DynamicPluginAttestationMode::SignatureIfPresent => match signature_ref { + Some(signature_ref) => verify_signature( + manifest, + manifest_ref, + artifact_path, + signature_ref, + &policy.trusted_public_keys, + ), + None => EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Unknown, + message: None, + }, + }, + DynamicPluginAttestationMode::SignatureRequired => match signature_ref { + Some(signature_ref) => verify_signature( + manifest, + manifest_ref, + artifact_path, + signature_ref, + &policy.trusted_public_keys, + ), + None => EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' requires integrity.signature under host policy", + manifest.plugin.id + )), + }, + }, + } +} + +fn verify_signature( + manifest: &DynamicPluginManifest, + manifest_ref: &str, + artifact_path: &Path, + signature_ref: &str, + trusted_public_keys: &[String], +) -> EvaluatedDynamicPluginTrust { + if trusted_public_keys.is_empty() { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' requires signature verification, but no trusted_public_keys are configured in host policy", + manifest.plugin.id + )), + }; + } + + let signature_path = resolve_artifact_path(manifest_ref, signature_ref); + let signature_bytes = match read_signature_bytes(&signature_path) { + Ok(signature_bytes) => signature_bytes, + Err(error) => { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' signature {} could not be read: {}", + manifest.plugin.id, + signature_path.display(), + error + )), + }; + } + }; + + let artifact_bytes = match fs::read(artifact_path) { + Ok(artifact_bytes) => artifact_bytes, + Err(error) => { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' artifact {} could not be read for signature verification: {}", + manifest.plugin.id, + artifact_path.display(), + error + )), + }; + } + }; + + let mut parse_errors = Vec::new(); + for trusted_public_key in trusted_public_keys { + let public_key_bytes = match parse_ed25519_public_key(trusted_public_key) { + Ok(public_key_bytes) => public_key_bytes, + Err(error) => { + parse_errors.push(error); + continue; + } + }; + + let verifier = UnparsedPublicKey::new(&ED25519, public_key_bytes); + if verifier.verify(&artifact_bytes, &signature_bytes).is_ok() { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Valid, + message: None, + }; + } + } + + let parse_error_suffix = if parse_errors.is_empty() { + String::new() + } else { + format!("; key parse errors: {}", parse_errors.join("; ")) + }; + + EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Valid, + authenticity: DynamicPluginCheckState::Invalid, + message: Some(format!( + "dynamic plugin '{}' failed signature verification for {} against configured host policy keys{}", + manifest.plugin.id, + signature_path.display(), + parse_error_suffix + )), + } +} + +fn read_signature_bytes(path: &Path) -> Result, String> { + let raw = fs::read(path).map_err(|error| error.to_string())?; + let trimmed = String::from_utf8_lossy(&raw).trim().to_owned(); + if trimmed.is_empty() { + return Err("signature file is empty".into()); + } + + let encoded = trimmed + .strip_prefix("ed25519:") + .unwrap_or(trimmed.as_str()) + .trim(); + base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|error| format!("invalid base64 signature: {error}")) +} + +fn parse_ed25519_public_key(value: &str) -> Result, String> { + let encoded = value + .trim() + .strip_prefix("ed25519:") + .ok_or_else(|| format!("unsupported trusted public key format '{value}'"))?; + base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|error| format!("invalid ed25519 trusted public key '{value}': {error}")) +} + +fn resolve_artifact_path(manifest_ref: &str, artifact_ref: &str) -> PathBuf { + let artifact_path = PathBuf::from(artifact_ref); + if artifact_path.is_absolute() { + artifact_path + } else { + Path::new(manifest_ref) + .parent() + .map(|parent| parent.join(&artifact_path)) + .unwrap_or(artifact_path) + } +} + +fn file_sha256(path: &Path) -> Result { + let bytes = fs::read(path)?; + let mut digest = Sha256::new(); + digest.update(&bytes); + Ok(format!( + "sha256:{}", + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} From e78b7c90bc3db1f098b3fec720260f09d9220d18 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 08:59:28 -0700 Subject: [PATCH 04/10] refactor: move plugin policy and trust formatting to boundaries Signed-off-by: Alex Fournier --- crates/cli/src/plugins/lifecycle.rs | 62 +-- crates/cli/src/plugins/lifecycle/responses.rs | 18 +- crates/cli/src/plugins/lifecycle/trust.rs | 434 +++++++++++------- crates/cli/src/plugins/policy.rs | 67 ++- crates/cli/tests/coverage/config_tests.rs | 6 +- 5 files changed, 375 insertions(+), 212 deletions(-) diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 8a0bee1e9..1c9a2720a 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -80,15 +80,20 @@ pub(crate) fn add(command: PluginsAddCommand, server: &ServerArgs) -> Result<(), COMMAND, Some(plugin_id.clone()), "policy_blocked", - policy.refusal_message(&plugin_id), + policy + .failure() + .map(|failure| failure.display(&plugin_id).to_string()) + .unwrap_or_else(|| { + format!("dynamic plugin '{}' is blocked by host policy", plugin_id) + }), )); } - if let Some(message) = trust.message.as_ref() { + if let Some(failure) = trust.failure() { return Err(plugin_refused_with_code( COMMAND, Some(plugin_id.clone()), trust_refusal_code(&trust), - message.clone(), + failure.display(&plugin_id).to_string(), )); } let record = validated_record_from_manifest(manifest, manifest_ref.clone(), &policy, &trust)?; @@ -379,15 +384,20 @@ fn mutate_enabled_state( return Err(plugin_refused( command, Some(plugin_id.clone()), - policy.refusal_message(&plugin_id), + policy + .failure() + .map(|failure| failure.display(&plugin_id).to_string()) + .unwrap_or_else(|| { + format!("dynamic plugin '{}' is blocked by host policy", plugin_id) + }), )); } - if enabled && let Some(message) = trust.message.as_ref() { + if enabled && let Some(failure) = trust.failure() { scopes[entry.scope_index].save()?; return Err(plugin_refused( command, Some(plugin_id.clone()), - message.clone(), + failure.display(&plugin_id).to_string(), )); } if enabled { @@ -484,8 +494,8 @@ fn validated_record_from_manifest( record.status.startup_class = Some(policy.startup_class); record.status.attestation_mode = Some(policy.attestation_mode); record.status.last_error = policy - .last_error() - .or_else(|| trust.last_error(policy.attestation_mode)); + .last_error(&record.metadata.id) + .or_else(|| trust.last_error(&record.metadata.id, policy.attestation_mode)); Ok(record) } @@ -510,7 +520,7 @@ fn update_registry_policy_status( policy.check_state(), policy.startup_class, policy.attestation_mode, - policy.last_error(), + policy.last_error(plugin_id), ) .map_err(|error| CliError::Config(error.to_string())) } @@ -543,8 +553,8 @@ fn update_registry_validation_status( .update_last_error( plugin_id, policy - .last_error() - .or_else(|| trust.last_error(policy.attestation_mode)), + .last_error(plugin_id) + .or_else(|| trust.last_error(plugin_id, policy.attestation_mode)), ) .map_err(|error| CliError::Config(error.to_string())) } @@ -767,17 +777,7 @@ fn plugin_refused_with_code( } fn trust_refusal_code(trust: &EvaluatedDynamicPluginTrust) -> &'static str { - match trust { - EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - .. - } => "integrity_failed", - EvaluatedDynamicPluginTrust { - authenticity: DynamicPluginCheckState::Invalid, - .. - } => "attestation_failed", - _ => "refused", - } + trust.refusal_code().unwrap_or("refused") } struct PluginListView<'a> { @@ -930,7 +930,7 @@ struct PluginValidationSummaryView<'a> { impl fmt::Display for PluginValidationSummaryView<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.policy.policy_satisfied && self.trust.message.is_none() { + if self.policy.policy_satisfied && self.trust.is_satisfied() { writeln!(f, "Dynamic plugin '{}' is valid.", self.manifest.plugin.id)?; } else if self.policy.policy_satisfied { writeln!( @@ -963,11 +963,19 @@ impl fmt::Display for PluginValidationSummaryView<'_> { )?; writeln!(f, "startup_class: {}", self.policy.startup_class)?; writeln!(f, "attestation_mode: {}", self.policy.attestation_mode)?; - if let Some(message) = &self.policy.message { - writeln!(f, "policy_error: {message}")?; + if let Some(failure) = self.policy.failure() { + writeln!( + f, + "policy_error: {}", + failure.display(&self.manifest.plugin.id) + )?; } - if let Some(message) = &self.trust.message { - writeln!(f, "trust_error: {message}")?; + if let Some(failure) = self.trust.failure() { + writeln!( + f, + "trust_error: {}", + failure.display(&self.manifest.plugin.id) + )?; } if let Some(entry) = self.entry { writeln!(f, "manifest: {}", self.manifest_ref)?; diff --git a/crates/cli/src/plugins/lifecycle/responses.rs b/crates/cli/src/plugins/lifecycle/responses.rs index 430ab00d1..32c29a8e2 100644 --- a/crates/cli/src/plugins/lifecycle/responses.rs +++ b/crates/cli/src/plugins/lifecycle/responses.rs @@ -245,13 +245,21 @@ pub(super) fn validate_success( .and_then(|entry| entry.record.status.validation.message.clone()) .into_iter() .collect::>(); - let valid = input.policy.policy_satisfied && input.trust.message.is_none(); + let valid = input.policy.policy_satisfied && input.trust.is_satisfied(); let errors = input .policy - .message - .iter() - .chain(input.trust.message.iter()) - .cloned() + .failure() + .map(|failure| { + failure + .display(input.manifest.plugin.id.as_str()) + .to_string() + }) + .into_iter() + .chain(input.trust.failure().map(|failure| { + failure + .display(input.manifest.plugin.id.as_str()) + .to_string() + })) .collect::>(); success( diff --git a/crates/cli/src/plugins/lifecycle/trust.rs b/crates/cli/src/plugins/lifecycle/trust.rs index 2e968f6e3..43a209bda 100644 --- a/crates/cli/src/plugins/lifecycle/trust.rs +++ b/crates/cli/src/plugins/lifecycle/trust.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +use std::fmt; use std::fs; use std::path::{Path, PathBuf}; @@ -14,19 +15,186 @@ use sha2::{Digest, Sha256}; use crate::plugins::policy::EvaluatedDynamicPluginHostPolicy; -#[derive(Debug)] +type TrustResult = Result; + +#[derive(Debug, Clone)] +pub(super) enum DynamicPluginTrustFailure { + MissingArtifact, + MissingIntegrityDigest, + ArtifactRead { + path: PathBuf, + error: String, + }, + IntegrityMismatch { + path: PathBuf, + expected: String, + actual: String, + }, + MissingSignature, + MissingTrustedKeys, + SignatureRead { + path: PathBuf, + error: String, + }, + InvalidTrustedKey { + key: String, + error: String, + }, + SignatureVerification { + path: PathBuf, + parse_errors: Vec, + }, +} + +impl DynamicPluginTrustFailure { + pub(super) fn display<'a>( + &'a self, + plugin_id: &'a str, + ) -> DynamicPluginTrustFailureDisplay<'a> { + DynamicPluginTrustFailureDisplay { + failure: self, + plugin_id, + } + } + + pub(super) fn refusal_code(&self) -> &'static str { + match self { + Self::MissingArtifact + | Self::MissingIntegrityDigest + | Self::ArtifactRead { .. } + | Self::IntegrityMismatch { .. } => "integrity_failed", + Self::MissingSignature + | Self::MissingTrustedKeys + | Self::SignatureRead { .. } + | Self::InvalidTrustedKey { .. } + | Self::SignatureVerification { .. } => "attestation_failed", + } + } +} + +pub(super) struct DynamicPluginTrustFailureDisplay<'a> { + failure: &'a DynamicPluginTrustFailure, + plugin_id: &'a str, +} + +impl fmt::Display for DynamicPluginTrustFailureDisplay<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.failure { + DynamicPluginTrustFailure::MissingArtifact => write!( + f, + "dynamic plugin '{}' is missing source.artifact required for integrity verification", + self.plugin_id + ), + DynamicPluginTrustFailure::MissingIntegrityDigest => write!( + f, + "dynamic plugin '{}' is missing integrity.sha256 required for host trust verification", + self.plugin_id + ), + DynamicPluginTrustFailure::ArtifactRead { path, error } => write!( + f, + "dynamic plugin '{}' artifact {} could not be read for trust verification: {}", + self.plugin_id, + path.display(), + error + ), + DynamicPluginTrustFailure::IntegrityMismatch { + path, + expected, + actual, + } => write!( + f, + "dynamic plugin '{}' failed integrity verification for {}: expected {}, got {}", + self.plugin_id, + path.display(), + expected, + actual + ), + DynamicPluginTrustFailure::MissingSignature => write!( + f, + "dynamic plugin '{}' requires integrity.signature under host policy", + self.plugin_id + ), + DynamicPluginTrustFailure::MissingTrustedKeys => write!( + f, + "dynamic plugin '{}' requires signature verification, but no trusted_public_keys are configured in host policy", + self.plugin_id + ), + DynamicPluginTrustFailure::SignatureRead { path, error } => write!( + f, + "dynamic plugin '{}' signature {} could not be read: {}", + self.plugin_id, + path.display(), + error + ), + DynamicPluginTrustFailure::InvalidTrustedKey { key, error } => write!( + f, + "dynamic plugin '{}' has invalid trusted public key '{}': {}", + self.plugin_id, key, error + ), + DynamicPluginTrustFailure::SignatureVerification { path, parse_errors } => { + write!( + f, + "dynamic plugin '{}' failed signature verification for {} against configured host policy keys", + self.plugin_id, + path.display() + )?; + if !parse_errors.is_empty() { + write!(f, "; key parse errors: {}", parse_errors.join("; "))?; + } + Ok(()) + } + } + } +} + +#[derive(Debug, Clone)] pub(super) struct EvaluatedDynamicPluginTrust { pub(super) integrity: DynamicPluginCheckState, pub(super) authenticity: DynamicPluginCheckState, - pub(super) message: Option, + pub(super) failure: Option, } impl EvaluatedDynamicPluginTrust { + fn valid(authenticity: DynamicPluginCheckState) -> Self { + Self { + integrity: DynamicPluginCheckState::Valid, + authenticity, + failure: None, + } + } + + fn failed( + integrity: DynamicPluginCheckState, + authenticity: DynamicPluginCheckState, + failure: DynamicPluginTrustFailure, + ) -> Self { + Self { + integrity, + authenticity, + failure: Some(failure), + } + } + + pub(super) fn failure(&self) -> Option<&DynamicPluginTrustFailure> { + self.failure.as_ref() + } + + pub(super) fn refusal_code(&self) -> Option<&'static str> { + self.failure + .as_ref() + .map(DynamicPluginTrustFailure::refusal_code) + } + + pub(super) fn is_satisfied(&self) -> bool { + self.failure.is_none() + } + pub(super) fn last_error( &self, + plugin_id: &str, attestation_mode: DynamicPluginAttestationMode, ) -> Option { - self.message.as_ref().map(|message| DynamicPluginFailure { + self.failure.as_ref().map(|failure| DynamicPluginFailure { phase: DynamicPluginFailurePhase::Validation, code: match attestation_mode { DynamicPluginAttestationMode::IntegrityOnly => "integrity_verification_failed", @@ -36,7 +204,7 @@ impl EvaluatedDynamicPluginTrust { } } .into(), - message: message.clone(), + message: failure.display(plugin_id).to_string(), }) } } @@ -46,68 +214,57 @@ pub(super) fn evaluate_dynamic_plugin_trust( manifest_ref: &str, policy: &EvaluatedDynamicPluginHostPolicy, ) -> EvaluatedDynamicPluginTrust { - let Some(artifact) = manifest + let artifact_path = match verify_integrity(manifest, manifest_ref) { + Ok(artifact_path) => artifact_path, + Err(failure) => { + return EvaluatedDynamicPluginTrust::failed( + DynamicPluginCheckState::Invalid, + DynamicPluginCheckState::Unknown, + failure, + ); + } + }; + + match evaluate_authenticity(manifest, manifest_ref, artifact_path.as_path(), policy) { + Ok(authenticity) => EvaluatedDynamicPluginTrust::valid(authenticity), + Err(failure) => EvaluatedDynamicPluginTrust::failed( + DynamicPluginCheckState::Valid, + DynamicPluginCheckState::Invalid, + failure, + ), + } +} + +fn verify_integrity(manifest: &DynamicPluginManifest, manifest_ref: &str) -> TrustResult { + let artifact = manifest .source .as_ref() .and_then(|source| source.artifact.as_deref()) - else { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - authenticity: DynamicPluginCheckState::Unknown, - message: Some(format!( - "dynamic plugin '{}' is missing source.artifact required for integrity verification", - manifest.plugin.id - )), - }; - }; - - let Some(expected_digest) = manifest + .ok_or(DynamicPluginTrustFailure::MissingArtifact)?; + let expected_digest = manifest .integrity .as_ref() .and_then(|integrity| integrity.sha256.as_deref()) - else { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - authenticity: DynamicPluginCheckState::Unknown, - message: Some(format!( - "dynamic plugin '{}' is missing integrity.sha256 required for host trust verification", - manifest.plugin.id - )), - }; - }; + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(DynamicPluginTrustFailure::MissingIntegrityDigest)?; let artifact_path = resolve_artifact_path(manifest_ref, artifact); - let actual_digest = match file_sha256(&artifact_path) { - Ok(digest) => digest, - Err(error) => { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - authenticity: DynamicPluginCheckState::Unknown, - message: Some(format!( - "dynamic plugin '{}' artifact {} could not be read for integrity verification: {}", - manifest.plugin.id, - artifact_path.display(), - error - )), - }; - } - }; + let actual_digest = + file_sha256(&artifact_path).map_err(|error| DynamicPluginTrustFailure::ArtifactRead { + path: artifact_path.clone(), + error: error.to_string(), + })?; - if actual_digest != expected_digest.trim() { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Invalid, - authenticity: DynamicPluginCheckState::Unknown, - message: Some(format!( - "dynamic plugin '{}' failed integrity verification for {}: expected {}, got {}", - manifest.plugin.id, - artifact_path.display(), - expected_digest.trim(), - actual_digest - )), - }; + if actual_digest != expected_digest { + return Err(DynamicPluginTrustFailure::IntegrityMismatch { + path: artifact_path, + expected: expected_digest.to_owned(), + actual: actual_digest, + }); } - evaluate_authenticity(manifest, manifest_ref, artifact_path.as_path(), policy) + Ok(artifact_path) } fn evaluate_authenticity( @@ -115,7 +272,7 @@ fn evaluate_authenticity( manifest_ref: &str, artifact_path: &Path, policy: &EvaluatedDynamicPluginHostPolicy, -) -> EvaluatedDynamicPluginTrust { +) -> TrustResult { let signature_ref = manifest .integrity .as_ref() @@ -124,139 +281,86 @@ fn evaluate_authenticity( .filter(|value| !value.is_empty()); match policy.attestation_mode { - DynamicPluginAttestationMode::IntegrityOnly => EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Unknown, - message: None, - }, + DynamicPluginAttestationMode::IntegrityOnly => Ok(DynamicPluginCheckState::Unknown), DynamicPluginAttestationMode::SignatureIfPresent => match signature_ref { - Some(signature_ref) => verify_signature( - manifest, - manifest_ref, - artifact_path, - signature_ref, - &policy.trusted_public_keys, - ), - None => EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Unknown, - message: None, - }, + Some(signature_ref) => { + verify_signature( + manifest_ref, + artifact_path, + signature_ref, + &policy.trusted_public_keys, + )?; + Ok(DynamicPluginCheckState::Valid) + } + None => Ok(DynamicPluginCheckState::Unknown), }, DynamicPluginAttestationMode::SignatureRequired => match signature_ref { - Some(signature_ref) => verify_signature( - manifest, - manifest_ref, - artifact_path, - signature_ref, - &policy.trusted_public_keys, - ), - None => EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' requires integrity.signature under host policy", - manifest.plugin.id - )), - }, + Some(signature_ref) => { + verify_signature( + manifest_ref, + artifact_path, + signature_ref, + &policy.trusted_public_keys, + )?; + Ok(DynamicPluginCheckState::Valid) + } + None => Err(DynamicPluginTrustFailure::MissingSignature), }, } } fn verify_signature( - manifest: &DynamicPluginManifest, manifest_ref: &str, artifact_path: &Path, signature_ref: &str, trusted_public_keys: &[String], -) -> EvaluatedDynamicPluginTrust { +) -> TrustResult<()> { if trusted_public_keys.is_empty() { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' requires signature verification, but no trusted_public_keys are configured in host policy", - manifest.plugin.id - )), - }; + return Err(DynamicPluginTrustFailure::MissingTrustedKeys); } let signature_path = resolve_artifact_path(manifest_ref, signature_ref); - let signature_bytes = match read_signature_bytes(&signature_path) { - Ok(signature_bytes) => signature_bytes, - Err(error) => { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' signature {} could not be read: {}", - manifest.plugin.id, - signature_path.display(), - error - )), - }; - } - }; - - let artifact_bytes = match fs::read(artifact_path) { - Ok(artifact_bytes) => artifact_bytes, - Err(error) => { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' artifact {} could not be read for signature verification: {}", - manifest.plugin.id, - artifact_path.display(), - error - )), - }; - } - }; + let signature_bytes = read_signature_bytes(&signature_path)?; + let artifact_bytes = + fs::read(artifact_path).map_err(|error| DynamicPluginTrustFailure::ArtifactRead { + path: artifact_path.to_path_buf(), + error: error.to_string(), + })?; let mut parse_errors = Vec::new(); for trusted_public_key in trusted_public_keys { let public_key_bytes = match parse_ed25519_public_key(trusted_public_key) { Ok(public_key_bytes) => public_key_bytes, - Err(error) => { + Err(DynamicPluginTrustFailure::InvalidTrustedKey { key: _, error }) => { parse_errors.push(error); continue; } + Err(other) => return Err(other), }; let verifier = UnparsedPublicKey::new(&ED25519, public_key_bytes); if verifier.verify(&artifact_bytes, &signature_bytes).is_ok() { - return EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Valid, - message: None, - }; + return Ok(()); } } - let parse_error_suffix = if parse_errors.is_empty() { - String::new() - } else { - format!("; key parse errors: {}", parse_errors.join("; ")) - }; - - EvaluatedDynamicPluginTrust { - integrity: DynamicPluginCheckState::Valid, - authenticity: DynamicPluginCheckState::Invalid, - message: Some(format!( - "dynamic plugin '{}' failed signature verification for {} against configured host policy keys{}", - manifest.plugin.id, - signature_path.display(), - parse_error_suffix - )), - } + Err(DynamicPluginTrustFailure::SignatureVerification { + path: signature_path, + parse_errors, + }) } -fn read_signature_bytes(path: &Path) -> Result, String> { - let raw = fs::read(path).map_err(|error| error.to_string())?; +fn read_signature_bytes(path: &Path) -> TrustResult> { + let raw = fs::read(path).map_err(|error| DynamicPluginTrustFailure::SignatureRead { + path: path.to_path_buf(), + error: error.to_string(), + })?; let trimmed = String::from_utf8_lossy(&raw).trim().to_owned(); if trimmed.is_empty() { - return Err("signature file is empty".into()); + return Err(DynamicPluginTrustFailure::SignatureRead { + path: path.to_path_buf(), + error: "signature file is empty".into(), + }); } let encoded = trimmed @@ -265,17 +369,25 @@ fn read_signature_bytes(path: &Path) -> Result, String> { .trim(); base64::engine::general_purpose::STANDARD .decode(encoded) - .map_err(|error| format!("invalid base64 signature: {error}")) + .map_err(|error| DynamicPluginTrustFailure::SignatureRead { + path: path.to_path_buf(), + error: format!("invalid base64 signature: {error}"), + }) } -fn parse_ed25519_public_key(value: &str) -> Result, String> { - let encoded = value - .trim() - .strip_prefix("ed25519:") - .ok_or_else(|| format!("unsupported trusted public key format '{value}'"))?; +fn parse_ed25519_public_key(value: &str) -> TrustResult> { + let encoded = value.trim().strip_prefix("ed25519:").ok_or_else(|| { + DynamicPluginTrustFailure::InvalidTrustedKey { + key: value.to_owned(), + error: format!("unsupported trusted public key format '{value}'"), + } + })?; base64::engine::general_purpose::STANDARD .decode(encoded.trim()) - .map_err(|error| format!("invalid ed25519 trusted public key '{value}': {error}")) + .map_err(|error| DynamicPluginTrustFailure::InvalidTrustedKey { + key: value.to_owned(), + error: format!("invalid ed25519 trusted public key '{value}': {error}"), + }) } fn resolve_artifact_path(manifest_ref: &str, artifact_ref: &str) -> PathBuf { diff --git a/crates/cli/src/plugins/policy.rs b/crates/cli/src/plugins/policy.rs index cd0656cd7..0ba9c9995 100644 --- a/crates/cli/src/plugins/policy.rs +++ b/crates/cli/src/plugins/policy.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use std::collections::BTreeMap; +use std::fmt; use nemo_relay::plugin::dynamic::{ DynamicPluginAttestationMode, DynamicPluginCapability, DynamicPluginCheckState, @@ -64,13 +65,53 @@ pub(crate) struct DynamicPluginHostPolicyRule { pub(crate) effect: DynamicPluginHostPolicyEffect, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DynamicPluginHostPolicyFailure { + Blocked, + CapabilityNotAllowed(DynamicPluginCapability), +} + +impl DynamicPluginHostPolicyFailure { + pub(crate) fn display<'a>( + &'a self, + plugin_id: &'a str, + ) -> DynamicPluginHostPolicyFailureDisplay<'a> { + DynamicPluginHostPolicyFailureDisplay { + failure: self, + plugin_id, + } + } +} + +pub(crate) struct DynamicPluginHostPolicyFailureDisplay<'a> { + failure: &'a DynamicPluginHostPolicyFailure, + plugin_id: &'a str, +} + +impl fmt::Display for DynamicPluginHostPolicyFailureDisplay<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.failure { + DynamicPluginHostPolicyFailure::Blocked => write!( + f, + "dynamic plugin '{}' is blocked by host policy", + self.plugin_id + ), + DynamicPluginHostPolicyFailure::CapabilityNotAllowed(capability) => write!( + f, + "dynamic plugin '{}' is blocked by host policy: capability '{}' is not allowed", + self.plugin_id, capability + ), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct EvaluatedDynamicPluginHostPolicy { pub(crate) policy_satisfied: bool, pub(crate) startup_class: DynamicPluginStartupClass, pub(crate) attestation_mode: DynamicPluginAttestationMode, pub(crate) trusted_public_keys: Vec, - pub(crate) message: Option, + pub(crate) failure: Option, } impl EvaluatedDynamicPluginHostPolicy { @@ -82,18 +123,16 @@ impl EvaluatedDynamicPluginHostPolicy { } } - pub(crate) fn last_error(&self) -> Option { - self.message.as_ref().map(|message| DynamicPluginFailure { + pub(crate) fn last_error(&self, plugin_id: &str) -> Option { + self.failure.as_ref().map(|failure| DynamicPluginFailure { phase: DynamicPluginFailurePhase::Policy, code: "policy_blocked".into(), - message: message.clone(), + message: failure.display(plugin_id).to_string(), }) } - pub(crate) fn refusal_message(&self, plugin_id: &str) -> String { - self.message - .clone() - .unwrap_or_else(|| format!("dynamic plugin '{}' is blocked by host policy", plugin_id)) + pub(crate) fn failure(&self) -> Option<&DynamicPluginHostPolicyFailure> { + self.failure.as_ref() } } @@ -135,10 +174,7 @@ pub(crate) fn evaluate_dynamic_plugin_host_policy( startup_class, attestation_mode, trusted_public_keys, - message: Some(format!( - "dynamic plugin '{}' is blocked by host policy", - manifest.plugin.id - )), + failure: Some(DynamicPluginHostPolicyFailure::Blocked), }; } @@ -154,9 +190,8 @@ pub(crate) fn evaluate_dynamic_plugin_host_policy( startup_class, attestation_mode, trusted_public_keys, - message: Some(format!( - "dynamic plugin '{}' is blocked by host policy: capability '{}' is not allowed", - manifest.plugin.id, blocked_capability + failure: Some(DynamicPluginHostPolicyFailure::CapabilityNotAllowed( + *blocked_capability, )), }; } @@ -166,7 +201,7 @@ pub(crate) fn evaluate_dynamic_plugin_host_policy( startup_class, attestation_mode, trusted_public_keys, - message: None, + failure: None, } } diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index 18b218f5b..c06b8178d 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -921,8 +921,8 @@ fn dynamic_plugin_host_policy_evaluator_applies_rules_before_plugin_overrides() ); assert!( evaluated - .message - .as_deref() + .failure() + .map(|failure| failure.display(manifest.plugin.id.as_str()).to_string()) .unwrap() .contains("blocked by host policy") ); @@ -973,7 +973,7 @@ fn dynamic_plugin_host_policy_evaluator_matches_functional_surface_capabilities( evaluated.attestation_mode, DynamicPluginAttestationMode::IntegrityOnly ); - assert!(evaluated.message.is_none()); + assert!(evaluated.failure().is_none()); } #[test] From ed4f8b37571bf89a7c43b47ba838d9d0eaaf6fcb Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 09:03:40 -0700 Subject: [PATCH 05/10] refactor: clarify dynamic plugin policy merge naming Signed-off-by: Alex Fournier --- crates/cli/src/config.rs | 2 +- crates/cli/src/plugins/policy.rs | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index effbbcd19..118202745 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1099,7 +1099,7 @@ where let resolved_plugins = resolve_dynamic_plugin_refs(path, &mut parsed, &mut seen_plugin_ids)?; dynamic_plugins.extend(resolved_plugins.dynamic_plugins); - dynamic_plugin_policy.merge(resolved_plugins.dynamic_plugin_policy); + dynamic_plugin_policy.merge_from(resolved_plugins.dynamic_plugin_policy); runtime_documents.push(( path.clone(), serde_json::to_value(remove_dynamic_plugin_sections(parsed)) diff --git a/crates/cli/src/plugins/policy.rs b/crates/cli/src/plugins/policy.rs index 0ba9c9995..498586fb5 100644 --- a/crates/cli/src/plugins/policy.rs +++ b/crates/cli/src/plugins/policy.rs @@ -19,11 +19,14 @@ pub(crate) struct DynamicPluginHostPolicy { } impl DynamicPluginHostPolicy { - pub(crate) fn merge(&mut self, other: Self) { - self.defaults.merge(other.defaults); + pub(crate) fn merge_from(&mut self, other: Self) { + self.defaults.merge_from(other.defaults); self.rules.extend(other.rules); for (plugin_id, effect) in other.overrides { - self.overrides.entry(plugin_id).or_default().merge(effect); + self.overrides + .entry(plugin_id) + .or_default() + .merge_from(effect); } } } @@ -38,7 +41,7 @@ pub(crate) struct DynamicPluginHostPolicyEffect { } impl DynamicPluginHostPolicyEffect { - fn merge(&mut self, other: Self) { + fn merge_from(&mut self, other: Self) { if let Some(value) = other.allowed { self.allowed = Some(value); } @@ -147,17 +150,17 @@ pub(crate) fn evaluate_dynamic_plugin_host_policy( allowed_capabilities: None, trusted_public_keys: None, }; - effect.merge(policy.defaults.clone()); + effect.merge_from(policy.defaults.clone()); for rule in &policy.rules { if !policy_rule_matches(rule, manifest) { continue; } - effect.merge(rule.effect.clone()); + effect.merge_from(rule.effect.clone()); } if let Some(override_effect) = policy.overrides.get(manifest.plugin.id.trim()) { - effect.merge(override_effect.clone()); + effect.merge_from(override_effect.clone()); } let startup_class = effect From 075cda53f4982fb6aa22bc84ef56feae2fed3809 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 09:27:56 -0700 Subject: [PATCH 06/10] refactor: clarify plugin lifecycle error context accessor Signed-off-by: Alex Fournier --- crates/cli/src/error.rs | 4 +++- crates/cli/src/plugins/lifecycle.rs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index 35e7887f6..ba019ae76 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -69,7 +69,9 @@ impl CliError { } } - pub(crate) fn plugin_lifecycle(&self) -> Option> { + pub(crate) fn as_plugin_lifecycle_error_context( + &self, + ) -> Option> { match self { Self::PluginLifecycle { command, diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 1c9a2720a..10edbf979 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -712,7 +712,8 @@ pub(crate) fn render_plugin_error( error: &CliError, json: bool, ) -> Result, CliError> { - let Some((command, target, kind, code, message)) = error.plugin_lifecycle() else { + let Some((command, target, kind, code, message)) = error.as_plugin_lifecycle_error_context() + else { return Ok(None); }; From 38efcab4364db1282666501ad6310f00f17d58e1 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 10:04:08 -0700 Subject: [PATCH 07/10] fix: persist hydrated plugin policy state Signed-off-by: Alex Fournier --- crates/cli/src/plugins/lifecycle.rs | 28 ++++++- crates/cli/src/plugins/lifecycle/responses.rs | 6 +- crates/cli/tests/cli_tests.rs | 74 +++++++++++++++++++ .../tests/coverage/plugins_lifecycle_tests.rs | 62 ++++++++++++++++ 4 files changed, 165 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 10edbf979..a65cc8e2f 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -425,7 +425,11 @@ fn load_and_hydrate_scopes( explicit: Option<&PathBuf>, resolved: &ResolvedConfig, ) -> Result, CliError> { - Ok(load_and_hydrate_scopes_with_updates(explicit, resolved)?.0) + let (scopes, touched_scope_indices) = load_and_hydrate_scopes_with_updates(explicit, resolved)?; + for scope_index in touched_scope_indices { + scopes[scope_index].save()?; + } + Ok(scopes) } fn load_and_hydrate_scopes_with_updates( @@ -781,6 +785,24 @@ fn trust_refusal_code(trust: &EvaluatedDynamicPluginTrust) -> &'static str { trust.refusal_code().unwrap_or("refused") } +fn list_validation_state(record: &DynamicPluginRecord) -> DynamicPluginCheckState { + let validation = &record.status.validation; + if validation.manifest == DynamicPluginCheckState::Invalid + || validation.compatibility == DynamicPluginCheckState::Invalid + || validation.integrity == DynamicPluginCheckState::Invalid + || validation.authenticity == DynamicPluginCheckState::Invalid + || validation.policy_satisfied == DynamicPluginCheckState::Invalid + { + DynamicPluginCheckState::Invalid + } else if validation.manifest == DynamicPluginCheckState::Unknown + || validation.compatibility == DynamicPluginCheckState::Unknown + { + DynamicPluginCheckState::Unknown + } else { + DynamicPluginCheckState::Valid + } +} + struct PluginListView<'a> { records: &'a [ScopedDynamicPluginRecord], host_config_by_id: &'a HashMap, @@ -808,7 +830,7 @@ impl fmt::Display for PluginListView<'_> { )?; for entry in self.records { let scope: &'static str = entry.scope.into(); - let validation: &'static str = entry.record.status.validation.manifest.into(); + let validation: &'static str = list_validation_state(&entry.record).into(); let policy: &'static str = entry.record.status.validation.policy_satisfied.into(); write!( f, @@ -876,7 +898,7 @@ impl PluginListWidths { validation: column_width( "VALIDATION", records.iter().map(|entry| { - let validation: &'static str = entry.record.status.validation.manifest.into(); + let validation: &'static str = list_validation_state(&entry.record).into(); validation }), ), diff --git a/crates/cli/src/plugins/lifecycle/responses.rs b/crates/cli/src/plugins/lifecycle/responses.rs index 32c29a8e2..37f8ebfda 100644 --- a/crates/cli/src/plugins/lifecycle/responses.rs +++ b/crates/cli/src/plugins/lifecycle/responses.rs @@ -23,7 +23,9 @@ use crate::plugins::policy::EvaluatedDynamicPluginHostPolicy; use super::state::ScopedDynamicPluginRecord; use super::trust::EvaluatedDynamicPluginTrust; -use super::{inspect_compat_data, inspect_load_data, redacted_host_config_json}; +use super::{ + inspect_compat_data, inspect_load_data, list_validation_state, redacted_host_config_json, +}; #[derive(Debug)] pub(super) struct ValidateResponseInput<'a> { @@ -152,7 +154,7 @@ pub(super) fn list_success( kind: record.metadata.kind, enabled: record.spec.enabled, tombstoned: record.is_tombstoned(), - validation_state: record.status.validation.manifest, + validation_state: list_validation_state(record), policy_state: record.status.validation.policy_satisfied, runtime_state: if record.is_tombstoned() { "tombstoned".into() diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index c9207d460..6f7f4991c 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -593,10 +593,84 @@ fn cli_plugins_list_json_reports_blocked_policy_for_installed_plugin() { let parsed: serde_json::Value = serde_json::from_slice(&list.stdout).unwrap(); assert_eq!(parsed["ok"], true); assert_eq!(parsed["data"][0]["id"], "acme.cli-blocked-list"); + assert_eq!(parsed["data"][0]["validation_state"], "invalid"); assert_eq!(parsed["data"][0]["policy_state"], "invalid"); assert_eq!(parsed["data"][0]["startup_class"], "required"); assert_eq!(parsed["data"][0]["attestation_mode"], "signature_required"); assert_eq!(parsed["data"][0]["last_error"]["phase"], "policy"); + + let state_path = config_dir.join(".dynamic-plugins.json"); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + assert_eq!( + state["records"][0]["status"]["validation"]["policy_satisfied"], + "invalid" + ); + assert_eq!( + state["records"][0]["status"]["last_error"]["phase"], + "policy" + ); +} + +#[test] +fn cli_plugins_list_json_reports_invalid_trust_in_validation_state() { + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("workdir"); + let plugin_dir = cwd.join("plugins").join("acme"); + let config_dir = cwd.join(".nemo-relay"); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + write_dynamic_plugin_manifest(&plugin_dir, "acme.cli-trust-list"); + + let add = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "add", "--project"]) + .arg(&plugin_dir) + .output() + .unwrap(); + assert!( + add.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&add.stderr) + ); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {}\n\n", + "[plugins.policy.defaults]\n", + "startup = \"required\"\n", + "attestation = \"signature_required\"\n" + ), + toml_basic_string(plugin_dir.to_string_lossy().as_ref()) + ), + ) + .unwrap(); + + let list = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "list", "--json"]) + .output() + .unwrap(); + + assert!( + list.status.success(), + "stderr was:\n{}", + String::from_utf8_lossy(&list.stderr) + ); + let parsed: serde_json::Value = serde_json::from_slice(&list.stdout).unwrap(); + assert_eq!(parsed["ok"], true); + assert_eq!(parsed["data"][0]["id"], "acme.cli-trust-list"); + assert_eq!(parsed["data"][0]["validation_state"], "invalid"); + assert_eq!(parsed["data"][0]["policy_state"], "valid"); + assert_eq!(parsed["data"][0]["attestation_mode"], "signature_required"); + assert_eq!(parsed["data"][0]["last_error"]["phase"], "validation"); } #[test] diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index 1bbfcacb0..571fbf07c 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -819,6 +819,68 @@ fn hydrate_applies_host_policy_status_to_discovered_dynamic_plugins() { ); } +#[test] +fn hydrate_persists_updated_policy_and_error_state() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + let config_dir = temp.path().join(".nemo-relay"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + write_dynamic_manifest_with_capabilities( + &plugin_dir, + "acme.persist-blocked", + &["plugin.worker", "middleware.guardrail"], + ); + + add( + PluginsAddCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + path: plugin_dir.clone(), + }, + &ServerArgs::default(), + ) + .unwrap(); + + std::fs::write( + config_dir.join("plugins.toml"), + format!( + concat!( + "[[plugins.dynamic]]\n", + "manifest = {:?}\n\n", + "[plugins.policy.defaults]\n", + "allowed_capabilities = [\"config.schema\"]\n" + ), + plugin_dir.join("relay-plugin.toml").to_string_lossy() + ), + ) + .unwrap(); + + let resolved = resolve_plugins_config(None).unwrap(); + let _ = load_and_hydrate_scopes(None, &resolved).unwrap(); + + let state_path = config_dir.join(".dynamic-plugins.json"); + let state: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap(); + let record = &state["records"][0]; + assert_eq!( + record["metadata"]["id"], + serde_json::json!("acme.persist-blocked") + ); + assert_eq!( + record["status"]["validation"]["policy_satisfied"], + serde_json::json!("invalid") + ); + assert_eq!( + record["status"]["last_error"]["phase"], + serde_json::json!("policy") + ); +} + #[test] fn hydrate_verifies_signatures_when_host_policy_provides_trusted_keys() { let temp = tempfile::tempdir().unwrap(); From b94fcd56d8edba1f9af1d166c39035ebcd8af01e Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 11:27:16 -0700 Subject: [PATCH 08/10] fix: narrow dynamic plugin policy capability scope Signed-off-by: Alex Fournier --- crates/cli/src/plugins/lifecycle.rs | 33 +++---- crates/cli/src/plugins/lifecycle/trust.rs | 23 +++-- crates/cli/src/plugins/policy.rs | 46 +--------- crates/cli/tests/cli_tests.rs | 70 ++------------- crates/cli/tests/coverage/config_tests.rs | 89 ++++--------------- .../tests/coverage/plugins_lifecycle_tests.rs | 81 ++++------------- crates/core/src/plugin/dynamic.rs | 20 +---- crates/core/src/plugin/dynamic/manifest.rs | 8 +- .../core/tests/unit/plugin_dynamic_tests.rs | 40 ++++----- 9 files changed, 86 insertions(+), 324 deletions(-) diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index a65cc8e2f..265f46b4a 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -197,23 +197,12 @@ pub(crate) fn validate( let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); - scopes[entry.scope_index] - .registry - .update_validation_status( - &plugin_id, - DynamicPluginValidationStatus { - manifest: DynamicPluginCheckState::Valid, - compatibility: DynamicPluginCheckState::Valid, - integrity: trust.integrity, - environment: DynamicPluginCheckState::Unknown, - authenticity: trust.authenticity, - policy_satisfied: policy.check_state(), - checked_at: None, - message: Some(VALIDATION_MESSAGE.into()), - }, - ) - .map_err(|error| CliError::Config(error.to_string()))?; - update_registry_policy_status(&mut scopes[entry.scope_index], &plugin_id, &policy)?; + update_registry_validation_status( + &mut scopes[entry.scope_index], + &plugin_id, + &policy, + &trust, + )?; scopes[entry.scope_index].save()?; let refreshed = find_record_by_id(&scopes, &plugin_id)? .expect("validated registry record should still exist"); @@ -381,9 +370,10 @@ fn mutate_enabled_state( update_registry_validation_status(&mut scopes[entry.scope_index], &plugin_id, &policy, &trust)?; if enabled && !policy.policy_satisfied { scopes[entry.scope_index].save()?; - return Err(plugin_refused( + return Err(plugin_refused_with_code( command, Some(plugin_id.clone()), + "policy_blocked", policy .failure() .map(|failure| failure.display(&plugin_id).to_string()) @@ -394,9 +384,10 @@ fn mutate_enabled_state( } if enabled && let Some(failure) = trust.failure() { scopes[entry.scope_index].save()?; - return Err(plugin_refused( + return Err(plugin_refused_with_code( command, Some(plugin_id.clone()), + trust_refusal_code(&trust), failure.display(&plugin_id).to_string(), )); } @@ -499,7 +490,7 @@ fn validated_record_from_manifest( record.status.attestation_mode = Some(policy.attestation_mode); record.status.last_error = policy .last_error(&record.metadata.id) - .or_else(|| trust.last_error(&record.metadata.id, policy.attestation_mode)); + .or_else(|| trust.last_error(&record.metadata.id)); Ok(record) } @@ -558,7 +549,7 @@ fn update_registry_validation_status( plugin_id, policy .last_error(plugin_id) - .or_else(|| trust.last_error(plugin_id, policy.attestation_mode)), + .or_else(|| trust.last_error(plugin_id)), ) .map_err(|error| CliError::Config(error.to_string())) } diff --git a/crates/cli/src/plugins/lifecycle/trust.rs b/crates/cli/src/plugins/lifecycle/trust.rs index 43a209bda..b3c6b225f 100644 --- a/crates/cli/src/plugins/lifecycle/trust.rs +++ b/crates/cli/src/plugins/lifecycle/trust.rs @@ -189,21 +189,10 @@ impl EvaluatedDynamicPluginTrust { self.failure.is_none() } - pub(super) fn last_error( - &self, - plugin_id: &str, - attestation_mode: DynamicPluginAttestationMode, - ) -> Option { + pub(super) fn last_error(&self, plugin_id: &str) -> Option { self.failure.as_ref().map(|failure| DynamicPluginFailure { phase: DynamicPluginFailurePhase::Validation, - code: match attestation_mode { - DynamicPluginAttestationMode::IntegrityOnly => "integrity_verification_failed", - DynamicPluginAttestationMode::SignatureIfPresent - | DynamicPluginAttestationMode::SignatureRequired => { - "attestation_verification_failed" - } - } - .into(), + code: failure.refusal_code().into(), message: failure.display(plugin_id).to_string(), }) } @@ -214,6 +203,14 @@ pub(super) fn evaluate_dynamic_plugin_trust( manifest_ref: &str, policy: &EvaluatedDynamicPluginHostPolicy, ) -> EvaluatedDynamicPluginTrust { + if !policy.policy_satisfied { + return EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Unknown, + authenticity: DynamicPluginCheckState::Unknown, + failure: None, + }; + } + let artifact_path = match verify_integrity(manifest, manifest_ref) { Ok(artifact_path) => artifact_path, Err(failure) => { diff --git a/crates/cli/src/plugins/policy.rs b/crates/cli/src/plugins/policy.rs index 498586fb5..4a43d38e9 100644 --- a/crates/cli/src/plugins/policy.rs +++ b/crates/cli/src/plugins/policy.rs @@ -5,9 +5,8 @@ use std::collections::BTreeMap; use std::fmt; use nemo_relay::plugin::dynamic::{ - DynamicPluginAttestationMode, DynamicPluginCapability, DynamicPluginCheckState, - DynamicPluginFailure, DynamicPluginFailurePhase, DynamicPluginKind, DynamicPluginManifest, - DynamicPluginStartupClass, + DynamicPluginAttestationMode, DynamicPluginCheckState, DynamicPluginFailure, + DynamicPluginFailurePhase, DynamicPluginKind, DynamicPluginManifest, DynamicPluginStartupClass, }; use serde::Deserialize; @@ -36,7 +35,6 @@ pub(crate) struct DynamicPluginHostPolicyEffect { pub(crate) allowed: Option, pub(crate) startup: Option, pub(crate) attestation: Option, - pub(crate) allowed_capabilities: Option>, pub(crate) trusted_public_keys: Option>, } @@ -51,9 +49,6 @@ impl DynamicPluginHostPolicyEffect { if let Some(value) = other.attestation { self.attestation = Some(value); } - if let Some(value) = other.allowed_capabilities { - self.allowed_capabilities = Some(value); - } if let Some(value) = other.trusted_public_keys { self.trusted_public_keys = Some(value); } @@ -63,7 +58,6 @@ impl DynamicPluginHostPolicyEffect { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct DynamicPluginHostPolicyRule { pub(crate) match_kind: Option, - pub(crate) match_capability: Option, pub(crate) match_plugin_id: Option, pub(crate) effect: DynamicPluginHostPolicyEffect, } @@ -71,7 +65,6 @@ pub(crate) struct DynamicPluginHostPolicyRule { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum DynamicPluginHostPolicyFailure { Blocked, - CapabilityNotAllowed(DynamicPluginCapability), } impl DynamicPluginHostPolicyFailure { @@ -99,11 +92,6 @@ impl fmt::Display for DynamicPluginHostPolicyFailureDisplay<'_> { "dynamic plugin '{}' is blocked by host policy", self.plugin_id ), - DynamicPluginHostPolicyFailure::CapabilityNotAllowed(capability) => write!( - f, - "dynamic plugin '{}' is blocked by host policy: capability '{}' is not allowed", - self.plugin_id, capability - ), } } } @@ -147,7 +135,6 @@ pub(crate) fn evaluate_dynamic_plugin_host_policy( allowed: Some(true), startup: Some(DynamicPluginStartupClass::Optional), attestation: Some(DynamicPluginAttestationMode::IntegrityOnly), - allowed_capabilities: None, trusted_public_keys: None, }; effect.merge_from(policy.defaults.clone()); @@ -181,24 +168,6 @@ pub(crate) fn evaluate_dynamic_plugin_host_policy( }; } - if let Some(allowed_capabilities) = effect.allowed_capabilities - && let Some(blocked_capability) = manifest - .capabilities - .items - .iter() - .find(|capability| !allowed_capabilities.contains(capability)) - { - return EvaluatedDynamicPluginHostPolicy { - policy_satisfied: false, - startup_class, - attestation_mode, - trusted_public_keys, - failure: Some(DynamicPluginHostPolicyFailure::CapabilityNotAllowed( - *blocked_capability, - )), - }; - } - EvaluatedDynamicPluginHostPolicy { policy_satisfied: true, startup_class, @@ -217,11 +186,6 @@ fn policy_rule_matches( { return false; } - if let Some(match_capability) = rule.match_capability - && !manifest.capabilities.items.contains(&match_capability) - { - return false; - } if let Some(match_plugin_id) = &rule.match_plugin_id && manifest.plugin.id.trim() != match_plugin_id { @@ -261,7 +225,6 @@ pub(crate) struct FileDynamicPluginHostPolicyEffect { allowed: Option, startup: Option, attestation: Option, - allowed_capabilities: Option>, trusted_public_keys: Option>, } @@ -271,7 +234,6 @@ impl From for DynamicPluginHostPolicyEffect { allowed: value.allowed, startup: value.startup, attestation: value.attestation, - allowed_capabilities: value.allowed_capabilities, trusted_public_keys: value .trusted_public_keys .map(|keys| keys.into_iter().map(|key| key.trim().to_owned()).collect()), @@ -283,12 +245,10 @@ impl From for DynamicPluginHostPolicyEffect { #[serde(deny_unknown_fields)] pub(crate) struct FileDynamicPluginHostPolicyRule { match_kind: Option, - match_capability: Option, match_plugin_id: Option, allowed: Option, startup: Option, attestation: Option, - allowed_capabilities: Option>, trusted_public_keys: Option>, } @@ -296,13 +256,11 @@ impl From for DynamicPluginHostPolicyRule { fn from(value: FileDynamicPluginHostPolicyRule) -> Self { Self { match_kind: value.match_kind, - match_capability: value.match_capability, match_plugin_id: value.match_plugin_id.map(|value| value.trim().to_owned()), effect: DynamicPluginHostPolicyEffect { allowed: value.allowed, startup: value.startup, attestation: value.attestation, - allowed_capabilities: value.allowed_capabilities, trusted_public_keys: value .trusted_public_keys .map(|keys| keys.into_iter().map(|key| key.trim().to_owned()).collect()), diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 6f7f4991c..100c4721f 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -39,15 +39,7 @@ fn toml_basic_string(value: &str) -> String { } fn write_dynamic_plugin_manifest(dir: &std::path::Path, plugin_id: &str) { - write_dynamic_plugin_manifest_with_options(dir, plugin_id, &["plugin.worker"], None); -} - -fn write_dynamic_plugin_manifest_with_capabilities( - dir: &std::path::Path, - plugin_id: &str, - capabilities: &[&str], -) { - write_dynamic_plugin_manifest_with_options(dir, plugin_id, capabilities, None); + write_dynamic_plugin_manifest_with_options(dir, plugin_id, &["plugin_worker"], None); } fn write_dynamic_plugin_manifest_with_options( @@ -348,7 +340,7 @@ fn cli_plugins_validate_json_reports_blocked_policy_for_path_target() { user_config_dir.join("plugins.toml"), r#" [plugins.policy.defaults] -allowed_capabilities = ["config.schema"] +allowed = false "#, ) .unwrap(); @@ -382,54 +374,6 @@ allowed_capabilities = ["config.schema"] ); } -#[test] -fn cli_plugins_validate_json_reports_blocked_functional_surface_capability_for_path_target() { - let temp = tempfile::tempdir().unwrap(); - let plugin_dir = temp.path().join("plugins").join("acme"); - let xdg = temp.path().join("xdg"); - let user_config_dir = xdg.join("nemo-relay"); - std::fs::create_dir_all(&user_config_dir).unwrap(); - write_dynamic_plugin_manifest_with_capabilities( - &plugin_dir, - "acme.cli-guardrail-path", - &["plugin.worker", "middleware.guardrail"], - ); - std::fs::write( - user_config_dir.join("plugins.toml"), - r#" -[plugins.policy.defaults] -allowed_capabilities = ["plugin.worker"] -"#, - ) - .unwrap(); - - let output = Command::new(gateway_bin()) - .env("XDG_CONFIG_HOME", &xdg) - .env("HOME", temp.path()) - .args(["plugins", "validate"]) - .arg(&plugin_dir) - .arg("--json") - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr was:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(parsed["ok"], true); - assert_eq!(parsed["data"]["target_kind"], "path"); - assert_eq!(parsed["data"]["valid"], false); - assert_eq!(parsed["data"]["policy_state"], "invalid"); - assert!( - parsed["data"]["errors"][0] - .as_str() - .unwrap() - .contains("middleware.guardrail") - ); -} - #[test] fn cli_plugins_validate_json_reports_verified_signature_for_path_target() { let temp = tempfile::tempdir().unwrap(); @@ -440,7 +384,7 @@ fn cli_plugins_validate_json_reports_verified_signature_for_path_target() { write_dynamic_plugin_manifest_with_options( &plugin_dir, "acme.cli-signed-path", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); let trusted_public_key = write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); @@ -488,7 +432,7 @@ fn cli_plugins_validate_json_reports_invalid_signature_for_wrong_trusted_key() { write_dynamic_plugin_manifest_with_options( &plugin_dir, "acme.cli-signed-wrong-key", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); @@ -570,7 +514,7 @@ fn cli_plugins_list_json_reports_blocked_policy_for_installed_plugin() { "[plugins.policy.defaults]\n", "startup = \"required\"\n", "attestation = \"signature_required\"\n", - "allowed_capabilities = [\"config.schema\"]\n" + "allowed = false\n" ), toml_basic_string(plugin_dir.to_string_lossy().as_ref()) ), @@ -706,7 +650,7 @@ fn cli_plugins_validate_json_reports_blocked_policy_for_installed_id_target() { "[plugins.policy.defaults]\n", "startup = \"required\"\n", "attestation = \"signature_required\"\n", - "allowed_capabilities = [\"config.schema\"]\n" + "allowed = false\n" ), toml_basic_string(plugin_dir.to_string_lossy().as_ref()) ), @@ -825,7 +769,7 @@ fn cli_plugins_inspect_json_reports_blocked_policy_for_installed_plugin() { "[plugins.policy.defaults]\n", "startup = \"required\"\n", "attestation = \"signature_required\"\n", - "allowed_capabilities = [\"config.schema\"]\n" + "allowed = false\n" ), toml_basic_string(plugin_dir.to_string_lossy().as_ref()) ), diff --git a/crates/cli/tests/coverage/config_tests.rs b/crates/cli/tests/coverage/config_tests.rs index c06b8178d..35824947a 100644 --- a/crates/cli/tests/coverage/config_tests.rs +++ b/crates/cli/tests/coverage/config_tests.rs @@ -5,8 +5,8 @@ use super::*; use axum::http::HeaderValue; use base64::Engine; use nemo_relay::plugin::dynamic::{ - DynamicPluginAttestationMode, DynamicPluginCapability, DynamicPluginCheckState, - DynamicPluginKind, DynamicPluginManifest, DynamicPluginStartupClass, + DynamicPluginAttestationMode, DynamicPluginCheckState, DynamicPluginKind, + DynamicPluginManifest, DynamicPluginStartupClass, }; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; @@ -36,15 +36,7 @@ fn isolated_config_path(temp: &tempfile::TempDir) -> std::path::PathBuf { } fn write_dynamic_manifest(dir: &std::path::Path, plugin_id: &str) -> std::path::PathBuf { - write_dynamic_manifest_with_options(dir, plugin_id, &["plugin.worker"], None) -} - -fn write_dynamic_manifest_with_capabilities( - dir: &std::path::Path, - plugin_id: &str, - capabilities: &[&str], -) -> std::path::PathBuf { - write_dynamic_manifest_with_options(dir, plugin_id, capabilities, None) + write_dynamic_manifest_with_options(dir, plugin_id, &["plugin_worker"], None) } fn write_dynamic_manifest_with_options( @@ -812,7 +804,7 @@ attestation = "integrity_only" trusted_public_keys = ["ed25519:ZmFrZS1rZXk="] [[plugins.policy.rules]] -match_capability = "plugin.worker" +match_kind = "worker" startup = "required" [plugins.policy.overrides."acme.worker"] @@ -854,8 +846,8 @@ attestation = "signature_required" ); assert_eq!(resolved.dynamic_plugin_policy.rules.len(), 1); assert_eq!( - resolved.dynamic_plugin_policy.rules[0].match_capability, - Some(DynamicPluginCapability::PluginWorker) + resolved.dynamic_plugin_policy.rules[0].match_kind, + Some(DynamicPluginKind::Worker) ); assert_eq!( resolved.dynamic_plugin_policy.rules[0].effect.startup, @@ -883,18 +875,15 @@ fn dynamic_plugin_host_policy_evaluator_applies_rules_before_plugin_overrides() allowed: Some(true), startup: Some(DynamicPluginStartupClass::Optional), attestation: Some(DynamicPluginAttestationMode::IntegrityOnly), - allowed_capabilities: None, trusted_public_keys: None, }, rules: vec![DynamicPluginHostPolicyRule { match_kind: Some(DynamicPluginKind::Worker), - match_capability: Some(DynamicPluginCapability::PluginWorker), match_plugin_id: None, effect: DynamicPluginHostPolicyEffect { allowed: None, startup: Some(DynamicPluginStartupClass::Required), attestation: None, - allowed_capabilities: Some(vec![DynamicPluginCapability::PluginWorker]), trusted_public_keys: None, }, }], @@ -904,7 +893,6 @@ fn dynamic_plugin_host_policy_evaluator_applies_rules_before_plugin_overrides() allowed: Some(false), startup: None, attestation: Some(DynamicPluginAttestationMode::SignatureRequired), - allowed_capabilities: None, trusted_public_keys: None, }, )) @@ -928,54 +916,6 @@ fn dynamic_plugin_host_policy_evaluator_applies_rules_before_plugin_overrides() ); } -#[test] -fn dynamic_plugin_host_policy_evaluator_matches_functional_surface_capabilities() { - let temp = tempfile::tempdir().unwrap(); - let plugin_dir = temp.path().join("plugins/acme"); - std::fs::create_dir_all(&plugin_dir).unwrap(); - let manifest_path = write_dynamic_manifest_with_capabilities( - &plugin_dir, - "acme.guardrail", - &["plugin.worker", "middleware.guardrail"], - ); - let (manifest, _) = DynamicPluginManifest::load_from_path(&manifest_path).unwrap(); - let policy = DynamicPluginHostPolicy { - defaults: DynamicPluginHostPolicyEffect { - allowed: Some(true), - startup: Some(DynamicPluginStartupClass::Optional), - attestation: Some(DynamicPluginAttestationMode::IntegrityOnly), - allowed_capabilities: Some(vec![ - DynamicPluginCapability::PluginWorker, - DynamicPluginCapability::MiddlewareGuardrail, - ]), - trusted_public_keys: None, - }, - rules: vec![DynamicPluginHostPolicyRule { - match_kind: Some(DynamicPluginKind::Worker), - match_capability: Some(DynamicPluginCapability::MiddlewareGuardrail), - match_plugin_id: None, - effect: DynamicPluginHostPolicyEffect { - allowed: None, - startup: Some(DynamicPluginStartupClass::Required), - attestation: None, - allowed_capabilities: None, - trusted_public_keys: None, - }, - }], - overrides: Default::default(), - }; - - let evaluated = evaluate_dynamic_plugin_host_policy(&policy, &manifest); - - assert!(evaluated.policy_satisfied); - assert_eq!(evaluated.startup_class, DynamicPluginStartupClass::Required); - assert_eq!( - evaluated.attestation_mode, - DynamicPluginAttestationMode::IntegrityOnly - ); - assert!(evaluated.failure().is_none()); -} - #[test] fn plugins_toml_layers_dynamic_plugin_host_policy_across_sources() { let temp = tempfile::tempdir().unwrap(); @@ -989,7 +929,7 @@ startup = "required" [[plugins.policy.rules]] match_kind = "worker" -allowed_capabilities = ["plugin.worker"] +startup = "required" [plugins.policy.overrides."acme.worker"] attestation = "signature_if_present" @@ -1003,7 +943,7 @@ attestation = "signature_if_present" attestation = "signature_required" [[plugins.policy.rules]] -match_capability = "plugin.worker" +match_plugin_id = "acme.worker" allowed = false [plugins.policy.overrides."acme.worker"] @@ -1016,6 +956,7 @@ allowed = true .unwrap() .unwrap(); + assert_eq!(resolved.value, None); assert_eq!( resolved.dynamic_plugin_policy.defaults.startup, Some(DynamicPluginStartupClass::Required) @@ -1030,8 +971,10 @@ allowed = true Some(DynamicPluginKind::Worker) ); assert_eq!( - resolved.dynamic_plugin_policy.rules[1].match_capability, - Some(DynamicPluginCapability::PluginWorker) + resolved.dynamic_plugin_policy.rules[1] + .match_plugin_id + .as_deref(), + Some("acme.worker") ); let override_effect = resolved .dynamic_plugin_policy @@ -1527,7 +1470,7 @@ fn server_resolution_fails_when_required_enabled_dynamic_plugin_lacks_trusted_ke write_dynamic_manifest_with_options( &plugin_dir, "acme.worker", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); @@ -1595,7 +1538,7 @@ fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_wrong_truste write_dynamic_manifest_with_options( &plugin_dir, "acme.worker", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); @@ -1667,7 +1610,7 @@ fn server_resolution_fails_when_required_enabled_dynamic_plugin_has_malformed_si write_dynamic_manifest_with_options( &plugin_dir, "acme.worker", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); std::fs::write(plugin_dir.join("plugin.py.sig"), "ed25519:not-base64\n").unwrap(); diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index 571fbf07c..466a2c2a8 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -85,15 +85,7 @@ impl Drop for EnvScope { } fn write_dynamic_manifest(dir: &Path, plugin_id: &str) -> PathBuf { - write_dynamic_manifest_with_options(dir, plugin_id, &["plugin.worker"], None) -} - -fn write_dynamic_manifest_with_capabilities( - dir: &Path, - plugin_id: &str, - capabilities: &[&str], -) -> PathBuf { - write_dynamic_manifest_with_options(dir, plugin_id, capabilities, None) + write_dynamic_manifest_with_options(dir, plugin_id, &["plugin_worker"], None) } fn write_dynamic_manifest_with_options( @@ -254,46 +246,6 @@ fn add_rejects_duplicate_dynamic_plugin_ids() { assert!(error.contains("already registered")); } -#[test] -fn add_refuses_functional_surface_capabilities_blocked_by_host_policy() { - let temp = tempfile::tempdir().unwrap(); - let _env = EnvScope::hermetic(&temp); - let _cwd = CurrentDirGuard::enter(temp.path()); - let plugin_dir = temp.path().join("plugins").join("acme"); - let config_dir = temp.path().join(".nemo-relay"); - std::fs::create_dir_all(&plugin_dir).unwrap(); - std::fs::create_dir_all(&config_dir).unwrap(); - write_dynamic_manifest_with_capabilities( - &plugin_dir, - "acme.guardrail-surface", - &["plugin.worker", "middleware.guardrail"], - ); - std::fs::write( - config_dir.join("plugins.toml"), - r#" -[plugins.policy.defaults] -allowed_capabilities = ["plugin.worker"] -"#, - ) - .unwrap(); - - let error = add( - PluginsAddCommand { - scope: PluginsScopeArgs { - project: true, - ..PluginsScopeArgs::default() - }, - path: plugin_dir, - }, - &crate::config::ServerArgs::default(), - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("middleware.guardrail")); - assert!(error.contains("blocked by host policy")); -} - #[test] fn add_rejects_scope_flags_when_explicit_config_is_set() { let temp = tempfile::tempdir().unwrap(); @@ -338,7 +290,7 @@ fn add_refuses_dynamic_plugins_blocked_by_host_policy() { config_dir.join("plugins.toml"), r#" [plugins.policy.defaults] -allowed_capabilities = ["config.schema"] +allowed = false "#, ) .unwrap(); @@ -360,7 +312,7 @@ allowed_capabilities = ["config.schema"] kind: PluginLifecycleFailureKind::Refused, message, .. - } => assert!(message.contains("capability 'plugin.worker' is not allowed")), + } => assert!(message.contains("blocked by host policy")), other => panic!("unexpected policy add error: {other}"), } @@ -442,7 +394,10 @@ fn list_and_inspect_render_discovered_dynamic_plugins() { assert!(list.contains("acme.guardrail")); assert!(list.contains("absent")); assert!(list.contains("false")); - assert!(list.contains("valid")); + assert!( + list.lines() + .any(|line| line.contains("acme.guardrail") && line.contains(" valid ")) + ); let entry = find_record_by_id(&scopes, "acme.guardrail") .unwrap() @@ -828,11 +783,7 @@ fn hydrate_persists_updated_policy_and_error_state() { let config_dir = temp.path().join(".nemo-relay"); std::fs::create_dir_all(&plugin_dir).unwrap(); std::fs::create_dir_all(&config_dir).unwrap(); - write_dynamic_manifest_with_capabilities( - &plugin_dir, - "acme.persist-blocked", - &["plugin.worker", "middleware.guardrail"], - ); + write_dynamic_manifest(&plugin_dir, "acme.persist-blocked"); add( PluginsAddCommand { @@ -853,7 +804,7 @@ fn hydrate_persists_updated_policy_and_error_state() { "[[plugins.dynamic]]\n", "manifest = {:?}\n\n", "[plugins.policy.defaults]\n", - "allowed_capabilities = [\"config.schema\"]\n" + "allowed = false\n" ), plugin_dir.join("relay-plugin.toml").to_string_lossy() ), @@ -893,7 +844,7 @@ fn hydrate_verifies_signatures_when_host_policy_provides_trusted_keys() { let manifest_path = write_dynamic_manifest_with_options( &plugin_dir, "acme.signed", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); let trusted_public_key = write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); @@ -944,7 +895,7 @@ fn hydrate_marks_signature_required_plugins_invalid_without_trusted_keys() { let manifest_path = write_dynamic_manifest_with_options( &plugin_dir, "acme.signed-without-trust", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); @@ -997,7 +948,7 @@ fn hydrate_marks_signature_required_plugins_invalid_with_wrong_trusted_key() { let manifest_path = write_dynamic_manifest_with_options( &plugin_dir, "acme.signed-wrong-key", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); write_detached_ed25519_signature(&plugin_dir, "plugin.py.sig"); @@ -1053,7 +1004,7 @@ fn hydrate_marks_malformed_signature_files_invalid_when_signature_is_present() { let manifest_path = write_dynamic_manifest_with_options( &plugin_dir, "acme.signed-malformed", - &["plugin.worker"], + &["plugin_worker"], Some("plugin.py.sig"), ); std::fs::write(plugin_dir.join("plugin.py.sig"), "ed25519:not-base64\n").unwrap(); @@ -1128,7 +1079,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { "[[plugins.dynamic]]\n", "manifest = {:?}\n\n", "[plugins.policy.defaults]\n", - "allowed_capabilities = [\"config.schema\"]\n" + "allowed = false\n" ), manifest_path.to_string_lossy() ), @@ -1148,7 +1099,7 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { kind: PluginLifecycleFailureKind::Refused, message, .. - } => assert!(message.contains("capability 'plugin.worker' is not allowed")), + } => assert!(message.contains("blocked by host policy")), other => panic!("unexpected enable policy error: {other}"), } @@ -1206,7 +1157,7 @@ fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { "[plugins.policy.defaults]\n", "startup = \"required\"\n", "attestation = \"signature_required\"\n", - "allowed_capabilities = [\"config.schema\"]\n" + "allowed = false\n" ), manifest_path.to_string_lossy() ), diff --git a/crates/core/src/plugin/dynamic.rs b/crates/core/src/plugin/dynamic.rs index 9db3c9908..639714538 100644 --- a/crates/core/src/plugin/dynamic.rs +++ b/crates/core/src/plugin/dynamic.rs @@ -49,30 +49,14 @@ pub enum WorkerRuntime { /// Relay-enforced capability declared by a dynamic plugin. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Display)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum DynamicPluginCapability { /// Trusted in-process native extension capability. - #[serde(rename = "plugin.native")] - #[strum(serialize = "plugin.native")] PluginNative, /// Isolated worker-based extension capability. - #[serde(rename = "plugin.worker")] - #[strum(serialize = "plugin.worker")] PluginWorker, - /// Middleware guardrail registration capability. - #[serde(rename = "middleware.guardrail")] - #[strum(serialize = "middleware.guardrail")] - MiddlewareGuardrail, - /// Middleware interceptor registration capability. - #[serde(rename = "middleware.interceptor")] - #[strum(serialize = "middleware.interceptor")] - MiddlewareInterceptor, - /// Observability exporter registration capability. - #[serde(rename = "telemetry.exporter")] - #[strum(serialize = "telemetry.exporter")] - TelemetryExporter, /// Typed configuration schema contribution capability. - #[serde(rename = "config.schema")] - #[strum(serialize = "config.schema")] ConfigSchema, } diff --git a/crates/core/src/plugin/dynamic/manifest.rs b/crates/core/src/plugin/dynamic/manifest.rs index a85d0c311..f71bf8daf 100644 --- a/crates/core/src/plugin/dynamic/manifest.rs +++ b/crates/core/src/plugin/dynamic/manifest.rs @@ -465,26 +465,26 @@ fn validate_capability_shape( DynamicPluginKind::RustDynamic => { if !has_native { return Err(PluginError::InvalidConfig( - "rust_dynamic plugins must declare capabilities.items containing plugin.native" + "rust_dynamic plugins must declare capabilities.items containing plugin_native" .into(), )); } if has_worker { return Err(PluginError::InvalidConfig( - "rust_dynamic plugins must not declare plugin.worker".into(), + "rust_dynamic plugins must not declare plugin_worker".into(), )); } } DynamicPluginKind::Worker => { if !has_worker { return Err(PluginError::InvalidConfig( - "worker plugins must declare capabilities.items containing plugin.worker" + "worker plugins must declare capabilities.items containing plugin_worker" .into(), )); } if has_native { return Err(PluginError::InvalidConfig( - "worker plugins must not declare plugin.native".into(), + "worker plugins must not declare plugin_native".into(), )); } } diff --git a/crates/core/tests/unit/plugin_dynamic_tests.rs b/crates/core/tests/unit/plugin_dynamic_tests.rs index 1345dcb36..fde4b7ba5 100644 --- a/crates/core/tests/unit/plugin_dynamic_tests.rs +++ b/crates/core/tests/unit/plugin_dynamic_tests.rs @@ -484,7 +484,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin.worker", "config.schema"] +items = ["plugin_worker", "config_schema"] [load] runtime = "python" @@ -515,7 +515,7 @@ native_api = "1" enabled = false [capabilities] -items = ["plugin.native"] +items = ["plugin_native"] [load] library = "target/release/libswitchyard.dylib" @@ -576,7 +576,7 @@ fn manifest_parse_and_conversion_supports_worker_lane() { } #[test] -fn manifest_supports_extended_functional_surface_capabilities() { +fn manifest_supports_declared_capabilities() { let manifest = DynamicPluginManifest::parse_toml( r#" manifest_version = 1 @@ -594,11 +594,8 @@ enabled = false [capabilities] items = [ - "plugin.worker", - "middleware.guardrail", - "middleware.interceptor", - "telemetry.exporter", - "config.schema", + "plugin_worker", + "config_schema", ] [load] @@ -612,9 +609,6 @@ entrypoint = "acme_guardrails.plugin:register" manifest.capabilities.items, vec![ DynamicPluginCapability::PluginWorker, - DynamicPluginCapability::MiddlewareGuardrail, - DynamicPluginCapability::MiddlewareInterceptor, - DynamicPluginCapability::TelemetryExporter, DynamicPluginCapability::ConfigSchema, ] ); @@ -638,7 +632,7 @@ worker_protocol = " 1 " enabled = false [capabilities] -items = ["plugin.worker"] +items = ["plugin_worker"] [load] runtime = "python" @@ -708,7 +702,7 @@ relay = ">=0.1.0,<0.2.0" enabled = false [capabilities] -items = ["plugin.worker"] +items = ["plugin_worker"] [load] runtime = "python" @@ -745,7 +739,7 @@ native_api = "1" enabled = false [capabilities] -items = ["plugin.worker"] +items = ["plugin_worker"] [load] library = "target/release/libbad.dylib" @@ -756,7 +750,7 @@ symbol = "nemo_relay_register_plugin" match err { PluginError::InvalidConfig(message) => { - assert!(message.contains("plugin.native"), "{message}"); + assert!(message.contains("plugin_native"), "{message}"); } other => panic!("unexpected capability validation error: {other}"), } @@ -780,7 +774,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin.worker"] +items = ["plugin_worker"] [load] runtime = "python" @@ -814,7 +808,7 @@ relay = ">=0.1.0,<0.2.0" enabled = false [capabilities] -items = ["plugin.native"] +items = ["plugin_native"] [load] library = "target/release/libmissing.dylib" @@ -849,7 +843,7 @@ native_api = "1" enabled = false [capabilities] -items = ["plugin.native"] +items = ["plugin_native"] [load] runtime = "python" @@ -993,7 +987,7 @@ worker_protocol = "1" enabled = true [capabilities] -items = ["plugin.worker"] +items = ["plugin_worker"] [load] runtime = "python" @@ -1028,7 +1022,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin.worker"] +items = ["plugin_worker"] [load] runtime = "python" @@ -1162,7 +1156,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin.worker"] +items = ["plugin_worker"] [load] runtime = "python" @@ -1197,7 +1191,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin.worker", "plugin.worker"] +items = ["plugin_worker", "plugin_worker"] [load] runtime = "python" @@ -1235,7 +1229,7 @@ worker_protocol = "1" enabled = false [capabilities] -items = ["plugin.worker"] +items = ["plugin_worker"] [load] runtime = "python" From e3a39d73a3077f5e405ac599ee33c05863f6954c Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 11:52:01 -0700 Subject: [PATCH 09/10] fix: allow disabling broken dynamic plugins Signed-off-by: Alex Fournier --- crates/cli/src/plugins/lifecycle.rs | 85 ++++++++++++------- .../tests/coverage/plugins_lifecycle_tests.rs | 47 ++++++++++ 2 files changed, 101 insertions(+), 31 deletions(-) diff --git a/crates/cli/src/plugins/lifecycle.rs b/crates/cli/src/plugins/lifecycle.rs index 265f46b4a..352a534d6 100644 --- a/crates/cli/src/plugins/lifecycle.rs +++ b/crates/cli/src/plugins/lifecycle.rs @@ -344,13 +344,64 @@ fn mutate_enabled_state( server: &ServerArgs, enabled: bool, ) -> Result<(), CliError> { - let resolved = resolve_plugins_config(server.config.as_ref())?; - let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; let command = if enabled { "plugins enable" } else { "plugins disable" }; + let mut scopes = if enabled { + let resolved = resolve_plugins_config(server.config.as_ref())?; + let mut scopes = load_and_hydrate_scopes(server.config.as_ref(), &resolved)?; + let entry = find_registered_entry(&scopes, command, &plugin_id)?; + if entry.record.is_tombstoned() { + return Err(plugin_refused( + command, + Some(plugin_id.clone()), + format!( + "dynamic plugin '{}' is tombstoned and cannot be {}d", + plugin_id, + if enabled { "enable" } else { "disable" } + ), + )); + } + let manifest_ref = manifest_ref_from_record(&entry.record)?; + let (manifest, manifest_ref) = load_manifest_for_action(command, &manifest_ref)?; + let policy = + evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); + update_registry_validation_status( + &mut scopes[entry.scope_index], + &plugin_id, + &policy, + &trust, + )?; + if !policy.policy_satisfied { + scopes[entry.scope_index].save()?; + return Err(plugin_refused_with_code( + command, + Some(plugin_id.clone()), + "policy_blocked", + policy + .failure() + .map(|failure| failure.display(&plugin_id).to_string()) + .unwrap_or_else(|| { + format!("dynamic plugin '{}' is blocked by host policy", plugin_id) + }), + )); + } + if let Some(failure) = trust.failure() { + scopes[entry.scope_index].save()?; + return Err(plugin_refused_with_code( + command, + Some(plugin_id.clone()), + trust_refusal_code(&trust), + failure.display(&plugin_id).to_string(), + )); + } + scopes + } else { + load_scoped_registries(server.config.as_ref())? + }; let entry = find_registered_entry(&scopes, command, &plugin_id)?; if entry.record.is_tombstoned() { return Err(plugin_refused( @@ -363,34 +414,6 @@ fn mutate_enabled_state( ), )); } - let manifest_ref = manifest_ref_from_record(&entry.record)?; - let (manifest, manifest_ref) = load_manifest_for_action(command, &manifest_ref)?; - let policy = evaluate_dynamic_plugin_host_policy(&resolved.dynamic_plugin_policy, &manifest); - let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &policy); - update_registry_validation_status(&mut scopes[entry.scope_index], &plugin_id, &policy, &trust)?; - if enabled && !policy.policy_satisfied { - scopes[entry.scope_index].save()?; - return Err(plugin_refused_with_code( - command, - Some(plugin_id.clone()), - "policy_blocked", - policy - .failure() - .map(|failure| failure.display(&plugin_id).to_string()) - .unwrap_or_else(|| { - format!("dynamic plugin '{}' is blocked by host policy", plugin_id) - }), - )); - } - if enabled && let Some(failure) = trust.failure() { - scopes[entry.scope_index].save()?; - return Err(plugin_refused_with_code( - command, - Some(plugin_id.clone()), - trust_refusal_code(&trust), - failure.display(&plugin_id).to_string(), - )); - } if enabled { scopes[entry.scope_index] .registry @@ -1018,7 +1041,7 @@ fn host_config_label(host_config: Option<&ResolvedDynamicPluginConfig>) -> &'sta let status: &'static str = plugin.host_config_status().into(); status }) - .unwrap_or("missing") + .unwrap_or("absent") } fn redacted_host_config_json(host_config: &ResolvedDynamicPluginConfig) -> Value { diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index 466a2c2a8..0d3281de8 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -1124,6 +1124,53 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { ); } +#[test] +fn disable_succeeds_when_registered_plugin_manifest_is_unreadable() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let plugin_dir = temp.path().join("plugins").join("acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, "acme.guardrail"); + let server = crate::config::ServerArgs::default(); + + add( + PluginsAddCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + path: plugin_dir.clone(), + }, + &server, + ) + .unwrap(); + + enable( + PluginsEnableCommand { + id: "acme.guardrail".into(), + }, + &server, + ) + .unwrap(); + + std::fs::remove_file(plugin_dir.join("relay-plugin.toml")).unwrap(); + + disable( + PluginsDisableCommand { + id: "acme.guardrail".into(), + }, + &server, + ) + .unwrap(); + + let scopes = load_scoped_registries(None).unwrap(); + let entry = find_record_by_id(&scopes, "acme.guardrail") + .unwrap() + .expect("disabled plugin record"); + assert!(!entry.record.spec.enabled); +} + #[test] fn validate_marks_registered_plugins_invalid_when_host_policy_blocks_them() { let temp = tempfile::tempdir().unwrap(); From ecbba98c143ccfeceab08cd6b0d53eb2602384c6 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Thu, 25 Jun 2026 12:23:07 -0700 Subject: [PATCH 10/10] test: improve dynamic plugin trust coverage Signed-off-by: Alex Fournier --- .../tests/coverage/plugins_lifecycle_tests.rs | 133 +++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs index 0d3281de8..4b2dfccf4 100644 --- a/crates/cli/tests/coverage/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/plugins_lifecycle_tests.rs @@ -11,6 +11,7 @@ use crate::config::{ }; use crate::error::PluginLifecycleFailureKind; use base64::Engine; +use nemo_relay::plugin::dynamic::DynamicPluginFailurePhase; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use sha2::{Digest, Sha256}; @@ -179,6 +180,129 @@ fn generate_ed25519_public_key() -> String { ) } +#[test] +fn trust_failure_messages_and_codes_cover_all_variants() { + let path = PathBuf::from("/tmp/plugin.py"); + let signature_path = PathBuf::from("/tmp/plugin.py.sig"); + let cases = vec![ + ( + trust::DynamicPluginTrustFailure::MissingArtifact, + "integrity_failed", + "missing source.artifact", + ), + ( + trust::DynamicPluginTrustFailure::MissingIntegrityDigest, + "integrity_failed", + "missing integrity.sha256", + ), + ( + trust::DynamicPluginTrustFailure::ArtifactRead { + path: path.clone(), + error: "boom".into(), + }, + "integrity_failed", + "could not be read for trust verification", + ), + ( + trust::DynamicPluginTrustFailure::IntegrityMismatch { + path: path.clone(), + expected: "sha256:expected".into(), + actual: "sha256:actual".into(), + }, + "integrity_failed", + "failed integrity verification", + ), + ( + trust::DynamicPluginTrustFailure::MissingSignature, + "attestation_failed", + "requires integrity.signature", + ), + ( + trust::DynamicPluginTrustFailure::MissingTrustedKeys, + "attestation_failed", + "no trusted_public_keys", + ), + ( + trust::DynamicPluginTrustFailure::SignatureRead { + path: signature_path.clone(), + error: "nope".into(), + }, + "attestation_failed", + "signature /tmp/plugin.py.sig could not be read", + ), + ( + trust::DynamicPluginTrustFailure::InvalidTrustedKey { + key: "ed25519:bad".into(), + error: "invalid".into(), + }, + "attestation_failed", + "invalid trusted public key", + ), + ( + trust::DynamicPluginTrustFailure::SignatureVerification { + path: signature_path, + parse_errors: vec!["bad key".into()], + }, + "attestation_failed", + "key parse errors: bad key", + ), + ]; + + for (failure, code, snippet) in cases { + assert_eq!(failure.refusal_code(), code); + let rendered = failure.display("acme.coverage").to_string(); + assert!(rendered.contains("acme.coverage"), "{rendered}"); + assert!(rendered.contains(snippet), "{rendered}"); + } +} + +#[test] +fn trust_last_error_preserves_integrity_code_under_signature_policy() { + let trust = EvaluatedDynamicPluginTrust { + integrity: DynamicPluginCheckState::Invalid, + authenticity: DynamicPluginCheckState::Unknown, + failure: Some(trust::DynamicPluginTrustFailure::IntegrityMismatch { + path: PathBuf::from("/tmp/plugin.py"), + expected: "sha256:expected".into(), + actual: "sha256:actual".into(), + }), + }; + + let error = trust + .last_error("acme.coverage") + .expect("integrity mismatch should persist an error"); + assert_eq!(error.phase, DynamicPluginFailurePhase::Validation); + assert_eq!(error.code, "integrity_failed"); + assert!(error.message.contains("acme.coverage")); + assert!(error.message.contains("failed integrity verification")); +} + +#[test] +fn trust_evaluation_short_circuits_when_policy_is_blocked() { + let temp = tempfile::tempdir().unwrap(); + let plugin_dir = temp.path().join("plugins").join("acme"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.blocked-short-circuit"); + + let (manifest, manifest_ref) = DynamicPluginManifest::load_from_path(&manifest_path) + .map_err(|error| CliError::Config(error.to_string())) + .unwrap(); + let blocked_policy = crate::plugins::policy::EvaluatedDynamicPluginHostPolicy { + policy_satisfied: false, + startup_class: nemo_relay::plugin::dynamic::DynamicPluginStartupClass::Required, + attestation_mode: + nemo_relay::plugin::dynamic::DynamicPluginAttestationMode::SignatureRequired, + trusted_public_keys: Vec::new(), + failure: Some(crate::plugins::policy::DynamicPluginHostPolicyFailure::Blocked), + }; + + let trust = evaluate_dynamic_plugin_trust(&manifest, &manifest_ref, &blocked_policy); + + assert_eq!(trust.integrity, DynamicPluginCheckState::Unknown); + assert_eq!(trust.authenticity, DynamicPluginCheckState::Unknown); + assert!(trust.failure().is_none()); +} + #[test] fn add_registers_dynamic_plugin_in_project_plugins_toml() { let temp = tempfile::tempdir().unwrap(); @@ -1097,11 +1221,18 @@ fn enable_refuses_dynamic_plugins_blocked_by_host_policy_and_persists_status() { match error { CliError::PluginLifecycle { kind: PluginLifecycleFailureKind::Refused, - message, + ref message, .. } => assert!(message.contains("blocked by host policy")), other => panic!("unexpected enable policy error: {other}"), } + let (command, target, kind, code, _) = error + .as_plugin_lifecycle_error_context() + .expect("plugin lifecycle error context"); + assert_eq!(command, "plugins enable"); + assert_eq!(target, Some("acme.enable-blocked")); + assert_eq!(kind, PluginLifecycleFailureKind::Refused); + assert_eq!(code, Some("policy_blocked")); let resolved = resolve_plugins_config(None).unwrap(); let scopes = load_and_hydrate_scopes(None, &resolved).unwrap();