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