use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use polyc_llm::ToolSpec;
use serde_json::{Value, json};
use tokio::process::Command;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const MAX_TIMEOUT_SECS: u64 = 120;
const MAX_OUTPUT_BYTES: usize = 60_000;
#[must_use]
pub(super) fn spec() -> ToolSpec {
ToolSpec::new(
"shell_exec",
"Run a shell command (`sh -c`) with the workspace as the working \
directory and return its exit code, stdout, and stderr. The environment \
is cleared (no harness secrets); bounded by a timeout; output is \
truncated if large.",
json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "Command line passed to `sh -c`." },
"timeout_secs": {
"type": "integer",
"minimum": 1,
"maximum": MAX_TIMEOUT_SECS,
"description": "Per-call timeout (default 30, max 120)."
}
},
"required": ["command"],
"additionalProperties": false
}),
)
.titled("Run a shell command")
.destructive()
}
fn cap(bytes: &[u8]) -> (String, bool) {
if bytes.len() > MAX_OUTPUT_BYTES {
(
String::from_utf8_lossy(&bytes[..MAX_OUTPUT_BYTES]).into_owned(),
true,
)
} else {
(String::from_utf8_lossy(bytes).into_owned(), false)
}
}
pub(super) async fn execute(root: &Path, args_json: &str) -> String {
let Ok(args) = serde_json::from_str::<Value>(args_json) else {
return super::err("arguments must be a JSON object");
};
let Some(command) = args.get("command").and_then(Value::as_str) else {
return super::err("`command` (string) is required");
};
let timeout = args
.get("timeout_secs")
.and_then(Value::as_u64)
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(1, MAX_TIMEOUT_SECS);
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(command)
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear()
.env(
"PATH",
std::env::var("PATH")
.unwrap_or_else(|_| "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".to_owned()),
)
.env("HOME", root.as_os_str())
.kill_on_drop(true);
match tokio::time::timeout(Duration::from_secs(timeout), cmd.output()).await {
Ok(Ok(output)) => {
let (stdout, out_trunc) = cap(&output.stdout);
let (stderr, err_trunc) = cap(&output.stderr);
json!({
"exit_code": output.status.code(),
"stdout": stdout,
"stderr": stderr,
"truncated": out_trunc || err_trunc,
"timed_out": false,
})
.to_string()
}
Ok(Err(e)) => super::err(format!("spawn failed: {e}")),
Err(_) => json!({
"error": format!("command timed out after {timeout}s"),
"timed_out": true,
})
.to_string(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn tmp_root() -> std::path::PathBuf {
super::super::tmp_dir("shell-test")
}
#[tokio::test]
async fn runs_command_in_workspace() {
let root = tmp_root();
std::fs::write(root.join("hi.txt"), "x").unwrap();
let out = execute(&root, r#"{"command":"ls"}"#).await;
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["exit_code"], 0, "{out}");
assert!(v["stdout"].as_str().unwrap().contains("hi.txt"), "{out}");
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn env_is_cleared_to_minimal_set() {
let root = tmp_root();
let out = execute(&root, r#"{"command":"env"}"#).await;
let v: Value = serde_json::from_str(&out).unwrap();
let stdout = v["stdout"].as_str().unwrap();
assert!(stdout.lines().any(|l| l.starts_with("PATH=")));
assert!(
stdout
.lines()
.any(|l| l.starts_with(&format!("HOME={}", root.display()))),
"HOME must be the workspace root: {stdout}"
);
let leaked: Vec<&str> = stdout
.lines()
.filter(|l| {
let up = l.to_uppercase();
["KEY", "TOKEN", "SECRET", "API", "POLYCHROME", "CARGO"]
.iter()
.any(|n| up.split('=').next().is_some_and(|k| k.contains(n)))
})
.collect();
assert!(
leaked.is_empty(),
"secret-shaped env leaked to shell: {leaked:?}"
);
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn timeout_is_reported_and_kills_child() {
let root = tmp_root();
let out = execute(&root, r#"{"command":"sleep 5","timeout_secs":1}"#).await;
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["timed_out"], true, "{out}");
std::fs::remove_dir_all(&root).ok();
}
}