vtcode_safety/sandboxing/
linux.rs1use std::collections::{HashSet, VecDeque};
34use std::path::{Path, PathBuf};
35
36use anyhow::{Result, anyhow, bail};
37
38use super::policy::{ResourceLimits, SandboxPolicy};
39
40const MAX_LANDLOCK_RULES: usize = 4096;
43
44pub fn landlock_supported() -> bool {
49 static SUPPORTED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| probe_landlock_abi().is_some());
50 *SUPPORTED
51}
52
53fn probe_landlock_abi() -> Option<u32> {
55 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
69pub fn apply_sandbox_restrictions(
75 policy: &SandboxPolicy,
76 seccomp: &super::policy::SeccompProfile,
77 limits: &ResourceLimits,
78 policy_cwd: &Path,
79) -> Result<()> {
80 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
95fn 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
120pub 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 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
158struct LandlockRule {
160 path: PathBuf,
161 access: landlock::BitFlags<landlock::AccessFs>,
162}
163
164fn 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
183fn 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
207fn 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
217fn 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
241fn path_within(path: &Path, ancestor: &Path) -> bool {
244 super::policy::path_starts_with_case_insensitive(path, ancestor)
245}
246
247fn 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 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 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
303fn compute_write_rule_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Vec<PathBuf> {
305 match policy {
306 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 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 assert!(grants.iter().any(|g| g.ends_with(".config/git")));
352 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 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 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 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 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 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 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}