Skip to main content

aidens_config/
lib.rs

1//! Config loading, validation, redaction, and atomic apply planning.
2
3use aidens_contracts::{MemoryModeV1, ReportLevelV1};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7use std::path::{Path, PathBuf};
8use thiserror::Error;
9
10pub const REDACTION_MASK: &str = "********";
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct AiDENsConfigV1 {
14    pub app_id: String,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub profile_id: Option<String>,
17    pub provider: ProviderConfigV1,
18    #[serde(default)]
19    pub tools: ToolsConfigV1,
20    #[serde(default)]
21    pub security: SecurityConfigV1,
22    #[serde(default)]
23    pub receipts: EventLogConfigV1,
24    #[serde(default, skip_serializing_if = "MemoryConfigV1::is_empty")]
25    pub memory: MemoryConfigV1,
26    pub memory_mode: MemoryModeV1,
27    pub receipt_level: ReportLevelV1,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct ProviderConfigV1 {
32    pub kind: String,
33    pub model: Option<String>,
34    pub api_key: Option<String>,
35    pub base_url: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub mock_response: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
41pub struct ToolsConfigV1 {
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub sandbox_root: Option<String>,
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub enabled_bundles: Vec<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
49pub struct EventLogConfigV1 {
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub store_root: Option<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
55pub struct MemoryConfigV1 {
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub store_root: Option<String>,
58}
59
60impl MemoryConfigV1 {
61    pub fn is_empty(&self) -> bool {
62        self.store_root.is_none()
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct SecurityConfigV1 {
68    pub approval_mode: String,
69    pub network_policy: String,
70    pub write_policy: String,
71}
72
73impl Default for SecurityConfigV1 {
74    fn default() -> Self {
75        Self {
76            approval_mode: "require-side-effects".into(),
77            network_policy: "disabled".into(),
78            write_policy: "approval-required".into(),
79        }
80    }
81}
82
83#[derive(Debug, Error)]
84pub enum ConfigError {
85    #[error("failed to read config at {path}: {source}")]
86    Read {
87        path: PathBuf,
88        #[source]
89        source: std::io::Error,
90    },
91    #[error("failed to parse config at {path}: {reason}")]
92    ParseToml { path: PathBuf, reason: String },
93    #[error("failed to render config as toml: {0}")]
94    RenderToml(String),
95    #[error("app_id must not be empty")]
96    EmptyAppId,
97    #[error("provider kind must not be empty")]
98    EmptyProviderKind,
99    #[error("dangerous or unsupported config field at {path}: {reason}")]
100    DangerousField { path: String, reason: String },
101    #[error("provider secret is not allowed for provider kind {kind}")]
102    ProviderSecretNotAllowed { kind: String },
103    #[error("provider endpoint is not allowed: {reason}")]
104    ProviderEndpointNotAllowed { reason: String },
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ConfigLoadOutcomeV1 {
109    pub path: PathBuf,
110    pub canonical_path: PathBuf,
111    pub source_fingerprint: String,
112    pub reason_codes: Vec<String>,
113    pub config: AiDENsConfigV1,
114}
115
116impl AiDENsConfigV1 {
117    pub fn safe_default(app_id: impl Into<String>) -> Self {
118        Self {
119            app_id: app_id.into(),
120            profile_id: Some("chat-only".into()),
121            provider: ProviderConfigV1 {
122                kind: "disabled".into(),
123                model: None,
124                api_key: None,
125                base_url: None,
126                mock_response: None,
127            },
128            tools: ToolsConfigV1::default(),
129            security: SecurityConfigV1::default(),
130            receipts: EventLogConfigV1::default(),
131            memory: MemoryConfigV1::default(),
132            memory_mode: MemoryModeV1::Disabled,
133            receipt_level: ReportLevelV1::Standard,
134        }
135    }
136
137    pub fn validate(&self) -> Result<(), ConfigError> {
138        if self.app_id.trim().is_empty() {
139            return Err(ConfigError::EmptyAppId);
140        }
141        if self.provider.kind.trim().is_empty() {
142            return Err(ConfigError::EmptyProviderKind);
143        }
144        let provider_kind = self.provider.kind.trim().to_ascii_lowercase();
145        if self.provider.api_key.is_some()
146            && matches!(
147                provider_kind.as_str(),
148                "disabled" | "mock" | "local" | "ollama"
149            )
150        {
151            return Err(ConfigError::ProviderSecretNotAllowed {
152                kind: self.provider.kind.clone(),
153            });
154        }
155        if let Some(base_url) = self.provider.base_url.as_deref() {
156            validate_provider_base_url(base_url)?;
157        }
158        validate_security_defaults(self)?;
159        Ok(())
160    }
161
162    pub fn redacted(&self) -> Self {
163        let mut copy = self.clone();
164        if copy.provider.api_key.is_some() {
165            copy.provider.api_key = Some(REDACTION_MASK.into());
166        }
167        if let Some(base_url) = copy.provider.base_url.as_deref() {
168            copy.provider.base_url = Some(redact_url_auth(base_url));
169        }
170        copy
171    }
172
173    pub fn redacted_json(&self) -> Result<Value, serde_json::Error> {
174        let mut value = serde_json::to_value(self)?;
175        redact_sensitive_json(&mut value, None);
176        Ok(value)
177    }
178
179    pub fn to_toml_string(&self) -> Result<String, ConfigError> {
180        toml::to_string_pretty(self).map_err(|e| ConfigError::RenderToml(e.to_string()))
181    }
182}
183
184pub fn load_config_file(path: impl AsRef<Path>) -> Result<ConfigLoadOutcomeV1, ConfigError> {
185    let path = path.as_ref().to_path_buf();
186    let contents = std::fs::read_to_string(&path).map_err(|source| ConfigError::Read {
187        path: path.clone(),
188        source,
189    })?;
190    let raw_toml = contents
191        .parse::<toml::Value>()
192        .map_err(|e| ConfigError::ParseToml {
193            path: path.clone(),
194            reason: e.to_string(),
195        })?;
196    reject_unknown_dangerous_fields(&raw_toml)?;
197    let config =
198        toml::from_str::<AiDENsConfigV1>(&contents).map_err(|e| ConfigError::ParseToml {
199            path: path.clone(),
200            reason: e.to_string(),
201        })?;
202    config.validate()?;
203    let canonical_path = path.canonicalize().map_err(|source| ConfigError::Read {
204        path: path.clone(),
205        source,
206    })?;
207    let source_fingerprint = format!("sha256:{}", hex_sha256(contents.as_bytes()));
208    Ok(ConfigLoadOutcomeV1 {
209        path,
210        canonical_path,
211        source_fingerprint,
212        reason_codes: vec![
213            "config-source-canonicalized".into(),
214            "config-fingerprint-sha256".into(),
215        ],
216        config,
217    })
218}
219
220pub fn redacted_json_value(mut value: Value) -> Value {
221    redact_sensitive_json(&mut value, None);
222    value
223}
224
225fn redact_sensitive_json(value: &mut Value, key: Option<&str>) {
226    match value {
227        Value::Object(map) => {
228            for (child_key, child_value) in map {
229                redact_sensitive_json(child_value, Some(child_key));
230            }
231        }
232        Value::Array(items) => {
233            for item in items {
234                redact_sensitive_json(item, key);
235            }
236        }
237        Value::String(text) => {
238            if key.is_some_and(is_sensitive_key) || looks_like_secret_value(text) {
239                *text = REDACTION_MASK.into();
240            } else {
241                *text = redact_url_auth(text);
242            }
243        }
244        Value::Null | Value::Bool(_) | Value::Number(_) => {}
245    }
246}
247
248fn is_sensitive_key(key: &str) -> bool {
249    let key = key
250        .chars()
251        .filter(|ch| ch.is_ascii_alphanumeric())
252        .collect::<String>()
253        .to_ascii_lowercase();
254    key.contains("api_key")
255        || key.contains("apikey")
256        || key.contains("token")
257        || key.contains("secret")
258        || key.contains("password")
259        || key.contains("credential")
260        || key.contains("accesskey")
261        || key.contains("privatekey")
262        || key.contains("authorization")
263        || key.contains("bearer")
264        || key == "auth"
265}
266
267fn looks_like_secret_value(value: &str) -> bool {
268    let lower = value.to_ascii_lowercase();
269    lower.starts_with("sk-")
270        || lower.starts_with("xoxb-")
271        || lower.starts_with("ghp_")
272        || lower.starts_with("github_pat_")
273        || lower.starts_with("bearer ")
274}
275
276fn redact_url_auth(value: &str) -> String {
277    let Some((scheme, rest)) = value.split_once("://") else {
278        return value.to_string();
279    };
280    let Some((_, host_and_path)) = rest.split_once('@') else {
281        return value.to_string();
282    };
283    format!("{scheme}://{REDACTION_MASK}@{host_and_path}")
284}
285
286fn reject_unknown_dangerous_fields(value: &toml::Value) -> Result<(), ConfigError> {
287    fn walk(path: &str, value: &toml::Value) -> Result<(), ConfigError> {
288        match value {
289            toml::Value::Table(table) => {
290                for (key, child) in table {
291                    let child_path = if path.is_empty() {
292                        key.clone()
293                    } else {
294                        format!("{path}.{key}")
295                    };
296                    if is_sensitive_key(key) && child_path != "provider.api_key" {
297                        return Err(ConfigError::DangerousField {
298                            path: child_path,
299                            reason: "secret-like keys are only accepted at provider.api_key".into(),
300                        });
301                    }
302                    walk(&child_path, child)?;
303                }
304            }
305            toml::Value::Array(items) => {
306                for (index, item) in items.iter().enumerate() {
307                    walk(&format!("{path}[{index}]"), item)?;
308                }
309            }
310            toml::Value::String(text) => {
311                if looks_like_secret_value(text) && path != "provider.api_key" {
312                    return Err(ConfigError::DangerousField {
313                        path: path.into(),
314                        reason: "secret-like values are only accepted at provider.api_key".into(),
315                    });
316                }
317            }
318            toml::Value::Integer(_)
319            | toml::Value::Float(_)
320            | toml::Value::Boolean(_)
321            | toml::Value::Datetime(_) => {}
322        }
323        Ok(())
324    }
325    walk("", value)
326}
327
328fn validate_provider_base_url(base_url: &str) -> Result<(), ConfigError> {
329    if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
330        return Err(ConfigError::ProviderEndpointNotAllowed {
331            reason: "only http or https endpoints are accepted".into(),
332        });
333    }
334    if base_url
335        .split_once("://")
336        .and_then(|(_, rest)| rest.split_once('@'))
337        .is_some()
338    {
339        return Err(ConfigError::ProviderEndpointNotAllowed {
340            reason: "embedded endpoint credentials are not allowed".into(),
341        });
342    }
343    Ok(())
344}
345
346fn validate_security_defaults(config: &AiDENsConfigV1) -> Result<(), ConfigError> {
347    if config.security.write_policy != "approval-required" {
348        return Err(ConfigError::DangerousField {
349            path: "security.write_policy".into(),
350            reason: "write/admin behavior requires approval-required policy".into(),
351        });
352    }
353    if !matches!(
354        config.security.network_policy.as_str(),
355        "disabled" | "local-only"
356    ) {
357        return Err(ConfigError::DangerousField {
358            path: "security.network_policy".into(),
359            reason: "network policy must be disabled or local-only in supported-local configs"
360                .into(),
361        });
362    }
363    Ok(())
364}
365
366fn hex_sha256(bytes: &[u8]) -> String {
367    format!("{:x}", Sha256::digest(bytes))
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn redacts_api_key() {
376        let cfg = AiDENsConfigV1 {
377            app_id: "app".into(),
378            provider: ProviderConfigV1 {
379                kind: "openai".into(),
380                model: None,
381                api_key: Some("secret".into()),
382                base_url: None,
383                mock_response: None,
384            },
385            profile_id: None,
386            tools: ToolsConfigV1::default(),
387            security: SecurityConfigV1::default(),
388            receipts: EventLogConfigV1::default(),
389            memory: MemoryConfigV1::default(),
390            memory_mode: MemoryModeV1::Disabled,
391            receipt_level: ReportLevelV1::Full,
392        };
393        assert_eq!(
394            cfg.redacted().provider.api_key.as_deref(),
395            Some(REDACTION_MASK)
396        );
397    }
398
399    #[test]
400    fn redacted_json_hides_secret_values_and_url_auth() {
401        let cfg = AiDENsConfigV1 {
402            app_id: "app".into(),
403            provider: ProviderConfigV1 {
404                kind: "openai".into(),
405                model: Some("model".into()),
406                api_key: Some("sk-secret".into()),
407                base_url: Some("https://user:pass@example.com/v1".into()),
408                mock_response: None,
409            },
410            profile_id: None,
411            tools: ToolsConfigV1::default(),
412            security: SecurityConfigV1::default(),
413            receipts: EventLogConfigV1::default(),
414            memory: MemoryConfigV1::default(),
415            memory_mode: MemoryModeV1::Disabled,
416            receipt_level: ReportLevelV1::Full,
417        };
418
419        let rendered = serde_json::to_string(&cfg.redacted_json().unwrap()).unwrap();
420        assert!(!rendered.contains("sk-secret"));
421        assert!(!rendered.contains("user:pass"));
422        assert!(rendered.contains(REDACTION_MASK));
423    }
424
425    #[test]
426    fn redacted_json_hides_adversarial_secret_keys_and_values() {
427        let value = serde_json::json!({
428            "api-key": "sk-test-value",
429            "nested": {
430                "authorization": "Bearer abc123",
431                "privateKey": "ghp_should_not_render"
432            },
433            "array": [
434                {"credential": "xoxb-credential"}
435            ],
436            "safe": "not secret"
437        });
438
439        let rendered = redacted_json_value(value).to_string();
440
441        assert!(!rendered.contains("sk-test-value"));
442        assert!(!rendered.contains("Bearer abc123"));
443        assert!(!rendered.contains("ghp_should_not_render"));
444        assert!(!rendered.contains("xoxb-credential"));
445        assert!(rendered.contains("not secret"));
446        assert!(rendered.contains(REDACTION_MASK));
447    }
448
449    #[test]
450    fn safe_default_is_valid_and_provider_disabled() {
451        let cfg = AiDENsConfigV1::safe_default("agent");
452
453        assert!(cfg.validate().is_ok());
454        assert_eq!(cfg.provider.kind, "disabled");
455        assert_eq!(cfg.memory_mode, MemoryModeV1::Disabled);
456    }
457
458    #[test]
459    fn config_round_trips_through_toml_file() {
460        let dir = std::env::temp_dir().join(format!("aidens-config-test-{}", std::process::id()));
461        std::fs::create_dir_all(&dir).unwrap();
462        let path = dir.join("aidens.toml");
463        let cfg = AiDENsConfigV1 {
464            app_id: "agent".into(),
465            provider: ProviderConfigV1 {
466                kind: "openai".into(),
467                model: Some("gpt-test".into()),
468                api_key: Some("sk-secret".into()),
469                base_url: Some("https://api.openai.com/v1".into()),
470                mock_response: None,
471            },
472            profile_id: Some("coding-agent".into()),
473            tools: ToolsConfigV1 {
474                sandbox_root: Some(".".into()),
475                enabled_bundles: vec!["safe-coding".into()],
476            },
477            security: SecurityConfigV1::default(),
478            receipts: EventLogConfigV1 {
479                store_root: Some("target/aidens-receipts/agent".into()),
480            },
481            memory: MemoryConfigV1 {
482                store_root: Some("target/aidens-memory/agent".into()),
483            },
484            memory_mode: MemoryModeV1::Optional,
485            receipt_level: ReportLevelV1::Full,
486        };
487        std::fs::write(&path, cfg.to_toml_string().unwrap()).unwrap();
488
489        let loaded = load_config_file(&path).unwrap();
490
491        assert_eq!(loaded.config, cfg);
492        assert_eq!(loaded.path, path);
493        assert!(loaded.canonical_path.is_absolute());
494        assert!(loaded.source_fingerprint.starts_with("sha256:"));
495        assert!(loaded
496            .reason_codes
497            .contains(&"config-source-canonicalized".into()));
498        let _ = std::fs::remove_file(&path);
499        let _ = std::fs::remove_dir(&dir);
500    }
501
502    #[test]
503    fn dangerous_unknown_secret_fields_are_rejected_before_deserialize_ignore() {
504        let dir = std::env::temp_dir().join(format!(
505            "aidens-config-dangerous-test-{}",
506            std::process::id()
507        ));
508        std::fs::create_dir_all(&dir).unwrap();
509        let path = dir.join("aidens.toml");
510        std::fs::write(
511            &path,
512            r#"
513app_id = "agent"
514memory_mode = "disabled"
515receipt_level = "standard"
516slack_token = "xoxb-hidden"
517
518[provider]
519kind = "mock"
520mock_response = "ok"
521"#,
522        )
523        .unwrap();
524
525        let error = load_config_file(&path).unwrap_err();
526
527        assert!(matches!(error, ConfigError::DangerousField { .. }));
528        let _ = std::fs::remove_file(&path);
529        let _ = std::fs::remove_dir(&dir);
530    }
531
532    #[test]
533    fn provider_secrets_and_endpoint_credentials_are_rejected() {
534        let mut cfg = AiDENsConfigV1::safe_default("agent");
535        cfg.provider.kind = "mock".into();
536        cfg.provider.api_key = Some("sk-secret".into());
537        assert!(matches!(
538            cfg.validate(),
539            Err(ConfigError::ProviderSecretNotAllowed { .. })
540        ));
541
542        cfg.provider.kind = "openai".into();
543        cfg.provider.api_key = Some("sk-secret".into());
544        cfg.provider.base_url = Some("https://user:pass@example.com/v1".into());
545        assert!(matches!(
546            cfg.validate(),
547            Err(ConfigError::ProviderEndpointNotAllowed { .. })
548        ));
549    }
550
551    #[test]
552    fn unsafe_write_or_network_defaults_are_rejected() {
553        let mut cfg = AiDENsConfigV1::safe_default("agent");
554        cfg.security.write_policy = "allow".into();
555        assert!(matches!(
556            cfg.validate(),
557            Err(ConfigError::DangerousField { .. })
558        ));
559
560        cfg.security.write_policy = "approval-required".into();
561        cfg.security.network_policy = "internet".into();
562        assert!(matches!(
563            cfg.validate(),
564            Err(ConfigError::DangerousField { .. })
565        ));
566    }
567
568    #[test]
569    fn safe_default_plan_config_matches_reference_interpreter() {
570        let cfg = AiDENsConfigV1::safe_default("agent");
571        let case = aidens_contracts::ReferenceCaseV1::new(
572            aidens_contracts::ReferenceDomainV1::PlanConfig,
573            "safe default plan config",
574            serde_json::json!({
575                "memory_mode": aidens_testkit::json_string(&cfg.memory_mode),
576                "receipt_level": aidens_testkit::json_string(&cfg.receipt_level),
577                "memory_store_configured": cfg.memory.store_root.is_some()
578            }),
579            serde_json::json!({}),
580        )
581        .with_memory_mode(cfg.memory_mode.clone())
582        .with_receipt_level(cfg.receipt_level.clone());
583        let durable_receipts_required = cfg.receipt_level != ReportLevelV1::Minimal;
584        let memory_blocked =
585            cfg.memory_mode == MemoryModeV1::Required && cfg.memory.store_root.is_none();
586        let actual = serde_json::json!({
587            "memory_mode": aidens_testkit::json_string(&cfg.memory_mode),
588            "receipt_level": aidens_testkit::json_string(&cfg.receipt_level),
589            "memory_store_configured": cfg.memory.store_root.is_some(),
590            "durable_receipts_required": durable_receipts_required,
591            "memory_blocked": memory_blocked,
592            "reason_codes": [
593                format!("memory-mode-{}", cfg.memory_mode),
594                format!("receipt-level-{}", cfg.receipt_level)
595            ]
596        });
597        let report = aidens_testkit::compare_case_to_actual(
598            &case,
599            "aidens-config::AiDENsConfigV1::safe_default",
600            actual,
601        );
602
603        assert!(
604            report.passed,
605            "{}",
606            report
607                .findings
608                .iter()
609                .map(|finding| finding.human_diff.as_str())
610                .collect::<Vec<_>>()
611                .join("\n")
612        );
613    }
614}