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. It runs in the sandbox this machine's confinement gives it
12//! unless the call names `where: host`; the tool's own description states
13//! which arm that is and what the arm does not hold. Python runs inside
14//! the wasip1 sandbox, where the capability surface is the mount list and
15//! there is no network at all. Shell probes for an interpreter and
16//! **refuses when there is none**, rather than quietly rewriting the
17//! request as a Program call: a shell line that silently becomes
18//! something else is a worse answer than a refusal naming what is
19//! missing.
20//!
21//! A missing component is `E_TOOL_UNAVAILABLE` carrying the alternative
22//! that would work, so the caller redirects instead of guessing.
23
24use std::collections::BTreeMap;
25use std::path::PathBuf;
26use std::sync::{Mutex, MutexGuard};
27
28use kernel::{
29    AxCode, AxError, CostTier, Effect, EnvVarName, ExecArm, Payload, RenderIntent, RunId, Temporal,
30    Tool, ToolCall, ToolMeta, ToolName, ToolOutcome,
31};
32use serde_json::{Map, Value};
33
34use crate::backlog::{Backlog, Exit, Started};
35use crate::sandbox::{Fuel, Mount, Sandbox, SandboxExit, SandboxJob};
36
37mod confinement;
38mod shell;
39mod yielding;
40
41pub use confinement::{
42    Assurances, Confined, Confinement, Guarantee, Kept, Missing, Offerings, Placed, Placement,
43    parse_placement,
44};
45pub use outcome::{FailureClass, ShellCount, ShellTally};
46pub use shell::Shell;
47
48/// Environment variables a child may inherit. Everything else is
49/// dropped: an allowlist stays safe when the process environment grows,
50/// which a denylist does not.
51const ENV_ALLOWLIST: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TZ"];
52
53/// What one run's execution boundary is made of.
54///
55/// Values that always travel together and are never chosen apart: read
56/// out of one frozen configuration, one machine and one dispatch, they
57/// reach this tool as one thing rather than as a parameter list nobody
58/// can call correctly from memory. `policy` is the run's cell (§8-55, §8-62).
59pub struct ExecSetup {
60    pub workdir: PathBuf,
61    pub mounts: Vec<Mount>,
62    pub python_wasm: Option<PathBuf>,
63    pub shell: Shell,
64    pub fuel: Fuel,
65    /// The names this building declared its children may inherit, on top
66    /// of [`ENV_ALLOWLIST`].
67    pub env_passthrough: Vec<EnvVarName>,
68    pub domain: kernel::Address,
69    /// The run this tool serves: a command it hands to the background
70    /// is owed to this run, and its output reaches no other.
71    pub run: RunId,
72    pub policy: crate::mode::PolicyReader,
73}
74
75pub struct ExecTool {
76    setup: ExecSetup,
77    /// Locked because `Sandbox::run` takes `&mut self` while `invoke`
78    /// takes `&self`; exec writes, so no second call waits on it.
79    sandbox: Mutex<Box<dyn Sandbox>>,
80    backlog: Backlog,
81    confinement: Mutex<Confined>,
82    meta: ToolMeta,
83}
84
85impl ExecTool {
86    pub fn new(
87        setup: ExecSetup,
88        sandbox: Box<dyn Sandbox>,
89        backlog: Backlog,
90    ) -> Result<ExecTool, AxError> {
91        let mut params = Map::new();
92        params.insert("type".to_owned(), Value::String("object".to_owned()));
93        let mut properties = Map::new();
94        let mut arm = Map::new();
95        arm.insert("type".to_owned(), Value::String("object".to_owned()));
96        arm.insert(
97            "description".to_owned(),
98            Value::String(
99                "one of {program:{path,args}}, {python:{code}}, {shell:{text}}; write the \
100                 Python as a short script against the standard library - pathlib, difflib, re, \
101                 itertools, collections - with no classes, no exception handlers and no \
102                 comments, and read the error when it fails"
103                    .to_owned(),
104            ),
105        );
106        properties.insert("arm".to_owned(), Value::Object(arm));
107        let mut placement = Map::new();
108        placement.insert("type".to_owned(), Value::String("string".to_owned()));
109        placement.insert(
110            "description".to_owned(),
111            Value::String(
112                "where the program or shell line runs: `sandbox` (the default) or \
113                 `host`"
114                    .to_owned(),
115            ),
116        );
117        properties.insert("where".to_owned(), Value::Object(placement));
118        params.insert("properties".to_owned(), Value::Object(properties));
119        params.insert(
120            "required".to_owned(),
121            Value::Array(vec![Value::String("arm".to_owned())]),
122        );
123        let domain = setup.domain.clone();
124        let confinement = Confined::detect();
125        // The arm and what it does not hold are in front of the caller,
126        // because a tool that said only "sandboxed" would let a command
127        // that needs a closed network be launched in a box whose network
128        // is open.
129        let disclosure = format!(
130            "Run a program, a Python snippet, or a shell line. A program or shell \
131             line runs in this machine's confinement, {}. Ask for `where: host` to \
132             run one outside it. Use `read` and `search` for what is already written here; a \
133             command that prints it comes back without the version `edit` guards on.{}",
134            confinement.statement(),
135            setup.shell.statement()
136        );
137        Ok(ExecTool {
138            setup,
139            sandbox: Mutex::new(sandbox),
140            backlog,
141            confinement: Mutex::new(confinement),
142            meta: ToolMeta {
143                name: ToolName::parse("exec")?,
144                disclosure,
145                params: Payload::new(params)?,
146                effect: Effect::Write { domain },
147                cost_tier: CostTier::Heavy,
148                timeout: None,
149                render: RenderIntent::Terminal,
150                temporal: Temporal::Timestamped,
151            },
152        })
153    }
154
155    fn run_program(
156        &self,
157        path: &str,
158        args: &[String],
159        placement: Placement,
160    ) -> Result<ToolOutcome, AxError> {
161        let mut command = std::process::Command::new(path);
162        command.current_dir(&self.setup.workdir).args(args);
163        let what = if args.is_empty() {
164            path.to_owned()
165        } else {
166            format!("{path} {}", args.join(" "))
167        };
168        self.through_the_backlog(command, what, "program", placement)
169    }
170
171    /// Every host command goes through the table, whichever arm asked
172    /// for it: there is no `background` argument, because two paths would
173    /// be two authorities and the one with the hole in it would always be
174    /// the one nobody remembered. The command is lowered before its
175    /// environment is cleared, so the clearing lands on whatever process
176    /// is actually spawned.
177    fn through_the_backlog(
178        &self,
179        command: std::process::Command,
180        what: String,
181        arm: &str,
182        placement: Placement,
183    ) -> Result<ToolOutcome, AxError> {
184        let mut command = yielding::one_level_down(command, self.backlog.shares())?;
185        let inherited = self.inherited_environment();
186        command.env_clear();
187        for (key, value) in &inherited {
188            command.env(key, value);
189        }
190        let (command, placed) = match placement {
191            Placement::Host => (command, None),
192            Placement::Sandbox => {
193                let (command, placed) =
194                    held(&self.confinement)?.place(command, &self.setup.workdir)?;
195                (command, Some(placed))
196            }
197        };
198        let started = self
199            .backlog
200            .run(self.setup.run, &self.setup.domain, what, command)?;
201        let result = match started {
202            Started::Settled {
203                exit,
204                stdout,
205                stderr,
206            } => {
207                if let Some(placed) = placed {
208                    held(&self.confinement)?.settled(placed);
209                }
210                settled(&stdout, &stderr, exit, arm)?
211            }
212            Started::Backgrounded { id, what } => {
213                if let Some(placed) = placed {
214                    held(&self.confinement)?.handed(id, placed);
215                }
216                backgrounded(&id, &what, arm)?
217            }
218        };
219        with_environment(result, &inherited)
220    }
221
222    /// The variables this run's children actually get: the floor every
223    /// city grants, plus the names this building declared, minus every
224    /// name this machine does not set.
225    ///
226    /// A name with no value on this machine is left out rather than set
227    /// empty, because an empty variable and an absent one are two
228    /// different things to the programs that read them.
229    fn inherited_environment(&self) -> BTreeMap<String, String> {
230        let mut chosen = BTreeMap::new();
231        let declared = self
232            .setup
233            .env_passthrough
234            .iter()
235            .map(EnvVarName::as_str)
236            .chain(ENV_ALLOWLIST);
237        for key in declared {
238            if let Ok(value) = std::env::var(key) {
239                chosen.insert(key.to_owned(), value);
240            }
241        }
242        chosen
243    }
244
245    fn run_python(&self, code: &str) -> Result<ToolOutcome, AxError> {
246        let Some(wasm) = self.setup.python_wasm.clone() else {
247            return Err(AxError::failure(
248                AxCode::ToolUnavailable,
249                "run python",
250                "no CPython-WASI component is configured",
251            )
252            .with_recovery("use the program arm, or configure a CPython-WASI component"));
253        };
254        let job = SandboxJob {
255            wasm,
256            argv: vec!["python".to_owned(), "-c".to_owned(), code.to_owned()],
257            env: Vec::new(),
258            stdin: Vec::new(),
259            mounts: self.setup.mounts.clone(),
260            fuel: self.setup.fuel,
261        };
262        let result = held(&self.sandbox)?.run(&job)?;
263        let exit = match &result.exit {
264            SandboxExit::Success => Exit::Ended { code: 0 },
265            SandboxExit::Failure { code } => Exit::Ended {
266                code: i32::try_from(*code).unwrap_or(i32::MAX),
267            },
268            // Exhaustion and traps are guest facts the caller must see
269            // as themselves, not flattened into a generic non-zero exit.
270            SandboxExit::FuelExhausted => {
271                return exceptional(&result.stdout, &result.stderr, "fuel_exhausted", None);
272            }
273            SandboxExit::Trap { message } => {
274                return exceptional(
275                    &result.stdout,
276                    &result.stderr,
277                    "trap",
278                    Some(message.clone()),
279                );
280            }
281        };
282        settled(
283            &String::from_utf8_lossy(&result.stdout),
284            &String::from_utf8_lossy(&result.stderr),
285            exit,
286            "python",
287        )
288    }
289
290    fn run_shell(&self, text: &str, placement: Placement) -> Result<ToolOutcome, AxError> {
291        let (mut command, interpreter) = self.setup.shell.command(text)?;
292        command.current_dir(&self.setup.workdir);
293        let answer = self.through_the_backlog(command, text.to_owned(), "shell", placement)?;
294        with_interpreter(answer, &interpreter)
295    }
296}
297/// Every result payload this tool can return has its own file: the
298/// arms above decide what happened, and `outcome` decides how it is
299/// written down.
300mod outcome;
301
302use outcome::{
303    backgrounded, exceptional, settled, with_backlog, with_environment, with_interpreter,
304};
305
306/// The refusal the Python arm gives when asked for a host interpreter.
307fn no_host_python() -> AxError {
308    AxError::failure(
309        AxCode::SandboxDenied,
310        "run python on the host",
311        "the python arm runs a wasip1 guest and has no host form",
312    )
313    .with_recovery(
314        "use `arm: program` with a host interpreter, or leave `where` out to run the \
315         configured component",
316    )
317}
318
319/// Reads the arm out of the call. An unrecognised shape is refused
320/// rather than defaulted to shell — guessing which arm was meant is how
321/// a program run becomes a shell injection.
322pub fn parse_arm(args: &Map<String, Value>) -> Result<ExecArm, AxError> {
323    let arm = args.get("arm").ok_or_else(|| {
324        AxError::failure(AxCode::InvalidArgs, "run exec", "missing `arm`").with_recovery(
325            "name the arm in the call: `program` with a path and args, `python` \
326                 with code, or `shell` with one command line",
327        )
328    })?;
329    serde_json::from_value(arm.clone()).map_err(|err| {
330        AxError::failure(
331            AxCode::InvalidArgs,
332            "run exec",
333            format!("unrecognised arm: {err}"),
334        )
335        .with_recovery("use one of program, python, shell")
336    })
337}
338
339impl Tool for ExecTool {
340    fn meta(&self) -> &ToolMeta {
341        &self.meta
342    }
343
344    fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
345        if call.name != self.meta.name {
346            return Err(AxError::failure(
347                AxCode::InvalidArgs,
348                "run exec",
349                format!("call routed to the wrong tool: {}", call.name.as_str()),
350            )
351            .with_recovery(format!(
352                "call `{}`, the name this tool answers to",
353                self.meta.name.as_str()
354            )));
355        }
356        crate::mode::mode_admits(&self.setup.policy.now(), kernel::ToolName::EXEC)?;
357        let placement = parse_placement(call.args.as_map())?.opened_by(&self.setup)?;
358        let answer = match parse_arm(call.args.as_map())? {
359            ExecArm::Program { path, args } => self.run_program(&path, &args, placement),
360            // The Python arm has no host form: its interpreter is a
361            // component this build runs as a wasip1 guest, and a host
362            // `python` would be a different program than the one the
363            // arm promises.
364            ExecArm::Python { code } => match placement {
365                Placement::Sandbox => self.run_python(&code),
366                Placement::Host => Err(no_host_python()),
367            },
368            ExecArm::Shell { text } => self.run_shell(&text, placement),
369        }?;
370        let finished = self.backlog.harvest(self.setup.run)?;
371        held(&self.confinement)?.reaped(&finished);
372        with_backlog(answer, finished)
373    }
374}
375
376/// One of this tool's own locks, or the refusal that says a thread died
377/// holding it.
378fn held<T>(lock: &Mutex<T>) -> Result<MutexGuard<'_, T>, AxError> {
379    lock.lock().map_err(|_| {
380        AxError::failure(
381            AxCode::ToolUnavailable,
382            "run exec",
383            "a previous command left this tool locked when its thread died",
384        )
385        .with_recovery("dispatch the work again; the next run starts with a fresh exec tool")
386    })
387}
388
389/// The tool is dropped when its run's bench is, which is when the run
390/// can no longer be handed anything: what it left in the background is
391/// terminated and released so the table does not keep it for the life of
392/// the city.
393impl Drop for ExecTool {
394    fn drop(&mut self) {
395        self.backlog.release(self.setup.run);
396    }
397}
398
399#[cfg(test)]
400#[allow(
401    clippy::unwrap_used,
402    clippy::expect_used,
403    clippy::panic,
404    clippy::indexing_slicing,
405    reason = "test code"
406)]
407mod tests;