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