sprawling-runtime 0.0.9

One run of a sprawling resident, from dispatch to freeze: turns, tools, replay and the sandbox that executes code.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
// 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. It runs in the sandbox this machine's confinement gives it
//! unless the call names `where: host`; the tool's own description 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::collections::BTreeMap;
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, Started};
use crate::sandbox::{Fuel, Mount, Sandbox, SandboxExit, SandboxJob};

mod confinement;
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,
    /// Locked because `Sandbox::run` takes `&mut self` while `invoke`
    /// takes `&self`; exec writes, so no second call waits on it.
    sandbox: Mutex<Box<dyn Sandbox>>,
    backlog: Backlog,
    confinement: Mutex<Confined>,
    meta: ToolMeta,
}

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();
        // The arm and what it does not hold are in front of the caller,
        // because a tool that said only "sandboxed" would let a command
        // that needs a closed network be launched in a box whose network
        // is open.
        let disclosure = format!(
            "Run a program, a Python snippet, or a shell line. A program or shell \
             line runs in this machine's confinement, {}. Ask for `where: host` to \
             run one outside it. Use `read` and `search` for what is already written here; a \
             command that prints it comes back without the version `edit` guards on.{}",
            confinement.statement(),
            setup.shell.statement()
        );
        Ok(ExecTool {
            setup,
            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)
    }

    /// Every host command goes through the table, whichever arm asked
    /// for it: there is no `background` argument, because two paths would
    /// be two authorities and the one with the hole in it would always be
    /// the one nobody remembered. The command is lowered before its
    /// environment is cleared, so the clearing lands on whatever process
    /// is actually spawned.
    fn through_the_backlog(
        &self,
        command: std::process::Command,
        what: String,
        arm: &str,
        placement: Placement,
    ) -> Result<ToolOutcome, AxError> {
        let mut command = yielding::one_level_down(command, self.backlog.shares())?;
        let inherited = self.inherited_environment();
        command.env_clear();
        for (key, value) in &inherited {
            command.env(key, value);
        }
        let (command, placed) = match placement {
            Placement::Host => (command, None),
            Placement::Sandbox => {
                let (command, placed) =
                    held(&self.confinement)?.place(command, &self.setup.workdir)?;
                (command, Some(placed))
            }
        };
        let started = self
            .backlog
            .run(self.setup.run, &self.setup.domain, what, command)?;
        let result = match started {
            Started::Settled {
                exit,
                stdout,
                stderr,
            } => {
                if let Some(placed) = placed {
                    held(&self.confinement)?.settled(placed);
                }
                settled(&stdout, &stderr, exit, arm)?
            }
            Started::Backgrounded { id, what } => {
                if let Some(placed) = placed {
                    held(&self.confinement)?.handed(id, placed);
                }
                backgrounded(&id, &what, arm)?
            }
        };
        with_environment(result, &inherited)
    }

    /// The variables this run's children actually get: the floor every
    /// city grants, plus the names this building declared, minus every
    /// name this machine does not set.
    ///
    /// A name with no value on this machine is left out rather than set
    /// empty, because an empty variable and an absent one are two
    /// different things to the programs that read them.
    fn inherited_environment(&self) -> BTreeMap<String, String> {
        let mut chosen = BTreeMap::new();
        let declared = self
            .setup
            .env_passthrough
            .iter()
            .map(EnvVarName::as_str)
            .chain(ENV_ALLOWLIST);
        for key in declared {
            if let Ok(value) = std::env::var(key) {
                chosen.insert(key.to_owned(), value);
            }
        }
        chosen
    }

    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) = 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)
    }
}
/// Every result payload this tool can return has its own file: the
/// arms above decide what happened, and `outcome` decides how it is
/// written down.
mod outcome;

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

/// The refusal the Python arm gives when asked for a host 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")
    })
}

/// The tool is dropped when its run's bench is, which is when the run
/// can no longer be handed anything: what it left in the background is
/// terminated and released so the table does not keep it for the life of
/// the city.
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;