Skip to main content

laminae_shadow/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4use crate::report::VulnSeverity;
5
6/// Minimum severity threshold that triggers automatic self-healing.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum HealThreshold {
10    Medium,
11    High,
12    Critical,
13}
14
15impl HealThreshold {
16    pub fn matches(&self, severity: VulnSeverity) -> bool {
17        match self {
18            HealThreshold::Critical => severity >= VulnSeverity::Critical,
19            HealThreshold::High => severity >= VulnSeverity::High,
20            HealThreshold::Medium => severity >= VulnSeverity::Medium,
21        }
22    }
23}
24
25/// Configuration for the Shadow red-teaming engine.
26///
27/// Can be loaded from a JSON file or constructed programmatically.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct ShadowConfig {
31    /// Master enable/disable.
32    #[serde(default = "default_enabled")]
33    pub enabled: bool,
34
35    /// 1 = static only, 2 = static + LLM, 3 = static + LLM + sandbox.
36    #[serde(default = "default_level")]
37    pub aggressiveness: u8,
38
39    /// Whether to run the LLM adversarial reviewer.
40    #[serde(default = "default_true")]
41    pub llm_review_enabled: bool,
42
43    /// Whether to attempt sandbox execution (requires Docker/Podman).
44    #[serde(default)]
45    pub sandbox_enabled: bool,
46
47    /// Ollama model for the Shadow reviewer.
48    #[serde(default = "default_shadow_model")]
49    pub shadow_model: String,
50
51    /// Temperature for the Shadow LLM (low = deterministic threat analysis).
52    #[serde(default = "default_temperature")]
53    pub temperature: f32,
54
55    /// Max tokens for Shadow LLM response.
56    #[serde(default = "default_max_tokens")]
57    pub max_tokens: i32,
58
59    /// Severity threshold for auto-healing. None = healing disabled.
60    #[serde(default)]
61    pub auto_heal_threshold: Option<HealThreshold>,
62
63    /// Docker image for sandbox execution.
64    #[serde(default = "default_sandbox_image")]
65    pub sandbox_image: String,
66
67    /// TTL in seconds before sandbox container is force-killed.
68    #[serde(default = "default_sandbox_ttl")]
69    pub sandbox_ttl_secs: u64,
70
71    /// Minimum code block size (chars) to trigger sandbox analysis.
72    #[serde(default = "default_min_code_len")]
73    pub sandbox_min_code_len: usize,
74
75    /// Maximum characters of output to send to LLM reviewer.
76    #[serde(default = "default_max_input_len")]
77    pub max_input_len: usize,
78
79    /// Path to config file. If None, uses default location.
80    #[serde(skip)]
81    pub config_path: Option<PathBuf>,
82}
83
84fn default_enabled() -> bool {
85    true
86}
87fn default_level() -> u8 {
88    2
89}
90fn default_true() -> bool {
91    true
92}
93fn default_shadow_model() -> String {
94    "qwen2.5:14b".to_string()
95}
96fn default_temperature() -> f32 {
97    0.05
98}
99fn default_max_tokens() -> i32 {
100    2048
101}
102fn default_sandbox_image() -> String {
103    "python:3.12-slim".to_string()
104}
105fn default_sandbox_ttl() -> u64 {
106    30
107}
108fn default_min_code_len() -> usize {
109    100
110}
111fn default_max_input_len() -> usize {
112    4000
113}
114
115impl Default for ShadowConfig {
116    fn default() -> Self {
117        Self {
118            enabled: default_enabled(),
119            aggressiveness: default_level(),
120            llm_review_enabled: default_true(),
121            sandbox_enabled: false,
122            shadow_model: default_shadow_model(),
123            temperature: default_temperature(),
124            max_tokens: default_max_tokens(),
125            auto_heal_threshold: None,
126            sandbox_image: default_sandbox_image(),
127            sandbox_ttl_secs: default_sandbox_ttl(),
128            sandbox_min_code_len: default_min_code_len(),
129            max_input_len: default_max_input_len(),
130            config_path: None,
131        }
132    }
133}
134
135impl ShadowConfig {
136    /// Load config from disk, falling back to defaults.
137    pub fn load() -> Self {
138        Self::load_from(Self::default_config_path())
139    }
140
141    /// Load from a specific path.
142    pub fn load_from(path: PathBuf) -> Self {
143        match std::fs::read_to_string(&path) {
144            Ok(content) => {
145                let mut config: Self = match serde_json::from_str(&content) {
146                    Ok(c) => c,
147                    Err(e) => {
148                        tracing::warn!(
149                            "Failed to parse Shadow config at {}: {e}, using defaults",
150                            path.display()
151                        );
152                        Self::default()
153                    }
154                };
155                config.config_path = Some(path);
156                config.clamp();
157                config
158            }
159            Err(_) => {
160                let mut config = Self {
161                    config_path: Some(path),
162                    ..Self::default()
163                };
164                config.clamp();
165                config
166            }
167        }
168    }
169
170    /// Persist config to disk.
171    pub fn save(&self) -> anyhow::Result<()> {
172        let path = self
173            .config_path
174            .clone()
175            .unwrap_or_else(Self::default_config_path);
176        if let Some(parent) = path.parent() {
177            std::fs::create_dir_all(parent)?;
178        }
179        std::fs::write(&path, serde_json::to_string_pretty(self)?)?;
180        Ok(())
181    }
182
183    /// Ensure all values are within sane bounds.
184    pub fn clamp(&mut self) {
185        self.aggressiveness = self.aggressiveness.clamp(1, 3);
186        self.temperature = self.temperature.clamp(0.0, 1.0);
187        self.max_tokens = self.max_tokens.clamp(256, 8192);
188        self.sandbox_ttl_secs = self.sandbox_ttl_secs.clamp(5, 300);
189        self.sandbox_min_code_len = self.sandbox_min_code_len.clamp(20, 10_000);
190        self.max_input_len = self.max_input_len.clamp(500, 16_000);
191    }
192
193    fn default_config_path() -> PathBuf {
194        dirs::config_dir()
195            .unwrap_or_else(|| PathBuf::from("/tmp"))
196            .join("laminae/shadow.json")
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_defaults_are_sane() {
206        let config = ShadowConfig::default();
207        assert!(config.enabled);
208        assert_eq!(config.aggressiveness, 2);
209        assert!(config.llm_review_enabled);
210        assert!(!config.sandbox_enabled);
211    }
212
213    #[test]
214    fn test_clamp_enforces_bounds() {
215        let mut config = ShadowConfig {
216            aggressiveness: 99,
217            temperature: 5.0,
218            max_tokens: 0,
219            sandbox_ttl_secs: 1,
220            ..Default::default()
221        };
222        config.clamp();
223        assert_eq!(config.aggressiveness, 3);
224        assert_eq!(config.temperature, 1.0);
225        assert_eq!(config.max_tokens, 256);
226        assert_eq!(config.sandbox_ttl_secs, 5);
227    }
228
229    #[test]
230    fn test_roundtrip_serde() {
231        let config = ShadowConfig::default();
232        let json = serde_json::to_string(&config).unwrap();
233        let parsed: ShadowConfig = serde_json::from_str(&json).unwrap();
234        assert_eq!(parsed.aggressiveness, config.aggressiveness);
235        assert_eq!(parsed.shadow_model, config.shadow_model);
236    }
237
238    #[test]
239    fn test_heal_threshold() {
240        assert!(HealThreshold::Critical.matches(VulnSeverity::Critical));
241        assert!(!HealThreshold::Critical.matches(VulnSeverity::High));
242        assert!(HealThreshold::High.matches(VulnSeverity::Critical));
243        assert!(HealThreshold::High.matches(VulnSeverity::High));
244    }
245}