mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 23:54:50 +08:00
merge: ultraclaw/trust-resolver into main
This commit is contained in:
@@ -26,6 +26,7 @@ pub mod stale_branch;
|
|||||||
pub mod task_registry;
|
pub mod task_registry;
|
||||||
pub mod task_packet;
|
pub mod task_packet;
|
||||||
pub mod team_cron_registry;
|
pub mod team_cron_registry;
|
||||||
|
pub mod trust_resolver;
|
||||||
mod usage;
|
mod usage;
|
||||||
pub mod worker_boot;
|
pub mod worker_boot;
|
||||||
|
|
||||||
@@ -125,6 +126,8 @@ pub use task_packet::{
|
|||||||
pub use usage::{
|
pub use usage::{
|
||||||
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
format_usd, pricing_for_model, ModelPricing, TokenUsage, UsageCostEstimate, UsageTracker,
|
||||||
};
|
};
|
||||||
|
||||||| f76311f
|
||||||
|
pub use trust_resolver::{TrustConfig, TrustDecision, TrustEvent, TrustPolicy, TrustResolver};
|
||||||
pub use worker_boot::{
|
pub use worker_boot::{
|
||||||
Worker, WorkerEvent, WorkerEventKind, WorkerFailure, WorkerFailureKind, WorkerReadySnapshot,
|
Worker, WorkerEvent, WorkerEventKind, WorkerFailure, WorkerFailureKind, WorkerReadySnapshot,
|
||||||
WorkerRegistry, WorkerStatus,
|
WorkerRegistry, WorkerStatus,
|
||||||
|
|||||||
299
rust/crates/runtime/src/trust_resolver.rs
Normal file
299
rust/crates/runtime/src/trust_resolver.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
denied: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrustConfig {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_allowlisted(mut self, path: impl Into<PathBuf>) -> Self {
|
||||||
|
self.allowlisted.push(path.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_denied(mut self, path: impl Into<PathBuf>) -> Self {
|
||||||
|
self.denied.push(path.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TrustDecision {
|
||||||
|
NotRequired,
|
||||||
|
Required {
|
||||||
|
policy: TrustPolicy,
|
||||||
|
events: Vec<TrustEvent>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrustDecision {
|
||||||
|
#[must_use]
|
||||||
|
pub fn policy(&self) -> Option<TrustPolicy> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user