Skip to main content

vtcode_core/utils/
gatekeeper.rs

1use 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}