forge-agent-runtime 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,872 @@
1
+ """Ephemeral Docker-based command execution for agent tasks.
2
+
3
+ Commands run in a disposable, unprivileged container with configurable network access
4
+ against a sanitized workspace snapshot. Only changed regular files are copied back, and only for
5
+ AgentForge roles with read-write permission.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import stat
17
+ import tarfile
18
+ import tempfile
19
+ import time
20
+ import uuid
21
+ from dataclasses import asdict, dataclass
22
+ from pathlib import Path, PurePosixPath
23
+ from typing import Any
24
+
25
+ from forge_runtime.workspace import WorkspaceError, WorkspaceTools
26
+
27
+
28
+ class SandboxError(RuntimeError):
29
+ """Docker sandbox configuration or execution failed."""
30
+
31
+
32
+ @dataclass
33
+ class SandboxResult:
34
+ exit_code: int
35
+ stdout: str
36
+ stderr: str
37
+ duration_seconds: float
38
+ timed_out: bool = False
39
+ output_truncated: bool = False
40
+ changed_files: list[str] | None = None
41
+ applied_files: list[str] | None = None
42
+ changes_discarded: bool = False
43
+
44
+ def as_dict(self) -> dict[str, Any]:
45
+ data = asdict(self)
46
+ data["changed_files"] = self.changed_files or []
47
+ data["applied_files"] = self.applied_files or []
48
+ return data
49
+
50
+
51
+ class DockerSandboxExecutor:
52
+ """Run allowlisted developer commands in a locked-down Docker container."""
53
+
54
+ ALLOWED_COMMANDS = {
55
+ "bash", "sh", "python", "python3", "pytest", "ruff", "mypy", "coverage",
56
+ "tox", "nox", "uv", "pip", "pip3", "npm", "npx", "node", "git",
57
+ "make", "cmake", "gcc", "g++", "clang", "go", "cargo", "rustc",
58
+ "java", "javac", "mvn", "gradle", "php", "composer", "ruby", "bundle",
59
+ "rspec", "dart", "flutter", "curl", "wget",
60
+ }
61
+ MAX_ARGS = 64
62
+ MAX_ARGUMENT_CHARS = 16_000
63
+ DEFAULT_TIMEOUT_SECONDS = 180
64
+ MAX_TIMEOUT_SECONDS = 600
65
+ DEFAULT_OUTPUT_CHARS = 24_000
66
+ MAX_SNAPSHOT_FILES = 5_000
67
+ MAX_SNAPSHOT_BYTES = 512 * 1024 * 1024
68
+ MAX_SINGLE_FILE_BYTES = 32 * 1024 * 1024
69
+ SENSITIVE_NAMES = WorkspaceTools.SENSITIVE_FILE_NAMES
70
+ SENSITIVE_SUFFIXES = WorkspaceTools.SENSITIVE_SUFFIXES
71
+ EXCLUDED_DIRS = WorkspaceTools.EXCLUDED_DIRS
72
+ IGNORED_FILES = {
73
+ ".coverage", "coverage.xml", ".DS_Store", "npm-debug.log",
74
+ "yarn-error.log", "pnpm-debug.log",
75
+ }
76
+
77
+ def __init__(
78
+ self,
79
+ workspace_root: Path | str = ".",
80
+ *,
81
+ image: str = "forge-runtime-sandbox:latest",
82
+ docker_binary: str = "docker",
83
+ cpu_limit: float = 2.0,
84
+ memory_mb: int = 2048,
85
+ pids_limit: int = 128,
86
+ default_timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
87
+ max_timeout_seconds: int = MAX_TIMEOUT_SECONDS,
88
+ output_chars: int = DEFAULT_OUTPUT_CHARS,
89
+ network: str = "bridge",
90
+ max_snapshot_files: int = MAX_SNAPSHOT_FILES,
91
+ max_snapshot_bytes: int = MAX_SNAPSHOT_BYTES,
92
+ ) -> None:
93
+ self.workspace = WorkspaceTools(workspace_root)
94
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}", image):
95
+ raise SandboxError("Sandbox image name contains unsupported characters.")
96
+ if not docker_binary or any(char.isspace() for char in docker_binary):
97
+ raise SandboxError("Docker binary must be a single executable name or path.")
98
+ if network not in {"bridge", "none"}:
99
+ raise SandboxError("Sandbox network must be bridge (internet) or none (offline).")
100
+ if not 0.25 <= cpu_limit <= 16:
101
+ raise SandboxError("Sandbox CPU limit must be between 0.25 and 16.")
102
+ if not 256 <= memory_mb <= 32768:
103
+ raise SandboxError("Sandbox memory limit must be between 256 and 32768 MB.")
104
+ if not 32 <= pids_limit <= 2048:
105
+ raise SandboxError("Sandbox PID limit must be between 32 and 2048.")
106
+ if not 1 <= default_timeout_seconds <= max_timeout_seconds <= self.MAX_TIMEOUT_SECONDS:
107
+ raise SandboxError(
108
+ f"Sandbox timeouts must be between 1 and {self.MAX_TIMEOUT_SECONDS} seconds."
109
+ )
110
+ if not 1_000 <= output_chars <= 100_000:
111
+ raise SandboxError("Sandbox output limit must be between 1000 and 100000 characters.")
112
+ if max_snapshot_files < 1 or max_snapshot_bytes < 1:
113
+ raise SandboxError("Sandbox snapshot limits must be positive.")
114
+ self.image = image
115
+ self.docker_binary = docker_binary
116
+ self.cpu_limit = cpu_limit
117
+ self.memory_mb = memory_mb
118
+ self.pids_limit = pids_limit
119
+ self.default_timeout_seconds = default_timeout_seconds
120
+ self.max_timeout_seconds = max_timeout_seconds
121
+ self.output_chars = output_chars
122
+ self.network = network
123
+ self.max_snapshot_files = max_snapshot_files
124
+ self.max_snapshot_bytes = max_snapshot_bytes
125
+
126
+ @staticmethod
127
+ def _relative(path: Path, root: Path) -> str:
128
+ return path.relative_to(root).as_posix()
129
+
130
+ def _is_excluded(self, relative: Path) -> bool:
131
+ parts = tuple(part.lower() for part in relative.parts)
132
+ if any(part in self.EXCLUDED_DIRS for part in parts):
133
+ return True
134
+ if not parts:
135
+ return False
136
+ filename = parts[-1]
137
+ if filename in self.SENSITIVE_NAMES or filename in self.IGNORED_FILES:
138
+ return True
139
+ if filename.startswith(".env.") and filename != ".env.example":
140
+ return True
141
+ return Path(filename).suffix.lower() in self.SENSITIVE_SUFFIXES
142
+
143
+ def _copy_workspace(self, destination: Path) -> dict[str, str]:
144
+ """Copy safe regular files and return their content-hash manifest."""
145
+ root = self.workspace.root
146
+ manifest: dict[str, str] = {}
147
+ total_bytes = 0
148
+ for base, dirs, files in os.walk(root, followlinks=False):
149
+ base_path = Path(base)
150
+ relative_dir = base_path.relative_to(root)
151
+ dirs[:] = [
152
+ name for name in dirs
153
+ if not (base_path / name).is_symlink()
154
+ and not self._is_excluded(relative_dir / name)
155
+ ]
156
+ target_dir = destination / relative_dir
157
+ target_dir.mkdir(parents=True, exist_ok=True)
158
+ for filename in files:
159
+ source = base_path / filename
160
+ relative_file = relative_dir / filename
161
+ if source.is_symlink() or self._is_excluded(relative_file):
162
+ continue
163
+ try:
164
+ file_size = source.stat().st_size
165
+ except OSError:
166
+ continue
167
+ if file_size > self.MAX_SINGLE_FILE_BYTES:
168
+ raise SandboxError(
169
+ f"Workspace file {relative_file.as_posix()} exceeds the "
170
+ f"{self.MAX_SINGLE_FILE_BYTES // (1024 * 1024)} MB snapshot limit."
171
+ )
172
+ total_bytes += file_size
173
+ if len(manifest) >= self.max_snapshot_files:
174
+ raise SandboxError(
175
+ f"Workspace snapshot exceeds {self.max_snapshot_files} files."
176
+ )
177
+ if total_bytes > self.max_snapshot_bytes:
178
+ raise SandboxError(
179
+ "Workspace snapshot exceeds "
180
+ f"{self.max_snapshot_bytes // (1024 * 1024)} MB."
181
+ )
182
+ target = destination / relative_file
183
+ target.parent.mkdir(parents=True, exist_ok=True)
184
+ digest = hashlib.sha256()
185
+ try:
186
+ with source.open("rb") as reader, target.open("wb") as writer:
187
+ while chunk := reader.read(1024 * 1024):
188
+ digest.update(chunk)
189
+ writer.write(chunk)
190
+ source_mode = stat.S_IMODE(source.stat().st_mode)
191
+ target.chmod(source_mode & 0o755)
192
+ except OSError as exc:
193
+ raise SandboxError(f"Could not snapshot {relative_file}: {exc}") from exc
194
+ manifest[relative_file.as_posix()] = digest.hexdigest()
195
+ return manifest
196
+
197
+ @staticmethod
198
+ def _hash_file(path: Path) -> str:
199
+ digest = hashlib.sha256()
200
+ with path.open("rb") as handle:
201
+ while chunk := handle.read(1024 * 1024):
202
+ digest.update(chunk)
203
+ return digest.hexdigest()
204
+
205
+ def _collect_changes(
206
+ self,
207
+ snapshot: Path,
208
+ baseline: dict[str, str],
209
+ ) -> tuple[list[str], list[str]]:
210
+ changed: list[str] = []
211
+ deleted: list[str] = []
212
+ seen: set[str] = set()
213
+ total_bytes = 0
214
+ for base, dirs, files in os.walk(snapshot, followlinks=False):
215
+ base_path = Path(base)
216
+ relative_dir = base_path.relative_to(snapshot)
217
+ dirs[:] = [
218
+ name for name in dirs
219
+ if not (base_path / name).is_symlink()
220
+ and not self._is_excluded(relative_dir / name)
221
+ ]
222
+ for filename in files:
223
+ item = base_path / filename
224
+ relative = relative_dir / filename
225
+ key = relative.as_posix()
226
+ if item.is_symlink() or self._is_excluded(relative):
227
+ continue
228
+ try:
229
+ size = item.stat().st_size
230
+ except OSError:
231
+ continue
232
+ if size > self.MAX_SINGLE_FILE_BYTES:
233
+ raise SandboxError(
234
+ f"Sandbox output file {key} exceeds the per-file size limit."
235
+ )
236
+ seen.add(key)
237
+ total_bytes += size
238
+ if len(seen) > self.max_snapshot_files:
239
+ raise SandboxError(
240
+ f"Sandbox output exceeds the {self.max_snapshot_files}-file limit."
241
+ )
242
+ if total_bytes > self.max_snapshot_bytes:
243
+ raise SandboxError("Sandbox output exceeds the workspace size limit.")
244
+ current_hash = self._hash_file(item)
245
+ if baseline.get(key) != current_hash:
246
+ changed.append(key)
247
+ for key in baseline:
248
+ if key not in seen:
249
+ deleted.append(key)
250
+ return sorted(changed), sorted(deleted)
251
+
252
+ def _safe_destination(self, relative_value: str) -> Path:
253
+ relative = PurePosixPath(relative_value)
254
+ if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts):
255
+ raise SandboxError(f"Sandbox produced an unsafe output path: {relative_value}")
256
+ if self._is_excluded(Path(*relative.parts)):
257
+ raise SandboxError(f"Sandbox output path is protected: {relative_value}")
258
+ current = self.workspace.root
259
+ for part in relative.parts[:-1]:
260
+ candidate = current / part
261
+ if candidate.is_symlink():
262
+ raise SandboxError(f"Sandbox output parent is a symlink: {relative_value}")
263
+ if candidate.exists() and not candidate.is_dir():
264
+ raise SandboxError(f"Sandbox output parent is not a directory: {relative_value}")
265
+ if not candidate.exists():
266
+ candidate.mkdir()
267
+ current = candidate
268
+ parent = current
269
+ try:
270
+ parent.resolve().relative_to(self.workspace.root)
271
+ except ValueError as exc:
272
+ raise SandboxError(f"Sandbox output escapes workspace: {relative_value}") from exc
273
+ target = parent / relative.name
274
+ if target.is_symlink():
275
+ raise SandboxError(f"Refusing to replace workspace symlink: {relative_value}")
276
+ return target
277
+
278
+ def _apply_changes(self, snapshot: Path, changed: list[str]) -> list[str]:
279
+ applied: list[str] = []
280
+ for relative in changed:
281
+ source = snapshot / Path(*PurePosixPath(relative).parts)
282
+ if source.is_symlink() or not source.is_file():
283
+ continue
284
+ target = self._safe_destination(relative)
285
+ data_size = source.stat().st_size
286
+ if data_size > self.MAX_SINGLE_FILE_BYTES:
287
+ continue
288
+ temporary_name: str | None = None
289
+ try:
290
+ with tempfile.NamedTemporaryFile(
291
+ mode="wb", prefix=".forge-sandbox-", dir=target.parent, delete=False
292
+ ) as temporary, source.open("rb") as reader:
293
+ shutil.copyfileobj(reader, temporary)
294
+ temporary.flush()
295
+ os.fsync(temporary.fileno())
296
+ temporary_name = temporary.name
297
+ os.replace(temporary_name, target)
298
+ source_mode = stat.S_IMODE(source.stat().st_mode)
299
+ target.chmod(source_mode & 0o755)
300
+ applied.append(relative)
301
+ except OSError as exc:
302
+ raise SandboxError(f"Could not apply sandbox output {relative}: {exc}") from exc
303
+ finally:
304
+ if temporary_name and os.path.exists(temporary_name):
305
+ os.unlink(temporary_name)
306
+ return applied
307
+
308
+ @staticmethod
309
+ def validate_argv(argv: Any) -> list[str]:
310
+ if not isinstance(argv, list) or not argv or len(argv) > DockerSandboxExecutor.MAX_ARGS:
311
+ raise SandboxError("argv must be a non-empty array with at most 64 strings.")
312
+ if any(not isinstance(item, str) or not item or "\x00" in item for item in argv):
313
+ raise SandboxError("Every command argument must be a non-empty string.")
314
+ if sum(len(item) for item in argv) > DockerSandboxExecutor.MAX_ARGUMENT_CHARS:
315
+ raise SandboxError("Command arguments exceed the 16000-character limit.")
316
+ executable = argv[0]
317
+ if executable not in DockerSandboxExecutor.ALLOWED_COMMANDS:
318
+ allowed = ", ".join(sorted(DockerSandboxExecutor.ALLOWED_COMMANDS))
319
+ raise SandboxError(
320
+ f"Command '{executable}' is not allowed. Available executables: {allowed}."
321
+ )
322
+ return argv
323
+
324
+ def tool_schemas(self) -> list[dict[str, Any]]:
325
+ return [
326
+ {
327
+ "name": "workspace_run_command",
328
+ "description": (
329
+ "Run a developer/test command in an ephemeral Docker sandbox. "
330
+ f"Network mode is {self.network}; bridge mode enables internet access. "
331
+ "The filesystem is isolated to a sanitized project "
332
+ "snapshot, and CPU/memory/process/time limits are enforced. Use argv as "
333
+ "an array (not a shell command string). Changes are copied back only for "
334
+ "read-write roles. Common commands: python -m pytest, pytest, ruff check ., "
335
+ "npm install && npm test, python -m pip install -r requirements.txt, "
336
+ "curl, wget, node --test and git status. Each run is ephemeral; "
337
+ "combine dependency installation and tests in one shell invocation."
338
+ ),
339
+ "parameters": {
340
+ "type": "object",
341
+ "properties": {
342
+ "argv": {
343
+ "type": "array",
344
+ "items": {"type": "string"},
345
+ "minItems": 1,
346
+ "maxItems": self.MAX_ARGS,
347
+ },
348
+ "timeout_seconds": {
349
+ "type": "integer",
350
+ "minimum": 1,
351
+ "maximum": self.max_timeout_seconds,
352
+ "default": self.default_timeout_seconds,
353
+ },
354
+ },
355
+ "required": ["argv"],
356
+ "additionalProperties": False,
357
+ },
358
+ }
359
+ ]
360
+
361
+ def _prepare_snapshot_owner(self, snapshot: Path) -> None:
362
+ uid = os.getuid() if hasattr(os, "getuid") else 10001
363
+ if uid != 0 or not hasattr(os, "chown"):
364
+ return
365
+ for base, dirs, files in os.walk(snapshot):
366
+ os.chown(base, 10001, 10001)
367
+ for name in dirs:
368
+ with contextlib.suppress(OSError):
369
+ os.chown(Path(base) / name, 10001, 10001, follow_symlinks=False)
370
+ for name in files:
371
+ with contextlib.suppress(OSError):
372
+ os.chown(Path(base) / name, 10001, 10001, follow_symlinks=False)
373
+
374
+ def _docker_args(self, *, container_name: str) -> list[str]:
375
+ uid = os.getuid() if hasattr(os, "getuid") else 10001
376
+ gid = os.getgid() if hasattr(os, "getgid") else 10001
377
+ if uid == 0:
378
+ uid, gid = 10001, 10001
379
+
380
+ workspace_tmpfs_mb = self.max_snapshot_bytes // (1024 * 1024) + 256
381
+ args = [
382
+ "--name", container_name,
383
+ "--network", self.network,
384
+ "--read-only",
385
+ "--cap-drop", "ALL",
386
+ "--security-opt", "no-new-privileges",
387
+ "--pids-limit", str(self.pids_limit),
388
+ "--memory", f"{self.memory_mb}m",
389
+ "--cpus", str(self.cpu_limit),
390
+ "--shm-size", "64m",
391
+ "--tmpfs", f"/workspace:rw,nosuid,nodev,size={workspace_tmpfs_mb}m,mode=1777",
392
+ "--tmpfs", "/tmp:rw,nosuid,nodev,size=512m,mode=1777",
393
+ "--env", "PIP_TARGET=/tmp/python-packages",
394
+ "--env", "PYTHONPATH=/tmp/python-packages",
395
+ "--env", "PIP_CACHE_DIR=/tmp/pip-cache",
396
+ "--user", f"{uid}:{gid}",
397
+ "--workdir", "/workspace",
398
+ "--env", "HOME=/tmp",
399
+ "--env", "TMPDIR=/tmp",
400
+ "--env", "PYTHONDONTWRITEBYTECODE=1",
401
+ "--env", "PYTEST_ADDOPTS=-p no:cacheprovider",
402
+ "--env", "PATH=/workspace/node_modules/.bin:/tmp/python-packages/bin:/usr/local/bin:/usr/bin:/bin",
403
+ ]
404
+ dependencies = self.workspace.root / "node_modules"
405
+ if self.network == "bridge":
406
+ # npm can install packages in this disposable writable volume during one command.
407
+ args.extend([
408
+ "--tmpfs",
409
+ "/workspace/node_modules:rw,nosuid,nodev,size=1024m,mode=1777",
410
+ ])
411
+ elif dependencies.is_dir() and not dependencies.is_symlink():
412
+ args.extend([
413
+ "--mount",
414
+ f"type=bind,src={dependencies.resolve()},dst=/opt/forge-node_modules,readonly",
415
+ ])
416
+ return args
417
+
418
+ async def _run_control(
419
+ self,
420
+ *command: str,
421
+ timeout_seconds: int = 30,
422
+ ) -> tuple[bytes, bytes]:
423
+ try:
424
+ process = await asyncio.create_subprocess_exec(
425
+ *command,
426
+ stdout=asyncio.subprocess.PIPE,
427
+ stderr=asyncio.subprocess.PIPE,
428
+ )
429
+ except (FileNotFoundError, OSError) as exc:
430
+ raise SandboxError(f"Could not start Docker command: {exc}") from exc
431
+ try:
432
+ stdout, stderr = await asyncio.wait_for(
433
+ process.communicate(),
434
+ timeout=timeout_seconds,
435
+ )
436
+ except TimeoutError as exc:
437
+ process.kill()
438
+ await process.communicate()
439
+ raise SandboxError(
440
+ f"Docker operation exceeded its {timeout_seconds}-second timeout."
441
+ ) from exc
442
+ if process.returncode != 0:
443
+ detail = stderr.decode("utf-8", errors="replace").strip()
444
+ raise SandboxError(
445
+ detail or f"Docker command exited with code {process.returncode}."
446
+ )
447
+ return stdout, stderr
448
+
449
+ async def _read_limited(self, stream: asyncio.StreamReader) -> tuple[bytes, bool]:
450
+ maximum = self.output_chars * 4
451
+ output = bytearray()
452
+ truncated = False
453
+ while chunk := await stream.read(64 * 1024):
454
+ available = maximum - len(output)
455
+ if available > 0:
456
+ output.extend(chunk[:available])
457
+ if len(chunk) > available:
458
+ truncated = True
459
+ return bytes(output), truncated
460
+
461
+ async def _copy_container_workspace(
462
+ self,
463
+ container_name: str,
464
+ destination: Path,
465
+ ) -> None:
466
+ """Stream container workspace tar data and safely extract regular files."""
467
+ process = await asyncio.create_subprocess_exec(
468
+ self.docker_binary,
469
+ "exec",
470
+ container_name,
471
+ "tar",
472
+ "--exclude=./node_modules",
473
+ "-cf",
474
+ "-",
475
+ "-C",
476
+ "/workspace",
477
+ ".",
478
+ stdout=asyncio.subprocess.PIPE,
479
+ stderr=asyncio.subprocess.PIPE,
480
+ )
481
+ assert process.stdout is not None
482
+ assert process.stderr is not None
483
+ archive_path: Path | None = None
484
+
485
+ async def save_archive() -> None:
486
+ nonlocal archive_path
487
+ with tempfile.NamedTemporaryFile(
488
+ mode="wb",
489
+ prefix=".forge-output-",
490
+ suffix=".tar",
491
+ dir=destination.parent,
492
+ delete=False,
493
+ ) as archive:
494
+ archive_path = Path(archive.name)
495
+ while chunk := await process.stdout.read(1024 * 1024):
496
+ archive.write(chunk)
497
+
498
+ stdout_task = asyncio.create_task(save_archive())
499
+ stderr_task = asyncio.create_task(process.stderr.read())
500
+ try:
501
+ _, stderr_data, _ = await asyncio.wait_for(
502
+ asyncio.gather(stdout_task, stderr_task, process.wait()),
503
+ timeout=120,
504
+ )
505
+ except TimeoutError as exc:
506
+ process.kill()
507
+ await process.wait()
508
+ for task in (stdout_task, stderr_task):
509
+ if not task.done():
510
+ task.cancel()
511
+ await asyncio.gather(stdout_task, stderr_task, return_exceptions=True)
512
+ raise SandboxError("Timed out while exporting the sandbox workspace.") from exc
513
+ except OSError as exc:
514
+ if process.returncode is None:
515
+ process.kill()
516
+ await process.wait()
517
+ raise SandboxError(f"Could not export sandbox workspace: {exc}") from exc
518
+
519
+ try:
520
+ if process.returncode != 0:
521
+ detail = stderr_data.decode("utf-8", errors="replace").strip()
522
+ raise SandboxError(f"Could not export sandbox workspace: {detail}")
523
+
524
+ if archive_path is None:
525
+ raise SandboxError("Docker did not return a workspace archive.")
526
+
527
+ total_bytes = 0
528
+ file_count = 0
529
+ with tarfile.open(archive_path, mode="r:") as archive:
530
+ for member in archive:
531
+ member_path = PurePosixPath(member.name)
532
+ if member_path.is_absolute() or any(
533
+ part == ".." for part in member_path.parts
534
+ ):
535
+ raise SandboxError(
536
+ f"Sandbox returned an unsafe path: {member.name!r}"
537
+ )
538
+ parts = tuple(part for part in member_path.parts if part not in {"", "."})
539
+ if not parts:
540
+ continue
541
+ relative = Path(*parts)
542
+ if self._is_excluded(relative):
543
+ continue
544
+ target = destination / relative
545
+ if member.isdir():
546
+ target.mkdir(parents=True, exist_ok=True)
547
+ continue
548
+ if not member.isfile():
549
+ # Never propagate symlinks, hard links, devices or other special files.
550
+ continue
551
+ if member.size > self.MAX_SINGLE_FILE_BYTES:
552
+ raise SandboxError(
553
+ f"Sandbox output file {relative.as_posix()} exceeds the per-file limit."
554
+ )
555
+ file_count += 1
556
+ total_bytes += member.size
557
+ if file_count > self.max_snapshot_files:
558
+ raise SandboxError(
559
+ f"Sandbox output exceeds the {self.max_snapshot_files}-file limit."
560
+ )
561
+ if total_bytes > self.max_snapshot_bytes:
562
+ raise SandboxError("Sandbox output exceeds the workspace size limit.")
563
+ target.parent.mkdir(parents=True, exist_ok=True)
564
+ source = archive.extractfile(member)
565
+ if source is None:
566
+ continue
567
+ with source, target.open("wb") as output:
568
+ shutil.copyfileobj(source, output)
569
+ target.chmod(member.mode & 0o755)
570
+ finally:
571
+ if archive_path is not None:
572
+ with contextlib.suppress(OSError):
573
+ archive_path.unlink()
574
+
575
+ async def _run_container(
576
+ self,
577
+ snapshot: Path,
578
+ argv: list[str],
579
+ timeout_seconds: int,
580
+ ) -> tuple[int, str, str, bool, bool]:
581
+ container_name = f"forge-task-{uuid.uuid4().hex[:16]}"
582
+ create_command = [
583
+ self.docker_binary,
584
+ "run",
585
+ "-d",
586
+ "--rm",
587
+ "--pull=never",
588
+ *self._docker_args(container_name=container_name),
589
+ self.image,
590
+ "sleep",
591
+ str(self.max_timeout_seconds + 60),
592
+ ]
593
+ await self._run_control(*create_command, timeout_seconds=30)
594
+ try:
595
+ self._prepare_snapshot_owner(snapshot)
596
+ # The project lives on a bounded tmpfs inside the container, not a host bind mount.
597
+ # Stream the snapshot into the container's tmpfs. docker cp attempts to
598
+ # populate the read-only rootfs and is not reliable for tmpfs destinations.
599
+ archive_path = snapshot.with_name("workspace-input.tar")
600
+ try:
601
+ with tarfile.open(archive_path, "w") as archive:
602
+ for item in snapshot.iterdir():
603
+ archive.add(item, arcname=item.name, recursive=True)
604
+ extract_process = await asyncio.create_subprocess_exec(
605
+ self.docker_binary,
606
+ "exec",
607
+ "-i",
608
+ container_name,
609
+ "tar",
610
+ "-xf",
611
+ "-",
612
+ "-C",
613
+ "/workspace",
614
+ stdin=asyncio.subprocess.PIPE,
615
+ stdout=asyncio.subprocess.DEVNULL,
616
+ stderr=asyncio.subprocess.PIPE,
617
+ )
618
+ assert extract_process.stdin is not None
619
+ assert extract_process.stderr is not None
620
+ try:
621
+ with archive_path.open("rb") as archive_file:
622
+ while chunk := archive_file.read(1024 * 1024):
623
+ extract_process.stdin.write(chunk)
624
+ await extract_process.stdin.drain()
625
+ extract_process.stdin.close()
626
+ await extract_process.stdin.wait_closed()
627
+ _, extract_stderr = await asyncio.wait_for(
628
+ extract_process.communicate(),
629
+ timeout=120,
630
+ )
631
+ except (BrokenPipeError, OSError, TimeoutError) as exc:
632
+ if extract_process.returncode is None:
633
+ extract_process.kill()
634
+ await extract_process.wait()
635
+ raise SandboxError(f"Could not transfer workspace snapshot: {exc}") from exc
636
+ if extract_process.returncode != 0:
637
+ detail = extract_stderr.decode("utf-8", errors="replace").strip()
638
+ raise SandboxError(f"Could not extract workspace snapshot: {detail}")
639
+ finally:
640
+ with contextlib.suppress(OSError):
641
+ archive_path.unlink()
642
+ dependencies = self.workspace.root / "node_modules"
643
+ if (
644
+ self.network == "none"
645
+ and dependencies.is_dir()
646
+ and not dependencies.is_symlink()
647
+ ):
648
+ await self._run_control(
649
+ self.docker_binary,
650
+ "exec",
651
+ container_name,
652
+ "ln",
653
+ "-s",
654
+ "/opt/forge-node_modules",
655
+ "/workspace/node_modules",
656
+ timeout_seconds=15,
657
+ )
658
+
659
+ command = [
660
+ self.docker_binary,
661
+ "exec",
662
+ "--workdir",
663
+ "/workspace",
664
+ container_name,
665
+ "timeout",
666
+ "--signal=TERM",
667
+ "--kill-after=5s",
668
+ f"{timeout_seconds}s",
669
+ *argv,
670
+ ]
671
+ try:
672
+ process = await asyncio.create_subprocess_exec(
673
+ *command,
674
+ stdout=asyncio.subprocess.PIPE,
675
+ stderr=asyncio.subprocess.PIPE,
676
+ )
677
+ except (FileNotFoundError, OSError) as exc:
678
+ raise SandboxError(f"Could not start sandbox command: {exc}") from exc
679
+
680
+ assert process.stdout is not None
681
+ assert process.stderr is not None
682
+ stdout_task = asyncio.create_task(self._read_limited(process.stdout))
683
+ stderr_task = asyncio.create_task(self._read_limited(process.stderr))
684
+ host_timed_out = False
685
+ try:
686
+ await asyncio.wait_for(process.wait(), timeout=timeout_seconds + 15)
687
+ except TimeoutError:
688
+ host_timed_out = True
689
+ await self._stop_container(container_name)
690
+ if process.returncode is None:
691
+ process.kill()
692
+ await process.wait()
693
+ stdout_data, stdout_truncated = await stdout_task
694
+ stderr_data, stderr_truncated = await stderr_task
695
+ timed_out = host_timed_out or process.returncode in {124, 137}
696
+ if timed_out:
697
+ stderr_data += b"\\nForge Runtime: command timed out and was stopped."
698
+
699
+ if not host_timed_out:
700
+ # Export into a fresh directory so deleted container files are detectable.
701
+ output_snapshot = snapshot.with_name("sandbox-output")
702
+ output_snapshot.mkdir()
703
+ await self._copy_container_workspace(container_name, output_snapshot)
704
+ shutil.rmtree(snapshot)
705
+ output_snapshot.rename(snapshot)
706
+ return (
707
+ process.returncode if process.returncode is not None else 124,
708
+ stdout_data.decode("utf-8", errors="replace"),
709
+ stderr_data.decode("utf-8", errors="replace"),
710
+ timed_out,
711
+ stdout_truncated or stderr_truncated,
712
+ )
713
+ finally:
714
+ await self._stop_container(container_name)
715
+
716
+ async def _stop_container(self, name: str) -> None:
717
+ for action in ("kill", "rm"):
718
+ command = [self.docker_binary, action]
719
+ if action == "rm":
720
+ command.append("-f")
721
+ command.append(name)
722
+ try:
723
+ process = await asyncio.create_subprocess_exec(
724
+ *command,
725
+ stdout=asyncio.subprocess.DEVNULL,
726
+ stderr=asyncio.subprocess.DEVNULL,
727
+ )
728
+ await asyncio.wait_for(process.wait(), timeout=5)
729
+ except (OSError, TimeoutError):
730
+ continue
731
+
732
+ async def run(
733
+ self,
734
+ argv: Any,
735
+ *,
736
+ writable: bool,
737
+ timeout_seconds: int | None = None,
738
+ ) -> SandboxResult:
739
+ command = self.validate_argv(argv)
740
+ timeout = timeout_seconds or self.default_timeout_seconds
741
+ if not isinstance(timeout, int) or not 1 <= timeout <= self.max_timeout_seconds:
742
+ raise SandboxError(
743
+ f"timeout_seconds must be between 1 and {self.max_timeout_seconds}."
744
+ )
745
+
746
+ started = time.monotonic()
747
+ with tempfile.TemporaryDirectory(prefix="forge-sandbox-") as temporary:
748
+ snapshot = Path(temporary) / "workspace"
749
+ snapshot.mkdir()
750
+ baseline = self._copy_workspace(snapshot)
751
+ exit_code, stdout, stderr, timed_out, truncated = await self._run_container(
752
+ snapshot, command, timeout
753
+ )
754
+ changed, deleted = self._collect_changes(snapshot, baseline)
755
+ applied = self._apply_changes(snapshot, changed) if writable else []
756
+ if deleted:
757
+ stderr += (
758
+ "\nForge Runtime: deletions are not copied back "
759
+ f"({len(deleted)} file(s) left untouched)."
760
+ )
761
+ return SandboxResult(
762
+ exit_code=exit_code,
763
+ stdout=stdout,
764
+ stderr=stderr,
765
+ duration_seconds=round(time.monotonic() - started, 3),
766
+ timed_out=timed_out,
767
+ output_truncated=truncated,
768
+ changed_files=changed,
769
+ applied_files=applied,
770
+ changes_discarded=bool(changed) and not writable,
771
+ )
772
+
773
+ async def execute_tool(
774
+ self,
775
+ arguments: dict[str, Any],
776
+ *,
777
+ writable: bool,
778
+ ) -> str:
779
+ try:
780
+ result = await self.run(
781
+ arguments.get("argv"),
782
+ writable=writable,
783
+ timeout_seconds=arguments.get("timeout_seconds"),
784
+ )
785
+ data = result.as_dict()
786
+ data["status"] = (
787
+ "timed_out" if result.timed_out
788
+ else "succeeded" if result.exit_code == 0
789
+ else "failed"
790
+ )
791
+ if result.changes_discarded:
792
+ data["note"] = "Role is read-only; sandbox changes were discarded."
793
+ elif result.applied_files:
794
+ data["note"] = "Changed files were copied back to the project workspace."
795
+ return json.dumps(data, ensure_ascii=False)
796
+ except (SandboxError, WorkspaceError) as exc:
797
+ return f"Sandbox error: {exc}"
798
+
799
+ async def check(self) -> dict[str, Any]:
800
+ """Check Docker Engine connectivity and whether the configured image exists."""
801
+ try:
802
+ process = await asyncio.create_subprocess_exec(
803
+ self.docker_binary, "info", "--format", "{{.ServerVersion}}",
804
+ stdout=asyncio.subprocess.PIPE,
805
+ stderr=asyncio.subprocess.PIPE,
806
+ )
807
+ except FileNotFoundError as exc:
808
+ raise SandboxError("Docker CLI was not found on PATH.") from exc
809
+ try:
810
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15)
811
+ except TimeoutError as exc:
812
+ process.kill()
813
+ await process.communicate()
814
+ raise SandboxError("Timed out while checking Docker Engine.") from exc
815
+ if process.returncode != 0:
816
+ detail = stderr.decode("utf-8", errors="replace").strip()
817
+ raise SandboxError(f"Docker Engine is unavailable: {detail}")
818
+ version = stdout.decode("utf-8", errors="replace").strip()
819
+ image_process = await asyncio.create_subprocess_exec(
820
+ self.docker_binary, "image", "inspect", self.image,
821
+ stdout=asyncio.subprocess.DEVNULL,
822
+ stderr=asyncio.subprocess.DEVNULL,
823
+ )
824
+ try:
825
+ await asyncio.wait_for(image_process.wait(), timeout=15)
826
+ except TimeoutError as exc:
827
+ image_process.kill()
828
+ await image_process.wait()
829
+ raise SandboxError("Timed out while checking the sandbox image.") from exc
830
+ return {
831
+ "docker_server_version": version,
832
+ "image": self.image,
833
+ "image_available": image_process.returncode == 0,
834
+ "workspace": str(self.workspace.root),
835
+ }
836
+
837
+ async def build_image(self, dockerfile: Path | None = None) -> str:
838
+ """Build the bundled, trusted toolchain image."""
839
+ bundled = Path(__file__).resolve().parent / "sandbox_assets" / "Dockerfile"
840
+ dockerfile = dockerfile or bundled
841
+ if not dockerfile.is_file():
842
+ raise SandboxError(f"Sandbox Dockerfile was not found: {dockerfile}")
843
+ command = [
844
+ self.docker_binary,
845
+ "build",
846
+ "--pull",
847
+ "--tag",
848
+ self.image,
849
+ "--file",
850
+ str(dockerfile),
851
+ str(dockerfile.parent),
852
+ ]
853
+ try:
854
+ process = await asyncio.create_subprocess_exec(
855
+ *command,
856
+ stdout=asyncio.subprocess.PIPE,
857
+ stderr=asyncio.subprocess.STDOUT,
858
+ )
859
+ except FileNotFoundError as exc:
860
+ raise SandboxError("Docker CLI was not found on PATH.") from exc
861
+ try:
862
+ output, _ = await asyncio.wait_for(
863
+ process.communicate(), timeout=30 * 60
864
+ )
865
+ except TimeoutError as exc:
866
+ process.kill()
867
+ await process.communicate()
868
+ raise SandboxError("Sandbox image build exceeded the 30-minute timeout.") from exc
869
+ text = output.decode("utf-8", errors="replace")
870
+ if process.returncode != 0:
871
+ raise SandboxError(text[-self.output_chars * 2 :])
872
+ return text[-self.output_chars * 2 :]