Skip to main content

perspt_agent/
verifier.rs

1//! Governed verifier sandbox: compiler/test/lint processes run inside an
2//! OS process sandbox with a deny-network profile and a read allow-list
3//! (toolchain roots, no credentials). Extracted from the candidate so the
4//! sandbox policy has one home.
5
6use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9
10use std::time::Duration;
11
12use anyhow::{Context, Result};
13use perspt_coding::LanguageId;
14use tokio::process::Command;
15
16/// Outcome of one governed verifier run.
17pub(crate) struct VerifierExecution {
18    pub(crate) success: bool,
19    pub(crate) output: String,
20}
21
22/// Per-stage wall-clock limits for governed verifier processes
23/// (`[verification] stage_timeout_secs` plus per-stage overrides).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct VerifierTimeouts {
26    pub default_secs: u64,
27    pub syntax: Option<u64>,
28    pub build: Option<u64>,
29    pub test: Option<u64>,
30    pub lint: Option<u64>,
31    pub format: Option<u64>,
32}
33
34impl Default for VerifierTimeouts {
35    fn default() -> Self {
36        Self {
37            default_secs: 180,
38            syntax: None,
39            build: None,
40            test: None,
41            lint: None,
42            format: None,
43        }
44    }
45}
46
47impl VerifierTimeouts {
48    /// The limit for one stage; `None` means an interactive or untyped run.
49    pub fn for_stage(&self, stage: Option<perspt_core::plugin::VerifierStage>) -> Duration {
50        use perspt_core::plugin::VerifierStage;
51        let secs = match stage {
52            Some(VerifierStage::SyntaxCheck) => self.syntax,
53            Some(VerifierStage::Build) => self.build,
54            Some(VerifierStage::Test) => self.test,
55            Some(VerifierStage::Lint) => self.lint,
56            Some(VerifierStage::Format) => self.format,
57            None => None,
58        };
59        Duration::from_secs(secs.unwrap_or(self.default_secs).max(1))
60    }
61}
62
63pub(crate) struct VerifierJob {
64    pub(crate) plugin: String,
65    pub(crate) adapter_id: LanguageId,
66    pub(crate) stage: perspt_core::plugin::VerifierStage,
67    pub(crate) command: String,
68    pub(crate) root: PathBuf,
69}
70
71pub(crate) async fn run_governed_verifier(
72    root: PathBuf,
73    command: String,
74    allow_unisolated: bool,
75    target_suffix: String,
76    extra_env: Vec<(String, String)>,
77    timeout: Duration,
78) -> Result<VerifierExecution> {
79    let tmp = root.join(".perspt-tmp").join(&target_suffix);
80    let target = root.join(".perspt-target").join(&target_suffix);
81    std::fs::create_dir_all(&tmp)?;
82    std::fs::create_dir_all(&target)?;
83    let read_roots = verifier_read_roots(&root, &extra_env);
84    let mut process = if allow_unisolated {
85        host_shell(&root, &command)
86    } else {
87        isolated_command(&root, &command, &read_roots)?
88    };
89    // Toolchain caches must live inside the writable overlay: the sandbox
90    // denies network and writes outside the candidate, so `uv` gets a
91    // per-run cache dir (mirroring CARGO_TARGET_DIR) and stays offline —
92    // verifiers run against the project's already-synced environment.
93    let uv_cache = root.join(".perspt-tmp").join("uv-cache");
94    let isolated_home = root.join(".perspt-home");
95    std::fs::create_dir_all(&uv_cache)?;
96    std::fs::create_dir_all(&isolated_home)?;
97    let original_home = std::env::var_os("HOME").map(PathBuf::from);
98    let cargo_home = std::env::var_os("CARGO_HOME")
99        .map(PathBuf::from)
100        .or_else(|| original_home.as_ref().map(|home| home.join(".cargo")));
101    let rustup_home = std::env::var_os("RUSTUP_HOME")
102        .map(PathBuf::from)
103        .or_else(|| original_home.as_ref().map(|home| home.join(".rustup")));
104    process
105        .env_clear()
106        .envs(verifier_environment())
107        .env("HOME", &isolated_home)
108        .env("CARGO_NET_OFFLINE", "true")
109        .env("CARGO_TARGET_DIR", target)
110        .env("UV_CACHE_DIR", uv_cache)
111        .env("UV_OFFLINE", "1")
112        .env("TMPDIR", tmp)
113        .env("TEMP", root.join(".perspt-tmp").join(&target_suffix))
114        .env("TMP", root.join(".perspt-tmp").join(&target_suffix))
115        .envs(extra_env)
116        .stdout(Stdio::piped())
117        .stderr(Stdio::piped())
118        .kill_on_drop(true);
119    if let Some(path) = cargo_home.filter(|path| path.is_dir()) {
120        process.env("CARGO_HOME", path);
121    }
122    if let Some(path) = rustup_home.filter(|path| path.is_dir()) {
123        process.env("RUSTUP_HOME", path);
124    }
125    configure_native_toolchain(&mut process);
126    let output = tokio::time::timeout(timeout, process.output())
127        .await
128        .with_context(|| {
129            format!(
130                "governed verifier exceeded its {} second limit \
131                 ([verification] stage_timeout_secs raises it)",
132                timeout.as_secs()
133            )
134        })??;
135    Ok(VerifierExecution {
136        success: output.status.success(),
137        output: format!(
138            "{}\n{}",
139            String::from_utf8_lossy(&output.stdout),
140            String::from_utf8_lossy(&output.stderr)
141        ),
142    })
143}
144
145fn verifier_environment() -> Vec<(String, String)> {
146    let portable = [
147        "PATH",
148        "LANG",
149        "LC_ALL",
150        "SystemRoot",
151        "SYSTEMROOT",
152        "WINDIR",
153        "COMSPEC",
154        "PATHEXT",
155    ];
156    portable
157        .into_iter()
158        .chain(native_toolchain_environment())
159        .filter_map(|key| std::env::var(key).ok().map(|value| (key.into(), value)))
160        .collect()
161}
162
163#[cfg(windows)]
164fn native_toolchain_environment() -> impl Iterator<Item = &'static str> {
165    // Rust's MSVC linker discovery needs the installation roots when a process
166    // starts from a deliberately cleared environment. In particular,
167    // find-msvc-tools consults ProgramFiles(x86); without it rustc may resolve
168    // Git for Windows' unrelated Unix `link.exe` from PATH. Keep this an
169    // explicit compiler/SDK allowlist rather than inheriting the host
170    // environment wholesale.
171    [
172        "ProgramFiles",
173        "ProgramFiles(x86)",
174        "ProgramW6432",
175        "VCINSTALLDIR",
176        "VSINSTALLDIR",
177        "VCToolsInstallDir",
178        "VCToolsVersion",
179        "VSCMD_ARG_HOST_ARCH",
180        "VSCMD_ARG_TGT_ARCH",
181        "WindowsSdkDir",
182        "WindowsSDKVersion",
183        "UniversalCRTSdkDir",
184        "UCRTVersion",
185        "INCLUDE",
186        "LIB",
187        "LIBPATH",
188    ]
189    .into_iter()
190}
191
192#[cfg(not(windows))]
193fn native_toolchain_environment() -> impl Iterator<Item = &'static str> {
194    std::iter::empty()
195}
196
197#[cfg(all(windows, target_env = "msvc"))]
198fn configure_native_toolchain(process: &mut Command) {
199    let Some((target, linker_variable)) = native_msvc_target() else {
200        return;
201    };
202    let Some(linker) = find_msvc_tools::find_tool(target, "link.exe") else {
203        return;
204    };
205    for (key, value) in linker.env() {
206        process.env(key, value);
207    }
208    // An absolute linker avoids collisions with Git for Windows' unrelated
209    // Unix `link.exe`, even when both tools occur in the host PATH.
210    process.env(linker_variable, linker.path());
211}
212
213#[cfg(all(windows, target_env = "msvc"))]
214fn native_msvc_target() -> Option<(&'static str, &'static str)> {
215    match std::env::consts::ARCH {
216        "x86_64" => Some((
217            "x86_64-pc-windows-msvc",
218            "CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER",
219        )),
220        "x86" => Some((
221            "i686-pc-windows-msvc",
222            "CARGO_TARGET_I686_PC_WINDOWS_MSVC_LINKER",
223        )),
224        "aarch64" => Some((
225            "aarch64-pc-windows-msvc",
226            "CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_LINKER",
227        )),
228        _ => None,
229    }
230}
231
232#[cfg(not(all(windows, target_env = "msvc")))]
233fn configure_native_toolchain(_process: &mut Command) {}
234
235#[cfg(windows)]
236fn host_shell(root: &Path, command: &str) -> Command {
237    let mut process = Command::new("cmd.exe");
238    process
239        .args(["/D", "/S", "/C"])
240        .arg(command)
241        .current_dir(root);
242    process
243}
244
245#[cfg(not(windows))]
246fn host_shell(root: &Path, command: &str) -> Command {
247    let mut process = Command::new("/bin/sh");
248    process.arg("-c").arg(command).current_dir(root);
249    process
250}
251
252#[cfg(target_os = "macos")]
253fn isolated_command(root: &Path, command: &str, read_roots: &[PathBuf]) -> Result<Command> {
254    let profile = macos_sandbox_profile(root, read_roots);
255    let mut process = Command::new("/usr/bin/sandbox-exec");
256    process
257        .arg("-p")
258        .arg(profile)
259        .arg("/bin/sh")
260        .arg("-c")
261        .arg(command)
262        .current_dir(root);
263    Ok(process)
264}
265
266#[cfg(target_os = "macos")]
267fn macos_sandbox_profile(root: &Path, read_roots: &[PathBuf]) -> String {
268    let escaped = root.to_string_lossy().replace('"', "\\\"");
269    let mut readable = read_roots.to_vec();
270    readable.push(root.to_path_buf());
271    let users_deny = deny_data_except("/Users", &readable);
272    let home_deny = deny_data_except("/home", &readable);
273    let root_home_deny = deny_data_except("/root", &readable);
274    let private_tmp_deny = deny_data_except("/private/tmp", &readable);
275    let user_tmp_deny = deny_data_except("/private/var/folders", &readable);
276    let volumes_deny = deny_data_except("/Volumes", &readable);
277    let network_deny = deny_data_except("/Network", &readable);
278    let extra_reads = read_roots
279        .iter()
280        .map(|path| {
281            format!(
282                "(allow file-read* (subpath \"{}\"))",
283                path.to_string_lossy()
284                    .replace('\\', "\\\\")
285                    .replace('"', "\\\"")
286            )
287        })
288        .collect::<Vec<_>>()
289        .join("\n");
290    format!(
291        "(version 1)\n\
292             (deny default)\n\
293             (allow process*)\n\
294             (allow sysctl-read)\n\
295             (allow file-read*)\n\
296             {users_deny}\n\
297             {home_deny}\n\
298             {root_home_deny}\n\
299             {private_tmp_deny}\n\
300             {user_tmp_deny}\n\
301             {volumes_deny}\n\
302             {network_deny}\n\
303             (allow file-read* (subpath \"{escaped}\"))\n\
304             {extra_reads}\n\
305             (allow file-write* (literal \"/dev/null\"))\n\
306             (allow file-write* (subpath \"{escaped}\"))\n\
307             (deny network*)"
308    )
309}
310
311#[cfg(target_os = "macos")]
312fn deny_data_except(parent: &str, allowed: &[PathBuf]) -> String {
313    let exceptions = allowed
314        .iter()
315        .filter(|path| path.starts_with(parent))
316        .map(|path| {
317            let escaped = path
318                .to_string_lossy()
319                .replace('\\', "\\\\")
320                .replace('"', "\\\"");
321            format!("(require-not (subpath \"{escaped}\"))")
322        })
323        .collect::<Vec<_>>();
324    if exceptions.is_empty() {
325        format!("(deny file-read-data (subpath \"{parent}\"))")
326    } else {
327        format!(
328            "(deny file-read-data (require-all (subpath \"{parent}\") {}))",
329            exceptions.join(" ")
330        )
331    }
332}
333
334#[cfg(target_os = "linux")]
335fn isolated_command(root: &Path, command: &str, read_roots: &[PathBuf]) -> Result<Command> {
336    let bwrap = ["/usr/bin/bwrap", "/bin/bwrap"]
337        .into_iter()
338        .find(|path| Path::new(path).is_file())
339        .context("bubblewrap is required for governed verifier execution")?;
340    let mut process = Command::new(bwrap);
341    process.args([
342        "--die-with-parent",
343        "--unshare-net",
344        "--ro-bind",
345        "/",
346        "/",
347        "--dev",
348        "/dev",
349    ]);
350    for sensitive in ["/home", "/root", "/Users"] {
351        if Path::new(sensitive).exists() {
352            process.arg("--tmpfs").arg(sensitive);
353        }
354    }
355    process.arg("--tmpfs").arg("/tmp");
356    for read_root in read_roots {
357        if !read_root.starts_with(root) {
358            prepare_bwrap_destination(&mut process, read_root);
359            process.arg("--ro-bind").arg(read_root).arg(read_root);
360        }
361    }
362    prepare_bwrap_destination(&mut process, root);
363    process
364        .arg("--bind")
365        .arg(root)
366        .arg(root)
367        .arg("--chdir")
368        .arg(root)
369        .arg("/bin/sh")
370        .arg("-c")
371        .arg(command);
372    Ok(process)
373}
374
375#[cfg(target_os = "linux")]
376fn prepare_bwrap_destination(process: &mut Command, destination: &Path) {
377    for directory in bwrap_destination_parents(destination) {
378        process.arg("--dir").arg(directory);
379    }
380}
381
382#[cfg(any(test, target_os = "linux"))]
383fn bwrap_destination_parents(destination: &Path) -> Vec<PathBuf> {
384    let Some(parent) = destination.parent() else {
385        return Vec::new();
386    };
387    let masked_roots = [
388        Path::new("/home"),
389        Path::new("/root"),
390        Path::new("/Users"),
391        Path::new("/tmp"),
392    ];
393    let Some(masked) = masked_roots
394        .into_iter()
395        .find(|masked| parent.starts_with(masked))
396    else {
397        return Vec::new();
398    };
399    let mut missing_parents: Vec<_> = parent
400        .ancestors()
401        .take_while(|ancestor| *ancestor != masked)
402        .map(Path::to_path_buf)
403        .collect();
404    missing_parents.reverse();
405    missing_parents
406}
407
408#[cfg(not(any(target_os = "macos", target_os = "linux")))]
409fn isolated_command(_root: &Path, _command: &str, _read_roots: &[PathBuf]) -> Result<Command> {
410    anyhow::bail!("this platform has no registered governed process sandbox")
411}
412
413fn verifier_read_roots(root: &Path, extra_env: &[(String, String)]) -> Vec<PathBuf> {
414    let mut roots = BTreeSet::new();
415    for (_, value) in extra_env {
416        let path = PathBuf::from(value);
417        if path.exists() {
418            roots.insert(path.canonicalize().unwrap_or_else(|_| path.clone()));
419            for interpreter in [
420                path.join("bin/python"),
421                path.join("bin/python3"),
422                path.join("Scripts/python.exe"),
423            ] {
424                if let Ok(target) = interpreter.canonicalize() {
425                    if let Some(installation) = target.parent().and_then(Path::parent) {
426                        roots.insert(installation.to_path_buf());
427                    }
428                }
429            }
430        }
431    }
432    if let Ok(value) = std::env::var("CARGO_HOME") {
433        extend_cargo_read_roots(&mut roots, &PathBuf::from(value));
434    }
435    if let Ok(value) = std::env::var("RUSTUP_HOME") {
436        let path = PathBuf::from(value);
437        if path.exists() {
438            roots.insert(path.canonicalize().unwrap_or(path));
439        }
440    }
441    if let Ok(home) = std::env::var("HOME") {
442        let home = PathBuf::from(home);
443        extend_cargo_read_roots(&mut roots, &home.join(".cargo"));
444        for relative in [".rustup", ".local/bin"] {
445            let path = home.join(relative);
446            if path.exists() {
447                roots.insert(path.canonicalize().unwrap_or(path));
448            }
449        }
450    }
451    if let Ok(path) = std::env::var("PATH") {
452        for directory in std::env::split_paths(&path) {
453            if directory.exists()
454                && !directory.starts_with("/usr")
455                && !directory.starts_with("/bin")
456            {
457                roots.insert(directory.canonicalize().unwrap_or(directory));
458            }
459        }
460    }
461    roots.remove(root);
462    roots.into_iter().collect()
463}
464
465fn extend_cargo_read_roots(roots: &mut BTreeSet<PathBuf>, cargo_home: &Path) {
466    // Cargo credentials are intentionally absent. Offline verification needs
467    // only executables, configuration, and already-fetched source/index data.
468    for relative in ["bin", "config", "config.toml", "git", "registry"] {
469        let path = cargo_home.join(relative);
470        if path.exists() {
471            roots.insert(path.canonicalize().unwrap_or(path));
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[cfg(target_os = "macos")]
481    #[test]
482    fn macos_profile_only_allows_candidate_and_null_device_writes() {
483        let profile = macos_sandbox_profile(
484            Path::new("/private/tmp/candidate"),
485            &[PathBuf::from("/Users/test/.rustup")],
486        );
487        assert!(profile.contains("(allow file-write* (literal \"/dev/null\"))"));
488        assert!(profile.contains("(allow file-write* (subpath \"/private/tmp/candidate\"))"));
489        assert_eq!(profile.matches("allow file-write*").count(), 2);
490        assert!(profile
491            .lines()
492            .any(|line| line.trim() == "(allow file-read*)"));
493        assert!(profile.contains("(deny file-read-data (require-all (subpath \"/Users\")"));
494        assert!(profile.contains("(deny file-read-data (require-all (subpath \"/private/tmp\")"));
495        assert!(profile.contains("/Users/test/.rustup"));
496        assert!(profile.contains("(deny network*)"));
497    }
498
499    #[cfg(unix)]
500    #[test]
501    fn verifier_roots_follow_virtualenv_interpreter_symlinks() {
502        let root = tempfile::tempdir().unwrap();
503        let install = root.path().join("uv-python");
504        let venv = root.path().join("project/.venv");
505        std::fs::create_dir_all(install.join("bin")).unwrap();
506        std::fs::create_dir_all(venv.join("bin")).unwrap();
507        std::fs::write(install.join("bin/python3"), "binary").unwrap();
508        std::os::unix::fs::symlink(install.join("bin/python3"), venv.join("bin/python3")).unwrap();
509
510        let roots = verifier_read_roots(
511            root.path(),
512            &[("UV_PROJECT_ENVIRONMENT".into(), venv.display().to_string())],
513        );
514        assert!(roots.contains(&install.canonicalize().unwrap()));
515    }
516
517    #[test]
518    fn cargo_verifier_roots_exclude_credentials() {
519        let root = tempfile::tempdir().unwrap();
520        let cargo_home = root.path().join("cargo-home");
521        std::fs::create_dir_all(cargo_home.join("bin")).unwrap();
522        std::fs::create_dir_all(cargo_home.join("registry")).unwrap();
523        std::fs::write(cargo_home.join("credentials.toml"), "[registry]").unwrap();
524
525        let mut roots = BTreeSet::new();
526        extend_cargo_read_roots(&mut roots, &cargo_home);
527        assert!(roots.contains(&cargo_home.join("bin").canonicalize().unwrap()));
528        assert!(roots.contains(&cargo_home.join("registry").canonicalize().unwrap()));
529        assert!(!roots.contains(&cargo_home));
530        assert!(!roots.contains(&cargo_home.join("credentials.toml")));
531    }
532
533    #[test]
534    fn bubblewrap_rebinds_recreate_only_masked_destination_parents() {
535        assert_eq!(
536            bwrap_destination_parents(Path::new("/home/user/.cargo/registry")),
537            [
538                PathBuf::from("/home/user"),
539                PathBuf::from("/home/user/.cargo")
540            ]
541        );
542        assert!(bwrap_destination_parents(Path::new("/usr/bin/cargo")).is_empty());
543    }
544}