From b543760d036175ecfdd799a21dcd857b0efc3747 Mon Sep 17 00:00:00 2001 From: Jobdori Date: Sat, 4 Apr 2026 00:42:28 +0900 Subject: [PATCH] feat(runtime): trust prompt resolver with allowlist and events --- rust/crates/runtime/src/lib.rs | 2 + rust/crates/runtime/src/trust_resolver.rs | 226 ++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 rust/crates/runtime/src/trust_resolver.rs diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 1c01a3f..cf785e6 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -22,6 +22,7 @@ mod session; mod sse; pub mod task_registry; pub mod team_cron_registry; +mod trust_resolver; mod usage; pub mod worker_boot; @@ -100,6 +101,7 @@ pub use session::{ SessionFork, }; pub use sse::{IncrementalSseParser, SseEvent}; +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..13f85be --- /dev/null +++ b/rust/crates/runtime/src/trust_resolver.rs @@ -0,0 +1,226 @@ +//! Self-contained trust resolution for repository and worktree paths. +//! +//! Evaluates a `(repo_path, worktree_path)` pair against a [`TrustConfig`] +//! of allowlisted and denied paths, returning a [`TrustDecision`] with the +//! chosen [`TrustPolicy`] and a log of [`TrustEvent`]s. + +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrustPolicy { + AutoTrust, + RequireApproval, + Deny, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrustEvent { + TrustRequired { repo: String, worktree: String }, + TrustResolved { repo: String, policy: TrustPolicy }, + TrustDenied { repo: 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 struct TrustDecision { + pub policy: TrustPolicy, + pub events: Vec, +} + +#[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_trust(&self, repo_path: &str, worktree_path: &str) -> TrustDecision { + let mut events = Vec::new(); + + events.push(TrustEvent::TrustRequired { + repo: repo_path.to_owned(), + worktree: worktree_path.to_owned(), + }); + + if self + .config + .denied + .iter() + .any(|root| path_matches(repo_path, root) || path_matches(worktree_path, root)) + { + let reason = format!("repository path matches deny list: {repo_path}"); + events.push(TrustEvent::TrustDenied { + repo: repo_path.to_owned(), + reason, + }); + return TrustDecision { + policy: TrustPolicy::Deny, + events, + }; + } + + if self + .config + .allowlisted + .iter() + .any(|root| path_matches(repo_path, root) || path_matches(worktree_path, root)) + { + events.push(TrustEvent::TrustResolved { + repo: repo_path.to_owned(), + policy: TrustPolicy::AutoTrust, + }); + return TrustDecision { + policy: TrustPolicy::AutoTrust, + events, + }; + } + + TrustDecision { + policy: TrustPolicy::RequireApproval, + events, + } + } +} + +fn normalize_path(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +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) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allowlisted_repo_auto_trusts_and_records_events() { + // Given: a resolver whose allowlist contains /tmp/trusted + let config = TrustConfig::new().with_allowlisted("/tmp/trusted"); + let resolver = TrustResolver::new(config); + + // When: we resolve trust for a repo under the allowlisted root + let decision = + resolver.resolve_trust("/tmp/trusted/repo-a", "/tmp/trusted/repo-a/worktree"); + + // Then: the policy is AutoTrust + assert_eq!(decision.policy, TrustPolicy::AutoTrust); + + // And: both TrustRequired and TrustResolved events are recorded + assert!(decision.events.iter().any(|e| matches!( + e, + TrustEvent::TrustRequired { repo, worktree } + if repo == "/tmp/trusted/repo-a" + && worktree == "/tmp/trusted/repo-a/worktree" + ))); + assert!(decision.events.iter().any(|e| matches!( + e, + TrustEvent::TrustResolved { policy, .. } + if *policy == TrustPolicy::AutoTrust + ))); + } + + #[test] + fn unknown_repo_requires_approval_and_remains_gated() { + // Given: a resolver with no matching paths for the tested repo + let config = TrustConfig::new().with_allowlisted("/tmp/other"); + let resolver = TrustResolver::new(config); + + // When: we resolve trust for an unknown repo + let decision = + resolver.resolve_trust("/tmp/unknown/repo-b", "/tmp/unknown/repo-b/worktree"); + + // Then: the policy is RequireApproval + assert_eq!(decision.policy, TrustPolicy::RequireApproval); + + // And: only the TrustRequired event is recorded (no resolution) + assert_eq!(decision.events.len(), 1); + assert!(matches!( + &decision.events[0], + TrustEvent::TrustRequired { .. } + )); + } + + #[test] + fn denied_repo_blocks_and_records_denial_events() { + // Given: a resolver whose deny list contains /tmp/blocked + let config = TrustConfig::new().with_denied("/tmp/blocked"); + let resolver = TrustResolver::new(config); + + // When: we resolve trust for a repo under the denied root + let decision = + resolver.resolve_trust("/tmp/blocked/repo-c", "/tmp/blocked/repo-c/worktree"); + + // Then: the policy is Deny + assert_eq!(decision.policy, TrustPolicy::Deny); + + // And: both TrustRequired and TrustDenied events are recorded + assert!(decision + .events + .iter() + .any(|e| matches!(e, TrustEvent::TrustRequired { .. }))); + assert!(decision.events.iter().any(|e| matches!( + e, + TrustEvent::TrustDenied { reason, .. } + if reason.contains("deny list") + ))); + } + + #[test] + fn denied_takes_precedence_over_allowlisted() { + // Given: a resolver where the same root appears in both lists + let config = TrustConfig::new() + .with_allowlisted("/tmp/contested") + .with_denied("/tmp/contested"); + let resolver = TrustResolver::new(config); + + // When: we resolve trust for a repo under the contested root + let decision = + resolver.resolve_trust("/tmp/contested/repo-d", "/tmp/contested/repo-d/worktree"); + + // Then: deny takes precedence — policy is Deny + assert_eq!(decision.policy, TrustPolicy::Deny); + + // And: TrustDenied is recorded, but TrustResolved is not + assert!(decision + .events + .iter() + .any(|e| matches!(e, TrustEvent::TrustDenied { .. }))); + assert!(!decision + .events + .iter() + .any(|e| matches!(e, TrustEvent::TrustResolved { .. }))); + } +}