use std::process::Stdio; use async_trait::async_trait; use serde_json::Value; use tokio::io::AsyncReadExt; use crate::{Tool, ToolContext, ToolOutput, artifact::guess_mime_type}; const DEFAULT_TIMEOUT_MS: u64 = 120_000; const MAX_CAPTURE: usize = 1 << 20; const MAX_SCAN_ENTRIES: usize = 50_000; pub struct BashTool; #[async_trait] impl Tool for BashTool { fn name(&self) -> &str { "bash" } fn serves(&self) -> &'static [&'static str] { &["code-exec"] } fn description(&self) -> &str { "Execute any command, program, or script in the execution sandbox: run applications, execute code in any language, run shell pipelines, process data or media, install packages and tools, run tests, and debug processes. Commands run in the workspace, the same place `read` and `write` work; temporary files and tool caches go to `.vak/scratch/`. A command that never exits is killed at its timeout. Never create or change a Word, Excel, PowerPoint or Visio file with a command or script: office_apply is the only way to make or edit a Word, Excel or PowerPoint file, because its result reaches the person as a draft they review, and a Visio drawing cannot be made yet, so say that instead of building one." } fn schema(&self) -> Value { serde_json::json!({ "type": "object", "properties": { "command": {"type": "string", "description": "Shell command to execute"}, "timeout_ms": {"type": "integer", "minimum": 1000, "description": "Timeout in milliseconds (default 120000)"}, "cwd": {"type": "string", "description": "Folder to run in, relative to the workspace root (default: the workspace root)"} }, "required": ["command"] }) } fn claims(&self, _args: &Value) -> crate::ResourceClaims { crate::ResourceClaims { exclusive: true, read_only: false, paths: Vec::new(), } } async fn execute(&self, args: &Value, ctx: &ToolContext) -> ToolOutput { let Some(command) = args.get("command").and_then(|c| c.as_str()) else { return ToolOutput::error("missing required parameter: command"); }; if ctx.sandbox_sink.is_some() && references_control_file(command) { return ToolOutput::error( "sandbox denied access to workspace control files (.env and .vak/config.toml)", ); } let timeout_ms = args .get("timeout_ms") .and_then(|t| t.as_u64()) .unwrap_or(DEFAULT_TIMEOUT_MS) .max(1000); // The command works in the workspace, where `read`/`write`/`edit` // work, so what one tool writes the next can read. Running each // command in its own empty scratch folder made a file written here // invisible to `read`, and a small model looped rewriting it. Runtime // state (temp files, tool caches) still goes to scratch (invariant 35). let agent_id = ctx.agent_id.as_deref().unwrap_or("vak"); let scratch_root = ctx.cwd.join(".vak").join("scratch").join(agent_id); let temp_dir = ctx .sandbox_sink .as_ref() .map(|sink| scratch_root.join(sink.execution_id())) .unwrap_or_else(|| scratch_root.join("shell")) .join("tmp"); let cache_dir = scratch_root.join("cache"); let _ = std::fs::create_dir_all(&temp_dir); let _ = std::fs::create_dir_all(&cache_dir); let mut execution_dir = ctx.cwd.clone(); if let Some(custom_cwd) = args .get("cwd") .and_then(|v| v.as_str()) .map(str::trim) .filter(|cwd| !cwd.is_empty()) { let workspace = match ctx.cwd.canonicalize() { Ok(path) => path, Err(_) => return ToolOutput::error("working directory is unavailable"), }; let target = match workspace.join(custom_cwd).canonicalize() { Ok(path) => path, Err(_) => return ToolOutput::error("working directory does not exist"), }; if !target.starts_with(&workspace) || !target.is_dir() { return ToolOutput::error("working directory must remain inside the workspace"); } execution_dir = target; } // Command-scoped backends do not inherit the host process directory. // Carry the validated directory into the command before wrapping it. let execution_command = if ctx.sandbox.as_ref().is_some_and(|sandbox| { sandbox.target() == vak_sandbox::backend::SandboxTarget::ToolCommand }) && execution_dir != ctx.cwd { format!( "cd {} && ({command})", shell_quote(&execution_dir.display().to_string()) ) } else { command.to_string() }; let effective = match &ctx.sandbox { Some(sb) => sb.wrap(&execution_command), None => execution_command.clone(), }; let mut cmd = shell_command(&effective); cmd.current_dir(&execution_dir) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); scrub_environment(&mut cmd); cmd.env("TMPDIR", &temp_dir) .env("XDG_CACHE_HOME", &cache_dir) .env("PYTHONPYCACHEPREFIX", cache_dir.join("pycache")) .env("PIP_CACHE_DIR", cache_dir.join("pip")) .env("npm_config_cache", cache_dir.join("npm")); // Every execution gets its own process group, including broker workers. // Otherwise a shell child can outlive the timed-out worker and keep the // captured pipes open until the original command exits. isolate_process_group(&mut cmd); let before = collect_candidate_files(&ctx.cwd); let start_instant = std::time::Instant::now(); if let Some(ref sink) = ctx.sandbox_sink { sink.emit_execution_started( "bash", command, "bash", &execution_dir.display().to_string(), ); } let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => { let duration_ms = start_instant.elapsed().as_millis() as u64; if let Some(ref sink) = ctx.sandbox_sink { sink.emit_finished(-1, duration_ms, Vec::new()); } return ToolOutput::error(format!("spawn failed: {e}")); } }; let child_pid = child.id(); let telemetry_cancel = tokio_util::sync::CancellationToken::new(); let telemetry_token = telemetry_cancel.clone(); let telemetry_sink = ctx.sandbox_sink.clone(); let telemetry_handle = tokio::spawn(async move { let mut interval = tokio::time::interval(std::time::Duration::from_millis(500)); let t0 = std::time::Instant::now(); loop { tokio::select! { _ = telemetry_token.cancelled() => break, _ = interval.tick() => { if let Some(ref sink) = telemetry_sink { let elapsed = t0.elapsed().as_millis() as u64; if elapsed >= 400 { let rss = probe_process_memory(child_pid); sink.emit_telemetry(elapsed, 0.0, rss); } } } } } }); let mut stdout = child.stdout.take(); let mut stderr = child.stderr.take(); let sink_out = ctx.sandbox_sink.clone(); let sink_err = ctx.sandbox_sink.clone(); let out_fut = tokio::spawn(async move { match stdout.as_mut() { Some(r) => read_capped_streaming(r, sink_out, false).await, None => String::new(), } }); let err_fut = tokio::spawn(async move { match stderr.as_mut() { Some(r) => read_capped_streaming(r, sink_err, true).await, None => String::new(), } }); let timeout = tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)); let cancelled = ctx.cancel.cancelled(); tokio::select! { _ = timeout => { telemetry_cancel.cancel(); let _ = telemetry_handle.await; kill_process_group(&child.id()); let _ = child.wait().await; let out = out_fut.await.unwrap_or_default(); let err = err_fut.await.unwrap_or_default(); let duration_ms = start_instant.elapsed().as_millis() as u64; let mut paths = Vec::new(); if let Some(ref sink) = ctx.sandbox_sink { let artifacts = scan_new_candidate_artifacts(&before, &ctx.cwd); for (rel_path, mime, size) in artifacts { sink.emit_artifact(&rel_path, &mime, size); paths.push(rel_path); } sink.emit_finished(-1, duration_ms, paths.clone()); } let mut reason = format!("command timed out after {timeout_ms}ms"); if !paths.is_empty() { reason.push_str(&format!( ". {} file(s) were preserved before timeout: {}. \ Read and verify them before claiming completion; any foreground server was stopped.", paths.len(), paths.join(", ") )); } return ToolOutput::error(interrupted_output(&out, &err, &reason)); } _ = cancelled => { telemetry_cancel.cancel(); let _ = telemetry_handle.await; kill_process_group(&child.id()); let _ = child.wait().await; let out = out_fut.await.unwrap_or_default(); let err = err_fut.await.unwrap_or_default(); let duration_ms = start_instant.elapsed().as_millis() as u64; if let Some(ref sink) = ctx.sandbox_sink { let artifacts = scan_new_candidate_artifacts(&before, &ctx.cwd); let mut paths = Vec::new(); for (rel_path, mime, size) in artifacts { sink.emit_artifact(&rel_path, &mime, size); paths.push(rel_path); } sink.emit_finished(-1, duration_ms, paths); } return ToolOutput::error(interrupted_output(&out, &err, "command cancelled")); } status = child.wait() => { telemetry_cancel.cancel(); let _ = telemetry_handle.await; let status = match status { Ok(s) => s, Err(e) => { let duration_ms = start_instant.elapsed().as_millis() as u64; if let Some(ref sink) = ctx.sandbox_sink { sink.emit_finished(-1, duration_ms, Vec::new()); } return ToolOutput::error(format!("wait failed: {e}")); } }; let out = out_fut.await.unwrap_or_default(); let err = err_fut.await.unwrap_or_default(); let duration_ms = start_instant.elapsed().as_millis() as u64; let new_artifacts = scan_new_candidate_artifacts(&before, &ctx.cwd); let mut artifact_paths = Vec::new(); if let Some(ref sink) = ctx.sandbox_sink { for (rel_path, mime, size) in &new_artifacts { sink.emit_artifact(rel_path, mime, *size); artifact_paths.push(rel_path.clone()); } if status.success() && let Some(packages) = detect_installed_packages(command) { sink.emit_packages_installed(&packages); } sink.emit_finished(status.code().unwrap_or(-1), duration_ms, artifact_paths); } let mut text = String::new(); for (path, _, _) in &new_artifacts { text.push_str(&format!("[file: {}]\n", ctx.cwd.join(path).display())); } if !out.is_empty() { text.push_str("[stdout]\n"); text.push_str(&out); text.push('\n'); } if !err.is_empty() { text.push_str("[stderr]\n"); text.push_str(&err); text.push('\n'); } if !status.success() { text.push_str(&format!("\n[exit code: {}]", status.code().unwrap_or(-1))); return ToolOutput::error(text); } if text.is_empty() { text.push_str("(no output)"); } ToolOutput::ok(text) } } } } /// The minimal operational environment forwarded to sandboxed subprocesses. /// Provider, gateway, and connector credentials never appear here — only the /// paths a working toolchain needs. const ALLOWED_ENV_VARS: &[&str] = &[ "PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR", "CARGO_HOME", "RUSTUP_HOME", ]; /// Predicate for the env-scrub allowlist. Extracted so the security boundary /// can be unit-tested without mutating process-global state. pub(crate) fn is_allowed_env_var(key: &str) -> bool { ALLOWED_ENV_VARS.contains(&key) || key.starts_with("LC_") || key.starts_with("XDG_") } fn execution_path() -> std::ffi::OsString { let mut path = std::env::var_os("PATH").unwrap_or_default(); #[cfg(target_os = "macos")] for candidate in [ "/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin", "/opt/local/bin", ] { let candidate = std::path::Path::new(candidate); if candidate.is_dir() { let mut repaired = candidate.as_os_str().to_os_string(); repaired.push(":"); repaired.push(&path); path = repaired; } } path } /// Resolve a configured executable against the exact PATH a scrubbed command /// receives. This is readiness evidence only; dispatch must still handle a /// race where the executable disappears after the probe. pub fn executable_available(program: &str, cwd: &std::path::Path) -> bool { let is_executable = |path: &std::path::Path| { if !path.is_file() { return false; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; path.metadata() .is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) } #[cfg(not(unix))] { true } }; let configured = std::path::Path::new(program); if configured.components().count() > 1 { return is_executable(&cwd.join(configured)); } std::env::split_paths(&execution_path()).any(|directory| { if is_executable(&directory.join(program)) { return true; } #[cfg(windows)] { return ["exe", "cmd", "bat", "com"] .iter() .any(|extension| is_executable(&directory.join(format!("{program}.{extension}")))); } #[cfg(not(windows))] false }) } /// Apply the same minimal non-secret environment used for brokered commands. /// Host-owned execution surfaces such as a managed preview process must call /// this before spawning; credentials are injected only through their scoped /// owner and never inherited from the server process. pub fn scrub_environment(cmd: &mut tokio::process::Command) { let inherited: Vec<(String, std::ffi::OsString)> = std::env::vars_os() .filter_map(|(key, value)| { let key = key.into_string().ok()?; if is_allowed_env_var(&key) { Some((key, value)) } else { None } }) .collect(); cmd.env_clear(); cmd.envs(inherited); // GUI-launched macOS applications do not receive the shell PATH. Keep the // sandbox language/toolchain-neutral, but make the normal user-installed // tool locations reachable by every command executed through `bash`. // This is deliberately a PATH repair, not a Python/Node/etc. special case. #[cfg(target_os = "macos")] { cmd.env("PATH", execution_path()); } } /// The shell a scrubbed command runs through, named by path. After /// `scrub_environment` a bare `sh` has to be looked up on the child's PATH, /// which std does only by fork+exec; with a path it can posix_spawn. #[cfg(unix)] pub const POSIX_SHELL: &str = "/bin/sh"; #[cfg(not(unix))] pub const POSIX_SHELL: &str = "sh"; /// Gives the command a process group of its own, which /// [`kill_process_group`] signals as a whole. Not a `pre_exec` closure: /// that takes `unsafe` and makes std fork+exec instead of posix_spawn. pub fn isolate_process_group(cmd: &mut tokio::process::Command) { #[cfg(unix)] cmd.process_group(0); } fn shell_command(command: &str) -> tokio::process::Command { #[cfg(unix)] { let mut c = tokio::process::Command::new(POSIX_SHELL); c.arg("-c").arg(command); c } #[cfg(windows)] { let mut c = tokio::process::Command::new("cmd"); c.arg("/C").arg(command); c } } fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } pub fn kill_process_group(pid: &Option) { #[cfg(unix)] if let Some(pid) = pid { #[allow(unsafe_code)] unsafe { libc::kill(-(*pid as i32), libc::SIGKILL); } } #[cfg(windows)] if let Some(pid) = pid { let _ = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .output(); } } async fn read_capped_streaming( r: &mut R, sink: Option, is_stderr: bool, ) -> String { let mut buf = Vec::new(); let mut chunk = [0u8; 8192]; let mut truncated = false; loop { match r.read(&mut chunk).await { Ok(0) | Err(_) => break, Ok(n) => { let space = MAX_CAPTURE.saturating_sub(buf.len()); let taken = n.min(space); buf.extend_from_slice(&chunk[..taken]); // Keep draining the pipe after the retained-output cap. A // child must never block because the UI chose bounded memory. // Do not continue forwarding unbounded data to the browser. if taken < n && !truncated { truncated = true; if let Some(ref sink) = sink { sink.emit_output_truncated(); } } if taken > 0 && !truncated { let chunk_str = String::from_utf8_lossy(&chunk[..n]); if let Some(ref sink) = sink { if is_stderr { sink.emit_stderr(&chunk_str); } else { sink.emit_stdout(&chunk_str); } } } } } } String::from_utf8_lossy(&buf).into_owned() } fn probe_process_memory(pid: Option) -> u64 { let Some(pid) = pid else { return 0 }; #[cfg(target_os = "linux")] { if let Ok(statm) = std::fs::read_to_string(format!("/proc/{pid}/statm")) { let mut parts = statm.split_whitespace(); if let Some(pages_str) = parts.nth(1) { if let Ok(pages) = pages_str.parse::() { return pages * 4096; } } } } #[cfg(target_os = "macos")] { if let Ok(out) = std::process::Command::new("ps") .args(["-o", "rss=", "-p", &pid.to_string()]) .output() && out.status.success() { let text = String::from_utf8_lossy(&out.stdout).trim().to_string(); if let Ok(kb) = text.parse::() { return kb * 1024; } } } 0 } /// User-visible files in the workspace and their modification times: the /// baseline a command's new or changed deliverables are found against. fn collect_candidate_files( workspace: &std::path::Path, ) -> std::collections::HashMap { candidate_files(workspace) .filter_map(|path| { let mtime = path.metadata().ok()?.modified().ok()?; Some((path, mtime)) }) .collect() } fn candidate_files(workspace: &std::path::Path) -> impl Iterator { walkdir::WalkDir::new(workspace) .follow_links(false) .max_depth(4) .into_iter() .filter_entry(|entry| !skip_workspace_dir(entry.path())) .filter_map(Result::ok) .take(MAX_SCAN_ENTRIES) .map(|entry| entry.into_path()) .filter(|path| path.is_file() && is_user_visible_artifact(path)) } /// The deliverables a command created or changed: `(relative path, mime, /// size)` for each user-visible file newer than its baseline. fn scan_new_candidate_artifacts( before: &std::collections::HashMap, workspace: &std::path::Path, ) -> Vec<(String, String, u64)> { candidate_files(workspace) .filter_map(|path| { let meta = path.metadata().ok()?; let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH); if before.get(&path).is_some_and(|&prev| mtime <= prev) { return None; } let rel = path .strip_prefix(workspace) .unwrap_or(&path) .display() .to_string(); Some((rel, guess_mime_type(&path), meta.len())) }) .collect() } fn skip_workspace_dir(path: &std::path::Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| { matches!( name, ".git" | "target" | "node_modules" // `.vak` holds tool-internal state, including every // other Agent's isolated workspace nested under // `.vak/agents//workspace` (see // vak_config::paths::agent_workspace) — an artifact scan // must never wander into another Agent's files. | ".vak" | ".vak-home" | ".venv" | "venv" | "__pycache__" | ".cache" ) }) } fn is_user_visible_artifact(path: &std::path::Path) -> bool { matches!( path.extension() .and_then(|ext| ext.to_str()) .unwrap_or("") .to_ascii_lowercase() .as_str(), "html" | "htm" | "css" | "js" | "mjs" | "jsx" | "ts" | "tsx" | "json" | "csv" | "tsv" | "md" | "txt" | "log" | "pdf" | "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "mp3" | "wav" | "ogg" | "m4a" | "aac" | "mp4" | "webm" | "mov" | "m4v" ) } fn interrupted_output(stdout: &str, stderr: &str, reason: &str) -> String { let mut text = String::new(); if !stdout.is_empty() { text.push_str("[stdout]\n"); text.push_str(stdout); text.push('\n'); } if !stderr.is_empty() { text.push_str("[stderr]\n"); text.push_str(stderr); text.push('\n'); } text.push_str("\n["); text.push_str(reason); text.push(']'); text } fn references_control_file(command: &str) -> bool { [".env", ".vak/config.toml", ".vak/config"] .iter() .any(|needle| command.contains(needle)) } fn detect_installed_packages(cmd: &str) -> Option> { let parts: Vec<&str> = cmd.split_whitespace().collect(); if parts.len() >= 3 && ((parts[0] == "pip" || parts[0] == "pip3") && parts[1] == "install") { let pkgs: Vec = parts[2..] .iter() .filter(|p| !p.starts_with('-')) .map(|p| p.to_string()) .collect(); if !pkgs.is_empty() { return Some(pkgs); } } else if parts.len() >= 3 && (parts[0] == "npm" && (parts[1] == "install" || parts[1] == "i" || parts[1] == "add")) { let pkgs: Vec = parts[2..] .iter() .filter(|p| !p.starts_with('-')) .map(|p| p.to_string()) .collect(); if !pkgs.is_empty() { return Some(pkgs); } } else if parts.len() >= 3 && (parts[0] == "cargo" && parts[1] == "add") { let pkgs: Vec = parts[2..] .iter() .filter(|p| !p.starts_with('-')) .map(|p| p.to_string()) .collect(); if !pkgs.is_empty() { return Some(pkgs); } } else if parts.len() >= 3 && (parts[0] == "uv" && (parts[1] == "add" || parts[1] == "pip")) { let pkgs: Vec = parts[2..] .iter() .filter(|p| !p.starts_with('-') && **p != "install") .map(|p| p.to_string()) .collect(); if !pkgs.is_empty() { return Some(pkgs); } } else if parts.len() >= 3 && (parts[0] == "pnpm" || parts[0] == "yarn") && (parts[1] == "add") { let pkgs: Vec = parts[2..] .iter() .filter(|p| !p.starts_with('-')) .map(|p| p.to_string()) .collect(); if !pkgs.is_empty() { return Some(pkgs); } } None } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::{executable_available, is_allowed_env_var, scrub_environment}; use std::sync::Mutex; /// Serialises std::env mutation across every env-scrubbing test in the /// process. `set_var` is process-global and `cargo test` runs in parallel. static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] fn executable_readiness_checks_relative_files_and_execute_permission() { let workspace = tempfile::tempdir().unwrap(); let executable = workspace.path().join("preview-tool"); std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); permissions.set_mode(0o755); std::fs::set_permissions(&executable, permissions).unwrap(); } assert!(executable_available("./preview-tool", workspace.path())); assert!(!executable_available("./missing-tool", workspace.path())); } #[test] fn is_allowed_env_var_rejects_common_secret_names() { // Provider / cloud / local-dev credentials that must NEVER reach a // sandboxed subprocess. Each of these is a real environment variable // naming convention an operator or CI system commonly sets. let secrets = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GITHUB_TOKEN", "GITHUB_PAT", "GITLAB_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "DATABASE_URL", "DB_PASSWORD", "VAULT_TOKEN", "VAULT_ADDR", "DOCKER_TOKEN", "NPM_TOKEN", "PYPI_TOKEN", "CLOUDSDK_AUTH_ACCESS_TOKEN", "GOOGLE_APPLICATION_CREDENTIALS", "AZURE_CLIENT_SECRET", "TAVILY_API_KEY", "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", "DISCORD_TOKEN", "SECRET_KEY", "PRIVATE_KEY", "SSH_AUTH_SOCK", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "REQUESTS_CA_BUNDLE", ]; for secret in secrets { assert!( !is_allowed_env_var(secret), "`{secret}` must not pass the env allowlist" ); } } #[test] fn is_allowed_env_var_accepts_operational_vars() { for ok in [ "PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "LC_MONETARY", "LC_TIME", "LC_COLLATE", "TERM", "SHELL", "TMPDIR", "CARGO_HOME", "RUSTUP_HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR", ] { assert!(is_allowed_env_var(ok), "`{ok}` must pass the env allowlist"); } } #[test] fn is_allowed_env_var_prefix_matching_is_exact() { // Substring prefixes must NOT match — only the exact listed names or // LC_*/XDG_* prefixes pass. A var like "MY_PATH" must not masquerade // as "PATH". assert!(!is_allowed_env_var("MY_PATH")); assert!(!is_allowed_env_var("PATH_EXTRA")); assert!(!is_allowed_env_var("CARGO_HOME_DIR")); assert!(!is_allowed_env_var("LD_PRELOAD")); assert!(!is_allowed_env_var("LD_LIBRARY_PATH")); assert!(!is_allowed_env_var("PYTHONPATH")); assert!(!is_allowed_env_var("NODE_OPTIONS")); // LC_ prefix is allowed; "LC" alone is not. assert!(is_allowed_env_var("LC_FOO")); assert!(!is_allowed_env_var("LC")); // XDG_ prefix is allowed; "XD" is not. assert!(is_allowed_env_var("XDG_DATA_DIRS")); assert!(!is_allowed_env_var("XDG")); } #[test] fn scrub_environment_only_carries_allowlist_into_child() { // Observational test: after scrubbing, the command's env must be a // strict subset of the process env filtered through the allowlist. // This validates the real filtering pipeline without mutating any // process-global state. let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let mut cmd = tokio::process::Command::new("sh"); cmd.env("VAK_TEST_SECRET", "must-not-survive"); cmd.env("PATH", "/bin:/usr/bin"); scrub_environment(&mut cmd); let scrubbed: std::collections::HashSet = cmd .as_std() .get_envs() .filter(|(_, v)| v.is_some()) .filter_map(|(k, _)| k.to_str().map(str::to_string)) .collect(); // No var that fails the predicate may be present. for key in &scrubbed { assert!( is_allowed_env_var(key), "`{key}` survived scrubbing but is not on the allowlist" ); } // A var we set on the command object before scrubbing must be gone. assert!( !scrubbed.contains("VAK_TEST_SECRET"), "command-level env must be cleared by scrub" ); } #[test] fn scrub_environment_drops_process_secrets_at_runtime() { // End-to-end security boundary: a secret in THIS process's environment // must not reach the sandboxed child. We set a representative set of // credential variable names, scrub, and verify each is absent from the // command's env as seen from the child. let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); for secret in [ "VAK_SCRUB_TEST_SECRET", "VAK_SCRUB_TEST_API_KEY", "VAK_SCRUB_TEST_TOKEN", ] { // SAFETY: test-only mutation of process-global env, serialised by // ENV_LOCK and cleaned up in the same scope. No production code // is affected. This is the narrow, annotated unsafe exception // pattern already used in this module for process-group kill. #[allow(unsafe_code)] unsafe { std::env::set_var(secret, "super-secret-value"); } } let cmd = { let mut c = tokio::process::Command::new("sh"); scrub_environment(&mut c); c }; let keys: Vec = cmd .as_std() .get_envs() .filter(|(_, v)| v.is_some()) .filter_map(|(k, _)| k.to_str().map(str::to_string)) .collect(); for secret in [ "VAK_SCRUB_TEST_SECRET", "VAK_SCRUB_TEST_API_KEY", "VAK_SCRUB_TEST_TOKEN", ] { assert!( !keys.contains(&secret.to_string()), "`{secret}` leaked into child env" ); } // Cleanup for secret in [ "VAK_SCRUB_TEST_SECRET", "VAK_SCRUB_TEST_API_KEY", "VAK_SCRUB_TEST_TOKEN", ] { #[allow(unsafe_code)] unsafe { std::env::remove_var(secret); } } } #[test] fn package_detection_covers_multiple_ecosystems() { use super::detect_installed_packages; assert_eq!( detect_installed_packages("pip install numpy pandas"), Some(vec!["numpy".into(), "pandas".into()]) ); assert_eq!( detect_installed_packages("npm i -D typescript solid-js"), Some(vec!["typescript".into(), "solid-js".into()]) ); assert_eq!( detect_installed_packages("cargo add tokio serde"), Some(vec!["tokio".into(), "serde".into()]) ); assert_eq!( detect_installed_packages("uv add fastapi uvicorn"), Some(vec!["fastapi".into(), "uvicorn".into()]) ); assert_eq!( detect_installed_packages("pnpm add tailwindcss"), Some(vec!["tailwindcss".into()]) ); assert_eq!(detect_installed_packages("ls -la"), None); } #[test] fn mime_type_guessing() { use super::guess_mime_type; use std::path::Path; assert_eq!(guess_mime_type(Path::new("chart.png")), "image/png"); assert_eq!(guess_mime_type(Path::new("index.html")), "text/html"); assert_eq!(guess_mime_type(Path::new("data.csv")), "text/csv"); assert_eq!( guess_mime_type(Path::new("unknown.xyz")), "application/octet-stream" ); } #[cfg(unix)] #[tokio::test] async fn killing_the_group_reaches_a_background_grandchild() { use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; let mut cmd = super::shell_command("sleep 30 & echo started; wait"); cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()); super::isolate_process_group(&mut cmd); let mut child = cmd.spawn().unwrap(); let mut stdout = BufReader::new(child.stdout.take().unwrap()); let mut line = String::new(); stdout.read_line(&mut line).await.unwrap(); assert_eq!(line, "started\n"); super::kill_process_group(&child.id()); // The background sleep holds stdout open: the pipe closes before // the sleep would end only if the signal reached the whole group. let mut rest = Vec::new(); tokio::time::timeout( std::time::Duration::from_secs(10), stdout.read_to_end(&mut rest), ) .await .expect("the group kill missed the background sleep") .unwrap(); child.wait().await.unwrap(); } #[test] fn control_files_are_not_available_to_sandbox_commands() { assert!(super::references_control_file("cat .env")); assert!(super::references_control_file("cp result .vak/config.toml")); assert!(!super::references_control_file("echo ok > result.txt")); } #[tokio::test] async fn cwd_dot_runs_in_the_workspace_root() { use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; let workspace = tempfile::tempdir().unwrap(); let test_file = workspace.path().join("marker.txt"); std::fs::write(&test_file, "workspace-marker").unwrap(); let (sink, _rx) = SandboxEventSink::new_with_id("test-quarantine-false".into()); let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox_sink(sink); let tool = super::BashTool; let out = tool .execute( &serde_json::json!({ "command": "cat marker.txt", "cwd": "." }), &ctx, ) .await; assert!(!out.is_error, "{}", out.content); assert!(out.content.contains("workspace-marker")); } #[tokio::test] async fn a_command_works_in_the_workspace_and_keeps_temp_files_in_scratch() { use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; let workspace = tempfile::tempdir().unwrap(); std::fs::write(workspace.path().join("unsorted.txt"), "delta\nalpha\n").unwrap(); let (sink, mut events) = SandboxEventSink::new_with_id("sort-1".into()); let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox_sink(sink); let out = super::BashTool .execute( &serde_json::json!({ "command": "sort unsorted.txt > sorted.txt && echo \"tmp=$TMPDIR\"" }), &ctx, ) .await; assert!(!out.is_error, "{}", out.content); assert_eq!( std::fs::read_to_string(workspace.path().join("sorted.txt")).unwrap(), "alpha\ndelta\n", "the output is where `read` looks for it" ); let temp = workspace.path().join(".vak/scratch/vak/sort-1/tmp"); assert!( out.content.contains(&format!("tmp={}", temp.display())), "{}", out.content ); let mut reported = false; while let Ok(event) = events.try_recv() { if let crate::SandboxEvent::ArtifactGenerated { path, .. } = event { reported |= path == "sorted.txt"; } } assert!(reported, "a new deliverable is reported to the Workbench"); } #[tokio::test] async fn custom_cwd_is_respected() { use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; let workspace = tempfile::tempdir().unwrap(); let sub = workspace .path() .join(".vak/scratch/vak/test-custom-cwd/sub_module"); std::fs::create_dir_all(&sub).unwrap(); std::fs::write(sub.join("sub.txt"), "in-sub").unwrap(); let (sink, _rx) = SandboxEventSink::new_with_id("test-custom-cwd".into()); let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox_sink(sink); let tool = super::BashTool; let out = tool .execute( &serde_json::json!({ "command": "cat sub.txt", "cwd": ".vak/scratch/vak/test-custom-cwd/sub_module" }), &ctx, ) .await; assert!(!out.is_error, "{}", out.content); assert!(out.content.contains("in-sub")); } #[tokio::test] async fn custom_cwd_rejects_workspace_escape() { use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; let workspace = tempfile::tempdir().unwrap(); let (sink, _rx) = SandboxEventSink::new_with_id("test-cwd-escape".into()); let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox_sink(sink); let out = super::BashTool .execute( &serde_json::json!({ "command": "pwd", "cwd": ".." }), &ctx, ) .await; assert!(out.is_error); assert!(out.content.contains("inside the workspace")); } #[tokio::test] async fn temp_files_are_scoped_to_the_agent() { use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; let workspace = tempfile::tempdir().unwrap(); let (sink, _events) = SandboxEventSink::new_with_id("test-agent-scratch".into()); let ctx = ToolContext::new(workspace.path().to_path_buf()) .with_agent_id("researcher-99") .with_sandbox_sink(sink); let out = super::BashTool .execute(&serde_json::json!({"command": "echo \"$TMPDIR\""}), &ctx) .await; assert!(!out.is_error, "stdout/stderr: {}", out.content); assert!( out.content .contains(".vak/scratch/researcher-99/test-agent-scratch/tmp"), "{}", out.content ); } #[tokio::test] async fn workspace_command_promotes_user_visible_result() { use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; let workspace = tempfile::tempdir().unwrap(); let (sink, mut events) = SandboxEventSink::new_with_id("test-workspace-result".into()); let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox_sink(sink); let out = super::BashTool .execute( &serde_json::json!({ "command": "printf '

India market

\\n' > index.html", "cwd": "." }), &ctx, ) .await; assert!(!out.is_error, "stdout/stderr: {}", out.content); assert!(workspace.path().join("index.html").is_file()); let mut saw_result = false; while let Ok(event) = events.try_recv() { if let crate::SandboxEvent::ArtifactGenerated { path, .. } = event { saw_result |= path == "index.html"; } } assert!( saw_result, "workspace result should be emitted as an artifact" ); } }