Skip to main content

oxicode_agent/mcp/
consent.rs

1//! MCP consent management — two policies over one store.
2//!
3//! Persists per-tool and per-server decisions to `mcp-consent.json` so they
4//! survive across sessions. Two distinct default policies share the store:
5//!
6//! - **Tool-call gate** ([`ConsentManager::check`]): unknown tools default
7//!   to `Allow`. Used by `direct_tool.rs` which only blocks `Deny`.
8//! - **Spawn gate** ([`ConsentManager::check_spawn_consent`]): unknown
9//!   servers default to `Ask`, which [`crate::mcp::McpManager::connect`]
10//!   treats as "not trusted — deny spawn". Closing the clone-to-RCE
11//!   surface (F-2, code audit 2026-07-25). The user pre-trusts servers via
12//!   `oxicode mcp trust <name>`.
13//!
14//! `Ask` is never persisted: [`ConsentManager::decide`] silently normalizes
15//! it to `Deny` so the on-disk file only ever contains `allow`/`deny`.
16
17use super::types::ConsentState;
18use anyhow::{Context, Result};
19use parking_lot::RwLock;
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24/// On-disk representation of the consent store.
25#[derive(Debug, Default, Serialize, Deserialize)]
26struct ConsentStore {
27    /// Schema version.
28    #[serde(default = "default_version")]
29    version: u32,
30    /// Map of "tool_or_server_name" → consent state.
31    decisions: HashMap<String, ConsentState>,
32}
33
34fn default_version() -> u32 {
35    1
36}
37
38/// In-memory + on-disk consent manager.
39pub struct ConsentManager {
40    /// Persisted state, plus in-memory copy under `RwLock`.
41    store: RwLock<ConsentStore>,
42    /// Path to the consent file.
43    persist_path: PathBuf,
44}
45
46impl std::fmt::Debug for ConsentManager {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("ConsentManager")
49            .field("decisions", &self.store.read().decisions)
50            .field("path", &self.persist_path)
51            .finish()
52    }
53}
54
55impl ConsentManager {
56    /// Create a new consent manager, resolving the default path under
57    /// `dirs::config_dir()/oxicode/mcp-consent.json`.
58    pub fn new() -> Self {
59        Self::with_path(default_consent_path())
60    }
61
62    /// Create a manager with a custom file path (used by tests).
63    pub fn with_path(persist_path: PathBuf) -> Self {
64        Self {
65            store: RwLock::new(ConsentStore::default()),
66            persist_path,
67        }
68    }
69
70    /// Returns the path the manager reads from / writes to.
71    pub fn path(&self) -> &Path {
72        &self.persist_path
73    }
74
75    /// Load decisions from disk. Missing file is not an error.
76    pub fn load(&self) -> Result<()> {
77        match std::fs::read_to_string(&self.persist_path) {
78            Ok(contents) => match serde_json::from_str::<ConsentStore>(&contents) {
79                Ok(store) => {
80                    *self.store.write() = store;
81                }
82                Err(e) => {
83                    tracing::warn!(
84                        "MCP consent: failed to parse {}: {} (starting fresh)",
85                        self.persist_path.display(),
86                        e
87                    );
88                }
89            },
90            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
91            Err(e) => {
92                return Err(anyhow::anyhow!(
93                    "Failed to read MCP consent file {}: {}",
94                    self.persist_path.display(),
95                    e
96                ));
97            }
98        }
99        Ok(())
100    }
101
102    /// Look up the consent for a given name. Defaults to `Allow`.
103    pub fn check(&self, name: &str) -> ConsentState {
104        self.store
105            .read()
106            .decisions
107            .get(name)
108            .cloned()
109            .unwrap_or(ConsentState::Allow)
110    }
111
112    /// Spawn-path consent check. Unknown servers return `Ask` (the caller
113    /// treats `Ask` and `Deny` identically: deny the spawn and surface
114    /// `McpError::ConsentDenied`). This is deliberately a *separate*
115    /// default from [`check`](Self::check) so the per-tool gate at
116    /// `direct_tool.rs` (which only blocks `Deny`) is unaffected.
117    pub fn check_spawn_consent(&self, name: &str) -> ConsentState {
118        self.store
119            .read()
120            .decisions
121            .get(name)
122            .cloned()
123            .unwrap_or(ConsentState::Ask)
124    }
125
126    /// Persist a consent decision. `name` is a tool or server name.
127    ///
128    /// `Ask` is normalized to `Deny` — it is a transient runtime signal,
129    /// never a stored state (see the module docs). Callers that resolve
130    /// `Ask` interactively must pass the resolved `Allow`/`Deny` here.
131    pub fn decide(&self, name: &str, state: ConsentState) -> Result<()> {
132        let state = match state {
133            ConsentState::Ask => {
134                tracing::warn!(
135                    "MCP consent: `Ask` passed to decide({name:?}) — \
136                     normalizing to `Deny` (Ask is transient, never persisted)"
137                );
138                ConsentState::Deny
139            }
140            // Allow and Deny pass through unchanged.
141            other => other,
142        };
143        let snapshot;
144        {
145            let mut store = self.store.write();
146            store.version = 1;
147            store.decisions.insert(name.to_string(), state);
148            snapshot = ConsentStore {
149                version: store.version,
150                decisions: store.decisions.clone(),
151            };
152        }
153        self.write_to_disk(&snapshot)
154    }
155
156    /// Trust a server (persist `Allow`). Convenience wrapper around
157    /// [`decide`](Self::decide). Used by `oxicode mcp trust <server>`.
158    pub fn trust(&self, name: &str) -> Result<()> {
159        self.decide(name, ConsentState::Allow)
160    }
161
162    /// Revoke trust for a server (persist `Deny`). Convenience wrapper
163    /// around [`decide`](Self::decide). Used by `oxicode mcp untrust <server>`.
164    pub fn untrust(&self, name: &str) -> Result<()> {
165        self.decide(name, ConsentState::Deny)
166    }
167
168    /// All current decisions (for the TUI dashboard).
169    pub fn all_decisions(&self) -> HashMap<String, ConsentState> {
170        self.store.read().decisions.clone()
171    }
172
173    /// Atomic write: serialize, write to `.tmp`, rename.
174    fn write_to_disk(&self, store: &ConsentStore) -> Result<()> {
175        if let Some(parent) = self.persist_path.parent() {
176            std::fs::create_dir_all(parent).with_context(|| {
177                format!(
178                    "Failed to create MCP consent directory {}",
179                    parent.display()
180                )
181            })?;
182        }
183        let json =
184            serde_json::to_string_pretty(store).context("Failed to serialize MCP consent store")?;
185        let tmp = self.persist_path.with_extension("json.tmp");
186        std::fs::write(&tmp, &json)
187            .with_context(|| format!("Failed to write MCP consent tmp {}", tmp.display()))?;
188        std::fs::rename(&tmp, &self.persist_path).with_context(|| {
189            format!(
190                "Failed to rename MCP consent {} → {}",
191                tmp.display(),
192                self.persist_path.display()
193            )
194        })?;
195        Ok(())
196    }
197}
198
199impl Default for ConsentManager {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205/// Default consent path: `dirs::config_dir()/oxicode/mcp-consent.json`.
206fn default_consent_path() -> PathBuf {
207    if let Some(config_dir) = dirs::config_dir() {
208        config_dir.join("oxicode").join("mcp-consent.json")
209    } else {
210        PathBuf::from(".oxicode/mcp-consent.json")
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use tempfile::TempDir;
218
219    #[test]
220    fn default_is_allow() {
221        let m = ConsentManager::new();
222        assert_eq!(m.check("anything"), ConsentState::Allow);
223    }
224
225    #[test]
226    fn decide_then_check() {
227        let dir = TempDir::new().unwrap();
228        let m = ConsentManager::with_path(dir.path().join("consent.json"));
229        m.load().unwrap();
230        m.decide("dangerous_tool", ConsentState::Deny).unwrap();
231        assert_eq!(m.check("dangerous_tool"), ConsentState::Deny);
232    }
233
234    #[test]
235    fn reload_round_trip() {
236        let dir = TempDir::new().unwrap();
237        let path = dir.path().join("consent.json");
238
239        let m1 = ConsentManager::with_path(path.clone());
240        m1.load().unwrap();
241        m1.decide("tool_a", ConsentState::Deny).unwrap();
242        m1.decide("tool_b", ConsentState::Allow).unwrap();
243
244        let m2 = ConsentManager::with_path(path);
245        m2.load().unwrap();
246        assert_eq!(m2.check("tool_a"), ConsentState::Deny);
247        assert_eq!(m2.check("tool_b"), ConsentState::Allow);
248        assert_eq!(m2.check("unknown"), ConsentState::Allow);
249    }
250
251    // ── F-2 spawn consent (code audit 2026-07-25) ─────────────────────
252
253    #[test]
254    fn spawn_consent_defaults_to_ask_for_unknown_servers() {
255        // The spawn gate treats unknown servers as `Ask` (→ deny spawn),
256        // closing the clone-to-RCE surface. Contrast with `check()` below
257        // which still returns `Allow` for the per-tool gate.
258        let m = ConsentManager::new();
259        assert_eq!(
260            m.check_spawn_consent("untrusted-server"),
261            ConsentState::Ask,
262            "unknown servers must be Ask at the spawn gate"
263        );
264        // Per-tool gate unchanged — backward compat for direct_tool.rs.
265        assert_eq!(
266            m.check("untrusted-server"),
267            ConsentState::Allow,
268            "unknown names stay Allow for the tool-call gate"
269        );
270    }
271
272    #[test]
273    fn trust_persists_allow_then_spawn_consent_allows() {
274        let dir = TempDir::new().unwrap();
275        let m = ConsentManager::with_path(dir.path().join("consent.json"));
276        m.load().unwrap();
277
278        // Before trust: spawn gate denies.
279        assert_eq!(m.check_spawn_consent("github"), ConsentState::Ask);
280
281        m.trust("github").unwrap();
282        assert_eq!(m.check_spawn_consent("github"), ConsentState::Allow);
283        // Tool gate also sees Allow.
284        assert_eq!(m.check("github"), ConsentState::Allow);
285    }
286
287    #[test]
288    fn untrust_persists_deny_then_both_gates_block() {
289        let dir = TempDir::new().unwrap();
290        let m = ConsentManager::with_path(dir.path().join("consent.json"));
291        m.load().unwrap();
292
293        m.untrust("sketchy").unwrap();
294        assert_eq!(m.check_spawn_consent("sketchy"), ConsentState::Deny);
295        assert_eq!(m.check("sketchy"), ConsentState::Deny);
296    }
297
298    #[test]
299    fn decide_normalizes_ask_to_deny_never_persists_ask() {
300        let dir = TempDir::new().unwrap();
301        let path = dir.path().join("consent.json");
302        let m = ConsentManager::with_path(path.clone());
303        m.load().unwrap();
304
305        // Ask must not be stored — it's a transient runtime signal.
306        m.decide("transient", ConsentState::Ask).unwrap();
307
308        // Reload and verify only Deny was persisted.
309        let raw = std::fs::read_to_string(&path).unwrap();
310        assert!(
311            !raw.contains("ask"),
312            "Ask must never be written to disk, got: {raw}"
313        );
314        let m2 = ConsentManager::with_path(path);
315        m2.load().unwrap();
316        assert_eq!(m2.check("transient"), ConsentState::Deny);
317    }
318
319    #[test]
320    fn trusted_server_survives_reload() {
321        let dir = TempDir::new().unwrap();
322        let path = dir.path().join("consent.json");
323
324        let m1 = ConsentManager::with_path(path.clone());
325        m1.load().unwrap();
326        m1.trust("persistent").unwrap();
327
328        let m2 = ConsentManager::with_path(path);
329        m2.load().unwrap();
330        assert_eq!(m2.check_spawn_consent("persistent"), ConsentState::Allow);
331    }
332}