Skip to main content

vtcode_safety/sandboxing/
linux.rs

1//! Linux kernel sandbox enforcement: Landlock filesystem restriction.
2//!
3//! This module is the enforcement half of [`SandboxType::LinuxLandlock`](super::SandboxType).
4//! The main binary acts as the sandbox helper (busybox pattern):
5//! [`super::SandboxManager`] wraps commands as
6//! `vtcode sandbox-exec --sandbox-policy … -- <command>` (or an external helper
7//! configured via `VTCODE_LINUX_SANDBOX_EXECUTABLE`), and this module applies
8//! the restrictions to the launcher process before it execs the wrapped command.
9//!
10//! Enforcement model (mirrors the macOS Seatbelt profile in
11//! [`super::SandboxManager`]):
12//!
13//! - **Reads**: allowed everywhere except sensitive credential paths
14//!   (`~/.ssh`, cloud configs, …) and virtual filesystems (`/proc`, `/sys`).
15//!   Landlock rules are additive grants, so the exclusion is implemented by
16//!   enumerating the filesystem and granting every subtree that does not
17//!   intersect a sensitive path. Directory *listings* of ungranted ancestors
18//!   (`ls ~`, `ls /`) fail; files beneath granted subtrees stay readable.
19//!   `/proc`/`/sys` stay ungranted so `/proc/self/fd` reopen-with-different-mode
20//!   escapes stay denied (FDs-as-capabilities). `/dev` is never granted
21//!   wholesale (`/dev/fd` → `/proc/self/fd`); only `/dev/null`, `/dev/zero`,
22//!   and entropy sources are granted explicitly.
23//! - **Writes**: denied everywhere except writable roots (workspace-write) or
24//!   `/dev/null` (read-only). Unlike Seatbelt, Landlock has no deny rules, so
25//!   `.git`/`.vtcode` inside writable roots cannot be subtracted at the kernel
26//!   layer; that protection stays at the preflight/approval layer on Linux.
27//! - **Execute**: unrestricted (not handled by the ruleset), matching the
28//!   Seatbelt profile's broad `(allow process-exec)`.
29//! - **Network**: enforced by the seccomp filter ([`super::linux_seccomp`]),
30//!   not by Landlock, so block-all works on any Landlock-capable kernel.
31//!
32//! The ruleset is built for the exact ABI the kernel reports (probed via
33//! `landlock_create_ruleset`), so the crate's best-effort compatibility layer
34//! never silently downgrades a right we believed we were enforcing. Kernels
35//! older than 5.13 (no Landlock) fail closed via [`landlock_supported`].
36
37use std::collections::{HashSet, VecDeque};
38use std::path::{Path, PathBuf};
39
40use anyhow::{Result, anyhow, bail};
41
42use super::policy::{ResourceLimits, SandboxPolicy};
43
44/// Sanity cap on generated Landlock rules; a well-formed policy needs at most
45/// a few hundred. Exceeding it means the sensitive-path descent degenerated.
46const MAX_LANDLOCK_RULES: usize = 4096;
47
48/// Probe whether the running kernel enforces Landlock (ABI >= 1, Linux 5.13+).
49///
50/// Cached per process: `SandboxType::is_available` consults this on every
51/// transform, and the launcher re-checks before applying restrictions.
52pub fn landlock_supported() -> bool {
53    static SUPPORTED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| probe_landlock_abi().is_some());
54    *SUPPORTED
55}
56
57/// Return the kernel's Landlock ABI version, or `None` when unsupported.
58///
59/// # Contract
60///
61/// - Ensures: `Some(version)` with `version >= 1` when the kernel enforces
62///   Landlock (Linux 5.13+), and `None` for `ENOSYS`/`EOPNOTSUPP` or any other
63///   `-errno` result. Absence of overflow: the raw `c_long` is narrowed with
64///   `u32::try_from`, so a negative or oversized value maps to `None` instead
65///   of truncating. Total and pure (one syscall, no allocation).
66#[allow(
67    unsafe_code,
68    reason = "The Landlock ABI probe is a bare `landlock_create_ruleset` syscall; libc exposes no safe wrapper for it."
69)]
70fn probe_landlock_abi() -> Option<u32> {
71    // landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)
72    // returns the highest supported ABI version on success.
73    const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1 << 0;
74    // SAFETY: `libc::syscall` is invoked with the documented
75    // `landlock_create_ruleset` version-probe arguments — a null ruleset
76    // pointer, a zero size, and the `LANDLOCK_CREATE_RULESET_VERSION` flag.
77    // With a null ruleset the kernel dereferences no user memory and writes
78    // nothing back; it only returns a `c_long` (a non-negative ABI version, or
79    // `-errno`), so no pointer-validity, aliasing, or initialization
80    // precondition can be violated.
81    let version = unsafe {
82        libc::syscall(
83            libc::SYS_landlock_create_ruleset,
84            std::ptr::null::<libc::c_void>(),
85            0usize,
86            LANDLOCK_CREATE_RULESET_VERSION,
87        )
88    };
89    if version < 0 { None } else { u32::try_from(version).ok() }
90}
91
92/// Apply every kernel-level restriction of the sandbox policy to the current
93/// process: resource limits, then Landlock filesystem rules, then seccomp.
94///
95/// Called by the `sandbox-exec` launcher. Fails closed: any error means the
96/// caller must not exec the wrapped command.
97pub fn apply_sandbox_restrictions(
98    policy: &SandboxPolicy,
99    seccomp: &super::policy::SeccompProfile,
100    limits: &ResourceLimits,
101    policy_cwd: &Path,
102) -> Result<()> {
103    // Launcher-side defense in depth: a hostname allowlist is unenforceable
104    // with Landlock/seccomp alone (BPF cannot inspect connect() destinations),
105    // so a caller that somehow reached the launcher with one must not get
106    // unrestricted network. Mirrors the transform-layer check.
107    if policy.has_network_allowlist() {
108        bail!(
109            "hostname network allowlists cannot be enforced exactly by the Linux sandbox; refusing unrestricted network"
110        );
111    }
112    apply_resource_limits(limits)?;
113    apply_landlock(policy, policy_cwd)?;
114    super::linux_seccomp::apply_seccomp_filter(seccomp)?;
115    Ok(())
116}
117
118/// Apply explicitly configured resource limits. Zero values mean unlimited and
119/// are skipped, so default policies impose no rlimits (Seatbelt parity).
120fn apply_resource_limits(limits: &ResourceLimits) -> Result<()> {
121    use nix::sys::resource::{Resource, setrlimit};
122
123    let mib = |mb: u64| mb.saturating_mul(1024 * 1024);
124    if limits.max_memory_mb > 0 {
125        setrlimit(Resource::RLIMIT_AS, mib(limits.max_memory_mb), mib(limits.max_memory_mb))
126            .map_err(|error| anyhow!("RLIMIT_AS failed: {error}"))?;
127    }
128    if limits.max_pids > 0 {
129        let pids = u64::from(limits.max_pids);
130        setrlimit(Resource::RLIMIT_NPROC, pids, pids).map_err(|error| anyhow!("RLIMIT_NPROC failed: {error}"))?;
131    }
132    if limits.max_disk_mb > 0 {
133        setrlimit(Resource::RLIMIT_FSIZE, mib(limits.max_disk_mb), mib(limits.max_disk_mb))
134            .map_err(|error| anyhow!("RLIMIT_FSIZE failed: {error}"))?;
135    }
136    if limits.cpu_time_secs > 0 {
137        let secs = limits.cpu_time_secs;
138        setrlimit(Resource::RLIMIT_CPU, secs, secs).map_err(|error| anyhow!("RLIMIT_CPU failed: {error}"))?;
139    }
140    Ok(())
141}
142
143/// Apply the Landlock filesystem restrictions of `policy` to this process.
144pub fn apply_landlock(policy: &SandboxPolicy, policy_cwd: &Path) -> Result<()> {
145    use landlock::{ABI, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus};
146
147    let Some(version) = probe_landlock_abi() else {
148        bail!("Landlock is not supported by this kernel (Linux 5.13+ required); refusing to run unsandboxed");
149    };
150    // Map the probed kernel ABI to exactly the rights the kernel supports, so
151    // the crate's BestEffort compat layer never silently downgrades anything.
152    let abi = ABI::from(i32::try_from(version).unwrap_or(0));
153    if abi == ABI::Unsupported {
154        bail!("Landlock ABI version {version} is not usable");
155    }
156
157    let handled = handled_fs_access(abi);
158    let rules = compute_rules(policy, policy_cwd, abi, handled)?;
159
160    let mut created = Ruleset::default()
161        .handle_access(handled)
162        .map_err(|error| anyhow!("Landlock ruleset setup failed: {error}"))?
163        .create()
164        .map_err(|error| anyhow!("Landlock ruleset creation failed: {error}"))?;
165    for rule in &rules {
166        let fd = PathFd::new(&rule.path)
167            .map_err(|error| anyhow!("Landlock cannot open rule path {}: {error}", rule.path.display()))?;
168        created = created
169            .add_rule(PathBeneath::new(fd, rule.access))
170            .map_err(|error| anyhow!("Landlock rule for {} failed: {error}", rule.path.display()))?;
171    }
172    let status = created
173        .restrict_self()
174        .map_err(|error| anyhow!("Landlock self-restriction failed: {error}"))?;
175    if status.ruleset != RulesetStatus::FullyEnforced {
176        bail!("Landlock restrictions were only partially enforced ({:?}); refusing to exec", status.ruleset);
177    }
178    Ok(())
179}
180
181/// One Landlock `path_beneath` rule: grant `access` beneath `path`.
182struct LandlockRule {
183    path: PathBuf,
184    access: landlock::BitFlags<landlock::AccessFs>,
185}
186
187/// Filesystem access rights this sandbox handles: everything the probed ABI
188/// supports for read and write, minus two deliberate exclusions.
189///
190/// - `Execute` stays unhandled so exec paths remain unrestricted, matching the
191///   Seatbelt profile's broad `(allow process-exec)` (`from_read` includes it).
192/// - `IoctlDev` stays unhandled so PTY terminals keep working (`from_write`
193///   includes it from ABI v5 on); writable-root grants never cover `/dev/pts`,
194///   so handling it would deny TTY ioctls to every sandboxed command.
195///
196/// Rule grants must stay within this set (`add_rule` rejects rights the
197/// ruleset does not handle), so callers intersect grant rights with the value
198/// this function returns.
199fn handled_fs_access(abi: landlock::ABI) -> landlock::BitFlags<landlock::AccessFs> {
200    use landlock::{AccessFs, BitFlags};
201
202    let abi_access: BitFlags<AccessFs> = AccessFs::from_read(abi) | AccessFs::from_write(abi);
203    abi_access & !(AccessFs::Execute | AccessFs::IoctlDev)
204}
205
206/// Compute the full grant set for `policy`: read grants everywhere except
207/// sensitive paths, plus write grants for writable roots (or `/dev/null`).
208fn compute_rules(
209    policy: &SandboxPolicy,
210    policy_cwd: &Path,
211    abi: landlock::ABI,
212    handled: landlock::BitFlags<landlock::AccessFs>,
213) -> Result<Vec<LandlockRule>> {
214    let mut rules = Vec::new();
215    for path in compute_read_rule_paths(policy, policy_cwd)? {
216        rules.push(LandlockRule {
217            path,
218            access: landlock::AccessFs::from_read(abi) & handled,
219        });
220    }
221    for path in compute_write_rule_paths(policy, policy_cwd) {
222        rules.push(LandlockRule {
223            path,
224            access: landlock::AccessFs::from_write(abi) & handled,
225        });
226    }
227    Ok(rules)
228}
229
230/// Virtual filesystems never granted read access.
231///
232/// Per the sandboxing-basics analysis, file descriptors cannot be modeled as
233/// capabilities when a sandboxed process can reopen `/proc/self/fd/N` with a
234/// different mode. Landlock grants are additive, so granting `/` or `/proc`
235/// wholesale would re-admit that escape. `/proc` and `/sys` are excluded from
236/// read grants entirely (fail closed for those subtrees); explicit device
237/// nodes in [`EXPLICIT_DEV_READ_GRANTS`] cover the compatibility cases.
238const VIRTUAL_FS_ROOTS: &[&str] = &["/proc", "/sys"];
239
240/// Device nodes explicitly granted read access.
241///
242/// `/dev` is never granted wholesale: a wholesale grant would re-admit
243/// `/dev/fd` (a symlink to `/proc/self/fd`) and sensitive device nodes.
244/// These four are the compatibility set legacy code expects
245/// (`/dev/null`, `/dev/zero`, entropy sources).
246const EXPLICIT_DEV_READ_GRANTS: &[&str] = &["/dev/null", "/dev/zero", "/dev/urandom", "/dev/random"];
247
248/// Effective sensitive paths with read blocking, expanded to absolute paths.
249fn read_blocked_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Vec<PathBuf> {
250    policy
251        .sensitive_paths_for_execution(policy_cwd)
252        .into_iter()
253        .filter(|sp| sp.block_read)
254        .map(|sp| sp.expand_path())
255        .collect()
256}
257
258/// Compute the read-grant rule paths: enumerate the filesystem from `/` and
259/// `$HOME`, granting every subtree that does not intersect a sensitive path.
260///
261/// Virtual filesystems (`/proc`, `/sys`) are always treated as exclusions so
262/// `/proc/self/fd` reopen escapes stay denied, and `/dev` wholesale grants
263/// are replaced with [`EXPLICIT_DEV_READ_GRANTS`].
264fn compute_read_rule_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Result<Vec<PathBuf>> {
265    let mut exclusions = read_blocked_paths(policy, policy_cwd);
266    exclusions.extend(VIRTUAL_FS_ROOTS.iter().map(PathBuf::from));
267
268    let mut roots = vec![PathBuf::from("/")];
269    if let Some(home) = dirs::home_dir()
270        && home != Path::new("/")
271    {
272        roots.push(home);
273    }
274    let mut grants = enumerate_read_grants(&roots, &exclusions)?;
275    restrict_dev_grants(&mut grants);
276    if grants.len() > MAX_LANDLOCK_RULES {
277        bail!(
278            "Landlock read enumeration produced {} rules (cap {MAX_LANDLOCK_RULES}); refusing to continue",
279            grants.len()
280        );
281    }
282    Ok(grants)
283}
284
285/// Replace wholesale `/dev` grants with explicit device-node grants.
286///
287/// A grant on `/dev` (or any path beneath it other than the explicit set)
288/// would re-admit `/dev/fd` → `/proc/self/fd` and unrelated device nodes, so
289/// those grants are dropped and only existing explicit nodes are kept.
290fn restrict_dev_grants(grants: &mut Vec<PathBuf>) {
291    let dev_root = Path::new("/dev");
292    grants.retain(|path| {
293        !path_within(path, dev_root) || EXPLICIT_DEV_READ_GRANTS.iter().any(|grant| path == Path::new(grant))
294    });
295    for candidate in EXPLICIT_DEV_READ_GRANTS {
296        let path = PathBuf::from(candidate);
297        if path.exists() && !grants.contains(&path) {
298            grants.push(path);
299        }
300    }
301}
302
303/// Case-insensitive component-boundary containment: does `path` lie within
304/// (or equal) `ancestor`?
305fn path_within(path: &Path, ancestor: &Path) -> bool {
306    super::policy::path_starts_with_case_insensitive(path, ancestor)
307}
308
309/// Enumerate read grants beneath `roots`, excluding `sensitive` subtrees.
310///
311/// Descends only along chains that lead to a sensitive path, so the grant set
312/// stays small; everything else is granted wholesale as one directory rule.
313/// Symlink entries are granted only when their canonical target neither is a
314/// sensitive path nor lies *above* one (Landlock rule paths are opened with
315/// `O_PATH`, which follows symlinks, so a grant on a link is a grant on its
316/// target — and an ancestor grant would re-admit the excluded subtree beneath
317/// it, since Landlock allows access granted by any ancestor rule).
318fn enumerate_read_grants(roots: &[PathBuf], sensitive: &[PathBuf]) -> Result<Vec<PathBuf>> {
319    let mut grants = Vec::new();
320    let mut queued: HashSet<PathBuf> = HashSet::new();
321    let mut queue: VecDeque<PathBuf> = VecDeque::new();
322    for root in roots {
323        if queued.insert(root.clone()) {
324            queue.push_back(root.clone());
325        }
326    }
327    while let Some(dir) = queue.pop_front() {
328        let Ok(entries) = std::fs::read_dir(&dir) else {
329            // Unreadable directory: its children simply stay ungranted
330            // (fail closed for that subtree).
331            continue;
332        };
333        for entry in entries.flatten() {
334            let path = entry.path();
335            if sensitive.iter().any(|sp| path_within(&path, sp)) {
336                continue;
337            }
338            let Ok(file_type) = entry.file_type() else { continue };
339            if file_type.is_dir() {
340                if sensitive.iter().any(|sp| path_within(sp, &path)) {
341                    if queued.insert(path.clone()) {
342                        queue.push_back(path);
343                    }
344                } else {
345                    grants.push(path);
346                }
347            } else if file_type.is_symlink() {
348                // Exclude targets that are sensitive OR ancestors of a
349                // sensitive path: an ancestor grant would re-admit the
350                // excluded subtree beneath it (e.g. a link to `$HOME` or `/`
351                // would re-admit `~/.ssh`).
352                if let Ok(target) = std::fs::canonicalize(&path)
353                    && !sensitive.iter().any(|sp| path_within(&target, sp) || path_within(sp, &target))
354                {
355                    grants.push(path);
356                }
357            } else {
358                grants.push(path);
359            }
360        }
361    }
362    Ok(grants)
363}
364
365/// Compute the write-grant rule paths for `policy`.
366fn compute_write_rule_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Vec<PathBuf> {
367    match policy {
368        // Seatbelt parity: read-only policies may write only to /dev/null.
369        SandboxPolicy::ReadOnly { .. } => vec![PathBuf::from("/dev/null")],
370        SandboxPolicy::WorkspaceWrite { .. } => policy
371            .get_writable_roots_with_cwd(policy_cwd)
372            .into_iter()
373            .map(|root| root.root)
374            .collect(),
375        // Only restrictive policies reach the Linux transform.
376        SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use std::fs;
384    use tempfile::TempDir;
385
386    fn sorted(mut paths: Vec<PathBuf>) -> Vec<String> {
387        paths.sort();
388        paths.into_iter().map(|p| p.display().to_string()).collect()
389    }
390
391    #[test]
392    fn read_grants_exclude_sensitive_subtrees_and_files() {
393        let root = TempDir::new().unwrap();
394        let root = root.path();
395        fs::create_dir_all(root.join("src")).unwrap();
396        fs::create_dir_all(root.join(".ssh")).unwrap();
397        fs::create_dir_all(root.join("deep/with/.config/gcloud")).unwrap();
398        fs::create_dir_all(root.join("deep/with/.config/git")).unwrap();
399        fs::write(root.join("readme.md"), "x").unwrap();
400        fs::write(root.join(".npmrc"), "token").unwrap();
401
402        let sensitive = vec![
403            root.join(".ssh"),
404            root.join(".npmrc"),
405            root.join("deep/with/.config/gcloud"),
406        ];
407        let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
408
409        assert!(grants.iter().any(|g| g.ends_with("src")), "wholesale dir grant: {grants:?}");
410        assert!(grants.iter().any(|g| g.ends_with("readme.md")));
411        // The .config level is descended into (gcloud beneath), so its other
412        // children get wholesale grants.
413        assert!(grants.iter().any(|g| g.ends_with(".config/git")));
414        // Excluded: sensitive dirs/files and every ancestor that was descended.
415        assert!(!grants.iter().any(|g| g.contains(".ssh")));
416        assert!(!grants.iter().any(|g| g.contains(".npmrc")));
417        assert!(!grants.iter().any(|g| g.contains("gcloud")));
418        assert!(!grants.iter().any(|g| g.as_str() == root.display().to_string()));
419    }
420
421    #[cfg(unix)]
422    #[test]
423    fn read_grants_exclude_symlinks_into_sensitive_paths() {
424        let root = TempDir::new().unwrap();
425        let root = root.path();
426        fs::create_dir_all(root.join(".ssh")).unwrap();
427        fs::create_dir_all(root.join("work")).unwrap();
428        std::os::unix::fs::symlink(root.join(".ssh"), root.join("ssh-link")).unwrap();
429        std::os::unix::fs::symlink(root.join("work"), root.join("work-link")).unwrap();
430
431        let sensitive = vec![root.join(".ssh")];
432        let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
433
434        assert!(
435            !grants.iter().any(|g| g.ends_with("ssh-link")),
436            "symlink into sensitive must be excluded: {grants:?}"
437        );
438        assert!(grants.iter().any(|g| g.ends_with("work")));
439        assert!(grants.iter().any(|g| g.ends_with("work-link")));
440    }
441
442    #[cfg(unix)]
443    #[test]
444    fn read_grants_exclude_symlinks_to_sensitive_ancestors() {
445        let root = TempDir::new().unwrap();
446        let root = root.path();
447        fs::create_dir_all(root.join(".ssh")).unwrap();
448        fs::create_dir_all(root.join("work")).unwrap();
449        // `root-link` resolves to the sensitive path's parent directory itself;
450        // `parent-link` resolves to an even higher ancestor. Granting either
451        // would re-admit `.ssh` beneath the target.
452        std::os::unix::fs::symlink(root, root.join("root-link")).unwrap();
453        std::os::unix::fs::symlink(root.parent().unwrap(), root.join("parent-link")).unwrap();
454        // Positive control: a sibling subtree and a link into it stay granted.
455        std::os::unix::fs::symlink(root.join("work"), root.join("work-link")).unwrap();
456
457        let sensitive = vec![root.join(".ssh")];
458        let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
459
460        assert!(
461            !grants.iter().any(|g| g.ends_with("root-link") || g.ends_with("parent-link")),
462            "symlink to a sensitive ancestor must be excluded: {grants:?}"
463        );
464        assert!(grants.iter().any(|g| g.ends_with("work")));
465        assert!(grants.iter().any(|g| g.ends_with("work-link")));
466    }
467
468    #[test]
469    fn handled_fs_access_excludes_execute_and_ioctl_dev() {
470        use landlock::{ABI, AccessFs};
471
472        let abis = [
473            ABI::V1,
474            ABI::V2,
475            ABI::V3,
476            ABI::V4,
477            ABI::V5,
478            ABI::V6,
479            ABI::V7,
480            ABI::V8,
481            ABI::V9,
482        ];
483        for abi in abis {
484            let handled = handled_fs_access(abi);
485            assert!(!handled.contains(AccessFs::Execute), "Execute must stay unhandled at {abi:?}");
486            assert!(!handled.contains(AccessFs::IoctlDev), "IoctlDev must stay unhandled at {abi:?}");
487            assert!(handled.contains(AccessFs::ReadFile), "read handling lost at {abi:?}");
488            assert!(handled.contains(AccessFs::WriteFile), "write handling lost at {abi:?}");
489            // Only the two exclusions may be dropped: later-ABI rights such as
490            // Truncate (v3+) must stay handled.
491            if matches!(abi, ABI::V3 | ABI::V4 | ABI::V5 | ABI::V6 | ABI::V7 | ABI::V8 | ABI::V9) {
492                assert!(handled.contains(AccessFs::Truncate), "Truncate must stay handled at {abi:?}");
493            }
494            // Rule grants are intersected with the handled set, so they must
495            // never carry a right the ruleset does not handle (add_rule fails
496            // on such rules).
497            assert!(!(AccessFs::from_read(abi) & handled).contains(AccessFs::Execute));
498            assert!(!(AccessFs::from_write(abi) & handled).contains(AccessFs::IoctlDev));
499        }
500    }
501
502    #[test]
503    fn write_grants_read_only_is_dev_null_only() {
504        let paths = compute_write_rule_paths(&SandboxPolicy::read_only(), Path::new("/tmp"));
505        assert_eq!(paths, vec![PathBuf::from("/dev/null")]);
506    }
507
508    #[test]
509    fn restrict_dev_grants_replaces_wholesale_dev_with_explicit_nodes() {
510        // Asymmetric oracle: wholesale /dev (and /dev/fd → /proc/self/fd)
511        // must go; explicit compatibility nodes must stay.
512        let mut grants = vec![
513            PathBuf::from("/usr/bin"),
514            PathBuf::from("/dev"),
515            PathBuf::from("/dev/sda"),
516            PathBuf::from("/dev/fd"),
517            PathBuf::from("/dev/null"),
518        ];
519        restrict_dev_grants(&mut grants);
520
521        assert!(!grants.iter().any(|g| g.as_os_str() == "/dev"), "wholesale /dev grant survives: {grants:?}");
522        assert!(!grants.iter().any(|g| g.as_os_str() == "/dev/sda"), "device node grant survives: {grants:?}");
523        assert!(!grants.iter().any(|g| g.as_os_str() == "/dev/fd"), "/dev/fd grant survives: {grants:?}");
524        assert!(grants.iter().any(|g| g.as_os_str() == "/usr/bin"), "unrelated grant lost: {grants:?}");
525        assert!(grants.iter().any(|g| g.as_os_str() == "/dev/null"), "explicit /dev/null grant lost: {grants:?}");
526        for candidate in EXPLICIT_DEV_READ_GRANTS {
527            let path = PathBuf::from(candidate);
528            if path.exists() {
529                assert!(grants.contains(&path), "existing explicit node {candidate} missing: {grants:?}");
530            }
531        }
532    }
533
534    #[test]
535    fn virtual_fs_roots_cover_fd_reopen_escape() {
536        assert!(VIRTUAL_FS_ROOTS.contains(&"/proc"), "proc exclusion lost: {VIRTUAL_FS_ROOTS:?}");
537        assert!(VIRTUAL_FS_ROOTS.contains(&"/sys"), "sys exclusion lost: {VIRTUAL_FS_ROOTS:?}");
538        assert!(
539            EXPLICIT_DEV_READ_GRANTS.contains(&"/dev/null"),
540            "dev/null compatibility grant lost: {EXPLICIT_DEV_READ_GRANTS:?}"
541        );
542        assert!(
543            !EXPLICIT_DEV_READ_GRANTS.iter().any(|g| *g == "/dev/fd" || *g == "/dev"),
544            "explicit grants must not re-admit /dev/fd: {EXPLICIT_DEV_READ_GRANTS:?}"
545        );
546    }
547
548    #[test]
549    fn write_grants_workspace_roots() {
550        let workspace = TempDir::new().unwrap();
551        let cwd = workspace.path().to_path_buf();
552        let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
553        let paths = compute_write_rule_paths(&policy, &cwd);
554        assert_eq!(paths, vec![cwd]);
555    }
556
557    #[test]
558    fn probe_landlock_abi_is_none_or_positive() {
559        // On Linux this exercises the real syscall; on other platforms this
560        // test only guards the type contract.
561        if let Some(version) = probe_landlock_abi() {
562            assert!(version >= 1);
563        }
564    }
565
566    #[test]
567    fn apply_sandbox_restrictions_rejects_hostname_allowlists() {
568        // The launcher-side fail-closed check fires before any kernel probe,
569        // so it is exercised whenever the Linux module is compiled and run.
570        let policy = SandboxPolicy::read_only_with_network(vec![super::super::policy::NetworkAllowlistEntry::https(
571            "api.example.com",
572        )]);
573        let error = apply_sandbox_restrictions(
574            &policy,
575            &super::super::policy::SeccompProfile::strict(),
576            &ResourceLimits::unlimited(),
577            Path::new("/tmp"),
578        )
579        .expect_err("allowlist must fail closed at the launcher");
580        assert!(error.to_string().contains("allowlist"), "got {error}");
581    }
582}