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