Skip to main content

laminae_shadow/
lib.rs

1//! # laminae-shadow — Adversarial Red-Teaming Engine
2//!
3//! The Shadow is an automated security auditor that red-teams AI output.
4//! It runs as an async post-processing pipeline — never blocking the user's
5//! conversation — and produces structured vulnerability reports.
6//!
7//! ## Pipeline Stages
8//!
9//! 1. **Static analysis** — regex pattern scanning (always runs)
10//! 2. **LLM adversarial review** — local Ollama model with attacker-mindset prompt
11//! 3. **Sandbox execution** — ephemeral container testing (optional)
12//!
13//! Each stage implements the [`Analyzer`] trait and can be extended or replaced.
14//!
15//! ## Quick Start
16//!
17//! ```rust,no_run
18//! use laminae_shadow::{ShadowEngine, ShadowEvent, create_report_store};
19//!
20//! #[tokio::main]
21//! async fn main() {
22//!     let store = create_report_store();
23//!     let engine = ShadowEngine::new(store.clone());
24//!
25//!     let mut rx = engine.analyze_async(
26//!         "session-1".into(),
27//!         "Here's some code:\n```python\neval(user_input)\n```".into(),
28//!     );
29//!
30//!     while let Some(event) = rx.recv().await {
31//!         match event {
32//!             ShadowEvent::Finding { finding, .. } => {
33//!                 println!("[{}] {}: {}", finding.severity, finding.category, finding.title);
34//!             }
35//!             ShadowEvent::Done { report, .. } => {
36//!                 println!("Analysis complete: {}", report.summary);
37//!             }
38//!             _ => {}
39//!         }
40//!     }
41//! }
42//! ```
43
44pub mod analyzer;
45pub mod config;
46pub mod extractor;
47pub mod llm_reviewer;
48pub mod prompts;
49pub mod report;
50pub mod sandbox;
51pub mod scanner;
52
53use std::collections::VecDeque;
54use std::sync::Arc;
55use std::time::Instant;
56
57use tokio::sync::{mpsc, RwLock};
58
59pub use analyzer::ShadowError;
60pub use config::ShadowConfig;
61
62use analyzer::{Analyzer, StaticAnalyzer};
63use extractor::CodeBlockExtractor;
64use llm_reviewer::LlmReviewer;
65use report::{build_summary, VulnReport, VulnSeverity};
66use sandbox::SandboxManager;
67
68use laminae_ollama::OllamaClient;
69
70/// Events emitted by the Shadow for telemetry/UI.
71#[derive(Debug, Clone, serde::Serialize)]
72#[serde(tag = "type")]
73pub enum ShadowEvent {
74    Started {
75        session_id: String,
76    },
77    Finding {
78        session_id: String,
79        finding: report::VulnFinding,
80    },
81    AnalyzerError {
82        session_id: String,
83        analyzer: String,
84        error: String,
85    },
86    Done {
87        session_id: String,
88        report: VulnReport,
89    },
90}
91
92/// Thread-safe report history with bounded capacity.
93pub type ReportStore = Arc<RwLock<VecDeque<VulnReport>>>;
94
95const MAX_REPORTS: usize = 100;
96
97/// Create a new bounded report store.
98pub fn create_report_store() -> ReportStore {
99    Arc::new(RwLock::new(VecDeque::with_capacity(MAX_REPORTS)))
100}
101
102/// The Shadow — adversarial red-teaming engine.
103///
104/// Composed from independent [`Analyzer`] implementations for extensibility.
105/// All analysis happens in detached async tasks — never blocks the caller.
106pub struct ShadowEngine {
107    config: ShadowConfig,
108    static_analyzer: Arc<StaticAnalyzer>,
109    llm_reviewer: Arc<LlmReviewer>,
110    sandbox: Arc<SandboxManager>,
111    extractor: CodeBlockExtractor,
112    report_store: ReportStore,
113}
114
115impl ShadowEngine {
116    /// Create a new ShadowEngine with default config and a default OllamaClient.
117    pub fn new(report_store: ReportStore) -> Self {
118        Self::with_ollama(report_store, OllamaClient::new())
119    }
120
121    /// Create with a custom OllamaClient (e.g., pointing to a remote Ollama instance).
122    pub fn with_ollama(report_store: ReportStore, ollama: OllamaClient) -> Self {
123        let config = ShadowConfig::load();
124
125        Self {
126            static_analyzer: Arc::new(StaticAnalyzer::new()),
127            llm_reviewer: Arc::new(LlmReviewer::new(ollama.clone(), &config)),
128            sandbox: Arc::new(SandboxManager::new(&config)),
129            extractor: CodeBlockExtractor::new(),
130            report_store,
131            config,
132        }
133    }
134
135    /// Create with explicit config and OllamaClient.
136    pub fn with_config(
137        report_store: ReportStore,
138        config: ShadowConfig,
139        ollama: OllamaClient,
140    ) -> Self {
141        Self {
142            static_analyzer: Arc::new(StaticAnalyzer::new()),
143            llm_reviewer: Arc::new(LlmReviewer::new(ollama, &config)),
144            sandbox: Arc::new(SandboxManager::new(&config)),
145            extractor: CodeBlockExtractor::new(),
146            report_store,
147            config,
148        }
149    }
150
151    pub fn config(&self) -> &ShadowConfig {
152        &self.config
153    }
154
155    /// Reload configuration from disk.
156    pub fn reload_config(&mut self) {
157        let new_config = ShadowConfig::load();
158        let ollama = OllamaClient::new();
159        self.llm_reviewer = Arc::new(LlmReviewer::new(ollama, &new_config));
160        self.sandbox = Arc::new(SandboxManager::new(&new_config));
161        self.config = new_config;
162    }
163
164    /// Submit output for async red-team analysis.
165    ///
166    /// Returns immediately — all work happens in a spawned task.
167    /// Events are emitted via the returned channel.
168    pub fn analyze_async(
169        &self,
170        session_id: String,
171        ego_output: String,
172    ) -> mpsc::Receiver<ShadowEvent> {
173        let (tx, rx) = mpsc::channel::<ShadowEvent>(32);
174
175        if !self.config.enabled {
176            return rx;
177        }
178
179        let config = self.config.clone();
180        let static_analyzer = Arc::clone(&self.static_analyzer);
181        let llm_reviewer = Arc::clone(&self.llm_reviewer);
182        let sandbox = Arc::clone(&self.sandbox);
183        let extractor = self.extractor.clone();
184        let store = Arc::clone(&self.report_store);
185
186        tokio::spawn(async move {
187            let start = Instant::now();
188            let _ = tx
189                .send(ShadowEvent::Started {
190                    session_id: session_id.clone(),
191                })
192                .await;
193
194            let code_blocks = extractor.extract(&ego_output);
195            let mut all_findings = Vec::new();
196            let mut static_run = false;
197            let mut llm_run = false;
198            let mut sandbox_run = false;
199
200            // Stage 1: Static analysis
201            if config.aggressiveness >= 1 {
202                match static_analyzer.analyze(&ego_output, &code_blocks).await {
203                    Ok(findings) => {
204                        static_run = true;
205                        for f in &findings {
206                            let _ = tx
207                                .send(ShadowEvent::Finding {
208                                    session_id: session_id.clone(),
209                                    finding: f.clone(),
210                                })
211                                .await;
212                        }
213                        all_findings.extend(findings);
214                    }
215                    Err(e) => {
216                        tracing::warn!("Shadow static analyzer error: {e}");
217                        let _ = tx
218                            .send(ShadowEvent::AnalyzerError {
219                                session_id: session_id.clone(),
220                                analyzer: static_analyzer.name().to_string(),
221                                error: e.to_string(),
222                            })
223                            .await;
224                    }
225                }
226            }
227
228            // Stage 2: LLM adversarial review
229            if config.aggressiveness >= 2
230                && config.llm_review_enabled
231                && llm_reviewer.is_available().await
232            {
233                match llm_reviewer.analyze(&ego_output, &code_blocks).await {
234                    Ok(findings) => {
235                        llm_run = true;
236                        for f in &findings {
237                            let _ = tx
238                                .send(ShadowEvent::Finding {
239                                    session_id: session_id.clone(),
240                                    finding: f.clone(),
241                                })
242                                .await;
243                        }
244                        all_findings.extend(findings);
245                    }
246                    Err(e) => {
247                        tracing::warn!("Shadow LLM reviewer error: {e}");
248                        let _ = tx
249                            .send(ShadowEvent::AnalyzerError {
250                                session_id: session_id.clone(),
251                                analyzer: llm_reviewer.name().to_string(),
252                                error: e.to_string(),
253                            })
254                            .await;
255                    }
256                }
257            }
258
259            // Stage 3: Sandbox execution
260            let has_substantial_code = code_blocks
261                .iter()
262                .any(|b| b.content.len() >= config.sandbox_min_code_len);
263
264            if config.aggressiveness >= 3
265                && config.sandbox_enabled
266                && has_substantial_code
267                && sandbox.is_available().await
268            {
269                match sandbox.analyze(&ego_output, &code_blocks).await {
270                    Ok(findings) => {
271                        sandbox_run = true;
272                        for f in &findings {
273                            let _ = tx
274                                .send(ShadowEvent::Finding {
275                                    session_id: session_id.clone(),
276                                    finding: f.clone(),
277                                })
278                                .await;
279                        }
280                        all_findings.extend(findings);
281                    }
282                    Err(e) => {
283                        tracing::warn!("Shadow sandbox error: {e}");
284                        let _ = tx
285                            .send(ShadowEvent::AnalyzerError {
286                                session_id: session_id.clone(),
287                                analyzer: sandbox.name().to_string(),
288                                error: e.to_string(),
289                            })
290                            .await;
291                    }
292                }
293            }
294
295            // Deduplicate
296            all_findings.sort_by(|a, b| {
297                a.category
298                    .to_string()
299                    .cmp(&b.category.to_string())
300                    .then(a.title.cmp(&b.title))
301                    .then(a.evidence.cmp(&b.evidence))
302            });
303            all_findings.dedup_by(|a, b| {
304                a.category == b.category && a.title == b.title && a.evidence == b.evidence
305            });
306
307            let max_severity = all_findings
308                .iter()
309                .map(|f| f.severity)
310                .max()
311                .unwrap_or(VulnSeverity::Info);
312
313            let clean = all_findings.is_empty();
314            let summary = build_summary(&all_findings, static_run, llm_run, sandbox_run);
315            let duration = start.elapsed();
316
317            let report = VulnReport {
318                session_id: session_id.clone(),
319                ego_response_excerpt: ego_output.chars().take(200).collect(),
320                findings: all_findings,
321                max_severity,
322                analysis_duration_ms: duration.as_millis() as u64,
323                static_run,
324                llm_run,
325                sandbox_run,
326                clean,
327                summary,
328            };
329
330            {
331                let mut reports = store.write().await;
332                if reports.len() >= MAX_REPORTS {
333                    reports.pop_front();
334                }
335                reports.push_back(report.clone());
336            }
337
338            if !report.clean {
339                tracing::info!(
340                    "Shadow found {} issue(s) (max severity: {}) in {}ms",
341                    report.findings.len(),
342                    report.max_severity,
343                    report.analysis_duration_ms
344                );
345            }
346
347            let _ = tx.send(ShadowEvent::Done { session_id, report }).await;
348        });
349
350        rx
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[tokio::test]
359    async fn test_shadow_disabled() {
360        let store = create_report_store();
361        let engine = ShadowEngine::with_config(
362            store,
363            ShadowConfig {
364                enabled: false,
365                ..Default::default()
366            },
367            OllamaClient::new(),
368        );
369        let mut rx = engine.analyze_async("test".into(), "hello".into());
370        assert!(rx.recv().await.is_none());
371    }
372
373    #[tokio::test]
374    async fn test_shadow_clean_output() {
375        let store = create_report_store();
376        let config = ShadowConfig {
377            aggressiveness: 1,
378            enabled: true,
379            ..Default::default()
380        };
381        let engine = ShadowEngine::with_config(store.clone(), config, OllamaClient::new());
382
383        let mut rx = engine.analyze_async(
384            "test".into(),
385            "```rust\nfn greet() -> String { \"hello\".to_string() }\n```".into(),
386        );
387
388        let mut got_done = false;
389        while let Some(event) = rx.recv().await {
390            if let ShadowEvent::Done { report, .. } = event {
391                got_done = true;
392                assert!(report.clean);
393                assert!(report.static_run);
394            }
395        }
396        assert!(got_done);
397    }
398
399    #[tokio::test]
400    async fn test_shadow_detects_eval() {
401        let store = create_report_store();
402        let config = ShadowConfig {
403            aggressiveness: 1,
404            enabled: true,
405            ..Default::default()
406        };
407        let engine = ShadowEngine::with_config(store.clone(), config, OllamaClient::new());
408
409        let mut rx = engine.analyze_async("vuln".into(), "```js\neval(userInput);\n```".into());
410
411        let mut found = false;
412        while let Some(event) = rx.recv().await {
413            if let ShadowEvent::Finding { .. } = event {
414                found = true;
415            }
416        }
417        assert!(found);
418
419        let reports = store.read().await;
420        assert_eq!(reports.len(), 1);
421        assert!(!reports[0].clean);
422    }
423
424    #[tokio::test]
425    async fn test_report_store_bounded() {
426        let store = create_report_store();
427        let mut reports = store.write().await;
428        for i in 0..MAX_REPORTS + 5 {
429            reports.push_back(VulnReport::clean(
430                format!("s-{i}"),
431                "test".into(),
432                std::time::Duration::from_millis(1),
433            ));
434            if reports.len() > MAX_REPORTS {
435                reports.pop_front();
436            }
437        }
438        assert_eq!(reports.len(), MAX_REPORTS);
439    }
440}