- 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")); - 1001
assert!(!super::references_control_file("echo ok > result.txt")); - 1002
} - 1003
- 1004
#[tokio::test] - 1005
async fn cwd_dot_runs_in_the_workspace_root() { - 1006
use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; - 1007
let workspace = tempfile::tempdir().unwrap(); - 1008
let test_file = workspace.path().join("marker.txt"); - 1009
std::fs::write(&test_file, "workspace-marker").unwrap(); - 1010
- 1011
let (sink, _rx) = SandboxEventSink::new_with_id("test-quarantine-false".into()); - 1012
let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox_sink(sink); - 1013
- 1014
let tool = super::BashTool; - 1015
let out = tool - 1016
.execute( - 1017
&serde_json::json!({ - 1018
"command": "cat marker.txt", - 1019
"cwd": "." - 1020
}), - 1021
&ctx, - 1022
) - 1023
.await; - 1024
assert!(!out.is_error, "{}", out.content); - 1025
assert!(out.content.contains("workspace-marker")); - 1026
} - 1027
- 1028
#[tokio::test] - 1029
async fn a_command_works_in_the_workspace_and_keeps_temp_files_in_scratch() { - 1030
use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; - 1031
let workspace = tempfile::tempdir().unwrap(); - 1032
std::fs::write(workspace.path().join("unsorted.txt"), "delta\nalpha\n").unwrap(); - 1033
- 1034
let (sink, mut events) = SandboxEventSink::new_with_id("sort-1".into()); - 1035
let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox_sink(sink); - 1036
let out = super::BashTool - 1037
.execute( - 1038
&serde_json::json!({ - 1039
"command": "sort unsorted.txt > sorted.txt && echo \"tmp=$TMPDIR\"" - 1040
}), - 1041
&ctx, - 1042
) - 1043
.await; - 1044
assert!(!out.is_error, "{}", out.content); - 1045
assert_eq!( - 1046
std::fs::read_to_string(workspace.path().join("sorted.txt")).unwrap(), - 1047
"alpha\ndelta\n", - 1048
"the output is where `read` looks for it" - 1049
); - 1050
let temp = workspace.path().join(".vak/scratch/vak/sort-1/tmp"); - 1051
assert!( - 1052
out.content.contains(&format!("tmp={}", temp.display())), - 1053
"{}", - 1054
out.content - 1055
); - 1056
let mut reported = false; - 1057
while let Ok(event) = events.try_recv() { - 1058
if let crate::SandboxEvent::ArtifactGenerated { path, .. } = event { - 1059
reported |= path == "sorted.txt"; - 1060
} - 1061
} - 1062
assert!(reported, "a new deliverable is reported to the Workbench"); - 1063
} - 1064
- 1065
#[tokio::test] - 1066
async fn custom_cwd_is_respected() { - 1067
use crate::{Tool, ToolContext, sandbox_events::SandboxEventSink}; - 1068
let workspace = tempfile::tempdir().unwrap(); - 1069
let sub = workspace - 1070
.path() - 1071
.join(".vak/scratch/vak/test-custom-cwd/sub_module"); - 1072
std::fs::create_dir_all(&sub).unwrap(); - 1073
std::fs::write(sub.join("sub.txt"), "in-sub").unwrap(); - 1074
- 1075
let (sink, _rx) = SandboxEventSink::new_with_id("test-custom-cwd".into()); - 1076
let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox_sink(sink); - 1077
- 1078
let tool = super::BashTool; - 1079
let out = tool - 1080
.execute( - 1081
&serde_json::json!({ - 1082
"command": "cat sub.txt", - 1083
"cwd": ".vak/scratch/vak/test-custom-cwd/sub_module" - 1084
}), - 1085
&ctx, - 1086
) - 1087
.await; - 1088
assert!(!out.is_error, "{}", out.content); - 1089
assert!(out.content.contains("in-sub")); - 1090
}
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.