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 yielding;
39
40pub use confinement::{
41 Assurances, Confined, Confinement, Guarantee, Kept, Missing, Offerings, Placed, Placement,
42 parse_placement,
43};
44
45const ENV_ALLOWLIST: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TZ"];
49
50pub 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 pub env_passthrough: Vec<EnvVarName>,
65 pub domain: kernel::Address,
66 pub run: RunId,
69 pub limit: kernel::WriteLimit,
70}
71
72pub struct ExecTool {
73 setup: ExecSetup,
74 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 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 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 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 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}
302mod outcome;
306
307use outcome::{backgrounded, exceptional, settled, with_backlog, with_environment};
308
309fn 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
322pub 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 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
378fn 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
391impl 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;