harness-bash 0.1.1

Bash tool for AI agent harnesses — autonomous shell execution with tokio process management, cwd-carry, inactivity timeout, head+tail spill, background jobs
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;

use crate::constants::MAX_COMMAND_LENGTH;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BashParams {
    pub command: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cwd: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub background: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub env: Option<std::collections::HashMap<String, String>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BashOutputParams {
    pub job_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub since_byte: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub head_limit: Option<usize>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BashKillParams {
    pub job_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub signal: Option<String>,
}

#[derive(Debug, Clone, thiserror::Error)]
pub enum BashParseError {
    #[error("{0}")]
    Message(String),
}

fn known_alias_hint(key: &str) -> Option<&'static str> {
    match key {
        "cmd" => Some("unknown parameter 'cmd'. Use 'command' instead."),
        "shell_command" => Some("unknown parameter 'shell_command'. Use 'command' instead."),
        "script" => Some("unknown parameter 'script'. Use 'command' instead."),
        "run" => Some("unknown parameter 'run'. Use 'command' instead."),
        "directory" => Some("unknown parameter 'directory'. Use 'cwd' instead."),
        "dir" => Some("unknown parameter 'dir'. Use 'cwd' instead."),
        "path" => Some("unknown parameter 'path'. Use 'cwd' instead."),
        "working_directory" => Some("unknown parameter 'working_directory'. Use 'cwd' instead."),
        "timeout" => Some(
            "unknown parameter 'timeout'. Use 'timeout_ms' instead (milliseconds, not seconds). For 30s pass timeout_ms: 30000.",
        ),
        "time_limit" => Some("unknown parameter 'time_limit'. Use 'timeout_ms' instead (milliseconds)."),
        "timeout_seconds" => Some("unknown parameter 'timeout_seconds'. Use 'timeout_ms' instead (multiply by 1000)."),
        "env_vars" => Some("unknown parameter 'env_vars'. Use 'env' instead."),
        "environment" => Some("unknown parameter 'environment'. Use 'env' instead."),
        "lang" => Some(
            "unknown parameter 'lang'. Bash runs shell commands; invoke other languages via the command itself (e.g. 'python -c \"...\"', 'node -e \"...\"').",
        ),
        "language" => Some(
            "unknown parameter 'language'. Invoke other languages via the command (e.g. 'python -c \"...\"', 'node -e \"...\"').",
        ),
        "interpreter" => Some(
            "unknown parameter 'interpreter'. Invoke the interpreter inside the command itself (e.g. 'python -c \"...\"').",
        ),
        "runtime" => Some(
            "unknown parameter 'runtime'. Invoke the runtime inside the command itself (e.g. 'node -e \"...\"').",
        ),
        "stdin" => Some(
            "unknown parameter 'stdin'. Interactive stdin is not supported in v1. Pipe data into the command instead (e.g. 'echo \"y\" | npm init').",
        ),
        "input" => Some(
            "unknown parameter 'input'. Interactive input is not supported in v1. Make the command non-interactive with flags like --yes.",
        ),
        "sandbox" => Some("unknown parameter 'sandbox'. Sandboxing is configured on the session, not per-call."),
        "sandbox_mode" => Some("unknown parameter 'sandbox_mode'. Sandboxing is configured on the session, not per-call."),
        "permissions" => Some("unknown parameter 'permissions'. The permission hook is configured on the session."),
        "network" => Some("unknown parameter 'network'. Network access is configured on the session / executor adapter."),
        "network_access" => Some("unknown parameter 'network_access'. Network access is configured on the session / executor adapter."),
        "shell" => Some("unknown parameter 'shell'. Shell binary is configured on the session."),
        "shell_binary" => Some("unknown parameter 'shell_binary'. Shell binary is configured on the session."),
        _ => None,
    }
}

fn canonical_bash_fields() -> HashSet<&'static str> {
    [
        "command",
        "cwd",
        "timeout_ms",
        "description",
        "background",
        "env",
    ]
    .into_iter()
    .collect()
}

pub fn safe_parse_bash_params(input: &Value) -> Result<BashParams, BashParseError> {
    if let Some(obj) = input.as_object() {
        let canonical = canonical_bash_fields();
        let mut alias_hints: Vec<String> = Vec::new();
        let mut unknown: Vec<String> = Vec::new();
        for key in obj.keys() {
            if canonical.contains(key.as_str()) {
                continue;
            }
            if let Some(hint) = known_alias_hint(key.as_str()) {
                alias_hints.push(hint.to_string());
            } else {
                unknown.push(format!("unknown parameter '{}'.", key));
            }
        }
        if !alias_hints.is_empty() || !unknown.is_empty() {
            let mut msgs = alias_hints;
            msgs.extend(unknown);
            return Err(BashParseError::Message(msgs.join("; ")));
        }
    }

    let parsed: BashParams = serde_json::from_value(input.clone())
        .map_err(|e| BashParseError::Message(e.to_string()))?;

    if parsed.command.trim().is_empty() {
        return Err(BashParseError::Message("command is required".to_string()));
    }
    if parsed.command.len() > MAX_COMMAND_LENGTH {
        return Err(BashParseError::Message(format!(
            "command exceeds {} bytes",
            MAX_COMMAND_LENGTH
        )));
    }
    if let Some(ms) = parsed.timeout_ms {
        if ms < 100 {
            return Err(BashParseError::Message(
                "timeout_ms must be >= 100 ms".to_string(),
            ));
        }
    }
    Ok(parsed)
}

pub fn safe_parse_bash_output_params(
    input: &Value,
) -> Result<BashOutputParams, BashParseError> {
    let parsed: BashOutputParams = serde_json::from_value(input.clone())
        .map_err(|e| BashParseError::Message(e.to_string()))?;
    if parsed.job_id.is_empty() {
        return Err(BashParseError::Message("job_id is required".to_string()));
    }
    Ok(parsed)
}

pub fn safe_parse_bash_kill_params(input: &Value) -> Result<BashKillParams, BashParseError> {
    let parsed: BashKillParams = serde_json::from_value(input.clone())
        .map_err(|e| BashParseError::Message(e.to_string()))?;
    if parsed.job_id.is_empty() {
        return Err(BashParseError::Message("job_id is required".to_string()));
    }
    if let Some(ref sig) = parsed.signal {
        if sig != "SIGTERM" && sig != "SIGKILL" {
            return Err(BashParseError::Message(
                "signal must be 'SIGTERM' or 'SIGKILL'".to_string(),
            ));
        }
    }
    Ok(parsed)
}

pub const BASH_TOOL_NAME: &str = "bash";
pub const BASH_TOOL_DESCRIPTION: &str = "Run a single shell command in a bash subprocess. Output is captured and returned with the exit code. See design/bash.md for the full contract.";

pub const BASH_OUTPUT_TOOL_NAME: &str = "bash_output";
pub const BASH_OUTPUT_TOOL_DESCRIPTION: &str = "Poll a backgrounded bash job's output since a given byte offset.";

pub const BASH_KILL_TOOL_NAME: &str = "bash_kill";
pub const BASH_KILL_TOOL_DESCRIPTION: &str = "Send a termination signal to a backgrounded bash job.";