Skip to main content

runtime/tools/exec/
dispatch.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//! Frozen program placement (`crates/runtime/spec/Tools/Exec/Container.lean`).
7
8use std::collections::BTreeMap;
9use std::sync::Mutex;
10
11use super::outcome::{backgrounded, settled, with_ceiling, with_environment};
12use super::{
13    Confined, Confinement, ContainerRuntime, ENV_ALLOWLIST, ExecTool, Placement, Shell, held,
14    yielding,
15};
16use crate::backlog::Started;
17use kernel::{AxCode, AxError, EnvVarName, ToolOutcome};
18
19#[derive(Clone, Copy)]
20pub(super) enum ProgramRoute {
21    Confined,
22    Host,
23    Python,
24}
25
26impl ExecTool {
27    /// Uses an explicitly admitted daemon and frozen limits for program and shell calls.
28    /// The daemon must be discovered by the host, rather than by a second configuration path.
29    #[must_use]
30    pub fn with_container(
31        mut self,
32        limits: kernel::ContainerLimits,
33        runtime: ContainerRuntime,
34    ) -> Self {
35        self.program_route = ProgramRoute::Confined;
36        self.container = Some((limits, runtime));
37        self.meta.disclosure = format!(
38            "Run a program, Python snippet, or shell line. Program and shell sandbox calls use \
39            an explicitly configured Linux container: writes land on a copy, network is closed, \
40            a non-root user and CPU/memory/process limits are inspected before start. \
41            Daemon cleanup failures are reported and retained for retry. `where: host` asks \
42            for host execution.{}",
43            match self.setup.shell {
44                Shell::Absent => "",
45                Shell::Missing {
46                    asked: kernel::Interpreter::Pwsh,
47                }
48                | Shell::Found {
49                    interpreter: kernel::Interpreter::Pwsh,
50                    ..
51                } => " Container shell lines use pwsh.",
52                Shell::Missing {
53                    asked: kernel::Interpreter::System,
54                }
55                | Shell::Found {
56                    interpreter: kernel::Interpreter::System,
57                    ..
58                } => " Container shell lines use /bin/sh.",
59            }
60        );
61        self
62    }
63
64    /// Chooses an execution boundary before the tool is admitted to a run.
65    /// The disclosure and actual placement always read the same confinement.
66    pub fn confined(mut self, confinement: Confined) -> Self {
67        self.program_route = ProgramRoute::Confined;
68        self.container = None;
69        self.meta.disclosure = super::disclosure::describe(&self.setup, &confinement);
70        self.confinement = Mutex::new(confinement);
71        self
72    }
73
74    /// Places sandbox program calls on the explicitly selected host arm.
75    #[must_use]
76    pub fn on_host(mut self) -> Self {
77        self.container = None;
78        self.program_route = ProgramRoute::Host;
79        self.meta.disclosure = format!(
80            "Program and shell calls run on the host with no confinement.{}",
81            self.setup.shell.statement()
82        );
83        self
84    }
85
86    /// Offers the existing WASI guest while refusing sandbox program and shell calls.
87    #[must_use]
88    pub fn python_only(mut self) -> Self {
89        self.container = None;
90        self.program_route = ProgramRoute::Python;
91        self.meta.disclosure = "Sandbox execution offers only the Python WASI arm; program and shell sandbox calls refuse.".to_owned();
92        self
93    }
94
95    /// Every host command goes through the table, whichever arm asked
96    /// for it: there is no `background` argument, because two paths would
97    /// be two authorities and the one with the hole in it would always be
98    /// the one nobody remembered. The command is lowered before its
99    /// environment is cleared, so the clearing lands on whatever process
100    /// is actually spawned.
101    pub(super) fn through_the_backlog(
102        &self,
103        command: std::process::Command,
104        what: String,
105        arm: &str,
106        placement: Placement,
107    ) -> Result<ToolOutcome, AxError> {
108        let placement = match (placement, self.program_route) {
109            (Placement::Sandbox, ProgramRoute::Host) => Placement::Host.opened_by(&self.setup)?,
110            (Placement::Sandbox, ProgramRoute::Python) => {
111                return Err(AxError::failure(
112                    AxCode::SandboxDenied,
113                    "execute a program",
114                    "the frozen sandbox arm only offers Python WASI",
115                )
116                .with_recovery(
117                    "use the Python arm, or select a program-capable sandbox for the next run",
118                ));
119            }
120            (Placement::Sandbox, ProgramRoute::Confined) | (Placement::Host, _) => placement,
121        };
122        let mut command = match (placement, &self.container) {
123            (Placement::Sandbox, _) => command,
124            (Placement::Host, _) => yielding::one_level_down(command, self.backlog.shares())?,
125        };
126        let inherited = self.inherited_environment();
127        command.env_clear();
128        for (key, value) in &inherited {
129            command.env(key, value);
130        }
131        let (started, placed) = match (placement, &self.container) {
132            (Placement::Sandbox, Some((limits, runtime))) => {
133                let mut copy =
134                    Confined::with_arm(Confinement::CopiedTree, Some(std::env::temp_dir()));
135                let (target, placed) = copy.place(command, &self.setup.workdir)?;
136                let copy = placed.into_container_copy()?;
137                (
138                    self.backlog.run_container(
139                        self.setup.run,
140                        &self.setup.domain,
141                        what,
142                        crate::backlog::ContainerRequest {
143                            runtime: runtime.clone(),
144                            limits: limits.clone(),
145                            copy,
146                            target,
147                        },
148                    )?,
149                    None,
150                )
151            }
152            (Placement::Sandbox, None) => {
153                let (mut command, placed) = held(&self.confinement)?.prepare(
154                    command,
155                    &self.setup.workdir,
156                    self.backlog.shares(),
157                )?;
158                command.env_clear();
159                for (key, value) in &inherited {
160                    command.env(key, value);
161                }
162                #[cfg(windows)]
163                let started = if matches!(
164                    held(&self.confinement)?.arm(),
165                    Confinement::WindowsJobObject
166                ) {
167                    self.backlog
168                        .run_native(self.setup.run, &self.setup.domain, what, command)?
169                } else {
170                    self.backlog
171                        .run(self.setup.run, &self.setup.domain, what, command)?
172                };
173                #[cfg(not(windows))]
174                let started =
175                    self.backlog
176                        .run(self.setup.run, &self.setup.domain, what, command)?;
177                (started, Some(placed))
178            }
179            (Placement::Host, _) => (
180                self.backlog
181                    .run(self.setup.run, &self.setup.domain, what, command)?,
182                None,
183            ),
184        };
185        let result = match started {
186            Started::Settled {
187                exit,
188                stdout,
189                stderr,
190                ceiling,
191            } => {
192                if let Some(placed) = placed {
193                    held(&self.confinement)?.settled(placed);
194                }
195                with_ceiling(settled(&stdout, &stderr, exit, arm)?, ceiling)?
196            }
197            Started::Backgrounded { id, what } => {
198                if let Some(placed) = placed {
199                    held(&self.confinement)?.handed(id, placed);
200                }
201                backgrounded(&id, &what, arm)?
202            }
203        };
204        with_environment(result, &inherited)
205    }
206
207    /// The variables this run's children actually get: the floor every
208    /// city grants, plus the names this building declared, minus every
209    /// name this machine does not set.
210    ///
211    /// A name with no value on this machine is left out rather than set
212    /// empty, because an empty variable and an absent one are two
213    /// different things to the programs that read them.
214    fn inherited_environment(&self) -> BTreeMap<String, String> {
215        let mut chosen = BTreeMap::new();
216        let declared = self
217            .setup
218            .env_passthrough
219            .iter()
220            .map(EnvVarName::as_str)
221            .chain(ENV_ALLOWLIST);
222        for key in declared {
223            if let Ok(value) = std::env::var(key) {
224                chosen.insert(key.to_owned(), value);
225            }
226        }
227        chosen
228    }
229}