oxicode_agent/mcp/
consent.rs1use 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#[derive(Debug, Default, Serialize, Deserialize)]
26struct ConsentStore {
27 #[serde(default = "default_version")]
29 version: u32,
30 decisions: HashMap<String, ConsentState>,
32}
33
34fn default_version() -> u32 {
35 1
36}
37
38pub struct ConsentManager {
40 store: RwLock<ConsentStore>,
42 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 pub fn new() -> Self {
59 Self::with_path(default_consent_path())
60 }
61
62 pub fn with_path(persist_path: PathBuf) -> Self {
64 Self {
65 store: RwLock::new(ConsentStore::default()),
66 persist_path,
67 }
68 }
69
70 pub fn path(&self) -> &Path {
72 &self.persist_path
73 }
74
75 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 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 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 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 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 pub fn trust(&self, name: &str) -> Result<()> {
159 self.decide(name, ConsentState::Allow)
160 }
161
162 pub fn untrust(&self, name: &str) -> Result<()> {
165 self.decide(name, ConsentState::Deny)
166 }
167
168 pub fn all_decisions(&self) -> HashMap<String, ConsentState> {
170 self.store.read().decisions.clone()
171 }
172
173 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
205fn 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 #[test]
254 fn spawn_consent_defaults_to_ask_for_unknown_servers() {
255 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 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 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 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 m.decide("transient", ConsentState::Ask).unwrap();
307
308 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}