1use std::path::PathBuf;
24use std::sync::{Mutex, MutexGuard};
25
26use kernel::{
27 AxCode, AxError, CostTier, Effect, EnvVarName, ExecArm, Payload, RenderIntent, RunId, Temporal,
28 Tool, ToolCall, ToolMeta, ToolName, ToolOutcome,
29};
30use serde_json::{Map, Value};
31
32use crate::backlog::{Backlog, Exit};
33use crate::sandbox::{Fuel, Mount, Sandbox, SandboxExit, SandboxJob};
34
35mod confinement;
36pub(crate) mod container;
37mod dispatch;
38#[cfg(any(target_os = "macos", test))]
39mod native_macos;
40#[cfg(windows)]
41pub(crate) mod native_windows;
42pub use container::{ContainerEngine, ContainerLaunch, ContainerRuntime};
43mod shell;
44mod yielding;
45
46pub use confinement::{
47 Assurances, Confined, Confinement, Guarantee, Kept, Missing, Offerings, Placed, Placement,
48 parse_placement,
49};
50pub use outcome::{FailureClass, ShellCount, ShellTally};
51pub use shell::Shell;
52
53const ENV_ALLOWLIST: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TZ"];
57
58pub struct ExecSetup {
65 pub workdir: PathBuf,
66 pub mounts: Vec<Mount>,
67 pub python_wasm: Option<PathBuf>,
68 pub shell: Shell,
69 pub fuel: Fuel,
70 pub env_passthrough: Vec<EnvVarName>,
73 pub domain: kernel::Address,
74 pub run: RunId,
77 pub policy: crate::mode::PolicyReader,
78}
79
80pub struct ExecTool {
81 setup: ExecSetup,
82 sandbox: Mutex<Box<dyn Sandbox>>,
84 backlog: Backlog,
85 confinement: Mutex<Confined>,
86 meta: ToolMeta,
87 container: Option<(kernel::ContainerLimits, ContainerRuntime)>,
88 program_route: dispatch::ProgramRoute,
89}
90
91impl ExecTool {
92 pub fn new(
93 setup: ExecSetup,
94 sandbox: Box<dyn Sandbox>,
95 backlog: Backlog,
96 ) -> Result<ExecTool, AxError> {
97 let mut params = Map::new();
98 params.insert("type".to_owned(), Value::String("object".to_owned()));
99 let mut properties = Map::new();
100 let mut arm = Map::new();
101 arm.insert("type".to_owned(), Value::String("object".to_owned()));
102 arm.insert(
103 "description".to_owned(),
104 Value::String(
105 "one of {program:{path,args}}, {python:{code}}, {shell:{text}}; write the \
106 Python as a short script against the standard library - pathlib, difflib, re, \
107 itertools, collections - with no classes, no exception handlers and no \
108 comments, and read the error when it fails"
109 .to_owned(),
110 ),
111 );
112 properties.insert("arm".to_owned(), Value::Object(arm));
113 let mut placement = Map::new();
114 placement.insert("type".to_owned(), Value::String("string".to_owned()));
115 placement.insert(
116 "description".to_owned(),
117 Value::String(
118 "where the program or shell line runs: `sandbox` (the default) or \
119 `host`"
120 .to_owned(),
121 ),
122 );
123 properties.insert("where".to_owned(), Value::Object(placement));
124 params.insert("properties".to_owned(), Value::Object(properties));
125 params.insert(
126 "required".to_owned(),
127 Value::Array(vec![Value::String("arm".to_owned())]),
128 );
129 let domain = setup.domain.clone();
130 let confinement = Confined::detect();
131 let disclosure = disclosure::describe(&setup, &confinement);
132 Ok(ExecTool {
133 setup,
134 container: None,
135 program_route: dispatch::ProgramRoute::Confined,
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 = child::command(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 run_python(&self, code: &str) -> Result<ToolOutcome, AxError> {
169 let Some(wasm) = self.setup.python_wasm.clone() else {
170 return Err(AxError::failure(
171 AxCode::ToolUnavailable,
172 "run python",
173 "no CPython-WASI component is configured",
174 )
175 .with_recovery("use the program arm, or configure a CPython-WASI component"));
176 };
177 let job = SandboxJob {
178 wasm,
179 argv: vec!["python".to_owned(), "-c".to_owned(), code.to_owned()],
180 env: Vec::new(),
181 stdin: Vec::new(),
182 mounts: self.setup.mounts.clone(),
183 fuel: self.setup.fuel,
184 };
185 let result = held(&self.sandbox)?.run(&job)?;
186 let exit = match &result.exit {
187 SandboxExit::Success => Exit::Ended { code: 0 },
188 SandboxExit::Failure { code } => Exit::Ended {
189 code: i32::try_from(*code).unwrap_or(i32::MAX),
190 },
191 SandboxExit::FuelExhausted => {
194 return exceptional(&result.stdout, &result.stderr, "fuel_exhausted", None);
195 }
196 SandboxExit::Trap { message } => {
197 return exceptional(
198 &result.stdout,
199 &result.stderr,
200 "trap",
201 Some(message.clone()),
202 );
203 }
204 };
205 settled(
206 &String::from_utf8_lossy(&result.stdout),
207 &String::from_utf8_lossy(&result.stderr),
208 exit,
209 "python",
210 )
211 }
212
213 fn run_shell(&self, text: &str, placement: Placement) -> Result<ToolOutcome, AxError> {
214 let (mut command, interpreter) = match (placement, &self.container) {
215 (Placement::Sandbox, Some(_)) => self.setup.shell.container_command(text)?,
216 (Placement::Host, _) | (Placement::Sandbox, None) => self.setup.shell.command(text)?,
217 };
218 command.current_dir(&self.setup.workdir);
219 let answer = self.through_the_backlog(command, text.to_owned(), "shell", placement)?;
220 with_interpreter(answer, &interpreter)
221 }
222}
223
224mod disclosure;
225mod outcome;
226
227use outcome::{exceptional, settled, with_backlog, with_interpreter};
228
229fn no_host_python() -> AxError {
230 AxError::failure(
231 AxCode::SandboxDenied,
232 "run python on the host",
233 "the python arm runs a wasip1 guest and has no host form",
234 )
235 .with_recovery(
236 "use `arm: program` with a host interpreter, or leave `where` out to run the \
237 configured component",
238 )
239}
240
241pub fn parse_arm(args: &Map<String, Value>) -> Result<ExecArm, AxError> {
245 let arm = args.get("arm").ok_or_else(|| {
246 AxError::failure(AxCode::InvalidArgs, "run exec", "missing `arm`").with_recovery(
247 "name the arm in the call: `program` with a path and args, `python` \
248 with code, or `shell` with one command line",
249 )
250 })?;
251 serde_json::from_value(arm.clone()).map_err(|err| {
252 AxError::failure(
253 AxCode::InvalidArgs,
254 "run exec",
255 format!("unrecognised arm: {err}"),
256 )
257 .with_recovery("use one of program, python, shell")
258 })
259}
260
261impl Tool for ExecTool {
262 fn meta(&self) -> &ToolMeta {
263 &self.meta
264 }
265
266 fn invoke(&self, call: &ToolCall) -> Result<ToolOutcome, AxError> {
267 if call.name != self.meta.name {
268 return Err(AxError::failure(
269 AxCode::InvalidArgs,
270 "run exec",
271 format!("call routed to the wrong tool: {}", call.name.as_str()),
272 )
273 .with_recovery(format!(
274 "call `{}`, the name this tool answers to",
275 self.meta.name.as_str()
276 )));
277 }
278 crate::mode::mode_admits(&self.setup.policy.now(), kernel::ToolName::EXEC)?;
279 let placement = parse_placement(call.args.as_map())?.opened_by(&self.setup)?;
280 let answer = match parse_arm(call.args.as_map())? {
281 ExecArm::Program { path, args } => self.run_program(&path, &args, placement),
282 ExecArm::Python { code } => match placement {
287 Placement::Sandbox => self.run_python(&code),
288 Placement::Host => Err(no_host_python()),
289 },
290 ExecArm::Shell { text } => self.run_shell(&text, placement),
291 }?;
292 let finished = self.backlog.harvest(self.setup.run)?;
293 held(&self.confinement)?.reaped(&finished);
294 with_backlog(answer, finished)
295 }
296}
297
298fn held<T>(lock: &Mutex<T>) -> Result<MutexGuard<'_, T>, AxError> {
301 lock.lock().map_err(|_| {
302 AxError::failure(
303 AxCode::ToolUnavailable,
304 "run exec",
305 "a previous command left this tool locked when its thread died",
306 )
307 .with_recovery("dispatch the work again; the next run starts with a fresh exec tool")
308 })
309}
310
311impl Drop for ExecTool {
313 fn drop(&mut self) {
314 self.backlog.release(self.setup.run);
315 }
316}
317
318#[cfg(test)]
319#[allow(
320 clippy::unwrap_used,
321 clippy::expect_used,
322 clippy::panic,
323 clippy::indexing_slicing,
324 reason = "test code"
325)]
326mod tests;