mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-29 16:08:26 +08:00
The pull brought the branch current with origin/main while replaying local follow-up work. Conflict resolution kept the roadmap/progress additions and integrated the runtime event/trust changes with upstream's newer surfaces. The trust allowlist now treats worktree_pattern as an additional required predicate, including the missing-worktree case, so auto-trust cannot fall back to cwd-only matching when a worktree constraint was declared. The runtime formatting cleanup keeps clippy/fmt green after the merge. Constraint: Local branch was 109 commits behind origin/main with dirty tracked follow-up work. Rejected: Drop the autostash after conflict resolution | keeping it preserves a reversible safety backup for unrelated recovery. Confidence: high Scope-risk: moderate Directive: Do not relax worktree_pattern matching without preserving the missing-worktree regression. Tested: git diff --cached --check; cargo fmt -p runtime -- --check; cargo clippy -p runtime --all-targets -- -D warnings; cargo test -p runtime; cargo test --workspace; architect verification approved Not-tested: Live tmux/worker auto-trust behavior outside unit/integration tests
924 lines
29 KiB
Rust
924 lines
29 KiB
Rust
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<String>,
|
|
/// Optional worktree path
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
worktree: Option<String>,
|
|
},
|
|
/// 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<String>,
|
|
/// Human-readable description of why this is allowlisted
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
impl TrustAllowlistEntry {
|
|
#[must_use]
|
|
pub fn new(pattern: impl Into<String>) -> Self {
|
|
Self {
|
|
pattern: pattern.into(),
|
|
worktree_pattern: None,
|
|
description: None,
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_worktree_pattern(mut self, pattern: impl Into<String>) -> Self {
|
|
self.worktree_pattern = Some(pattern.into());
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_description(mut self, desc: impl Into<String>) -> 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<TrustAllowlistEntry>,
|
|
/// Denied paths (exact or prefix matches)
|
|
pub denied: Vec<PathBuf>,
|
|
/// 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<String>) -> 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<PathBuf>) -> 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<char> = pattern.chars().collect();
|
|
let s_chars: Vec<char> = 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<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, 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<String> {
|
|
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);
|
|
}
|
|
}
|
|
}
|