car-server-core 0.33.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! `GeneralExecutor` — the assistant's host-side tool executor.
//!
//! Generalizes the coder's [`WorktreeExecutor`] from a git worktree to any
//! bound [`Substrate`] (a Docker sandbox by default, the local host with
//! `--local`, or a remote VM). It exposes the full commodity toolset —
//! `agent_basics` file tools + `calculate` + a real `shell` + an optional
//! network delegate (`http_request`/`web_search`) — with the shared, bounded
//! shell implementation ([`run_shell_on`]) and the assistant inspector chain
//! gating every call.
//!
//! Run it as a [`Runtime`]'s tool executor so the validator, policy engine,
//! permission tiers, and event log still wrap each call; this executor owns the
//! actual work.
//!
//! [`WorktreeExecutor`]: crate::coder::shell_tool::WorktreeExecutor
//! [`run_shell_on`]: crate::coder::shell_tool::run_shell_on
//! [`Runtime`]: car_engine::Runtime

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 {
    /// The bound execution environment. **All** file + shell work runs here, so
    /// sandbox writes land in the container and local writes on the host — never
    /// a fresh `LocalSubstrate` (the historic `WorktreeExecutor` footgun).
    substrate: Arc<dyn Substrate>,
    /// Working-directory root: the shell cwd on the local path, and (when
    /// `clamp` is set) the boundary relative paths are rooted/clamped to.
    root: PathBuf,
    /// Clamp relative file paths to `root` and reject writes that escape it.
    /// On for the local host; off in a sandbox, where the container root already
    /// bounds every path.
    clamp: bool,
    inspectors: InspectorChain,
    delegate: Option<Arc<dyn ToolExecutor>>,
    delegate_defs: Vec<Value>,
}

impl GeneralExecutor {
    /// Build an executor bound to `substrate`, rooted at `root`. `clamp` should
    /// be `true` for the local host (bound file writes to `root`) and `false`
    /// for a sandbox/VM whose own root already contains the agent.
    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(),
        }
    }

    /// Replace the inspector chain (tests / callers wanting extra rules).
    pub fn with_chain(mut self, chain: InspectorChain) -> Self {
        self.inspectors = chain;
        self
    }

    /// Attach a delegate executor (e.g. the network tools) that owns `defs` by
    /// name. Delegate tools bypass the file-path clamp/inspector logic.
    pub fn with_delegate(mut self, delegate: Arc<dyn ToolExecutor>, defs: Vec<Value>) -> Self {
        self.delegate = Some(delegate);
        self.delegate_defs = defs;
        self
    }

    /// The static built-in tool defs: agent_basics file tools + `calculate` +
    /// the `shell` tool. (Unlike the coder, the assistant keeps `calculate`.)
    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
    }

    /// All tool defs to advertise: the static built-ins plus any delegate tools.
    /// Agent loops should advertise these so delegate tools are allowlistable.
    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
    }

    /// Root relative path params at `root` and reject write escapes. Only used
    /// when `clamp` is set (local host). Mirrors the coder's clamp.
    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> {
        // Tier gating (approval for writes/shell) is enforced by the loop's
        // ApprovalGate before it ever calls the executor; here we enforce only
        // the hard footgun inspectors + substrate isolation.
        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);
            // cwd is honored only on the local path; sandbox/VM substrates carry
            // their own root inside `run_command`.
            return run_shell_on(
                &self.substrate,
                Some(&self.root),
                &self.inspectors,
                command,
                timeout_secs,
            )
            .await;
        }

        // Delegate-owned tools (network) carry no working-dir paths — route
        // straight through, skipping clamp/inspector.
        if self.delegate_defs.iter().any(|d| d["name"] == tool) {
            if let Some(delegate) = &self.delegate {
                return delegate.execute(tool, params).await;
            }
        }

        // File / calculate tools: inspector-gate (credential reads etc.), clamp,
        // then run against the BOUND substrate.
        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");
    }
}