Skip to main content

rpi_cli/
trust.rs

1//! Trust management for project resources.
2//!
3//! Provides functionality to manage trust decisions for project-local resources,
4//! extensions, and configurations. This ensures that untrusted projects cannot
5//! execute arbitrary code or load malicious resources.
6//!
7//! # Overview
8//!
9//! When a project is not trusted, the following restrictions apply:
10//! - Project-local extensions are not loaded
11//! - Project-local skills and prompts are not loaded
12//! - Project-local context files are not loaded
13//! - Certain settings are ignored
14//!
15//! Trust decisions are persisted in `.rpi/trust.json` and can be managed via
16//! the `/trust` slash command or the `--approve`/`--no-approve` CLI flags.
17
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, RwLock};
22
23/// Trust decision for a project.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26#[derive(Default)]
27pub enum TrustDecision {
28    /// Project is trusted; load all project-local resources.
29    Trusted,
30    /// Project is not trusted; restrict project-local resources.
31    NotTrusted,
32    /// No decision has been made yet; prompt the user.
33    #[default]
34    Undecided,
35}
36
37/// Trust configuration for a project.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ProjectTrust {
40    /// The project path (normalized).
41    pub path: String,
42    /// The trust decision.
43    pub decision: TrustDecision,
44    /// When the decision was made (Unix timestamp in milliseconds).
45    pub decided_at: Option<i64>,
46    /// Who made the decision (user, auto, etc.).
47    pub decided_by: Option<String>,
48}
49
50/// Trust store that manages trust decisions for multiple projects.
51#[derive(Debug, Clone)]
52pub struct TrustStore {
53    /// Map from normalized project path to trust decision.
54    decisions: Arc<RwLock<HashMap<String, ProjectTrust>>>,
55    /// Path to the trust store file.
56    store_path: PathBuf,
57}
58
59impl TrustStore {
60    /// Create a new trust store.
61    pub fn new(store_path: PathBuf) -> Self {
62        Self {
63            decisions: Arc::new(RwLock::new(HashMap::new())),
64            store_path,
65        }
66    }
67
68    /// Load trust decisions from disk.
69    pub fn load(&self) -> Result<(), TrustError> {
70        if !self.store_path.exists() {
71            return Ok(());
72        }
73
74        let content = std::fs::read_to_string(&self.store_path)
75            .map_err(|e| TrustError::IoError(e.to_string()))?;
76
77        let decisions: HashMap<String, ProjectTrust> =
78            serde_json::from_str(&content).map_err(|e| TrustError::ParseError(e.to_string()))?;
79
80        let mut store = self.decisions.write().map_err(|_| TrustError::LockError)?;
81        *store = decisions;
82
83        Ok(())
84    }
85
86    /// Save trust decisions to disk.
87    pub fn save(&self) -> Result<(), TrustError> {
88        let decisions = self.decisions.read().map_err(|_| TrustError::LockError)?;
89        let content = serde_json::to_string_pretty(&*decisions)
90            .map_err(|e| TrustError::SerializeError(e.to_string()))?;
91
92        // Create parent directories if they don't exist
93        if let Some(parent) = self.store_path.parent() {
94            std::fs::create_dir_all(parent).map_err(|e| TrustError::IoError(e.to_string()))?;
95        }
96
97        std::fs::write(&self.store_path, content)
98            .map_err(|e| TrustError::IoError(e.to_string()))?;
99
100        Ok(())
101    }
102
103    /// Get the trust decision for a project.
104    pub fn get_decision(&self, project_path: &Path) -> TrustDecision {
105        let normalized = normalize_path(project_path);
106        let decisions = self.decisions.read().unwrap_or_else(|e| e.into_inner());
107
108        decisions
109            .get(&normalized)
110            .map(|t| t.decision)
111            .unwrap_or(TrustDecision::Undecided)
112    }
113
114    /// Set the trust decision for a project.
115    pub fn set_decision(
116        &self,
117        project_path: &Path,
118        decision: TrustDecision,
119        decided_by: Option<String>,
120    ) -> Result<(), TrustError> {
121        let normalized = normalize_path(project_path);
122        let now = std::time::SystemTime::now()
123            .duration_since(std::time::UNIX_EPOCH)
124            .map(|d| d.as_millis() as i64)
125            .unwrap_or(0);
126
127        let trust = ProjectTrust {
128            path: normalized.clone(),
129            decision,
130            decided_at: Some(now),
131            decided_by,
132        };
133
134        let mut decisions = self.decisions.write().map_err(|_| TrustError::LockError)?;
135        decisions.insert(normalized, trust);
136
137        Ok(())
138    }
139
140    /// Check if a project is trusted.
141    pub fn is_trusted(&self, project_path: &Path) -> bool {
142        self.get_decision(project_path) == TrustDecision::Trusted
143    }
144
145    /// Remove the trust decision for a project.
146    pub fn remove_decision(&self, project_path: &Path) -> Result<(), TrustError> {
147        let normalized = normalize_path(project_path);
148        let mut decisions = self.decisions.write().map_err(|_| TrustError::LockError)?;
149        decisions.remove(&normalized);
150        Ok(())
151    }
152
153    /// Get all trust decisions.
154    pub fn all_decisions(&self) -> Vec<ProjectTrust> {
155        let decisions = self.decisions.read().unwrap_or_else(|e| e.into_inner());
156        decisions.values().cloned().collect()
157    }
158}
159
160/// Normalize a project path for consistent lookup.
161fn normalize_path(path: &Path) -> String {
162    // Convert to absolute path and normalize
163    let absolute = if path.is_absolute() {
164        path.to_path_buf()
165    } else {
166        std::env::current_dir()
167            .unwrap_or_else(|_| PathBuf::from("."))
168            .join(path)
169    };
170
171    // Convert to string and normalize separators
172    absolute
173        .to_string_lossy()
174        .replace('\\', "/")
175        .trim_end_matches('/')
176        .to_string()
177}
178
179/// Errors that can occur during trust operations.
180#[derive(Debug, Clone)]
181pub enum TrustError {
182    /// I/O error reading or writing the trust store.
183    IoError(String),
184    /// Error parsing the trust store file.
185    ParseError(String),
186    /// Error serializing the trust store.
187    SerializeError(String),
188    /// Lock error (poisoned mutex).
189    LockError,
190}
191
192impl std::fmt::Display for TrustError {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        match self {
195            TrustError::IoError(e) => write!(f, "I/O error: {}", e),
196            TrustError::ParseError(e) => write!(f, "Parse error: {}", e),
197            TrustError::SerializeError(e) => write!(f, "Serialize error: {}", e),
198            TrustError::LockError => write!(f, "Lock error"),
199        }
200    }
201}
202
203impl std::error::Error for TrustError {}
204
205/// Check if a project should be trusted based on various heuristics.
206///
207/// This function implements automatic trust detection based on:
208/// - Whether the project is in a known safe directory (e.g., home directory)
209/// - Whether the project has a `.rpi/trust.json` file with a positive decision
210/// - Whether the user has explicitly approved the project via CLI flag
211pub fn should_trust_project(
212    project_path: &Path,
213    trust_store: &TrustStore,
214    cli_approve: Option<bool>,
215) -> bool {
216    // CLI flag takes precedence
217    if let Some(approve) = cli_approve {
218        return approve;
219    }
220
221    // Check trust store
222    match trust_store.get_decision(project_path) {
223        TrustDecision::Trusted => return true,
224        TrustDecision::NotTrusted => return false,
225        TrustDecision::Undecided => {}
226    }
227
228    // Auto-trust projects in home directory
229    if let Some(home) = dirs::home_dir() {
230        if project_path.starts_with(&home) {
231            return true;
232        }
233    }
234
235    // Default to not trusted for undecided projects
236    false
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use tempfile::TempDir;
243
244    #[test]
245    fn test_trust_decision_default() {
246        assert_eq!(TrustDecision::default(), TrustDecision::Undecided);
247    }
248
249    #[test]
250    fn test_trust_store_new() {
251        let temp_dir = TempDir::new().unwrap();
252        let store_path = temp_dir.path().join("trust.json");
253        let store = TrustStore::new(store_path);
254
255        assert_eq!(store.all_decisions().len(), 0);
256    }
257
258    #[test]
259    fn test_trust_store_set_and_get() {
260        let temp_dir = TempDir::new().unwrap();
261        let store_path = temp_dir.path().join("trust.json");
262        let store = TrustStore::new(store_path);
263
264        let project_path = Path::new("/test/project");
265
266        // Initially undecided
267        assert_eq!(store.get_decision(project_path), TrustDecision::Undecided);
268
269        // Set to trusted
270        store
271            .set_decision(
272                project_path,
273                TrustDecision::Trusted,
274                Some("test".to_string()),
275            )
276            .unwrap();
277        assert_eq!(store.get_decision(project_path), TrustDecision::Trusted);
278        assert!(store.is_trusted(project_path));
279
280        // Set to not trusted
281        store
282            .set_decision(project_path, TrustDecision::NotTrusted, None)
283            .unwrap();
284        assert_eq!(store.get_decision(project_path), TrustDecision::NotTrusted);
285        assert!(!store.is_trusted(project_path));
286    }
287
288    #[test]
289    fn test_trust_store_save_and_load() {
290        let temp_dir = TempDir::new().unwrap();
291        let store_path = temp_dir.path().join("trust.json");
292
293        // Create and populate store
294        let store1 = TrustStore::new(store_path.clone());
295        let project1 = Path::new("/test/project1");
296        let project2 = Path::new("/test/project2");
297
298        store1
299            .set_decision(project1, TrustDecision::Trusted, Some("user".to_string()))
300            .unwrap();
301        store1
302            .set_decision(project2, TrustDecision::NotTrusted, None)
303            .unwrap();
304        store1.save().unwrap();
305
306        // Load into new store
307        let store2 = TrustStore::new(store_path);
308        store2.load().unwrap();
309
310        assert_eq!(store2.get_decision(project1), TrustDecision::Trusted);
311        assert_eq!(store2.get_decision(project2), TrustDecision::NotTrusted);
312    }
313
314    #[test]
315    fn test_normalize_path() {
316        let path1 = Path::new("/test/project");
317        let path2 = Path::new("/test/project/");
318
319        assert_eq!(normalize_path(path1), normalize_path(path2));
320    }
321
322    #[test]
323    fn test_should_trust_project_with_cli_flag() {
324        let temp_dir = TempDir::new().unwrap();
325        let store_path = temp_dir.path().join("trust.json");
326        let store = TrustStore::new(store_path);
327        let project_path = Path::new("/test/project");
328
329        // CLI flag overrides everything
330        assert!(should_trust_project(project_path, &store, Some(true)));
331        assert!(!should_trust_project(project_path, &store, Some(false)));
332    }
333
334    #[test]
335    fn test_should_trust_project_with_store() {
336        let temp_dir = TempDir::new().unwrap();
337        let store_path = temp_dir.path().join("trust.json");
338        let store = TrustStore::new(store_path);
339        let project_path = Path::new("/test/project");
340
341        // Store decision overrides default
342        store
343            .set_decision(project_path, TrustDecision::Trusted, None)
344            .unwrap();
345        assert!(should_trust_project(project_path, &store, None));
346
347        store
348            .set_decision(project_path, TrustDecision::NotTrusted, None)
349            .unwrap();
350        assert!(!should_trust_project(project_path, &store, None));
351    }
352}