Skip to main content

hematite/agent/
trust_resolver.rs

1use std::path::{Path, PathBuf};
2
3use crate::agent::config::WorkspaceTrustConfig;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum WorkspaceTrustPolicy {
7    Trusted,
8    RequireApproval,
9    Denied,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum WorkspaceTrustSource {
14    DefaultWorkspace,
15    Allowlist,
16    UnknownWorkspace,
17    Denylist,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct WorkspaceTrustDecision {
22    pub policy: WorkspaceTrustPolicy,
23    pub source: WorkspaceTrustSource,
24    pub workspace_display: String,
25    pub matched_root: Option<String>,
26    pub reason: Option<String>,
27}
28
29pub fn resolve_workspace_trust(
30    workspace_root: &Path,
31    config: &WorkspaceTrustConfig,
32) -> WorkspaceTrustDecision {
33    let workspace = normalize_path(workspace_root);
34    let workspace_display = workspace.to_string_lossy().replace('\\', "/");
35
36    let denied_roots = resolve_roots(workspace_root, &config.deny);
37    if let Some(root) = denied_roots
38        .iter()
39        .find(|root| path_matches(&workspace, root))
40    {
41        let matched = root.to_string_lossy().replace('\\', "/");
42        return WorkspaceTrustDecision {
43            policy: WorkspaceTrustPolicy::Denied,
44            source: WorkspaceTrustSource::Denylist,
45            workspace_display,
46            matched_root: Some(matched.clone()),
47            reason: Some(format!("workspace matches denied trust root: {}", matched)),
48        };
49    }
50
51    let allow_roots = resolve_roots(workspace_root, &config.allow);
52    if let Some(root) = allow_roots
53        .iter()
54        .find(|root| path_matches(&workspace, root))
55    {
56        let matched = root.to_string_lossy().replace('\\', "/");
57        let source = if root == &workspace {
58            WorkspaceTrustSource::DefaultWorkspace
59        } else {
60            WorkspaceTrustSource::Allowlist
61        };
62        return WorkspaceTrustDecision {
63            policy: WorkspaceTrustPolicy::Trusted,
64            source,
65            workspace_display,
66            matched_root: Some(matched),
67            reason: None,
68        };
69    }
70
71    WorkspaceTrustDecision {
72        policy: WorkspaceTrustPolicy::RequireApproval,
73        source: WorkspaceTrustSource::UnknownWorkspace,
74        workspace_display,
75        matched_root: None,
76        reason: Some(
77            "workspace is not trust-allowlisted, so destructive or external actions require approval."
78                .to_string(),
79        ),
80    }
81}
82
83fn resolve_roots(workspace_root: &Path, configured_roots: &[String]) -> Vec<PathBuf> {
84    configured_roots
85        .iter()
86        .map(|root| {
87            let path = Path::new(root);
88            if path.is_absolute() {
89                normalize_path(path)
90            } else {
91                normalize_path(&workspace_root.join(path))
92            }
93        })
94        .collect()
95}
96
97fn path_matches(candidate: &Path, root: &Path) -> bool {
98    candidate == root || candidate.starts_with(root)
99}
100
101fn normalize_path(path: &Path) -> PathBuf {
102    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
103}
104
105#[cfg(test)]
106mod tests {
107    use super::{resolve_workspace_trust, WorkspaceTrustPolicy, WorkspaceTrustSource};
108    use crate::agent::config::WorkspaceTrustConfig;
109    use std::path::PathBuf;
110
111    #[test]
112    fn trusts_current_workspace_by_default() {
113        let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
114        let decision = resolve_workspace_trust(&root, &WorkspaceTrustConfig::default());
115        assert_eq!(decision.policy, WorkspaceTrustPolicy::Trusted);
116        assert_eq!(decision.source, WorkspaceTrustSource::DefaultWorkspace);
117    }
118
119    #[test]
120    fn denied_root_takes_precedence() {
121        let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
122        let config = WorkspaceTrustConfig {
123            allow: vec![".".to_string()],
124            deny: vec![root.to_string_lossy().to_string()],
125        };
126        let decision = resolve_workspace_trust(&root, &config);
127        assert_eq!(decision.policy, WorkspaceTrustPolicy::Denied);
128        assert_eq!(decision.source, WorkspaceTrustSource::Denylist);
129    }
130
131    #[test]
132    fn unknown_workspace_requires_approval() {
133        let root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
134        let config = WorkspaceTrustConfig {
135            allow: vec!["./somewhere-else".to_string()],
136            deny: Vec::new(),
137        };
138        let decision = resolve_workspace_trust(&root, &config);
139        assert_eq!(decision.policy, WorkspaceTrustPolicy::RequireApproval);
140        assert_eq!(decision.source, WorkspaceTrustSource::UnknownWorkspace);
141    }
142}