ferroday_cage/restrict.rs
1//! The restriction fallback: confine a plain spawned command with Landlock
2//! and seccomp, for hosts without unprivileged user namespaces.
3//!
4//! A [`Restriction`] is the degraded counterpart of a [`Cage`], for a host that
5//! cannot provide the namespaces and root-swap a cage is built from. It spawns
6//! the command directly on the host, confined by the same Landlock filesystem
7//! and network rules and seccomp filters the cage's hardening layer applies,
8//! always under the no-new-privileges flag. The command runs in the host PID
9//! space as the calling user, unmapped.
10//!
11//! Each control confines only what it covers, and nothing is confined by
12//! default:
13//!
14//! - a Landlock filesystem grant restricts the host filesystem to the granted
15//! paths, but with no filesystem grant the command sees the entire host
16//! filesystem;
17//! - a Landlock network grant restricts *TCP* bind and connect to the granted
18//! ports, but leaves UDP, other socket families, and — with no network grant
19//! — all networking unrestricted;
20//! - a seccomp filter restricts syscalls, but confines neither the filesystem
21//! nor the network.
22//!
23//! A restriction confined only by seccomp therefore still exposes the whole
24//! host filesystem as the calling user; the filesystem boundary is the
25//! Landlock grant, not the filter.
26//!
27//! # Residual host surface
28//!
29//! Because a restriction runs a host process with no namespace or root-swap
30//! isolation, several host-reaching surfaces are governed only by whatever
31//! Landlock and seccomp the caller configured. A restriction always drops to
32//! the empty capability set, so no capability-gated reach remains; what is left
33//! is same-credential access that only a configured control closes:
34//!
35//! - **Abstract-socket services.** A Landlock network grant governs TCP alone,
36//! so `AF_UNIX` sockets — including the abstract-namespace paths to D-Bus, the
37//! X server, and the session bus — stay reachable. Denying them needs a
38//! seccomp filter on the socket syscalls, or a cage's network namespace.
39//! - **Same-user `/proc`.** Under a seccomp-only restriction with no Landlock
40//! filesystem grant, `/proc/<pid>/{environ,mem,maps}` of other host processes
41//! sharing the caller's uid remain readable. A Landlock filesystem grant that
42//! does not cover `/proc` closes this.
43//! - **Same-user `ptrace`.** Under a Landlock-only restriction (no seccomp),
44//! `ptrace` of other same-uid host processes is possible where the host's
45//! Yama policy permits it. The curated seccomp denylist blocks `ptrace`
46//! outright.
47//! - **The caller's terminal.** A command holding a descriptor onto the
48//! caller's terminal can read what is typed at it, change its settings, and —
49//! where the terminal is the command's own controlling terminal — push input
50//! into it with the `TIOCSTI` ioctl. Landlock does not close this, whatever
51//! the ABI: its rights bind a descriptor when it is opened, and an inherited
52//! terminal was opened before the ruleset existed. Neither does a namespace or
53//! a swapped root: a controlling terminal is reached through the session, not
54//! through the filesystem, and an inherited descriptor is reached through
55//! neither. What closes it is not handing the terminal over — see below — the
56//! `dev.tty.legacy_tiocsti` sysctl, which modern kernels default to off, or a
57//! seccomp filter on `ioctl`, which the curated denylist provides.
58//!
59//! A cage closes the first three through its namespaces and swapped root; a
60//! restriction closes each of those only through the control that covers it.
61//!
62//! # The caller's terminal
63//!
64//! The fourth surface is not a namespace question, so a cage and a restriction
65//! answer it the same way: **a sandboxed command reaches the caller's terminal
66//! exactly through the standard streams the caller handed it.** Closing it
67//! therefore takes all three streams, and each closes a different part.
68//!
69//! **Standard input decides the session.** A command that inherits it
70//! ([`Stdio::Inherit`](crate::Stdio::Inherit), the default) has been given the
71//! caller's terminal along with it, so it stays in the caller's session. There
72//! the terminal is reachable through file descriptor 0 and through `/dev/tty`,
73//! which is the same terminal by a different name. A command that does not
74//! inherit it — [`Stdio::Null`](crate::Stdio::Null) or
75//! [`Stdio::Fd`](crate::Stdio::Fd) — runs in a session of its own, with no
76//! controlling terminal at all: `/dev/tty` fails with `ENXIO`, and `TIOCSTI` is
77//! refused on every terminal descriptor, because the kernel grants it only for
78//! the caller's own controlling terminal.
79//!
80//! **The output pair is not closed by the session.** Run from a shell with no
81//! redirection, file descriptors 1 and 2 are dups of one read-write open of the
82//! caller's terminal, and a session of the sandbox's own does not take them
83//! away. A command holding them can still read what is typed, leave the
84//! terminal without echo with `tcsetattr`, and resize it with `TIOCSWINSZ`.
85//! [`stdout`](RestrictionBuilder::stdout) and
86//! [`stderr`](RestrictionBuilder::stderr) are what close that, and a capturing
87//! launch closes it too by supplying pipes of its own.
88//!
89//! **What it costs.** A sandbox in a session of its own is outside the caller's
90//! job control, so a caller that wants to stop it on an interrupt uses
91//! [`Running::terminate`](crate::Running::terminate) or
92//! [`Running::kill`](crate::Running::kill). It also has no job control of its
93//! own, since the session it holds owns no terminal — and under
94//! [`Stdio::Inherit`](crate::Stdio::Inherit) it does, but only by taking the
95//! caller's terminal's foreground process group, a shell inside the sandbox
96//! competing with the caller's own shell for one terminal.
97//!
98//! A restriction is a deliberately weaker boundary with its own
99//! configuration, not an automatic downgrade of a cage: its paths are host
100//! paths, so the grants that make a command runnable (its interpreter and
101//! libraries among them) must name host locations. A launch of a [`Cage`] on
102//! a blocked host still fails with [`Error::UsernsUnavailable`]; a caller
103//! that wants to degrade catches that error and runs its separately
104//! configured restriction.
105//!
106//! Beyond the confinement, a restriction shares the cage's process model:
107//! the clean deterministic environment, the standard-stream dispositions,
108//! output streaming to an [`Observer`], a pseudoterminal of its own through
109//! [`spawn_terminal`](Restriction::spawn_terminal), and the [`Running`] handle
110//! with waiting, deadlines, termination, and kill. Because there is no PID
111//! namespace, the same caveats as [`CageBuilder::pid_namespace`]`(false)`
112//! apply: descendants of the command can outlive it, and a kill reaches only
113//! the command process itself.
114//!
115//! [`Cage`]: crate::Cage
116//! [`CageBuilder::pid_namespace`]: crate::CageBuilder::pid_namespace
117//! [`Error::UsernsUnavailable`]: crate::Error::UsernsUnavailable
118
119use std::collections::BTreeMap;
120use std::ffi::{CString, OsStr, OsString};
121use std::path::Path;
122
123use rustix::thread::UnshareFlags;
124
125use crate::error::{ConfigError, Error};
126use crate::hardening::{FsAccess, NetAccess, Request, SeccompPolicy};
127use crate::limits::{Limit, Resource};
128use crate::mechanism::{Confinement, IdentityPlan, LaunchPlan, ManagedPlaceholders, StdioPlan};
129use crate::observer::{Collect, Observer, Output};
130use crate::running::Running;
131use crate::spec::{self, Stdio};
132use crate::status::ExitStatus;
133
134/// The base environment every restricted command starts with.
135///
136/// Deterministic, like the cage's base: nothing is read from the host.
137/// Unlike the cage's, it carries no `HOME` — the command runs as the real
138/// calling user, for whom `/root` would be wrong, and reading the host's
139/// `HOME` would break determinism. A caller that wants `HOME` sets it.
140const BASE_ENV: [(&str, &str); 1] = [("PATH", spec::BASE_PATH)];
141
142/// A validated, ready-to-run restriction.
143///
144/// Constructed by [`Restriction::builder`]; all validation, the seccomp
145/// compilation, and the Landlock lowering happen in
146/// [`RestrictionBuilder::build`], which freezes the configuration into a
147/// launch plan. [`run`](Restriction::run) then executes the command under
148/// the restriction and blocks until it terminates.
149///
150/// # Example
151///
152/// Run a command that may read the system trees and a work directory, and
153/// nothing else on the filesystem:
154///
155/// ```no_run
156/// use ferroday_cage::{FsAccess, Restriction};
157///
158/// # fn main() -> ferroday_cage::Result<()> {
159/// let status = Restriction::builder()
160/// .command("/usr/bin/sort")
161/// .args(["/work/input"])
162/// .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/usr")
163/// .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/lib")
164/// .landlock_fs(FsAccess::READ, "/etc")
165/// .landlock_fs(FsAccess::READ | FsAccess::WRITE, "/work")
166/// .build()?
167/// .run()?;
168/// assert!(status.success());
169/// # Ok(())
170/// # }
171/// ```
172///
173/// A `Restriction` holds no live resources — it is a frozen plan — so it is
174/// `Clone` and every launch method takes `&self`, exactly as
175/// [`Cage`](crate::Cage) is. One restriction can launch the same command any
176/// number of times, concurrently or in sequence.
177#[derive(Debug, Clone)]
178#[non_exhaustive]
179pub struct Restriction {
180 /// The frozen launch plan the mechanism executes.
181 plan: LaunchPlan,
182}
183
184impl Restriction {
185 /// The environment this restriction will actually apply.
186 ///
187 /// The counterpart of [`Cage::resolved_inputs`](crate::Cage::resolved_inputs).
188 /// Its mount list is always empty: a restriction confines a process in
189 /// place rather than building a filesystem for it.
190 pub fn resolved_inputs(&self) -> crate::ResolvedInputs {
191 crate::ResolvedInputs::project(&self.plan)
192 }
193
194 /// Returns a builder for configuring a restriction.
195 pub fn builder() -> RestrictionBuilder {
196 RestrictionBuilder::default()
197 }
198
199 /// Runs the command under the restriction and blocks until it
200 /// terminates.
201 ///
202 /// Returns `Ok` with the command's [`ExitStatus`] whenever the command
203 /// was executed, regardless of its exit code. `Err` is reserved for the
204 /// library failing: spawning the process, a setup step (a Landlock rule
205 /// that cannot apply, a rejected filter), or collecting the outcome.
206 ///
207 /// The command runs with the stream dispositions the restriction carries,
208 /// which are inherited unless the builder said otherwise.
209 pub fn run(&self) -> Result<ExitStatus, Error> {
210 self.spawn()?.wait()
211 }
212
213 /// Runs the command under the restriction, streaming its output to the
214 /// observer, and blocks until it terminates.
215 ///
216 /// Like [`run`](Self::run), but the command's standard output and
217 /// standard error are captured and delivered to `observer` as they are
218 /// produced, on the calling thread.
219 pub fn run_with(&self, observer: &mut dyn Observer) -> Result<ExitStatus, Error> {
220 self.spawn_with(observer)?.wait()
221 }
222
223 /// Runs the command under the restriction and returns its exit status
224 /// together with everything it wrote.
225 ///
226 /// The restriction's counterpart of [`Cage::output`](crate::Cage::output),
227 /// with the same unbounded-capture caution: for a command whose output
228 /// volume the caller does not control, stream through an [`Observer`] that
229 /// caps what it keeps instead.
230 pub fn output(&self) -> Result<Output, Error> {
231 let mut collect = Collect::default();
232 let status = self.run_with(&mut collect)?;
233 Ok(collect.into_output(status))
234 }
235
236 /// Starts the command under the restriction and returns a handle to it.
237 ///
238 /// `spawn` blocks until the restriction is applied and the command is
239 /// executing, so a setup failure is reported here, as the same typed
240 /// error [`run`](Self::run) would return. The command runs with
241 /// the stream dispositions the restriction carries; the returned [`Running`]
242 /// waits for,
243 /// signals, or kills it.
244 pub fn spawn(&self) -> Result<Running<'static>, Error> {
245 Running::launch(&self.plan, None)
246 }
247
248 /// Starts the command under the restriction, capturing its output for
249 /// the observer, and returns a handle to it.
250 ///
251 /// Like [`spawn`](Self::spawn), but the command's standard output and
252 /// standard error are captured into pipes. The captured bytes are
253 /// delivered to `observer` while the returned handle is waited on; see
254 /// [`Observer`].
255 pub fn spawn_with<'obs>(
256 &self,
257 observer: &'obs mut dyn Observer,
258 ) -> Result<Running<'obs>, Error> {
259 Running::launch(&self.plan, Some(observer))
260 }
261
262 /// Starts the command on a pseudoterminal of its own, returning a handle to
263 /// it and the caller's end of the terminal.
264 ///
265 /// The counterpart of
266 /// [`Cage::spawn_terminal`](crate::Cage::spawn_terminal), and the fallback
267 /// supports it in full: a host-allocated pseudoterminal needs no `devpts`
268 /// instance, so a restriction gets the same interactive story a cage does.
269 /// The command process is the session leader here, since a restriction has
270 /// no PID namespace and so no init above it.
271 pub fn spawn_terminal(
272 &self,
273 terminal: &crate::Terminal,
274 ) -> Result<(Running<'static>, crate::Pty), Error> {
275 Running::launch_terminal(&self.plan, terminal)
276 }
277}
278
279/// Builder for a [`Restriction`].
280///
281/// A restriction needs a command and at least one thing to enforce — a
282/// Landlock grant or a seccomp policy; a restriction that restricts nothing
283/// is rejected by [`build`](Self::build) rather than launching a plain
284/// process that appears sandboxed.
285///
286/// The command starts in `/` with the deterministic base environment
287/// (`PATH` alone) plus the caller's variables, inherited standard streams,
288/// default signal dispositions, an empty signal mask, and a descriptor table
289/// holding only its standard streams. It also starts with no capabilities: a
290/// restriction always drops to the empty capability set, and the always-set
291/// no-new-privileges flag keeps a set-user-ID or file-capabilities binary from
292/// regaining any across the exec. This matters when the caller is privileged —
293/// a root process on a host with unprivileged user namespaces disabled is a
294/// plausible way to reach the restriction fallback — since it would otherwise
295/// pass its full capability set to the command; when the caller is already
296/// unprivileged the drop is a no-op.
297#[derive(Debug, Clone)]
298pub struct RestrictionBuilder {
299 command: Option<std::path::PathBuf>,
300 args: Vec<OsString>,
301 env: Vec<(OsString, OsString)>,
302 /// Whether the deterministic base environment is composed under the
303 /// caller's variables. Defaults to `true`, which is why this type carries
304 /// a hand-written `Default` rather than deriving one.
305 base_env: bool,
306 workdir: Option<std::path::PathBuf>,
307 stdin: Stdio,
308 stdout: Stdio,
309 stderr: Stdio,
310 stop_with_caller: bool,
311 rlimits: BTreeMap<Resource, (Limit, Limit)>,
312 hardening: Request,
313}
314
315impl Default for RestrictionBuilder {
316 fn default() -> Self {
317 RestrictionBuilder {
318 command: None,
319 args: Vec::new(),
320 env: Vec::new(),
321 base_env: true,
322 workdir: None,
323 stdin: Stdio::default(),
324 stdout: Stdio::default(),
325 stderr: Stdio::default(),
326 stop_with_caller: false,
327 rlimits: BTreeMap::new(),
328 hardening: Request::default(),
329 }
330 }
331}
332
333impl RestrictionBuilder {
334 /// Sets the command to execute under the restriction.
335 ///
336 /// The path is a host path and must be absolute. It is not resolved
337 /// against `PATH`. Executing it requires a Landlock grant with
338 /// [`FsAccess::EXECUTE`] covering the file when any grant is configured.
339 pub fn command(mut self, program: impl AsRef<Path>) -> Self {
340 self.command = Some(program.as_ref().to_path_buf());
341 self
342 }
343
344 /// Sets a resource limit on the command.
345 ///
346 /// The restriction's counterpart of
347 /// [`CageBuilder::rlimit`](crate::CageBuilder::rlimit), with the same
348 /// semantics: the limit is applied to the command process before the
349 /// hardening layer and inherited by every process it starts. It matters
350 /// more here than in a cage, because a restriction has no namespaces at
351 /// all — the only thing bounding what the command consumes is what the
352 /// caller sets.
353 pub fn rlimit(
354 mut self,
355 resource: Resource,
356 soft: impl Into<Limit>,
357 hard: impl Into<Limit>,
358 ) -> Self {
359 self.rlimits.insert(resource, (soft.into(), hard.into()));
360 self
361 }
362
363 /// Appends one argument to the command's argument list.
364 pub fn arg(mut self, arg: impl AsRef<OsStr>) -> Self {
365 self.args.push(arg.as_ref().to_os_string());
366 self
367 }
368
369 /// Appends arguments to the command's argument list.
370 pub fn args<I, S>(mut self, args: I) -> Self
371 where
372 I: IntoIterator<Item = S>,
373 S: AsRef<OsStr>,
374 {
375 self.args
376 .extend(args.into_iter().map(|arg| arg.as_ref().to_os_string()));
377 self
378 }
379
380 /// Sets an environment variable for the command.
381 ///
382 /// The command's environment is built from scratch: the deterministic
383 /// base (`PATH` alone) plus the variables set here, which override the
384 /// base on collision. Nothing is inherited from the host.
385 pub fn env(mut self, name: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
386 self.env
387 .push((name.as_ref().to_os_string(), value.as_ref().to_os_string()));
388 self
389 }
390
391 /// Sets environment variables for the command.
392 ///
393 /// Equivalent to calling [`env`](Self::env) for each pair.
394 pub fn envs<I, K, V>(mut self, vars: I) -> Self
395 where
396 I: IntoIterator<Item = (K, V)>,
397 K: AsRef<OsStr>,
398 V: AsRef<OsStr>,
399 {
400 self.env.extend(
401 vars.into_iter()
402 .map(|(k, v)| (k.as_ref().to_os_string(), v.as_ref().to_os_string())),
403 );
404 self
405 }
406
407 /// Controls whether the deterministic base environment is composed under
408 /// the caller's variables (default `true`).
409 ///
410 /// Set to `false`, the command's environment is exactly the pairs given
411 /// to [`env`](Self::env) and [`envs`](Self::envs) — `PATH` is not
412 /// supplied, and no variable the library may add to its base in a later
413 /// release is supplied either.
414 ///
415 /// The counterpart of [`CageBuilder::base_env`](crate::CageBuilder::base_env),
416 /// over the restriction's own `PATH`-only base.
417 pub fn base_env(mut self, base: bool) -> Self {
418 self.base_env = base;
419 self
420 }
421
422 /// Sets the command's working directory, a host path.
423 ///
424 /// The path must be absolute and must exist at launch; a missing
425 /// directory is a setup error, not a fallback. The default is `/`.
426 pub fn current_dir(mut self, path: impl AsRef<Path>) -> Self {
427 self.workdir = Some(path.as_ref().to_path_buf());
428 self
429 }
430
431 /// Selects the disposition of the command's standard input.
432 ///
433 /// The default is [`Stdio::Inherit`], which also keeps the command in the
434 /// caller's session; see [`Stdio`] for the whole rule. It applies here
435 /// unchanged: a restriction has no namespace to close the caller's terminal
436 /// with, so the standard streams are the only thing that can.
437 pub fn stdin(mut self, stdin: Stdio) -> Self {
438 self.stdin = stdin;
439 self
440 }
441
442 /// Selects the disposition of the command's standard output.
443 ///
444 /// The counterpart of [`CageBuilder::stdout`](crate::CageBuilder::stdout),
445 /// with the same default and the same interaction with a capturing launch.
446 /// It matters at least as much here: a restriction's command inherits the
447 /// caller's file descriptors exactly as a cage's does, and closing the
448 /// caller's terminal to it takes the output pair as well as standard input.
449 pub fn stdout(mut self, stdout: Stdio) -> Self {
450 self.stdout = stdout;
451 self
452 }
453
454 /// Selects the disposition of the command's standard error.
455 ///
456 /// The counterpart of [`stdout`](Self::stdout).
457 pub fn stderr(mut self, stderr: Stdio) -> Self {
458 self.stderr = stderr;
459 self
460 }
461
462 /// Ties the command's lifetime to the caller's (default `false`).
463 ///
464 /// As on the cage builder: when enabled, the command is stopped as soon
465 /// as the calling process exits — however it exits — or the last handle
466 /// to the launch is dropped. Only the command process itself is stopped;
467 /// without a PID namespace its descendants are beyond the library's
468 /// reach.
469 pub fn stop_with_caller(mut self, tie: bool) -> Self {
470 self.stop_with_caller = tie;
471 self
472 }
473
474 /// Grants `access` beneath the host path `path` under a Landlock
475 /// ruleset.
476 ///
477 /// Configuring any grant enrolls the command in Landlock: it may then
478 /// reach a path only where a grant allows it, and everything else on the
479 /// host filesystem is denied. A runnable command therefore needs grants
480 /// covering its own binary and libraries — commonly
481 /// `FsAccess::READ | FsAccess::EXECUTE` on `/usr`, `/bin`, and `/lib` —
482 /// alongside the data paths it works on.
483 ///
484 /// The ruleset is enforced on a kernel that offers Landlock, best-effort
485 /// down to the kernel's supported ABI. A filesystem grant keeps its ABI-1
486 /// base rights on every Landlock kernel, so it always enforces there; a
487 /// kernel without the Landlock LSM cannot enforce the grant, so the
488 /// restriction is refused at build rather than run unconfined.
489 ///
490 /// An empty `access` is refused at build with
491 /// [`ConfigError::LandlockGrantEmpty`](crate::ConfigError::LandlockGrantEmpty):
492 /// it reads as "grant nothing here" and would behave as "deny the whole
493 /// filesystem", the command's own binary included.
494 pub fn landlock_fs(mut self, access: FsAccess, path: impl AsRef<Path>) -> Self {
495 self.hardening.grant_fs(access, path.as_ref());
496 self
497 }
498
499 /// Grants network `access` on the TCP `port` under a Landlock ruleset.
500 ///
501 /// Configuring any grant — filesystem or network — enrolls the command in
502 /// Landlock. A network grant governs TCP bind and connect only: the command
503 /// may bind only ports granted [`NetAccess::BIND`] and connect only to
504 /// ports granted [`NetAccess::CONNECT`], and every other TCP bind and
505 /// connect is denied. Because a restriction runs on the host, these grants
506 /// govern the command's access to the host network.
507 ///
508 /// Landlock confines TCP alone. UDP, raw sockets, and other socket
509 /// families — including `AF_UNIX`, so abstract-socket paths to host
510 /// services such as the session bus or the X server — are not restricted
511 /// by a network grant. A restriction that must deny those needs a seccomp
512 /// filter on the relevant socket syscalls, or a cage's network namespace.
513 ///
514 /// Network rights require Landlock ABI 4. On an older kernel that offers
515 /// Landlock they narrow away best-effort, as an unsupported filesystem
516 /// right does — except that a restriction whose only Landlock grant is a
517 /// network grant is refused at build, since nothing would then be
518 /// enforced. An empty `access` is refused at build for the same reason an
519 /// empty filesystem grant is: it would deny all TCP rather than nothing.
520 ///
521 /// [`NetAccess::BIND`]: crate::NetAccess::BIND
522 /// [`NetAccess::CONNECT`]: crate::NetAccess::CONNECT
523 pub fn landlock_net(mut self, access: NetAccess, port: u16) -> Self {
524 self.hardening.grant_net(access, port);
525 self
526 }
527
528 /// Applies a seccomp syscall filter to the command.
529 ///
530 /// See [`SeccompPolicy`] for the curated profile, caller-authored rules,
531 /// and the pre-compiled escape hatch. The filter binds the command and
532 /// its descendants.
533 pub fn seccomp(mut self, policy: SeccompPolicy) -> Self {
534 self.hardening.set_seccomp(policy);
535 self
536 }
537
538 /// Validates the configuration and freezes it into a [`Restriction`].
539 ///
540 /// All fallible and allocating preparation happens here: the command
541 /// line and environment are marshaled, the Landlock grants are validated
542 /// and frozen, and the seccomp policy is compiled to its BPF program. A
543 /// restriction with no grant and no policy is rejected as
544 /// [`ConfigError::RestrictionEmpty`].
545 pub fn build(self) -> Result<Restriction, Error> {
546 let command = self.command.ok_or(ConfigError::CommandMissing)?;
547 // The absence of a command written down, reported as that rather than
548 // as a path that is not absolute; a cage reads it the same way.
549 if command.as_os_str().is_empty() {
550 return Err(ConfigError::CommandMissing.into());
551 }
552 if !command.is_absolute() {
553 return Err(ConfigError::CommandNotAbsolute { command }.into());
554 }
555 let program = spec::cstring(command.as_os_str())?;
556 let exec_label = spec::exec_label(&command, &[]);
557 let (rlimits, rlimit_labels) = spec::lower_rlimits(&self.rlimits)?;
558 let args = self
559 .args
560 .iter()
561 .map(|arg| spec::cstring(arg))
562 .collect::<Result<Vec<_>, _>>()?;
563 let base: &[(&str, &str)] = if self.base_env { &BASE_ENV } else { &[] };
564 let env = spec::compose_env(base, &self.env)?;
565 let workdir = self
566 .workdir
567 .map(|path| spec::validate_workdir(&path))
568 .transpose()?
569 .flatten();
570
571 if self.hardening.is_unrestricted() {
572 return Err(ConfigError::RestrictionEmpty.into());
573 }
574 let mut hardening = self.hardening.lower()?;
575 // A restriction is confined only by what this host enforces, so its
576 // Landlock grants must actually apply here. If they all fall outside
577 // the running kernel's Landlock ABI, no ruleset is built and the
578 // command would run unconfined; refuse rather than silently drop the
579 // requested confinement.
580 hardening.ensure_landlock_enforceable()?;
581 // Always drop to the empty capability set. A restriction runs on the
582 // host with no user namespace, so a privileged caller (root, or one
583 // holding file capabilities) would otherwise carry its full permitted
584 // and effective sets into the command — CAP_SYS_PTRACE, CAP_SYS_ADMIN,
585 // and the rest, none of which Landlock governs. The drop is applied
586 // after the (no-op) identity switch, under the no-new-privileges flag
587 // set for every non-empty plan, so nothing can be regained across the
588 // exec. Reducing the capability sets is safe and a no-op when the
589 // caller is already unprivileged, which is the common case a
590 // restriction exists to serve.
591 hardening.keep_caps = Some(0);
592
593 Ok(Restriction {
594 plan: LaunchPlan {
595 confinement: Confinement::Restriction,
596 // A restriction creates no user namespace: there is no map
597 // to establish, no identity to switch to, and nothing to nest
598 // inside — the mount flags a cage locks by nesting are the
599 // host's here, and no mount of its own is established.
600 identity: IdentityPlan::Single,
601 nested: None,
602 run_as: None,
603 program,
604 // A restriction keeps the absolute-command contract: no PATH
605 // search over the shared host filesystem.
606 program_search: Vec::new(),
607 args,
608 env,
609 // No root is swapped and no namespace is created: the
610 // container fields stay empty and the launch runs the
611 // outside-supervised process shape.
612 rootfs_path: CString::default(),
613 overlay: None,
614 unshare: UnshareFlags::empty(),
615 pid_namespace: false,
616 stdin: StdioPlan::of(&self.stdin),
617 stdout: StdioPlan::of(&self.stdout),
618 stderr: StdioPlan::of(&self.stderr),
619 // The same rule a cage follows: the command joins the caller's
620 // session only to inherit its standard input. A restriction has
621 // no namespace to close the terminal, so the session and the
622 // output dispositions are the only things that can.
623 own_session: !matches!(self.stdin, Stdio::Inherit),
624 stop_with_caller: self.stop_with_caller,
625 hostname: None,
626 configure_loopback: false,
627 // No network namespace is created, so the command runs in the
628 // caller's.
629 network: crate::Network::Host,
630 workdir,
631 ops: Vec::new(),
632 op_labels: Vec::new(),
633 // A restriction establishes no mount, so it creates no target.
634 managed_placeholders: ManagedPlaceholders::default(),
635 exec_label,
636 rlimits,
637 rlimit_labels,
638 hardening,
639 },
640 })
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 /// Every field of a restriction's builder is classified against the cage
649 /// builder's, and stays classified because this stops compiling otherwise.
650 ///
651 /// The two types are deliberate twins: a restriction is the degraded mode a
652 /// cage's caller falls back to, and the point is that the same
653 /// configuration reads the same way. What holds them together is prose --
654 /// each setter says what its knob means *under a restriction* -- so the
655 /// pair cannot be generated from one source without losing the thing that
656 /// makes two worth having. What can be held mechanically is that neither
657 /// grows a field the other has not considered.
658 ///
659 /// The destructure below is exhaustive, with no `..`, the technique
660 /// `CageBuilder::check_restricted` uses: a field added to
661 /// `RestrictionBuilder` fails to compile here until someone classifies it.
662 /// The other direction is that same `check_restricted`, which destructures
663 /// `CageBuilder` exhaustively for its own reason -- so a field added there
664 /// is already refused until it is classified, and this comment is where a
665 /// reader is told the pair is what the two destructures are about.
666 #[test]
667 fn every_restriction_builder_field_is_classified_against_the_cage_builder() {
668 let RestrictionBuilder {
669 // The same field, meaning the same thing, on `CageBuilder`. A
670 // change to one of these is a change to both.
671 command: _,
672 args: _,
673 env: _,
674 workdir: _,
675 stdin: _,
676 stdout: _,
677 stderr: _,
678 rlimits: _,
679 hardening: _,
680 // Deliberately different in type. `CageBuilder` holds an
681 // `Option<bool>` for each, because a profile that says nothing has
682 // to be distinguishable from one that says `false` when the two are
683 // merged. A restriction has no profile, so the two-state form is
684 // the whole question here.
685 base_env: _,
686 stop_with_caller: _,
687 } = RestrictionBuilder::default();
688 }
689
690 fn restricted_true() -> RestrictionBuilder {
691 Restriction::builder()
692 .command("/bin/true")
693 .seccomp(SeccompPolicy::Curated)
694 }
695
696 #[test]
697 fn build_requires_a_command() {
698 let err = Restriction::builder()
699 .seccomp(SeccompPolicy::Curated)
700 .build()
701 .unwrap_err();
702 assert!(matches!(err, Error::Config(ConfigError::CommandMissing)));
703 }
704
705 #[test]
706 fn build_rejects_a_relative_command() {
707 let err = Restriction::builder()
708 .command("bin/true")
709 .seccomp(SeccompPolicy::Curated)
710 .build()
711 .unwrap_err();
712 assert!(matches!(
713 err,
714 Error::Config(ConfigError::CommandNotAbsolute { .. })
715 ));
716 }
717
718 #[test]
719 fn an_empty_restriction_is_rejected() {
720 let err = Restriction::builder()
721 .command("/bin/true")
722 .build()
723 .unwrap_err();
724 assert!(matches!(err, Error::Config(ConfigError::RestrictionEmpty)));
725 }
726
727 #[test]
728 fn a_net_grant_alone_is_a_valid_restriction() {
729 // A network grant is something to enforce, so it satisfies the
730 // non-empty requirement on its own. Whether it then builds depends on
731 // the host: a network grant needs Landlock ABI 4, and a net-only
732 // restriction is refused below it rather than run unconfined.
733 let builder = || {
734 Restriction::builder()
735 .command("/bin/true")
736 .landlock_net(NetAccess::CONNECT, 443)
737 };
738 match crate::mechanism::probe_landlock_abi() {
739 Some(abi) if abi >= 4 => {
740 let restriction = builder().build().expect("a network grant is a restriction");
741 let hardening = &restriction.plan.hardening;
742 assert_eq!(hardening.landlock_net.len(), 1);
743 assert_ne!(hardening.landlock_net_handled, 0);
744 assert!(hardening.landlock.is_empty());
745 }
746 _ => {
747 // Below ABI 4, or with no Landlock LSM, the grant cannot apply,
748 // so the build is refused rather than silently dropping it.
749 assert!(matches!(
750 builder().build(),
751 Err(Error::Config(
752 ConfigError::RestrictionLandlockUnenforceable { .. }
753 ))
754 ));
755 }
756 }
757 }
758
759 #[test]
760 fn a_relative_landlock_path_is_rejected() {
761 let err = Restriction::builder()
762 .command("/bin/true")
763 .landlock_fs(FsAccess::READ, "usr")
764 .build()
765 .unwrap_err();
766 assert!(matches!(
767 err,
768 Error::Config(ConfigError::LandlockPathInvalid { .. })
769 ));
770 }
771
772 #[test]
773 fn a_dot_dot_landlock_path_is_rejected() {
774 let err = Restriction::builder()
775 .command("/bin/true")
776 .landlock_fs(FsAccess::READ, "/usr/../etc")
777 .build()
778 .unwrap_err();
779 assert!(matches!(
780 err,
781 Error::Config(ConfigError::LandlockPathInvalid { .. })
782 ));
783 }
784
785 #[test]
786 fn the_root_is_a_valid_landlock_path() {
787 restricted_true()
788 .landlock_fs(FsAccess::READ | FsAccess::EXECUTE, "/")
789 .build()
790 .expect("a grant on the root covers the whole filesystem");
791 }
792
793 #[test]
794 fn build_rejects_an_empty_command() {
795 // The absence of a command written down, reported as that rather than
796 // as a path that is not absolute -- which is how a cage reads it too.
797 let err = Restriction::builder()
798 .command("")
799 .seccomp(SeccompPolicy::Curated)
800 .build()
801 .unwrap_err();
802 assert!(
803 matches!(err, Error::Config(ConfigError::CommandMissing)),
804 "{err}",
805 );
806 }
807
808 #[test]
809 fn build_rejects_a_relative_workdir() {
810 let err = restricted_true().current_dir("work").build().unwrap_err();
811 assert!(matches!(
812 err,
813 Error::Config(ConfigError::WorkdirNotAbsolute { .. })
814 ));
815 }
816
817 #[test]
818 fn the_environment_base_is_path_alone() {
819 let restriction = restricted_true().env("A", "b").build().unwrap();
820 let env: Vec<&str> = restriction
821 .plan
822 .env
823 .iter()
824 .map(|entry| entry.to_str().unwrap())
825 .collect();
826 assert_eq!(env, ["A=b", &format!("PATH={}", spec::BASE_PATH)]);
827 }
828
829 #[test]
830 fn an_exact_environment_drops_the_restriction_base_too() {
831 // The restriction's base is `PATH` alone, and opting out removes it as
832 // completely as the cage's opt-out removes `PATH` and `HOME`.
833 let restriction = restricted_true()
834 .base_env(false)
835 .env("A", "b")
836 .build()
837 .unwrap();
838 let env: Vec<&str> = restriction
839 .plan
840 .env
841 .iter()
842 .map(|entry| entry.to_str().unwrap())
843 .collect();
844 assert_eq!(env, ["A=b"]);
845 }
846
847 #[test]
848 fn the_plan_creates_no_namespaces_and_mounts_nothing() {
849 let restriction = restricted_true()
850 .current_dir("/tmp")
851 .stdin(Stdio::Null)
852 .stop_with_caller(true)
853 .build()
854 .unwrap();
855 let plan = &restriction.plan;
856 assert_eq!(plan.confinement, Confinement::Restriction);
857 assert!(plan.unshare.is_empty());
858 assert!(!plan.pid_namespace);
859 assert!(!plan.configure_loopback);
860 assert!(plan.hostname.is_none());
861 assert!(plan.ops.is_empty() && plan.op_labels.is_empty());
862 assert!(plan.rootfs_path.is_empty());
863 assert!(matches!(plan.stdin, StdioPlan::Null));
864 assert!(plan.stop_with_caller);
865 assert_eq!(plan.workdir.as_deref(), Some(c"/tmp"));
866 // The lowered hardening carries the compiled policy and always the
867 // empty capability keep set, so a privileged caller's capabilities are
868 // dropped rather than inherited by the command.
869 assert!(plan.hardening.seccomp.is_some());
870 assert_eq!(plan.hardening.keep_caps, Some(0));
871 }
872}