- 1
use std::path::{Path, PathBuf}; - 2
use std::sync::Arc; - 3
- 4
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 5
pub enum SandboxMode { - 6
Off, - 7
ReadOnly, - 8
WorkspaceWrite, - 9
} - 10
- 11
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 12
pub enum SandboxTarget { - 13
WorkerProcess, - 14
ToolCommand, - 15
} - 16
- 17
pub trait Sandbox: Send + Sync { - 18
fn name(&self) -> &str; - 19
fn wrap(&self, command: &str) -> String; - 20
- 21
fn target(&self) -> SandboxTarget { - 22
SandboxTarget::WorkerProcess - 23
} - 24
- 25
fn read_only_variant(&self) -> Option<Arc<dyn Sandbox>> { - 26
None - 27
} - 28
- 29
/// This sandbox, plus listening on a port: for a dev-server preview the - 30
/// user configured, and nothing else. Outbound connections stay denied. - 31
/// `None` when the backend cannot grant it, and the caller keeps the - 32
/// closed sandbox. - 33
fn listening_variant(&self) -> Option<Arc<dyn Sandbox>> { - 34
None - 35
} - 36
} - 37
- 38
pub fn no_sandbox() -> Option<Arc<dyn Sandbox>> { - 39
None - 40
} - 41
- 42
#[derive(Debug, Clone)] - 43
pub struct DenySandbox { - 44
reason: String, - 45
} - 46
- 47
impl DenySandbox { - 48
pub fn new(reason: impl Into<String>) -> Self { - 49
Self { - 50
reason: reason.into(), - 51
} - 52
} - 53
} - 54
- 55
impl Sandbox for DenySandbox { - 56
fn name(&self) -> &str { - 57
"unavailable-deny" - 58
} - 59
- 60
fn wrap(&self, _command: &str) -> String { - 61
format!( - 62
"echo {} >&2; exit 126", - 63
shell_quote(&format!("vak sandbox unavailable: {}", self.reason)) - 64
) - 65
} - 66
- 67
fn read_only_variant(&self) -> Option<Arc<dyn Sandbox>> { - 68
Some(Arc::new(self.clone())) - 69
} - 70
} - 71
- 72
#[derive(Debug, Clone)] - 73
pub struct Seatbelt { - 74
pub mode: SandboxMode, - 75
pub read_paths: Vec<PathBuf>, - 76
pub write_paths: Vec<PathBuf>, - 77
/// Task copies must not inherit the broad host temp write allowances. - 78
pub allow_host_temp: bool, - 79
/// May accept connections on a port (`Sandbox::listening_variant`). - 80
/// Seatbelt cannot limit this to loopback: a server that binds every - 81
/// interface is reachable from the LAN, as it would be run by hand. - 82
/// Outbound connections stay denied. - 83
pub allow_listen: bool, - 84
} - 85
- 86
impl Seatbelt { - 87
pub fn new(mode: SandboxMode, cwd: &Path) -> Self { - 88
Self::build(mode, cwd, true) - 89
} - 90
- 91
/// Contain a retained task copy, including when the original workspace - 92
/// itself lives below a host temp directory. - 93
pub fn task_copy(mode: SandboxMode, cwd: &Path) -> Self { - 94
Self::build(mode, cwd, false) - 95
} - 96
- 97
fn build(mode: SandboxMode, cwd: &Path, allow_host_temp: bool) -> Self { - 98
let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); - 99
let mut read_paths: Vec<PathBuf> = [ - 100
"/System", - 101
"/usr", - 102
"/bin", - 103
"/sbin", - 104
"/Library", - 105
"/Applications", - 106
"/opt", - 107
"/private/etc", - 108
"/private/var/db", - 109
"/dev", - 110
] - 111
.into_iter() - 112
.map(PathBuf::from) - 113
.filter(|path| path.exists()) - 114
.collect(); - 115
read_paths.push(canonical.clone()); - 116
if let Ok(executable) = std::env::current_exe() - 117
&& let Some(parent) = executable.parent() - 118
{ - 119
read_paths.push(parent.to_path_buf()); - 120
} - 121
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { - 122
for relative in [ - 123
".cargo/bin", - 124
".cargo/git", - 125
".cargo/registry", - 126
".rustup", - 127
".local/bin", - 128
] { - 129
let path = home.join(relative); - 130
if path.exists() { - 131
read_paths.push(path); - 132
} - 133
} - 134
} - 135
if allow_host_temp { - 136
for path in Self::temp_write_paths() { - 137
let path = PathBuf::from(path); - 138
if path.exists() && !read_paths.contains(&path) { - 139
read_paths.push(path); - 140
} - 141
} - 142
} - 143
let write_paths = match mode { - 144
SandboxMode::WorkspaceWrite => vec![canonical], - 145
_ => Vec::new(), - 146
}; - 147
Seatbelt { - 148
mode, - 149
read_paths, - 150
write_paths, - 151
allow_host_temp, - 152
allow_listen: false, - 153
} - 154
} - 155
- 156
/// Extra write allowances that keep real-world tooling working under - 157
/// workspace-write: the OS per-user temp/cache areas (macOS TMPDIR lives - 158
/// under /var/folders, NOT /tmp) and /tmp itself. Without these, any - 159
/// test suite using tempfile/std::env::temp_dir fails under the sandbox - 160
/// — found by dogfooding `cargo test` through the agent. - 161
pub fn temp_write_paths() -> Vec<String> { - 162
let mut out = vec![ - 163
"/private/tmp".to_string(), - 164
"/private/var/tmp".to_string(), - 165
"/private/var/folders".to_string(), - 166
"/tmp".to_string(), - 167
]; - 168
if let Some(tmpdir) = std::env::var_os("TMPDIR") { - 169
let p = std::path::PathBuf::from(&tmpdir).display().to_string(); - 170
if !out.contains(&p) { - 171
out.push(p); - 172
} - 173
} - 174
out - 175
} - 176
- 177
pub fn profile(&self) -> String { - 178
let mut p = String::from("(version 1)\n"); - 179
match self.mode { - 180
SandboxMode::Off => return "(version 1)(allow default)".to_string(), - 181
SandboxMode::ReadOnly => { - 182
p.push_str("(deny default)\n"); - 183
self.append_read_allowances(&mut p); - 184
p.push_str("(allow process-exec)\n"); - 185
p.push_str("(allow process-fork)\n"); - 186
p.push_str("(allow sysctl-read)\n"); - 187
p.push_str("(allow mach-lookup)\n"); - 188
p.push_str("(allow iokit-get-properties)\n"); - 189
} - 190
SandboxMode::WorkspaceWrite => { - 191
p.push_str("(deny default)\n"); - 192
self.append_read_allowances(&mut p); - 193
p.push_str("(allow process-exec)\n"); - 194
p.push_str("(allow process-fork)\n"); - 195
p.push_str("(allow sysctl-read)\n"); - 196
p.push_str("(allow mach-lookup)\n"); - 197
p.push_str("(allow iokit-get-properties)\n"); - 198
for path in &self.write_paths { - 199
p.push_str(&format!( - 200
"(allow file-write* (subpath {}))\n", - 201
sbpl_quote(&path.display().to_string()) - 202
)); - 203
} - 204
if self.allow_host_temp { - 205
for tmp in Self::temp_write_paths() { - 206
p.push_str(&format!( - 207
"(allow file-write* (subpath {}))\n", - 208
sbpl_quote(&tmp) - 209
)); - 210
} - 211
} - 212
for dev in ["/dev/null", "/dev/urandom"] { - 213
p.push_str(&format!( - 214
"(allow file-write* (subpath {}))\n", - 215
sbpl_quote(dev) - 216
)); - 217
} - 218
} - 219
} - 220
if self.allow_listen { - 221
p.push_str("(allow network-bind (local ip \"localhost:*\"))\n"); - 222
p.push_str("(allow network-inbound (local ip \"localhost:*\"))\n"); - 223
} - 224
p - 225
} - 226
- 227
fn append_read_allowances(&self, profile: &mut String) { - 228
profile.push_str("(allow file-read-metadata)\n"); - 229
profile.push_str("(allow file-read* (literal \"/\"))\n"); - 230
for path in &self.read_paths { - 231
profile.push_str(&format!( - 232
"(allow file-read* (subpath {}))\n", - 233
sbpl_quote(&path.display().to_string()) - 234
)); - 235
} - 236
} - 237
} - 238
- 239
/// SBPL string literal: escape backslash and double quote so a path - 240
/// containing quotes cannot break out of (or corrupt) the profile. - 241
fn sbpl_quote(s: &str) -> String { - 242
let mut out = String::with_capacity(s.len() + 2); - 243
out.push('"'); - 244
for c in s.chars() { - 245
match c { - 246
'\\' => out.push_str("\\\\"), - 247
'"' => out.push_str("\\\""), - 248
_ => out.push(c), - 249
} - 250
} - 251
out.push('"'); - 252
out - 253
} - 254
- 255
impl Sandbox for Seatbelt { - 256
fn name(&self) -> &str { - 257
"seatbelt" - 258
} - 259
- 260
fn wrap(&self, command: &str) -> String { - 261
if self.mode == SandboxMode::Off { - 262
return command.to_string(); - 263
} - 264
// The profile itself goes through single-quote shell escaping — - 265
// stripping characters would silently alter the policy for paths - 266
// containing them. - 267
format!( - 268
"sandbox-exec -p {} -- sh -c {}", - 269
shell_quote(&self.profile()), - 270
shell_quote(command) - 271
) - 272
} - 273
- 274
fn read_only_variant(&self) -> Option<Arc<dyn Sandbox>> { - 275
Some(Arc::new(Seatbelt { - 276
mode: SandboxMode::ReadOnly, - 277
read_paths: self.read_paths.clone(), - 278
write_paths: Vec::new(), - 279
allow_host_temp: self.allow_host_temp, - 280
allow_listen: self.allow_listen, - 281
})) - 282
} - 283
- 284
fn listening_variant(&self) -> Option<Arc<dyn Sandbox>> { - 285
Some(Arc::new(Seatbelt { - 286
allow_listen: true, - 287
..self.clone() - 288
})) - 289
} - 290
} - 291
- 292
fn shell_quote(s: &str) -> String { - 293
let mut out = String::with_capacity(s.len() + 2); - 294
out.push('\''); - 295
for c in s.chars() { - 296
if c == '\'' { - 297
out.push_str("'\\''"); - 298
} else { - 299
out.push(c); - 300
} - 301
} - 302
out.push('\''); - 303
out - 304
} - 305
- 306
#[cfg(test)] - 307
mod tests { - 308
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 309
use super::*; - 310
- 311
#[test] - 312
fn task_copy_profile_does_not_grant_host_temp_writes() { - 313
let copy = tempfile::tempdir().expect("task copy"); - 314
let sandbox = Seatbelt::task_copy(SandboxMode::WorkspaceWrite, copy.path()); - 315
let profile = sandbox.profile(); - 316
let canonical = copy.path().canonicalize().expect("canonical copy"); - 317
assert!(profile.contains(&format!( - 318
"(allow file-write* (subpath \"{}\"))", - 319
canonical.display() - 320
))); - 321
assert!(!profile.contains("(allow file-write* (subpath \"/private/tmp\"))")); - 322
assert!(!profile.contains("(allow file-write* (subpath \"/private/var/folders\"))")); - 323
assert!(!sandbox.read_paths.contains(&PathBuf::from("/private/tmp"))); - 324
} - 325
- 326
#[cfg(target_os = "macos")] - 327
#[test] - 328
fn task_copy_worker_can_write_copy_but_not_sibling_workspace() { - 329
let root = tempfile::tempdir().expect("test root"); - 330
let copy = root.path().join("task-copy"); - 331
let source = root.path().join("original-workspace"); - 332
std::fs::create_dir_all(©).expect("copy directory"); - 333
std::fs::create_dir_all(&source).expect("source directory"); - 334
let sandbox = Seatbelt::task_copy(SandboxMode::WorkspaceWrite, ©); - 335
let inside = copy.join("allowed.txt"); - 336
let outside = source.join("forbidden.txt"); - 337
let escaped = copy.join("outside-link"); - 338
std::os::unix::fs::symlink(&source, &escaped).expect("link to original workspace"); - 339
let allowed = sandbox.wrap(&format!( - 340
"echo allowed > {}", - 341
shell_quote(&inside.display().to_string()) - 342
)); - 343
let denied = sandbox.wrap(&format!( - 344
"echo forbidden > {}", - 345
shell_quote(&outside.display().to_string()) - 346
)); - 347
let denied_link = sandbox.wrap(&format!( - 348
"echo forbidden > {}", - 349
shell_quote(&escaped.join("through-link.txt").display().to_string()) - 350
)); - 351
assert!( - 352
std::process::Command::new("/bin/sh") - 353
.arg("-c") - 354
.arg(allowed) - 355
.status() - 356
.expect("allowed command") - 357
.success() - 358
); - 359
assert!( - 360
!std::process::Command::new("/bin/sh") - 361
.arg("-c") - 362
.arg(denied) - 363
.status() - 364
.expect("denied command") - 365
.success() - 366
); - 367
assert!( - 368
!std::process::Command::new("/bin/sh") - 369
.arg("-c") - 370
.arg(denied_link) - 371
.status() - 372
.expect("symlink escape command") - 373
.success() - 374
); - 375
assert_eq!( - 376
std::fs::read_to_string(inside).expect("copy write"), - 377
"allowed\n" - 378
); - 379
assert!(!outside.exists()); - 380
assert!(!source.join("through-link.txt").exists()); - 381
} - 382
- 383
/// Measured against the real `sandbox-exec`: the closed profile refuses - 384
/// to listen, the listening variant accepts, and neither can connect out. - 385
#[cfg(target_os = "macos")] - 386
#[test] - 387
fn only_the_listening_variant_listens_and_neither_connects_out() { - 388
let python = |code: &str| format!("python3 -c '{code}'"); - 389
let dir = tempfile::tempdir().expect("workspace"); - 390
// From the workspace, as a preview runs: Python reads its working - 391
// directory on import, and the profile only admits the workspace. - 392
let run = |wrapped: String| { - 393
std::process::Command::new("sh") - 394
.arg("-c") - 395
.arg(wrapped) - 396
.current_dir(dir.path()) - 397
.output() - 398
.expect("sh runs") - 399
}; - 400
if !run("python3 --version".into()).status.success() { - 401
eprintln!("python3 unavailable; skipping"); - 402
return; - 403
} - 404
let closed = Seatbelt::new(SandboxMode::WorkspaceWrite, dir.path()); - 405
let open = Sandbox::listening_variant(&closed).expect("seatbelt can listen"); - 406
let listen = python( - 407
"import socket; s = socket.socket(); s.bind((\"127.0.0.1\", 0)); s.listen(); print(\"listening\")", - 408
); - 409
let connect = python( - 410
"import socket; socket.create_connection((\"1.1.1.1\", 53), timeout=3); print(\"connected\")", - 411
); - 412
- 413
let refused = run(closed.wrap(&listen)); - 414
assert!(!String::from_utf8_lossy(&refused.stdout).contains("listening")); - 415
let listened = run(open.wrap(&listen)); - 416
assert!( - 417
String::from_utf8_lossy(&listened.stdout).contains("listening"), - 418
"{}", - 419
String::from_utf8_lossy(&listened.stderr) - 420
); - 421
for sandbox in [&closed as &dyn Sandbox, open.as_ref()] { - 422
let out = run(sandbox.wrap(&connect)); - 423
assert!(!String::from_utf8_lossy(&out.stdout).contains("connected")); - 424
} - 425
} - 426
- 427
#[test] - 428
fn deny_sandbox_wrap_emits_exit_126_with_reason() { - 429
let sb = DenySandbox::new("docker unavailable"); - 430
let wrapped = sb.wrap("echo hello"); - 431
assert!( - 432
wrapped.contains("exit 126"), - 433
"deny must refuse to run: {wrapped}" - 434
); - 435
assert!( - 436
wrapped.contains("vak sandbox unavailable: docker unavailable"), - 437
"{wrapped}" - 438
); - 439
// The original command must never appear unguarded. - 440
assert!( - 441
!wrapped.contains("echo hello") && !wrapped.contains("'echo hello'"), - 442
"denied command must not be embedded: {wrapped}" - 443
); - 444
} - 445
- 446
#[test] - 447
fn deny_sandbox_read_only_variant_preserves_deny() { - 448
let sb = DenySandbox::new("unsupported host"); - 449
let ro = Sandbox::read_only_variant(&sb).expect("deny must expose read-only variant"); - 450
let wrapped = ro.wrap("anything"); - 451
assert!(wrapped.contains("exit 126"), "{wrapped}"); - 452
assert_eq!(ro.name(), "unavailable-deny"); - 453
} - 454
- 455
#[test] - 456
fn seatbelt_read_only_variant_strips_write_paths() { - 457
let tmp = std::path::Path::new("/tmp"); - 458
let sb = Seatbelt::new(SandboxMode::WorkspaceWrite, tmp); - 459
let ro = Sandbox::read_only_variant(&sb).expect("read-only variant must exist"); - 460
// The profile is embedded inside wrap()'s sandbox-exec invocation; - 461
// inspect it there rather than downcasting the trait object. - 462
let wrapped = ro.wrap("true"); - 463
let profile_start = wrapped.find("(version 1)").expect("profile present"); - 464
let profile_end = wrapped - 465
.rfind("' -- sh -c") - 466
.expect("command follows profile"); - 467
let profile = &wrapped[profile_start..profile_end]; - 468
assert!( - 469
!profile.contains("file-write*"), - 470
"read-only variant must not grant any write allowance: {profile}" - 471
); - 472
} - 473
- 474
#[test] - 475
fn seatbelt_read_only_variant_name_and_mode() { - 476
let sb = Seatbelt::new(SandboxMode::WorkspaceWrite, std::path::Path::new("/tmp")); - 477
let ro = Sandbox::read_only_variant(&sb).expect("variant"); - 478
assert_eq!(ro.name(), "seatbelt"); - 479
} - 480
- 481
// ── quoting safety ──────────────────────────────────────────────── - 482
- 483
#[test] - 484
fn shell_quote_survives_single_quotes_and_semicolons() { - 485
let cmd = "echo 'it's great'; rm -rf /"; - 486
let quoted = shell_quote(cmd); - 487
// Must be a single balanced pair wrapping the whole string, with - 488
// embedded single quotes POSIX-escaped as '\''. - 489
assert_eq!( - 490
quoted.matches('\'').count() % 2, - 491
1, - 492
"must have odd count (outer pair + escapes): {quoted}" - 493
); - 494
- 495
// Re-executing the quoted string through sh must reproduce the - 496
// original verbatim (POSIX round-trip via single-quote escape). - 497
let verified = std::process::Command::new("sh") - 498
.arg("-c") - 499
.arg(format!("printf %s {}", quoted)) - 500
.output(); - 501
if let Ok(out) = verified { - 502
let text = String::from_utf8_lossy(&out.stdout); - 503
assert_eq!(text, cmd, "shell_quote round-trip failed: {quoted}"); - 504
} else { - 505
panic!("sh not available for round-trip test"); - 506
} - 507
} - 508
- 509
#[test] - 510
fn sbpl_quote_escapes_backslash_and_double_quote() { - 511
// A path containing a double-quote can't break out of the SBPL literal. - 512
let path = r#"path"with"quotes"#; - 513
let q = sbpl_quote(path); - 514
assert!(q.starts_with('"') && q.ends_with('"')); - 515
assert!( - 516
q.contains(r#"\""#), - 517
"embedded double-quote must be escaped: {q}" - 518
); - 519
// A backslash before a quote must be preserved literally, not - 520
// consumed as an escape by the shell-quote layer. - 521
let bs = r#"a\"b"#; - 522
let q2 = sbpl_quote(bs); - 523
assert!(q2.contains(r#"\\""#), "backslash must be escaped: {q2}"); - 524
} - 525
- 526
#[test] - 527
fn off_mode_passes_command_through_unchanged() { - 528
let sb = Seatbelt::new(SandboxMode::Off, std::path::Path::new("/tmp")); - 529
assert_eq!(Sandbox::wrap(&sb, "echo hi"), "echo hi"); - 530
} - 531
- 532
#[test] - 533
fn workspace_write_wrap_embeds_sandbox_exec_and_profile() { - 534
let dir = tempfile::tempdir().unwrap(); - 535
let sb = Seatbelt::new(SandboxMode::WorkspaceWrite, dir.path()); - 536
let wrapped = sb.wrap("ls -la"); - 537
assert!(wrapped.starts_with("sandbox-exec -p '")); - 538
assert!(wrapped.contains("(version 1)")); - 539
assert!(wrapped.ends_with("-- sh -c 'ls -la'")); - 540
} - 541
} - 542
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.