zeph_config/memory/consent_gate.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Write-time memory-consent gate configuration (issue #6490, `MemGhost`).
5//!
6//! See [`crate::memory::root::MemoryConfig::consent_gate`].
7
8use serde::{Deserialize, Serialize};
9
10fn default_confirm_threshold() -> String {
11 "external_untrusted".to_owned()
12}
13
14fn default_disclose_threshold() -> String {
15 "local_untrusted".to_owned()
16}
17
18/// Configuration for the write-time memory-consent gate, nested under `[memory.consent_gate]`
19/// in TOML (issue #6490).
20///
21/// Gates memory writes derived from untrusted content (tool output, web scrapes, MCP
22/// responses) behind either an interactive confirmation (`memory_save` tool path) or a
23/// visible in-turn disclosure note (autonomous background tool-output writes, which must
24/// never block on `Channel::confirm` per the non-blocking contract — see spec-039).
25///
26/// `confirm_threshold`/`disclose_threshold` accept the `snake_case` serialization of
27/// `zeph_sanitizer::ContentTrustLevel` (`"trusted"`, `"local_untrusted"`,
28/// `"external_untrusted"`). `zeph-config` cannot depend on `zeph-sanitizer` (the dependency
29/// runs the other way), so these are plain strings parsed by callers via
30/// `ContentTrustLevel::from_str_opt`.
31///
32/// # Example (TOML)
33///
34/// ```toml
35/// [memory.consent_gate]
36/// enabled = true
37/// confirm_threshold = "external_untrusted"
38/// disclose_threshold = "local_untrusted"
39/// audit_all = true
40/// ```
41#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(default)]
43pub struct ConsentGateConfig {
44 /// Master switch. Default: `true`.
45 pub enabled: bool,
46 /// Minimum trust tier (inclusive) that requires interactive confirmation via
47 /// `Channel::confirm` on the `memory_save` tool path. Default: `"external_untrusted"`.
48 #[serde(default = "default_confirm_threshold")]
49 pub confirm_threshold: String,
50 /// Minimum trust tier (inclusive) that requires a visible in-turn disclosure note on
51 /// autonomous background tool-output memory writes. Default: `"local_untrusted"`.
52 #[serde(default = "default_disclose_threshold")]
53 pub disclose_threshold: String,
54 /// When `true`, every memory write is recorded in the audit log with source
55 /// attribution, regardless of trust tier. Default: `true`.
56 pub audit_all: bool,
57}
58
59impl Default for ConsentGateConfig {
60 fn default() -> Self {
61 Self {
62 enabled: true,
63 confirm_threshold: default_confirm_threshold(),
64 disclose_threshold: default_disclose_threshold(),
65 audit_all: true,
66 }
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn defaults_match_spec() {
76 let cfg = ConsentGateConfig::default();
77 assert!(cfg.enabled);
78 assert_eq!(cfg.confirm_threshold, "external_untrusted");
79 assert_eq!(cfg.disclose_threshold, "local_untrusted");
80 assert!(cfg.audit_all);
81 }
82
83 #[test]
84 fn deserializes_from_empty_table() {
85 let cfg: ConsentGateConfig = toml::from_str("").unwrap();
86 assert!(cfg.enabled);
87 assert_eq!(cfg.confirm_threshold, "external_untrusted");
88 assert_eq!(cfg.disclose_threshold, "local_untrusted");
89 }
90
91 #[test]
92 fn deserializes_explicit_values() {
93 let cfg: ConsentGateConfig = toml::from_str(
94 "enabled = false\nconfirm_threshold = \"local_untrusted\"\naudit_all = false\n",
95 )
96 .unwrap();
97 assert!(!cfg.enabled);
98 assert_eq!(cfg.confirm_threshold, "local_untrusted");
99 assert!(!cfg.audit_all);
100 }
101}