Skip to main content

runtime/tools/exec/confinement/
placing.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 adapter that places a command: a copy of the working tree synced
7//! to it, the platform's wrapper where the arm has one, and the budget
8//! that keeps a copy from growing without a bound.
9//!
10//! Where a command runs is the caller's choice and one of two words
11//! ([`Placement`]); what runs there is the arm's, and the arm's promises
12//! are [`super::Confinement`]'s. This file holds no policy about which
13//! arm a machine should get.
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19use kernel::{AxCode, AxError};
20use serde_json::{Map, Value};
21
22use crate::backlog::{BacklogId, Finished};
23
24use super::{Confinement, Missing, Offerings};
25
26/// Where a host command runs. The safe arm is what a call gets when it
27/// says nothing, because an agent that already knew a command was
28/// dangerous would not need to be told to sandbox it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Placement {
31    /// In the confinement [`Confinement::detect`](super::Confinement::detect) reports: a copy of
32    /// the working tree, and the platform's own isolation where there is
33    /// a wrapper for it.
34    Sandbox,
35    /// Where it stands, on the room the run may write in. This is the
36    /// placement a person decides on; a command that needs it asks for
37    /// it by name.
38    Host,
39}
40
41/// Reads the placement out of the call. Absent is [`Placement::Sandbox`].
42pub fn parse_placement(args: &Map<String, Value>) -> Result<Placement, AxError> {
43    match args.get("where") {
44        None => Ok(Placement::Sandbox),
45        Some(Value::String(said)) if said == "sandbox" => Ok(Placement::Sandbox),
46        Some(Value::String(said)) if said == "host" => Ok(Placement::Host),
47        Some(other) => Err(AxError::failure(
48            AxCode::InvalidArgs,
49            "run exec",
50            format!("unrecognised placement: {other}"),
51        )
52        .with_recovery(
53            "leave `where` out to run in the sandbox, or name one of `sandbox` and \
54             `host`",
55        )),
56    }
57}
58
59impl Placement {
60    /// This placement, when the run's write limit opens it.
61    ///
62    /// A command on the host could change, remove or link any file it
63    /// reaches, and nothing on this machine makes the existing ones
64    /// read-only to it without administrator rights, so under `Create`
65    /// only the copy is open; what a command writes there stays there
66    /// (`crates/runtime/spec/Tools.lean` §8-55, runtime D11).
67    ///
68    /// # Errors
69    /// The refusal of `kernel::gate::replacing`, naming the run's
70    /// domain, before any process starts.
71    pub fn opened_by(self, setup: &crate::tools::ExecSetup) -> Result<Placement, AxError> {
72        match self {
73            Placement::Sandbox => Ok(self),
74            Placement::Host => match kernel::gate::replacing(setup.limit, &setup.domain) {
75                kernel::GateOutcome::Allow => Ok(self),
76                kernel::GateOutcome::Deny { refusal } => Err(*refusal),
77                kernel::GateOutcome::Ask { question } => Err(*question),
78            },
79        }
80    }
81
82    /// What the exec tool's description adds under a write limit, so
83    /// the model reads the limit before its first call rather than at
84    /// its first refusal.
85    pub fn told_under(limit: kernel::WriteLimit) -> &'static str {
86        match limit {
87            kernel::WriteLimit::Full => "",
88            kernel::WriteLimit::Create => {
89                " This run creates files and changes none, so `where: host` is refused; \
90                 what a command writes stays in the copy."
91            }
92        }
93    }
94}
95
96/// The arm in use, the copy kept for the next command, and the copies
97/// commands still hold.
98///
99/// The copy of a command that settled is kept for the next command,
100/// which syncs it rather than making another (runtime D10). A
101/// command handed to the backlog keeps its copy until the member reports
102/// its ending, because the process is still in it.
103pub struct Confined {
104    arm: Confinement,
105    scratch: Option<PathBuf>,
106    idle: Option<Mirror>,
107    outstanding: BTreeMap<BacklogId, Mirror>,
108}
109
110/// One copy, and the working directory it mirrors.
111struct Mirror {
112    of: PathBuf,
113    at: PathBuf,
114}
115
116/// Where one command was put, so that whoever ends the wait can hand
117/// the copy back.
118pub struct Placed {
119    copy: Option<Mirror>,
120    work: storage::FileWork,
121}
122
123impl Placed {
124    /// What putting the copy in place cost the filesystem (`crates/storage/Spec.lean`
125    /// §8-31).
126    pub fn work(&self) -> storage::FileWork {
127        self.work
128    }
129}
130
131impl Confined {
132    /// The arm this machine gives, and where its copies go.
133    pub fn detect() -> Confined {
134        let offered = Offerings::this_machine();
135        Confined {
136            arm: Confinement::choose(&offered),
137            scratch: offered.scratch,
138            idle: None,
139            outstanding: BTreeMap::new(),
140        }
141    }
142
143    /// An arm stated rather than found: a test's machine, or a report
144    /// about one.
145    pub fn with_arm(arm: Confinement, scratch: Option<PathBuf>) -> Confined {
146        Confined {
147            arm,
148            scratch,
149            idle: None,
150            outstanding: BTreeMap::new(),
151        }
152    }
153
154    pub fn arm(&self) -> &Confinement {
155        &self.arm
156    }
157
158    /// What this arm promises and does not, for the caller's own
159    /// description of itself.
160    pub fn statement(&self) -> String {
161        self.arm.statement()
162    }
163
164    /// Places one command under this machine's arm: the tool's copy of
165    /// `workdir`, synced to it, for the command to run in, wrapped in the
166    /// platform's isolation where the arm is one that has a wrapper.
167    ///
168    /// # Errors
169    /// `E_SANDBOX_DENIED` when this machine has no arm, when the arm
170    /// cannot be given by any build of this crate, or when the working
171    /// tree is too large or too deep to copy.
172    pub fn place(
173        &mut self,
174        mut command: Command,
175        workdir: &Path,
176    ) -> Result<(Command, Placed), AxError> {
177        let wrapper = match &self.arm {
178            Confinement::Unavailable { missing } => return Err(no_arm(*missing)),
179            Confinement::WindowsJobObject => return Err(no_job_object()),
180            Confinement::LinuxNamespaces { wrapper } => Some(wrapper.clone()),
181            Confinement::CopiedTree => None,
182        };
183        let (copy, work) = self.synced(workdir)?;
184        let command = match wrapper {
185            Some(wrapper) => namespaced(&wrapper, &copy.at, workdir, &command),
186            None => {
187                command.current_dir(&copy.at);
188                command
189            }
190        };
191        Ok((
192            command,
193            Placed {
194                copy: Some(copy),
195                work,
196            },
197        ))
198    }
199
200    /// The wait ended and the command is over: its copy is kept for the
201    /// next command, or goes when one is kept already.
202    pub fn settled(&mut self, placed: Placed) {
203        if let Some(copy) = placed.copy {
204            self.keep(copy);
205        }
206    }
207
208    /// The command is still going: its copy is kept against the member
209    /// that will report the ending.
210    pub fn handed(&mut self, id: BacklogId, placed: Placed) {
211        if let Some(copy) = placed.copy {
212            self.outstanding.insert(id, copy);
213        }
214    }
215
216    /// Copies whose member has reported are kept for the next command,
217    /// or go.
218    pub fn reaped(&mut self, finished: &[Finished]) {
219        for member in finished {
220            if let Some(copy) = self.outstanding.remove(&member.id) {
221                self.keep(copy);
222            }
223        }
224    }
225
226    /// Keeps `copy` for the next command when no copy is kept yet, and
227    /// removes it otherwise.
228    ///
229    /// A copy that cannot be removed costs disk space rather than the
230    /// answer, so the command's ending is not turned into a failure of
231    /// the tool: the judgment is `backlog/member.rs`'s about the place
232    /// a child's output was written, and it is recorded here rather than
233    /// repeated there.
234    fn keep(&mut self, copy: Mirror) {
235        match self.idle {
236            None => self.idle = Some(copy),
237            Some(_) => remove(&copy.at),
238        }
239    }
240
241    /// The copy kept for the next command, or a fresh one, brought to
242    /// what `workdir` holds.
243    fn synced(&mut self, workdir: &Path) -> Result<(Mirror, storage::FileWork), AxError> {
244        let root = self
245            .scratch
246            .as_deref()
247            .ok_or_else(|| no_arm(Missing::ScratchDirectory))?;
248        let copy = match self.idle.take() {
249            Some(kept) if kept.of == workdir => kept,
250            Some(other) => {
251                remove(&other.at);
252                Mirror {
253                    of: workdir.to_path_buf(),
254                    at: fresh(root)?,
255                }
256            }
257            None => Mirror {
258                of: workdir.to_path_buf(),
259                at: fresh(root)?,
260            },
261        };
262        let mut budget = Budget::new();
263        match mirror(
264            &Stage {
265                from: workdir,
266                into: &copy.at,
267                copy: &copy.at,
268            },
269            0,
270            &mut budget,
271        ) {
272            Ok(()) => Ok((copy, budget.work())),
273            Err(fault) => {
274                // The half-synced copy goes before the refusal is
275                // returned: a scratch root that grew a tree per refusal
276                // would be a leak nothing reports. A removal that fails
277                // here is the secondary failure and stays unstated rather
278                // than replacing the one that caused it.
279                remove(&copy.at);
280                Err(fault)
281            }
282        }
283    }
284}
285
286impl Drop for Confined {
287    fn drop(&mut self) {
288        // The copy kept for a next command that will not come, and those
289        // of commands still running when the tool ended. There is no
290        // caller left to tell, and the alternative is a panic, which this
291        // crate forbids: a directory that will not go stays behind in the
292        // scratch root, where an operating system clears it.
293        for copy in self.idle.iter().chain(self.outstanding.values()) {
294            remove(&copy.at);
295        }
296    }
297}
298
299/// The refusal for a machine that cannot take a command at all.
300fn no_arm(missing: Missing) -> AxError {
301    AxError::failure(
302        AxCode::SandboxDenied,
303        "run outside the confinement",
304        missing.phrase(),
305    )
306    .with_recovery(missing.recovery())
307}
308
309/// The refusal for the one arm no build of this crate can give.
310///
311/// Refused rather than served as the copied tree: the copied tree does
312/// not isolate the network, and answering for an arm that does would
313/// tell a caller its network was closed while it was open.
314fn no_job_object() -> AxError {
315    AxError::failure(
316        AxCode::SandboxDenied,
317        "run under a job object",
318        "this build makes no job object",
319    )
320    .with_recovery(
321        "run under the copied tree instead: it copies the working directory and does \
322         not isolate the network, so keep the command off the network or run it on a \
323         machine that has the arm",
324    )
325}
326
327mod copy;
328
329use copy::{Budget, Stage, fresh, mirror, remove};
330
331/// The command as the platform's wrapper runs it: the whole machine
332/// readable, the copy bound over the working directory, and every
333/// namespace the wrapper knows how to separate.
334fn namespaced(wrapper: &Path, copy: &Path, workdir: &Path, command: &Command) -> Command {
335    let mut wrapped = Command::new(wrapper);
336    wrapped
337        .current_dir(workdir)
338        .arg("--ro-bind")
339        .arg("/")
340        .arg("/")
341        .arg("--bind")
342        .arg(copy)
343        .arg(workdir)
344        .arg("--chdir")
345        .arg(workdir)
346        .arg("--proc")
347        .arg("/proc")
348        .arg("--dev")
349        .arg("/dev")
350        .arg("--unshare-all")
351        .arg("--die-with-parent")
352        .arg("--")
353        .arg(command.get_program());
354    for arg in command.get_args() {
355        wrapped.arg(arg);
356    }
357    for (name, value) in command.get_envs() {
358        match value {
359            Some(value) => wrapped.env(name, value),
360            None => wrapped.env_remove(name),
361        };
362    }
363    wrapped
364}