use std::collections::BTreeMap;
use std::process::Stdio;
use std::sync::{Arc, LazyLock};
use agent_bridle_core::{
Caveats, ConfinedCommand, Denial, DenialKind, Disclosure, SandboxKind, SandboxPolicy, Scope,
Tool, ToolContext, ToolEnvelope, ToolError, ToolResult,
};
use async_trait::async_trait;
const ENGINE_NAME: &str = "sandbox-host";
const DEFAULT_MAX_OUTPUT: usize = 64 * 1024;
static DEFAULT_SCHEMA: LazyLock<Arc<serde_json::Value>> = LazyLock::new(|| {
Arc::new(
serde_json::from_str(include_str!("host_shell.schema.json"))
.expect("embedded host_shell.schema.json must be valid JSON"),
)
});
#[derive(Clone)]
pub struct HostShellTool {
shell: String,
max_output: usize,
sandbox: Arc<SandboxPolicy>,
schema: Arc<serde_json::Value>,
}
impl std::fmt::Debug for HostShellTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HostShellTool")
.field("shell", &self.shell)
.finish_non_exhaustive()
}
}
impl Default for HostShellTool {
fn default() -> Self {
Self::new()
}
}
impl HostShellTool {
#[must_use]
pub fn new() -> Self {
Self {
shell: "/bin/sh".to_string(),
max_output: DEFAULT_MAX_OUTPUT,
sandbox: Arc::new(SandboxPolicy::default()),
schema: DEFAULT_SCHEMA.clone(),
}
}
#[must_use]
pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
self.schema = Arc::new(schema);
self
}
#[must_use]
pub fn with_shell(mut self, shell: impl Into<String>) -> Self {
self.shell = shell.into();
self
}
#[must_use]
pub fn sandbox_policy(mut self, policy: Arc<SandboxPolicy>) -> Self {
self.sandbox = policy;
self
}
fn engine_unavailable(&self, kind: DenialKind, axis: &str) -> serde_json::Value {
ToolEnvelope::new(SandboxKind::None)
.with_disclosure(self.disclosure())
.with_denials(vec![Denial {
kind,
target: format!("sandbox-host engine ({axis} restricted)"),
reason: format!(
"the sandbox-host engine does not serve a restricted `{axis}` grant: \
inside a full shell it cannot bound the shell's forked children, so it \
refuses rather than claim confinement it does not have (ADR 0019 D2). \
Use the safe-subset engine for a restricted `{axis}` grant."
),
}])
.into_json()
}
fn disclosure(&self) -> Disclosure {
Disclosure {
engine: Some(ENGINE_NAME.to_string()),
..Disclosure::default()
}
}
}
fn is_restricted<T: Ord + Clone>(scope: &Scope<T>) -> bool {
!matches!(scope, Scope::All)
}
#[async_trait]
impl Tool for HostShellTool {
fn name(&self) -> &str {
"shell"
}
fn schema(&self) -> serde_json::Value {
(*self.schema).clone()
}
async fn invoke(
&self,
args: serde_json::Value,
cx: &ToolContext,
) -> ToolResult<serde_json::Value> {
let cmd = args
.get("cmd")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| ToolError::denied("sandbox-host: missing required `cmd` string"))?
.to_string();
let caveats: &Caveats = cx.caveats();
if is_restricted(&caveats.exec) {
return Ok(self.engine_unavailable(DenialKind::Exec, "exec"));
}
if is_restricted(&caveats.net) {
return Ok(self.engine_unavailable(DenialKind::Net, "net"));
}
let mut env: BTreeMap<String, String> = args
.get("env")
.and_then(serde_json::Value::as_object)
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
env.entry("PATH".to_string())
.or_insert_with(agent_bridle_core::default_exec_path);
let cwd = args.get("cwd").and_then(serde_json::Value::as_str);
let max_output = self.max_output;
let mut command = ConfinedCommand::new(&self.shell)
.arg("-c")
.arg(&cmd)
.sandbox_policy(Arc::clone(&self.sandbox))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in &env {
command = command.env(k, v);
}
if let Some(dir) = cwd {
command = command.current_dir(dir);
}
let confined = command.spawn(cx)?;
let sandbox_kind = confined.sandbox_kind;
let child = confined.child;
let output = tokio::task::spawn_blocking(move || child.wait_with_output())
.await
.map_err(|e| ToolError::Exec(std::io::Error::other(format!("join: {e}"))))?
.map_err(ToolError::Exec)?;
let stdout = cap_utf8(&output.stdout, max_output);
let stderr = cap_utf8(&output.stderr, max_output);
Ok(ToolEnvelope::new(sandbox_kind)
.with_disclosure(self.disclosure())
.with_exit_code(output.status.code().unwrap_or(-1))
.with_stdout(stdout)
.with_stderr(stderr)
.with_timed_out(false)
.into_json())
}
}
fn cap_utf8(bytes: &[u8], max: usize) -> String {
let s = String::from_utf8_lossy(bytes);
if s.len() <= max {
return s.into_owned();
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
format!("{}…[truncated]", &s[..end])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_schema_loads_from_data_file_with_expected_shape() {
let s = HostShellTool::new().schema();
assert_eq!(s["type"], "object");
assert_eq!(s["required"], serde_json::json!(["cmd"]));
for key in ["cmd", "env", "cwd"] {
assert!(
s["properties"].get(key).is_some(),
"schema is missing the `{key}` property: {s}"
);
}
}
#[test]
fn with_schema_overrides_the_property() {
let custom = serde_json::json!({ "type": "object", "properties": {} });
let s = HostShellTool::new().with_schema(custom.clone()).schema();
assert_eq!(s, custom);
}
}