Skip to main content

cpex_hosts_python/
factory.rs

1// Location: ./crates/cpex-hosts-python/src/factory.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// The `isolated_venv` plugin factory.
7//
8// Hosts register this before `PolicyEngine::from_config()`. For each config
9// entry whose `kind:` is `isolated_venv`, the engine calls `create()`, which
10// parses the venv settings out of the entry's opaque `config` map and returns
11// the plugin plus one handler per declared hook.
12
13use 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
23/// `kind:` string operators write in CPEX YAML to declare a Python plugin
24/// that runs out-of-process in its own virtualenv.
25///
26/// This supersedes issue #20's `python://` in-process framing: the host runs
27/// the plugin in a subprocess, not via PyO3, so the runtime never links
28/// libpython and a plugin crash cannot take the gateway down.
29pub const KIND: &str = "isolated_venv";
30
31/// Creates out-of-process Python plugins from `isolated_venv` config entries.
32pub struct IsolatedVenvFactory;
33
34/// Whether an entry sets the ignored `plugin_dirs` key in its `config:` block.
35///
36/// Split out from the warning call so the *decision* is directly assertable —
37/// the `tracing::warn!` itself is a no-op without a subscriber, and the
38/// workspace carries none in its test profile.
39fn 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        // `plugin_dirs` is no longer configurable — the host always uses
61        // `<project root>/plugins`. An entry that still sets it would otherwise
62        // appear to work while pointing somewhere else entirely, so say so once,
63        // naming the plugin.
64        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        // Parsing happens here rather than in `initialize()` so a malformed
75        // entry fails at config load, while the engine can still report
76        // which plugin was bad, instead of midway through a rollback.
77        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                    // The registry keys handlers by `&'static str`, and hook names
88                    // arrive as owned Strings from YAML. Leaking is what
89                    // audit-logger does: the set is bounded by config size and
90                    // lives as long as the process holds the plugin anyway.
91                    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    /// A config with one `isolated_venv` plugin, mirroring the YAML shape an
113    /// operator writes. No `plugin_dirs`: the host always resolves
114    /// `<project root>/plugins` — see `plugin::DEFAULT_PLUGIN_DIR`.
115    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        // The load path is what would raise "no factory registered for plugin
147        // kind 'isolated_venv'" if registration did not take.
148        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        // The block carries only the now-ignored `plugin_dirs`, so this also
155        // covers that an ignored key does not mask the missing required one.
156        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        // `PolicyEngine` is not Debug, so `expect_err` is unavailable here.
168        let Err(err) = PolicyEngine::from_config(config, &registry()) 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, &registry()) 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        // Guards the warning's trigger condition. Without this an operator
205        // upgrading gets silence: the key stops working and nothing says so.
206        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        // An absent block must not panic or warn.
228        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        // An operator upgrading from a config that set `plugin_dirs` must not
238        // hit a load failure — the key is ignored (with a warning), not
239        // rejected. A hard error here would break every existing config.
240        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, &registry())
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        // The registry refuses a handler whose reported family differs from the
284        // hook's. A handler reporting the bare hook name would fail on every
285        // `cmf.*` hook, whose family is `cmf`.
286        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, &registry())
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        // PPE closed the document's key set. Only the in-plugin `config:` key
316        // is tolerated (with a warning); the top-level one is an error naming
317        // its replacement.
318        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}