use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; const TRUST_PROMPT_CUES: &[&str] = &[ "do you trust the files in this folder", "trust the files in this folder", "trust this folder", "allow and continue", "yes, proceed", ]; /// Resolution method for trust decisions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TrustPolicy { /// Automatically trust this path (allowlisted) AutoTrust, /// Require manual approval RequireApproval, /// Deny trust for this path Deny, } /// Events emitted during trust resolution lifecycle. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum TrustEvent { /// Trust prompt was detected and is required TrustRequired { /// Current working directory where trust is needed cwd: String, /// Optional repo identifier #[serde(skip_serializing_if = "Option::is_none")] repo: Option, /// Optional worktree path #[serde(skip_serializing_if = "Option::is_none")] worktree: Option, }, /// Trust was resolved (granted) TrustResolved { /// Current working directory cwd: String, /// The policy that was applied policy: TrustPolicy, /// How the trust was resolved resolution: TrustResolution, }, /// Trust was denied TrustDenied { /// Current working directory cwd: String, /// Reason for denial reason: String, }, } /// How trust was resolved. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TrustResolution { /// Automatically granted due to allowlist AutoAllowlisted, /// Manually approved by user ManualApproval, } /// Entry in the trust allowlist with pattern matching support. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TrustAllowlistEntry { /// Repository path or glob pattern to match pub pattern: String, /// Optional worktree subpath pattern #[serde(skip_serializing_if = "Option::is_none")] pub worktree_pattern: Option, /// Human-readable description of why this is allowlisted #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } impl TrustAllowlistEntry { #[must_use] pub fn new(pattern: impl Into) -> Self { Self { pattern: pattern.into(), worktree_pattern: None, description: None, } } #[must_use] pub fn with_worktree_pattern(mut self, pattern: impl Into) -> Self { self.worktree_pattern = Some(pattern.into()); self } #[must_use] pub fn with_description(mut self, desc: impl Into) -> Self { self.description = Some(desc.into()); self } } /// Configuration for trust resolution with allowlist/denylist support. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TrustConfig { /// Allowlisted paths with pattern matching pub allowlisted: Vec, /// Denied paths (exact or prefix matches) pub denied: Vec, /// Whether to emit events for trust decisions #[serde(default = "default_emit_events")] pub emit_events: bool, } fn default_emit_events() -> bool { true } impl Default for TrustConfig { fn default() -> Self { Self { allowlisted: Vec::new(), denied: Vec::new(), emit_events: true, } } } impl TrustConfig { #[must_use] pub fn new() -> Self { Self::default() } #[must_use] pub fn with_allowlisted(mut self, path: impl Into) -> Self { self.allowlisted.push(TrustAllowlistEntry::new(path)); self } #[must_use] pub fn with_allowlisted_entry(mut self, entry: TrustAllowlistEntry) -> Self { self.allowlisted.push(entry); self } #[must_use] pub fn with_denied(mut self, path: impl Into) -> Self { self.denied.push(path.into()); self } /// Check if a path matches an allowlisted entry using glob patterns. #[must_use] pub fn is_allowlisted( &self, cwd: &str, worktree: Option<&str>, ) -> Option<&TrustAllowlistEntry> { self.allowlisted.iter().find(|entry| { let path_matches = Self::pattern_matches(&entry.pattern, cwd); if !path_matches { return false; } match (&entry.worktree_pattern, worktree) { (Some(wt_pattern), Some(wt)) => Self::pattern_matches(wt_pattern, wt), (Some(_), None) => false, (None, _) => true, } }) } /// Match a pattern against a path string. /// Supports exact matching and glob patterns (* and ?). fn pattern_matches(pattern: &str, path: &str) -> bool { let pattern = pattern.trim(); let path = path.trim(); // Exact match if pattern == path { return true; } // Normalize paths for comparison let pattern_normalized = pattern.replace("//", "/"); let path_normalized = path.replace("//", "/"); // Check if pattern is a path prefix (e.g., "/tmp/worktrees" matches "/tmp/worktrees/repo-a") // This handles the common case of directory containment if !pattern_normalized.contains('*') && !pattern_normalized.contains('?') { // Prefix match: pattern is a directory that contains path if path_normalized.starts_with(&pattern_normalized) { let rest = &path_normalized[pattern_normalized.len()..]; // Must be exact match or continue with / return rest.is_empty() || rest.starts_with('/'); } } // Check if pattern ends with wildcard (prefix match) if pattern_normalized.ends_with("/*") { let prefix = pattern_normalized.trim_end_matches("/*"); if let Some(rest) = path_normalized.strip_prefix(prefix) { // Must either be exact match or continue with / return rest.is_empty() || rest.starts_with('/'); } } else if pattern_normalized.ends_with('*') && !pattern_normalized.contains("/*/") { // Simple trailing * (not a path component wildcard) let prefix = pattern_normalized.trim_end_matches('*'); if let Some(rest) = path_normalized.strip_prefix(prefix) { return rest.is_empty() || !rest.starts_with('/'); } } // Check if pattern is a path component match (bounded by /) if path_normalized .split('/') .any(|component| component == pattern_normalized) { return true; } // Check if pattern appears as a substring within a path component // (e.g., "repo" matches "/tmp/worktrees/repo-a") if path_normalized .split('/') .any(|component| component.contains(&pattern_normalized)) { return true; } // Glob matching for patterns with ? or * in the middle if pattern.contains('?') || pattern.contains("/*/") || pattern.starts_with("*/") { return Self::glob_matches(&pattern_normalized, &path_normalized); } false } /// Simple glob pattern matching (? matches single char, * matches any sequence). /// Handles patterns like /tmp/*/repo-* where * matches path components. fn glob_matches(pattern: &str, path: &str) -> bool { // Use recursive backtracking for proper glob matching Self::glob_match_recursive(pattern, path, 0, 0) } fn glob_match_recursive(pattern: &str, path: &str, p_idx: usize, s_idx: usize) -> bool { let p_chars: Vec = pattern.chars().collect(); let s_chars: Vec = path.chars().collect(); let mut p = p_idx; let mut s = s_idx; while p < p_chars.len() { match p_chars[p] { '*' => { // Try all possible matches for * p += 1; if p >= p_chars.len() { // * at end matches everything remaining return true; } // Try matching 0 or more characters for skip in 0..=(s_chars.len() - s) { if Self::glob_match_recursive(pattern, path, p, s + skip) { return true; } } return false; } '?' => { // ? matches exactly one character if s >= s_chars.len() { return false; } p += 1; s += 1; } c => { // Exact character match if s >= s_chars.len() || s_chars[s] != c { return false; } p += 1; s += 1; } } } // Pattern exhausted - path must also be exhausted s >= s_chars.len() } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum TrustDecision { NotRequired, Required { policy: TrustPolicy, events: Vec, }, } impl TrustDecision { #[must_use] pub fn policy(&self) -> Option { match self { Self::NotRequired => None, Self::Required { policy, .. } => Some(*policy), } } #[must_use] pub fn events(&self) -> &[TrustEvent] { match self { Self::NotRequired => &[], Self::Required { events, .. } => events, } } } #[derive(Debug, Clone)] pub struct TrustResolver { config: TrustConfig, } impl TrustResolver { #[must_use] pub fn new(config: TrustConfig) -> Self { Self { config } } #[must_use] pub fn resolve(&self, cwd: &str, worktree: Option<&str>, screen_text: &str) -> TrustDecision { if !detect_trust_prompt(screen_text) { return TrustDecision::NotRequired; } let repo = extract_repo_name(cwd); let mut events = vec![TrustEvent::TrustRequired { cwd: cwd.to_owned(), repo: repo.clone(), worktree: worktree.map(String::from), }]; // Check denylist first if let Some(matched_root) = self .config .denied .iter() .find(|root| path_matches(cwd, root)) { let reason = format!("cwd matches denied trust root: {}", matched_root.display()); events.push(TrustEvent::TrustDenied { cwd: cwd.to_owned(), reason, }); return TrustDecision::Required { policy: TrustPolicy::Deny, events, }; } // Check allowlist with pattern matching if self.config.is_allowlisted(cwd, worktree).is_some() { events.push(TrustEvent::TrustResolved { cwd: cwd.to_owned(), policy: TrustPolicy::AutoTrust, resolution: TrustResolution::AutoAllowlisted, }); return TrustDecision::Required { policy: TrustPolicy::AutoTrust, events, }; } // Check for manual trust resolution via screen text analysis if detect_manual_approval(screen_text) { events.push(TrustEvent::TrustResolved { cwd: cwd.to_owned(), policy: TrustPolicy::RequireApproval, resolution: TrustResolution::ManualApproval, }); return TrustDecision::Required { policy: TrustPolicy::RequireApproval, events, }; } TrustDecision::Required { policy: TrustPolicy::RequireApproval, events, } } #[must_use] pub fn trusts(&self, cwd: &str, worktree: Option<&str>) -> bool { // Check denylist first let denied = self .config .denied .iter() .any(|root| path_matches(cwd, root)); if denied { return false; } // Check allowlist using pattern matching self.config.is_allowlisted(cwd, worktree).is_some() } } #[must_use] pub fn detect_trust_prompt(screen_text: &str) -> bool { let lowered = screen_text.to_ascii_lowercase(); TRUST_PROMPT_CUES .iter() .any(|needle| lowered.contains(needle)) } #[must_use] pub fn path_matches_trusted_root(cwd: &str, trusted_root: &str) -> bool { path_matches(cwd, &normalize_path(Path::new(trusted_root))) } fn path_matches(candidate: &str, root: &Path) -> bool { let candidate = normalize_path(Path::new(candidate)); let root = normalize_path(root); candidate == root || candidate.starts_with(&root) } fn normalize_path(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } /// Extract repository name from a path for event context. fn extract_repo_name(cwd: &str) -> Option { let path = Path::new(cwd); // Try to find a .git directory to identify repo root let mut current = Some(path); while let Some(p) = current { if p.join(".git").is_dir() { return p.file_name().map(|n| n.to_string_lossy().to_string()); } current = p.parent(); } // Fallback: use the last component of the path path.file_name().map(|n| n.to_string_lossy().to_string()) } /// Detect if the screen text indicates manual approval was granted. fn detect_manual_approval(screen_text: &str) -> bool { let lowered = screen_text.to_ascii_lowercase(); // Look for indicators that user manually approved MANUAL_APPROVAL_CUES.iter().any(|cue| lowered.contains(cue)) } const MANUAL_APPROVAL_CUES: &[&str] = &[ "yes, i trust", "i trust this", "trusted manually", "approval granted", ]; #[cfg(test)] mod path_matching_tests { use super::*; #[test] fn glob_pattern_star_matches_any_sequence() { assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/foo")); assert!(TrustConfig::pattern_matches("/tmp/*", "/tmp/bar/baz")); assert!(!TrustConfig::pattern_matches("/tmp/*", "/other/tmp/foo")); } #[test] fn glob_pattern_question_matches_single_char() { assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/test1")); assert!(TrustConfig::pattern_matches("/tmp/test?", "/tmp/testA")); assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test12")); assert!(!TrustConfig::pattern_matches("/tmp/test?", "/tmp/test")); } #[test] fn pattern_matches_exact() { assert!(TrustConfig::pattern_matches( "/tmp/worktrees", "/tmp/worktrees" )); assert!(!TrustConfig::pattern_matches( "/tmp/worktrees", "/tmp/worktrees-other" )); } #[test] fn pattern_matches_prefix_with_wildcard() { assert!(TrustConfig::pattern_matches( "/tmp/worktrees/*", "/tmp/worktrees/repo-a" )); assert!(TrustConfig::pattern_matches( "/tmp/worktrees/*", "/tmp/worktrees/repo-a/subdir" )); assert!(!TrustConfig::pattern_matches( "/tmp/worktrees/*", "/tmp/other/repo" )); } #[test] fn pattern_matches_contains() { // Pattern contained within path assert!(TrustConfig::pattern_matches( "worktrees", "/tmp/worktrees/repo-a" )); assert!(TrustConfig::pattern_matches( "repo", "/tmp/worktrees/repo-a" )); } #[test] fn allowlist_entry_with_worktree_pattern() { let config = TrustConfig::new().with_allowlisted_entry( TrustAllowlistEntry::new("/tmp/worktrees/*") .with_worktree_pattern("*/.git") .with_description("Git worktrees"), ); // Should match when both patterns match assert!(config .is_allowlisted("/tmp/worktrees/repo-a", Some("/tmp/worktrees/repo-a/.git")) .is_some()); // Should not match when worktree pattern doesn't match assert!(config .is_allowlisted("/tmp/worktrees/repo-a", Some("/other/path")) .is_none()); // Should not match when a worktree pattern is required but no worktree is supplied assert!(config .is_allowlisted("/tmp/worktrees/repo-a", None) .is_none()); // Should match when no worktree pattern required and path matches let config_no_worktree = TrustConfig::new().with_allowlisted("/tmp/worktrees/*"); assert!(config_no_worktree .is_allowlisted("/tmp/worktrees/repo-a", None) .is_some()); } #[test] fn allowlist_entry_returns_matched_entry() { let entry = TrustAllowlistEntry::new("/tmp/worktrees/*").with_description("Test worktrees"); let config = TrustConfig::new().with_allowlisted_entry(entry.clone()); let matched = config.is_allowlisted("/tmp/worktrees/repo-a", None); assert!(matched.is_some()); assert_eq!( matched.unwrap().description, Some("Test worktrees".to_string()) ); } #[test] fn complex_glob_patterns() { // Multiple wildcards assert!(TrustConfig::pattern_matches( "/tmp/*/repo-*", "/tmp/worktrees/repo-123" )); assert!(TrustConfig::pattern_matches( "/tmp/*/repo-*", "/tmp/other/repo-abc" )); assert!(!TrustConfig::pattern_matches( "/tmp/*/repo-*", "/tmp/worktrees/other" )); // Mixed ? and * assert!(TrustConfig::pattern_matches( "/tmp/test?/*.txt", "/tmp/test1/file.txt" )); assert!(TrustConfig::pattern_matches( "/tmp/test?/*.txt", "/tmp/testA/subdir/file.txt" )); } #[test] fn serde_serialization_roundtrip() { let config = TrustConfig::new() .with_allowlisted_entry( TrustAllowlistEntry::new("/tmp/worktrees/*") .with_worktree_pattern("*/.git") .with_description("Git worktrees"), ) .with_denied("/tmp/malicious"); let json = serde_json::to_string(&config).expect("serialization failed"); let deserialized: TrustConfig = serde_json::from_str(&json).expect("deserialization failed"); assert_eq!(config.allowlisted.len(), deserialized.allowlisted.len()); assert_eq!(config.denied.len(), deserialized.denied.len()); assert_eq!(config.emit_events, deserialized.emit_events); } #[test] fn trust_event_serialization() { let event = TrustEvent::TrustRequired { cwd: "/tmp/test".to_string(), repo: Some("test-repo".to_string()), worktree: Some("/tmp/test/.git".to_string()), }; let json = serde_json::to_string(&event).expect("serialization failed"); assert!(json.contains("trust_required")); assert!(json.contains("/tmp/test")); assert!(json.contains("test-repo")); let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed"); match deserialized { TrustEvent::TrustRequired { cwd, repo, worktree, } => { assert_eq!(cwd, "/tmp/test"); assert_eq!(repo, Some("test-repo".to_string())); assert_eq!(worktree, Some("/tmp/test/.git".to_string())); } _ => panic!("wrong event type"), } } #[test] fn trust_event_resolved_serialization() { let event = TrustEvent::TrustResolved { cwd: "/tmp/test".to_string(), policy: TrustPolicy::AutoTrust, resolution: TrustResolution::AutoAllowlisted, }; let json = serde_json::to_string(&event).expect("serialization failed"); assert!(json.contains("trust_resolved")); assert!(json.contains("auto_allowlisted")); let deserialized: TrustEvent = serde_json::from_str(&json).expect("deserialization failed"); match deserialized { TrustEvent::TrustResolved { resolution, .. } => { assert_eq!(resolution, TrustResolution::AutoAllowlisted); } _ => panic!("wrong event type"), } } } #[cfg(test)] mod tests { use super::{ detect_manual_approval, detect_trust_prompt, path_matches_trusted_root, TrustAllowlistEntry, TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolution, TrustResolver, }; #[test] fn detects_known_trust_prompt_copy() { // given let screen_text = "Do you trust the files in this folder?\n1. Yes, proceed\n2. No"; // when let detected = detect_trust_prompt(screen_text); // then assert!(detected); } #[test] fn does_not_emit_events_when_prompt_is_absent() { // given let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees")); // when let decision = resolver.resolve("/tmp/worktrees/repo-a", None, "Ready for your input\n>"); // then assert_eq!(decision, TrustDecision::NotRequired); assert_eq!(decision.events(), &[]); assert_eq!(decision.policy(), None); } #[test] fn auto_trusts_allowlisted_cwd_after_prompt_detection() { // given let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees")); // when let decision = resolver.resolve( "/tmp/worktrees/repo-a", None, "Do you trust the files in this folder?\n1. Yes, proceed\n2. No", ); // then assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust)); let events = decision.events(); assert_eq!(events.len(), 2); assert!(matches!(events[0], TrustEvent::TrustRequired { .. })); assert!(matches!( events[1], TrustEvent::TrustResolved { policy: TrustPolicy::AutoTrust, resolution: TrustResolution::AutoAllowlisted, .. } )); } #[test] fn requires_approval_for_unknown_cwd_after_prompt_detection() { // given let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees")); // when let decision = resolver.resolve( "/tmp/other/repo-b", None, "Do you trust the files in this folder?\n1. Yes, proceed\n2. No", ); // then assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval)); assert_eq!( decision.events(), &[TrustEvent::TrustRequired { cwd: "/tmp/other/repo-b".to_string(), repo: Some("repo-b".to_string()), worktree: None, }] ); } #[test] fn denied_root_takes_precedence_over_allowlist() { // given let resolver = TrustResolver::new( TrustConfig::new() .with_allowlisted("/tmp/worktrees") .with_denied("/tmp/worktrees/repo-c"), ); // when let decision = resolver.resolve( "/tmp/worktrees/repo-c", None, "Do you trust the files in this folder?\n1. Yes, proceed\n2. No", ); // then assert_eq!(decision.policy(), Some(TrustPolicy::Deny)); assert_eq!( decision.events(), &[ TrustEvent::TrustRequired { cwd: "/tmp/worktrees/repo-c".to_string(), repo: Some("repo-c".to_string()), worktree: None, }, TrustEvent::TrustDenied { cwd: "/tmp/worktrees/repo-c".to_string(), reason: "cwd matches denied trust root: /tmp/worktrees/repo-c".to_string(), }, ] ); } #[test] fn auto_trusts_with_glob_pattern_allowlist() { // given let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees/*")); // when - any repo under /tmp/worktrees should auto-trust let decision = resolver.resolve( "/tmp/worktrees/repo-a", None, "Do you trust the files in this folder?\n1. Yes, proceed\n2. No", ); // then assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust)); } #[test] fn resolve_with_worktree_pattern_matching() { // given let config = TrustConfig::new().with_allowlisted_entry( TrustAllowlistEntry::new("/tmp/worktrees/*").with_worktree_pattern("*/.git"), ); let resolver = TrustResolver::new(config); // when - with worktree that matches the pattern let decision = resolver.resolve( "/tmp/worktrees/repo-a", Some("/tmp/worktrees/repo-a/.git"), "Do you trust the files in this folder?\n1. Yes, proceed\n2. No", ); // then - should auto-trust because both patterns match assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust)); } #[test] fn manual_approval_detected_from_screen_text() { // given let resolver = TrustResolver::new(TrustConfig::new()); // when - screen text indicates manual approval let decision = resolver.resolve( "/tmp/some/repo", None, "Do you trust the files in this folder?\nUser selected: Yes, I trust this folder", ); // then - should detect manual approval assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval)); let events = decision.events(); assert!(events.len() >= 2); assert!(matches!( events[events.len() - 1], TrustEvent::TrustResolved { resolution: TrustResolution::ManualApproval, .. } )); } #[test] fn sibling_prefix_does_not_match_trusted_root() { // given let trusted_root = "/tmp/worktrees"; let sibling_path = "/tmp/worktrees-other/repo-d"; // when let matched = path_matches_trusted_root(sibling_path, trusted_root); // then assert!(!matched); } #[test] fn detects_manual_approval_cues() { assert!(detect_manual_approval( "User selected: Yes, I trust this folder" )); assert!(detect_manual_approval( "I trust this repository and its contents" )); assert!(detect_manual_approval("Approval granted by user")); assert!(!detect_manual_approval( "Do you trust the files in this folder?" )); assert!(!detect_manual_approval("Some unrelated text")); } #[test] fn trust_config_default_emit_events() { let config = TrustConfig::default(); assert!(config.emit_events); } #[test] fn trust_resolver_trusts_method() { let resolver = TrustResolver::new( TrustConfig::new() .with_allowlisted("/tmp/worktrees/*") .with_denied("/tmp/worktrees/bad-repo"), ); // Should trust allowlisted paths assert!(resolver.trusts("/tmp/worktrees/good-repo", None)); // Should not trust denied paths assert!(!resolver.trusts("/tmp/worktrees/bad-repo", None)); // Should not trust unknown paths assert!(!resolver.trusts("/tmp/other/repo", None)); } #[test] fn trust_policy_serde_roundtrip() { for policy in [ TrustPolicy::AutoTrust, TrustPolicy::RequireApproval, TrustPolicy::Deny, ] { let json = serde_json::to_string(&policy).expect("serialization failed"); let deserialized: TrustPolicy = serde_json::from_str(&json).expect("deserialization failed"); assert_eq!(policy, deserialized); } } #[test] fn trust_resolution_serde_roundtrip() { for resolution in [ TrustResolution::AutoAllowlisted, TrustResolution::ManualApproval, ] { let json = serde_json::to_string(&resolution).expect("serialization failed"); let deserialized: TrustResolution = serde_json::from_str(&json).expect("deserialization failed"); assert_eq!(resolution, deserialized); } } }