cpex_hosts_python/
factory.rs1use std::sync::Arc;
14
15use ppe::praxis_policy_core::{
16 error::PluginError,
17 factory::{PluginFactory, PluginInstance},
18 plugin::PluginConfig,
19};
20
21use crate::plugin::{IsolatedPythonPlugin, PythonHookAdapter};
22
23pub const KIND: &str = "isolated_venv";
30
31pub struct IsolatedVenvFactory;
33
34fn declares_ignored_plugin_dirs(config: &PluginConfig) -> bool {
40 config
41 .config
42 .as_ref()
43 .and_then(serde_json::Value::as_object)
44 .is_some_and(|block| block.contains_key("plugin_dirs"))
45}
46
47impl PluginFactory for IsolatedVenvFactory {
48 fn create(&self, config: &PluginConfig) -> Result<PluginInstance, Box<PluginError>> {
49 if config.hooks.is_empty() {
50 return Err(PluginError::Config {
51 message: format!(
52 "plugin '{}' (isolated_venv): `hooks:` must list at least one hook \
53 for the Python plugin to handle (e.g. cmf.tool_pre_invoke)",
54 config.name
55 ),
56 }
57 .boxed());
58 }
59
60 if declares_ignored_plugin_dirs(config) {
65 tracing::warn!(
66 plugin = %config.name,
67 "plugin '{}' (isolated_venv) sets `plugin_dirs` in its config block, but the \
68 host always uses '{}' at the project root. Setting ignored — remove it.",
69 config.name,
70 crate::plugin::DEFAULT_PLUGIN_DIR,
71 );
72 }
73
74 let plugin = Arc::new(IsolatedPythonPlugin::from_config(config)?);
78
79 let handlers: Vec<_> = config
80 .hooks
81 .iter()
82 .map(
83 |hook| -> (
84 &'static str,
85 Arc<dyn ppe::praxis_policy_core::registry::AnyHookHandler>,
86 ) {
87 let leaked: &'static str = Box::leak(hook.clone().into_boxed_str());
92 (
93 leaked,
94 Arc::new(PythonHookAdapter::new(Arc::clone(&plugin), leaked)),
95 )
96 },
97 )
98 .collect();
99
100 Ok(PluginInstance { plugin, handlers })
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use ppe::PolicyEngine;
107 use ppe::praxis_policy_core::config::PolicyConfig;
108 use ppe::praxis_policy_core::factory::PluginFactoryRegistry;
109
110 use super::*;
111
112 fn minimal_config_yaml() -> &'static str {
116 r#"
117engine_settings:
118 dispatch: hooks
119plugins:
120 - name: pii-filter
121 kind: isolated_venv
122 hooks: [cmf.tool_pre_invoke]
123 config:
124 class_name: my_pkg.filters.PiiFilter
125"#
126 }
127
128 fn registry() -> PluginFactoryRegistry {
129 let mut factories = PluginFactoryRegistry::new();
130 factories.register(KIND, Box::new(IsolatedVenvFactory));
131 factories
132 }
133
134 #[test]
135 fn factory_registers_under_isolated_venv_kind() {
136 let factories = registry();
137 assert!(factories.has(KIND));
138 assert_eq!(KIND, "isolated_venv");
139 }
140
141 #[test]
142 fn loading_an_isolated_venv_config_succeeds() {
143 let config: PolicyConfig = serde_yaml::from_str(minimal_config_yaml()).expect("valid YAML");
144 let factories = registry();
145
146 PolicyEngine::from_config(config, &factories)
149 .expect("config loads with the factory registered");
150 }
151
152 #[test]
153 fn missing_class_name_is_a_config_error_not_a_panic() {
154 let yaml = r#"
157engine_settings:
158 dispatch: hooks
159plugins:
160 - name: broken
161 kind: isolated_venv
162 hooks: [cmf.tool_pre_invoke]
163 config:
164 plugin_dirs: ["./plugins"]
165"#;
166 let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML");
167 let Err(err) = PolicyEngine::from_config(config, ®istry()) else {
169 panic!("a config block without class_name must be rejected");
170 };
171
172 assert!(
173 matches!(*err, PluginError::Config { .. }),
174 "expected a Config error, got: {err:?}"
175 );
176 let msg = err.to_string();
177 assert!(
178 msg.contains("class_name"),
179 "the error should name the missing field so an operator can fix it: {msg}"
180 );
181 }
182
183 #[test]
184 fn empty_hooks_list_is_rejected() {
185 let yaml = r#"
186engine_settings:
187 dispatch: hooks
188plugins:
189 - name: no-hooks
190 kind: isolated_venv
191 hooks: []
192 config:
193 class_name: my_pkg.filters.PiiFilter
194"#;
195 let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML");
196 let Err(err) = PolicyEngine::from_config(config, ®istry()) else {
197 panic!("a plugin declaring no hooks can never be invoked");
198 };
199 assert!(matches!(*err, PluginError::Config { .. }));
200 }
201
202 #[test]
203 fn the_ignored_plugin_dirs_key_is_detected_for_the_warning() {
204 let with_key = PluginConfig {
207 name: "legacy".into(),
208 kind: KIND.into(),
209 hooks: vec!["cmf.tool_pre_invoke".into()],
210 config: Some(serde_json::json!({
211 "class_name": "my_pkg.P",
212 "plugin_dirs": ["/somewhere"],
213 })),
214 ..Default::default()
215 };
216 assert!(declares_ignored_plugin_dirs(&with_key));
217
218 let without_key = PluginConfig {
219 config: Some(serde_json::json!({ "class_name": "my_pkg.P" })),
220 ..with_key.clone()
221 };
222 assert!(
223 !declares_ignored_plugin_dirs(&without_key),
224 "a clean config must not warn"
225 );
226
227 let no_block = PluginConfig {
229 config: None,
230 ..with_key
231 };
232 assert!(!declares_ignored_plugin_dirs(&no_block));
233 }
234
235 #[test]
236 fn an_ignored_plugin_dirs_key_still_loads() {
237 let yaml = r#"
241engine_settings:
242 dispatch: hooks
243plugins:
244 - name: legacy-dirs
245 kind: isolated_venv
246 hooks: [cmf.tool_pre_invoke]
247 config:
248 class_name: my_pkg.filters.PiiFilter
249 plugin_dirs: ["/somewhere/else"]
250"#;
251 let config: PolicyConfig = serde_yaml::from_str(yaml).expect("valid YAML");
252 PolicyEngine::from_config(config, ®istry())
253 .expect("an ignored plugin_dirs key must not fail the load");
254 }
255
256 #[test]
257 fn one_handler_is_produced_per_declared_hook() {
258 let config = PluginConfig {
259 name: "multi".into(),
260 kind: KIND.into(),
261 hooks: vec![
262 "cmf.tool_pre_invoke".into(),
263 "identity.resolve".into(),
264 "token.delegate".into(),
265 ],
266 config: Some(serde_json::json!({ "class_name": "my_pkg.Multi" })),
267 ..Default::default()
268 };
269
270 let instance = IsolatedVenvFactory
271 .create(&config)
272 .expect("a valid multi-hook entry creates an instance");
273
274 assert_eq!(instance.handlers.len(), 3);
275 let names: Vec<&str> = instance.handlers.iter().map(|(n, _)| *n).collect();
276 assert!(names.contains(&"cmf.tool_pre_invoke"));
277 assert!(names.contains(&"identity.resolve"));
278 assert!(names.contains(&"token.delegate"));
279 }
280
281 #[test]
282 fn handlers_pass_the_engine_family_check_on_every_dispatched_hook_family() {
283 let yaml = r#"
287engine_settings:
288 dispatch: hooks
289plugins:
290 - name: py-everything
291 kind: isolated_venv
292 hooks:
293 - cmf.tool_pre_invoke
294 - cmf.prompt_pre_invoke
295 - identity.resolve
296 - token.delegate
297 config:
298 class_name: my_pkg.Everything
299"#;
300 let config = ppe::praxis_policy_core::config::parse_config(yaml).expect("valid config");
301 let engine = PolicyEngine::from_config(config, ®istry())
302 .expect("every dispatched hook family accepts the isolated_venv handler");
303 for hook in [
304 "cmf.tool_pre_invoke",
305 "cmf.prompt_pre_invoke",
306 "identity.resolve",
307 "token.delegate",
308 ] {
309 assert!(engine.has_hooks_for(hook), "no handler on {hook}");
310 }
311 }
312
313 #[test]
314 fn a_top_level_plugin_dirs_key_fails_the_load() {
315 let yaml = r#"
319plugin_dirs: ["./plugins"]
320engine_settings:
321 dispatch: hooks
322plugins:
323 - name: legacy-dirs
324 kind: isolated_venv
325 hooks: [cmf.tool_pre_invoke]
326 config:
327 class_name: my_pkg.filters.PiiFilter
328"#;
329 let err = ppe::praxis_policy_core::config::parse_config(yaml)
330 .expect_err("a top-level plugin_dirs key must be rejected");
331 assert!(
332 matches!(*err, PluginError::Config { .. }),
333 "expected a Config error, got: {err:?}"
334 );
335 let msg = err.to_string();
336 assert!(
337 msg.contains("plugin_dirs"),
338 "the error should name the key: {msg}"
339 );
340 assert!(
341 msg.contains("register_factory"),
342 "the error should name the replacement: {msg}"
343 );
344 }
345
346 #[test]
347 fn a_legacy_hook_name_fails_the_load() {
348 let yaml = r#"
349engine_settings:
350 dispatch: hooks
351plugins:
352 - name: legacy-hook
353 kind: isolated_venv
354 hooks: [identity_resolve]
355 config:
356 class_name: my_pkg.Resolver
357"#;
358 let err = ppe::praxis_policy_core::config::parse_config(yaml)
359 .expect_err("PPE does not dispatch the unprefixed identity hook");
360 assert!(err.to_string().contains("identity.resolve"), "{err}");
361 }
362}