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