mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
1197 lines
45 KiB
Python
1197 lines
45 KiB
Python
"""Linux containment and evidence staging for workflow-bench model sessions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
from collections.abc import Iterator, Mapping, Sequence
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path, PurePosixPath
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
from .process_control import ManagedProcessResult, run_managed
|
|
|
|
|
|
MAX_EVIDENCE_FILE_BYTES = 256 * 1024
|
|
MAX_BUNDLE_BYTES = 2 * 1024 * 1024
|
|
SANDBOX_WORKSPACE = "/workspace"
|
|
SANDBOX_HOME = "/home/agent"
|
|
SANDBOX_TMP = "/tmp"
|
|
SANDBOX_CLAUDE = "/opt/claude/claude"
|
|
SANDBOX_SHELL_PREFIX = "/opt/claude/shell-prefix"
|
|
SANDBOX_PYTHON3 = "/opt/claude/python3"
|
|
SANDBOX_GITNEXUS_CLI = "/opt/claude/gitnexus"
|
|
SANDBOX_GIT_EXCLUDES = "/opt/claude/git-excludes"
|
|
SANDBOX_NODE = "/opt/claude/node"
|
|
SANDBOX_NODE_PREFIX = "/opt/claude/nodejs"
|
|
# Vite transpiles a TypeScript config into <node_modules>/.vite-temp before it
|
|
# loads anything, so a read-only dependency mount makes `vitest` die with EROFS
|
|
# before a single test runs -- and every task verify command and every hidden
|
|
# oracle ends in `npx vitest run <test>`. bwrap cannot create a mount point
|
|
# inside an already-read-only bind, so the directory is captured into the
|
|
# dependency snapshot (task_assets.py) and a tmpfs is overlaid on it here.
|
|
VITE_TEMP_DIR = ".vite-temp"
|
|
DEPENDENCY_MOUNT_BASENAME = "node_modules"
|
|
SANDBOX_PATH = f"/opt/claude:{SANDBOX_NODE_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin"
|
|
SANDBOX_GITNEXUS = "/opt/gitnexus"
|
|
SANDBOX_GITNEXUS_SHARED = "/opt/gitnexus-shared"
|
|
SANDBOX_GITNEXUS_REGISTRY = "/opt/gitnexus-registry"
|
|
SANDBOX_USER_SKILLS = f"{SANDBOX_HOME}/.claude/skills"
|
|
SANDBOX_EVIDENCE = "/evidence"
|
|
|
|
# Claude Code's weaker nested sandbox overlays these absent root paths with
|
|
# /dev/null devices. They are tool-created mount noise, not model-authored
|
|
# files. Git must ignore them so provenance never mistakes those synthetic
|
|
# devices for untracked repository content. Leading "/" anchors every pattern
|
|
# at the repository root; tracked paths are never hidden by excludes.
|
|
CLAUDE_SANDBOX_GIT_EXCLUDES = (
|
|
"/.bash_profile",
|
|
"/.bashrc",
|
|
"/.gitconfig",
|
|
"/.idea",
|
|
"/.profile",
|
|
"/.ripgreprc",
|
|
"/.vscode",
|
|
"/.zprofile",
|
|
"/.zshrc",
|
|
"/scripts",
|
|
)
|
|
|
|
|
|
class SandboxError(RuntimeError):
|
|
"""Containment could not be established without weakening the contract."""
|
|
|
|
|
|
# Claude Code 2.1.214's subprocess scrubber and nested sandbox bind these
|
|
# paths even when absent. Mount targets must exist before sealing the clone.
|
|
REVIEW_RUNTIME_FILES = (
|
|
"bunfig.toml",
|
|
".mcp.json",
|
|
"package.json",
|
|
".npmrc",
|
|
".yarnrc",
|
|
".yarnrc.yml",
|
|
".gitmodules",
|
|
"package-lock.json",
|
|
"yarn.lock",
|
|
"pnpm-lock.yaml",
|
|
".env",
|
|
".env.local",
|
|
".env.development",
|
|
".env.development.local",
|
|
".env.test",
|
|
".env.test.local",
|
|
".env.production",
|
|
".env.production.local",
|
|
".git/config",
|
|
".git/config.lock",
|
|
".git/config.worktree",
|
|
".git/config.worktree.lock",
|
|
".git/info/exclude",
|
|
".gitconfig",
|
|
".bash_profile",
|
|
".bashrc",
|
|
".profile",
|
|
".ripgreprc",
|
|
".zprofile",
|
|
".zshrc",
|
|
)
|
|
REVIEW_RUNTIME_DIRECTORIES = (
|
|
".git/hooks",
|
|
".git/info",
|
|
".git/modules",
|
|
".git/worktrees",
|
|
".claude/commands",
|
|
".claude/agents",
|
|
"node_modules/.bin",
|
|
".github",
|
|
"scripts",
|
|
".vscode",
|
|
".idea",
|
|
)
|
|
|
|
|
|
def prepare_review_workspace(sandbox: SandboxSession, artifact_name: str) -> Path:
|
|
"""Prepare disposable mount targets; never truncate a pre-existing entry."""
|
|
|
|
clone = _real_directory(sandbox.clone, label="review clone")
|
|
if PurePosixPath(artifact_name).name != artifact_name or "\\" in artifact_name or artifact_name in ("", ".", ".."):
|
|
raise SandboxError("review artifact must be a root filename")
|
|
output = clone / artifact_name
|
|
# No agent runs while this private clone is being prepared. On POSIX the
|
|
# directory descriptor additionally binds the exclusive create to its owner.
|
|
directory_fd = None
|
|
try:
|
|
if os.name != "nt":
|
|
directory_fd = os.open(clone, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
fd = os.open(
|
|
artifact_name if directory_fd is not None else output,
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
|
0o600,
|
|
dir_fd=directory_fd,
|
|
)
|
|
try:
|
|
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
raise SandboxError("review artifact must be a regular file")
|
|
finally:
|
|
os.close(fd)
|
|
except FileExistsError as exc:
|
|
raise SandboxError("review artifact already exists") from exc
|
|
finally:
|
|
if directory_fd is not None:
|
|
os.close(directory_fd)
|
|
|
|
if sandbox.backend != "bwrap":
|
|
return output
|
|
created: list[str] = []
|
|
for names, directory in ((REVIEW_RUNTIME_DIRECTORIES, True), (REVIEW_RUNTIME_FILES, False)):
|
|
for name in names:
|
|
# Record only absent entries. The no-follow traversal validates
|
|
# every parent before creating anything in the host filesystem.
|
|
missing = not os.path.lexists(clone / name)
|
|
_prepare_clone_target(clone, PurePosixPath(name), directory=directory, label="review runtime")
|
|
if missing:
|
|
if name == ".mcp.json":
|
|
(clone / name).write_text("{}\n")
|
|
created.append(name)
|
|
common_dir = clone / ".git/commondir"
|
|
missing = not os.path.lexists(common_dir)
|
|
_prepare_clone_target(clone, PurePosixPath(".git/commondir"), directory=False, label="review runtime")
|
|
if missing:
|
|
common_dir.write_text(".\n")
|
|
created.append(".git/commondir")
|
|
# Private status presentation only; never alter the repository's ignores.
|
|
excludes = sandbox.private_root / "git-excludes"
|
|
excludes.chmod(0o600)
|
|
with excludes.open("a") as stream:
|
|
stream.write("".join(f"/{name}\n" for name in created))
|
|
excludes.chmod(0o400)
|
|
(sandbox.private_root / "review-created-paths.json").write_text(json.dumps(created))
|
|
return output
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReadOnlyMount:
|
|
source: Path
|
|
target: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SandboxSession:
|
|
private_root: Path
|
|
clone: Path
|
|
home: Path
|
|
temp: Path
|
|
bwrap_bin: Path
|
|
backend: str
|
|
claude_host_bin: Path
|
|
command_prefix: list[str]
|
|
read_only_mounts: tuple[ReadOnlyMount, ...]
|
|
|
|
@property
|
|
def require_pid_namespace(self) -> bool:
|
|
return self.backend == "bwrap"
|
|
|
|
def host_path(self, raw: str | Path) -> str:
|
|
"""Translate a sandbox path to its real host path in unsafe mode."""
|
|
|
|
value = str(raw)
|
|
if self.backend == "bwrap":
|
|
return value
|
|
node = Path(shutil.which("node") or "/usr/bin/node").resolve()
|
|
mappings = [
|
|
*(self.read_only_mounts),
|
|
ReadOnlyMount(node.parent.parent, SANDBOX_NODE_PREFIX),
|
|
ReadOnlyMount(node, SANDBOX_NODE),
|
|
ReadOnlyMount(self.claude_host_bin, SANDBOX_CLAUDE),
|
|
ReadOnlyMount(self.clone, SANDBOX_WORKSPACE),
|
|
ReadOnlyMount(self.home, SANDBOX_HOME),
|
|
ReadOnlyMount(self.temp, SANDBOX_TMP),
|
|
]
|
|
for mount in sorted(mappings, key=lambda item: len(item.target), reverse=True):
|
|
target = mount.target.rstrip("/")
|
|
if value == target:
|
|
return str(mount.source)
|
|
if value.startswith(f"{target}/"):
|
|
return str(mount.source / value[len(target) + 1 :])
|
|
return value
|
|
|
|
def host_text(self, value: str) -> str:
|
|
if self.backend == "bwrap":
|
|
return value
|
|
targets = [
|
|
*(mount.target for mount in self.read_only_mounts),
|
|
SANDBOX_NODE_PREFIX,
|
|
SANDBOX_NODE,
|
|
SANDBOX_CLAUDE,
|
|
SANDBOX_WORKSPACE,
|
|
SANDBOX_HOME,
|
|
SANDBOX_TMP,
|
|
]
|
|
ordered = sorted(set(targets), key=len, reverse=True)
|
|
pattern = re.compile("|".join(re.escape(target) for target in ordered))
|
|
return pattern.sub(lambda match: self.host_path(match.group(0)), value)
|
|
|
|
@property
|
|
def claude_bin(self) -> str:
|
|
return self.host_path(SANDBOX_CLAUDE)
|
|
|
|
@property
|
|
def transcript_projects(self) -> Path:
|
|
return self.home / ".claude" / "projects"
|
|
|
|
@property
|
|
def settings_json(self) -> str:
|
|
return build_claude_settings(sandbox_enabled=self.backend == "bwrap")
|
|
|
|
def environment(
|
|
self,
|
|
*,
|
|
auth_token: str | None = None,
|
|
base_url: str | None = None,
|
|
) -> dict[str, str]:
|
|
env = build_sandbox_environment(auth_token=auth_token, base_url=base_url)
|
|
if self.backend != "bwrap":
|
|
env = {key: self.host_text(value) for key, value in env.items()}
|
|
env.pop("CLAUDE_CODE_SUBPROCESS_ENV_SCRUB", None)
|
|
env.pop("CLAUDE_CODE_SHELL_PREFIX", None)
|
|
# Sandbox-only bin dirs have no single host counterpart; drop the
|
|
# entries that survive translation as non-existent paths.
|
|
env["PATH"] = ":".join(
|
|
entry for entry in env["PATH"].split(":") if Path(entry).is_dir()
|
|
)
|
|
return env
|
|
|
|
def run(
|
|
self,
|
|
command: Sequence[str],
|
|
*,
|
|
timeout: float,
|
|
env: Mapping[str, str] | None = None,
|
|
stdin_data: bytes | None = None,
|
|
) -> ManagedProcessResult:
|
|
translated = [self.host_text(str(part)) for part in command]
|
|
return run_managed(
|
|
[*self.command_prefix, *translated],
|
|
cwd=None if self.command_prefix else self.clone,
|
|
timeout=timeout,
|
|
env=(
|
|
{key: self.host_text(value) for key, value in env.items()}
|
|
if env is not None
|
|
else self.environment()
|
|
),
|
|
require_pid_namespace=self.require_pid_namespace,
|
|
stdin_data=stdin_data,
|
|
)
|
|
|
|
def command_prefix_for(
|
|
self,
|
|
*,
|
|
read_only_workspace: bool = False,
|
|
unshare_network: bool = False,
|
|
read_only_paths: Sequence[Path] = (),
|
|
extra_read_only_mounts: Sequence[ReadOnlyMount] = (),
|
|
extra_writable_mounts: Sequence[ReadOnlyMount] = (),
|
|
) -> list[str]:
|
|
"""Build a stricter command boundary from this session's fixed roots.
|
|
|
|
Model sessions use ``read_only_paths`` to freeze the evaluated skill
|
|
roots. Verifiers use ``read_only_workspace`` so candidate-authored code
|
|
cannot change the credited implementation. Extra mounts are reserved
|
|
for harness-owned, post-session evidence such as hidden oracles.
|
|
"""
|
|
|
|
if self.backend != "bwrap":
|
|
return []
|
|
|
|
additional: list[ReadOnlyMount] = []
|
|
clone = _real_directory(self.clone, label="sandbox clone")
|
|
for raw_path in read_only_paths:
|
|
lexical = raw_path.expanduser().absolute()
|
|
try:
|
|
relative = lexical.relative_to(clone)
|
|
metadata = lexical.lstat()
|
|
resolved = lexical.resolve(strict=True)
|
|
except (OSError, ValueError) as exc:
|
|
raise SandboxError(f"read-only sandbox path is unavailable: {raw_path}") from exc
|
|
if (
|
|
resolved != lexical
|
|
or stat.S_ISLNK(metadata.st_mode)
|
|
or not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode))
|
|
):
|
|
raise SandboxError(f"read-only sandbox path must be real and non-symlink: {raw_path}")
|
|
additional.append(
|
|
ReadOnlyMount(
|
|
source=lexical,
|
|
target=f"{SANDBOX_WORKSPACE}/{PurePosixPath(relative.as_posix())}",
|
|
)
|
|
)
|
|
|
|
for mount in extra_read_only_mounts:
|
|
source = mount.source.expanduser().absolute()
|
|
try:
|
|
metadata = source.lstat()
|
|
resolved = source.resolve(strict=True)
|
|
except OSError as exc:
|
|
raise SandboxError(f"extra read-only mount is unavailable: {source}") from exc
|
|
if (
|
|
resolved != source
|
|
or stat.S_ISLNK(metadata.st_mode)
|
|
or not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode))
|
|
):
|
|
raise SandboxError(f"extra read-only mount must be real and non-symlink: {source}")
|
|
target = PurePosixPath(mount.target)
|
|
if not target.is_absolute() or ".." in target.parts:
|
|
raise SandboxError(f"extra read-only mount target must be absolute: {mount.target}")
|
|
additional.append(ReadOnlyMount(source=source, target=target.as_posix()))
|
|
|
|
writable: list[ReadOnlyMount] = []
|
|
for mount in extra_writable_mounts:
|
|
source = mount.source.expanduser().absolute()
|
|
try:
|
|
metadata = source.lstat()
|
|
resolved = source.resolve(strict=True)
|
|
except OSError as exc:
|
|
raise SandboxError(f"writable artifact mount is unavailable: {source}") from exc
|
|
if (
|
|
resolved != source
|
|
or stat.S_ISLNK(metadata.st_mode)
|
|
or not (stat.S_ISDIR(metadata.st_mode) or stat.S_ISREG(metadata.st_mode))
|
|
):
|
|
raise SandboxError(f"writable artifact mount must be real and non-symlink: {source}")
|
|
target = PurePosixPath(mount.target)
|
|
if not target.is_absolute() or ".." in target.parts:
|
|
raise SandboxError(f"writable artifact mount target must be absolute: {mount.target}")
|
|
writable.append(ReadOnlyMount(source=source, target=target.as_posix()))
|
|
|
|
return _sandbox_command_prefix(
|
|
bwrap=self.bwrap_bin,
|
|
clone=clone,
|
|
home=self.home,
|
|
temp=self.temp,
|
|
claude_bin=self.claude_host_bin,
|
|
mounts=(*self.read_only_mounts, *additional),
|
|
writable_mounts=writable,
|
|
read_only_workspace=read_only_workspace,
|
|
unshare_network=unshare_network,
|
|
)
|
|
|
|
|
|
_TOKEN_PATTERNS = (
|
|
re.compile(r"sk-ant-[A-Za-z0-9_-]{8,}"),
|
|
re.compile(r"gh(?:p|o|u|s|r)_[A-Za-z0-9_]{8,}"),
|
|
re.compile(r"(?i)(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s,;]+"),
|
|
re.compile(r"(?i)(https?://)[^/@\s:]+:[^/@\s]+@"),
|
|
)
|
|
|
|
|
|
def redact_text(text: str, secrets: Sequence[str] = ()) -> str:
|
|
for secret in secrets:
|
|
if secret:
|
|
text = text.replace(secret, "[REDACTED]")
|
|
text = _TOKEN_PATTERNS[0].sub("[REDACTED]", text)
|
|
text = _TOKEN_PATTERNS[1].sub("[REDACTED]", text)
|
|
text = _TOKEN_PATTERNS[2].sub(r"\1[REDACTED]", text)
|
|
return _TOKEN_PATTERNS[3].sub(r"\1[REDACTED]@", text)
|
|
|
|
|
|
def _evidence_bytes(value: Any, secrets: Sequence[str]) -> bytes:
|
|
if isinstance(value, Path):
|
|
try:
|
|
mode = value.lstat().st_mode
|
|
except OSError as exc:
|
|
raise SandboxError(f"evidence path is unreadable: {value}: {exc}") from exc
|
|
if value.is_symlink() or not stat.S_ISREG(mode):
|
|
raise SandboxError(f"evidence must be a regular non-symlink file: {value}")
|
|
if value.stat().st_size > MAX_EVIDENCE_FILE_BYTES:
|
|
raise SandboxError(f"evidence exceeds the per-file limit: {value}")
|
|
raw = value.read_bytes()
|
|
return redact_text(raw.decode(errors="replace"), secrets).encode()
|
|
if isinstance(value, bytes):
|
|
raw = value
|
|
elif isinstance(value, str):
|
|
raw = value.encode()
|
|
else:
|
|
raw = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
return redact_text(raw.decode(errors="replace"), secrets).encode()
|
|
|
|
|
|
def stage_evidence_bundle(
|
|
destination: Path,
|
|
entries: Mapping[str, Any],
|
|
*,
|
|
secrets: Sequence[str] = (),
|
|
) -> Path:
|
|
"""Write a redacted owner-only evidence bundle with hard byte caps."""
|
|
|
|
destination = destination.resolve()
|
|
if destination.exists():
|
|
raise SandboxError(f"evidence destination already exists: {destination}")
|
|
destination.mkdir(parents=True, mode=0o700)
|
|
destination.chmod(0o700)
|
|
total = 0
|
|
try:
|
|
for name, value in entries.items():
|
|
relative = PurePosixPath(name)
|
|
if len(relative.parts) != 1 or relative.name in {"", ".", ".."}:
|
|
raise SandboxError(f"evidence names must be simple relative files: {name!r}")
|
|
payload = _evidence_bytes(value, secrets)
|
|
if len(payload) > MAX_EVIDENCE_FILE_BYTES:
|
|
raise SandboxError(f"evidence exceeds the per-file limit: {name}")
|
|
total += len(payload)
|
|
if total > MAX_BUNDLE_BYTES:
|
|
raise SandboxError("evidence bundle exceeds the total byte limit")
|
|
path = destination / relative.name
|
|
path.write_bytes(payload)
|
|
path.chmod(0o600)
|
|
except BaseException:
|
|
shutil.rmtree(destination, ignore_errors=True)
|
|
raise
|
|
return destination
|
|
|
|
|
|
def _validated_base_url(base_url: str) -> str:
|
|
value = base_url.strip()
|
|
parsed = urlsplit(value)
|
|
if (
|
|
parsed.scheme not in {"http", "https"}
|
|
or not parsed.hostname
|
|
or parsed.username is not None
|
|
or parsed.password is not None
|
|
or parsed.query
|
|
or parsed.fragment
|
|
):
|
|
raise SandboxError("model base URL must be an HTTP(S) endpoint without credentials, query, or fragment")
|
|
return value
|
|
|
|
|
|
def build_sandbox_environment(
|
|
*,
|
|
auth_token: str | None = None,
|
|
base_url: str | None = None,
|
|
) -> dict[str, str]:
|
|
"""Build the entire parent environment; never copy ``os.environ``."""
|
|
|
|
env = {
|
|
"HOME": SANDBOX_HOME,
|
|
"USER": "agent",
|
|
"LOGNAME": "agent",
|
|
"TMPDIR": SANDBOX_TMP,
|
|
"XDG_CONFIG_HOME": f"{SANDBOX_HOME}/.config",
|
|
"XDG_CACHE_HOME": f"{SANDBOX_HOME}/.cache",
|
|
"XDG_STATE_HOME": f"{SANDBOX_HOME}/.local/state",
|
|
"PATH": SANDBOX_PATH,
|
|
"LANG": "C.UTF-8",
|
|
"LC_ALL": "C.UTF-8",
|
|
"TERM": "dumb",
|
|
"CI": "1",
|
|
"NO_COLOR": "1",
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"GIT_CONFIG_NOSYSTEM": "1",
|
|
"NPM_CONFIG_UPDATE_NOTIFIER": "false",
|
|
"NPM_CONFIG_AUDIT": "false",
|
|
"NPM_CONFIG_FUND": "false",
|
|
"NPM_CONFIG_CACHE": f"{SANDBOX_TMP}/npm-cache",
|
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
"DISABLE_AUTOUPDATER": "1",
|
|
"CLAUDE_CODE_DISABLE_TELEMETRY": "1",
|
|
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB": "1",
|
|
"CLAUDE_CODE_DONT_INHERIT_ENV": "1",
|
|
"CLAUDE_CODE_SHELL_PREFIX": SANDBOX_SHELL_PREFIX,
|
|
"CLAUDE_CONFIG_DIR": f"{SANDBOX_HOME}/.claude",
|
|
}
|
|
if auth_token is not None:
|
|
token = auth_token.strip()
|
|
if not token:
|
|
raise SandboxError("model auth token must not be blank")
|
|
# Every benchmark/proposer invocation uses Claude's --bare mode,
|
|
# which intentionally ignores OAuth/keychain/AUTH_TOKEN credentials.
|
|
env["ANTHROPIC_API_KEY"] = token
|
|
if base_url is not None:
|
|
env["ANTHROPIC_BASE_URL"] = _validated_base_url(base_url)
|
|
return env
|
|
|
|
|
|
def build_claude_settings(*, sandbox_enabled: bool = True) -> str:
|
|
"""Inline settings that keep every Bash sandboxed and pre-approve the tools.
|
|
|
|
Deliberately hook-free: headless ``claude -p`` (2.1.247) never dispatches
|
|
``PreToolUse``, whatever source the hook is declared in — inline
|
|
``--settings``, a settings file, project/user/local ``--setting-sources``,
|
|
or a trusted project entry in ``~/.claude.json``. Confinement therefore
|
|
rests only on mechanisms the CLI honors in this mode: the sandbox policy
|
|
below, ``--tools``/``--allowedTools``, and the bwrap mounts.
|
|
"""
|
|
|
|
permissions = {
|
|
"allow": ["Read", "Grep", "Glob", "Bash"],
|
|
}
|
|
if sandbox_enabled:
|
|
permissions["disableBypassPermissionsMode"] = "disable"
|
|
settings = {
|
|
"sandbox": {
|
|
"enabled": sandbox_enabled,
|
|
"failIfUnavailable": sandbox_enabled,
|
|
"autoAllowBashIfSandboxed": sandbox_enabled,
|
|
"allowUnsandboxedCommands": False,
|
|
"enableWeakerNestedSandbox": True,
|
|
"network": {
|
|
"allowedDomains": [],
|
|
"deniedDomains": ["*"],
|
|
"allowAllUnixSockets": False,
|
|
"allowLocalBinding": False,
|
|
},
|
|
"filesystem": {
|
|
"allowWrite": [SANDBOX_WORKSPACE, SANDBOX_TMP, SANDBOX_HOME],
|
|
"denyRead": ["/"],
|
|
"allowRead": [
|
|
SANDBOX_WORKSPACE,
|
|
SANDBOX_TMP,
|
|
SANDBOX_HOME,
|
|
"/usr",
|
|
"/bin",
|
|
"/lib",
|
|
"/lib64",
|
|
"/opt/claude",
|
|
SANDBOX_GITNEXUS,
|
|
SANDBOX_GITNEXUS_SHARED,
|
|
SANDBOX_GITNEXUS_REGISTRY,
|
|
SANDBOX_EVIDENCE,
|
|
],
|
|
},
|
|
},
|
|
"permissions": {
|
|
# CLAUDE_CODE_SUBPROCESS_ENV_SCRUB forces permission mode to
|
|
# "default" (allowed_non_write_users hardening), so requesting a
|
|
# non-default mode only emits a warning and never takes effect.
|
|
# Under "default" a tool runs without a prompt only if it matches an
|
|
# allow rule, so pre-approve the proposer's exact tool surface. Bash
|
|
# is the only writable tool under --bare (it writes the candidate
|
|
# overlay) and stays sandbox-confined by the sandbox.* policy above.
|
|
**permissions,
|
|
},
|
|
"env": (
|
|
{
|
|
"CLAUDE_CODE_SUBPROCESS_ENV_SCRUB": "1",
|
|
"CLAUDE_CODE_DONT_INHERIT_ENV": "1",
|
|
}
|
|
if sandbox_enabled
|
|
else {"CLAUDE_CODE_DONT_INHERIT_ENV": "1"}
|
|
),
|
|
}
|
|
return json.dumps(settings, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
def _runtime_mount_args() -> list[str]:
|
|
args: list[str] = []
|
|
system_trees = ("/usr", "/bin", "/lib", "/lib64")
|
|
for raw in system_trees:
|
|
path = Path(raw)
|
|
if path.exists():
|
|
args += ["--ro-bind", raw, raw]
|
|
# sanitized_graph.py and runner_sessions.py invoke the sandboxed graph
|
|
# CLI via SANDBOX_NODE. Bind whatever `node` actually resolves to on PATH
|
|
# there -- true node location varies by host (GitHub-hosted runner images
|
|
# happen to have one under /usr/local/bin; a self-hosted runner's
|
|
# actions/setup-node installs into its own tool-cache directory instead).
|
|
# Target must be a fresh path like /opt/claude/... rather than anywhere
|
|
# under /usr, /bin, /lib, or /lib64: those are already read-only bound
|
|
# above, and bwrap can't create a new mount-point file inside an
|
|
# already-read-only tree when the real path doesn't already exist there
|
|
# (the exact case a self-hosted runner hits, and the reason this bind
|
|
# exists at all).
|
|
node_bin = shutil.which("node")
|
|
if node_bin:
|
|
args += ["--ro-bind", node_bin, SANDBOX_NODE]
|
|
# The single-binary bind above gives SANDBOX_NODE but NOT npm or npx:
|
|
# those are symlinks into ../lib/node_modules/npm/bin/*-cli.js, so the
|
|
# install prefix carrying both bin/ and lib/node_modules has to be
|
|
# mounted for them to resolve at all. When node really lives under a
|
|
# system tree (/usr/local/bin on GitHub-hosted images) the prefix is
|
|
# already inside the wholesale read-only binds above and npm/npx came
|
|
# along for free -- which is exactly why this gap stayed invisible
|
|
# until a self-hosted runner put node in actions/setup-node's tool
|
|
# cache, outside /usr, and every task verify command
|
|
# ("cd gitnexus && npx tsc ... && npx vitest ...") died with
|
|
# "/bin/sh: 1: npx: not found". Skip the redundant bind in the
|
|
# already-covered case so the mount surface stays minimal.
|
|
#
|
|
# The prefix is only ever derived from a real <prefix>/bin/node layout
|
|
# that actually carries npm. Deriving it as parent.parent unconditionally
|
|
# would mount an unrelated ancestor whenever node sits somewhere else:
|
|
# /opt/bin/node would bind all of /opt (every tool cache on a hosted
|
|
# runner) and a bare <dir>/node would bind <dir>'s parent. This function
|
|
# exists to keep the sandbox surface minimal, so an unrecognized layout
|
|
# binds nothing extra and simply leaves npx unavailable, exactly as
|
|
# before.
|
|
node_bin_dir = Path(node_bin).resolve().parent
|
|
node_prefix = node_bin_dir.parent
|
|
# Test the property actually needed -- a working npx next to node in a
|
|
# real bin/ directory -- rather than a proxy like lib/node_modules/npm.
|
|
# .exists() follows the symlink, so a dangling npx correctly fails: it
|
|
# would not survive the mount either. Requiring the "bin" name keeps
|
|
# the parent.parent derivation honest; an npx sitting directly beside
|
|
# node in a flat directory would make that derivation name the wrong
|
|
# prefix.
|
|
provides_npx = node_bin_dir.name == "bin" and (node_bin_dir / "npx").exists()
|
|
if provides_npx and not any(node_prefix.is_relative_to(tree) for tree in system_trees):
|
|
args += ["--ro-bind", str(node_prefix), SANDBOX_NODE_PREFIX]
|
|
for raw in (
|
|
"/etc/ssl",
|
|
"/etc/hosts",
|
|
"/etc/resolv.conf",
|
|
"/etc/nsswitch.conf",
|
|
"/etc/passwd",
|
|
"/etc/group",
|
|
):
|
|
path = Path(raw)
|
|
if path.exists():
|
|
args += ["--ro-bind", raw, raw]
|
|
return args
|
|
|
|
|
|
def _create_shell_prefix_wrapper(private_root: Path) -> Path:
|
|
"""Create Claude's immutable clean-environment command adapter."""
|
|
|
|
wrapper = private_root / "shell-prefix"
|
|
wrapper.write_text(
|
|
"#!/bin/bash\n"
|
|
"set -eu\n"
|
|
'if [ "$#" -ne 1 ]; then exit 64; fi\n'
|
|
"exec /usr/bin/env -i "
|
|
f"HOME={SANDBOX_HOME} USER=agent LOGNAME=agent TMPDIR={SANDBOX_TMP} "
|
|
f"PATH={SANDBOX_PATH} LANG=C.UTF-8 LC_ALL=C.UTF-8 TERM=dumb "
|
|
f"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.excludesFile GIT_CONFIG_VALUE_0={SANDBOX_GIT_EXCLUDES} "
|
|
"GITNEXUS_INVOCATION=gitnexus "
|
|
'/bin/bash -c "$1"\n'
|
|
)
|
|
wrapper.chmod(0o500)
|
|
return wrapper
|
|
|
|
|
|
def _create_python3_wrapper(private_root: Path) -> Path:
|
|
"""A trusted, self-owned Python 3 launcher for evidence-provenance.mjs's atomic mover.
|
|
|
|
/usr/bin/python3 is a real system binary, but it's root-owned on the host.
|
|
Inside this --unshare-user sandbox only the calling uid is mapped (root is
|
|
not), so root-owned files surface as the kernel's overflow uid — which
|
|
evidence-provenance.mjs's PATH-scan correctly refuses to trust. This
|
|
wrapper is freshly created by the same host process that owns
|
|
home/temp/shell-prefix, so it maps to the sandbox's own trusted uid
|
|
instead, and simply execs the real interpreter through to do the work.
|
|
"""
|
|
|
|
wrapper = private_root / "python3"
|
|
wrapper.write_text('#!/bin/bash\nset -eu\nexec /usr/bin/python3 "$@"\n')
|
|
wrapper.chmod(0o500)
|
|
return wrapper
|
|
|
|
|
|
def _create_gitnexus_wrapper(private_root: Path) -> Path:
|
|
"""Expose the already-mounted pinned CLI without npm or network access."""
|
|
|
|
wrapper = private_root / "gitnexus"
|
|
wrapper.write_text(f'#!/bin/bash\nset -eu\nexec {SANDBOX_NODE} {SANDBOX_GITNEXUS}/dist/cli/index.js "$@"\n')
|
|
wrapper.chmod(0o500)
|
|
return wrapper
|
|
|
|
|
|
def _create_git_excludes(private_root: Path) -> Path:
|
|
"""Create the immutable excludes for nested-sandbox mount artifacts."""
|
|
|
|
excludes = private_root / "git-excludes"
|
|
excludes.write_text("\n".join(CLAUDE_SANDBOX_GIT_EXCLUDES) + "\n")
|
|
excludes.chmod(0o400)
|
|
return excludes
|
|
|
|
|
|
def _resolve_executable(executable: Path | str | None, default: str) -> Path:
|
|
raw = os.fspath(executable) if executable is not None else shutil.which(default)
|
|
if not raw:
|
|
raise SandboxError(f"required executable is unavailable: {default}")
|
|
path = Path(raw).expanduser().resolve()
|
|
if not path.is_file() or not os.access(path, os.X_OK):
|
|
raise SandboxError(f"required executable is not an executable regular file: {path}")
|
|
return path
|
|
|
|
|
|
def preflight_bubblewrap(bwrap_bin: Path | str | None = None) -> Path:
|
|
"""Prove the required namespaces work; never fall back to host execution."""
|
|
|
|
if sys.platform != "linux":
|
|
raise SandboxError(f"Bubblewrap containment is supported only on Linux/WSL2, not {sys.platform}")
|
|
bwrap = _resolve_executable(bwrap_bin, "bwrap")
|
|
command = [
|
|
str(bwrap),
|
|
"--unshare-user",
|
|
"--unshare-pid",
|
|
"--unshare-ipc",
|
|
"--unshare-uts",
|
|
"--die-with-parent",
|
|
"--new-session",
|
|
*_runtime_mount_args(),
|
|
"--proc",
|
|
"/proc",
|
|
"--dev",
|
|
"/dev",
|
|
"--",
|
|
"/usr/bin/true",
|
|
]
|
|
result = run_managed(command, timeout=10, require_pid_namespace=True)
|
|
if not result.ok:
|
|
raise SandboxError(f"Bubblewrap namespace preflight failed: {result.detail or result.stderr_tail[-1000:]}")
|
|
return bwrap
|
|
|
|
|
|
def preflight_unsafe_host() -> Path:
|
|
"""Return a harmless sentinel for the explicit non-containment backend."""
|
|
|
|
return _resolve_executable(None, "env")
|
|
|
|
|
|
def pid_namespace_command(
|
|
command: Sequence[str],
|
|
*,
|
|
bwrap_bin: Path,
|
|
) -> list[str]:
|
|
"""Wrap a trusted host command in an owned PID namespace.
|
|
|
|
This boundary deliberately preserves the host filesystem and network; its
|
|
sole purpose is making every descendant visible to the outer driver even
|
|
when a nested command creates a new session or process group.
|
|
"""
|
|
|
|
if not command:
|
|
raise ValueError("PID-namespace command must not be empty")
|
|
return [
|
|
str(bwrap_bin),
|
|
"--unshare-user",
|
|
"--unshare-pid",
|
|
"--unshare-ipc",
|
|
"--unshare-uts",
|
|
"--die-with-parent",
|
|
"--new-session",
|
|
"--bind",
|
|
"/",
|
|
"/",
|
|
"--proc",
|
|
"/proc",
|
|
"--dev",
|
|
"/dev",
|
|
"--",
|
|
*command,
|
|
]
|
|
|
|
|
|
def require_claude_sandbox_helpers() -> None:
|
|
"""Fail before paid work when Claude's mandatory inner sandbox cannot run."""
|
|
|
|
_resolve_executable(None, "socat")
|
|
|
|
|
|
def _real_directory(path: Path, *, label: str) -> Path:
|
|
"""Return an absolute directory path without accepting any symlink hop."""
|
|
|
|
lexical = path.expanduser().absolute()
|
|
try:
|
|
mode = lexical.lstat().st_mode
|
|
except OSError as exc:
|
|
raise SandboxError(f"{label} must be a real directory: {lexical}: {exc}") from exc
|
|
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
|
raise SandboxError(f"{label} must be a real directory: {lexical}")
|
|
try:
|
|
resolved = lexical.resolve(strict=True)
|
|
except OSError as exc:
|
|
raise SandboxError(f"{label} must be a real directory: {lexical}: {exc}") from exc
|
|
if resolved != lexical:
|
|
raise SandboxError(f"{label} must not traverse symlinks: {lexical}")
|
|
return lexical
|
|
|
|
|
|
def _safe_repo_source(repo: Path, relative: str, *, label: str) -> tuple[Path, Path]:
|
|
candidate = PurePosixPath(relative)
|
|
if candidate.is_absolute() or ".." in candidate.parts or not candidate.parts:
|
|
raise SandboxError(f"{label} must be a repository-relative path: {relative!r}")
|
|
lexical = repo / Path(*candidate.parts)
|
|
resolved = lexical.resolve()
|
|
try:
|
|
resolved.relative_to(repo)
|
|
except ValueError as exc:
|
|
raise SandboxError(f"{label} escapes its allowed repository root: {relative}") from exc
|
|
if not resolved.exists():
|
|
raise SandboxError(f"{label} does not exist: {relative}")
|
|
return lexical, resolved
|
|
|
|
|
|
def _prepare_clone_target(
|
|
clone: Path,
|
|
relative: PurePosixPath,
|
|
*,
|
|
directory: bool | None,
|
|
label: str,
|
|
) -> Path:
|
|
"""Validate/create a clone-local target without following any symlink.
|
|
|
|
This runs before Bubblewrap, so ordinary ``Path.mkdir``/``touch`` calls
|
|
are not acceptable: an untrusted tracked parent symlink could redirect a
|
|
mount placeholder write into the host filesystem.
|
|
"""
|
|
|
|
flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0)
|
|
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
|
current_fd = os.open(clone, flags | nofollow)
|
|
try:
|
|
for part in relative.parts[:-1]:
|
|
try:
|
|
os.mkdir(part, mode=0o700, dir_fd=current_fd)
|
|
except FileExistsError:
|
|
pass
|
|
try:
|
|
next_fd = os.open(part, flags | nofollow, dir_fd=current_fd)
|
|
except OSError as exc:
|
|
raise SandboxError(f"{label} target has a non-directory or symlink parent: {relative}") from exc
|
|
os.close(current_fd)
|
|
current_fd = next_fd
|
|
|
|
leaf = relative.parts[-1]
|
|
try:
|
|
mode = os.stat(leaf, dir_fd=current_fd, follow_symlinks=False).st_mode
|
|
except FileNotFoundError:
|
|
mode = None
|
|
if mode is not None and stat.S_ISLNK(mode):
|
|
raise SandboxError(f"{label} target cannot be a symlink: {relative}")
|
|
if directory is True:
|
|
if mode is None:
|
|
os.mkdir(leaf, mode=0o700, dir_fd=current_fd)
|
|
elif not stat.S_ISDIR(mode):
|
|
raise SandboxError(f"{label} directory target has the wrong type: {relative}")
|
|
elif directory is False:
|
|
if mode is None:
|
|
file_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | nofollow
|
|
file_fd = os.open(leaf, file_flags, 0o600, dir_fd=current_fd)
|
|
os.close(file_fd)
|
|
elif not stat.S_ISREG(mode):
|
|
raise SandboxError(f"{label} file target has the wrong type: {relative}")
|
|
elif mode is not None and not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
|
raise SandboxError(f"{label} target has the wrong type: {relative}")
|
|
finally:
|
|
os.close(current_fd)
|
|
return clone / Path(*relative.parts)
|
|
|
|
|
|
def stage_task_assets(
|
|
task: Mapping[str, Any],
|
|
*,
|
|
repo: Path,
|
|
clone: Path,
|
|
) -> list[ReadOnlyMount]:
|
|
"""Compatibility wrapper for immutable task-asset staging."""
|
|
|
|
# Kept lazy to avoid a module cycle: task_assets uses the sandbox's
|
|
# shared error, mount, and no-follow target primitives.
|
|
from .task_assets import stage_task_assets as stage_immutable_task_assets
|
|
|
|
return stage_immutable_task_assets(task, repo=repo, clone=clone)
|
|
|
|
|
|
def _sandbox_command_prefix(
|
|
*,
|
|
bwrap: Path,
|
|
clone: Path,
|
|
home: Path,
|
|
temp: Path,
|
|
claude_bin: Path,
|
|
mounts: Sequence[ReadOnlyMount],
|
|
writable_mounts: Sequence[ReadOnlyMount] = (),
|
|
read_only_workspace: bool = False,
|
|
unshare_network: bool = False,
|
|
) -> list[str]:
|
|
args = [
|
|
str(bwrap),
|
|
"--unshare-user",
|
|
"--unshare-pid",
|
|
"--unshare-ipc",
|
|
"--unshare-uts",
|
|
*(["--unshare-net"] if unshare_network else []),
|
|
"--die-with-parent",
|
|
"--new-session",
|
|
*_runtime_mount_args(),
|
|
"--proc",
|
|
"/proc",
|
|
"--dev",
|
|
"/dev",
|
|
"--tmpfs",
|
|
"/run",
|
|
"--ro-bind" if read_only_workspace else "--bind",
|
|
str(clone),
|
|
SANDBOX_WORKSPACE,
|
|
"--bind",
|
|
str(home),
|
|
SANDBOX_HOME,
|
|
"--bind",
|
|
str(temp),
|
|
SANDBOX_TMP,
|
|
"--ro-bind",
|
|
str(claude_bin),
|
|
SANDBOX_CLAUDE,
|
|
]
|
|
for mount in mounts:
|
|
args += ["--ro-bind", str(mount.source), mount.target]
|
|
# Overlay an empty writable tmpfs on the one path vite must write.
|
|
# Everything else in the mount, and the whole workspace, stays
|
|
# read-only, and the overlay lives only inside the sandbox -- it never
|
|
# reaches the host clone the credited patch is captured from.
|
|
#
|
|
# Gate on the mount SOURCE actually containing the directory, not on
|
|
# the target name: bwrap cannot create a mount point inside an
|
|
# already-read-only bind, so a tmpfs can only be overlaid where the
|
|
# directory already exists in the bound bytes. task_assets.py captures
|
|
# it into dependency-snapshot node_modules; other node_modules mounts
|
|
# (e.g. the trusted GitNexus runtime at /opt/gitnexus/node_modules) do
|
|
# not carry it, and overlaying them would fail with EROFS.
|
|
if PurePosixPath(mount.target).name == DEPENDENCY_MOUNT_BASENAME and (mount.source / VITE_TEMP_DIR).is_dir():
|
|
args += ["--tmpfs", f"{mount.target}/{VITE_TEMP_DIR}"]
|
|
for mount in writable_mounts:
|
|
args += ["--bind", str(mount.source), mount.target]
|
|
args += ["--chdir", SANDBOX_WORKSPACE, "--"]
|
|
return args
|
|
|
|
|
|
def _drop_host_workspace_write_bits(
|
|
root: Path,
|
|
*,
|
|
writable: Sequence[Path],
|
|
) -> list[tuple[Path, int]]:
|
|
"""Clear write bits on a host-unsafe clone except explicit artifact paths."""
|
|
|
|
root = root.expanduser().absolute()
|
|
try:
|
|
metadata = root.lstat()
|
|
resolved = root.resolve(strict=True)
|
|
except OSError as exc:
|
|
raise SandboxError(f"host workspace lock root is unavailable: {root}: {exc}") from exc
|
|
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode) or resolved != root:
|
|
raise SandboxError(f"host workspace lock root must be a real directory: {root}")
|
|
|
|
allowed: set[Path] = set()
|
|
for raw in writable:
|
|
path = raw.expanduser().absolute()
|
|
try:
|
|
metadata = path.lstat()
|
|
resolved = path.resolve(strict=True)
|
|
path.relative_to(root)
|
|
except (OSError, ValueError) as exc:
|
|
raise SandboxError(f"writable host path escapes the workspace: {raw}") from exc
|
|
if stat.S_ISLNK(metadata.st_mode) or resolved != path:
|
|
raise SandboxError(f"writable host path must be real and non-symlink: {raw}")
|
|
allowed.add(path)
|
|
|
|
records: list[tuple[Path, int]] = []
|
|
pending = [root]
|
|
while pending:
|
|
current = pending.pop()
|
|
try:
|
|
metadata = current.lstat()
|
|
except OSError as exc:
|
|
raise SandboxError(f"host workspace lock path is unreadable: {current}: {exc}") from exc
|
|
records.append((current, stat.S_IMODE(metadata.st_mode)))
|
|
if stat.S_ISLNK(metadata.st_mode):
|
|
continue
|
|
if stat.S_ISDIR(metadata.st_mode):
|
|
if current in allowed:
|
|
# Match bwrap: a writable directory bind keeps its children writable.
|
|
continue
|
|
try:
|
|
children = [Path(entry.path) for entry in os.scandir(current)]
|
|
except OSError as exc:
|
|
raise SandboxError(f"host workspace lock directory is unreadable: {current}: {exc}") from exc
|
|
pending.extend(children)
|
|
os.chmod(current, 0o500)
|
|
continue
|
|
if current in allowed:
|
|
os.chmod(current, stat.S_IMODE(metadata.st_mode) | 0o222)
|
|
continue
|
|
if stat.S_ISREG(metadata.st_mode):
|
|
os.chmod(current, stat.S_IMODE(metadata.st_mode) & ~0o222)
|
|
return records
|
|
|
|
|
|
def _force_rmtree(path: Path) -> None:
|
|
"""Delete a tree even when leftover copies inherited 0555 directory modes.
|
|
|
|
Host-unsafe review sessions drop write bits on the clone. An agent that
|
|
``copytree``s those directories into the private TMPDIR leaves a tree
|
|
``shutil.rmtree`` cannot remove: a non-empty 0555 directory raises
|
|
PermissionError. Restore owner write bits, then delete.
|
|
"""
|
|
|
|
root = Path(path)
|
|
if not root.exists():
|
|
return
|
|
for dirpath, _dirnames, filenames in os.walk(root, topdown=False, followlinks=False):
|
|
try:
|
|
os.chmod(
|
|
dirpath,
|
|
stat.S_IMODE(os.lstat(dirpath).st_mode) | 0o700,
|
|
follow_symlinks=False,
|
|
)
|
|
except OSError:
|
|
# Directory may already be gone or refuse chmod; rmtree still tries.
|
|
pass
|
|
for name in filenames:
|
|
child = os.path.join(dirpath, name)
|
|
try:
|
|
metadata = os.lstat(child)
|
|
except OSError:
|
|
continue
|
|
if stat.S_ISLNK(metadata.st_mode):
|
|
continue
|
|
try:
|
|
os.chmod(child, stat.S_IMODE(metadata.st_mode) | 0o200, follow_symlinks=False)
|
|
except OSError:
|
|
# File vanished or is immutable; skip and let rmtree report.
|
|
pass
|
|
shutil.rmtree(root)
|
|
|
|
|
|
def _restore_host_workspace_modes(records: Sequence[tuple[Path, int]]) -> None:
|
|
errors: list[str] = []
|
|
for path, mode in reversed(records):
|
|
try:
|
|
os.chmod(path, mode)
|
|
except FileNotFoundError:
|
|
continue
|
|
except OSError as exc:
|
|
errors.append(f"{path}: {exc}")
|
|
if errors:
|
|
raise SandboxError("failed to restore host workspace modes: " + "; ".join(errors[:8]))
|
|
|
|
|
|
@contextmanager
|
|
def host_workspace_write_boundary(
|
|
root: Path,
|
|
*,
|
|
writable: Sequence[Path] = (),
|
|
) -> Iterator[None]:
|
|
"""Best-effort host analog of bwrap ``--ro-bind`` plus one writable artifact.
|
|
|
|
This is not a security boundary: a session that can ``chmod`` can undo it.
|
|
It is enough to stop accidental ``npm install`` / analyze writes from
|
|
invalidating review evidence on a host-unsafe diagnostic run.
|
|
"""
|
|
|
|
records = _drop_host_workspace_write_bits(root, writable=writable)
|
|
try:
|
|
yield
|
|
finally:
|
|
_restore_host_workspace_modes(records)
|
|
|
|
|
|
@contextmanager
|
|
def sandbox_workspace_write_boundary(
|
|
sandbox: Any,
|
|
*,
|
|
read_only_workspace: bool,
|
|
writable: Sequence[Path] = (),
|
|
) -> Iterator[None]:
|
|
"""Apply the host-unsafe write lock only when bwrap is not the backend."""
|
|
|
|
if getattr(sandbox, "backend", "bwrap") != "host-unsafe" or not read_only_workspace:
|
|
yield
|
|
return
|
|
clone = Path(sandbox.clone)
|
|
with host_workspace_write_boundary(clone, writable=writable):
|
|
yield
|
|
|
|
|
|
@contextmanager
|
|
def prepare_sandbox(
|
|
*,
|
|
clone: Path,
|
|
claude_bin: Path | str | None = None,
|
|
bwrap_bin: Path | str | None = None,
|
|
read_only_mounts: Sequence[ReadOnlyMount] = (),
|
|
preflight: bool = True,
|
|
backend: str = "bwrap",
|
|
) -> Iterator[SandboxSession]:
|
|
"""Create private host backing dirs and one virtualized command."""
|
|
|
|
# Validate the lexical path before resolving it. Resolving first would
|
|
# erase the evidence that the caller supplied a symlinked clone root.
|
|
clone = _real_directory(clone, label="sandbox clone")
|
|
if backend not in ("bwrap", "host-unsafe"):
|
|
raise SandboxError(f"unknown sandbox backend: {backend}")
|
|
if preflight:
|
|
bwrap = preflight_bubblewrap(bwrap_bin) if backend == "bwrap" else preflight_unsafe_host()
|
|
if backend == "bwrap":
|
|
require_claude_sandbox_helpers()
|
|
else:
|
|
bwrap = _resolve_executable(bwrap_bin, "bwrap") if backend == "bwrap" else preflight_unsafe_host()
|
|
claude = _resolve_executable(claude_bin, "claude")
|
|
private_root = Path(tempfile.mkdtemp(prefix="wfbench-sandbox-"))
|
|
private_root.chmod(0o700)
|
|
home = private_root / "home"
|
|
temp = private_root / "tmp"
|
|
for directory in (home, temp):
|
|
directory.mkdir(mode=0o700)
|
|
directory.chmod(0o700)
|
|
shell_prefix = _create_shell_prefix_wrapper(private_root)
|
|
python3_wrapper = _create_python3_wrapper(private_root)
|
|
gitnexus_wrapper = _create_gitnexus_wrapper(private_root)
|
|
git_excludes = _create_git_excludes(private_root)
|
|
# Claude may discover user-level skills below HOME. Keep the rest of HOME
|
|
# writable for normal CLI state, but overlay an immutable empty skills root
|
|
# so a model cannot shadow the evaluated repository/plugin skill by name.
|
|
user_skills = home / ".claude" / "skills"
|
|
user_skills.mkdir(parents=True, mode=0o500)
|
|
user_skills.chmod(0o500)
|
|
protected_mounts = (
|
|
*read_only_mounts,
|
|
ReadOnlyMount(source=user_skills, target=SANDBOX_USER_SKILLS),
|
|
ReadOnlyMount(source=shell_prefix, target=SANDBOX_SHELL_PREFIX),
|
|
ReadOnlyMount(source=python3_wrapper, target=SANDBOX_PYTHON3),
|
|
ReadOnlyMount(source=gitnexus_wrapper, target=SANDBOX_GITNEXUS_CLI),
|
|
ReadOnlyMount(source=git_excludes, target=SANDBOX_GIT_EXCLUDES),
|
|
)
|
|
primary: BaseException | None = None
|
|
try:
|
|
command_prefix = (
|
|
_sandbox_command_prefix(
|
|
bwrap=bwrap,
|
|
clone=clone,
|
|
home=home,
|
|
temp=temp,
|
|
claude_bin=claude,
|
|
mounts=protected_mounts,
|
|
)
|
|
if backend == "bwrap"
|
|
else []
|
|
)
|
|
yield SandboxSession(
|
|
private_root=private_root,
|
|
clone=clone,
|
|
home=home,
|
|
temp=temp,
|
|
bwrap_bin=bwrap,
|
|
backend=backend,
|
|
claude_host_bin=claude,
|
|
command_prefix=command_prefix,
|
|
read_only_mounts=protected_mounts,
|
|
)
|
|
except BaseException as exc:
|
|
primary = exc
|
|
raise
|
|
finally:
|
|
try:
|
|
_force_rmtree(private_root)
|
|
except OSError as cleanup:
|
|
if primary is None:
|
|
raise
|
|
primary.add_note(f"sandbox cleanup also failed: {type(cleanup).__name__}: {cleanup}")
|