- 1
//! Docker execution backend (docs/design/25-docker-sandbox.md): runs bash - 2
//! commands inside a throwaway container with the workspace bind-mounted at - 3
//! its real absolute path, so host-side file tools and container-side shell - 4
//! see identical paths. - 5
//! - 6
//! Posture: no network, capped CPU/memory, read-only rootfs in ReadOnly - 7
//! mode, and a disposable writable rootfs in WorkspaceWrite mode. Only BashTool consults the sandbox seam today — file tools keep - 8
//! their host-side confinement via the permission engine. - 9
- 10
use std::path::{Path, PathBuf}; - 11
use std::process::{Command, Output}; - 12
use std::sync::Arc; - 13
use std::sync::OnceLock; - 14
use std::sync::atomic::{AtomicU64, Ordering}; - 15
- 16
use crate::backend::SandboxMode; - 17
- 18
pub const DEFAULT_IMAGE: &str = "alpine:3.20"; - 19
const MEMORY_CAP: &str = "2g"; - 20
const CPUS_CAP: &str = "2"; - 21
const PIDS_CAP: &str = "256"; - 22
const STORAGE_CAP: &str = "4g"; - 23
static TASK_SEQUENCE: AtomicU64 = AtomicU64::new(0); - 24
- 25
/// A task-scoped Docker environment. Its writable container layer survives - 26
/// multiple commands and is destroyed explicitly or when the owner drops it. - 27
/// The workspace is the only host path it can see. - 28
pub struct DockerTaskEnvironment { - 29
container_id: String, - 30
workspace: PathBuf, - 31
} - 32
- 33
impl DockerTaskEnvironment { - 34
pub fn create( - 35
mode: SandboxMode, - 36
image: Option<String>, - 37
workspace: &Path, - 38
broker_socket: Option<&Path>, - 39
) -> Result<Self, String> { - 40
let workspace = workspace - 41
.canonicalize() - 42
.map_err(|e| format!("workspace is not accessible: {e}"))?; - 43
let image = image.unwrap_or_else(|| DEFAULT_IMAGE.to_string()); - 44
let name = format!( - 45
"vak-task-{}-{}-{}", - 46
std::process::id(), - 47
std::time::SystemTime::now() - 48
.duration_since(std::time::UNIX_EPOCH) - 49
.map(|duration| duration.as_nanos()) - 50
.unwrap_or_default(), - 51
TASK_SEQUENCE.fetch_add(1, Ordering::Relaxed) - 52
); - 53
let ws = workspace.display().to_string(); - 54
let mount = match mode { - 55
SandboxMode::ReadOnly => format!("{ws}:{ws}:ro"), - 56
SandboxMode::WorkspaceWrite | SandboxMode::Off => format!("{ws}:{ws}"), - 57
}; - 58
let mut args = vec![ - 59
"run".to_string(), - 60
"-d".to_string(), - 61
"--rm".to_string(), - 62
"--name".to_string(), - 63
name, - 64
"--network".to_string(), - 65
"none".to_string(), - 66
"--memory".to_string(), - 67
MEMORY_CAP.to_string(), - 68
"--cpus".to_string(), - 69
CPUS_CAP.to_string(), - 70
"--pids-limit".to_string(), - 71
PIDS_CAP.to_string(), - 72
"--cap-drop".to_string(), - 73
"ALL".to_string(), - 74
"--security-opt".to_string(), - 75
"no-new-privileges".to_string(), - 76
"-v".to_string(), - 77
mount, - 78
]; - 79
args.extend([ - 80
"--storage-opt".to_string(), - 81
format!("size={STORAGE_CAP}"), - 82
"--tmpfs".to_string(), - 83
"/tmp:rw,nosuid,nodev,noexec,size=512m".to_string(), - 84
]); - 85
if mode == SandboxMode::ReadOnly { - 86
args.push("--read-only".to_string()); - 87
} - 88
if let Some(socket) = broker_socket - 89
&& socket.exists() - 90
{ - 91
args.extend([ - 92
"-v".to_string(), - 93
format!("{}:{}", socket.display(), socket.display()), - 94
"-e".to_string(), - 95
format!("VAK_AGENT_NETWORK_SOCKET={}", socket.display()), - 96
]); - 97
} - 98
args.extend([ - 99
"-w".to_string(), - 100
ws, - 101
image, - 102
"sh".to_string(), - 103
"-c".to_string(), - 104
"while :; do sleep 3600; done".to_string(), - 105
]); - 106
let output = Command::new("docker") - 107
.args(args) - 108
.output() - 109
.map_err(|e| format!("docker task environment spawn failed: {e}"))?; - 110
if !output.status.success() { - 111
return Err(format_docker_error( - 112
"docker task environment create failed", - 113
&output, - 114
)); - 115
} - 116
let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string(); - 117
if container_id.is_empty() { - 118
return Err("docker task environment returned no container id".into()); - 119
} - 120
Ok(Self { - 121
container_id, - 122
workspace, - 123
}) - 124
} - 125
- 126
pub fn exec(&self, command: &str) -> Result<Output, String> { - 127
Command::new("docker") - 128
.args(["exec", "-w"]) - 129
.arg(&self.workspace) - 130
.arg(&self.container_id) - 131
.args(["sh", "-c", command]) - 132
.output() - 133
.map_err(|e| format!("docker task environment exec failed: {e}")) - 134
} - 135
- 136
pub fn container_id(&self) -> &str { - 137
&self.container_id - 138
} - 139
- 140
pub fn destroy(&self) -> Result<(), String> { - 141
let output = Command::new("docker") - 142
.args(["rm", "-f", &self.container_id]) - 143
.output() - 144
.map_err(|e| format!("docker task environment cleanup failed: {e}"))?; - 145
if output.status.success() { - 146
Ok(()) - 147
} else { - 148
Err(format_docker_error( - 149
"docker task environment cleanup failed", - 150
&output, - 151
)) - 152
} - 153
} - 154
} - 155
- 156
/// Sandbox adapter for a task environment whose root layer survives each - 157
/// command in the same agent turn. - 158
pub struct DockerTaskSandbox { - 159
environment: Arc<DockerTaskEnvironment>, - 160
mode: SandboxMode, - 161
workspace: PathBuf, - 162
image: Option<String>, - 163
broker_socket: Option<PathBuf>, - 164
} - 165
- 166
impl DockerTaskSandbox { - 167
pub fn create( - 168
mode: SandboxMode, - 169
image: Option<String>, - 170
workspace: &Path, - 171
broker_socket: Option<&Path>, - 172
) -> Result<Self, String> { - 173
let workspace = workspace - 174
.canonicalize() - 175
.map_err(|e| format!("workspace is not accessible: {e}"))?; - 176
Ok(Self { - 177
environment: Arc::new(DockerTaskEnvironment::create( - 178
mode, - 179
image.clone(), - 180
&workspace, - 181
broker_socket, - 182
)?), - 183
mode, - 184
workspace, - 185
image, - 186
broker_socket: broker_socket.map(Path::to_path_buf), - 187
}) - 188
} - 189
} - 190
- 191
impl crate::backend::Sandbox for DockerTaskSandbox { - 192
fn name(&self) -> &str { - 193
match self.mode { - 194
SandboxMode::ReadOnly => "docker-task-ro", - 195
SandboxMode::WorkspaceWrite | SandboxMode::Off => "docker-task", - 196
} - 197
} - 198
- 199
fn wrap(&self, command: &str) -> String { - 200
format!( - 201
"docker exec -w {} {} sh -c {}", - 202
shell_quote(&self.workspace.display().to_string()), - 203
shell_quote(self.environment.container_id()), - 204
shell_quote(command), - 205
) - 206
} - 207
- 208
fn target(&self) -> crate::backend::SandboxTarget { - 209
crate::backend::SandboxTarget::ToolCommand - 210
} - 211
- 212
fn read_only_variant(&self) -> Option<Arc<dyn crate::backend::Sandbox>> { - 213
if self.mode == SandboxMode::ReadOnly { - 214
return Some(Arc::new(Self { - 215
environment: self.environment.clone(), - 216
mode: SandboxMode::ReadOnly, - 217
workspace: self.workspace.clone(), - 218
image: self.image.clone(), - 219
broker_socket: self.broker_socket.clone(), - 220
})); - 221
} - 222
Self::create( - 223
SandboxMode::ReadOnly, - 224
self.image.clone(), - 225
&self.workspace, - 226
self.broker_socket.as_deref(), - 227
) - 228
.ok() - 229
.map(|sandbox| Arc::new(sandbox) as Arc<dyn crate::backend::Sandbox>) - 230
} - 231
} - 232
- 233
impl Drop for DockerTaskEnvironment { - 234
fn drop(&mut self) { - 235
let _ = self.destroy(); - 236
} - 237
} - 238
- 239
fn format_docker_error(prefix: &str, output: &Output) -> String { - 240
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - 241
if stderr.is_empty() { - 242
prefix.to_string() - 243
} else { - 244
format!("{prefix}: {stderr}") - 245
} - 246
} - 247
- 248
pub struct DockerSandbox { - 249
mode: SandboxMode, - 250
image: String, - 251
workspace: PathBuf, - 252
} - 253
- 254
impl DockerSandbox { - 255
pub fn new(mode: SandboxMode, image: Option<String>, workspace: &Path) -> Self { - 256
DockerSandbox { - 257
mode, - 258
image: image.unwrap_or_else(|| DEFAULT_IMAGE.to_string()), - 259
workspace: workspace.to_path_buf(), - 260
} - 261
} - 262
- 263
/// One-shot probe with process-lifetime caching; `docker info` answers - 264
/// whether a daemon is reachable without pulling anything. - 265
pub fn available() -> bool { - 266
static OK: OnceLock<bool> = OnceLock::new(); - 267
*OK.get_or_init(|| { - 268
std::process::Command::new("docker") - 269
.arg("info") - 270
.stdout(std::process::Stdio::null()) - 271
.stderr(std::process::Stdio::null()) - 272
.status() - 273
.map(|s| s.success()) - 274
.unwrap_or(false) - 275
}) - 276
} - 277
- 278
/// The command BashTool will run, as `docker run … sh -c <quoted>`. - 279
pub fn wrap_command(&self, command: &str) -> String { - 280
let ws = self.workspace.display().to_string(); - 281
let ro_suffix = match self.mode { - 282
SandboxMode::ReadOnly => ":ro", - 283
SandboxMode::WorkspaceWrite | SandboxMode::Off => "", - 284
}; - 285
let mount = shell_quote(&format!("{ws}:{ws}{ro_suffix}")); - 286
let workdir = shell_quote(&ws); - 287
let image = shell_quote(&self.image); - 288
let rootfs = match self.mode { - 289
SandboxMode::ReadOnly => "--read-only ", - 290
// This layer is private to the throwaway container and disappears - 291
// with --rm, so package installation cannot modify the host. - 292
SandboxMode::WorkspaceWrite | SandboxMode::Off => "", - 293
}; - 294
format!( - 295
"docker run --rm --network none {rootfs}--tmpfs /tmp:rw,nosuid,size=512m \ - 296
--memory {MEMORY_CAP} --cpus {CPUS_CAP} --pids-limit 256 --cap-drop ALL \ - 297
--security-opt no-new-privileges -v {mount} -w {workdir} {image} sh -c {cmd}", - 298
cmd = shell_quote(command), - 299
) - 300
} - 301
} - 302
- 303
impl crate::backend::Sandbox for DockerSandbox { - 304
fn name(&self) -> &str { - 305
match self.mode { - 306
SandboxMode::ReadOnly => "docker-ro", - 307
SandboxMode::WorkspaceWrite | SandboxMode::Off => "docker", - 308
} - 309
} - 310
- 311
fn wrap(&self, command: &str) -> String { - 312
self.wrap_command(command) - 313
} - 314
- 315
fn target(&self) -> crate::backend::SandboxTarget { - 316
crate::backend::SandboxTarget::ToolCommand - 317
} - 318
- 319
fn read_only_variant(&self) -> Option<Arc<dyn crate::backend::Sandbox>> { - 320
Some(Arc::new(DockerSandbox::new( - 321
SandboxMode::ReadOnly, - 322
Some(self.image.clone()), - 323
&self.workspace, - 324
))) - 325
} - 326
} - 327
- 328
/// Single-argument POSIX shell quoting, sufficient for the inner layer of - 329
/// the two-layer quoting discipline (BashTool quotes the outer layer). - 330
fn shell_quote(s: &str) -> String { - 331
let mut out = String::with_capacity(s.len() + 2); - 332
out.push('\''); - 333
for ch in s.chars() { - 334
if ch == '\'' { - 335
out.push_str("'\\''"); - 336
} else { - 337
out.push(ch); - 338
} - 339
} - 340
out.push('\''); - 341
out - 342
} - 343
- 344
#[cfg(test)] - 345
mod tests { - 346
#![allow(clippy::unwrap_used, clippy::expect_used)] - 347
- 348
use super::*; - 349
// Trait method access inside this module. - 350
use crate::backend::Sandbox as _; - 351
- 352
#[test] - 353
fn wrap_embeds_workspace_mount_and_quotes_inner_command() { - 354
let sb = DockerSandbox::new( - 355
SandboxMode::WorkspaceWrite, - 356
Some("test-image:9".into()), - 357
Path::new("/tmp/ws"), - 358
); - 359
let wrapped = sb.wrap("echo 'hello world' && ls"); - 360
assert!(wrapped.starts_with("docker run --rm --network none ")); - 361
assert!(!wrapped.contains("--read-only")); - 362
assert!(wrapped.contains("-v '/tmp/ws:/tmp/ws'")); - 363
assert!(wrapped.contains("-w '/tmp/ws'")); - 364
assert!(wrapped.contains("--memory 2g")); - 365
assert!(wrapped.contains("--pids-limit 256")); - 366
assert!(wrapped.contains("--cap-drop ALL")); - 367
assert!(wrapped.contains("'test-image:9'")); - 368
assert!( - 369
wrapped.ends_with("sh -c 'echo '\\''hello world'\\'' && ls'"), - 370
"inner command must be single-quoted: {wrapped}" - 371
); - 372
} - 373
- 374
#[test] - 375
fn readonly_mode_mounts_ro_and_adds_tmp() { - 376
let sb = DockerSandbox::new(SandboxMode::ReadOnly, None, Path::new("/tmp/ws")); - 377
let wrapped = sb.wrap("true"); - 378
assert!(wrapped.contains("'/tmp/ws:/tmp/ws:ro'")); - 379
assert!(wrapped.contains("--network none --read-only --tmpfs")); - 380
assert!(wrapped.contains("--tmpfs /tmp:rw,nosuid,size=512m")); - 381
assert!(wrapped.contains(DEFAULT_IMAGE)); - 382
} - 383
} - 384
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.