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