diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index a086e43..fb2d95d 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -26,6 +26,7 @@ pub mod stale_branch; pub mod task_registry; pub mod task_packet; pub mod team_cron_registry; +pub mod trust_resolver; mod usage; pub mod worker_boot; @@ -125,6 +126,8 @@ pub use task_packet::{ pub use usage::{ format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker, }; +||||||| f76311f +pub use trust_resolver::{TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolver}; pub use worker_boot::{ Worker, WorkerEvent, WorkerEventKind, WorkerFailure, WorkerFailureKind, WorkerReadySnapshot, WorkerRegistry, WorkerStatus, diff --git a/rust/crates/runtime/src/trust_resolver.rs b/rust/crates/runtime/src/trust_resolver.rs new file mode 100644 index 0000000..52d46dc --- /dev/null +++ b/rust/crates/runtime/src/trust_resolver.rs @@ -0,0 +1,299 @@ +use std::path::{Path, PathBuf}; + +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", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustPolicy { + AutoTrust, + RequireApproval, + Deny, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrustEvent { + TrustRequired { cwd: String }, + TrustResolved { cwd: String, policy: TrustPolicy }, + TrustDenied { cwd: String, reason: String }, +} + +#[derive(Debug, Clone, Default)] +pub struct TrustConfig { + allowlisted: Vec, + denied: Vec, +} + +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(path.into()); + self + } + + #[must_use] + pub fn with_denied(mut self, path: impl Into) -> Self { + self.denied.push(path.into()); + self + } +} + +#[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, screen_text: &str) -> TrustDecision { + if !detect_trust_prompt(screen_text) { + return TrustDecision::NotRequired; + } + + let mut events = vec![TrustEvent::TrustRequired { + cwd: cwd.to_owned(), + }]; + + 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, + }; + } + + if self + .config + .allowlisted + .iter() + .any(|root| path_matches(cwd, root)) + { + events.push(TrustEvent::TrustResolved { + cwd: cwd.to_owned(), + policy: TrustPolicy::AutoTrust, + }); + return TrustDecision::Required { + policy: TrustPolicy::AutoTrust, + events, + }; + } + + TrustDecision::Required { + policy: TrustPolicy::RequireApproval, + events, + } + } + + #[must_use] + pub fn trusts(&self, cwd: &str) -> bool { + !self + .config + .denied + .iter() + .any(|root| path_matches(cwd, root)) + && self + .config + .allowlisted + .iter() + .any(|root| path_matches(cwd, root)) + } +} + +#[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()) +} + +#[cfg(test)] +mod tests { + use super::{ + detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent, + TrustPolicy, 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", "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", + "Do you trust the files in this folder?\n1. Yes, proceed\n2. No", + ); + + // then + assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust)); + assert_eq!( + decision.events(), + &[ + TrustEvent::TrustRequired { + cwd: "/tmp/worktrees/repo-a".to_string(), + }, + TrustEvent::TrustResolved { + cwd: "/tmp/worktrees/repo-a".to_string(), + policy: TrustPolicy::AutoTrust, + }, + ] + ); + } + + #[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", + "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(), + }] + ); + } + + #[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", + "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(), + }, + TrustEvent::TrustDenied { + cwd: "/tmp/worktrees/repo-c".to_string(), + reason: "cwd matches denied trust root: /tmp/worktrees/repo-c".to_string(), + }, + ] + ); + } + + #[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); + } +}