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;
const ENV_ALLOWLIST: [&str; 4] = ["PATH", "LANG", "LC_ALL", "TZ"];
pub struct ExecSetup {
pub workdir: PathBuf,
pub mounts: Vec<Mount>,
pub python_wasm: Option<PathBuf>,
pub shell: Shell,
pub fuel: Fuel,
pub env_passthrough: Vec<EnvVarName>,
pub domain: kernel::Address,
pub run: RunId,
pub policy: crate::mode::PolicyReader,
}
pub struct ExecTool {
setup: ExecSetup,
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 = child::command(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),
},
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",
)
}
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),
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)
}
}
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")
})
}
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;