1use 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
48const ENV_ALLOWLIST: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TZ"];
52
53pub 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 pub env_passthrough: Vec<EnvVarName>,
68 pub domain: kernel::Address,
69 pub run: RunId,
72 pub policy: crate::mode::PolicyReader,
73}
74
75pub struct ExecTool {
76 setup: ExecSetup,
77 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 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 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 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 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}
297mod outcome;
301
302use outcome::{
303 backgrounded, exceptional, settled, with_backlog, with_environment, with_interpreter,
304};
305
306fn 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
319pub 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 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
376fn 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
389impl 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;