Skip to main content

agent_engine/extensions/
trust.rs

1//! Per-provider trust state (enable/disable controls).
2//!
3//! Trust decisions are local and user-owned. State is persisted under
4//! `$SYNAPS_BASE_DIR/extensions/trust.json`. Enabled-by-default semantics:
5//! a provider with no entry is considered enabled. Users explicitly
6//! disable providers they distrust.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
12pub struct ProviderTrustState {
13    /// Map of `runtime_id` (`<plugin_id>:<provider_id>`) to disabled flag.
14    /// Absence means trusted/enabled by default.
15    #[serde(default)]
16    pub disabled: BTreeMap<String, ProviderTrustEntry>,
17}
18
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
20pub struct ProviderTrustEntry {
21    pub disabled: bool,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub reason: Option<String>,
24}
25
26/// Path to the trust file under the active base dir. Caller is responsible for
27/// creating parent directories when writing.
28pub fn trust_file_path() -> PathBuf {
29    trust_file_path_for(&crate::config::base_dir())
30}
31
32/// Path to the trust file rooted at an explicit base dir (test helper / reuse).
33pub(crate) fn trust_file_path_for(base: &Path) -> PathBuf {
34    base.join("extensions").join("trust.json")
35}
36
37/// Load the persisted state. Missing file → `Default::default()`. IO errors → Err.
38/// Malformed JSON → Err with a descriptive message.
39pub fn load_trust_state() -> Result<ProviderTrustState, String> {
40    load_trust_state_from(&crate::config::base_dir())
41}
42
43/// Load state from an explicit base dir.
44pub(crate) fn load_trust_state_from(base: &Path) -> Result<ProviderTrustState, String> {
45    let path = trust_file_path_for(base);
46    match std::fs::read_to_string(&path) {
47        Ok(contents) => serde_json::from_str(&contents)
48            .map_err(|e| format!("failed to parse trust.json at {}: {}", path.display(), e)),
49        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ProviderTrustState::default()),
50        Err(e) => Err(format!(
51            "failed to read trust.json at {}: {}",
52            path.display(),
53            e
54        )),
55    }
56}
57
58/// Persist the state. Creates parent dirs if needed. Atomic via tempfile + rename.
59pub fn save_trust_state(state: &ProviderTrustState) -> Result<(), String> {
60    save_trust_state_to(&crate::config::base_dir(), state)
61}
62
63/// Persist state under an explicit base dir.
64pub(crate) fn save_trust_state_to(base: &Path, state: &ProviderTrustState) -> Result<(), String> {
65    let path = trust_file_path_for(base);
66    let parent = path
67        .parent()
68        .ok_or_else(|| format!("trust.json path has no parent: {}", path.display()))?;
69    std::fs::create_dir_all(parent)
70        .map_err(|e| format!("failed to create dir {}: {}", parent.display(), e))?;
71    let serialized = serde_json::to_string_pretty(state)
72        .map_err(|e| format!("failed to serialize trust state: {}", e))?;
73
74    // Atomic write: write to a unique temp file in the same directory then rename.
75    // Using tempfile::NamedTempFile avoids trampling when concurrent writers hit
76    // the same target path.
77    let mut tmp = tempfile::NamedTempFile::new_in(parent)
78        .map_err(|e| format!("failed to create temp file in {}: {}", parent.display(), e))?;
79    std::io::Write::write_all(&mut tmp, serialized.as_bytes())
80        .map_err(|e| format!("failed to write {}: {}", tmp.path().display(), e))?;
81    // fsync before rename so data is durable on power loss. Through the temp
82    // file's own handle: Windows refuses to flush a read-only one ("Access is
83    // denied"), which failed every trust-store save there.
84    tmp.as_file()
85        .sync_all()
86        .map_err(|e| format!("failed to fsync {}: {}", tmp.path().display(), e))?;
87    tmp.persist(&path).map_err(|e| {
88        format!(
89            "failed to rename {} -> {}: {}",
90            e.file.path().display(),
91            path.display(),
92            e.error,
93        )
94    })?;
95    Ok(())
96}
97
98/// Returns true if the runtime_id is permitted to be routed (i.e. NOT disabled).
99/// Default: true (enabled when absent).
100pub fn is_provider_enabled(state: &ProviderTrustState, runtime_id: &str) -> bool {
101    match state.disabled.get(runtime_id) {
102        Some(entry) => !entry.disabled,
103        None => true,
104    }
105}
106
107/// Record a disabled decision. Replaces any existing entry for the runtime_id.
108pub fn disable_provider(state: &mut ProviderTrustState, runtime_id: &str, reason: Option<String>) {
109    state.disabled.insert(
110        runtime_id.to_string(),
111        ProviderTrustEntry {
112            disabled: true,
113            reason,
114        },
115    );
116}
117
118/// Re-enable a previously disabled provider. Removes the entry.
119pub fn enable_provider(state: &mut ProviderTrustState, runtime_id: &str) {
120    state.disabled.remove(runtime_id);
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use tempfile::TempDir;
127
128    #[test]
129    fn enabled_by_default_when_entry_absent() {
130        let state = ProviderTrustState::default();
131        assert!(is_provider_enabled(&state, "plug:prov"));
132    }
133
134    #[test]
135    fn disabled_entry_makes_provider_not_enabled() {
136        let mut state = ProviderTrustState::default();
137        state.disabled.insert(
138            "plug:prov".to_string(),
139            ProviderTrustEntry {
140                disabled: true,
141                reason: None,
142            },
143        );
144        assert!(!is_provider_enabled(&state, "plug:prov"));
145    }
146
147    #[test]
148    fn disable_then_check() {
149        let mut state = ProviderTrustState::default();
150        disable_provider(&mut state, "plug:prov", Some("untrusted".into()));
151        assert!(!is_provider_enabled(&state, "plug:prov"));
152        let entry = state.disabled.get("plug:prov").unwrap();
153        assert!(entry.disabled);
154        assert_eq!(entry.reason.as_deref(), Some("untrusted"));
155    }
156
157    #[test]
158    fn enable_after_disable_removes_entry() {
159        let mut state = ProviderTrustState::default();
160        disable_provider(&mut state, "plug:prov", None);
161        enable_provider(&mut state, "plug:prov");
162        assert!(!state.disabled.contains_key("plug:prov"));
163        assert!(is_provider_enabled(&state, "plug:prov"));
164    }
165
166    #[test]
167    fn load_from_missing_file_returns_default() {
168        let dir = TempDir::new().unwrap();
169        let state = load_trust_state_from(dir.path()).unwrap();
170        assert_eq!(state, ProviderTrustState::default());
171    }
172
173    #[test]
174    fn save_then_load_round_trip() {
175        let dir = TempDir::new().unwrap();
176        let mut state = ProviderTrustState::default();
177        disable_provider(&mut state, "plug:prov", Some("nope".into()));
178        disable_provider(&mut state, "other:thing", None);
179        save_trust_state_to(dir.path(), &state).unwrap();
180        let loaded = load_trust_state_from(dir.path()).unwrap();
181        assert_eq!(loaded, state);
182    }
183
184    #[test]
185    fn malformed_json_errors_with_context() {
186        let dir = TempDir::new().unwrap();
187        let path = trust_file_path_for(dir.path());
188        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
189        std::fs::write(&path, "{ this is not json").unwrap();
190        let err = load_trust_state_from(dir.path()).unwrap_err();
191        assert!(
192            err.contains("trust.json"),
193            "error should mention trust.json: {}",
194            err
195        );
196    }
197
198    #[test]
199    fn save_is_atomic_replacement() {
200        let dir = TempDir::new().unwrap();
201        let mut s1 = ProviderTrustState::default();
202        disable_provider(&mut s1, "a:b", None);
203        save_trust_state_to(dir.path(), &s1).unwrap();
204
205        let mut s2 = ProviderTrustState::default();
206        disable_provider(&mut s2, "c:d", Some("reason".into()));
207        disable_provider(&mut s2, "e:f", None);
208        save_trust_state_to(dir.path(), &s2).unwrap();
209
210        let loaded = load_trust_state_from(dir.path()).unwrap();
211        assert_eq!(loaded, s2);
212        // Ensure no stale temp file left behind.
213        let tmp = trust_file_path_for(dir.path()).with_extension("json.tmp");
214        assert!(!tmp.exists(), "temp file should not remain after rename");
215    }
216}