mirror of
https://github.com/instructkr/claude-code.git
synced 2026-04-06 03:08:48 +03:00
feat(runtime): trust prompt resolver
This commit is contained in:
@@ -22,7 +22,7 @@ mod session;
|
|||||||
mod sse;
|
mod sse;
|
||||||
pub mod task_registry;
|
pub mod task_registry;
|
||||||
pub mod team_cron_registry;
|
pub mod team_cron_registry;
|
||||||
mod trust_resolver;
|
pub mod trust_resolver;
|
||||||
mod usage;
|
mod usage;
|
||||||
pub mod worker_boot;
|
pub mod worker_boot;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
//! 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};
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TrustPolicy {
|
pub enum TrustPolicy {
|
||||||
AutoTrust,
|
AutoTrust,
|
||||||
@@ -15,9 +17,9 @@ pub enum TrustPolicy {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum TrustEvent {
|
pub enum TrustEvent {
|
||||||
TrustRequired { repo: String, worktree: String },
|
TrustRequired { cwd: String },
|
||||||
TrustResolved { repo: String, policy: TrustPolicy },
|
TrustResolved { cwd: String, policy: TrustPolicy },
|
||||||
TrustDenied { repo: String, reason: String },
|
TrustDenied { cwd: String, reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -46,9 +48,30 @@ impl TrustConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TrustDecision {
|
pub enum TrustDecision {
|
||||||
pub policy: TrustPolicy,
|
NotRequired,
|
||||||
pub events: Vec<TrustEvent>,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -63,26 +86,27 @@ impl TrustResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn resolve_trust(&self, repo_path: &str, worktree_path: &str) -> TrustDecision {
|
pub fn resolve(&self, cwd: &str, screen_text: &str) -> TrustDecision {
|
||||||
let mut events = Vec::new();
|
if !detect_trust_prompt(screen_text) {
|
||||||
|
return TrustDecision::NotRequired;
|
||||||
|
}
|
||||||
|
|
||||||
events.push(TrustEvent::TrustRequired {
|
let mut events = vec![TrustEvent::TrustRequired {
|
||||||
repo: repo_path.to_owned(),
|
cwd: cwd.to_owned(),
|
||||||
worktree: worktree_path.to_owned(),
|
}];
|
||||||
});
|
|
||||||
|
|
||||||
if self
|
if let Some(matched_root) = self
|
||||||
.config
|
.config
|
||||||
.denied
|
.denied
|
||||||
.iter()
|
.iter()
|
||||||
.any(|root| path_matches(repo_path, root) || path_matches(worktree_path, root))
|
.find(|root| path_matches(cwd, root))
|
||||||
{
|
{
|
||||||
let reason = format!("repository path matches deny list: {repo_path}");
|
let reason = format!("cwd matches denied trust root: {}", matched_root.display());
|
||||||
events.push(TrustEvent::TrustDenied {
|
events.push(TrustEvent::TrustDenied {
|
||||||
repo: repo_path.to_owned(),
|
cwd: cwd.to_owned(),
|
||||||
reason,
|
reason,
|
||||||
});
|
});
|
||||||
return TrustDecision {
|
return TrustDecision::Required {
|
||||||
policy: TrustPolicy::Deny,
|
policy: TrustPolicy::Deny,
|
||||||
events,
|
events,
|
||||||
};
|
};
|
||||||
@@ -92,27 +116,50 @@ impl TrustResolver {
|
|||||||
.config
|
.config
|
||||||
.allowlisted
|
.allowlisted
|
||||||
.iter()
|
.iter()
|
||||||
.any(|root| path_matches(repo_path, root) || path_matches(worktree_path, root))
|
.any(|root| path_matches(cwd, root))
|
||||||
{
|
{
|
||||||
events.push(TrustEvent::TrustResolved {
|
events.push(TrustEvent::TrustResolved {
|
||||||
repo: repo_path.to_owned(),
|
cwd: cwd.to_owned(),
|
||||||
policy: TrustPolicy::AutoTrust,
|
policy: TrustPolicy::AutoTrust,
|
||||||
});
|
});
|
||||||
return TrustDecision {
|
return TrustDecision::Required {
|
||||||
policy: TrustPolicy::AutoTrust,
|
policy: TrustPolicy::AutoTrust,
|
||||||
events,
|
events,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
TrustDecision {
|
TrustDecision::Required {
|
||||||
policy: TrustPolicy::RequireApproval,
|
policy: TrustPolicy::RequireApproval,
|
||||||
events,
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(path: &Path) -> PathBuf {
|
#[must_use]
|
||||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
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 {
|
fn path_matches(candidate: &str, root: &Path) -> bool {
|
||||||
@@ -121,106 +168,132 @@ fn path_matches(candidate: &str, root: &Path) -> bool {
|
|||||||
candidate == root || candidate.starts_with(&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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::{
|
||||||
|
detect_trust_prompt, path_matches_trusted_root, TrustConfig, TrustDecision, TrustEvent,
|
||||||
|
TrustPolicy, TrustResolver,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn allowlisted_repo_auto_trusts_and_records_events() {
|
fn detects_known_trust_prompt_copy() {
|
||||||
// Given: a resolver whose allowlist contains /tmp/trusted
|
// given
|
||||||
let config = TrustConfig::new().with_allowlisted("/tmp/trusted");
|
let screen_text = "Do you trust the files in this folder?\n1. Yes, proceed\n2. No";
|
||||||
let resolver = TrustResolver::new(config);
|
|
||||||
|
|
||||||
// When: we resolve trust for a repo under the allowlisted root
|
// when
|
||||||
let decision =
|
let detected = detect_trust_prompt(screen_text);
|
||||||
resolver.resolve_trust("/tmp/trusted/repo-a", "/tmp/trusted/repo-a/worktree");
|
|
||||||
|
|
||||||
// Then: the policy is AutoTrust
|
// then
|
||||||
assert_eq!(decision.policy, TrustPolicy::AutoTrust);
|
assert!(detected);
|
||||||
|
|
||||||
// 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]
|
#[test]
|
||||||
fn unknown_repo_requires_approval_and_remains_gated() {
|
fn does_not_emit_events_when_prompt_is_absent() {
|
||||||
// Given: a resolver with no matching paths for the tested repo
|
// given
|
||||||
let config = TrustConfig::new().with_allowlisted("/tmp/other");
|
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
||||||
let resolver = TrustResolver::new(config);
|
|
||||||
|
|
||||||
// When: we resolve trust for an unknown repo
|
// when
|
||||||
let decision =
|
let decision = resolver.resolve("/tmp/worktrees/repo-a", "Ready for your input\n>");
|
||||||
resolver.resolve_trust("/tmp/unknown/repo-b", "/tmp/unknown/repo-b/worktree");
|
|
||||||
|
|
||||||
// Then: the policy is RequireApproval
|
// then
|
||||||
assert_eq!(decision.policy, TrustPolicy::RequireApproval);
|
assert_eq!(decision, TrustDecision::NotRequired);
|
||||||
|
assert_eq!(decision.events(), &[]);
|
||||||
// And: only the TrustRequired event is recorded (no resolution)
|
assert_eq!(decision.policy(), None);
|
||||||
assert_eq!(decision.events.len(), 1);
|
|
||||||
assert!(matches!(
|
|
||||||
&decision.events[0],
|
|
||||||
TrustEvent::TrustRequired { .. }
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn denied_repo_blocks_and_records_denial_events() {
|
fn auto_trusts_allowlisted_cwd_after_prompt_detection() {
|
||||||
// Given: a resolver whose deny list contains /tmp/blocked
|
// given
|
||||||
let config = TrustConfig::new().with_denied("/tmp/blocked");
|
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
||||||
let resolver = TrustResolver::new(config);
|
|
||||||
|
|
||||||
// When: we resolve trust for a repo under the denied root
|
// when
|
||||||
let decision =
|
let decision = resolver.resolve(
|
||||||
resolver.resolve_trust("/tmp/blocked/repo-c", "/tmp/blocked/repo-c/worktree");
|
"/tmp/worktrees/repo-a",
|
||||||
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
|
);
|
||||||
|
|
||||||
// Then: the policy is Deny
|
// then
|
||||||
assert_eq!(decision.policy, TrustPolicy::Deny);
|
assert_eq!(decision.policy(), Some(TrustPolicy::AutoTrust));
|
||||||
|
assert_eq!(
|
||||||
// And: both TrustRequired and TrustDenied events are recorded
|
decision.events(),
|
||||||
assert!(decision
|
&[
|
||||||
.events
|
TrustEvent::TrustRequired {
|
||||||
.iter()
|
cwd: "/tmp/worktrees/repo-a".to_string(),
|
||||||
.any(|e| matches!(e, TrustEvent::TrustRequired { .. })));
|
},
|
||||||
assert!(decision.events.iter().any(|e| matches!(
|
TrustEvent::TrustResolved {
|
||||||
e,
|
cwd: "/tmp/worktrees/repo-a".to_string(),
|
||||||
TrustEvent::TrustDenied { reason, .. }
|
policy: TrustPolicy::AutoTrust,
|
||||||
if reason.contains("deny list")
|
},
|
||||||
)));
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn denied_takes_precedence_over_allowlisted() {
|
fn requires_approval_for_unknown_cwd_after_prompt_detection() {
|
||||||
// Given: a resolver where the same root appears in both lists
|
// given
|
||||||
let config = TrustConfig::new()
|
let resolver = TrustResolver::new(TrustConfig::new().with_allowlisted("/tmp/worktrees"));
|
||||||
.with_allowlisted("/tmp/contested")
|
|
||||||
.with_denied("/tmp/contested");
|
|
||||||
let resolver = TrustResolver::new(config);
|
|
||||||
|
|
||||||
// When: we resolve trust for a repo under the contested root
|
// when
|
||||||
let decision =
|
let decision = resolver.resolve(
|
||||||
resolver.resolve_trust("/tmp/contested/repo-d", "/tmp/contested/repo-d/worktree");
|
"/tmp/other/repo-b",
|
||||||
|
"Do you trust the files in this folder?\n1. Yes, proceed\n2. No",
|
||||||
|
);
|
||||||
|
|
||||||
// Then: deny takes precedence — policy is Deny
|
// then
|
||||||
assert_eq!(decision.policy, TrustPolicy::Deny);
|
assert_eq!(decision.policy(), Some(TrustPolicy::RequireApproval));
|
||||||
|
assert_eq!(
|
||||||
|
decision.events(),
|
||||||
|
&[TrustEvent::TrustRequired {
|
||||||
|
cwd: "/tmp/other/repo-b".to_string(),
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// And: TrustDenied is recorded, but TrustResolved is not
|
#[test]
|
||||||
assert!(decision
|
fn denied_root_takes_precedence_over_allowlist() {
|
||||||
.events
|
// given
|
||||||
.iter()
|
let resolver = TrustResolver::new(
|
||||||
.any(|e| matches!(e, TrustEvent::TrustDenied { .. })));
|
TrustConfig::new()
|
||||||
assert!(!decision
|
.with_allowlisted("/tmp/worktrees")
|
||||||
.events
|
.with_denied("/tmp/worktrees/repo-c"),
|
||||||
.iter()
|
);
|
||||||
.any(|e| matches!(e, TrustEvent::TrustResolved { .. })));
|
|
||||||
|
// 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