- 1
use std::process::Stdio; - 2
- 3
use async_trait::async_trait; - 4
use serde_json::Value; - 5
use tokio::io::AsyncReadExt; - 6
- 7
use crate::{Tool, ToolContext, ToolOutput, artifact::guess_mime_type}; - 8
- 9
const DEFAULT_TIMEOUT_MS: u64 = 120_000; - 10
const MAX_CAPTURE: usize = 1 << 20; - 11
const MAX_SCAN_ENTRIES: usize = 50_000; - 12
- 13
pub struct BashTool; - 14
- 15
#[async_trait] - 16
impl Tool for BashTool { - 17
fn name(&self) -> &str { - 18
"bash" - 19
} - 20
- 21
fn serves(&self) -> &'static [&'static str] { - 22
&["code-exec"] - 23
} - 24
- 25
fn description(&self) -> &str { - 26
"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." - 27
} - 28
- 29
fn schema(&self) -> Value { - 30
serde_json::json!({ - 31
"type": "object", - 32
"properties": { - 33
"command": {"type": "string", "description": "Shell command to execute"}, - 34
"timeout_ms": {"type": "integer", "minimum": 1000, "description": "Timeout in milliseconds (default 120000)"}, - 35
"cwd": {"type": "string", "description": "Folder to run in, relative to the workspace root (default: the workspace root)"} - 36
}, - 37
"required": ["command"] - 38
}) - 39
} - 40
- 41
fn claims(&self, _args: &Value) -> crate::ResourceClaims { - 42
crate::ResourceClaims { - 43
exclusive: true, - 44
read_only: false, - 45
paths: Vec::new(), - 46
} - 47
} - 48
- 49
async fn execute(&self, args: &Value, ctx: &ToolContext) -> ToolOutput { - 50
let Some(command) = args.get("command").and_then(|c| c.as_str()) else { - 51
return ToolOutput::error("missing required parameter: command"); - 52
}; - 53
if ctx.sandbox_sink.is_some() && references_control_file(command) { - 54
return ToolOutput::error( - 55
"sandbox denied access to workspace control files (.env and .vak/config.toml)", - 56
); - 57
} - 58
let timeout_ms = args - 59
.get("timeout_ms") - 60
.and_then(|t| t.as_u64()) - 61
.unwrap_or(DEFAULT_TIMEOUT_MS) - 62
.max(1000); - 63
- 64
// The command works in the workspace, where `read`/`write`/`edit` - 65
// work, so what one tool writes the next can read. Running each - 66
// command in its own empty scratch folder made a file written here - 67
// invisible to `read`, and a small model looped rewriting it. Runtime - 68
// state (temp files, tool caches) still goes to scratch (invariant 35). - 69
let agent_id = ctx.agent_id.as_deref().unwrap_or("vak"); - 70
let scratch_root = ctx.cwd.join(".vak").join("scratch").join(agent_id); - 71
let temp_dir = ctx - 72
.sandbox_sink - 73
.as_ref() - 74
.map(|sink| scratch_root.join(sink.execution_id())) - 75
.unwrap_or_else(|| scratch_root.join("shell")) - 76
.join("tmp"); - 77
let cache_dir = scratch_root.join("cache"); - 78
let _ = std::fs::create_dir_all(&temp_dir); - 79
let _ = std::fs::create_dir_all(&cache_dir); - 80
- 81
let mut execution_dir = ctx.cwd.clone(); - 82
if let Some(custom_cwd) = args - 83
.get("cwd") - 84
.and_then(|v| v.as_str()) - 85
.map(str::trim) - 86
.filter(|cwd| !cwd.is_empty()) - 87
{ - 88
let workspace = match ctx.cwd.canonicalize() { - 89
Ok(path) => path, - 90
Err(_) => return ToolOutput::error("working directory is unavailable"), - 91
}; - 92
let target = match workspace.join(custom_cwd).canonicalize() { - 93
Ok(path) => path, - 94
Err(_) => return ToolOutput::error("working directory does not exist"), - 95
}; - 96
if !target.starts_with(&workspace) || !target.is_dir() { - 97
return ToolOutput::error("working directory must remain inside the workspace"); - 98
} - 99
execution_dir = target; - 100
} - 101
- 102
// Command-scoped backends do not inherit the host process directory. - 103
// Carry the validated directory into the command before wrapping it. - 104
let execution_command = if ctx.sandbox.as_ref().is_some_and(|sandbox| { - 105
sandbox.target() == vak_sandbox::backend::SandboxTarget::ToolCommand - 106
}) && execution_dir != ctx.cwd - 107
{ - 108
format!( - 109
"cd {} && ({command})", - 110
shell_quote(&execution_dir.display().to_string()) - 111
) - 112
} else { - 113
command.to_string() - 114
}; - 115
let effective = match &ctx.sandbox { - 116
Some(sb) => sb.wrap(&execution_command), - 117
None => execution_command.clone(), - 118
}; - 119
- 120
let mut cmd = shell_command(&effective); - 121
cmd.current_dir(&execution_dir) - 122
.stdin(Stdio::null()) - 123
.stdout(Stdio::piped()) - 124
.stderr(Stdio::piped()); - 125
scrub_environment(&mut cmd); - 126
cmd.env("TMPDIR", &temp_dir) - 127
.env("XDG_CACHE_HOME", &cache_dir) - 128
.env("PYTHONPYCACHEPREFIX", cache_dir.join("pycache")) - 129
.env("PIP_CACHE_DIR", cache_dir.join("pip")) - 130
.env("npm_config_cache", cache_dir.join("npm")); - 131
- 132
// Every execution gets its own process group, including broker workers. - 133
// Otherwise a shell child can outlive the timed-out worker and keep the - 134
// captured pipes open until the original command exits. - 135
isolate_process_group(&mut cmd); - 136
- 137
let before = collect_candidate_files(&ctx.cwd); - 138
- 139
let start_instant = std::time::Instant::now(); - 140
if let Some(ref sink) = ctx.sandbox_sink { - 141
sink.emit_execution_started( - 142
"bash", - 143
command, - 144
"bash", - 145
&execution_dir.display().to_string(), - 146
); - 147
} - 148
- 149
let mut child = match cmd.spawn() { - 150
Ok(c) => c, - 151
Err(e) => { - 152
let duration_ms = start_instant.elapsed().as_millis() as u64; - 153
if let Some(ref sink) = ctx.sandbox_sink { - 154
sink.emit_finished(-1, duration_ms, Vec::new()); - 155
} - 156
return ToolOutput::error(format!("spawn failed: {e}")); - 157
} - 158
}; - 159
- 160
let child_pid = child.id(); - 161
let telemetry_cancel = tokio_util::sync::CancellationToken::new(); - 162
let telemetry_token = telemetry_cancel.clone(); - 163
let telemetry_sink = ctx.sandbox_sink.clone(); - 164
let telemetry_handle = tokio::spawn(async move { - 165
let mut interval = tokio::time::interval(std::time::Duration::from_millis(500)); - 166
let t0 = std::time::Instant::now(); - 167
loop { - 168
tokio::select! { - 169
_ = telemetry_token.cancelled() => break, - 170
_ = interval.tick() => { - 171
if let Some(ref sink) = telemetry_sink { - 172
let elapsed = t0.elapsed().as_millis() as u64; - 173
if elapsed >= 400 { - 174
let rss = probe_process_memory(child_pid); - 175
sink.emit_telemetry(elapsed, 0.0, rss); - 176
} - 177
} - 178
} - 179
} - 180
} - 181
}); - 182
- 183
let mut stdout = child.stdout.take(); - 184
let mut stderr = child.stderr.take(); - 185
let sink_out = ctx.sandbox_sink.clone(); - 186
let sink_err = ctx.sandbox_sink.clone(); - 187
let out_fut = tokio::spawn(async move { - 188
match stdout.as_mut() { - 189
Some(r) => read_capped_streaming(r, sink_out, false).await, - 190
None => String::new(), - 191
} - 192
}); - 193
let err_fut = tokio::spawn(async move { - 194
match stderr.as_mut() { - 195
Some(r) => read_capped_streaming(r, sink_err, true).await, - 196
None => String::new(), - 197
} - 198
}); - 199
- 200
let timeout = tokio::time::sleep(std::time::Duration::from_millis(timeout_ms)); - 201
let cancelled = ctx.cancel.cancelled(); - 202
tokio::select! { - 203
_ = timeout => { - 204
telemetry_cancel.cancel(); - 205
let _ = telemetry_handle.await; - 206
kill_process_group(&child.id()); - 207
let _ = child.wait().await; - 208
let out = out_fut.await.unwrap_or_default(); - 209
let err = err_fut.await.unwrap_or_default(); - 210
let duration_ms = start_instant.elapsed().as_millis() as u64; - 211
let mut paths = Vec::new(); - 212
if let Some(ref sink) = ctx.sandbox_sink { - 213
let artifacts = scan_new_candidate_artifacts(&before, &ctx.cwd); - 214
for (rel_path, mime, size) in artifacts { - 215
sink.emit_artifact(&rel_path, &mime, size); - 216
paths.push(rel_path); - 217
} - 218
sink.emit_finished(-1, duration_ms, paths.clone()); - 219
} - 220
let mut reason = format!("command timed out after {timeout_ms}ms"); - 221
if !paths.is_empty() { - 222
reason.push_str(&format!( - 223
". {} file(s) were preserved before timeout: {}. \ - 224
Read and verify them before claiming completion; any foreground server was stopped.", - 225
paths.len(), - 226
paths.join(", ") - 227
)); - 228
} - 229
return ToolOutput::error(interrupted_output(&out, &err, &reason)); - 230
} - 231
_ = cancelled => { - 232
telemetry_cancel.cancel(); - 233
let _ = telemetry_handle.await; - 234
kill_process_group(&child.id()); - 235
let _ = child.wait().await; - 236
let out = out_fut.await.unwrap_or_default(); - 237
let err = err_fut.await.unwrap_or_default(); - 238
let duration_ms = start_instant.elapsed().as_millis() as u64; - 239
if let Some(ref sink) = ctx.sandbox_sink { - 240
let artifacts = scan_new_candidate_artifacts(&before, &ctx.cwd); - 241
let mut paths = Vec::new(); - 242
for (rel_path, mime, size) in artifacts { - 243
sink.emit_artifact(&rel_path, &mime, size); - 244
paths.push(rel_path); - 245
} - 246
sink.emit_finished(-1, duration_ms, paths); - 247
} - 248
return ToolOutput::error(interrupted_output(&out, &err, "command cancelled")); - 249
} - 250
status = child.wait() => { - 251
telemetry_cancel.cancel(); - 252
let _ = telemetry_handle.await; - 253
let status = match status { - 254
Ok(s) => s, - 255
Err(e) => { - 256
let duration_ms = start_instant.elapsed().as_millis() as u64; - 257
if let Some(ref sink) = ctx.sandbox_sink { - 258
sink.emit_finished(-1, duration_ms, Vec::new()); - 259
} - 260
return ToolOutput::error(format!("wait failed: {e}")); - 261
} - 262
}; - 263
let out = out_fut.await.unwrap_or_default(); - 264
let err = err_fut.await.unwrap_or_default(); - 265
let duration_ms = start_instant.elapsed().as_millis() as u64; - 266
- 267
let new_artifacts = scan_new_candidate_artifacts(&before, &ctx.cwd); - 268
let mut artifact_paths = Vec::new(); - 269
if let Some(ref sink) = ctx.sandbox_sink { - 270
for (rel_path, mime, size) in &new_artifacts { - 271
sink.emit_artifact(rel_path, mime, *size); - 272
artifact_paths.push(rel_path.clone()); - 273
} - 274
if status.success() - 275
&& let Some(packages) = detect_installed_packages(command) - 276
{ - 277
sink.emit_packages_installed(&packages); - 278
} - 279
sink.emit_finished(status.code().unwrap_or(-1), duration_ms, artifact_paths); - 280
} - 281
- 282
let mut text = String::new(); - 283
for (path, _, _) in &new_artifacts { - 284
text.push_str(&format!("[file: {}]\n", ctx.cwd.join(path).display())); - 285
} - 286
if !out.is_empty() { - 287
text.push_str("[stdout]\n"); - 288
text.push_str(&out); - 289
text.push('\n'); - 290
} - 291
if !err.is_empty() { - 292
text.push_str("[stderr]\n"); - 293
text.push_str(&err); - 294
text.push('\n'); - 295
} - 296
if !status.success() { - 297
text.push_str(&format!("\n[exit code: {}]", status.code().unwrap_or(-1))); - 298
return ToolOutput::error(text); - 299
} - 300
if text.is_empty() { - 301
text.push_str("(no output)"); - 302
} - 303
ToolOutput::ok(text) - 304
} - 305
} - 306
} - 307
} - 308
- 309
/// The minimal operational environment forwarded to sandboxed subprocesses. - 310
/// Provider, gateway, and connector credentials never appear here — only the - 311
/// paths a working toolchain needs. - 312
const ALLOWED_ENV_VARS: &[&str] = &[ - 313
"PATH", - 314
"HOME", - 315
"USER", - 316
"LOGNAME", - 317
"LANG", - 318
"LC_ALL", - 319
"TERM", - 320
"SHELL", - 321
"TMPDIR", - 322
"CARGO_HOME", - 323
"RUSTUP_HOME", - 324
]; - 325
- 326
/// Predicate for the env-scrub allowlist. Extracted so the security boundary - 327
/// can be unit-tested without mutating process-global state. - 328
pub(crate) fn is_allowed_env_var(key: &str) -> bool { - 329
ALLOWED_ENV_VARS.contains(&key) || key.starts_with("LC_") || key.starts_with("XDG_") - 330
} - 331
- 332
fn execution_path() -> std::ffi::OsString { - 333
let mut path = std::env::var_os("PATH").unwrap_or_default(); - 334
#[cfg(target_os = "macos")] - 335
for candidate in [ - 336
"/opt/homebrew/bin", - 337
"/opt/homebrew/sbin", - 338
"/usr/local/bin", - 339
"/usr/local/sbin", - 340
"/opt/local/bin", - 341
] { - 342
let candidate = std::path::Path::new(candidate); - 343
if candidate.is_dir() { - 344
let mut repaired = candidate.as_os_str().to_os_string(); - 345
repaired.push(":"); - 346
repaired.push(&path); - 347
path = repaired; - 348
} - 349
} - 350
path - 351
} - 352
- 353
/// Resolve a configured executable against the exact PATH a scrubbed command - 354
/// receives. This is readiness evidence only; dispatch must still handle a - 355
/// race where the executable disappears after the probe. - 356
pub fn executable_available(program: &str, cwd: &std::path::Path) -> bool { - 357
let is_executable = |path: &std::path::Path| { - 358
if !path.is_file() { - 359
return false; - 360
} - 361
#[cfg(unix)] - 362
{ - 363
use std::os::unix::fs::PermissionsExt; - 364
path.metadata() - 365
.is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) - 366
} - 367
#[cfg(not(unix))] - 368
{ - 369
true - 370
} - 371
}; - 372
let configured = std::path::Path::new(program); - 373
if configured.components().count() > 1 { - 374
return is_executable(&cwd.join(configured)); - 375
} - 376
std::env::split_paths(&execution_path()).any(|directory| { - 377
if is_executable(&directory.join(program)) { - 378
return true; - 379
} - 380
#[cfg(windows)] - 381
{ - 382
return ["exe", "cmd", "bat", "com"] - 383
.iter() - 384
.any(|extension| is_executable(&directory.join(format!("{program}.{extension}")))); - 385
} - 386
#[cfg(not(windows))] - 387
false - 388
}) - 389
} - 390
- 391
/// Apply the same minimal non-secret environment used for brokered commands. - 392
/// Host-owned execution surfaces such as a managed preview process must call - 393
/// this before spawning; credentials are injected only through their scoped - 394
/// owner and never inherited from the server process. - 395
pub fn scrub_environment(cmd: &mut tokio::process::Command) { - 396
let inherited: Vec<(String, std::ffi::OsString)> = std::env::vars_os() - 397
.filter_map(|(key, value)| { - 398
let key = key.into_string().ok()?; - 399
if is_allowed_env_var(&key) { - 400
Some((key, value)) - 401
} else { - 402
None - 403
} - 404
}) - 405
.collect(); - 406
cmd.env_clear(); - 407
cmd.envs(inherited); - 408
// GUI-launched macOS applications do not receive the shell PATH. Keep the - 409
// sandbox language/toolchain-neutral, but make the normal user-installed - 410
// tool locations reachable by every command executed through `bash`. - 411
// This is deliberately a PATH repair, not a Python/Node/etc. special case. - 412
#[cfg(target_os = "macos")] - 413
{ - 414
cmd.env("PATH", execution_path()); - 415
} - 416
} - 417
- 418
/// The shell a scrubbed command runs through, named by path. After - 419
/// `scrub_environment` a bare `sh` has to be looked up on the child's PATH, - 420
/// which std does only by fork+exec; with a path it can posix_spawn. - 421
#[cfg(unix)] - 422
pub const POSIX_SHELL: &str = "/bin/sh"; - 423
#[cfg(not(unix))] - 424
pub const POSIX_SHELL: &str = "sh"; - 425
- 426
/// Gives the command a process group of its own, which - 427
/// [`kill_process_group`] signals as a whole. Not a `pre_exec` closure: - 428
/// that takes `unsafe` and makes std fork+exec instead of posix_spawn. - 429
pub fn isolate_process_group(cmd: &mut tokio::process::Command) { - 430
#[cfg(unix)] - 431
cmd.process_group(0); - 432
} - 433
- 434
fn shell_command(command: &str) -> tokio::process::Command { - 435
#[cfg(unix)] - 436
{ - 437
let mut c = tokio::process::Command::new(POSIX_SHELL); - 438
c.arg("-c").arg(command); - 439
c - 440
} - 441
#[cfg(windows)] - 442
{ - 443
let mut c = tokio::process::Command::new("cmd"); - 444
c.arg("/C").arg(command); - 445
c - 446
} - 447
} - 448
- 449
fn shell_quote(value: &str) -> String { - 450
format!("'{}'", value.replace('\'', "'\\''")) - 451
} - 452
- 453
pub fn kill_process_group(pid: &Option<u32>) { - 454
#[cfg(unix)] - 455
if let Some(pid) = pid { - 456
#[allow(unsafe_code)] - 457
unsafe { - 458
libc::kill(-(*pid as i32), libc::SIGKILL); - 459
} - 460
} - 461
#[cfg(windows)] - 462
if let Some(pid) = pid { - 463
let _ = std::process::Command::new("taskkill") - 464
.args(["/PID", &pid.to_string(), "/T", "/F"]) - 465
.output(); - 466
} - 467
} - 468
- 469
async fn read_capped_streaming<R: AsyncReadExt + Unpin>( - 470
r: &mut R, - 471
sink: Option<crate::sandbox_events::SandboxEventSink>, - 472
is_stderr: bool, - 473
) -> String { - 474
let mut buf = Vec::new(); - 475
let mut chunk = [0u8; 8192]; - 476
let mut truncated = false; - 477
loop { - 478
match r.read(&mut chunk).await { - 479
Ok(0) | Err(_) => break, - 480
Ok(n) => { - 481
let space = MAX_CAPTURE.saturating_sub(buf.len()); - 482
let taken = n.min(space); - 483
buf.extend_from_slice(&chunk[..taken]); - 484
// Keep draining the pipe after the retained-output cap. A - 485
// child must never block because the UI chose bounded memory. - 486
// Do not continue forwarding unbounded data to the browser. - 487
if taken < n && !truncated { - 488
truncated = true; - 489
if let Some(ref sink) = sink { - 490
sink.emit_output_truncated(); - 491
} - 492
} - 493
if taken > 0 && !truncated { - 494
let chunk_str = String::from_utf8_lossy(&chunk[..n]); - 495
if let Some(ref sink) = sink { - 496
if is_stderr { - 497
sink.emit_stderr(&chunk_str); - 498
} else { - 499
sink.emit_stdout(&chunk_str); - 500
} - 501
} - 502
} - 503
} - 504
} - 505
} - 506
String::from_utf8_lossy(&buf).into_owned() - 507
} - 508
- 509
fn probe_process_memory(pid: Option<u32>) -> u64 { - 510
let Some(pid) = pid else { return 0 }; - 511
#[cfg(target_os = "linux")] - 512
{ - 513
if let Ok(statm) = std::fs::read_to_string(format!("/proc/{pid}/statm")) { - 514
let mut parts = statm.split_whitespace(); - 515
if let Some(pages_str) = parts.nth(1) { - 516
if let Ok(pages) = pages_str.parse::<u64>() { - 517
return pages * 4096; - 518
} - 519
} - 520
} - 521
} - 522
#[cfg(target_os = "macos")] - 523
{ - 524
if let Ok(out) = std::process::Command::new("ps") - 525
.args(["-o", "rss=", "-p", &pid.to_string()]) - 526
.output() - 527
&& out.status.success() - 528
{ - 529
let text = String::from_utf8_lossy(&out.stdout).trim().to_string(); - 530
if let Ok(kb) = text.parse::<u64>() { - 531
return kb * 1024; - 532
} - 533
} - 534
} - 535
0 - 536
} - 537
- 538
/// User-visible files in the workspace and their modification times: the - 539
/// baseline a command's new or changed deliverables are found against. - 540
fn collect_candidate_files( - 541
workspace: &std::path::Path, - 542
) -> std::collections::HashMap<std::path::PathBuf, std::time::SystemTime> { - 543
candidate_files(workspace) - 544
.filter_map(|path| { - 545
let mtime = path.metadata().ok()?.modified().ok()?; - 546
Some((path, mtime)) - 547
}) - 548
.collect() - 549
} - 550
- 551
fn candidate_files(workspace: &std::path::Path) -> impl Iterator<Item = std::path::PathBuf> { - 552
walkdir::WalkDir::new(workspace) - 553
.follow_links(false) - 554
.max_depth(4) - 555
.into_iter() - 556
.filter_entry(|entry| !skip_workspace_dir(entry.path())) - 557
.filter_map(Result::ok) - 558
.take(MAX_SCAN_ENTRIES) - 559
.map(|entry| entry.into_path()) - 560
.filter(|path| path.is_file() && is_user_visible_artifact(path)) - 561
} - 562
- 563
/// The deliverables a command created or changed: `(relative path, mime, - 564
/// size)` for each user-visible file newer than its baseline. - 565
fn scan_new_candidate_artifacts( - 566
before: &std::collections::HashMap<std::path::PathBuf, std::time::SystemTime>, - 567
workspace: &std::path::Path, - 568
) -> Vec<(String, String, u64)> { - 569
candidate_files(workspace) - 570
.filter_map(|path| { - 571
let meta = path.metadata().ok()?; - 572
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH); - 573
if before.get(&path).is_some_and(|&prev| mtime <= prev) { - 574
return None; - 575
} - 576
let rel = path - 577
.strip_prefix(workspace) - 578
.unwrap_or(&path) - 579
.display() - 580
.to_string(); - 581
Some((rel, guess_mime_type(&path), meta.len())) - 582
}) - 583
.collect() - 584
} - 585
- 586
fn skip_workspace_dir(path: &std::path::Path) -> bool { - 587
path.file_name() - 588
.and_then(|name| name.to_str()) - 589
.is_some_and(|name| { - 590
matches!( - 591
name, - 592
".git" - 593
| "target" - 594
| "node_modules" - 595
// `.vak` holds tool-internal state, including every - 596
// other Agent's isolated workspace nested under - 597
// `.vak/agents/<id>/workspace` (see - 598
// vak_config::paths::agent_workspace) — an artifact scan - 599
// must never wander into another Agent's files. - 600
| ".vak" - 601
| ".vak-home" - 602
| ".venv" - 603
| "venv" - 604
| "__pycache__" - 605
| ".cache" - 606
) - 607
}) - 608
} - 609
- 610
fn is_user_visible_artifact(path: &std::path::Path) -> bool { - 611
matches!( - 612
path.extension() - 613
.and_then(|ext| ext.to_str()) - 614
.unwrap_or("") - 615
.to_ascii_lowercase() - 616
.as_str(), - 617
"html" - 618
| "htm" - 619
| "css" - 620
| "js" - 621
| "mjs" - 622
| "jsx" - 623
| "ts" - 624
| "tsx" - 625
| "json" - 626
| "csv" - 627
| "tsv" - 628
| "md" - 629
| "txt" - 630
| "log" - 631
| "pdf" - 632
| "png" - 633
| "jpg" - 634
| "jpeg" - 635
| "gif" - 636
| "svg" - 637
| "webp" - 638
| "mp3" - 639
| "wav" - 640
| "ogg" - 641
| "m4a" - 642
| "aac" - 643
| "mp4" - 644
| "webm" - 645
| "mov" - 646
| "m4v" - 647
) - 648
} - 649
- 650
fn interrupted_output(stdout: &str, stderr: &str, reason: &str) -> String { - 651
let mut text = String::new(); - 652
if !stdout.is_empty() { - 653
text.push_str("[stdout]\n"); - 654
text.push_str(stdout); - 655
text.push('\n'); - 656
} - 657
if !stderr.is_empty() { - 658
text.push_str("[stderr]\n"); - 659
text.push_str(stderr); - 660
text.push('\n'); - 661
} - 662
text.push_str("\n["); - 663
text.push_str(reason); - 664
text.push(']'); - 665
text - 666
} - 667
- 668
fn references_control_file(command: &str) -> bool { - 669
[".env", ".vak/config.toml", ".vak/config"] - 670
.iter() - 671
.any(|needle| command.contains(needle)) - 672
} - 673
- 674
fn detect_installed_packages(cmd: &str) -> Option<Vec<String>> { - 675
let parts: Vec<&str> = cmd.split_whitespace().collect(); - 676
if parts.len() >= 3 && ((parts[0] == "pip" || parts[0] == "pip3") && parts[1] == "install") { - 677
let pkgs: Vec<String> = parts[2..] - 678
.iter() - 679
.filter(|p| !p.starts_with('-')) - 680
.map(|p| p.to_string()) - 681
.collect(); - 682
if !pkgs.is_empty() { - 683
return Some(pkgs); - 684
} - 685
} else if parts.len() >= 3 - 686
&& (parts[0] == "npm" && (parts[1] == "install" || parts[1] == "i" || parts[1] == "add")) - 687
{ - 688
let pkgs: Vec<String> = parts[2..] - 689
.iter() - 690
.filter(|p| !p.starts_with('-')) - 691
.map(|p| p.to_string()) - 692
.collect(); - 693
if !pkgs.is_empty() { - 694
return Some(pkgs); - 695
} - 696
} else if parts.len() >= 3 && (parts[0] == "cargo" && parts[1] == "add") { - 697
let pkgs: Vec<String> = parts[2..] - 698
.iter() - 699
.filter(|p| !p.starts_with('-')) - 700
.map(|p| p.to_string()) - 701
.collect(); - 702
if !pkgs.is_empty() { - 703
return Some(pkgs); - 704
} - 705
} else if parts.len() >= 3 && (parts[0] == "uv" && (parts[1] == "add" || parts[1] == "pip")) { - 706
let pkgs: Vec<String> = parts[2..] - 707
.iter() - 708
.filter(|p| !p.starts_with('-') && **p != "install") - 709
.map(|p| p.to_string()) - 710
.collect(); - 711
if !pkgs.is_empty() { - 712
return Some(pkgs); - 713
} - 714
} else if parts.len() >= 3 && (parts[0] == "pnpm" || parts[0] == "yarn") && (parts[1] == "add") - 715
{ - 716
let pkgs: Vec<String> = parts[2..] - 717
.iter() - 718
.filter(|p| !p.starts_with('-')) - 719
.map(|p| p.to_string()) - 720
.collect(); - 721
if !pkgs.is_empty() { - 722
return Some(pkgs); - 723
} - 724
} - 725
None - 726
} - 727
- 728
#[cfg(test)] - 729
#[allow(clippy::unwrap_used, clippy::expect_used)] - 730
mod tests { - 731
use super::{executable_available, is_allowed_env_var, scrub_environment}; - 732
use std::sync::Mutex; - 733
- 734
/// Serialises std::env mutation across every env-scrubbing test in the - 735
/// process. `set_var` is process-global and `cargo test` runs in parallel. - 736
static ENV_LOCK: Mutex<()> = Mutex::new(()); - 737
- 738
#[test] - 739
fn executable_readiness_checks_relative_files_and_execute_permission() { - 740
let workspace = tempfile::tempdir().unwrap(); - 741
let executable = workspace.path().join("preview-tool"); - 742
std::fs::write(&executable, "#!/bin/sh\nexit 0\n").unwrap(); - 743
#[cfg(unix)] - 744
{ - 745
use std::os::unix::fs::PermissionsExt; - 746
let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); - 747
permissions.set_mode(0o755); - 748
std::fs::set_permissions(&executable, permissions).unwrap(); - 749
} - 750
- 751
assert!(executable_available("./preview-tool", workspace.path())); - 752
assert!(!executable_available("./missing-tool", workspace.path())); - 753
} - 754
- 755
#[test] - 756
fn is_allowed_env_var_rejects_common_secret_names() { - 757
// Provider / cloud / local-dev credentials that must NEVER reach a - 758
// sandboxed subprocess. Each of these is a real environment variable - 759
// naming convention an operator or CI system commonly sets. - 760
let secrets = [ - 761
"OPENAI_API_KEY", - 762
"ANTHROPIC_API_KEY", - 763
"GITHUB_TOKEN", - 764
"GITHUB_PAT", - 765
"GITLAB_TOKEN", - 766
"AWS_ACCESS_KEY_ID", - 767
"AWS_SECRET_ACCESS_KEY", - 768
"AWS_SESSION_TOKEN", - 769
"DATABASE_URL", - 770
"DB_PASSWORD", - 771
"VAULT_TOKEN", - 772
"VAULT_ADDR", - 773
"DOCKER_TOKEN", - 774
"NPM_TOKEN", - 775
"PYPI_TOKEN", - 776
"CLOUDSDK_AUTH_ACCESS_TOKEN", - 777
"GOOGLE_APPLICATION_CREDENTIALS", - 778
"AZURE_CLIENT_SECRET", - 779
"TAVILY_API_KEY", - 780
"SLACK_BOT_TOKEN", - 781
"TELEGRAM_BOT_TOKEN", - 782
"DISCORD_TOKEN", - 783
"SECRET_KEY", - 784
"PRIVATE_KEY", - 785
"SSH_AUTH_SOCK", - 786
"HTTP_PROXY", - 787
"HTTPS_PROXY", - 788
"ALL_PROXY", - 789
"REQUESTS_CA_BUNDLE", - 790
]; - 791
for secret in secrets { - 792
assert!( - 793
!is_allowed_env_var(secret), - 794
"`{secret}` must not pass the env allowlist" - 795
); - 796
} - 797
} - 798
- 799
#[test] - 800
fn is_allowed_env_var_accepts_operational_vars() { - 801
for ok in [ - 802
"PATH", - 803
"HOME", - 804
"USER", - 805
"LOGNAME", - 806
"LANG", - 807
"LC_ALL", - 808
"LC_MONETARY", - 809
"LC_TIME", - 810
"LC_COLLATE", - 811
"TERM", - 812
"SHELL", - 813
"TMPDIR", - 814
"CARGO_HOME", - 815
"RUSTUP_HOME", - 816
"XDG_CACHE_HOME", - 817
"XDG_CONFIG_HOME", - 818
"XDG_RUNTIME_DIR", - 819
] { - 820
assert!(is_allowed_env_var(ok), "`{ok}` must pass the env allowlist"); - 821
} - 822
} - 823
- 824
#[test] - 825
fn is_allowed_env_var_prefix_matching_is_exact() { - 826
// Substring prefixes must NOT match — only the exact listed names or - 827
// LC_*/XDG_* prefixes pass. A var like "MY_PATH" must not masquerade - 828
// as "PATH". - 829
assert!(!is_allowed_env_var("MY_PATH")); - 830
assert!(!is_allowed_env_var("PATH_EXTRA")); - 831
assert!(!is_allowed_env_var("CARGO_HOME_DIR")); - 832
assert!(!is_allowed_env_var("LD_PRELOAD")); - 833
assert!(!is_allowed_env_var("LD_LIBRARY_PATH")); - 834
assert!(!is_allowed_env_var("PYTHONPATH")); - 835
assert!(!is_allowed_env_var("NODE_OPTIONS")); - 836
// LC_ prefix is allowed; "LC" alone is not. - 837
assert!(is_allowed_env_var("LC_FOO")); - 838
assert!(!is_allowed_env_var("LC")); - 839
// XDG_ prefix is allowed; "XD" is not. - 840
assert!(is_allowed_env_var("XDG_DATA_DIRS")); - 841
assert!(!is_allowed_env_var("XDG")); - 842
} - 843
- 844
#[test] - 845
fn scrub_environment_only_carries_allowlist_into_child() { - 846
// Observational test: after scrubbing, the command's env must be a - 847
// strict subset of the process env filtered through the allowlist. - 848
// This validates the real filtering pipeline without mutating any - 849
// process-global state. - 850
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - 851
let mut cmd = tokio::process::Command::new("sh"); - 852
cmd.env("VAK_TEST_SECRET", "must-not-survive"); - 853
cmd.env("PATH", "/bin:/usr/bin"); - 854
scrub_environment(&mut cmd); - 855
let scrubbed: std::collections::HashSet<String> = cmd - 856
.as_std() - 857
.get_envs() - 858
.filter(|(_, v)| v.is_some()) - 859
.filter_map(|(k, _)| k.to_str().map(str::to_string)) - 860
.collect(); - 861
// No var that fails the predicate may be present. - 862
for key in &scrubbed { - 863
assert!( - 864
is_allowed_env_var(key), - 865
"`{key}` survived scrubbing but is not on the allowlist" - 866
); - 867
} - 868
// A var we set on the command object before scrubbing must be gone. - 869
assert!( - 870
!scrubbed.contains("VAK_TEST_SECRET"), - 871
"command-level env must be cleared by scrub" - 872
); - 873
} - 874
- 875
#[test] - 876
fn scrub_environment_drops_process_secrets_at_runtime() { - 877
// End-to-end security boundary: a secret in THIS process's environment - 878
// must not reach the sandboxed child. We set a representative set of - 879
// credential variable names, scrub, and verify each is absent from the - 880
// command's env as seen from the child. - 881
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - 882
for secret in [ - 883
"VAK_SCRUB_TEST_SECRET", - 884
"VAK_SCRUB_TEST_API_KEY", - 885
"VAK_SCRUB_TEST_TOKEN", - 886
] { - 887
// SAFETY: test-only mutation of process-global env, serialised by - 888
// ENV_LOCK and cleaned up in the same scope. No production code - 889
// is affected. This is the narrow, annotated unsafe exception - 890
// pattern already used in this module for process-group kill. - 891
#[allow(unsafe_code)] - 892
unsafe { - 893
std::env::set_var(secret, "super-secret-value"); - 894
} - 895
} - 896
let cmd = { - 897
let mut c = tokio::process::Command::new("sh"); - 898
scrub_environment(&mut c); - 899
c - 900
}; - 901
let keys: Vec<String> = cmd - 902
.as_std() - 903
.get_envs() - 904
.filter(|(_, v)| v.is_some()) - 905
.filter_map(|(k, _)| k.to_str().map(str::to_string)) - 906
.collect(); - 907
for secret in [ - 908
"VAK_SCRUB_TEST_SECRET", - 909
"VAK_SCRUB_TEST_API_KEY", - 910
"VAK_SCRUB_TEST_TOKEN", - 911
] { - 912
assert!( - 913
!keys.contains(&secret.to_string()), - 914
"`{secret}` leaked into child env" - 915
); - 916
} - 917
// Cleanup - 918
for secret in [ - 919
"VAK_SCRUB_TEST_SECRET", - 920
"VAK_SCRUB_TEST_API_KEY", - 921
"VAK_SCRUB_TEST_TOKEN", - 922
] { - 923
#[allow(unsafe_code)] - 924
unsafe { - 925
std::env::remove_var(secret); - 926
} - 927
} - 928
} - 929
- 930
#[test] - 931
fn package_detection_covers_multiple_ecosystems() { - 932
use super::detect_installed_packages; - 933
assert_eq!( - 934
detect_installed_packages("pip install numpy pandas"), - 935
Some(vec!["numpy".into(), "pandas".into()]) - 936
); - 937
assert_eq!( - 938
detect_installed_packages("npm i -D typescript solid-js"), - 939
Some(vec!["typescript".into(), "solid-js".into()]) - 940
); - 941
assert_eq!( - 942
detect_installed_packages("cargo add tokio serde"), - 943
Some(vec!["tokio".into(), "serde".into()]) - 944
); - 945
assert_eq!( - 946
detect_installed_packages("uv add fastapi uvicorn"), - 947
Some(vec!["fastapi".into(), "uvicorn".into()]) - 948
); - 949
assert_eq!( - 950
detect_installed_packages("pnpm add tailwindcss"), - 951
Some(vec!["tailwindcss".into()]) - 952
); - 953
assert_eq!(detect_installed_packages("ls -la"), None); - 954
} - 955
- 956
#[test] - 957
fn mime_type_guessing() { - 958
use super::guess_mime_type; - 959
use std::path::Path; - 960
assert_eq!(guess_mime_type(Path::new("chart.png")), "image/png"); - 961
assert_eq!(guess_mime_type(Path::new("index.html")), "text/html"); - 962
assert_eq!(guess_mime_type(Path::new("data.csv")), "text/csv"); - 963
assert_eq!( - 964
guess_mime_type(Path::new("unknown.xyz")), - 965
"application/octet-stream" - 966
); - 967
} - 968
- 969
#[cfg(unix)] - 970
#[tokio::test] - 971
async fn killing_the_group_reaches_a_background_grandchild() { - 972
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; - 973
let mut cmd = super::shell_command("sleep 30 & echo started; wait"); - 974
cmd.stdin(std::process::Stdio::null()) - 975
.stdout(std::process::Stdio::piped()); - 976
super::isolate_process_group(&mut cmd); - 977
let mut child = cmd.spawn().unwrap(); - 978
let mut stdout = BufReader::new(child.stdout.take().unwrap()); - 979
let mut line = String::new(); - 980
stdout.read_line(&mut line).await.unwrap(); - 981
assert_eq!(line, "started\n"); - 982
- 983
super::kill_process_group(&child.id()); - 984
// The background sleep holds stdout open: the pipe closes before - 985
// the sleep would end only if the signal reached the whole group. - 986
let mut rest = Vec::new(); - 987
tokio::time::timeout( - 988
std::time::Duration::from_secs(10), - 989
stdout.read_to_end(&mut rest), - 990
) - 991
.await - 992
.expect("the group kill missed the background sleep") - 993
.unwrap(); - 994
child.wait().await.unwrap(); - 995
} - 996
- 997
#[test] - 998
fn control_files_are_not_available_to_sandbox_commands() { - 999
assert!(super::references_control_file("cat .env")); - 1000
assert!(super::references_control_file("cp result .vak/config.toml"));
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.