use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::report::VulnSeverity;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HealThreshold {
Medium,
High,
Critical,
}
impl HealThreshold {
pub fn matches(&self, severity: VulnSeverity) -> bool {
match self {
HealThreshold::Critical => severity >= VulnSeverity::Critical,
HealThreshold::High => severity >= VulnSeverity::High,
HealThreshold::Medium => severity >= VulnSeverity::Medium,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ShadowConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_level")]
pub aggressiveness: u8,
#[serde(default = "default_true")]
pub llm_review_enabled: bool,
#[serde(default)]
pub sandbox_enabled: bool,
#[serde(default = "default_shadow_model")]
pub shadow_model: String,
#[serde(default = "default_temperature")]
pub temperature: f32,
#[serde(default = "default_max_tokens")]
pub max_tokens: i32,
#[serde(default)]
pub auto_heal_threshold: Option<HealThreshold>,
#[serde(default = "default_sandbox_image")]
pub sandbox_image: String,
#[serde(default = "default_sandbox_ttl")]
pub sandbox_ttl_secs: u64,
#[serde(default = "default_min_code_len")]
pub sandbox_min_code_len: usize,
#[serde(default = "default_max_input_len")]
pub max_input_len: usize,
#[serde(skip)]
pub config_path: Option<PathBuf>,
}
fn default_enabled() -> bool {
true
}
fn default_level() -> u8 {
2
}
fn default_true() -> bool {
true
}
fn default_shadow_model() -> String {
"qwen2.5:14b".to_string()
}
fn default_temperature() -> f32 {
0.05
}
fn default_max_tokens() -> i32 {
2048
}
fn default_sandbox_image() -> String {
"python:3.12-slim".to_string()
}
fn default_sandbox_ttl() -> u64 {
30
}
fn default_min_code_len() -> usize {
100
}
fn default_max_input_len() -> usize {
4000
}
impl Default for ShadowConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
aggressiveness: default_level(),
llm_review_enabled: default_true(),
sandbox_enabled: false,
shadow_model: default_shadow_model(),
temperature: default_temperature(),
max_tokens: default_max_tokens(),
auto_heal_threshold: None,
sandbox_image: default_sandbox_image(),
sandbox_ttl_secs: default_sandbox_ttl(),
sandbox_min_code_len: default_min_code_len(),
max_input_len: default_max_input_len(),
config_path: None,
}
}
}
impl ShadowConfig {
pub fn load() -> Self {
Self::load_from(Self::default_config_path())
}
pub fn load_from(path: PathBuf) -> Self {
match std::fs::read_to_string(&path) {
Ok(content) => {
let mut config: Self = match serde_json::from_str(&content) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"Failed to parse Shadow config at {}: {e}, using defaults",
path.display()
);
Self::default()
}
};
config.config_path = Some(path);
config.clamp();
config
}
Err(_) => {
let mut config = Self {
config_path: Some(path),
..Self::default()
};
config.clamp();
config
}
}
}
pub fn save(&self) -> anyhow::Result<()> {
let path = self
.config_path
.clone()
.unwrap_or_else(Self::default_config_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serde_json::to_string_pretty(self)?)?;
Ok(())
}
pub fn clamp(&mut self) {
self.aggressiveness = self.aggressiveness.clamp(1, 3);
self.temperature = self.temperature.clamp(0.0, 1.0);
self.max_tokens = self.max_tokens.clamp(256, 8192);
self.sandbox_ttl_secs = self.sandbox_ttl_secs.clamp(5, 300);
self.sandbox_min_code_len = self.sandbox_min_code_len.clamp(20, 10_000);
self.max_input_len = self.max_input_len.clamp(500, 16_000);
}
fn default_config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("laminae/shadow.json")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_defaults_are_sane() {
let config = ShadowConfig::default();
assert!(config.enabled);
assert_eq!(config.aggressiveness, 2);
assert!(config.llm_review_enabled);
assert!(!config.sandbox_enabled);
}
#[test]
fn test_clamp_enforces_bounds() {
let mut config = ShadowConfig {
aggressiveness: 99,
temperature: 5.0,
max_tokens: 0,
sandbox_ttl_secs: 1,
..Default::default()
};
config.clamp();
assert_eq!(config.aggressiveness, 3);
assert_eq!(config.temperature, 1.0);
assert_eq!(config.max_tokens, 256);
assert_eq!(config.sandbox_ttl_secs, 5);
}
#[test]
fn test_roundtrip_serde() {
let config = ShadowConfig::default();
let json = serde_json::to_string(&config).unwrap();
let parsed: ShadowConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.aggressiveness, config.aggressiveness);
assert_eq!(parsed.shadow_model, config.shadow_model);
}
#[test]
fn test_heal_threshold() {
assert!(HealThreshold::Critical.matches(VulnSeverity::Critical));
assert!(!HealThreshold::Critical.matches(VulnSeverity::High));
assert!(HealThreshold::High.matches(VulnSeverity::Critical));
assert!(HealThreshold::High.matches(VulnSeverity::High));
}
}