use std::sync::Arc;
use ppe::praxis_policy_core::{
error::PluginError,
factory::{PluginFactory, PluginInstance},
plugin::PluginConfig,
};
use crate::plugin::{IsolatedPythonPlugin, PythonHookAdapter};
pub const KIND: &str = "isolated_venv";
pub struct IsolatedVenvFactory;
fn declares_ignored_plugin_dirs(config: &PluginConfig) -> bool {
config
.config
.as_ref()
.and_then(serde_json::Value::as_object)
.is_some_and(|block| block.contains_key("plugin_dirs"))
}
impl PluginFactory for IsolatedVenvFactory {
fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<PluginError>> {
if config.hooks.is_empty() {
return Err(PluginError::Config {
message: format!(
"plugin '{}' (isolated_venv): `hooks:` must list at least one hook \
for the Python plugin to handle (e.g. cmf.tool_pre_invoke)",
config.name
),
}
.boxed());
}
if declares_ignored_plugin_dirs(config) {
tracing::warn!(
plugin = %config.name,
"plugin '{}' (isolated_venv) sets `plugin_dirs` in its config block, but the \
host always uses '{}' at the project root. Setting ignored — remove it.",
config.name,
crate::plugin::DEFAULT_PLUGIN_DIR,
);
}
let plugin = Arc::new(IsolatedPythonPlugin::from_config(config)?);
let handlers: Vec<_> = config
.hooks
.iter()
.map(
|hook| -> (
&'static str,
Arc<dyn ppe::praxis_policy_core::registry::AnyHookHandler>,
) {
let leaked: &'static str = Box::leak(hook.clone().into_boxed_str());
(
leaked,
Arc::new(PythonHookAdapter::new(Arc::clone(&plugin), leaked)),
)
},
)
.collect();
Ok(PluginInstance { plugin, handlers })
}
}
#[cfg(test)]
mod tests {
use ppe::PolicyEngine;
use ppe::praxis_policy_core::config::PolicyConfig;
use ppe::praxis_policy_core::factory::PluginFactoryRegistry;
use super::*;
fn minimal_config_yaml() -> &'static str {
r#"
engine_settings:
dispatch: hooks
plugins:
- name: pii-filter
kind: isolated_venv
hooks: [cmf.tool_pre_invoke]
config:
class_name: my_pkg.filters.PiiFilter
"#
}
fn registry() -> PluginFactoryRegistry {
let mut factories = PluginFactoryRegistry::new();
factories.register(KIND, Box::new(IsolatedVenvFactory));
factories
}
#[test]
fn factory_registers_under_isolated_venv_kind() {
let factories = registry();
assert!(factories.has(KIND));
assert_eq!(KIND, "isolated_venv");
}
#[test]
fn loading_an_isolated_venv_config_succeeds() {
let config: PolicyConfig = serde_yaml::from_str(minimal_config_yaml()).expect("valid YAML");
let factories = registry();
PolicyEngine::from_config(config, &factories)
.expect("config loads with the factory registered");
}
#[test]
fn missing_class_name_is_a_config_error_not_a_panic() {
let yaml = r#"
engine_settings:
dispatch: hooks
plugins:
- name: broken
kind: isolated_venv
hooks: [cmf.tool_pre_invoke]
config:
plugin_dirs: ["./plugins"]
"#;
let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML");
let Err(err) = PolicyEngine::from_config(config, ®istry()) else {
panic!("a config block without class_name must be rejected");
};
assert!(
matches!(*err, PluginError::Config { .. }),
"expected a Config error, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("class_name"),
"the error should name the missing field so an operator can fix it: {msg}"
);
}
#[test]
fn empty_hooks_list_is_rejected() {
let yaml = r#"
engine_settings:
dispatch: hooks
plugins:
- name: no-hooks
kind: isolated_venv
hooks: []
config:
class_name: my_pkg.filters.PiiFilter
"#;
let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML");
let Err(err) = PolicyEngine::from_config(config, ®istry()) else {
panic!("a plugin declaring no hooks can never be invoked");
};
assert!(matches!(*err, PluginError::Config { .. }));
}
#[test]
fn the_ignored_plugin_dirs_key_is_detected_for_the_warning() {
let with_key = PluginConfig {
name: "legacy".into(),
kind: KIND.into(),
hooks: vec!["cmf.tool_pre_invoke".into()],
config: Some(serde_json::json!({
"class_name": "my_pkg.P",
"plugin_dirs": ["/somewhere"],
})),
..Default::default()
};
assert!(declares_ignored_plugin_dirs(&with_key));
let without_key = PluginConfig {
config: Some(serde_json::json!({ "class_name": "my_pkg.P" })),
..with_key.clone()
};
assert!(
!declares_ignored_plugin_dirs(&without_key),
"a clean config must not warn"
);
let no_block = PluginConfig {
config: None,
..with_key
};
assert!(!declares_ignored_plugin_dirs(&no_block));
}
#[test]
fn an_ignored_plugin_dirs_key_still_loads() {
let yaml = r#"
engine_settings:
dispatch: hooks
plugins:
- name: legacy-dirs
kind: isolated_venv
hooks: [cmf.tool_pre_invoke]
config:
class_name: my_pkg.filters.PiiFilter
plugin_dirs: ["/somewhere/else"]
"#;
let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML");
PolicyEngine::from_config(config, ®istry())
.expect("an ignored plugin_dirs key must not fail the load");
}
#[test]
fn one_handler_is_produced_per_declared_hook() {
let config = PluginConfig {
name: "multi".into(),
kind: KIND.into(),
hooks: vec![
"cmf.tool_pre_invoke".into(),
"identity.resolve".into(),
"token.delegate".into(),
],
config: Some(serde_json::json!({ "class_name": "my_pkg.Multi" })),
..Default::default()
};
let instance = IsolatedVenvFactory
.create(&config)
.expect("a valid multi-hook entry creates an instance");
assert_eq!(instance.handlers.len(), 3);
let names: Vec<&str> = instance.handlers.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"cmf.tool_pre_invoke"));
assert!(names.contains(&"identity.resolve"));
assert!(names.contains(&"token.delegate"));
}
#[test]
fn handlers_pass_the_engine_family_check_on_every_dispatched_hook_family() {
let yaml = r#"
engine_settings:
dispatch: hooks
plugins:
- name: py-everything
kind: isolated_venv
hooks:
- cmf.tool_pre_invoke
- cmf.prompt_pre_invoke
- identity.resolve
- token.delegate
config:
class_name: my_pkg.Everything
"#;
let config = ppe::praxis_policy_core::config::parse_config(yaml).expect("valid config");
let engine = PolicyEngine::from_config(config, ®istry())
.expect("every dispatched hook family accepts the isolated_venv handler");
for hook in [
"cmf.tool_pre_invoke",
"cmf.prompt_pre_invoke",
"identity.resolve",
"token.delegate",
] {
assert!(engine.has_hooks_for(hook), "no handler on {hook}");
}
}
#[test]
fn a_top_level_plugin_dirs_key_fails_the_load() {
let yaml = r#"
plugin_dirs: ["./plugins"]
engine_settings:
dispatch: hooks
plugins:
- name: legacy-dirs
kind: isolated_venv
hooks: [cmf.tool_pre_invoke]
config:
class_name: my_pkg.filters.PiiFilter
"#;
let err = ppe::praxis_policy_core::config::parse_config(yaml)
.expect_err("a top-level plugin_dirs key must be rejected");
assert!(
matches!(*err, PluginError::Config { .. }),
"expected a Config error, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("plugin_dirs"),
"the error should name the key: {msg}"
);
assert!(
msg.contains("register_factory"),
"the error should name the replacement: {msg}"
);
}
#[test]
fn a_legacy_hook_name_fails_the_load() {
let yaml = r#"
engine_settings:
dispatch: hooks
plugins:
- name: legacy-hook
kind: isolated_venv
hooks: [identity_resolve]
config:
class_name: my_pkg.Resolver
"#;
let err = ppe::praxis_policy_core::config::parse_config(yaml)
.expect_err("PPE does not dispatch the unprefixed identity hook");
assert!(err.to_string().contains("identity.resolve"), "{err}");
}
}