polyc-tools 2026.8.3

The in-process tool core for polychrome agents: local executors (coding, web fetch, wallet, ...), the tool registry, and MCP composition. The networked connectors live in polyc-connectors.
//! `shell_exec`: run a shell command inside the workspace.
//!
//! The command runs with the workspace as its working directory. The child's
//! environment is CLEARED and only `PATH`/`HOME` are re-added, so process
//! secrets the harness holds (API keys, etc.) are never exposed to the command.
//! The call is bounded by a timeout and the child is killed on drop, so a
//! runaway command can't outlive the call or hang the turn; stdout/stderr are
//! capped. It is destructive, so it is gated per the active
//! [`SandboxMode`](super::workspace::SandboxMode).
//!
//! `shell_exec` requires the `arbitrary-egress` capability. The capability gate
//! refuses it before process creation when the grant lacks that capability.

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;

/// Default per-call timeout when the caller doesn't specify one.
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// Hard ceiling on the per-call timeout.
const MAX_TIMEOUT_SECS: u64 = 120;
/// Cap on captured stdout/stderr bytes (each), so output can't blow context.
const MAX_OUTPUT_BYTES: usize = 60_000;

/// `shell_exec` spec.
#[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()
}

/// Truncate `bytes` to [`MAX_OUTPUT_BYTES`] as lossy UTF-8, flagging truncation.
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)
    }
}

/// Execute `shell_exec` against `root` (the working directory).
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())
        // Clear inherited env so process secrets (e.g. API keys) never leak into
        // a command's view; re-add only a minimal, non-secret PATH/HOME.
        .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 the child if the future is dropped (e.g. on timeout) so a runaway
        // command can't outlive the call.
        .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();
        // The child env is cleared and re-seeded with only PATH + HOME, so its
        // full environment is exactly those two — proving inherited process
        // secrets (API keys, etc.) are not visible. HOME is the workspace 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();
        // PATH + HOME(=root) are present; the shell may inject PWD/SHLVL/_, but
        // no inherited secret-shaped var (KEY/TOKEN/SECRET/API/POLYCHROME) leaks.
        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();
    }
}