sprawling-runtime 0.0.10

One run of a sprawling resident, from dispatch to freeze: turns, tools, replay and the sandbox that executes code.
Documentation
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors

//! The exec tool: exactly three arms, each with its own failure story.
//!
//! Program runs a host process with a pinned working directory and an
//! environment allowlist — secrets are never passed through, because a
//! child process inherits whatever it is given and cannot be asked to
//! forget. Its chosen confinement applies unless the call names `where: host`; disclosure states
//! which arm that is and what the arm does not hold. Python runs inside
//! the wasip1 sandbox, where the capability surface is the mount list and
//! there is no network at all. Shell probes for an interpreter and
//! **refuses when there is none**, rather than quietly rewriting the
//! request as a Program call: a shell line that silently becomes
//! something else is a worse answer than a refusal naming what is
//! missing.
//!
//! A missing component is `E_TOOL_UNAVAILABLE` carrying the alternative
//! that would work, so the caller redirects instead of guessing.

use std::path::PathBuf;
use std::sync::{Mutex, MutexGuard};

use kernel::{
    AxCode, AxError, CostTier, Effect, EnvVarName, ExecArm, Payload, RenderIntent, RunId, Temporal,
    Tool, ToolCall, ToolMeta, ToolName, ToolOutcome,
};
use serde_json::{Map, Value};

use crate::backlog::{Backlog, Exit};
use crate::sandbox::{Fuel, Mount, Sandbox, SandboxExit, SandboxJob};

mod confinement;
pub(crate) mod container;
mod dispatch;
#[cfg(any(target_os = "macos", test))]
mod native_macos;
#[cfg(windows)]
pub(crate) mod native_windows;
pub use container::{ContainerEngine, ContainerLaunch, ContainerRuntime};
mod shell;
mod yielding;

pub use confinement::{
    Assurances, Confined, Confinement, Guarantee, Kept, Missing, Offerings, Placed, Placement,
    parse_placement,
};
pub use outcome::{FailureClass, ShellCount, ShellTally};
pub use shell::Shell;

/// Environment variables a child may inherit. Everything else is
/// dropped: an allowlist stays safe when the process environment grows,
/// which a denylist does not.
const ENV_ALLOWLIST: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TZ"];

/// What one run's execution boundary is made of.
///
/// Values that always travel together and are never chosen apart: read
/// out of one frozen configuration, one machine and one dispatch, they
/// reach this tool as one thing rather than as a parameter list nobody
/// can call correctly from memory. `policy` is the run's cell (§8-55, §8-62).
pub struct ExecSetup {
    pub workdir: PathBuf,
    pub mounts: Vec<Mount>,
    pub python_wasm: Option<PathBuf>,
    pub shell: Shell,
    pub fuel: Fuel,
    /// The names this building declared its children may inherit, on top
    /// of [`ENV_ALLOWLIST`].
    pub env_passthrough: Vec<EnvVarName>,
    pub domain: kernel::Address,
    /// The run this tool serves: a command it hands to the background
    /// is owed to this run, and its output reaches no other.
    pub run: RunId,
    pub policy: crate::mode::PolicyReader,
}

pub struct ExecTool {
    setup: ExecSetup,
    /// `Sandbox::run` mutates the guest; `invoke` borrows the tool.
    sandbox: Mutex<Box<dyn Sandbox>>,
    backlog: Backlog,
    confinement: Mutex<Confined>,
    meta: ToolMeta,
    container: Option<(kernel::ContainerLimits, ContainerRuntime)>,
    program_route: dispatch::ProgramRoute,
}

impl ExecTool {
    pub fn new(
        setup: ExecSetup,
        sandbox: Box<dyn Sandbox>,
        backlog: Backlog,
    ) -> Result<ExecTool, AxError> {
        let mut params = Map::new();
        params.insert("type".to_owned(), Value::String("object".to_owned()));
        let mut properties = Map::new();
        let mut arm = Map::new();
        arm.insert("type".to_owned(), Value::String("object".to_owned()));
        arm.insert(
            "description".to_owned(),
            Value::String(
                "one of {program:{path,args}}, {python:{code}}, {shell:{text}}; write the \
                 Python as a short script against the standard library - pathlib, difflib, re, \
                 itertools, collections - with no classes, no exception handlers and no \
                 comments, and read the error when it fails"
                    .to_owned(),
            ),
        );
        properties.insert("arm".to_owned(), Value::Object(arm));
        let mut placement = Map::new();
        placement.insert("type".to_owned(), Value::String("string".to_owned()));
        placement.insert(
            "description".to_owned(),
            Value::String(
                "where the program or shell line runs: `sandbox` (the default) or \
                 `host`"
                    .to_owned(),
            ),
        );
        properties.insert("where".to_owned(), Value::Object(placement));
        params.insert("properties".to_owned(), Value::Object(properties));
        params.insert(
            "required".to_owned(),
            Value::Array(vec![Value::String("arm".to_owned())]),
        );
        let domain = setup.domain.clone();
        let confinement = Confined::detect();
        let disclosure = disclosure::describe(&setup, &confinement);
        Ok(ExecTool {
            setup,
            container: None,
            program_route: dispatch::ProgramRoute::Confined,
            sandbox: Mutex::new(sandbox),
            backlog,
            confinement: Mutex::new(confinement),
            meta: ToolMeta {
                name: ToolName::parse("exec")?,
                disclosure,
                params: Payload::new(params)?,
                effect: Effect::Write { domain },
                cost_tier: CostTier::Heavy,
                timeout: None,
                render: RenderIntent::Terminal,
                temporal: Temporal::Timestamped,
            },
        })
    }

    fn run_program(
        &self,
        path: &str,
        args: &[String],
        placement: Placement,
    ) -> Result<ToolOutcome, AxError> {
        let mut command = std::process::Command::new(path);
        command.current_dir(&self.setup.workdir).args(args);
        let what = if args.is_empty() {
            path.to_owned()
        } else {
            format!("{path} {}", args.join(" "))
        };
        self.through_the_backlog(command, what, "program", placement)
    }

    fn run_python(&self, code: &str) -> Result<ToolOutcome, AxError> {
        let Some(wasm) = self.setup.python_wasm.clone() else {
            return Err(AxError::failure(
                AxCode::ToolUnavailable,
                "run python",
                "no CPython-WASI component is configured",
            )
            .with_recovery("use the program arm, or configure a CPython-WASI component"));
        };
        let job = SandboxJob {
            wasm,
            argv: vec!["python".to_owned(), "-c".to_owned(), code.to_owned()],
            env: Vec::new(),
            stdin: Vec::new(),
            mounts: self.setup.mounts.clone(),
            fuel: self.setup.fuel,
        };
        let result = held(&self.sandbox)?.run(&job)?;
        let exit = match &result.exit {
            SandboxExit::Success => Exit::Ended { code: 0 },
            SandboxExit::Failure { code } => Exit::Ended {
                code: i32::try_from(*code).unwrap_or(i32::MAX),
            },
            // Exhaustion and traps are guest facts the caller must see
            // as themselves, not flattened into a generic non-zero exit.
            SandboxExit::FuelExhausted => {
                return exceptional(&result.stdout, &result.stderr, "fuel_exhausted", None);
            }
            SandboxExit::Trap { message } => {
                return exceptional(
                    &result.stdout,
                    &result.stderr,
                    "trap",
                    Some(message.clone()),
                );
            }
        };
        settled(
            &String::from_utf8_lossy(&result.stdout),
            &String::from_utf8_lossy(&result.stderr),
            exit,
            "python",
        )
    }

    fn run_shell(&self, text: &str, placement: Placement) -> Result<ToolOutcome, AxError> {
        let (mut command, interpreter) = match (placement, &self.container) {
            (Placement::Sandbox, Some(_)) => self.setup.shell.container_command(text)?,
            (Placement::Host, _) | (Placement::Sandbox, None) => self.setup.shell.command(text)?,
        };
        command.current_dir(&self.setup.workdir);
        let answer = self.through_the_backlog(command, text.to_owned(), "shell", placement)?;
        with_interpreter(answer, &interpreter)
    }
}

mod disclosure;
mod outcome;

use outcome::{exceptional, settled, with_backlog, with_interpreter};

fn no_host_python() -> AxError {
    AxError::failure(
        AxCode::SandboxDenied,
        "run python on the host",
        "the python arm runs a wasip1 guest and has no host form",
    )
    .with_recovery(
        "use `arm: program` with a host interpreter, or leave `where` out to run the \
         configured component",
    )
}

/// Reads the arm out of the call. An unrecognised shape is refused
/// rather than defaulted to shell — guessing which arm was meant is how
/// a program run becomes a shell injection.
pub fn parse_arm(args: &Map<String, Value>) -> Result<ExecArm, AxError> {
    let arm = args.get("arm").ok_or_else(|| {
        AxError::failure(AxCode::InvalidArgs, "run exec", "missing `arm`").with_recovery(
            "name the arm in the call: `program` with a path and args, `python` \
                 with code, or `shell` with one command line",
        )
    })?;
    serde_json::from_value(arm.clone()).map_err(|err| {
        AxError::failure(
            AxCode::InvalidArgs,
            "run exec",
            format!("unrecognised arm: {err}"),
        )
        .with_recovery("use one of program, python, shell")
    })
}

impl Tool for ExecTool {
    fn meta(&self) -> &ToolMeta {
        &self.meta
    }

    fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
        if call.name != self.meta.name {
            return Err(AxError::failure(
                AxCode::InvalidArgs,
                "run exec",
                format!("call routed to the wrong tool: {}", call.name.as_str()),
            )
            .with_recovery(format!(
                "call `{}`, the name this tool answers to",
                self.meta.name.as_str()
            )));
        }
        crate::mode::mode_admits(&self.setup.policy.now(), kernel::ToolName::EXEC)?;
        let placement = parse_placement(call.args.as_map())?.opened_by(&self.setup)?;
        let answer = match parse_arm(call.args.as_map())? {
            ExecArm::Program { path, args } => self.run_program(&path, &args, placement),
            // The Python arm has no host form: its interpreter is a
            // component this build runs as a wasip1 guest, and a host
            // `python` would be a different program than the one the
            // arm promises.
            ExecArm::Python { code } => match placement {
                Placement::Sandbox => self.run_python(&code),
                Placement::Host => Err(no_host_python()),
            },
            ExecArm::Shell { text } => self.run_shell(&text, placement),
        }?;
        let finished = self.backlog.harvest(self.setup.run)?;
        held(&self.confinement)?.reaped(&finished);
        with_backlog(answer, finished)
    }
}

/// One of this tool's own locks, or the refusal that says a thread died
/// holding it.
fn held<T>(lock: &Mutex<T>) -> Result<MutexGuard<'_, T>, AxError> {
    lock.lock().map_err(|_| {
        AxError::failure(
            AxCode::ToolUnavailable,
            "run exec",
            "a previous command left this tool locked when its thread died",
        )
        .with_recovery("dispatch the work again; the next run starts with a fresh exec tool")
    })
}

/// Releases background commands when the run drops its tool bench.
impl Drop for ExecTool {
    fn drop(&mut self) {
        self.backlog.release(self.setup.run);
    }
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    reason = "test code"
)]
mod tests;