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`](crate::tools::Confinement); 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 runs as a user of its own",
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 => "a user of its own",
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/// `WindowsJobObject` is the arm whose assurances a job object would
152/// keep. No build of this crate constructs it, although a job object is
153/// reachable through a safe interface (`runtime::backlog::jobs` makes one
154/// with `win32job`), because two of its assurances cannot be kept today:
155/// the job-wide memory limit is a private field of that crate and it
156/// sets no CPU rate control at all, so `resources` would promise a
157/// limit that is not there; and a child
158/// joins the job only after it starts, so a grandchild started in that
159/// gap is outside the tree the job ends. A Windows machine therefore
160/// reports [`Confinement::CopiedTree`], whose [`Assurances`] say what it
161/// does not hold (the network, above all) rather than implying it holds
162/// everything a job object would.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum Confinement {
165 /// Namespaces of its own — mount, network, process, user — made by a
166 /// wrapper program on this machine.
167 LinuxNamespaces { wrapper: PathBuf },
168 /// A Windows job object: the tree ends together, the limits hold,
169 /// and the network is not isolated.
170 WindowsJobObject,
171 /// The floor every platform has: the command runs in a copy of the
172 /// working tree, and the tree itself is not what it writes to.
173 CopiedTree,
174 /// No arm at all, and what this machine is missing.
175 Unavailable { missing: Missing },
176}
177
178impl Confinement {
179 /// The arm this machine gives a command. One sampling point: the
180 /// scratch root, the search path and this platform are asked here
181 /// and nowhere else, so a report and a placement cannot disagree.
182 pub fn detect() -> Confinement {
183 Confinement::choose(&Offerings::this_machine())
184 }
185
186 /// The arm a machine with these capabilities gives.
187 pub fn choose(offered: &Offerings) -> Confinement {
188 if offered.scratch.is_none() {
189 return Confinement::Unavailable {
190 missing: Missing::ScratchDirectory,
191 };
192 }
193 match &offered.namespace_tool {
194 Some(wrapper) => Confinement::LinuxNamespaces {
195 wrapper: wrapper.clone(),
196 },
197 None => Confinement::CopiedTree,
198 }
199 }
200
201 /// What this arm promises. A pure function of the arm, so a report
202 /// can be read without running anything.
203 pub fn assurances(&self) -> Assurances {
204 let all = Kept::Yes;
205 let none = Kept::No;
206 match self {
207 Confinement::LinuxNamespaces { .. } => Assurances {
208 filesystem: all,
209 network: all,
210 process_tree: all,
211 user: all,
212 resources: none,
213 },
214 Confinement::WindowsJobObject => Assurances {
215 filesystem: all,
216 network: none,
217 process_tree: all,
218 user: none,
219 resources: all,
220 },
221 Confinement::CopiedTree => Assurances {
222 filesystem: all,
223 network: none,
224 process_tree: none,
225 user: none,
226 resources: none,
227 },
228 Confinement::Unavailable { .. } => Assurances {
229 filesystem: none,
230 network: none,
231 process_tree: none,
232 user: none,
233 resources: none,
234 },
235 }
236 }
237
238 /// The name a report carries. Closed and spelled once, because a
239 /// page, a refusal and a test all have to mean the same arm.
240 pub fn name(&self) -> &'static str {
241 match self {
242 Confinement::LinuxNamespaces { .. } => "linux_namespaces",
243 Confinement::WindowsJobObject => "windows_job_object",
244 Confinement::CopiedTree => "copied_tree",
245 Confinement::Unavailable { .. } => "unavailable",
246 }
247 }
248
249 /// What a command under this arm can and cannot count on, in one
250 /// sentence. Derived from [`Confinement::assurances`] rather than
251 /// written beside it, so the sentence cannot drift from the type.
252 pub fn statement(&self) -> String {
253 match self {
254 Confinement::Unavailable { missing } => {
255 format!("no sandbox is available: {}", missing.phrase())
256 }
257 Confinement::LinuxNamespaces { .. }
258 | Confinement::WindowsJobObject
259 | Confinement::CopiedTree => {
260 let kept = self.assurances();
261 let holds: Vec<&str> = Guarantee::ALL
262 .iter()
263 .filter(|axis| kept.of(**axis) == Kept::Yes)
264 .map(|axis| axis.phrase())
265 .collect();
266 let misses: Vec<&str> = Guarantee::ALL
267 .iter()
268 .filter(|axis| kept.of(**axis) == Kept::No)
269 .map(|axis| axis.unkept())
270 .collect();
271 format!(
272 "{}: it guarantees that {}; it does not guarantee: {}.",
273 self.name(),
274 listed(&holds),
275 listed(&misses)
276 )
277 }
278 }
279 }
280}
281
282/// What a machine has, read once and handed to [`Confinement::choose`]
283/// so that a test states a machine instead of borrowing one.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct Offerings {
286 /// The program that makes this machine's own isolation, where one
287 /// exists.
288 pub namespace_tool: Option<PathBuf>,
289 /// A writable directory a working tree can be copied into.
290 pub scratch: Option<PathBuf>,
291}
292
293impl Offerings {
294 /// Reads this machine: whether the platform's wrapper program is on
295 /// the search path, and whether the scratch root exists.
296 pub fn this_machine() -> Offerings {
297 let scratch = std::env::temp_dir();
298 Offerings {
299 namespace_tool: namespace_wrapper(),
300 scratch: scratch.is_dir().then_some(scratch),
301 }
302 }
303}
304
305/// The wrapper that gives a Linux command namespaces of its own.
306///
307/// One program, looked up by its exact name, because the arm it makes
308/// exists on Linux alone: a search path walked here is not a second
309/// authority on which programs a machine has — the doctor's requirement
310/// table is that — it is the question of whether *this* arm can be
311/// given at all.
312fn namespace_wrapper() -> Option<PathBuf> {
313 if !cfg!(target_os = "linux") {
314 return None;
315 }
316 let path = std::env::var_os("PATH")?;
317 std::env::split_paths(&path)
318 .map(|dir| dir.join("bwrap"))
319 .find(|candidate| candidate.is_file())
320}
321
322/// A list in the shape a sentence uses: `a`, `a and b`, `a, b and c`.
323fn listed(parts: &[&str]) -> String {
324 match parts {
325 [] => "nothing".to_owned(),
326 [one] => (*one).to_owned(),
327 [one, two] => format!("{one} and {two}"),
328 [first @ .., last] => format!("{}, and {last}", first.join(", ")),
329 }
330}
331
332mod placing;
333
334pub use placing::{Confined, Placed, Placement, parse_placement};
335
336#[cfg(test)]
337#[allow(
338 clippy::unwrap_used,
339 clippy::expect_used,
340 clippy::panic,
341 clippy::indexing_slicing,
342 clippy::arithmetic_side_effects,
343 reason = "test code"
344)]
345mod tests;