use regex::Regex;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const CONFIG_ENV_VAR: &str = "VALLUM_CONFIG";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct AppConfig {
pub audit: AuditConfig,
pub pipeline: PipelineConfig,
pub scrubber: ScrubberConfig,
pub security: SecurityConfig,
pub optimizer: OptimizerConfig,
pub policy: PolicyConfig,
pub privacy: PrivacyConfig,
#[serde(skip)]
pub project: Option<ProjectProvenance>,
}
#[derive(Debug, Clone)]
pub struct ProjectProvenance {
pub path: PathBuf,
pub accepted_rules: usize,
pub rejected: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AuditConfig {
pub log_dir: Option<PathBuf>,
pub raw_enabled: bool,
pub sanitized_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PipelineConfig {
pub head_lines: usize,
pub tail_lines: usize,
pub min_optimize_tokens: usize,
pub max_output_bytes: usize,
pub timeout_secs: u64,
pub max_line_length: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ScrubberConfig {
pub extra_secret_patterns: Vec<RedactionRule>,
pub entropy: bool,
pub normalize: bool,
}
impl Default for ScrubberConfig {
fn default() -> Self {
Self {
extra_secret_patterns: Vec::new(),
entropy: true,
normalize: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PrivacyConfig {
pub enabled: bool,
pub categories: Vec<String>,
}
impl Default for PrivacyConfig {
fn default() -> Self {
Self {
enabled: false,
categories: crate::scrubber::pii::alias::Category::ALL
.iter()
.map(|c| c.tag().to_string())
.collect(),
}
}
}
impl PrivacyConfig {
pub fn active(&self) -> Vec<crate::scrubber::pii::alias::Category> {
self.categories
.iter()
.filter_map(|t| crate::scrubber::pii::alias::Category::from_tag(t))
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityConfig {
pub strict: bool,
pub guardrail: bool,
pub assume_yes: bool,
pub circuit_breaker: bool,
pub breaker_threshold: u32,
pub breaker_window_secs: u64,
pub breaker_cooldown_secs: u64,
pub approval_cache: bool,
pub approval_cache_ttl_days: u64,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
strict: false,
guardrail: true,
assume_yes: false,
circuit_breaker: true,
breaker_threshold: 5,
breaker_window_secs: 60,
breaker_cooldown_secs: 300,
approval_cache: true,
approval_cache_ttl_days: 14,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct OptimizerConfig {
pub disabled: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RedactionRule {
pub pattern: String,
pub replacement: String,
}
impl<'de> Deserialize<'de> for RedactionRule {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct RuleVisitor;
impl<'de> serde::de::Visitor<'de> for RuleVisitor {
type Value = RedactionRule;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "a table like {{ pattern = \"…\", replacement = \"…\" }}")
}
fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
Err(E::custom(format!(
"bare string \"{s}\" is not a valid secret pattern entry; use a table: \
extra_secret_patterns = [{{ pattern = \"{s}\", replacement = \"***\" }}]"
)))
}
fn visit_map<M: serde::de::MapAccess<'de>>(
self,
mut map: M,
) -> Result<Self::Value, M::Error> {
let mut pattern = None;
let mut replacement = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"pattern" => pattern = Some(map.next_value()?),
"replacement" => replacement = Some(map.next_value()?),
other => {
return Err(serde::de::Error::unknown_field(
other,
&["pattern", "replacement"],
))
}
}
}
Ok(RedactionRule {
pattern: pattern.ok_or_else(|| serde::de::Error::missing_field("pattern"))?,
replacement: replacement
.ok_or_else(|| serde::de::Error::missing_field("replacement"))?,
})
}
}
deserializer.deserialize_any(RuleVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct PolicyConfig {
pub rules: Vec<PolicyRuleConfig>,
pub allow: Vec<PolicyAllowConfig>,
#[serde(skip)]
pub project_rules: Vec<PolicyRuleConfig>,
pub disabled: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRuleConfig {
pub pattern: String,
pub action: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyAllowConfig {
pub pattern: String,
pub suppresses: String,
pub reason: String,
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
log_dir: None,
raw_enabled: false,
sanitized_enabled: true,
}
}
}
impl Default for PipelineConfig {
fn default() -> Self {
Self {
head_lines: 50,
tail_lines: 50,
min_optimize_tokens: 50,
max_output_bytes: 10 * 1024 * 1024,
timeout_secs: 300,
max_line_length: 2000,
}
}
}
impl AppConfig {
pub fn load() -> Result<Self, String> {
let path = config_path_from_env_or_default();
let mut config = Self::from_path(&path)?;
config.apply_project_overlay(crate::project_config::load());
Ok(config)
}
pub fn apply_project_overlay(&mut self, outcome: crate::project_config::LoadOutcome) {
match outcome {
crate::project_config::LoadOutcome::None => {}
crate::project_config::LoadOutcome::Loaded { path, rules } => {
self.project = Some(ProjectProvenance {
path,
accepted_rules: rules.len(),
rejected: None,
});
self.policy.project_rules = rules;
}
crate::project_config::LoadOutcome::Rejected { path, reason } => {
self.project = Some(ProjectProvenance {
path,
accepted_rules: 0,
rejected: Some(reason),
});
}
}
}
pub fn from_path(path: &Path) -> Result<Self, String> {
if !path.exists() {
return Ok(Self::default());
}
let raw = fs::read_to_string(path)
.map_err(|e| format!("failed to read config {}: {}", path.display(), e))?;
let config: Self = toml::from_str(&raw)
.map_err(|e| format!("failed to parse config {}: {}", path.display(), e))?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), String> {
for rule in &self.scrubber.extra_secret_patterns {
Regex::new(&rule.pattern)
.map_err(|e| format!("invalid scrubber regex '{}': {}", rule.pattern, e))?;
}
for tag in &self.privacy.categories {
if crate::scrubber::pii::alias::Category::from_tag(tag).is_none() {
let known: Vec<&str> = crate::scrubber::pii::alias::Category::ALL
.iter()
.map(|c| c.tag())
.collect();
return Err(format!(
"unknown privacy category '{}'; known categories: {}",
tag,
known.join(", ")
));
}
}
for rule in &self.policy.rules {
match rule.action.as_str() {
"ask" | "deny" => {}
"allow" => {
return Err(format!(
"policy rule action \"allow\" is not allowed (pattern '{}'); \
user rules may only \"ask\" or \"deny\" — use [[policy.allow]] \
for a scoped exception, or [policy] disabled to suppress a built-in",
rule.pattern
))
}
other => {
return Err(format!(
"invalid policy rule action \"{}\" (pattern '{}'); expected \"ask\" or \"deny\"",
other, rule.pattern
))
}
}
Regex::new(&rule.pattern)
.map_err(|e| format!("invalid policy regex '{}': {}", rule.pattern, e))?;
}
for rule in &self.policy.allow {
let re = Regex::new(&rule.pattern)
.map_err(|e| format!("invalid policy allow regex '{}': {}", rule.pattern, e))?;
if re.is_match("") {
return Err(format!(
"policy allow pattern '{}' matches the empty string (too broad); \
anchor it to the exact command shape, e.g. \
'^git push --force origin main-backup$'",
rule.pattern
));
}
if !crate::policy::builtin_names().contains(&rule.suppresses.as_str()) {
return Err(format!(
"policy allow 'suppresses' names unknown built-in \"{}\" (pattern '{}'); \
valid names: {}",
rule.suppresses,
rule.pattern,
crate::policy::builtin_names().join(", ")
));
}
if rule.reason.trim().is_empty() {
return Err(format!(
"policy allow entry (pattern '{}') needs a non-empty reason",
rule.pattern
));
}
}
if self.security.breaker_threshold == 0 {
return Err("breaker_threshold must be at least 1".to_string());
}
if self.security.breaker_window_secs == 0 {
return Err("breaker_window_secs must be at least 1".to_string());
}
if self.security.breaker_cooldown_secs == 0 {
return Err("breaker_cooldown_secs must be at least 1".to_string());
}
if !(1..=90).contains(&self.security.approval_cache_ttl_days) {
return Err("approval_cache_ttl_days must be between 1 and 90".to_string());
}
Ok(())
}
}
pub fn config_path_from_env_or_default() -> PathBuf {
if let Ok(path) = env::var(CONFIG_ENV_VAR) {
PathBuf::from(path)
} else if let Some(home) = dirs::home_dir() {
home.join(".vallum").join("config.toml")
} else {
PathBuf::from("vallum-config.toml")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn missing_config_uses_defaults() {
let path = unique_temp_path("missing");
let config = AppConfig::from_path(&path).unwrap();
assert!(!config.audit.raw_enabled);
assert!(config.audit.sanitized_enabled);
assert_eq!(config.pipeline.head_lines, 50);
assert_eq!(config.pipeline.tail_lines, 50);
assert_eq!(config.pipeline.min_optimize_tokens, 50);
assert_eq!(config.pipeline.max_output_bytes, 10 * 1024 * 1024);
assert_eq!(config.pipeline.timeout_secs, 300);
assert_eq!(config.pipeline.max_line_length, 2000);
assert!(config.scrubber.extra_secret_patterns.is_empty());
}
#[test]
fn parses_config_file_and_validates_regex() {
let dir = unique_temp_path("valid");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
fs::write(
&path,
format!(
r#"
[audit]
log_dir = "{}"
raw_enabled = false
sanitized_enabled = true
[pipeline]
head_lines = 3
tail_lines = 2
[scrubber]
extra_secret_patterns = [{{ pattern = "token-[0-9]+", replacement = "token-***" }}]
"#,
dir.join("logs").display()
),
)
.unwrap();
let config = AppConfig::from_path(&path).unwrap();
assert_eq!(config.audit.log_dir.as_ref().unwrap(), &dir.join("logs"));
assert!(!config.audit.raw_enabled);
assert!(config.audit.sanitized_enabled);
assert_eq!(config.pipeline.head_lines, 3);
assert_eq!(config.pipeline.tail_lines, 2);
assert_eq!(config.scrubber.extra_secret_patterns.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rejects_invalid_regex_in_config() {
let dir = unique_temp_path("invalid");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
fs::write(
&path,
r#"
[scrubber]
extra_secret_patterns = [ { pattern = "token-(", replacement = "token-***" } ]
"#,
)
.unwrap();
let err = AppConfig::from_path(&path).unwrap_err();
assert!(err.contains("invalid scrubber regex"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn bare_string_secret_pattern_error_shows_expected_form() {
let dir = unique_temp_path("barestr");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
fs::write(
&path,
r#"
[scrubber]
extra_secret_patterns = ["token-[0-9]+"]
"#,
)
.unwrap();
let err = AppConfig::from_path(&path).unwrap_err();
assert!(
err.contains("pattern =") && err.contains("replacement ="),
"error must show the expected table form, got: {err}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn table_secret_pattern_missing_field_still_clear() {
let dir = unique_temp_path("missingfield");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
fs::write(
&path,
r#"
[scrubber]
extra_secret_patterns = [ { pattern = "token-[0-9]+" } ]
"#,
)
.unwrap();
let err = AppConfig::from_path(&path).unwrap_err();
assert!(
err.contains("replacement"),
"error must name the missing field, got: {err}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn security_strict_defaults_false_and_parses() {
let def = AppConfig::default();
assert!(!def.security.strict);
let dir = unique_temp_path("security");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
fs::write(&path, "[security]\nstrict = true\n").unwrap();
let config = AppConfig::from_path(&path).unwrap();
assert!(config.security.strict);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn privacy_defaults_to_off_with_all_categories() {
let cfg = AppConfig::default();
assert!(!cfg.privacy.enabled);
assert_eq!(cfg.privacy.categories.len(), 7);
assert_eq!(cfg.privacy.active().len(), 7);
}
#[test]
fn privacy_parses_and_narrows_categories() {
let cfg: AppConfig =
toml::from_str("[privacy]\nenabled = true\ncategories = [\"tckn\", \"iban\"]\n")
.expect("valid toml");
assert!(cfg.privacy.enabled);
let active = cfg.privacy.active();
assert_eq!(active.len(), 2);
assert!(active.contains(&crate::scrubber::pii::alias::Category::Tckn));
assert!(active.contains(&crate::scrubber::pii::alias::Category::Iban));
}
#[test]
fn unknown_privacy_category_is_rejected_by_name() {
let cfg: AppConfig =
toml::from_str("[privacy]\ncategories = [\"tckn\", \"ssn\"]\n").expect("valid toml");
let err = cfg.validate().unwrap_err();
assert!(err.contains("ssn"), "got: {err}");
assert!(err.contains("privacy"), "got: {err}");
}
#[test]
fn scrubber_entropy_defaults_true_and_parses_false() {
assert!(AppConfig::default().scrubber.entropy);
let parsed: AppConfig =
toml::from_str("[scrubber]\nentropy = false\n").expect("valid toml");
assert!(!parsed.scrubber.entropy);
}
#[test]
fn scrubber_normalize_defaults_true_and_parses_false() {
assert!(AppConfig::default().scrubber.normalize);
let parsed: AppConfig =
toml::from_str("[scrubber]\nnormalize = false\n").expect("valid toml");
assert!(!parsed.scrubber.normalize);
}
#[test]
fn optimizer_disabled_defaults_empty_and_parses() {
assert!(AppConfig::default().optimizer.disabled.is_empty());
let dir = unique_temp_path("optimizer");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
fs::write(&path, "[optimizer]\ndisabled = [\"npm\", \"docker\"]\n").unwrap();
let config = AppConfig::from_path(&path).unwrap();
assert_eq!(
config.optimizer.disabled,
vec!["npm".to_string(), "docker".to_string()]
);
let _ = fs::remove_dir_all(&dir);
}
fn unique_temp_path(name: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("vallum_config_test_{}_{}", name, suffix))
}
fn write_tmp(name: &str, body: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"vallum_cfg_{}_{}",
name,
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("config.toml");
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn guardrail_defaults_on_assume_yes_off() {
let cfg = AppConfig::default();
assert!(cfg.security.guardrail);
assert!(!cfg.security.assume_yes);
assert!(cfg.policy.rules.is_empty());
assert!(cfg.policy.disabled.is_empty());
}
#[test]
fn breaker_defaults_on_with_conservative_thresholds() {
let c = SecurityConfig::default();
assert!(c.circuit_breaker);
assert_eq!(c.breaker_threshold, 5);
assert_eq!(c.breaker_window_secs, 60);
assert_eq!(c.breaker_cooldown_secs, 300);
}
#[test]
fn breaker_zero_threshold_is_config_error() {
let mut config = AppConfig::default();
config.security.breaker_threshold = 0;
let err = config.validate().unwrap_err();
assert!(err.contains("breaker_threshold"), "{err}");
config.security.breaker_threshold = 5;
config.security.breaker_window_secs = 0;
let err = config.validate().unwrap_err();
assert!(err.contains("breaker_window_secs"), "{err}");
config.security.breaker_window_secs = 60;
config.security.breaker_cooldown_secs = 0;
let err = config.validate().unwrap_err();
assert!(err.contains("breaker_cooldown_secs"), "{err}");
}
#[test]
fn policy_rule_parses_and_validates() {
let p = write_tmp(
"ok",
"[[policy.rules]]\npattern = 'terraform\\s+destroy'\naction = \"deny\"\nreason = \"blocked\"\n",
);
let cfg = AppConfig::from_path(&p).unwrap();
assert_eq!(cfg.policy.rules.len(), 1);
assert_eq!(cfg.policy.rules[0].action, "deny");
}
#[test]
fn policy_rule_allow_action_is_error() {
let p = write_tmp(
"allow",
"[[policy.rules]]\npattern = 'x'\naction = \"allow\"\nreason = \"r\"\n",
);
let err = AppConfig::from_path(&p).unwrap_err();
assert!(err.contains("allow"), "got: {err}");
}
#[test]
fn policy_rule_bad_regex_is_error() {
let p = write_tmp(
"badre",
"[[policy.rules]]\npattern = '('\naction = \"ask\"\nreason = \"r\"\n",
);
assert!(AppConfig::from_path(&p).is_err());
}
#[test]
fn policy_rule_unknown_action_is_error() {
let p = write_tmp(
"unk",
"[[policy.rules]]\npattern = 'x'\naction = \"warn\"\nreason = \"r\"\n",
);
let err = AppConfig::from_path(&p).unwrap_err();
assert!(err.contains("warn") || err.contains("action"), "got: {err}");
}
#[test]
fn policy_allow_parses_and_validates() {
let p = write_tmp(
"allow_ok",
"[[policy.allow]]\npattern = '^git push --force origin main-backup$'\nsuppresses = \"git_push_force\"\nreason = \"release flow\"\n",
);
let cfg = AppConfig::from_path(&p).unwrap();
assert_eq!(cfg.policy.allow.len(), 1);
assert_eq!(cfg.policy.allow[0].suppresses, "git_push_force");
}
#[test]
fn policy_allow_unknown_suppresses_is_error() {
let p = write_tmp(
"allow_unknown",
"[[policy.allow]]\npattern = '^x$'\nsuppresses = \"no_such_rule\"\nreason = \"r\"\n",
);
let err = AppConfig::from_path(&p).unwrap_err();
assert!(err.contains("no_such_rule"), "got: {err}");
assert!(
err.contains("git_push_force"),
"must list valid names: {err}"
);
}
#[test]
fn policy_allow_empty_matching_pattern_is_error() {
for pat in [".*", "a*", "x?"] {
let p = write_tmp(
"allow_broad",
&format!(
"[[policy.allow]]\npattern = '{pat}'\nsuppresses = \"git_push_force\"\nreason = \"r\"\n"
),
);
let err = AppConfig::from_path(&p).unwrap_err();
assert!(err.contains("empty string"), "pattern {pat}: {err}");
}
}
#[test]
fn policy_allow_bad_regex_and_empty_reason_are_errors() {
let p = write_tmp(
"allow_badre",
"[[policy.allow]]\npattern = '('\nsuppresses = \"git_push_force\"\nreason = \"r\"\n",
);
assert!(AppConfig::from_path(&p).is_err());
let p = write_tmp(
"allow_noreason",
"[[policy.allow]]\npattern = '^x$'\nsuppresses = \"git_push_force\"\nreason = \" \"\n",
);
let err = AppConfig::from_path(&p).unwrap_err();
assert!(err.contains("reason"), "got: {err}");
}
#[test]
fn policy_rules_allow_action_hint_mentions_policy_allow() {
let p = write_tmp(
"allow_hint",
"[[policy.rules]]\npattern = 'x'\naction = \"allow\"\nreason = \"r\"\n",
);
let err = AppConfig::from_path(&p).unwrap_err();
assert!(err.contains("policy.allow"), "got: {err}");
}
#[test]
fn approval_cache_defaults_on_14_days() {
let c = SecurityConfig::default();
assert!(c.approval_cache);
assert_eq!(c.approval_cache_ttl_days, 14);
}
#[test]
fn approval_cache_ttl_bounds_are_config_errors() {
let mut config = AppConfig::default();
config.security.approval_cache_ttl_days = 0;
assert!(config
.validate()
.unwrap_err()
.contains("approval_cache_ttl_days"));
config.security.approval_cache_ttl_days = 91;
assert!(config
.validate()
.unwrap_err()
.contains("approval_cache_ttl_days"));
config.security.approval_cache_ttl_days = 90;
assert!(config.validate().is_ok());
}
#[test]
fn overlay_loaded_appends_project_rules_and_provenance() {
let mut cfg = AppConfig::default();
cfg.apply_project_overlay(crate::project_config::LoadOutcome::Loaded {
path: PathBuf::from("/repo/.vallum.toml"),
rules: vec![PolicyRuleConfig {
pattern: "x".into(),
action: "deny".into(),
reason: "r".into(),
}],
});
assert_eq!(cfg.policy.project_rules.len(), 1);
let p = cfg.project.as_ref().unwrap();
assert_eq!(p.accepted_rules, 1);
assert!(p.rejected.is_none());
}
#[test]
fn overlay_rejected_records_reason_and_adds_no_rules() {
let mut cfg = AppConfig::default();
cfg.apply_project_overlay(crate::project_config::LoadOutcome::Rejected {
path: PathBuf::from("/repo/.vallum.toml"),
reason: "unknown field `security`".into(),
});
assert!(
cfg.policy.project_rules.is_empty(),
"rejected file adds nothing"
);
assert_eq!(
cfg.project.as_ref().unwrap().rejected.as_deref(),
Some("unknown field `security`")
);
}
#[test]
fn project_fields_do_not_serialize_into_config_show() {
let mut cfg = AppConfig::default();
cfg.policy.project_rules = vec![PolicyRuleConfig {
pattern: "x".into(),
action: "deny".into(),
reason: "r".into(),
}];
let toml_body = toml::to_string_pretty(&cfg).unwrap();
assert!(
!toml_body.contains("project"),
"serde-skip must hold: {toml_body}"
);
}
}