Skip to main content

vtcode_core/utils/
gatekeeper.rs

1use hashbrown::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4
5use once_cell::sync::OnceCell;
6
7use crate::config::GatekeeperConfig;
8
9#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
10#[derive(Debug, Clone)]
11pub struct GatekeeperPolicy {
12    warn_on_quarantine: bool,
13    auto_clear_quarantine: bool,
14    auto_clear_paths: Vec<PathBuf>,
15    cache: Arc<Mutex<HashMap<PathBuf, GatekeeperCacheEntry>>>,
16}
17
18#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
19#[derive(Debug, Clone)]
20struct GatekeeperCacheEntry {
21    quarantined: bool,
22    warned: bool,
23}
24
25static GATEKEEPER_POLICY: OnceCell<GatekeeperPolicy> = OnceCell::new();
26
27pub fn initialize_gatekeeper(config: &GatekeeperConfig, workspace_root: Option<&Path>) {
28    let policy = GatekeeperPolicy::from_config(config, workspace_root);
29    let _ = GATEKEEPER_POLICY.set(policy);
30}
31
32#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
33impl GatekeeperPolicy {
34    fn from_config(config: &GatekeeperConfig, workspace_root: Option<&Path>) -> Self {
35        let auto_clear_paths = config
36            .auto_clear_paths
37            .iter()
38            .filter_map(|raw| resolve_path(raw, workspace_root))
39            .collect();
40
41        Self {
42            warn_on_quarantine: config.warn_on_quarantine,
43            auto_clear_quarantine: config.auto_clear_quarantine,
44            auto_clear_paths,
45            cache: Arc::new(Mutex::new(HashMap::new())),
46        }
47    }
48
49    fn should_auto_clear(&self, target: &Path) -> bool {
50        self.auto_clear_paths
51            .iter()
52            .any(|base| target.starts_with(base))
53    }
54
55    fn cache_entry(&self, path: &Path) -> Option<GatekeeperCacheEntry> {
56        self.cache
57            .lock()
58            .ok()
59            .and_then(|cache| cache.get(path).cloned())
60    }
61
62    fn update_cache(&self, path: PathBuf, entry: GatekeeperCacheEntry) {
63        if let Ok(mut cache) = self.cache.lock() {
64            cache.insert(path, entry);
65        }
66    }
67}
68
69pub fn check_quarantine_for_program(program: &str) {
70    if program.contains(std::path::MAIN_SEPARATOR) || program.contains('/') {
71        check_quarantine(Path::new(program));
72    }
73}
74
75pub fn check_quarantine(path: &Path) {
76    let Some(policy) = GATEKEEPER_POLICY.get() else {
77        return;
78    };
79
80    #[cfg(not(target_os = "macos"))]
81    {
82        let _ = (policy, path);
83    }
84
85    #[cfg(target_os = "macos")]
86    {
87        if !path.exists() {
88            return;
89        }
90
91        let canonical = match path.canonicalize() {
92            Ok(canonical) => canonical,
93            Err(err) => {
94                tracing::warn!(
95                    path = %path.display(),
96                    error = %err,
97                    "Failed to canonicalize path"
98                );
99                return;
100            }
101        };
102
103        if let Some(entry) = policy.cache_entry(&canonical) {
104            if !entry.quarantined {
105                return;
106            }
107            if entry.warned || !policy.warn_on_quarantine {
108                return;
109            }
110        }
111
112        match read_quarantine_xattr(&canonical) {
113            Ok(Some(_)) => {
114                let should_auto_clear =
115                    policy.auto_clear_quarantine && policy.should_auto_clear(&canonical);
116
117                if policy.warn_on_quarantine {
118                    tracing::warn!(
119                        path = %canonical.display(),
120                        auto_clear = should_auto_clear,
121                        "Gatekeeper quarantine detected for executable"
122                    );
123                }
124
125                let warned = policy.warn_on_quarantine;
126
127                if should_auto_clear {
128                    match clear_quarantine_xattr(&canonical) {
129                        Ok(()) => {
130                            tracing::debug!(
131                                path = %canonical.display(),
132                                "Cleared Gatekeeper quarantine attribute"
133                            );
134                            policy.update_cache(
135                                canonical,
136                                GatekeeperCacheEntry {
137                                    quarantined: false,
138                                    warned: false,
139                                },
140                            );
141                            return;
142                        }
143                        Err(err) => {
144                            tracing::warn!(
145                                path = %canonical.display(),
146                                error = %err,
147                                "Failed to clear Gatekeeper quarantine"
148                            );
149                        }
150                    }
151                }
152
153                policy.update_cache(
154                    canonical,
155                    GatekeeperCacheEntry {
156                        quarantined: true,
157                        warned,
158                    },
159                );
160            }
161            Ok(None) => {
162                policy.update_cache(
163                    canonical,
164                    GatekeeperCacheEntry {
165                        quarantined: false,
166                        warned: false,
167                    },
168                );
169            }
170            Err(err) => {
171                tracing::debug!(
172                    path = %canonical.display(),
173                    error = %err,
174                    "Gatekeeper check failed"
175                );
176            }
177        }
178    }
179}
180
181fn resolve_path(raw: &str, workspace_root: Option<&Path>) -> Option<PathBuf> {
182    if raw.trim().is_empty() {
183        return None;
184    }
185
186    let expanded = if raw.starts_with("~/") {
187        dirs::home_dir().map(|home| home.join(raw.trim_start_matches("~/")))
188    } else {
189        Some(PathBuf::from(raw))
190    }?;
191
192    if expanded.is_absolute() {
193        Some(expanded)
194    } else {
195        let relative = expanded.clone();
196        workspace_root
197            .map(|root| root.join(expanded))
198            .or_else(|| std::env::current_dir().ok().map(|cwd| cwd.join(relative)))
199    }
200}
201
202#[cfg(target_os = "macos")]
203fn read_quarantine_xattr(path: &Path) -> std::io::Result<Option<Vec<u8>>> {
204    xattr::get(path, "com.apple.quarantine")
205}
206
207#[cfg(target_os = "macos")]
208fn clear_quarantine_xattr(path: &Path) -> std::io::Result<()> {
209    match xattr::remove(path, "com.apple.quarantine") {
210        Ok(()) => Ok(()),
211        Err(err) if err.raw_os_error() == Some(libc::ENOATTR) => Ok(()),
212        Err(err) => Err(err),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn auto_clear_resolves_workspace_paths() {
222        let config = GatekeeperConfig {
223            warn_on_quarantine: true,
224            auto_clear_quarantine: true,
225            auto_clear_paths: vec![".vtcode/bin".to_string()],
226        };
227        let policy = GatekeeperPolicy::from_config(&config, Some(Path::new("/tmp/workspace")));
228
229        let target = Path::new("/tmp/workspace/.vtcode/bin/tool");
230        assert!(policy.should_auto_clear(target));
231    }
232}