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> = serde_json::from_str(&content)
82            .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)
99                .map_err(|e| TrustError::IoError(e.to_string()))?;
100        }
101
102        std::fs::write(&self.store_path, content)
103            .map_err(|e| TrustError::IoError(e.to_string()))?;
104
105        Ok(())
106    }
107
108    /// Get the trust decision for a project.
109    pub fn get_decision(&self, project_path: &Path) -> TrustDecision {
110        let normalized = normalize_path(project_path);
111        let decisions = self.decisions.read().unwrap_or_else(|e| e.into_inner());
112        
113        decisions
114            .get(&normalized)
115            .map(|t| t.decision)
116            .unwrap_or(TrustDecision::Undecided)
117    }
118
119    /// Set the trust decision for a project.
120    pub fn set_decision(
121        &self,
122        project_path: &Path,
123        decision: TrustDecision,
124        decided_by: Option<String>,
125    ) -> Result<(), TrustError> {
126        let normalized = normalize_path(project_path);
127        let now = std::time::SystemTime::now()
128            .duration_since(std::time::UNIX_EPOCH)
129            .map(|d| d.as_millis() as i64)
130            .unwrap_or(0);
131
132        let trust = ProjectTrust {
133            path: normalized.clone(),
134            decision,
135            decided_at: Some(now),
136            decided_by,
137        };
138
139        let mut decisions = self.decisions.write().map_err(|_| TrustError::LockError)?;
140        decisions.insert(normalized, trust);
141
142        Ok(())
143    }
144
145    /// Check if a project is trusted.
146    pub fn is_trusted(&self, project_path: &Path) -> bool {
147        self.get_decision(project_path) == TrustDecision::Trusted
148    }
149
150    /// Remove the trust decision for a project.
151    pub fn remove_decision(&self, project_path: &Path) -> Result<(), TrustError> {
152        let normalized = normalize_path(project_path);
153        let mut decisions = self.decisions.write().map_err(|_| TrustError::LockError)?;
154        decisions.remove(&normalized);
155        Ok(())
156    }
157
158    /// Get all trust decisions.
159    pub fn all_decisions(&self) -> Vec<ProjectTrust> {
160        let decisions = self.decisions.read().unwrap_or_else(|e| e.into_inner());
161        decisions.values().cloned().collect()
162    }
163}
164
165/// Normalize a project path for consistent lookup.
166fn normalize_path(path: &Path) -> String {
167    // Convert to absolute path and normalize
168    let absolute = if path.is_absolute() {
169        path.to_path_buf()
170    } else {
171        std::env::current_dir()
172            .unwrap_or_else(|_| PathBuf::from("."))
173            .join(path)
174    };
175
176    // Convert to string and normalize separators
177    absolute
178        .to_string_lossy()
179        .replace('\\', "/")
180        .trim_end_matches('/')
181        .to_string()
182}
183
184/// Errors that can occur during trust operations.
185#[derive(Debug, Clone)]
186pub enum TrustError {
187    /// I/O error reading or writing the trust store.
188    IoError(String),
189    /// Error parsing the trust store file.
190    ParseError(String),
191    /// Error serializing the trust store.
192    SerializeError(String),
193    /// Lock error (poisoned mutex).
194    LockError,
195}
196
197impl std::fmt::Display for TrustError {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        match self {
200            TrustError::IoError(e) => write!(f, "I/O error: {}", e),
201            TrustError::ParseError(e) => write!(f, "Parse error: {}", e),
202            TrustError::SerializeError(e) => write!(f, "Serialize error: {}", e),
203            TrustError::LockError => write!(f, "Lock error"),
204        }
205    }
206}
207
208impl std::error::Error for TrustError {}
209
210/// Check if a project should be trusted based on various heuristics.
211///
212/// This function implements automatic trust detection based on:
213/// - Whether the project is in a known safe directory (e.g., home directory)
214/// - Whether the project has a `.rpi/trust.json` file with a positive decision
215/// - Whether the user has explicitly approved the project via CLI flag
216pub fn should_trust_project(
217    project_path: &Path,
218    trust_store: &TrustStore,
219    cli_approve: Option<bool>,
220) -> bool {
221    // CLI flag takes precedence
222    if let Some(approve) = cli_approve {
223        return approve;
224    }
225
226    // Check trust store
227    match trust_store.get_decision(project_path) {
228        TrustDecision::Trusted => return true,
229        TrustDecision::NotTrusted => return false,
230        TrustDecision::Undecided => {}
231    }
232
233    // Auto-trust projects in home directory
234    if let Some(home) = dirs::home_dir() {
235        if project_path.starts_with(&home) {
236            return true;
237        }
238    }
239
240    // Default to not trusted for undecided projects
241    false
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use tempfile::TempDir;
248
249    #[test]
250    fn test_trust_decision_default() {
251        assert_eq!(TrustDecision::default(), TrustDecision::Undecided);
252    }
253
254    #[test]
255    fn test_trust_store_new() {
256        let temp_dir = TempDir::new().unwrap();
257        let store_path = temp_dir.path().join("trust.json");
258        let store = TrustStore::new(store_path);
259        
260        assert_eq!(store.all_decisions().len(), 0);
261    }
262
263    #[test]
264    fn test_trust_store_set_and_get() {
265        let temp_dir = TempDir::new().unwrap();
266        let store_path = temp_dir.path().join("trust.json");
267        let store = TrustStore::new(store_path);
268        
269        let project_path = Path::new("/test/project");
270        
271        // Initially undecided
272        assert_eq!(store.get_decision(project_path), TrustDecision::Undecided);
273        
274        // Set to trusted
275        store.set_decision(project_path, TrustDecision::Trusted, Some("test".to_string())).unwrap();
276        assert_eq!(store.get_decision(project_path), TrustDecision::Trusted);
277        assert!(store.is_trusted(project_path));
278        
279        // Set to not trusted
280        store.set_decision(project_path, TrustDecision::NotTrusted, None).unwrap();
281        assert_eq!(store.get_decision(project_path), TrustDecision::NotTrusted);
282        assert!(!store.is_trusted(project_path));
283    }
284
285    #[test]
286    fn test_trust_store_save_and_load() {
287        let temp_dir = TempDir::new().unwrap();
288        let store_path = temp_dir.path().join("trust.json");
289        
290        // Create and populate store
291        let store1 = TrustStore::new(store_path.clone());
292        let project1 = Path::new("/test/project1");
293        let project2 = Path::new("/test/project2");
294        
295        store1.set_decision(project1, TrustDecision::Trusted, Some("user".to_string())).unwrap();
296        store1.set_decision(project2, TrustDecision::NotTrusted, None).unwrap();
297        store1.save().unwrap();
298        
299        // Load into new store
300        let store2 = TrustStore::new(store_path);
301        store2.load().unwrap();
302        
303        assert_eq!(store2.get_decision(project1), TrustDecision::Trusted);
304        assert_eq!(store2.get_decision(project2), TrustDecision::NotTrusted);
305    }
306
307    #[test]
308    fn test_normalize_path() {
309        let path1 = Path::new("/test/project");
310        let path2 = Path::new("/test/project/");
311        
312        assert_eq!(normalize_path(path1), normalize_path(path2));
313    }
314
315    #[test]
316    fn test_should_trust_project_with_cli_flag() {
317        let temp_dir = TempDir::new().unwrap();
318        let store_path = temp_dir.path().join("trust.json");
319        let store = TrustStore::new(store_path);
320        let project_path = Path::new("/test/project");
321        
322        // CLI flag overrides everything
323        assert!(should_trust_project(project_path, &store, Some(true)));
324        assert!(!should_trust_project(project_path, &store, Some(false)));
325    }
326
327    #[test]
328    fn test_should_trust_project_with_store() {
329        let temp_dir = TempDir::new().unwrap();
330        let store_path = temp_dir.path().join("trust.json");
331        let store = TrustStore::new(store_path);
332        let project_path = Path::new("/test/project");
333        
334        // Store decision overrides default
335        store.set_decision(project_path, TrustDecision::Trusted, None).unwrap();
336        assert!(should_trust_project(project_path, &store, None));
337        
338        store.set_decision(project_path, TrustDecision::NotTrusted, None).unwrap();
339        assert!(!should_trust_project(project_path, &store, None));
340    }
341}