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
57#[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 const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1 << 0;
74 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
92pub fn apply_sandbox_restrictions(
98 policy: &SandboxPolicy,
99 seccomp: &super::policy::SeccompProfile,
100 limits: &ResourceLimits,
101 policy_cwd: &Path,
102) -> Result<()> {
103 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
118fn 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
143pub 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 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
181struct LandlockRule {
183 path: PathBuf,
184 access: landlock::BitFlags<landlock::AccessFs>,
185}
186
187fn 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
206fn 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
230const VIRTUAL_FS_ROOTS: &[&str] = &["/proc", "/sys"];
239
240const EXPLICIT_DEV_READ_GRANTS: &[&str] = &["/dev/null", "/dev/zero", "/dev/urandom", "/dev/random"];
247
248fn 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
258fn 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
285fn 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
303fn path_within(path: &Path, ancestor: &Path) -> bool {
306 super::policy::path_starts_with_case_insensitive(path, ancestor)
307}
308
309fn 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 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 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
365fn compute_write_rule_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Vec<PathBuf> {
367 match policy {
368 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 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 assert!(grants.iter().any(|g| g.ends_with(".config/git")));
414 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 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 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 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 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 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 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 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}