vtcode_core/utils/
gatekeeper.rs1use 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}