1 line
170 KiB
JSON
1 line
170 KiB
JSON
{"repo": "alextes/ava", "files": {"src/tool/exec.rs": {"e4b6c135111e30ea53e2636b623f412ab6058cd9": {"t": 1785745839.7652164, "n": 14126, "h": "7eda138340033aa3", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\npub(super) struct ProcessGroupGuard {\n pgid: Option<u32>,\n}\n\nimpl ProcessGroupGuard {\n pub(super) fn new(pgid: Option<u32>) -> Self {\n Self { pgid }\n }\n\n pub(super) fn disarm(&mut self) {\n self.pgid = None;\n }\n}\n\nimpl Drop for ProcessGroupGuard {\n fn drop(&mut self) {\n if let Some(pgid) = self.pgid {\n // the shell starts in its own process group, so a negative pid\n // terminates the shell and every descendant it spawned.\n unsafe {\n libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);\n }\n }\n }\n}\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\n \"ANTHROPIC_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"GEMINI_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OPENAI_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n ];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\")\n .arg(command)\n .stdout(std::process::Stdio::piped())\n .stderr(std::process::Stdio::piped())\n // start in its own process group so we can kill the whole tree\n .process_group(0);\n if let Some(dir) = cwd {\n let path = std::path::Path::new(dir);\n if !path.is_dir() {\n return format!(\n \"error: cwd '{dir}' does not exist on this host. \\\n use \\\".\\\" for the current working directory or provide a valid path.\"\n );\n }\n cmd.current_dir(dir);\n }\n\n let child = match cmd.spawn() {\n Ok(c) => c,\n Err(e) => return format!(\"failed to execute command: {e}\"),\n };\n\n // save the process group id before wait_with_output consumes the child\n let mut process_group = ProcessGroupGuard::new(child.id());\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n child.wait_with_output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n process_group.disarm();\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $DEEPSEEK_API_KEY\"));\n assert!(references_sensitive_env(\"echo $GEMINI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $NVIDIA_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENAI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENROUTER_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[cfg(unix)]\n #[tokio::test]\n async fn test_cancelled_command_kills_process_group() {\n let temp = tempfile::tempdir().unwrap();\n let pid_path = temp.path().join(\"child.pid\");\n let command = format!(\"sleep 30 & echo $! > {}; wait\", pid_path.display());\n let (abort_handle, registration) = futures::future::AbortHandle::new_pair();\n let task = tokio::spawn(async move {\n futures::future::Abortable::new(execute_command(&command, Some(30), None), registration)\n .await\n });\n\n for _ in 0..100 {\n if pid_path.exists() {\n break;\n }\n tokio::time::sleep(std::time::Duration::from_millis(10)).await;\n }\n let pid: libc::pid_t = std::fs::read_to_string(&pid_path)\n .expect(\"child pid file should be created\")\n .trim()\n .parse()\n .unwrap();\n\n abort_handle.abort();\n assert!(task.await.unwrap().is_err());\n\n for _ in 0..100 {\n let alive = unsafe { libc::kill(pid, 0) } == 0;\n if !alive {\n return;\n }\n tokio::time::sleep(std::time::Duration::from_millis(10)).await;\n }\n panic!(\"child process {pid} survived cancellation\");\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"does not exist on this host\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "058da91d1836ae47f0dfbdb33e8db8a0d099733b": {"t": 1785757090.469488, "n": 14126, "h": "7eda138340033aa3", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\npub(super) struct ProcessGroupGuard {\n pgid: Option<u32>,\n}\n\nimpl ProcessGroupGuard {\n pub(super) fn new(pgid: Option<u32>) -> Self {\n Self { pgid }\n }\n\n pub(super) fn disarm(&mut self) {\n self.pgid = None;\n }\n}\n\nimpl Drop for ProcessGroupGuard {\n fn drop(&mut self) {\n if let Some(pgid) = self.pgid {\n // the shell starts in its own process group, so a negative pid\n // terminates the shell and every descendant it spawned.\n unsafe {\n libc::kill(-(pgid as libc::pid_t), libc::SIGKILL);\n }\n }\n }\n}\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\n \"ANTHROPIC_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"GEMINI_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OPENAI_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n ];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\")\n .arg(command)\n .stdout(std::process::Stdio::piped())\n .stderr(std::process::Stdio::piped())\n // start in its own process group so we can kill the whole tree\n .process_group(0);\n if let Some(dir) = cwd {\n let path = std::path::Path::new(dir);\n if !path.is_dir() {\n return format!(\n \"error: cwd '{dir}' does not exist on this host. \\\n use \\\".\\\" for the current working directory or provide a valid path.\"\n );\n }\n cmd.current_dir(dir);\n }\n\n let child = match cmd.spawn() {\n Ok(c) => c,\n Err(e) => return format!(\"failed to execute command: {e}\"),\n };\n\n // save the process group id before wait_with_output consumes the child\n let mut process_group = ProcessGroupGuard::new(child.id());\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n child.wait_with_output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n process_group.disarm();\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $DEEPSEEK_API_KEY\"));\n assert!(references_sensitive_env(\"echo $GEMINI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $NVIDIA_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENAI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENROUTER_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[cfg(unix)]\n #[tokio::test]\n async fn test_cancelled_command_kills_process_group() {\n let temp = tempfile::tempdir().unwrap();\n let pid_path = temp.path().join(\"child.pid\");\n let command = format!(\"sleep 30 & echo $! > {}; wait\", pid_path.display());\n let (abort_handle, registration) = futures::future::AbortHandle::new_pair();\n let task = tokio::spawn(async move {\n futures::future::Abortable::new(execute_command(&command, Some(30), None), registration)\n .await\n });\n\n for _ in 0..100 {\n if pid_path.exists() {\n break;\n }\n tokio::time::sleep(std::time::Duration::from_millis(10)).await;\n }\n let pid: libc::pid_t = std::fs::read_to_string(&pid_path)\n .expect(\"child pid file should be created\")\n .trim()\n .parse()\n .unwrap();\n\n abort_handle.abort();\n assert!(task.await.unwrap().is_err());\n\n for _ in 0..100 {\n let alive = unsafe { libc::kill(pid, 0) } == 0;\n if !alive {\n return;\n }\n tokio::time::sleep(std::time::Duration::from_millis(10)).await;\n }\n panic!(\"child process {pid} survived cancellation\");\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"does not exist on this host\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "393e72dd346430bfbd650e4fd3b96c62443f286d": {"t": 1785757092.177137, "n": 12415, "h": "d9e752189eeda079", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\n \"ANTHROPIC_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"GEMINI_API_KEY\",\n \"NVIDIA_API_KEY\",\n \"OPENAI_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n ];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\")\n .arg(command)\n .stdout(std::process::Stdio::piped())\n .stderr(std::process::Stdio::piped())\n // start in its own process group so we can kill the whole tree\n .process_group(0);\n if let Some(dir) = cwd {\n let path = std::path::Path::new(dir);\n if !path.is_dir() {\n return format!(\n \"error: cwd '{dir}' does not exist on this host. \\\n use \\\".\\\" for the current working directory or provide a valid path.\"\n );\n }\n cmd.current_dir(dir);\n }\n\n let child = match cmd.spawn() {\n Ok(c) => c,\n Err(e) => return format!(\"failed to execute command: {e}\"),\n };\n\n // save the process group id before wait_with_output consumes the child\n let pgid = child.id();\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n child.wait_with_output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => {\n // kill the entire process group to clean up child processes silently\n if let Some(pid) = pgid {\n unsafe {\n libc::kill(-(pid as libc::pid_t), libc::SIGKILL);\n }\n }\n format!(\"command timed out after {timeout}s\")\n }\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $DEEPSEEK_API_KEY\"));\n assert!(references_sensitive_env(\"echo $GEMINI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $NVIDIA_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENAI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENROUTER_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"does not exist on this host\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "671b7f812835e3a0fd4ac7a35c928957e1fe6f3e": {"t": 1785757094.2722046, "n": 12322, "h": "3196703a8bb5c1c8", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\n \"ANTHROPIC_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"GEMINI_API_KEY\",\n \"OPENAI_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n ];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\")\n .arg(command)\n .stdout(std::process::Stdio::piped())\n .stderr(std::process::Stdio::piped())\n // start in its own process group so we can kill the whole tree\n .process_group(0);\n if let Some(dir) = cwd {\n let path = std::path::Path::new(dir);\n if !path.is_dir() {\n return format!(\n \"error: cwd '{dir}' does not exist on this host. \\\n use \\\".\\\" for the current working directory or provide a valid path.\"\n );\n }\n cmd.current_dir(dir);\n }\n\n let child = match cmd.spawn() {\n Ok(c) => c,\n Err(e) => return format!(\"failed to execute command: {e}\"),\n };\n\n // save the process group id before wait_with_output consumes the child\n let pgid = child.id();\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n child.wait_with_output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => {\n // kill the entire process group to clean up child processes silently\n if let Some(pid) = pgid {\n unsafe {\n libc::kill(-(pid as libc::pid_t), libc::SIGKILL);\n }\n }\n format!(\"command timed out after {timeout}s\")\n }\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $DEEPSEEK_API_KEY\"));\n assert!(references_sensitive_env(\"echo $GEMINI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENAI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENROUTER_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"does not exist on this host\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "104a98ca835b5126da905677b767e9ec556cb910": {"t": 1785757096.0354598, "n": 12225, "h": "a80f191700a87753", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\n \"ANTHROPIC_API_KEY\",\n \"GEMINI_API_KEY\",\n \"OPENAI_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"TELEGRAM_BOT_TOKEN\",\n ];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\")\n .arg(command)\n .stdout(std::process::Stdio::piped())\n .stderr(std::process::Stdio::piped())\n // start in its own process group so we can kill the whole tree\n .process_group(0);\n if let Some(dir) = cwd {\n let path = std::path::Path::new(dir);\n if !path.is_dir() {\n return format!(\n \"error: cwd '{dir}' does not exist on this host. \\\n use \\\".\\\" for the current working directory or provide a valid path.\"\n );\n }\n cmd.current_dir(dir);\n }\n\n let child = match cmd.spawn() {\n Ok(c) => c,\n Err(e) => return format!(\"failed to execute command: {e}\"),\n };\n\n // save the process group id before wait_with_output consumes the child\n let pgid = child.id();\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n child.wait_with_output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => {\n // kill the entire process group to clean up child processes silently\n if let Some(pid) = pgid {\n unsafe {\n libc::kill(-(pid as libc::pid_t), libc::SIGKILL);\n }\n }\n format!(\"command timed out after {timeout}s\")\n }\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $GEMINI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENAI_API_KEY\"));\n assert!(references_sensitive_env(\"echo $OPENROUTER_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"does not exist on this host\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "938fa914a8b24881b117dc92ed6c8eae3b15b5b4": {"t": 1785757097.6433163, "n": 11915, "h": "8a445ac5a0438bd1", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELEGRAM_BOT_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\")\n .arg(command)\n .stdout(std::process::Stdio::piped())\n .stderr(std::process::Stdio::piped())\n // start in its own process group so we can kill the whole tree\n .process_group(0);\n if let Some(dir) = cwd {\n let path = std::path::Path::new(dir);\n if !path.is_dir() {\n return format!(\n \"error: cwd '{dir}' does not exist on this host. \\\n use \\\".\\\" for the current working directory or provide a valid path.\"\n );\n }\n cmd.current_dir(dir);\n }\n\n let child = match cmd.spawn() {\n Ok(c) => c,\n Err(e) => return format!(\"failed to execute command: {e}\"),\n };\n\n // save the process group id before wait_with_output consumes the child\n let pgid = child.id();\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n child.wait_with_output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => {\n // kill the entire process group to clean up child processes silently\n if let Some(pid) = pgid {\n unsafe {\n libc::kill(-(pid as libc::pid_t), libc::SIGKILL);\n }\n }\n format!(\"command timed out after {timeout}s\")\n }\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"does not exist on this host\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n let _guard = crate::config::ENV_TEST_LOCK.lock().unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "bd8e34b44f636b2bab7edc69d07818b0b331da5a": {"t": 1785757099.2044873, "n": 11779, "h": "0a3527ab2e4718d1", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELEGRAM_BOT_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\")\n .arg(command)\n .stdout(std::process::Stdio::piped())\n .stderr(std::process::Stdio::piped())\n // start in its own process group so we can kill the whole tree\n .process_group(0);\n if let Some(dir) = cwd {\n let path = std::path::Path::new(dir);\n if !path.is_dir() {\n return format!(\n \"error: cwd '{dir}' does not exist on this host. \\\n use \\\".\\\" for the current working directory or provide a valid path.\"\n );\n }\n cmd.current_dir(dir);\n }\n\n let child = match cmd.spawn() {\n Ok(c) => c,\n Err(e) => return format!(\"failed to execute command: {e}\"),\n };\n\n // save the process group id before wait_with_output consumes the child\n let pgid = child.id();\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n child.wait_with_output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => {\n // kill the entire process group to clean up child processes silently\n if let Some(pid) = pgid {\n unsafe {\n libc::kill(-(pid as libc::pid_t), libc::SIGKILL);\n }\n }\n format!(\"command timed out after {timeout}s\")\n }\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"does not exist on this host\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "aa7279303c5b6b83e1cc19a6c34a75a59f642e5d": {"t": 1785757100.4590979, "n": 11496, "h": "6c084cd2a9df5c81", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELEGRAM_BOT_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\")\n .arg(command)\n .stdout(std::process::Stdio::piped())\n .stderr(std::process::Stdio::piped())\n // start in its own process group so we can kill the whole tree\n .process_group(0);\n if let Some(dir) = cwd {\n cmd.current_dir(dir);\n }\n\n let child = match cmd.spawn() {\n Ok(c) => c,\n Err(e) => return format!(\"failed to execute command: {e}\"),\n };\n\n // save the process group id before wait_with_output consumes the child\n let pgid = child.id();\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n child.wait_with_output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => {\n // kill the entire process group to clean up child processes silently\n if let Some(pid) = pgid {\n unsafe {\n libc::kill(-(pid as libc::pid_t), libc::SIGKILL);\n }\n }\n format!(\"command timed out after {timeout}s\")\n }\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"failed to execute command\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "20573d909e98e9e48e341a5c53bc9dd075632ec2": {"t": 1785757103.1451743, "n": 10751, "h": "2d7ca2440deca919", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELEGRAM_BOT_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\").arg(command);\n if let Some(dir) = cwd {\n cmd.current_dir(dir);\n }\n\n let result = tokio::time::timeout(std::time::Duration::from_secs(timeout), cmd.output()).await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"failed to execute command\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n}\n"}, "560e2711289a487246f98b631be84fdf05232232": {"t": 1785757104.8410783, "n": 18581, "h": "cf9fb77146267298", "c": "use std::collections::HashMap;\n\nuse serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELEGRAM_BOT_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\").arg(command);\n if let Some(dir) = cwd {\n cmd.current_dir(dir);\n }\n\n let result = tokio::time::timeout(std::time::Duration::from_secs(timeout), cmd.output()).await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// resolve a vault:// secret source by reading from ~/.ava/vault/<name>.\n/// returns the secret value or an error message.\npub(crate) fn resolve_vault_secret(source: &str) -> Result<String, String> {\n let name = source\n .strip_prefix(\"vault://\")\n .ok_or_else(|| format!(\"not a vault source: {source}\"))?;\n\n let path = crate::config::vault_dir().join(name);\n std::fs::read_to_string(&path)\n .map(|s| s.trim().to_string())\n .map_err(|e| format!(\"failed to read vault secret '{}': {e}\", path.display()))\n}\n\n/// load all vault file contents into memory for output scrubbing.\n/// reads every file in ~/.ava/vault/, returns trimmed non-empty contents.\npub(crate) fn load_vault_secrets() -> Vec<String> {\n let vault = crate::config::vault_dir();\n let entries = match std::fs::read_dir(&vault) {\n Ok(e) => e,\n Err(_) => return Vec::new(),\n };\n\n let mut secrets = Vec::new();\n for entry in entries.flatten() {\n let path = entry.path();\n if !path.is_file() {\n continue;\n }\n match std::fs::read_to_string(&path) {\n Ok(content) => {\n let trimmed = content.trim().to_string();\n if !trimmed.is_empty() {\n secrets.push(trimmed);\n }\n }\n Err(e) => {\n tracing::warn!(path = %path.display(), %e, \"failed to read vault file\");\n }\n }\n }\n secrets\n}\n\n/// replace all occurrences of vault secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_vault_secrets(text: &str, secrets: &[String]) -> String {\n let mut result = text.to_string();\n for value in secrets {\n if value.is_empty() {\n continue;\n }\n result = result.replace(value.as_str(), \"[REDACTED]\");\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n/// replace all occurrences of secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_secrets(text: &str, secrets: &HashMap<String, String>) -> String {\n let mut result = text.to_string();\n for value in secrets.values() {\n if value.is_empty() {\n continue;\n }\n // scrub the full value\n result = result.replace(value.as_str(), \"[REDACTED]\");\n // also scrub individual lines for multi-line secrets (e.g. PEM keys)\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n/// execute a command with secrets injected as env vars.\n/// secret values are scrubbed from the output before returning.\n/// the agent never sees the raw secret values.\npub(crate) async fn sealed_exec(\n command: &str,\n secrets: &HashMap<String, String>,\n timeout_secs: Option<u64>,\n cwd: Option<&str>,\n) -> String {\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(\n command,\n timeout,\n ?cwd,\n secret_count = secrets.len(),\n \"sealed execution\"\n );\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\").arg(command);\n if let Some(dir) = cwd {\n cmd.current_dir(dir);\n }\n\n // inject secrets as env vars — only for this command's process\n for (name, value) in secrets {\n cmd.env(name, value);\n }\n\n let result = tokio::time::timeout(std::time::Duration::from_secs(timeout), cmd.output()).await;\n\n let raw_output = match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n result\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n };\n\n // scrub secrets from output before it reaches the agent\n let scrubbed = scrub_secrets(&raw_output, secrets);\n truncate_output(&scrubbed)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"failed to execute command\"));\n }\n\n // --- load_vault_secrets tests ---\n\n #[test]\n fn test_load_vault_secrets_from_dir() {\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"db-url\"), \"postgres://admin:secret@db\\n\").unwrap();\n std::fs::write(vault.join(\"api-key\"), \"sk-abc123\\n\").unwrap();\n std::fs::write(vault.join(\"empty\"), \"\").unwrap();\n\n unsafe { std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap()) };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n\n assert_eq!(secrets.len(), 2);\n assert!(secrets.contains(&\"postgres://admin:secret@db\".to_string()));\n assert!(secrets.contains(&\"sk-abc123\".to_string()));\n }\n\n #[test]\n fn test_load_vault_secrets_no_dir() {\n unsafe { std::env::set_var(\"AVA_HOME\", \"/nonexistent_ava_test_xyz\") };\n let secrets = load_vault_secrets();\n unsafe { std::env::remove_var(\"AVA_HOME\") };\n assert!(secrets.is_empty());\n }\n\n // --- scrub_vault_secrets tests ---\n\n #[test]\n fn test_scrub_vault_secrets_basic() {\n let secrets = vec![\"postgres://admin:s3cret@db:5432\".to_string()];\n let output = \"connected to postgres://admin:s3cret@db:5432 ok\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] ok\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiline() {\n let secrets = vec![\"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".to_string()];\n let output = \"key line: MIIBogIBAAJBALK\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert_eq!(scrubbed, \"key line: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_vault_secrets_multiple() {\n let secrets = vec![\"secretA123\".to_string(), \"secretB456\".to_string()];\n let output = \"found secretA123 and secretB456\";\n let scrubbed = scrub_vault_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"secretA123\"));\n assert!(!scrubbed.contains(\"secretB456\"));\n }\n\n #[test]\n fn test_scrub_vault_secrets_empty_list() {\n let output = \"nothing to scrub\";\n assert_eq!(scrub_vault_secrets(output, &[]), \"nothing to scrub\");\n }\n\n // --- scrub_secrets tests ---\n\n #[test]\n fn test_scrub_secrets_basic() {\n let mut secrets = HashMap::new();\n secrets.insert(\"DB_URL\".into(), \"postgres://admin:s3cret@db:5432\".into());\n\n let output = \"connected to postgres://admin:s3cret@db:5432 successfully\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] successfully\");\n assert!(!scrubbed.contains(\"s3cret\"));\n }\n\n #[test]\n fn test_scrub_secrets_multiline() {\n let mut secrets = HashMap::new();\n secrets.insert(\n \"KEY\".into(),\n \"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".into(),\n );\n\n let output = \"loaded key: MIIBogIBAAJBALK\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert_eq!(scrubbed, \"loaded key: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_secrets_multiple() {\n let mut secrets = HashMap::new();\n secrets.insert(\"USER\".into(), \"admin\".into());\n secrets.insert(\"PASS\".into(), \"hunter2\".into());\n\n let output = \"login: admin / hunter2\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"admin\"));\n assert!(!scrubbed.contains(\"hunter2\"));\n }\n\n #[test]\n fn test_scrub_secrets_empty_value_skipped() {\n let mut secrets = HashMap::new();\n secrets.insert(\"EMPTY\".into(), \"\".into());\n\n let output = \"nothing to scrub\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert_eq!(scrubbed, \"nothing to scrub\");\n }\n\n #[test]\n fn test_scrub_secrets_short_lines_skipped() {\n let mut secrets = HashMap::new();\n secrets.insert(\"KEY\".into(), \"line1\\nab\\nlong_enough_line\".into());\n\n // \"ab\" is too short (< 8 chars) to scrub individually, avoids false positives\n let output = \"found: ab and long_enough_line\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert!(scrubbed.contains(\"ab\"));\n assert!(!scrubbed.contains(\"long_enough_line\"));\n }\n\n // --- sealed_exec tests ---\n\n #[tokio::test]\n async fn test_sealed_exec_injects_env() {\n let mut secrets = HashMap::new();\n secrets.insert(\"MY_SECRET\".into(), \"supersecret123\".into());\n\n let result = sealed_exec(\"echo $MY_SECRET\", &secrets, None, None).await;\n // the output should have the secret scrubbed\n assert!(result.contains(\"[REDACTED]\"));\n assert!(!result.contains(\"supersecret123\"));\n }\n\n #[tokio::test]\n async fn test_sealed_exec_scrubs_stderr() {\n let mut secrets = HashMap::new();\n secrets.insert(\"TOKEN\".into(), \"tok_abc123xyz\".into());\n\n let result = sealed_exec(\"echo tok_abc123xyz >&2\", &secrets, None, None).await;\n assert!(result.contains(\"[REDACTED]\"));\n assert!(!result.contains(\"tok_abc123xyz\"));\n }\n\n #[tokio::test]\n async fn test_sealed_exec_no_secrets_works_normally() {\n let secrets = HashMap::new();\n let result = sealed_exec(\"echo hello\", &secrets, None, None).await;\n assert!(result.contains(\"hello\"));\n assert!(result.contains(\"exit code: 0\"));\n }\n\n // --- resolve_vault_secret tests ---\n\n #[test]\n fn test_resolve_vault_secret_not_vault_source() {\n let result = resolve_vault_secret(\"op://Private/key\");\n assert!(result.is_err());\n assert!(result.unwrap_err().contains(\"not a vault source\"));\n }\n\n #[test]\n fn test_resolve_vault_secret_missing_file() {\n let result = resolve_vault_secret(\"vault://nonexistent-secret-xyz\");\n assert!(result.is_err());\n assert!(result.unwrap_err().contains(\"failed to read\"));\n }\n\n #[test]\n fn test_resolve_vault_secret_reads_file() {\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"test-secret\"), \"my-secret-value\\n\").unwrap();\n\n // temporarily override AVA_HOME\n // SAFETY: test-only, single-threaded context\n unsafe {\n std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap());\n }\n\n let result = resolve_vault_secret(\"vault://test-secret\");\n assert_eq!(result.unwrap(), \"my-secret-value\");\n\n unsafe {\n std::env::remove_var(\"AVA_HOME\");\n }\n }\n}\n"}, "3b0ad7e94eb7d51410e109af22c0748e73a22087": {"t": 1785757106.3742173, "n": 14692, "h": "f433b429d6e5b422", "c": "use std::collections::HashMap;\n\nuse serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELEGRAM_BOT_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\").arg(command);\n if let Some(dir) = cwd {\n cmd.current_dir(dir);\n }\n\n let result = tokio::time::timeout(std::time::Duration::from_secs(timeout), cmd.output()).await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n/// resolve a vault:// secret source by reading from ~/.ava/vault/<name>.\n/// returns the secret value or an error message.\npub(crate) fn resolve_vault_secret(source: &str) -> Result<String, String> {\n let name = source\n .strip_prefix(\"vault://\")\n .ok_or_else(|| format!(\"not a vault source: {source}\"))?;\n\n let path = crate::config::vault_dir().join(name);\n std::fs::read_to_string(&path)\n .map(|s| s.trim().to_string())\n .map_err(|e| format!(\"failed to read vault secret '{}': {e}\", path.display()))\n}\n\n/// replace all occurrences of secret values in text with [REDACTED].\n/// handles multi-line secrets by scrubbing each line independently.\npub(crate) fn scrub_secrets(text: &str, secrets: &HashMap<String, String>) -> String {\n let mut result = text.to_string();\n for value in secrets.values() {\n if value.is_empty() {\n continue;\n }\n // scrub the full value\n result = result.replace(value.as_str(), \"[REDACTED]\");\n // also scrub individual lines for multi-line secrets (e.g. PEM keys)\n for line in value.lines() {\n let line = line.trim();\n if line.len() >= 8 {\n result = result.replace(line, \"[REDACTED]\");\n }\n }\n }\n result\n}\n\n/// execute a command with secrets injected as env vars.\n/// secret values are scrubbed from the output before returning.\n/// the agent never sees the raw secret values.\npub(crate) async fn sealed_exec(\n command: &str,\n secrets: &HashMap<String, String>,\n timeout_secs: Option<u64>,\n cwd: Option<&str>,\n) -> String {\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(\n command,\n timeout,\n ?cwd,\n secret_count = secrets.len(),\n \"sealed execution\"\n );\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\").arg(command);\n if let Some(dir) = cwd {\n cmd.current_dir(dir);\n }\n\n // inject secrets as env vars — only for this command's process\n for (name, value) in secrets {\n cmd.env(name, value);\n }\n\n let result = tokio::time::timeout(std::time::Duration::from_secs(timeout), cmd.output()).await;\n\n let raw_output = match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n result\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n };\n\n // scrub secrets from output before it reaches the agent\n let scrubbed = scrub_secrets(&raw_output, secrets);\n truncate_output(&scrubbed)\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"failed to execute command\"));\n }\n\n // --- scrub_secrets tests ---\n\n #[test]\n fn test_scrub_secrets_basic() {\n let mut secrets = HashMap::new();\n secrets.insert(\"DB_URL\".into(), \"postgres://admin:s3cret@db:5432\".into());\n\n let output = \"connected to postgres://admin:s3cret@db:5432 successfully\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert_eq!(scrubbed, \"connected to [REDACTED] successfully\");\n assert!(!scrubbed.contains(\"s3cret\"));\n }\n\n #[test]\n fn test_scrub_secrets_multiline() {\n let mut secrets = HashMap::new();\n secrets.insert(\n \"KEY\".into(),\n \"-----BEGIN KEY-----\\nMIIBogIBAAJBALK\\n-----END KEY-----\".into(),\n );\n\n let output = \"loaded key: MIIBogIBAAJBALK\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert_eq!(scrubbed, \"loaded key: [REDACTED]\");\n }\n\n #[test]\n fn test_scrub_secrets_multiple() {\n let mut secrets = HashMap::new();\n secrets.insert(\"USER\".into(), \"admin\".into());\n secrets.insert(\"PASS\".into(), \"hunter2\".into());\n\n let output = \"login: admin / hunter2\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert!(!scrubbed.contains(\"admin\"));\n assert!(!scrubbed.contains(\"hunter2\"));\n }\n\n #[test]\n fn test_scrub_secrets_empty_value_skipped() {\n let mut secrets = HashMap::new();\n secrets.insert(\"EMPTY\".into(), \"\".into());\n\n let output = \"nothing to scrub\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert_eq!(scrubbed, \"nothing to scrub\");\n }\n\n #[test]\n fn test_scrub_secrets_short_lines_skipped() {\n let mut secrets = HashMap::new();\n secrets.insert(\"KEY\".into(), \"line1\\nab\\nlong_enough_line\".into());\n\n // \"ab\" is too short (< 8 chars) to scrub individually, avoids false positives\n let output = \"found: ab and long_enough_line\";\n let scrubbed = scrub_secrets(output, &secrets);\n assert!(scrubbed.contains(\"ab\"));\n assert!(!scrubbed.contains(\"long_enough_line\"));\n }\n\n // --- sealed_exec tests ---\n\n #[tokio::test]\n async fn test_sealed_exec_injects_env() {\n let mut secrets = HashMap::new();\n secrets.insert(\"MY_SECRET\".into(), \"supersecret123\".into());\n\n let result = sealed_exec(\"echo $MY_SECRET\", &secrets, None, None).await;\n // the output should have the secret scrubbed\n assert!(result.contains(\"[REDACTED]\"));\n assert!(!result.contains(\"supersecret123\"));\n }\n\n #[tokio::test]\n async fn test_sealed_exec_scrubs_stderr() {\n let mut secrets = HashMap::new();\n secrets.insert(\"TOKEN\".into(), \"tok_abc123xyz\".into());\n\n let result = sealed_exec(\"echo tok_abc123xyz >&2\", &secrets, None, None).await;\n assert!(result.contains(\"[REDACTED]\"));\n assert!(!result.contains(\"tok_abc123xyz\"));\n }\n\n #[tokio::test]\n async fn test_sealed_exec_no_secrets_works_normally() {\n let secrets = HashMap::new();\n let result = sealed_exec(\"echo hello\", &secrets, None, None).await;\n assert!(result.contains(\"hello\"));\n assert!(result.contains(\"exit code: 0\"));\n }\n\n // --- resolve_vault_secret tests ---\n\n #[test]\n fn test_resolve_vault_secret_not_vault_source() {\n let result = resolve_vault_secret(\"op://Private/key\");\n assert!(result.is_err());\n assert!(result.unwrap_err().contains(\"not a vault source\"));\n }\n\n #[test]\n fn test_resolve_vault_secret_missing_file() {\n let result = resolve_vault_secret(\"vault://nonexistent-secret-xyz\");\n assert!(result.is_err());\n assert!(result.unwrap_err().contains(\"failed to read\"));\n }\n\n #[test]\n fn test_resolve_vault_secret_reads_file() {\n let dir = tempfile::tempdir().unwrap();\n let vault = dir.path().join(\"vault\");\n std::fs::create_dir(&vault).unwrap();\n std::fs::write(vault.join(\"test-secret\"), \"my-secret-value\\n\").unwrap();\n\n // temporarily override AVA_HOME\n // SAFETY: test-only, single-threaded context\n unsafe {\n std::env::set_var(\"AVA_HOME\", dir.path().to_str().unwrap());\n }\n\n let result = resolve_vault_secret(\"vault://test-secret\");\n assert_eq!(result.unwrap(), \"my-secret-value\");\n\n unsafe {\n std::env::remove_var(\"AVA_HOME\");\n }\n }\n}\n"}, "639d0ab11d8534bb3c88e16629fa07b2c94f2586": {"t": 1785757108.1380804, "n": 6862, "h": "26e88451ee68ba85", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELEGRAM_BOT_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\").arg(command);\n if let Some(dir) = cwd {\n cmd.current_dir(dir);\n }\n\n let result = tokio::time::timeout(std::time::Duration::from_secs(timeout), cmd.output()).await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELEGRAM_BOT_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"failed to execute command\"));\n }\n}\n"}, "849aee7ef198e76199d78014828d9142ac96f5cd": {"t": 1785757109.5667233, "n": 6854, "h": "c547b45ba7b69c8b", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELOXIDE_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n cwd: Option<String>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => {\n execute_command(&input.command, input.timeout_secs, input.cwd.as_deref()).await\n }\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>, cwd: Option<&str>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, ?cwd, \"executing command\");\n\n let mut cmd = tokio::process::Command::new(\"sh\");\n cmd.arg(\"-c\").arg(command);\n if let Some(dir) = cwd {\n cmd.current_dir(dir);\n }\n\n let result = tokio::time::timeout(std::time::Duration::from_secs(timeout), cmd.output()).await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n },\n \"cwd\": {\n \"type\": \"string\",\n \"description\": \"working directory for the command (default: process working directory)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELOXIDE_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None, None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1), None).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None, None).await;\n assert!(result.contains(\"blocked\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_cwd() {\n let result = execute_command(\"pwd\", None, Some(\"/tmp\")).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"/tmp\") || result.contains(\"/private/tmp\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_with_nonexistent_cwd() {\n let result = execute_command(\n \"echo hi\",\n None,\n Some(\"/nonexistent_dir_that_does_not_exist\"),\n )\n .await;\n assert!(result.contains(\"failed to execute command\"));\n }\n}\n"}, "cbe477c55e9094e8ac222f44664939aed817c27c": {"t": 1785757110.841156, "n": 5938, "h": "72d778658cd73c4d", "c": "use serde::Deserialize;\nuse serde_json::json;\n\nuse super::{ToolCall, ToolDefinition};\n\npub const EXEC_TOOL_NAME: &str = \"exec\";\n\nconst MAX_OUTPUT_CHARS: usize = 4000;\nconst DEFAULT_TIMEOUT_SECS: u64 = 30;\nconst MAX_TIMEOUT_SECS: u64 = 300;\n\nconst BLOCKED_PATTERNS: &[&str] = &[\n \"rm -rf /\",\n \"rm -rf /*\",\n \"mkfs\",\n \"dd if=\",\n \"> /dev/sd\",\n \":(){ :|:& };:\", // fork bomb\n \".fork\", // another fork bomb pattern\n];\n\n/// returns Some(reason) if the command is blocked by the safety filter\nfn check_safety_filter(command: &str) -> Option<&'static str> {\n let trimmed = command.trim();\n for pattern in BLOCKED_PATTERNS {\n if trimmed.contains(pattern) {\n return Some(\"command blocked: matches safety filter\");\n }\n }\n None\n}\n\n/// returns true if the command references sensitive env vars\npub fn references_sensitive_env(command: &str) -> bool {\n const SENSITIVE_VARS: &[&str] = &[\"ANTHROPIC_API_KEY\", \"TELOXIDE_TOKEN\"];\n SENSITIVE_VARS.iter().any(|var| command.contains(var))\n}\n\n#[derive(Debug, Deserialize)]\nstruct ExecInput {\n command: String,\n timeout_secs: Option<u64>,\n}\n\npub(super) async fn handle_exec(call: &ToolCall) -> String {\n match serde_json::from_value::<ExecInput>(call.input.clone()) {\n Ok(input) => execute_command(&input.command, input.timeout_secs).await,\n Err(err) => format!(\"invalid input: {err}\"),\n }\n}\n\nasync fn execute_command(command: &str, timeout_secs: Option<u64>) -> String {\n // safety filter\n if let Some(reason) = check_safety_filter(command) {\n return reason.to_string();\n }\n\n let timeout = timeout_secs\n .unwrap_or(DEFAULT_TIMEOUT_SECS)\n .min(MAX_TIMEOUT_SECS);\n\n tracing::info!(command, timeout, \"executing command\");\n\n let result = tokio::time::timeout(\n std::time::Duration::from_secs(timeout),\n tokio::process::Command::new(\"sh\")\n .arg(\"-c\")\n .arg(command)\n .output(),\n )\n .await;\n\n match result {\n Ok(Ok(output)) => {\n let stdout = String::from_utf8_lossy(&output.stdout);\n let stderr = String::from_utf8_lossy(&output.stderr);\n let code = output.status.code().unwrap_or(-1);\n\n let mut result = format!(\"exit code: {code}\");\n\n if !stdout.is_empty() {\n result.push_str(\"\\nstdout:\\n\");\n result.push_str(&stdout);\n }\n\n if !stderr.is_empty() {\n result.push_str(\"\\nstderr:\\n\");\n result.push_str(&stderr);\n }\n\n if stdout.is_empty() && stderr.is_empty() {\n result.push_str(\"\\n(no output)\");\n }\n\n truncate_output(&result)\n }\n Ok(Err(e)) => format!(\"failed to execute command: {e}\"),\n Err(_) => format!(\"command timed out after {timeout}s\"),\n }\n}\n\npub(crate) fn truncate_output(output: &str) -> String {\n if output.len() <= MAX_OUTPUT_CHARS {\n return output.to_string();\n }\n let mut truncated: String = output.chars().take(MAX_OUTPUT_CHARS).collect();\n truncated.push_str(\"\\n... (output truncated)\");\n truncated\n}\n\npub(super) fn exec_definition() -> ToolDefinition {\n ToolDefinition::Custom {\n name: EXEC_TOOL_NAME,\n description: \"execute a shell command via sh -c. use this to run commands on the host system. the user may need to approve the command before it runs.\",\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"command\": {\n \"type\": \"string\",\n \"description\": \"shell command to run via sh -c\"\n },\n \"timeout_secs\": {\n \"type\": \"integer\",\n \"description\": \"timeout in seconds (default 30, max 300)\"\n }\n },\n \"required\": [\"command\"]\n }),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_safety_filter_blocks_rm_rf_root() {\n assert!(check_safety_filter(\"rm -rf /\").is_some());\n assert!(check_safety_filter(\"rm -rf /*\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_fork_bomb() {\n assert!(check_safety_filter(\":(){ :|:& };:\").is_some());\n }\n\n #[test]\n fn test_safety_filter_blocks_mkfs() {\n assert!(check_safety_filter(\"mkfs.ext4 /dev/sda1\").is_some());\n }\n\n #[test]\n fn test_safety_filter_allows_normal_commands() {\n assert!(check_safety_filter(\"ls -la\").is_none());\n assert!(check_safety_filter(\"cargo test\").is_none());\n assert!(check_safety_filter(\"echo hello\").is_none());\n }\n\n #[test]\n fn test_references_sensitive_env() {\n assert!(references_sensitive_env(\"echo $ANTHROPIC_API_KEY\"));\n assert!(references_sensitive_env(\"echo $TELOXIDE_TOKEN\"));\n assert!(!references_sensitive_env(\"echo hello\"));\n }\n\n #[test]\n fn test_truncate_output_short() {\n let short = \"hello world\";\n assert_eq!(truncate_output(short), short);\n }\n\n #[test]\n fn test_truncate_output_long() {\n let long = \"x\".repeat(MAX_OUTPUT_CHARS + 100);\n let result = truncate_output(&long);\n assert!(result.len() < long.len());\n assert!(result.ends_with(\"... (output truncated)\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_ls() {\n let result = execute_command(\"echo hello\", None).await;\n assert!(result.contains(\"exit code: 0\"));\n assert!(result.contains(\"hello\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_timeout() {\n let result = execute_command(\"sleep 10\", Some(1)).await;\n assert!(result.contains(\"timed out\"));\n }\n\n #[tokio::test]\n async fn test_execute_command_safety_filter() {\n let result = execute_command(\"rm -rf /\", None).await;\n assert!(result.contains(\"blocked\"));\n }\n}\n"}}}} |