1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4use crate::report::VulnSeverity;
5
6#[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#[derive(Debug, Clone, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct ShadowConfig {
31 #[serde(default = "default_enabled")]
33 pub enabled: bool,
34
35 #[serde(default = "default_level")]
37 pub aggressiveness: u8,
38
39 #[serde(default = "default_true")]
41 pub llm_review_enabled: bool,
42
43 #[serde(default)]
45 pub sandbox_enabled: bool,
46
47 #[serde(default = "default_shadow_model")]
49 pub shadow_model: String,
50
51 #[serde(default = "default_temperature")]
53 pub temperature: f32,
54
55 #[serde(default = "default_max_tokens")]
57 pub max_tokens: i32,
58
59 #[serde(default)]
61 pub auto_heal_threshold: Option<HealThreshold>,
62
63 #[serde(default = "default_sandbox_image")]
65 pub sandbox_image: String,
66
67 #[serde(default = "default_sandbox_ttl")]
69 pub sandbox_ttl_secs: u64,
70
71 #[serde(default = "default_min_code_len")]
73 pub sandbox_min_code_len: usize,
74
75 #[serde(default = "default_max_input_len")]
77 pub max_input_len: usize,
78
79 #[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 pub fn load() -> Self {
138 Self::load_from(Self::default_config_path())
139 }
140
141 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 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 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}