1use std::os::unix::ffi::OsStrExt as _;
18use std::path::Path;
19use std::process::{Command, Stdio};
20use std::time::{Instant, SystemTime};
21
22use procage::native::{Cage, CageOptions, Grants};
23use procage::policy::{
24 AbsPath, Access, Base, EnvPolicy, Exec, FsRule, FsTarget, HardDenyClass, Lift, LowerOptions,
25 Network as Net, Policy, Ports, Relax, Relaxation, Relaxed, Signals,
26};
27
28use super::{Network, Profile};
29use crate::verify::process::{Captured, Invocation, Outcome, supervise};
30
31fn abs(p: &Path) -> Result<AbsPath, String> {
32 AbsPath::new(p.as_os_str().as_bytes()).map_err(|e| format!("{}: {e}", p.display()))
33}
34
35pub fn policy(profile: &Profile) -> Result<Policy, String> {
41 let mut p = Policy::default();
42 p.base = Base::V3;
47 p.process.exec = Exec::Any;
48 p.process.fork = true;
49 p.process.signals = if profile.unenforced.signal_scope {
50 Signals::SameUser
51 } else {
52 Signals::SelfAndChildren
53 };
54 p.env = EnvPolicy::Allowlist(Vec::new());
56 p.fs.push(FsRule::new(
58 Access::Read,
59 FsTarget::Subpath(abs(Path::new("/"))?),
60 ));
61 let existing = |paths: &[std::path::PathBuf]| -> Result<Vec<AbsPath>, String> {
64 paths
65 .iter()
66 .filter(|p| p.exists())
67 .map(|p| abs(p))
68 .collect()
69 };
70 let mut writable = profile.writable.clone();
71 writable.push(profile.tmp.clone());
72 let writable = existing(&writable)?;
73 for w in &writable {
74 p.fs.push(FsRule::new(Access::Write, FsTarget::Subpath(w.clone())));
75 }
76 for r in existing(&profile.read_only)? {
77 p.fs.push(FsRule::new(Access::Protect, FsTarget::Subpath(r)));
78 }
79 for h in existing(&profile.hidden)? {
80 p.fs.push(FsRule::new(Access::Hide, FsTarget::Subpath(h)));
81 }
82 if !profile.unenforced.workspace_sockets {
86 p.ipc.unix_sockets = writable;
87 }
88 if profile.unenforced.trust_handoff {
89 p.waive.insert(HardDenyClass::TrustHandoff);
90 }
91 p.net = match profile.network {
92 Network::Off => Net::None,
93 Network::Loopback if cfg!(target_os = "linux") => Net::None,
95 Network::Loopback => Net::Loopback(Ports::Any),
96 Network::On => Net::Ip,
99 };
100 Ok(p)
101}
102
103fn cage(profile: &Profile) -> Result<Cage, procage::native::SpawnError> {
107 cage_at(profile, None)
108}
109
110fn cage_at(profile: &Profile, cap: Option<u8>) -> Result<Cage, procage::native::SpawnError> {
112 cage_with(profile, &options(profile, cap))
113}
114
115fn cage_with(
117 profile: &Profile,
118 options: &CageOptions,
119) -> Result<Cage, procage::native::SpawnError> {
120 let policy = policy(profile).map_err(|e| {
121 procage::native::SpawnError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
122 })?;
123 Cage::with_options(&policy, options)
124}
125
126fn options(profile: &Profile, cap: Option<u8>) -> CageOptions {
132 let mut options = CageOptions::default().with_lower(
133 LowerOptions::default()
134 .accept_spawn_snapshot()
135 .allow(procage::policy::Construct::ProcOtherProcesses),
136 );
137 for objects in profile
138 .read_only
139 .iter()
140 .filter(|r| r.ends_with(".git"))
141 .map(|r| r.join("objects"))
142 .filter(|o| o.is_dir())
143 {
144 options = options.with_link_exemption(objects);
145 }
146 #[cfg(target_os = "linux")]
147 let options = match cap {
148 Some(abi) => options.with_landlock_abi(abi),
149 None => options,
150 };
151 #[cfg(not(target_os = "linux"))]
152 let _ = cap;
153 options
154}
155
156#[cfg(target_os = "linux")]
158fn landlock_abi(cage: &Cage) -> Option<u8> {
159 Some(cage.landlock_abi())
160}
161
162#[cfg(not(target_os = "linux"))]
163fn landlock_abi(_: &Cage) -> Option<u8> {
164 None
165}
166
167pub fn available() -> Result<(), String> {
173 let mut p = Policy::default();
174 p.base = Base::V3;
175 Cage::new(&p).map(drop).map_err(|e| e.to_string())
176}
177
178pub fn fit(profile: &Profile) -> Result<(Profile, Option<String>), String> {
198 fit_at(profile, None)
199}
200
201fn relax() -> Relax {
204 Relax::new()
205 .leave_out_protect()
206 .leave_out_hide()
207 .waive(HardDenyClass::TrustHandoff)
208 .no_unix_sockets()
209 .signals_same_user()
210}
211
212fn fit_at(profile: &Profile, cap: Option<u8>) -> Result<(Profile, Option<String>), String> {
214 let (cage, relaxed) = Cage::fit(&policy(profile)?, &options(profile, cap), &relax())
215 .map_err(|e| e.to_string())?;
216 let mut fitted = profile.clone();
217 let mut notes = Vec::new();
218 for r in &relaxed {
219 let what = adopt(&mut fitted, r)?;
220 note(&mut notes, cause(&r.lift), what);
221 }
222 if !relaxed.is_empty() {
223 cage_at(&fitted, cap).map_err(|e| format!("the fitted profile does not hold: {e}"))?;
224 }
225 let abi = landlock_abi(&cage);
226 if abi.is_some_and(|a| a < 2) {
227 note(
228 &mut notes,
229 Cause::Kernel(2),
230 "files cannot move between directories, so builds fail",
231 );
232 }
233 if abi.is_some_and(|a| a < 3) {
234 note(
235 &mut notes,
236 Cause::Kernel(3),
237 "truncating a file by path fails",
238 );
239 }
240 Ok((fitted, notice(¬es, abi)))
241}
242
243fn adopt(profile: &mut Profile, r: &Relaxed) -> Result<&'static str, String> {
246 Ok(match r.relaxation {
247 Relaxation::LeaveOutProtect | Relaxation::LeaveOutHide => {
248 let is =
249 |p: &std::path::PathBuf| r.gap.subject.as_deref() == Some(&*p.to_string_lossy());
250 let before = profile.read_only.len() + profile.hidden.len();
251 profile.read_only.retain(|p| !is(p));
252 profile.hidden.retain(|p| !is(p));
253 if before == profile.read_only.len() + profile.hidden.len() {
254 return Err(format!("{r}: not a path of the profile"));
255 }
256 "commands can write `.git` and read git-ignored secrets in place (the run stops if `.git` changes)"
257 }
258 Relaxation::Waive(HardDenyClass::TrustHandoff) => {
259 profile.unenforced.trust_handoff = true;
260 "git hooks and shell startup files in the workspace stay writable"
261 }
262 Relaxation::NoUnixSockets => {
263 profile.unenforced.workspace_sockets = true;
264 "Unix sockets in the workspace are unreachable"
265 }
266 Relaxation::SignalsSameUser => {
267 profile.unenforced.signal_scope = true;
268 "commands can signal your other processes"
269 }
270 _ => return Err(format!("{r}: speccoder does not accept this")),
271 })
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277enum Cause {
278 UserNamespaces,
280 Kernel(u8),
282 Unknown,
284}
285
286fn cause(lift: &Lift) -> Cause {
287 match (lift.tier1, lift.landlock_abi) {
288 (true, _) => Cause::UserNamespaces,
289 (false, Some(abi)) => Cause::Kernel(abi),
290 (false, None) => Cause::Unknown,
291 }
292}
293
294fn linux_with(abi: u8) -> Option<&'static str> {
296 Some(match abi {
297 1 => "5.13",
298 2 => "5.19",
299 3 => "6.2",
300 4 => "6.7",
301 5 => "6.10",
302 6 => "6.12",
303 7 => "6.15",
304 8 => "7.0",
305 _ => return None,
306 })
307}
308
309type Note = (Cause, String);
310
311fn note(notes: &mut Vec<Note>, cause: Cause, what: &str) {
312 let what = match cause {
313 Cause::Kernel(abi) => match linux_with(abi) {
314 Some(linux) => format!("{what} (Linux {linux} lifts this)"),
315 None => format!("{what} (Landlock ABI {abi} lifts this)"),
316 },
317 _ => what.to_owned(),
318 };
319 if !notes.iter().any(|(_, w)| *w == what) {
320 notes.push((cause, what));
321 }
322}
323
324fn notice(notes: &[Note], abi: Option<u8>) -> Option<String> {
326 let items = |pick: fn(&Cause) -> bool| {
327 notes
328 .iter()
329 .filter(|(c, _)| pick(c))
330 .map(|(_, w)| w.as_str())
331 .collect::<Vec<_>>()
332 .join("; ")
333 };
334 let mut clauses = Vec::new();
335 let userns = items(|c| *c == Cause::UserNamespaces);
336 if !userns.is_empty() {
337 clauses.push(format!(
338 "this machine does not allow user namespaces (on Ubuntu 23.10+: AppArmor), so {userns}"
339 ));
340 }
341 let kernel = items(|c| matches!(c, Cause::Kernel(_)));
342 if !kernel.is_empty() {
343 let which = abi.map_or_else(String::new, |a| format!(" (Landlock ABI {a})"));
344 clauses.push(format!("this kernel is too old{which}, so {kernel}"));
345 }
346 let unknown = items(|c| *c == Cause::Unknown);
347 if !unknown.is_empty() {
348 clauses.push(format!(
349 "this machine cannot enforce every part, so {unknown}"
350 ));
351 }
352 (!clauses.is_empty()).then(|| clauses.join("; and "))
353}
354
355fn command(argv: &[String], cwd: &Path, env: &[(String, String)]) -> Option<Command> {
357 let (program, args) = argv.split_first()?;
358 let mut cmd = Command::new(program);
359 cmd.args(args)
360 .current_dir(cwd)
361 .env_clear()
362 .envs(env.iter().map(|(k, v)| (k, v)))
363 .envs(foreground_git(
364 env.iter()
365 .find(|(k, _)| k == "GIT_CONFIG_COUNT")
366 .map(|(_, v)| v.as_str()),
367 ));
368 Some(cmd)
369}
370
371fn foreground_git(count: Option<&str>) -> [(String, String); 3] {
376 let n: usize = count.and_then(|c| c.parse().ok()).unwrap_or(0);
377 [
378 (format!("GIT_CONFIG_KEY_{n}"), "gc.autoDetach".to_owned()),
379 (format!("GIT_CONFIG_VALUE_{n}"), "false".to_owned()),
380 ("GIT_CONFIG_COUNT".to_owned(), (n + 1).to_string()),
381 ]
382}
383
384#[must_use]
387pub fn run(profile: &Profile, inv: &Invocation) -> Outcome {
388 let started = SystemTime::now();
389 let clock = Instant::now();
390 let failed = |e: String| Outcome {
391 exit_code: None,
392 timed_out: false,
393 spawn_error: Some(e),
394 started,
395 duration: clock.elapsed(),
396 stdout: Captured::default(),
397 stderr: Captured::default(),
398 sandbox: Default::default(),
399 };
400 let cage = match cage_with(profile, &options(profile, None).with_violations()) {
404 Ok(c) => c,
405 Err(e) => return failed(format!("the sandbox could not confine the command: {e}")),
406 };
407 let Some(mut cmd) = command(&inv.argv, &inv.cwd, &inv.env) else {
408 return failed("empty argv".to_owned());
409 };
410 cmd.stdin(Stdio::null())
411 .stdout(Stdio::piped())
412 .stderr(Stdio::piped());
413 let program = &inv.argv[0];
414 let child = match cage.spawn(&mut cmd, &Grants::new()) {
415 Ok(c) => c,
416 Err(e) => return failed(format!("could not start `{program}` in the sandbox: {e}")),
417 };
418 let mut caged = Caged(child);
419 let mut outcome = supervise(&mut caged, inv, started, clock);
420 if outcome.exit_code != Some(0) {
423 outcome.sandbox = super::denials::from_record(&caged.0.violations());
424 }
425 outcome
426}
427
428struct Caged(procage::native::Child);
431
432impl crate::verify::process::Supervised for Caged {
433 fn child(&mut self) -> &mut std::process::Child {
434 &mut self.0
435 }
436
437 fn kill(&mut self) {
438 let _ = self.0.kill_all();
439 }
440}
441
442pub fn exec(profile: &Profile, argv: &[String]) -> String {
448 let cage = match cage(profile) {
449 Ok(c) => c,
450 Err(e) => return format!("the sandbox could not confine the command: {e}"),
451 };
452 let Some((program, args)) = argv.split_first() else {
453 return "nothing to run".to_owned();
454 };
455 let mut cmd = Command::new(program);
456 cmd.args(args)
458 .envs(std::env::vars_os())
459 .envs(foreground_git(
460 std::env::var("GIT_CONFIG_COUNT").ok().as_deref(),
461 ));
462 let err = cage.exec_session(&mut cmd, &Grants::new());
463 format!("could not start `{program}` in the sandbox: {err}")
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use procage::policy::{CapabilityGap, GapKind, Target, Tier};
470
471 fn relaxed(relaxation: Relaxation, subject: Option<&str>, lift: Lift) -> Relaxed {
472 Relaxed {
473 gap: CapabilityGap {
474 construct: relaxation.construct(),
475 target: Target::Linux {
476 tier: Tier::T2,
477 landlock_abi: 5,
478 },
479 kind: GapKind::Unsupported,
480 subject: subject.map(str::to_owned),
481 },
482 relaxation,
483 lift,
484 }
485 }
486
487 #[test]
488 fn caged_git_configuration_keeps_what_the_environment_set() {
489 assert_eq!(
490 foreground_git(Some("2")),
491 [
492 ("GIT_CONFIG_KEY_2".to_owned(), "gc.autoDetach".to_owned()),
493 ("GIT_CONFIG_VALUE_2".to_owned(), "false".to_owned()),
494 ("GIT_CONFIG_COUNT".to_owned(), "3".to_owned()),
495 ]
496 );
497 assert_eq!(foreground_git(None)[2].1, "1");
498 }
499
500 #[test]
503 fn the_policy_is_on_base_v3() {
504 let p = Profile::new(Path::new("/w"), &super::super::Config::default(), None);
505 assert_eq!(policy(&p).unwrap().base, Base::V3);
506 }
507
508 const T1: Lift = Lift {
509 tier1: true,
510 landlock_abi: None,
511 };
512
513 fn profile() -> Profile {
514 let mut p = Profile::new(Path::new("/w"), &super::super::Config::default(), None);
515 p.read_only.push("/w/.git".into());
516 p.hidden.push("/w/.env".into());
517 p
518 }
519
520 #[test]
523 fn each_relaxation_is_recorded_in_the_profile() {
524 let mut p = profile();
525 let protect = relaxed(Relaxation::LeaveOutProtect, Some("/w/.git"), T1);
526 assert!(adopt(&mut p, &protect).unwrap().contains("`.git`"));
527 assert!(p.read_only.is_empty() && p.hidden == [Path::new("/w/.env")]);
528 adopt(
529 &mut p,
530 &relaxed(Relaxation::LeaveOutHide, Some("/w/.env"), T1),
531 )
532 .unwrap();
533 assert!(p.hidden.is_empty());
534 let handoff = Relaxation::Waive(HardDenyClass::TrustHandoff);
535 assert!(
536 adopt(&mut p, &relaxed(handoff, None, T1))
537 .unwrap()
538 .contains("git hooks")
539 );
540 adopt(&mut p, &relaxed(Relaxation::NoUnixSockets, None, T1)).unwrap();
541 adopt(
542 &mut p,
543 &relaxed(Relaxation::SignalsSameUser, None, Lift::default()),
544 )
545 .unwrap();
546 assert!(p.unenforced.trust_handoff && p.unenforced.workspace_sockets);
547 assert!(p.unenforced.signal_scope);
548 let policy = policy(&p).unwrap();
550 assert!(policy.waive.contains(&HardDenyClass::TrustHandoff));
551 assert!(policy.ipc.unix_sockets.is_empty());
552 assert_eq!(policy.process.signals, Signals::SameUser);
553 assert!(
554 !policy
555 .fs
556 .iter()
557 .any(|r| matches!(r.access, Access::Protect | Access::Hide))
558 );
559 }
560
561 #[test]
563 fn other_relaxations_fail() {
564 let mut p = profile();
565 for r in [
566 relaxed(Relaxation::Waive(HardDenyClass::Secrets), None, T1),
567 relaxed(Relaxation::NoLoopback, None, T1),
568 relaxed(Relaxation::LeaveOutHide, Some("/w/other"), T1),
569 ] {
570 assert!(adopt(&mut p, &r).is_err(), "{r}");
571 }
572 assert_eq!(p, profile());
573 assert_eq!(relax().accepted().count(), 5);
574 }
575
576 #[test]
579 fn the_notice_names_each_cause() {
580 assert_eq!(cause(&T1), Cause::UserNamespaces);
581 let abi6 = Lift {
582 tier1: false,
583 landlock_abi: Some(6),
584 };
585 assert_eq!(cause(&abi6), Cause::Kernel(6));
586 assert_eq!(cause(&Lift::default()), Cause::Unknown);
587 let mut notes = Vec::new();
588 note(&mut notes, Cause::UserNamespaces, "sockets");
589 assert_eq!(
590 notice(¬es, Some(8)).unwrap(),
591 "this machine does not allow user namespaces (on Ubuntu 23.10+: AppArmor), so sockets"
592 );
593 let mut kernel = Vec::new();
594 note(&mut kernel, Cause::Kernel(6), "signals");
595 let n = notice(&kernel, Some(5)).unwrap();
596 assert_eq!(
597 n,
598 "this kernel is too old (Landlock ABI 5), so signals (Linux 6.12 lifts this)"
599 );
600 notes.extend(kernel);
601 note(&mut notes, Cause::Unknown, "other");
602 let all = notice(¬es, Some(5)).unwrap();
603 assert!(
604 all.starts_with("this machine does not allow user namespaces")
605 && all.contains("; and this kernel is too old (Landlock ABI 5), so signals")
606 && all.ends_with("; and this machine cannot enforce every part, so other"),
607 "{all}"
608 );
609 let mut new = Vec::new();
610 note(&mut new, Cause::Kernel(12), "x");
611 assert!(
612 notice(&new, None)
613 .unwrap()
614 .ends_with("x (Landlock ABI 12 lifts this)")
615 );
616 assert_eq!(notice(&[], Some(5)), None);
617 }
618
619 #[cfg(target_os = "linux")]
623 #[test]
624 fn older_kernels_keep_commands_caged() {
625 let dir = tempfile::tempdir().unwrap();
626 let base = std::fs::canonicalize(dir.path()).unwrap();
627 let (root, home, outside) = (base.join("w"), base.join("home"), base.join("o"));
628 for d in [&root, &home.join(".ssh"), &outside] {
629 std::fs::create_dir_all(d).unwrap();
630 }
631 std::fs::write(home.join(".ssh/id"), "secret").unwrap();
632 let mut sleeper = Command::new("sleep").arg("30").spawn().unwrap();
633 let profile = Profile::new(&root, &super::super::Config::default(), Some(&home));
634 let kernel = procage::linux::probe()
635 .landlock_abi
636 .min(procage::linux::BACKEND_MAX_ABI);
637 let ok = |p: &Profile, cap: Option<u8>, script: &str| -> bool {
638 let cage = cage_at(p, cap).unwrap();
639 let mut cmd = Command::new("/bin/sh");
640 cmd.args(["-c", script])
641 .env_clear()
642 .env("PATH", "/usr/bin:/bin")
643 .stdout(Stdio::null())
644 .stderr(Stdio::null());
645 let mut child = cage.spawn(&mut cmd, &Grants::new()).unwrap();
646 child.wait().unwrap().success()
647 };
648 let signal = format!("kill -0 {}", sleeper.id());
649 let moves = format!(
651 "cd {} && mkdir -p a b && echo x > a/x && python3 -I -c \
652 'import os; os.rename(\"a/x\", \"b/x\"); os.rename(\"b/x\", \"a/x\")'",
653 root.display()
654 );
655 for cap in [1, 2, 5].into_iter().filter(|&c| c < kernel) {
656 let (fitted, notice) = fit_at(&profile, Some(cap)).unwrap();
657 assert!(fitted.unenforced.signal_scope, "ABI {cap}");
658 let notice = notice.unwrap();
659 assert!(
660 notice.contains(&format!("this kernel is too old (Landlock ABI {cap})"))
661 && notice.contains("commands can signal your other processes"),
662 "{notice}"
663 );
664 assert_eq!(
665 notice.contains("truncating a file by path"),
666 cap < 3,
667 "{notice}"
668 );
669 assert_eq!(
670 notice.contains("files cannot move between directories"),
671 cap < 2,
672 "{notice}"
673 );
674 let at = Some(cap);
675 assert_eq!(ok(&fitted, at, &moves), cap >= 2, "ABI {cap}");
676 assert!(
677 ok(&fitted, at, &format!("echo x > {}/f", root.display())),
678 "ABI {cap}"
679 );
680 assert!(
681 !ok(&fitted, at, &format!("echo x > {}/f", outside.display())),
682 "ABI {cap}"
683 );
684 assert!(
685 !ok(&fitted, at, &format!("cat {}/.ssh/id", home.display())),
686 "ABI {cap}"
687 );
688 assert!(ok(&fitted, at, &signal), "ABI {cap}");
689 }
690 if kernel >= 6 {
691 let (fitted, _) = fit_at(&profile, None).unwrap();
692 assert!(!fitted.unenforced.signal_scope);
693 assert!(!ok(&fitted, None, &signal));
694 }
695 assert!(!outside.join("f").exists());
696 let _ = sleeper.kill();
697 let _ = sleeper.wait();
698 }
699
700 #[test]
703 fn the_network_on_keeps_the_unix_socket_rule() {
704 let dir = tempfile::tempdir().unwrap();
705 let mut p = Profile::new(dir.path(), &super::super::Config::default(), None);
706 let sockets = policy(&p).unwrap().ipc.unix_sockets;
707 assert!(!sockets.is_empty());
708 p.network = Network::On;
709 let on = policy(&p).unwrap();
710 assert_eq!((on.net, on.ipc.unix_sockets), (Net::Ip, sockets));
711 p.unenforced.workspace_sockets = true;
712 assert!(policy(&p).unwrap().ipc.unix_sockets.is_empty());
713 }
714}