Skip to main content

runtime/tools/
exec.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 exec tool: exactly three arms, each with its own failure story.
7//!
8//! Program runs a host process with a pinned working directory and an
9//! environment allowlist — secrets are never passed through, because a
10//! child process inherits whatever it is given and cannot be asked to
11//! forget. Its chosen confinement applies unless the call names `where: host`; disclosure states
12//! which arm that is and what the arm does not hold. Python runs inside
13//! the wasip1 sandbox, where the capability surface is the mount list and
14//! there is no network at all. Shell probes for an interpreter and
15//! **refuses when there is none**, rather than quietly rewriting the
16//! request as a Program call: a shell line that silently becomes
17//! something else is a worse answer than a refusal naming what is
18//! missing.
19//!
20//! A missing component is `E_TOOL_UNAVAILABLE` carrying the alternative
21//! that would work, so the caller redirects instead of guessing.
22
23use std::path::PathBuf;
24use std::sync::{Mutex, MutexGuard};
25
26use kernel::{
27    AxCode, AxError, CostTier, Effect, EnvVarName, ExecArm, Payload, RenderIntent, RunId, Temporal,
28    Tool, ToolCall, ToolMeta, ToolName, ToolOutcome,
29};
30use serde_json::{Map, Value};
31
32use crate::backlog::{Backlog, Exit};
33use crate::sandbox::{Fuel, Mount, Sandbox, SandboxExit, SandboxJob};
34
35mod confinement;
36pub(crate) mod container;
37mod dispatch;
38#[cfg(any(target_os = "macos", test))]
39mod native_macos;
40#[cfg(windows)]
41pub(crate) mod native_windows;
42pub use container::{ContainerEngine, ContainerLaunch, ContainerRuntime};
43mod shell;
44mod yielding;
45
46pub use confinement::{
47    Assurances, Confined, Confinement, Guarantee, Kept, Missing, Offerings, Placed, Placement,
48    parse_placement,
49};
50pub use outcome::{FailureClass, ShellCount, ShellTally};
51pub use shell::Shell;
52
53/// Environment variables a child may inherit. Everything else is
54/// dropped: an allowlist stays safe when the process environment grows,
55/// which a denylist does not.
56const ENV_ALLOWLIST: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TZ"];
57
58/// What one run's execution boundary is made of.
59///
60/// Values that always travel together and are never chosen apart: read
61/// out of one frozen configuration, one machine and one dispatch, they
62/// reach this tool as one thing rather than as a parameter list nobody
63/// can call correctly from memory. `policy` is the run's cell (§8-55, §8-62).
64pub struct ExecSetup {
65    pub workdir: PathBuf,
66    pub mounts: Vec<Mount>,
67    pub python_wasm: Option<PathBuf>,
68    pub shell: Shell,
69    pub fuel: Fuel,
70    /// The names this building declared its children may inherit, on top
71    /// of [`ENV_ALLOWLIST`].
72    pub env_passthrough: Vec<EnvVarName>,
73    pub domain: kernel::Address,
74    /// The run this tool serves: a command it hands to the background
75    /// is owed to this run, and its output reaches no other.
76    pub run: RunId,
77    pub policy: crate::mode::PolicyReader,
78}
79
80pub struct ExecTool {
81    setup: ExecSetup,
82    /// `Sandbox::run` mutates the guest; `invoke` borrows the tool.
83    sandbox: Mutex<Box<dyn Sandbox>>,
84    backlog: Backlog,
85    confinement: Mutex<Confined>,
86    meta: ToolMeta,
87    container: Option<(kernel::ContainerLimits, ContainerRuntime)>,
88    program_route: dispatch::ProgramRoute,
89}
90
91impl ExecTool {
92    pub fn new(
93        setup: ExecSetup,
94        sandbox: Box<dyn Sandbox>,
95        backlog: Backlog,
96    ) -> Result<ExecTool, AxError> {
97        let mut params = Map::new();
98        params.insert("type".to_owned(), Value::String("object".to_owned()));
99        let mut properties = Map::new();
100        let mut arm = Map::new();
101        arm.insert("type".to_owned(), Value::String("object".to_owned()));
102        arm.insert(
103            "description".to_owned(),
104            Value::String(
105                "one of {program:{path,args}}, {python:{code}}, {shell:{text}}; write the \
106                 Python as a short script against the standard library - pathlib, difflib, re, \
107                 itertools, collections - with no classes, no exception handlers and no \
108                 comments, and read the error when it fails"
109                    .to_owned(),
110            ),
111        );
112        properties.insert("arm".to_owned(), Value::Object(arm));
113        let mut placement = Map::new();
114        placement.insert("type".to_owned(), Value::String("string".to_owned()));
115        placement.insert(
116            "description".to_owned(),
117            Value::String(
118                "where the program or shell line runs: `sandbox` (the default) or \
119                 `host`"
120                    .to_owned(),
121            ),
122        );
123        properties.insert("where".to_owned(), Value::Object(placement));
124        params.insert("properties".to_owned(), Value::Object(properties));
125        params.insert(
126            "required".to_owned(),
127            Value::Array(vec![Value::String("arm".to_owned())]),
128        );
129        let domain = setup.domain.clone();
130        let confinement = Confined::detect();
131        let disclosure = disclosure::describe(&setup, &confinement);
132        Ok(ExecTool {
133            setup,
134            container: None,
135            program_route: dispatch::ProgramRoute::Confined,
136            sandbox: Mutex::new(sandbox),
137            backlog,
138            confinement: Mutex::new(confinement),
139            meta: ToolMeta {
140                name: ToolName::parse("exec")?,
141                disclosure,
142                params: Payload::new(params)?,
143                effect: Effect::Write { domain },
144                cost_tier: CostTier::Heavy,
145                timeout: None,
146                render: RenderIntent::Terminal,
147                temporal: Temporal::Timestamped,
148            },
149        })
150    }
151
152    fn run_program(
153        &self,
154        path: &str,
155        args: &[String],
156        placement: Placement,
157    ) -> Result<ToolOutcome, AxError> {
158        let mut command = child::command(path);
159        command.current_dir(&self.setup.workdir).args(args);
160        let what = if args.is_empty() {
161            path.to_owned()
162        } else {
163            format!("{path} {}", args.join(" "))
164        };
165        self.through_the_backlog(command, what, "program", placement)
166    }
167
168    fn run_python(&self, code: &str) -> Result<ToolOutcome, AxError> {
169        let Some(wasm) = self.setup.python_wasm.clone() else {
170            return Err(AxError::failure(
171                AxCode::ToolUnavailable,
172                "run python",
173                "no CPython-WASI component is configured",
174            )
175            .with_recovery("use the program arm, or configure a CPython-WASI component"));
176        };
177        let job = SandboxJob {
178            wasm,
179            argv: vec!["python".to_owned(), "-c".to_owned(), code.to_owned()],
180            env: Vec::new(),
181            stdin: Vec::new(),
182            mounts: self.setup.mounts.clone(),
183            fuel: self.setup.fuel,
184        };
185        let result = held(&self.sandbox)?.run(&job)?;
186        let exit = match &result.exit {
187            SandboxExit::Success => Exit::Ended { code: 0 },
188            SandboxExit::Failure { code } => Exit::Ended {
189                code: i32::try_from(*code).unwrap_or(i32::MAX),
190            },
191            // Exhaustion and traps are guest facts the caller must see
192            // as themselves, not flattened into a generic non-zero exit.
193            SandboxExit::FuelExhausted => {
194                return exceptional(&result.stdout, &result.stderr, "fuel_exhausted", None);
195            }
196            SandboxExit::Trap { message } => {
197                return exceptional(
198                    &result.stdout,
199                    &result.stderr,
200                    "trap",
201                    Some(message.clone()),
202                );
203            }
204        };
205        settled(
206            &String::from_utf8_lossy(&result.stdout),
207            &String::from_utf8_lossy(&result.stderr),
208            exit,
209            "python",
210        )
211    }
212
213    fn run_shell(&self, text: &str, placement: Placement) -> Result<ToolOutcome, AxError> {
214        let (mut command, interpreter) = match (placement, &self.container) {
215            (Placement::Sandbox, Some(_)) => self.setup.shell.container_command(text)?,
216            (Placement::Host, _) | (Placement::Sandbox, None) => self.setup.shell.command(text)?,
217        };
218        command.current_dir(&self.setup.workdir);
219        let answer = self.through_the_backlog(command, text.to_owned(), "shell", placement)?;
220        with_interpreter(answer, &interpreter)
221    }
222}
223
224mod disclosure;
225mod outcome;
226
227use outcome::{exceptional, settled, with_backlog, with_interpreter};
228
229fn no_host_python() -> AxError {
230    AxError::failure(
231        AxCode::SandboxDenied,
232        "run python on the host",
233        "the python arm runs a wasip1 guest and has no host form",
234    )
235    .with_recovery(
236        "use `arm: program` with a host interpreter, or leave `where` out to run the \
237         configured component",
238    )
239}
240
241/// Reads the arm out of the call. An unrecognised shape is refused
242/// rather than defaulted to shell — guessing which arm was meant is how
243/// a program run becomes a shell injection.
244pub fn parse_arm(args: &Map<String, Value>) -> Result<ExecArm, AxError> {
245    let arm = args.get("arm").ok_or_else(|| {
246        AxError::failure(AxCode::InvalidArgs, "run exec", "missing `arm`").with_recovery(
247            "name the arm in the call: `program` with a path and args, `python` \
248                 with code, or `shell` with one command line",
249        )
250    })?;
251    serde_json::from_value(arm.clone()).map_err(|err| {
252        AxError::failure(
253            AxCode::InvalidArgs,
254            "run exec",
255            format!("unrecognised arm: {err}"),
256        )
257        .with_recovery("use one of program, python, shell")
258    })
259}
260
261impl Tool for ExecTool {
262    fn meta(&self) -> &ToolMeta {
263        &self.meta
264    }
265
266    fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
267        if call.name != self.meta.name {
268            return Err(AxError::failure(
269                AxCode::InvalidArgs,
270                "run exec",
271                format!("call routed to the wrong tool: {}", call.name.as_str()),
272            )
273            .with_recovery(format!(
274                "call `{}`, the name this tool answers to",
275                self.meta.name.as_str()
276            )));
277        }
278        crate::mode::mode_admits(&self.setup.policy.now(), kernel::ToolName::EXEC)?;
279        let placement = parse_placement(call.args.as_map())?.opened_by(&self.setup)?;
280        let answer = match parse_arm(call.args.as_map())? {
281            ExecArm::Program { path, args } => self.run_program(&path, &args, placement),
282            // The Python arm has no host form: its interpreter is a
283            // component this build runs as a wasip1 guest, and a host
284            // `python` would be a different program than the one the
285            // arm promises.
286            ExecArm::Python { code } => match placement {
287                Placement::Sandbox => self.run_python(&code),
288                Placement::Host => Err(no_host_python()),
289            },
290            ExecArm::Shell { text } => self.run_shell(&text, placement),
291        }?;
292        let finished = self.backlog.harvest(self.setup.run)?;
293        held(&self.confinement)?.reaped(&finished);
294        with_backlog(answer, finished)
295    }
296}
297
298/// One of this tool's own locks, or the refusal that says a thread died
299/// holding it.
300fn held<T>(lock: &Mutex<T>) -> Result<MutexGuard<'_, T>, AxError> {
301    lock.lock().map_err(|_| {
302        AxError::failure(
303            AxCode::ToolUnavailable,
304            "run exec",
305            "a previous command left this tool locked when its thread died",
306        )
307        .with_recovery("dispatch the work again; the next run starts with a fresh exec tool")
308    })
309}
310
311/// Releases background commands when the run drops its tool bench.
312impl Drop for ExecTool {
313    fn drop(&mut self) {
314        self.backlog.release(self.setup.run);
315    }
316}
317
318#[cfg(test)]
319#[allow(
320    clippy::unwrap_used,
321    clippy::expect_used,
322    clippy::panic,
323    clippy::indexing_slicing,
324    reason = "test code"
325)]
326mod tests;