Skip to main content

runtime/tools/exec/
confinement.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The confinement a host command runs under: which arm this machine
7//! gives, and what that arm promises.
8//!
9//! [`crate::sandbox`] is the other half of the execution boundary: a
10//! wasip1 guest that reaches a host directory only when a
11//! [`Mount`](crate::sandbox::Mount) named it, and reaches no network at
12//! all because wasip1 has no way to obtain a socket. A program the agent
13//! asks for is not a guest, so its isolation has to be bought from the
14//! platform, and no platform sells all of it. What this module owns is
15//! therefore a taxonomy rather than a switch: every arm states, axis by
16//! axis, what it keeps and what it does not.
17//!
18//! **An arm that cannot say what it does not hold is worse than no arm.**
19//! The agent reads [`Confinement::statement`] in the exec tool's own
20//! description before it acts; a report that said only "sandboxed" would
21//! send it into a box whose network is open.
22//!
23//! A placement is a copy of the working tree, synced to it before each
24//! command, so a command that ruins it ruins nothing of the person's and
25//! nothing the next command sees. The copy is bounded and lives in
26//! [`placing::copy`]; a tree over the bound is
27//! refused by name rather than half-copied, because a sandbox that
28//! silently answers for files it did not carry is the failure this
29//! module exists to prevent.
30
31use std::path::PathBuf;
32
33/// One axis a confinement arm can promise, or refuse to promise.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35pub enum Guarantee {
36    /// Writes through the working directory land on a copy; the tree the
37    /// person has stays as it is.
38    Filesystem,
39    /// The command cannot open a network connection.
40    Network,
41    /// The whole process tree ends when the command ends.
42    ProcessTree,
43    /// The command runs with an identity of its own.
44    User,
45    /// CPU and memory ceilings are enforced, not merely asked for.
46    Resources,
47}
48
49impl Guarantee {
50    /// Every axis, in the order a report lists them.
51    pub const ALL: [Guarantee; 5] = [
52        Guarantee::Filesystem,
53        Guarantee::Network,
54        Guarantee::ProcessTree,
55        Guarantee::User,
56        Guarantee::Resources,
57    ];
58
59    /// What holding this axis means, in the words a sentence shown to a
60    /// caller may use.
61    pub fn phrase(self) -> &'static str {
62        match self {
63            Guarantee::Filesystem => "writes through the working directory land on a copy",
64            Guarantee::Network => "the network is closed",
65            Guarantee::ProcessTree => "the whole process tree ends with the command",
66            Guarantee::User => "the command has an isolated security identity",
67            Guarantee::Resources => "CPU and memory ceilings are enforced",
68        }
69    }
70
71    /// What not holding it means, as the thing the caller does not get.
72    /// Stated as a missing capability rather than as an absence, because
73    /// the list it lands in is a list of what is not bought.
74    pub fn unkept(self) -> &'static str {
75        match self {
76            Guarantee::Filesystem => "that writes land on a copy",
77            Guarantee::Network => "network isolation",
78            Guarantee::ProcessTree => "process-tree containment",
79            Guarantee::User => "an isolated security identity",
80            Guarantee::Resources => "CPU and memory ceilings",
81        }
82    }
83}
84
85/// Whether one arm keeps one axis.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Kept {
88    Yes,
89    No,
90}
91
92/// What one arm promises, axis by axis.
93///
94/// Every arm states all five axes, and that is the point of the type: a
95/// new axis is a compile error in every arm's constructor, so no arm can
96/// keep a stale answer to a question that arrived after it was written.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct Assurances {
99    pub filesystem: Kept,
100    pub network: Kept,
101    pub process_tree: Kept,
102    pub user: Kept,
103    pub resources: Kept,
104}
105
106impl Assurances {
107    /// The whole list, in the order [`Guarantee::ALL`] declares.
108    pub fn of(self, axis: Guarantee) -> Kept {
109        match axis {
110            Guarantee::Filesystem => self.filesystem,
111            Guarantee::Network => self.network,
112            Guarantee::ProcessTree => self.process_tree,
113            Guarantee::User => self.user,
114            Guarantee::Resources => self.resources,
115        }
116    }
117}
118
119/// What a machine lacks when none of its arms can take a command at all.
120///
121/// A name, never a sentence: a page supplies the words, and a machine
122/// that lacks something else gets an arm here rather than a second
123/// string field no reader can match on.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum Missing {
126    /// A writable directory a working tree can be copied into.
127    ScratchDirectory,
128}
129
130impl Missing {
131    /// The thing itself, for a refusal's subject.
132    pub fn phrase(self) -> &'static str {
133        match self {
134            Missing::ScratchDirectory => "a writable scratch directory",
135        }
136    }
137
138    /// What a person does about it.
139    pub fn recovery(self) -> &'static str {
140        match self {
141            Missing::ScratchDirectory => {
142                "point TMPDIR (TEMP on Windows) at a directory this process can write, \
143                 and start sprawling again"
144            }
145        }
146    }
147}
148
149/// The backends, by platform, each carrying what it promises.
150///
151/// On Windows the native arm is an AppContainer with no capabilities and a
152/// Job tree admitted while the child is suspended. Limits and resource
153/// ownership are specified by `Tools/Exec/NativeWindows.lean`.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum Confinement {
156    /// Namespaces of its own — mount, network, process, user — made by a
157    /// wrapper program on this machine.
158    LinuxNamespaces { wrapper: PathBuf },
159    /// A capability-free AppContainer and Job, assigned before execution.
160    WindowsJobObject,
161    /// Seatbelt permits writes only in the copy and refuses network operations.
162    /// It does not provide a new identity, tree termination or aggregate resource limits.
163    MacosSeatbelt { wrapper: PathBuf },
164    /// The floor every platform has: the command runs in a copy of the
165    /// working tree, and the tree itself is not what it writes to.
166    CopiedTree,
167    /// No arm at all, and what this machine is missing.
168    Unavailable { missing: Missing },
169}
170
171impl Confinement {
172    /// The arm this machine gives a command. One sampling point: the
173    /// scratch root, the search path and this platform are asked here
174    /// and nowhere else, so a report and a placement cannot disagree.
175    pub fn detect() -> Confinement {
176        Confinement::choose(&Offerings::this_machine())
177    }
178
179    /// The arm a machine with these capabilities gives when no arm is named.
180    ///
181    /// A Windows build resolves [`Confinement::CopiedTree`] until the native
182    /// arm passes the conformance that `Tools/Exec.lean` SB1 item 3 lists;
183    /// a building that names `native` still gets [`Confinement::WindowsJobObject`].
184    pub fn choose(offered: &Offerings) -> Confinement {
185        if offered.scratch.is_none() {
186            return Confinement::Unavailable {
187                missing: Missing::ScratchDirectory,
188            };
189        }
190        match &offered.namespace_tool {
191            Some(wrapper) => Confinement::LinuxNamespaces {
192                wrapper: wrapper.clone(),
193            },
194            None => Confinement::CopiedTree,
195        }
196    }
197
198    /// What this arm promises. A pure function of the arm, so a report
199    /// can be read without running anything.
200    pub fn assurances(&self) -> Assurances {
201        let all = Kept::Yes;
202        let none = Kept::No;
203        match self {
204            Confinement::LinuxNamespaces { .. } => Assurances {
205                filesystem: all,
206                network: all,
207                process_tree: all,
208                user: all,
209                resources: none,
210            },
211            Confinement::MacosSeatbelt { .. } => Assurances {
212                filesystem: all,
213                network: all,
214                process_tree: none,
215                user: none,
216                resources: none,
217            },
218            Confinement::WindowsJobObject => Assurances {
219                filesystem: all,
220                network: all,
221                process_tree: all,
222                user: all,
223                resources: all,
224            },
225            Confinement::CopiedTree => Assurances {
226                filesystem: all,
227                network: none,
228                process_tree: none,
229                user: none,
230                resources: none,
231            },
232            Confinement::Unavailable { .. } => Assurances {
233                filesystem: none,
234                network: none,
235                process_tree: none,
236                user: none,
237                resources: none,
238            },
239        }
240    }
241
242    /// The name a report carries. Closed and spelled once, because a
243    /// page, a refusal and a test all have to mean the same arm.
244    pub fn name(&self) -> &'static str {
245        match self {
246            Confinement::LinuxNamespaces { .. } => "linux_namespaces",
247            Confinement::WindowsJobObject => "windows_job_object",
248            Confinement::MacosSeatbelt { .. } => "macos_seatbelt",
249            Confinement::CopiedTree => "copied_tree",
250            Confinement::Unavailable { .. } => "unavailable",
251        }
252    }
253
254    /// What a command under this arm can and cannot count on, in one
255    /// sentence. Derived from [`Confinement::assurances`] rather than
256    /// written beside it, so the sentence cannot drift from the type.
257    pub fn statement(&self) -> String {
258        match self {
259            Confinement::Unavailable { missing } => {
260                format!("no sandbox is available: {}", missing.phrase())
261            }
262            Confinement::LinuxNamespaces { .. }
263            | Confinement::WindowsJobObject
264            | Confinement::MacosSeatbelt { .. }
265            | Confinement::CopiedTree => {
266                let kept = self.assurances();
267                let holds: Vec<&str> = Guarantee::ALL
268                    .iter()
269                    .filter(|axis| kept.of(**axis) == Kept::Yes)
270                    .map(|axis| axis.phrase())
271                    .collect();
272                let misses: Vec<&str> = Guarantee::ALL
273                    .iter()
274                    .filter(|axis| kept.of(**axis) == Kept::No)
275                    .map(|axis| axis.unkept())
276                    .collect();
277                let statement = format!(
278                    "{}: it guarantees that {}; it does not guarantee: {}.",
279                    self.name(),
280                    listed(&holds),
281                    listed(&misses)
282                );
283                #[cfg(target_os = "linux")]
284                if matches!(self, Confinement::LinuxNamespaces { .. }) {
285                    return format!(
286                        "{statement} An unprivileged user namespace is required; \
287                         namespace setup failure refuses the placement."
288                    );
289                }
290                statement
291            }
292        }
293    }
294}
295
296/// What a machine has, read once and handed to [`Confinement::choose`]
297/// so that a test states a machine instead of borrowing one.
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub struct Offerings {
300    /// The program that makes this machine's own isolation, where one
301    /// exists.
302    pub namespace_tool: Option<PathBuf>,
303    /// A writable directory a working tree can be copied into.
304    pub scratch: Option<PathBuf>,
305}
306
307impl Offerings {
308    /// Reads this machine: whether the platform's wrapper program is on
309    /// the search path, and whether the scratch root exists.
310    pub fn this_machine() -> Offerings {
311        let scratch = std::env::temp_dir();
312        Offerings {
313            namespace_tool: namespace_wrapper(),
314            scratch: scratch.is_dir().then_some(scratch),
315        }
316    }
317}
318
319/// The wrapper that gives a Linux command namespaces of its own.
320///
321/// One program, looked up by its exact name, because the arm it makes
322/// exists on Linux alone: a search path walked here is not a second
323/// authority on which programs a machine has — the doctor's requirement
324/// table is that — it is the question of whether *this* arm can be
325/// given at all.
326fn namespace_wrapper() -> Option<PathBuf> {
327    if !cfg!(target_os = "linux") {
328        return None;
329    }
330    let path = std::env::var_os("PATH")?;
331    std::env::split_paths(&path)
332        .map(|dir| dir.join("bwrap"))
333        .find(|candidate| candidate.is_file())
334}
335
336/// A list in the shape a sentence uses: `a`, `a and b`, `a, b and c`.
337fn listed(parts: &[&str]) -> String {
338    match parts {
339        [] => "nothing".to_owned(),
340        [one] => (*one).to_owned(),
341        [one, two] => format!("{one} and {two}"),
342        [first @ .., last] => format!("{}, and {last}", first.join(", ")),
343    }
344}
345
346mod placement;
347mod placing;
348
349pub use placement::{Placement, parse_placement};
350pub use placing::{Confined, Placed};
351
352impl Confined {
353    pub(super) fn prepare(
354        &mut self,
355        command: std::process::Command,
356        workdir: &std::path::Path,
357        shares: crate::backlog::Shares,
358    ) -> Result<(std::process::Command, Placed), kernel::AxError> {
359        #[cfg(target_os = "macos")]
360        if matches!(self.arm(), Confinement::MacosSeatbelt { .. }) {
361            super::yielding::require_executable(&command)?;
362            let (command, placed) = self.place(command, workdir)?;
363            return match super::yielding::one_level_down(command, shares) {
364                Ok(command) => Ok((command, placed)),
365                Err(fault) => {
366                    self.settled(placed);
367                    Err(fault)
368                }
369            };
370        }
371        self.place(super::yielding::one_level_down(command, shares)?, workdir)
372    }
373}
374
375#[cfg(test)]
376#[allow(
377    clippy::unwrap_used,
378    clippy::expect_used,
379    clippy::panic,
380    clippy::indexing_slicing,
381    clippy::arithmetic_side_effects,
382    reason = "test code"
383)]
384mod tests;