vtcode_safety/sandboxing/
linux.rs1use std::collections::{HashSet, VecDeque};
38use std::path::{Path, PathBuf};
39
40use anyhow::{Result, anyhow, bail};
41
42use super::policy::{ResourceLimits, SandboxPolicy};
43
44const MAX_LANDLOCK_RULES: usize = 4096;
47
48pub fn landlock_supported() -> bool {
53 static SUPPORTED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| probe_landlock_abi().is_some());
54 *SUPPORTED
55}
56
57fn probe_landlock_abi() -> Option<u32> {
59 const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1 << 0;
62 let version = unsafe {
63 libc::syscall(
64 libc::SYS_landlock_create_ruleset,
65 std::ptr::null::<libc::c_void>(),
66 0usize,
67 LANDLOCK_CREATE_RULESET_VERSION,
68 )
69 };
70 if version < 0 { None } else { u32::try_from(version).ok() }
71}
72
73pub fn apply_sandbox_restrictions(
79 policy: &SandboxPolicy,
80 seccomp: &super::policy::SeccompProfile,
81 limits: &ResourceLimits,
82 policy_cwd: &Path,
83) -> Result<()> {
84 if policy.has_network_allowlist() {
89 bail!(
90 "hostname network allowlists cannot be enforced exactly by the Linux sandbox; refusing unrestricted network"
91 );
92 }
93 apply_resource_limits(limits)?;
94 apply_landlock(policy, policy_cwd)?;
95 super::linux_seccomp::apply_seccomp_filter(seccomp)?;
96 Ok(())
97}
98
99fn apply_resource_limits(limits: &ResourceLimits) -> Result<()> {
102 use nix::sys::resource::{Resource, setrlimit};
103
104 let mib = |mb: u64| mb.saturating_mul(1024 * 1024);
105 if limits.max_memory_mb > 0 {
106 setrlimit(Resource::RLIMIT_AS, mib(limits.max_memory_mb), mib(limits.max_memory_mb))
107 .map_err(|error| anyhow!("RLIMIT_AS failed: {error}"))?;
108 }
109 if limits.max_pids > 0 {
110 let pids = u64::from(limits.max_pids);
111 setrlimit(Resource::RLIMIT_NPROC, pids, pids).map_err(|error| anyhow!("RLIMIT_NPROC failed: {error}"))?;
112 }
113 if limits.max_disk_mb > 0 {
114 setrlimit(Resource::RLIMIT_FSIZE, mib(limits.max_disk_mb), mib(limits.max_disk_mb))
115 .map_err(|error| anyhow!("RLIMIT_FSIZE failed: {error}"))?;
116 }
117 if limits.cpu_time_secs > 0 {
118 let secs = limits.cpu_time_secs;
119 setrlimit(Resource::RLIMIT_CPU, secs, secs).map_err(|error| anyhow!("RLIMIT_CPU failed: {error}"))?;
120 }
121 Ok(())
122}
123
124pub fn apply_landlock(policy: &SandboxPolicy, policy_cwd: &Path) -> Result<()> {
126 use landlock::{ABI, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus};
127
128 let Some(version) = probe_landlock_abi() else {
129 bail!("Landlock is not supported by this kernel (Linux 5.13+ required); refusing to run unsandboxed");
130 };
131 let abi = ABI::from(i32::try_from(version).unwrap_or(0));
134 if abi == ABI::Unsupported {
135 bail!("Landlock ABI version {version} is not usable");
136 }
137
138 let handled = handled_fs_access(abi);
139 let rules = compute_rules(policy, policy_cwd, abi, handled)?;
140
141 let mut created = Ruleset::default()
142 .handle_access(handled)
143 .map_err(|error| anyhow!("Landlock ruleset setup failed: {error}"))?
144 .create()
145 .map_err(|error| anyhow!("Landlock ruleset creation failed: {error}"))?;
146 for rule in &rules {
147 let fd = PathFd::new(&rule.path)
148 .map_err(|error| anyhow!("Landlock cannot open rule path {}: {error}", rule.path.display()))?;
149 created = created
150 .add_rule(PathBeneath::new(fd, rule.access))
151 .map_err(|error| anyhow!("Landlock rule for {} failed: {error}", rule.path.display()))?;
152 }
153 let status = created
154 .restrict_self()
155 .map_err(|error| anyhow!("Landlock self-restriction failed: {error}"))?;
156 if status.ruleset != RulesetStatus::FullyEnforced {
157 bail!("Landlock restrictions were only partially enforced ({:?}); refusing to exec", status.ruleset);
158 }
159 Ok(())
160}
161
162struct LandlockRule {
164 path: PathBuf,
165 access: landlock::BitFlags<landlock::AccessFs>,
166}
167
168fn handled_fs_access(abi: landlock::ABI) -> landlock::BitFlags<landlock::AccessFs> {
181 use landlock::{AccessFs, BitFlags};
182
183 let abi_access: BitFlags<AccessFs> = AccessFs::from_read(abi) | AccessFs::from_write(abi);
184 abi_access & !(AccessFs::Execute | AccessFs::IoctlDev)
185}
186
187fn compute_rules(
190 policy: &SandboxPolicy,
191 policy_cwd: &Path,
192 abi: landlock::ABI,
193 handled: landlock::BitFlags<landlock::AccessFs>,
194) -> Result<Vec<LandlockRule>> {
195 let mut rules = Vec::new();
196 for path in compute_read_rule_paths(policy, policy_cwd)? {
197 rules.push(LandlockRule {
198 path,
199 access: landlock::AccessFs::from_read(abi) & handled,
200 });
201 }
202 for path in compute_write_rule_paths(policy, policy_cwd) {
203 rules.push(LandlockRule {
204 path,
205 access: landlock::AccessFs::from_write(abi) & handled,
206 });
207 }
208 Ok(rules)
209}
210
211const VIRTUAL_FS_ROOTS: &[&str] = &["/proc", "/sys"];
220
221const EXPLICIT_DEV_READ_GRANTS: &[&str] = &["/dev/null", "/dev/zero", "/dev/urandom", "/dev/random"];
228
229fn read_blocked_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Vec<PathBuf> {
231 policy
232 .sensitive_paths_for_execution(policy_cwd)
233 .into_iter()
234 .filter(|sp| sp.block_read)
235 .map(|sp| sp.expand_path())
236 .collect()
237}
238
239fn compute_read_rule_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Result<Vec<PathBuf>> {
246 let mut exclusions = read_blocked_paths(policy, policy_cwd);
247 exclusions.extend(VIRTUAL_FS_ROOTS.iter().map(PathBuf::from));
248
249 let mut roots = vec![PathBuf::from("/")];
250 if let Some(home) = dirs::home_dir()
251 && home != Path::new("/")
252 {
253 roots.push(home);
254 }
255 let mut grants = enumerate_read_grants(&roots, &exclusions)?;
256 restrict_dev_grants(&mut grants);
257 if grants.len() > MAX_LANDLOCK_RULES {
258 bail!(
259 "Landlock read enumeration produced {} rules (cap {MAX_LANDLOCK_RULES}); refusing to continue",
260 grants.len()
261 );
262 }
263 Ok(grants)
264}
265
266fn restrict_dev_grants(grants: &mut Vec<PathBuf>) {
272 let dev_root = Path::new("/dev");
273 grants.retain(|path| {
274 !path_within(path, dev_root) || EXPLICIT_DEV_READ_GRANTS.iter().any(|grant| path == Path::new(grant))
275 });
276 for candidate in EXPLICIT_DEV_READ_GRANTS {
277 let path = PathBuf::from(candidate);
278 if path.exists() && !grants.contains(&path) {
279 grants.push(path);
280 }
281 }
282}
283
284fn path_within(path: &Path, ancestor: &Path) -> bool {
287 super::policy::path_starts_with_case_insensitive(path, ancestor)
288}
289
290fn enumerate_read_grants(roots: &[PathBuf], sensitive: &[PathBuf]) -> Result<Vec<PathBuf>> {
300 let mut grants = Vec::new();
301 let mut queued: HashSet<PathBuf> = HashSet::new();
302 let mut queue: VecDeque<PathBuf> = VecDeque::new();
303 for root in roots {
304 if queued.insert(root.clone()) {
305 queue.push_back(root.clone());
306 }
307 }
308 while let Some(dir) = queue.pop_front() {
309 let Ok(entries) = std::fs::read_dir(&dir) else {
310 continue;
313 };
314 for entry in entries.flatten() {
315 let path = entry.path();
316 if sensitive.iter().any(|sp| path_within(&path, sp)) {
317 continue;
318 }
319 let Ok(file_type) = entry.file_type() else { continue };
320 if file_type.is_dir() {
321 if sensitive.iter().any(|sp| path_within(sp, &path)) {
322 if queued.insert(path.clone()) {
323 queue.push_back(path);
324 }
325 } else {
326 grants.push(path);
327 }
328 } else if file_type.is_symlink() {
329 if let Ok(target) = std::fs::canonicalize(&path)
334 && !sensitive.iter().any(|sp| path_within(&target, sp) || path_within(sp, &target))
335 {
336 grants.push(path);
337 }
338 } else {
339 grants.push(path);
340 }
341 }
342 }
343 Ok(grants)
344}
345
346fn compute_write_rule_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Vec<PathBuf> {
348 match policy {
349 SandboxPolicy::ReadOnly { .. } => vec![PathBuf::from("/dev/null")],
351 SandboxPolicy::WorkspaceWrite { .. } => policy
352 .get_writable_roots_with_cwd(policy_cwd)
353 .into_iter()
354 .map(|root| root.root)
355 .collect(),
356 SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use std::fs;
365 use tempfile::TempDir;
366
367 fn sorted(mut paths: Vec<PathBuf>) -> Vec<String> {
368 paths.sort();
369 paths.into_iter().map(|p| p.display().to_string()).collect()
370 }
371
372 #[test]
373 fn read_grants_exclude_sensitive_subtrees_and_files() {
374 let root = TempDir::new().unwrap();
375 let root = root.path();
376 fs::create_dir_all(root.join("src")).unwrap();
377 fs::create_dir_all(root.join(".ssh")).unwrap();
378 fs::create_dir_all(root.join("deep/with/.config/gcloud")).unwrap();
379 fs::create_dir_all(root.join("deep/with/.config/git")).unwrap();
380 fs::write(root.join("readme.md"), "x").unwrap();
381 fs::write(root.join(".npmrc"), "token").unwrap();
382
383 let sensitive = vec![
384 root.join(".ssh"),
385 root.join(".npmrc"),
386 root.join("deep/with/.config/gcloud"),
387 ];
388 let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
389
390 assert!(grants.iter().any(|g| g.ends_with("src")), "wholesale dir grant: {grants:?}");
391 assert!(grants.iter().any(|g| g.ends_with("readme.md")));
392 assert!(grants.iter().any(|g| g.ends_with(".config/git")));
395 assert!(!grants.iter().any(|g| g.contains(".ssh")));
397 assert!(!grants.iter().any(|g| g.contains(".npmrc")));
398 assert!(!grants.iter().any(|g| g.contains("gcloud")));
399 assert!(!grants.iter().any(|g| g.as_str() == root.display().to_string()));
400 }
401
402 #[cfg(unix)]
403 #[test]
404 fn read_grants_exclude_symlinks_into_sensitive_paths() {
405 let root = TempDir::new().unwrap();
406 let root = root.path();
407 fs::create_dir_all(root.join(".ssh")).unwrap();
408 fs::create_dir_all(root.join("work")).unwrap();
409 std::os::unix::fs::symlink(root.join(".ssh"), root.join("ssh-link")).unwrap();
410 std::os::unix::fs::symlink(root.join("work"), root.join("work-link")).unwrap();
411
412 let sensitive = vec![root.join(".ssh")];
413 let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
414
415 assert!(
416 !grants.iter().any(|g| g.ends_with("ssh-link")),
417 "symlink into sensitive must be excluded: {grants:?}"
418 );
419 assert!(grants.iter().any(|g| g.ends_with("work")));
420 assert!(grants.iter().any(|g| g.ends_with("work-link")));
421 }
422
423 #[cfg(unix)]
424 #[test]
425 fn read_grants_exclude_symlinks_to_sensitive_ancestors() {
426 let root = TempDir::new().unwrap();
427 let root = root.path();
428 fs::create_dir_all(root.join(".ssh")).unwrap();
429 fs::create_dir_all(root.join("work")).unwrap();
430 std::os::unix::fs::symlink(root, root.join("root-link")).unwrap();
434 std::os::unix::fs::symlink(root.parent().unwrap(), root.join("parent-link")).unwrap();
435 std::os::unix::fs::symlink(root.join("work"), root.join("work-link")).unwrap();
437
438 let sensitive = vec![root.join(".ssh")];
439 let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
440
441 assert!(
442 !grants.iter().any(|g| g.ends_with("root-link") || g.ends_with("parent-link")),
443 "symlink to a sensitive ancestor must be excluded: {grants:?}"
444 );
445 assert!(grants.iter().any(|g| g.ends_with("work")));
446 assert!(grants.iter().any(|g| g.ends_with("work-link")));
447 }
448
449 #[test]
450 fn handled_fs_access_excludes_execute_and_ioctl_dev() {
451 use landlock::{ABI, AccessFs};
452
453 let abis = [
454 ABI::V1,
455 ABI::V2,
456 ABI::V3,
457 ABI::V4,
458 ABI::V5,
459 ABI::V6,
460 ABI::V7,
461 ABI::V8,
462 ABI::V9,
463 ];
464 for abi in abis {
465 let handled = handled_fs_access(abi);
466 assert!(!handled.contains(AccessFs::Execute), "Execute must stay unhandled at {abi:?}");
467 assert!(!handled.contains(AccessFs::IoctlDev), "IoctlDev must stay unhandled at {abi:?}");
468 assert!(handled.contains(AccessFs::ReadFile), "read handling lost at {abi:?}");
469 assert!(handled.contains(AccessFs::WriteFile), "write handling lost at {abi:?}");
470 if matches!(abi, ABI::V3 | ABI::V4 | ABI::V5 | ABI::V6 | ABI::V7 | ABI::V8 | ABI::V9) {
473 assert!(handled.contains(AccessFs::Truncate), "Truncate must stay handled at {abi:?}");
474 }
475 assert!(!(AccessFs::from_read(abi) & handled).contains(AccessFs::Execute));
479 assert!(!(AccessFs::from_write(abi) & handled).contains(AccessFs::IoctlDev));
480 }
481 }
482
483 #[test]
484 fn write_grants_read_only_is_dev_null_only() {
485 let paths = compute_write_rule_paths(&SandboxPolicy::read_only(), Path::new("/tmp"));
486 assert_eq!(paths, vec![PathBuf::from("/dev/null")]);
487 }
488
489 #[test]
490 fn restrict_dev_grants_replaces_wholesale_dev_with_explicit_nodes() {
491 let mut grants = vec![
494 PathBuf::from("/usr/bin"),
495 PathBuf::from("/dev"),
496 PathBuf::from("/dev/sda"),
497 PathBuf::from("/dev/fd"),
498 PathBuf::from("/dev/null"),
499 ];
500 restrict_dev_grants(&mut grants);
501
502 assert!(!grants.iter().any(|g| g.as_os_str() == "/dev"), "wholesale /dev grant survives: {grants:?}");
503 assert!(!grants.iter().any(|g| g.as_os_str() == "/dev/sda"), "device node grant survives: {grants:?}");
504 assert!(!grants.iter().any(|g| g.as_os_str() == "/dev/fd"), "/dev/fd grant survives: {grants:?}");
505 assert!(grants.iter().any(|g| g.as_os_str() == "/usr/bin"), "unrelated grant lost: {grants:?}");
506 assert!(grants.iter().any(|g| g.as_os_str() == "/dev/null"), "explicit /dev/null grant lost: {grants:?}");
507 for candidate in EXPLICIT_DEV_READ_GRANTS {
508 let path = PathBuf::from(candidate);
509 if path.exists() {
510 assert!(grants.contains(&path), "existing explicit node {candidate} missing: {grants:?}");
511 }
512 }
513 }
514
515 #[test]
516 fn virtual_fs_roots_cover_fd_reopen_escape() {
517 assert!(VIRTUAL_FS_ROOTS.contains(&"/proc"), "proc exclusion lost: {VIRTUAL_FS_ROOTS:?}");
518 assert!(VIRTUAL_FS_ROOTS.contains(&"/sys"), "sys exclusion lost: {VIRTUAL_FS_ROOTS:?}");
519 assert!(
520 EXPLICIT_DEV_READ_GRANTS.contains(&"/dev/null"),
521 "dev/null compatibility grant lost: {EXPLICIT_DEV_READ_GRANTS:?}"
522 );
523 assert!(
524 !EXPLICIT_DEV_READ_GRANTS.iter().any(|g| *g == "/dev/fd" || *g == "/dev"),
525 "explicit grants must not re-admit /dev/fd: {EXPLICIT_DEV_READ_GRANTS:?}"
526 );
527 }
528
529 #[test]
530 fn write_grants_workspace_roots() {
531 let workspace = TempDir::new().unwrap();
532 let cwd = workspace.path().to_path_buf();
533 let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
534 let paths = compute_write_rule_paths(&policy, &cwd);
535 assert_eq!(paths, vec![cwd]);
536 }
537
538 #[test]
539 fn probe_landlock_abi_is_none_or_positive() {
540 if let Some(version) = probe_landlock_abi() {
543 assert!(version >= 1);
544 }
545 }
546
547 #[test]
548 fn apply_sandbox_restrictions_rejects_hostname_allowlists() {
549 let policy = SandboxPolicy::read_only_with_network(vec![super::super::policy::NetworkAllowlistEntry::https(
552 "api.example.com",
553 )]);
554 let error = apply_sandbox_restrictions(
555 &policy,
556 &super::super::policy::SeccompProfile::strict(),
557 &ResourceLimits::unlimited(),
558 Path::new("/tmp"),
559 )
560 .expect_err("allowlist must fail closed at the launcher");
561 assert!(error.to_string().contains("allowlist"), "got {error}");
562 }
563}