use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use car_engine::{agent_basics, Substrate, ToolExecutor};
use car_policy::InspectorChain;
use serde_json::{json, Value};
use super::policy::assistant_inspector_chain;
use crate::coder::policy::stays_under;
use crate::coder::shell_tool::run_shell_on;
pub struct GeneralExecutor {
substrate: Arc<dyn Substrate>,
root: PathBuf,
clamp: bool,
inspectors: InspectorChain,
delegate: Option<Arc<dyn ToolExecutor>>,
delegate_defs: Vec<Value>,
}
impl GeneralExecutor {
pub fn new(substrate: Arc<dyn Substrate>, root: impl Into<PathBuf>, clamp: bool) -> Self {
let root: PathBuf = root.into();
let root = root.canonicalize().unwrap_or(root);
let inspectors = assistant_inspector_chain(&root);
Self {
substrate,
root,
clamp,
inspectors,
delegate: None,
delegate_defs: Vec::new(),
}
}
pub fn with_chain(mut self, chain: InspectorChain) -> Self {
self.inspectors = chain;
self
}
pub fn with_delegate(mut self, delegate: Arc<dyn ToolExecutor>, defs: Vec<Value>) -> Self {
self.delegate = Some(delegate);
self.delegate_defs = defs;
self
}
pub fn tool_defs() -> Vec<Value> {
let mut defs: Vec<Value> = agent_basics::entries()
.iter()
.map(|e| {
json!({
"name": e.schema.name,
"description": e.schema.description,
"parameters": e.schema.parameters,
})
})
.collect();
defs.push(json!({
"name": "shell",
"description": "Run a shell command in the working directory. Use for \
builds, tests, package installs, and anything the file \
tools can't do. Output is the combined stdout+stderr tail; \
a non-zero exit is reported. Some commands (git push, sudo, \
destructive ops outside the working directory) are denied by \
policy.",
"parameters": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "Command executed via sh -c." },
"timeout_secs": { "type": "integer", "description": "Wall-clock limit (default 120, max 600)." }
},
"required": ["command"]
}
}));
defs
}
pub fn all_tool_defs(&self) -> Vec<Value> {
let mut defs = Self::tool_defs();
defs.extend(self.delegate_defs.iter().cloned());
defs
}
pub fn root(&self) -> &Path {
&self.root
}
fn clamp_paths(&self, tool: &str, params: &Value) -> Result<Value, String> {
if !self.clamp {
return Ok(params.clone());
}
let mut params = params.clone();
let Some(obj) = params.as_object_mut() else {
return Ok(params);
};
if let Some(Value::String(p)) = obj.get("path") {
if !stays_under(&self.root, p) && matches!(tool, "write_file" | "edit_file") {
return Err(format!("path '{p}' resolves outside the working directory"));
}
if Path::new(p).is_relative() {
let abs = self.root.join(p);
obj.insert("path".into(), json!(abs.to_string_lossy()));
}
} else if matches!(tool, "list_dir" | "find_files" | "grep_files") {
obj.entry("path")
.or_insert_with(|| json!(self.root.to_string_lossy()));
}
Ok(params)
}
}
#[async_trait]
impl ToolExecutor for GeneralExecutor {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
if tool == "shell" {
let command = params
.get("command")
.and_then(Value::as_str)
.ok_or("missing 'command' parameter")?;
let timeout_secs = params.get("timeout_secs").and_then(Value::as_u64);
return run_shell_on(
&self.substrate,
Some(&self.root),
&self.inspectors,
command,
timeout_secs,
)
.await;
}
if self.delegate_defs.iter().any(|d| d["name"] == tool) {
if let Some(delegate) = &self.delegate {
return delegate.execute(tool, params).await;
}
}
let clamped = self.clamp_paths(tool, params)?;
if let Some(reason) = self.inspectors.check(tool, &clamped) {
return Err(format!("denied by policy: {reason}"));
}
match agent_basics::execute(&self.substrate, tool, &clamped).await {
Some(result) => result,
None => Err(format!("unknown tool: {tool}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_engine::LocalSubstrate;
fn local_executor() -> (tempfile::TempDir, GeneralExecutor) {
let dir = tempfile::tempdir().unwrap();
let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
let exec = GeneralExecutor::new(substrate, dir.path(), true);
(dir, exec)
}
#[tokio::test]
async fn calculate_is_available_to_the_assistant() {
let (_dir, exec) = local_executor();
let out = exec
.execute("calculate", &json!({ "expression": "2 + 3 * 4" }))
.await
.unwrap();
assert_eq!(out["result"], 14.0);
}
#[tokio::test]
async fn shell_runs_in_root() {
let (dir, exec) = local_executor();
let out = exec
.execute("shell", &json!({ "command": "pwd", "timeout_secs": 10 }))
.await
.unwrap();
let cwd = out["output"].as_str().unwrap().trim();
assert_eq!(
PathBuf::from(cwd).canonicalize().unwrap(),
dir.path().canonicalize().unwrap()
);
}
#[tokio::test]
async fn relative_writes_land_in_root_when_clamped() {
let (dir, exec) = local_executor();
exec.execute("write_file", &json!({ "path": "sub/o.txt", "content": "hi" }))
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("sub/o.txt")).unwrap(),
"hi"
);
}
#[tokio::test]
async fn escaping_writes_rejected_when_clamped() {
let (_dir, exec) = local_executor();
let err = exec
.execute("write_file", &json!({ "path": "../escape.txt", "content": "x" }))
.await
.unwrap_err();
assert!(err.contains("outside the working directory"), "{err}");
}
#[tokio::test]
async fn shell_sudo_denied_by_policy() {
let (_dir, exec) = local_executor();
let err = exec
.execute("shell", &json!({ "command": "sudo rm -rf /", "timeout_secs": 5 }))
.await
.unwrap_err();
assert!(err.contains("denied by policy"), "{err}");
}
#[tokio::test]
async fn delegate_tool_routes_and_is_advertised() {
let dir = tempfile::tempdir().unwrap();
let substrate: Arc<dyn Substrate> = Arc::new(LocalSubstrate::new());
let defs = vec![json!({
"name": "web_search",
"description": "x",
"parameters": { "type": "object", "properties": {} }
})];
struct Stub;
#[async_trait]
impl ToolExecutor for Stub {
async fn execute(&self, tool: &str, _p: &Value) -> Result<Value, String> {
Ok(json!({ "via": "delegate", "tool": tool }))
}
}
let exec = GeneralExecutor::new(substrate, dir.path(), true)
.with_delegate(Arc::new(Stub), defs);
let names: Vec<String> = exec
.all_tool_defs()
.iter()
.filter_map(|d| d["name"].as_str().map(String::from))
.collect();
assert!(names.contains(&"web_search".to_string()));
assert!(names.contains(&"read_file".to_string()));
assert!(names.contains(&"calculate".to_string()));
let out = exec.execute("web_search", &json!({})).await.unwrap();
assert_eq!(out["via"], "delegate");
}
}