1use 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}