Skip to main content

quorum_rs/middleware/
config.rs

1//! Middleware configuration — YAML deserialization and pipeline builder.
2//!
3//! ```yaml
4//! middleware:
5//!   before_release:
6//!     - builtin: rule_based
7//!       stages: [edit, release]
8//!       config:
9//!         max_content_length: 50000
10//!     - binary: ./middleware/moderate
11//!       timeout_secs: 30
12//!       stages: [release]
13//!   on_provider_response:
14//!     - binary: ./middleware/transform
15//!       timeout_secs: 10
16//! ```
17
18use super::BinaryMiddleware;
19use crate::llms::AiModel;
20use crate::middleware::{AgentMiddleware, MiddlewareStage, pipeline::MiddlewarePipeline};
21use serde::Deserialize;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::Duration;
25
26/// Default binary middleware timeout (30 seconds).
27fn default_timeout() -> u64 {
28    30
29}
30
31/// Top-level middleware configuration from agent YAML config.
32#[derive(Debug, Clone, Deserialize, Default)]
33pub struct MiddlewareConfig {
34    /// Middleware that runs before buffer release (edit + release stages).
35    #[serde(default)]
36    pub before_release: Vec<MiddlewareEntry>,
37    /// Middleware that runs after LLM provider returns.
38    #[serde(default)]
39    pub on_provider_response: Vec<MiddlewareEntry>,
40    /// Middleware that runs before constructing the LLM prompt.
41    #[serde(default)]
42    pub before_prompt: Vec<MiddlewareEntry>,
43    /// Middleware that runs after deliberation completes (per round).
44    #[serde(default)]
45    pub on_completion: Vec<MiddlewareEntry>,
46    /// Middleware that runs once at job-final (terminal winner known).
47    #[serde(default)]
48    pub on_job_complete: Vec<MiddlewareEntry>,
49    /// Optional AiModel instance for LLM moderation middleware.
50    /// Set at runtime (not from YAML) — call [`Self::with_moderation_model()`].
51    #[serde(skip)]
52    pub moderation_model: Option<Arc<dyn AiModel>>,
53}
54
55impl MiddlewareConfig {
56    /// Set the LLM model for moderation middleware.
57    /// Called at agent startup after constructing the provider.
58    pub fn with_moderation_model(mut self, model: Arc<dyn AiModel>) -> Self {
59        self.moderation_model = Some(model);
60        self
61    }
62}
63
64/// A single middleware entry — builtin, external binary, or dynamic library.
65#[derive(Debug, Clone, Deserialize)]
66#[serde(untagged)]
67pub enum MiddlewareEntry {
68    /// Builtin middleware (compiled into the binary).
69    Builtin {
70        /// Builtin type identifier.
71        builtin: BuiltinMiddlewareType,
72        /// Which stages this middleware runs at (default: all for the hook point).
73        #[serde(default)]
74        stages: Option<Vec<MiddlewareStage>>,
75        /// Builtin-specific configuration.
76        #[serde(default)]
77        config: serde_json::Value,
78    },
79    /// Dynamic library middleware (.so / .dylib / .dll via FFI).
80    Dylib {
81        /// Path to the shared library.
82        dylib: PathBuf,
83        /// Which stages this middleware runs at (default: all for the hook point).
84        #[serde(default)]
85        stages: Option<Vec<MiddlewareStage>>,
86        /// Opaque config passed to the dylib. Merged into `MiddlewareContext.metadata`
87        /// before each FFI call, so a dylib self-configures from the yml without env
88        /// vars (feature-parity with builtin `config`). The dylib defines its own
89        /// schema under whatever key it reads.
90        #[serde(default)]
91        config: serde_json::Value,
92    },
93    /// External binary middleware (stdin/stdout JSON protocol).
94    Binary {
95        /// Path to the executable.
96        binary: PathBuf,
97        /// Extra command-line arguments.
98        #[serde(default)]
99        args: Vec<String>,
100        /// Timeout in seconds (default: 30).
101        #[serde(default = "default_timeout")]
102        timeout_secs: u64,
103        /// Which stages this middleware runs at (default: all for the hook point).
104        #[serde(default)]
105        stages: Option<Vec<MiddlewareStage>>,
106    },
107}
108
109/// Built-in middleware types. Implementations live in Issue #110.
110#[derive(Debug, Clone, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum BuiltinMiddlewareType {
113    /// Signature verification (check cryptographic signatures on content).
114    SignatureVerification,
115    /// Rule-based validation (blocklist, content length, PII patterns).
116    RuleBased,
117    /// LLM-based content moderation (uses a separate LLM to classify content).
118    LlmModeration,
119    /// Blocks LLM output that leaks the agent's own system prompt (XML tags,
120    /// protocol phrases) or the canonical tool registry.
121    PromptExposure,
122}
123
124impl MiddlewareConfig {
125    /// Build a pipeline for the `before_release` hook point. `Err` if any
126    /// configured middleware fails to build (fail-closed — see `build_pipeline`).
127    pub fn build_before_release_pipeline(&self) -> Result<MiddlewarePipeline, String> {
128        self.build_pipeline(
129            &self.before_release,
130            &[MiddlewareStage::Edit, MiddlewareStage::Release],
131        )
132    }
133
134    /// Build a pipeline for the `on_provider_response` hook point.
135    pub fn build_provider_response_pipeline(&self) -> Result<MiddlewarePipeline, String> {
136        self.build_pipeline(
137            &self.on_provider_response,
138            &[MiddlewareStage::ProviderResponse],
139        )
140    }
141
142    /// Build a pipeline for the `before_prompt` hook point.
143    pub fn build_before_prompt_pipeline(&self) -> Result<MiddlewarePipeline, String> {
144        self.build_pipeline(&self.before_prompt, &[MiddlewareStage::BeforePrompt])
145    }
146
147    /// Build a pipeline for the `on_completion` hook point.
148    pub fn build_completion_pipeline(&self) -> Result<MiddlewarePipeline, String> {
149        self.build_pipeline(&self.on_completion, &[MiddlewareStage::Completion])
150    }
151
152    /// Build a pipeline for the `on_job_complete` hook point.
153    pub fn build_job_complete_pipeline(&self) -> Result<MiddlewarePipeline, String> {
154        self.build_pipeline(&self.on_job_complete, &[MiddlewareStage::JobComplete])
155    }
156
157    /// Returns true if no middleware is configured at any hook point.
158    pub fn is_empty(&self) -> bool {
159        self.before_release.is_empty()
160            && self.on_provider_response.is_empty()
161            && self.before_prompt.is_empty()
162            && self.on_completion.is_empty()
163            && self.on_job_complete.is_empty()
164    }
165
166    fn build_pipeline(
167        &self,
168        entries: &[MiddlewareEntry],
169        default_stages: &[MiddlewareStage],
170    ) -> Result<MiddlewarePipeline, String> {
171        // Fail CLOSED: a middleware that can't be built (missing/corrupt dylib,
172        // builtin create error) is a broken security guard — propagate the error so
173        // the agent refuses to start, rather than silently dropping the guard and
174        // running the pipeline without it (fail-open).
175        let middleware = entries
176            .iter()
177            .map(|entry| self.build_entry(entry, default_stages))
178            .collect::<Result<Vec<_>, _>>()?;
179        Ok(MiddlewarePipeline::new(middleware))
180    }
181
182    fn build_entry(
183        &self,
184        entry: &MiddlewareEntry,
185        default_stages: &[MiddlewareStage],
186    ) -> Result<Box<dyn AgentMiddleware>, String> {
187        match entry {
188            MiddlewareEntry::Builtin {
189                builtin,
190                stages,
191                config,
192            } => {
193                let active_stages = stages
194                    .as_ref()
195                    .cloned()
196                    .unwrap_or_else(|| default_stages.to_vec());
197
198                match super::builtin::create_builtin_middleware(
199                    builtin,
200                    config,
201                    active_stages,
202                    self.moderation_model.clone(),
203                ) {
204                    Ok(mw) => {
205                        tracing::info!(
206                            builtin_type = ?builtin,
207                            "Loaded builtin middleware"
208                        );
209                        Ok(mw)
210                    }
211                    Err(e) => Err(format!(
212                        "failed to create builtin middleware {builtin:?}: {e}"
213                    )),
214                }
215            }
216            MiddlewareEntry::Dylib {
217                dylib,
218                stages,
219                config,
220            } => {
221                let active_stages = stages
222                    .as_ref()
223                    .cloned()
224                    .unwrap_or_else(|| default_stages.to_vec());
225
226                // Safety: we trust the operator's config to point at a valid dylib.
227                // The FFI contract is documented in dylib.rs.
228                match unsafe { super::DylibMiddleware::load(dylib, active_stages, config.clone()) }
229                {
230                    Ok(mw) => {
231                        tracing::info!(
232                            dylib = ?dylib,
233                            "Loaded dynamic library middleware"
234                        );
235                        Ok(Box::new(mw))
236                    }
237                    // Fail CLOSED: propagate so the agent refuses to start (matching
238                    // the operator-facing contract) instead of running without this
239                    // guard. Fix the dylib path or remove it from config.
240                    Err(e) => Err(format!(
241                        "failed to load dynamic library middleware {dylib:?}: {e} \
242                         — fix the dylib path or remove it from config"
243                    )),
244                }
245            }
246            MiddlewareEntry::Binary {
247                binary,
248                args,
249                timeout_secs,
250                stages,
251            } => {
252                let active_stages = stages
253                    .as_ref()
254                    .cloned()
255                    .unwrap_or_else(|| default_stages.to_vec());
256
257                let name = binary
258                    .file_name()
259                    .and_then(|n| n.to_str())
260                    .unwrap_or("binary")
261                    .to_string();
262
263                Ok(Box::new(BinaryMiddleware {
264                    display_name: name,
265                    path: binary.clone(),
266                    args: args.clone(),
267                    timeout: Duration::from_secs(*timeout_secs),
268                    active_stages,
269                }))
270            }
271        }
272    }
273}
274
275// ---------------------------------------------------------------------------
276// Tests
277// ---------------------------------------------------------------------------
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn config_deserialize_empty() {
285        let yaml = "{}";
286        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
287        assert!(config.is_empty());
288    }
289
290    #[test]
291    fn config_deserialize_binary_entry() {
292        let yaml = r#"
293before_release:
294  - binary: ./hooks/moderate
295    timeout_secs: 15
296    stages: [release]
297"#;
298        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
299        assert_eq!(config.before_release.len(), 1);
300        match &config.before_release[0] {
301            MiddlewareEntry::Binary {
302                binary,
303                timeout_secs,
304                stages,
305                ..
306            } => {
307                assert_eq!(binary, &PathBuf::from("./hooks/moderate"));
308                assert_eq!(*timeout_secs, 15);
309                assert_eq!(stages.as_ref().unwrap(), &[MiddlewareStage::Release]);
310            }
311            _ => panic!("Expected Binary entry"),
312        }
313    }
314
315    #[test]
316    fn config_deserialize_builtin_entry() {
317        let yaml = r#"
318before_release:
319  - builtin: rule_based
320    stages: [edit, release]
321    config:
322      max_content_length: 50000
323      pii_patterns: true
324"#;
325        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
326        assert_eq!(config.before_release.len(), 1);
327        match &config.before_release[0] {
328            MiddlewareEntry::Builtin {
329                builtin,
330                stages,
331                config,
332            } => {
333                assert!(matches!(builtin, BuiltinMiddlewareType::RuleBased));
334                assert_eq!(
335                    stages.as_ref().unwrap(),
336                    &[MiddlewareStage::Edit, MiddlewareStage::Release]
337                );
338                assert_eq!(config["max_content_length"], 50000);
339                assert_eq!(config["pii_patterns"], true);
340            }
341            _ => panic!("Expected Builtin entry"),
342        }
343    }
344
345    #[test]
346    fn config_deserialize_mixed() {
347        let yaml = r#"
348before_release:
349  - builtin: signature_verification
350    stages: [release]
351  - binary: ./hooks/moderate
352    timeout_secs: 30
353    stages: [release]
354on_provider_response:
355  - binary: ./hooks/transform
356    timeout_secs: 10
357"#;
358        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
359        assert_eq!(config.before_release.len(), 2);
360        assert_eq!(config.on_provider_response.len(), 1);
361        assert!(!config.is_empty());
362    }
363
364    #[test]
365    fn config_default_timeout() {
366        let yaml = r#"
367before_release:
368  - binary: ./hooks/check
369"#;
370        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
371        match &config.before_release[0] {
372            MiddlewareEntry::Binary { timeout_secs, .. } => {
373                assert_eq!(*timeout_secs, 30); // default
374            }
375            _ => panic!("Expected Binary entry"),
376        }
377    }
378
379    #[test]
380    fn build_binary_pipeline() {
381        let yaml = r#"
382before_release:
383  - binary: /bin/true
384    timeout_secs: 5
385"#;
386        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
387        let pipeline = config.build_before_release_pipeline().unwrap();
388        assert_eq!(pipeline.len(), 1);
389        assert!(!pipeline.is_empty());
390    }
391
392    #[test]
393    fn build_builtin_pipeline_creates_rule_based() {
394        let yaml = r#"
395before_release:
396  - builtin: rule_based
397    config: {}
398"#;
399        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
400        let pipeline = config.build_before_release_pipeline().unwrap();
401        // rule_based is implemented — should create 1 middleware
402        assert_eq!(pipeline.len(), 1);
403    }
404
405    #[test]
406    fn unloadable_dylib_fails_closed_not_dropped() {
407        // A dylib that can't load must make pipeline-build ERROR (→ agent refuses to
408        // start), not silently drop the guard and run the pipeline without it.
409        let yaml = r#"
410before_prompt:
411  - dylib: /nonexistent/path/to/guard.so
412"#;
413        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
414        let result = config.build_before_prompt_pipeline();
415        assert!(
416            result.is_err(),
417            "an unloadable dylib guard must fail closed (Err), got Ok"
418        );
419        assert!(result.unwrap_err().contains("dynamic library"));
420    }
421
422    #[test]
423    fn dylib_entry_parses_config() {
424        let yaml = r#"
425before_prompt:
426  - dylib: /nonexistent.dylib
427    config:
428      patch_deliberation:
429        upstream: epic
430        downstream_root: ./downstreams
431"#;
432        let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
433        match &config.before_prompt[0] {
434            MiddlewareEntry::Dylib { dylib, config, .. } => {
435                assert_eq!(dylib.to_str(), Some("/nonexistent.dylib"));
436                assert_eq!(
437                    config["patch_deliberation"]["upstream"],
438                    serde_json::json!("epic")
439                );
440                assert_eq!(
441                    config["patch_deliberation"]["downstream_root"],
442                    serde_json::json!("./downstreams")
443                );
444            }
445            other => panic!("expected Dylib entry, got {other:?}"),
446        }
447    }
448}