boxd-inspect 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,7 @@
1
+ """Run Inspect AI sandboxes on boxd machines."""
2
+
3
+ from ._config import BoxdSandboxEnvironmentConfig
4
+ from ._environment import BoxdSandboxEnvironment
5
+ from ._version import __version__
6
+
7
+ __all__ = ["BoxdSandboxEnvironment", "BoxdSandboxEnvironmentConfig", "__version__"]
@@ -0,0 +1,203 @@
1
+ """Shell lines the machine runs for each sandbox operation.
2
+
3
+ Every exec on a boxd machine runs one ``/bin/sh`` line as the machine's user
4
+ ``boxd``. These functions build those lines; they do no I/O, so the unit tests
5
+ check them directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shlex
12
+ from dataclasses import dataclass
13
+
14
+ SHELL_PATH = "/bin/sh"
15
+ SYSTEM_PATH = "/usr/sbin:/usr/bin:/sbin:/bin"
16
+
17
+ SUDO = "/usr/bin/sudo"
18
+ TIMEOUT = "/usr/bin/timeout"
19
+ ENV = "/usr/bin/env"
20
+ TIMEOUT_KILL_AFTER = 5
21
+ """Seconds between the TERM and the KILL a timed-out command gets."""
22
+
23
+ _ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
24
+
25
+ # cd to $0, then run "$@": the directory and the argv never pass through a
26
+ # shell's word splitting.
27
+ _CD_AND_RUN = 'cd -- "$0" && exec "$@"'
28
+
29
+
30
+ def check_env(env: dict[str, str] | None) -> None:
31
+ """Raise ``ValueError`` for a variable name a shell cannot assign."""
32
+ for name in env or {}:
33
+ if not _ENV_NAME.fullmatch(name):
34
+ raise ValueError(f"{name!r} is not an environment variable name")
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Compose:
39
+ """A compose project inside the machine, and one of its services."""
40
+
41
+ project: str
42
+ directory: str
43
+ file: str
44
+ service: str
45
+
46
+ def base(self) -> list[str]:
47
+ return [
48
+ "docker",
49
+ "compose",
50
+ "--project-name",
51
+ self.project,
52
+ "--project-directory",
53
+ self.directory,
54
+ "--file",
55
+ self.file,
56
+ ]
57
+
58
+
59
+ def exec_argv(
60
+ cmd: list[str],
61
+ *,
62
+ cwd: str,
63
+ env: dict[str, str] | None,
64
+ user: str | None,
65
+ timeout: int | None,
66
+ compose: Compose | None,
67
+ ) -> list[str]:
68
+ """Build the argv, run by the machine's shell, that runs ``cmd`` as Inspect asks.
69
+
70
+ On the machine: ``timeout`` and ``sudo`` by absolute path, then a shell that
71
+ changes to ``cwd`` (as ``user``, so a directory only they can enter works)
72
+ and ``exec``s ``cmd``. ``env`` goes through ``/usr/bin/env`` so it survives
73
+ ``sudo``. In a compose service: ``docker compose exec`` with ``--workdir``,
74
+ ``--user`` and ``--env``, and the same ``timeout`` inside the container so
75
+ the command's whole process tree is stopped when it runs out.
76
+ """
77
+ if not cmd:
78
+ raise ValueError("cmd must not be empty")
79
+ check_env(env)
80
+ limit = [TIMEOUT, "-k", f"{TIMEOUT_KILL_AFTER}s", f"{timeout}s"] if timeout is not None else []
81
+ if compose is not None:
82
+ argv = [*compose.base(), "exec", "-T", "--workdir", cwd]
83
+ if user is not None:
84
+ argv += ["--user", user]
85
+ for name, value in (env or {}).items():
86
+ argv += ["--env", f"{name}={value}"]
87
+ return [*argv, compose.service, *limit, *cmd]
88
+
89
+ run = [SHELL_PATH, "-c", _CD_AND_RUN, cwd]
90
+ if env:
91
+ run += [ENV, *(f"{name}={value}" for name, value in env.items())]
92
+ run += cmd
93
+ if user is not None:
94
+ return [SUDO, "-n", "-H", "-u", _sudo_user(user), "--", *limit, *run]
95
+ return [*limit, *run]
96
+
97
+
98
+ def privileged_argv(
99
+ script: str,
100
+ *args: str,
101
+ user: str | None,
102
+ compose: Compose | None,
103
+ ) -> list[str]:
104
+ """Run a POSIX ``script`` (``$1``... are ``args``) as ``user`` with a pinned PATH.
105
+
106
+ For the file operations: their helper programs come from the system
107
+ directories, never from a PATH the sandbox's user can change.
108
+ """
109
+ inner = pinned_shell_command(script, *args)
110
+ if compose is not None:
111
+ argv = [*compose.base(), "exec", "-T"]
112
+ if user is not None:
113
+ argv += ["--user", user]
114
+ return [*argv, compose.service, *inner]
115
+ if user is not None:
116
+ return [SUDO, "-n", "-H", "-u", _sudo_user(user), "--", *inner]
117
+ return inner
118
+
119
+
120
+ def shell_line(
121
+ argv: list[str],
122
+ *,
123
+ stdin: str | None = None,
124
+ stdout: str | None = None,
125
+ remove: list[str] | None = None,
126
+ ) -> str:
127
+ """One ``/bin/sh`` line: ``argv`` with redirections, then remove temp files.
128
+
129
+ The exit status is ``argv``'s own. ``remove`` lists temp files the line
130
+ deletes after the command, whatever its result.
131
+ """
132
+ line = shlex.join(argv)
133
+ if stdin is not None:
134
+ line += f" < {shlex.quote(stdin)}"
135
+ if stdout is not None:
136
+ line += f" > {shlex.quote(stdout)}"
137
+ if remove:
138
+ line += f"; rc=$?; /bin/rm -f -- {shlex.join(remove)}; exit $rc"
139
+ return line
140
+
141
+
142
+ def script_line(script_path: str, *, remove: list[str] | None = None) -> str:
143
+ """Run an uploaded script (for commands too long for one argument)."""
144
+ line = f"{SHELL_PATH} {shlex.quote(script_path)}"
145
+ if remove:
146
+ line += f"; rc=$?; /bin/rm -f -- {shlex.join(remove)}; exit $rc"
147
+ return line
148
+
149
+
150
+ def pinned_shell_command(script: str, *args: str) -> list[str]:
151
+ """``/bin/sh`` running ``script`` with PATH set to the system directories.
152
+
153
+ The same pin Inspect puts on its own privileged commands: what the script
154
+ runs never comes from a directory the sandbox's user can write to.
155
+ """
156
+ pin = f"PATH={SYSTEM_PATH}\nexport PATH\nunset CDPATH\n"
157
+ return [SHELL_PATH, "-c", pin + script, "sh", *args]
158
+
159
+
160
+ def _sudo_user(user: str) -> str:
161
+ """``sudo -u`` takes a uid as ``#<uid>``."""
162
+ return f"#{user}" if user.isdigit() else user
163
+
164
+
165
+ READ_SCRIPT = """\
166
+ if [ -d "$1" ]; then echo "$1: Is a directory" >&2; exit 21; fi
167
+ if [ -e "$1" ] && [ -r "$1" ]; then
168
+ size=$(stat -L -c %s -- "$1") || exit 1
169
+ if [ "$size" -gt "$2" ]; then echo "$1: larger than $2 bytes" >&2; exit 27; fi
170
+ fi
171
+ exec cat -- "$1"
172
+ """
173
+ """Print a file (``$1``) of at most ``$2`` bytes. A directory fails with
174
+ ``EISDIR`` (21), a larger file with ``EFBIG`` (27), before anything is sent."""
175
+
176
+ WRITE_SCRIPT = """\
177
+ parent=$(dirname -- "$1") || exit 1
178
+ mkdir -p -- "$parent" || exit 1
179
+ if [ -d "$1" ]; then echo "$1: Is a directory" >&2; exit 21; fi
180
+ exec cat > "$1"
181
+ """
182
+ """Write stdin to a file, making its directory first. Writes through the file
183
+ (and a symlink to it), so its owner and mode stay and its permissions apply."""
184
+
185
+ PWD_SCRIPT = "exec pwd"
186
+
187
+ __all__ = [
188
+ "ENV",
189
+ "PWD_SCRIPT",
190
+ "READ_SCRIPT",
191
+ "SHELL_PATH",
192
+ "SUDO",
193
+ "SYSTEM_PATH",
194
+ "TIMEOUT",
195
+ "WRITE_SCRIPT",
196
+ "Compose",
197
+ "check_env",
198
+ "exec_argv",
199
+ "pinned_shell_command",
200
+ "privileged_argv",
201
+ "script_line",
202
+ "shell_line",
203
+ ]
@@ -0,0 +1,157 @@
1
+ """A compose project to run with Docker inside the machine.
2
+
3
+ The compose file (or a ``Dockerfile``, wrapped in the same compose file Inspect's
4
+ Docker sandbox makes for one) goes to the machine with its directory, so build
5
+ contexts next to it work as they do locally.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import os
12
+ import tarfile
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import yaml
17
+ from inspect_ai.util import ComposeConfig, is_dockerfile, parse_compose_yaml
18
+
19
+ GENERATED_FILE = ".boxd-inspect-compose.yaml"
20
+ """The compose file this package writes next to a ``Dockerfile``."""
21
+
22
+ MAX_CONTEXT_BYTES = 512 * 1024 * 1024
23
+ """The largest directory (packed) sent to each machine."""
24
+
25
+ DOCKERFILE_COMPOSE = """\
26
+ services:
27
+ default:
28
+ build:
29
+ context: "."
30
+ dockerfile: "{dockerfile}"
31
+ command: "tail -f /dev/null"
32
+ init: true
33
+ network_mode: none
34
+ stop_grace_period: 1s
35
+ """
36
+ """What Inspect's Docker sandbox runs for a lone ``Dockerfile``."""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ComposeSource:
41
+ """What goes to the machine: a packed directory and the compose file in it."""
42
+
43
+ archive: bytes
44
+ file: str
45
+ services: list[str]
46
+ default_service: str
47
+
48
+
49
+ _cache: dict[tuple[str, float], ComposeSource] = {}
50
+
51
+
52
+ def compose_source(compose: str | ComposeConfig) -> ComposeSource:
53
+ """Pack the compose project once per file version; samples reuse the result."""
54
+ if isinstance(compose, ComposeConfig):
55
+ return _from_config(compose)
56
+ path = Path(compose).resolve()
57
+ key = (str(path), _directory_mtime(path.parent))
58
+ cached = _cache.get(key)
59
+ if cached is None:
60
+ cached = _from_file(path)
61
+ _cache[key] = cached
62
+ return cached
63
+
64
+
65
+ def _from_file(path: Path) -> ComposeSource:
66
+ if not path.is_file():
67
+ raise FileNotFoundError(f"{path} does not exist")
68
+ extra: dict[str, bytes] = {}
69
+ if is_dockerfile(str(path)):
70
+ text = DOCKERFILE_COMPOSE.format(dockerfile=path.name)
71
+ extra[GENERATED_FILE] = text.encode()
72
+ config = ComposeConfig.model_validate(yaml.safe_load(text))
73
+ file = GENERATED_FILE
74
+ else:
75
+ config = parse_compose_yaml(str(path), multiple_services=True)
76
+ file = path.name
77
+ services = list(config.services)
78
+ return ComposeSource(
79
+ archive=_pack(path.parent, extra),
80
+ file=file,
81
+ services=services,
82
+ default_service=default_service(config),
83
+ )
84
+
85
+
86
+ def _from_config(config: ComposeConfig) -> ComposeSource:
87
+ text = yaml.safe_dump(
88
+ config.model_dump(mode="json", by_alias=True, exclude_none=True), sort_keys=False
89
+ )
90
+ buffer = io.BytesIO()
91
+ with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
92
+ _add_bytes(tar, GENERATED_FILE, text.encode())
93
+ return ComposeSource(
94
+ archive=buffer.getvalue(),
95
+ file=GENERATED_FILE,
96
+ services=list(config.services),
97
+ default_service=default_service(config),
98
+ )
99
+
100
+
101
+ def default_service(config: ComposeConfig) -> str:
102
+ """Pick the service Inspect calls ``default``: ``x-default: true``, else the one named so."""
103
+ for name, service in config.services.items():
104
+ extra = service.model_extra or {}
105
+ if getattr(service, "x_default", None) is True or extra.get("x-default") is True:
106
+ return name
107
+ if "default" in config.services:
108
+ return "default"
109
+ raise ValueError(
110
+ "No 'default' service in the compose file. Name a service 'default' "
111
+ "or add 'x-default: true' to one."
112
+ )
113
+
114
+
115
+ def _pack(directory: Path, extra: dict[str, bytes]) -> bytes:
116
+ buffer = io.BytesIO()
117
+ with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
118
+ tar.add(directory, arcname=".", filter=_portable)
119
+ for name, data in extra.items():
120
+ _add_bytes(tar, name, data)
121
+ data = buffer.getvalue()
122
+ if len(data) > MAX_CONTEXT_BYTES:
123
+ raise ValueError(
124
+ f"{directory} packs to {len(data) // (1024 * 1024)} MiB, more than the "
125
+ f"{MAX_CONTEXT_BYTES // (1024 * 1024)} MiB sent to each machine. Move the "
126
+ "compose file to a smaller directory, or start from a snapshot."
127
+ )
128
+ return data
129
+
130
+
131
+ def _portable(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
132
+ """Own every entry by the machine's user; skip devices and version-control data."""
133
+ if info.isdev() or "/.git/" in f"/{info.name}/":
134
+ return None
135
+ info.uid = info.gid = 1000
136
+ info.uname = info.gname = "boxd"
137
+ return info
138
+
139
+
140
+ def _add_bytes(tar: tarfile.TarFile, name: str, data: bytes) -> None:
141
+ info = tarfile.TarInfo(name)
142
+ info.size = len(data)
143
+ info.mode = 0o644
144
+ tar.addfile(info, io.BytesIO(data))
145
+
146
+
147
+ def _directory_mtime(directory: Path) -> float:
148
+ """Find the newest change in a directory tree, so an edited build context is re-packed."""
149
+ newest = directory.stat().st_mtime
150
+ for root, dirs, files in os.walk(directory):
151
+ dirs[:] = [d for d in dirs if d != ".git"]
152
+ for name in files:
153
+ try:
154
+ newest = max(newest, os.stat(os.path.join(root, name)).st_mtime)
155
+ except OSError:
156
+ continue
157
+ return newest
@@ -0,0 +1,148 @@
1
+ """The configuration of a ``boxd`` sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from inspect_ai.util import ComposeConfig, is_compose_yaml, is_dockerfile
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ REGIONS = ("fra", "nyc")
13
+ SIZES = {1: "4G", 2: "8G", 4: "16G", 8: "32G"}
14
+
15
+ _PREFIX = re.compile(r"[a-z][a-z0-9-]{0,19}")
16
+ _LABEL_KEY = re.compile(r"[a-z0-9]([a-z0-9._/-]{0,61}[a-z0-9])?")
17
+
18
+
19
+ class BoxdSandboxEnvironmentConfig(BaseModel):
20
+ """How each sample's boxd machine is made.
21
+
22
+ The API key is never part of the configuration (it would end up in the eval
23
+ log). Set ``BOXD_API_KEY``.
24
+ """
25
+
26
+ model_config = ConfigDict(extra="forbid", frozen=True)
27
+
28
+ compose: str | None = None
29
+ """A ``compose.yaml`` or ``Dockerfile`` to run with Docker inside the machine.
30
+
31
+ Without it, commands run on the machine itself (Ubuntu 24.04, user ``boxd``,
32
+ passwordless ``sudo``, Docker, Python and Node preinstalled).
33
+ """
34
+
35
+ region: str | None = None
36
+ """``fra`` or ``nyc``. Default: ``BOXD_REGION``, then your account's region."""
37
+
38
+ org: str | None = None
39
+ """The org to create machines in. Default: ``BOXD_ORG``, then the key's org."""
40
+
41
+ vcpu: int | None = None
42
+ """1, 2, 4 or 8. Memory follows (4, 8, 16 or 32 GiB) unless ``memory`` is set."""
43
+
44
+ memory: str | None = None
45
+ """``4G``, ``8G``, ``16G`` or ``32G``. Default: the org's machine size."""
46
+
47
+ from_snapshot: str | None = None
48
+ """Start each sample from this snapshot (for example one with images already pulled)."""
49
+
50
+ isolated: bool = True
51
+ """Run the eval's untrusted commands on an isolated machine: no peers on your
52
+ org's private network, and none of the in-machine boxd credentials, CLI or
53
+ connected integrations a non-isolated machine of yours gets. Outbound
54
+ internet still works. Turn it off only for code you trust."""
55
+
56
+ working_dir: str | None = None
57
+ """Where commands run and relative paths resolve. Default: ``/home/boxd`` on
58
+ the machine, the container's working directory with ``compose``."""
59
+
60
+ name_prefix: str = "inspect"
61
+ """Machine names start with this, then the task, the sample and a random suffix."""
62
+
63
+ labels: dict[str, str] = Field(default_factory=dict)
64
+ """Extra labels on every machine, next to ``inspect-run`` and ``inspect-task``."""
65
+
66
+ delete_after: int | None = None
67
+ """Seconds after its creation at which boxd deletes a machine, whatever it is
68
+ doing: a safety net for machines an eval that was killed never cleaned up.
69
+ Keep it above your longest sample."""
70
+
71
+ ready_timeout: float = 180
72
+ """Seconds to wait for a new machine to answer."""
73
+
74
+ compose_timeout: float = 900
75
+ """Seconds ``docker compose up`` may take (pulls and builds included)."""
76
+
77
+ def __hash__(self) -> int:
78
+ # Inspect keeps configurations in dicts; the dict field is not hashable.
79
+ return hash(self.model_dump_json())
80
+
81
+ def check(self) -> None:
82
+ """Raise ``ValueError`` for a value boxd would refuse, before any request."""
83
+ if self.region is not None and self.region not in REGIONS:
84
+ raise ValueError(f"region is one of {', '.join(REGIONS)}, not {self.region!r}")
85
+ if self.vcpu is not None and self.vcpu not in SIZES:
86
+ raise ValueError(f"vcpu is one of 1, 2, 4 or 8, not {self.vcpu}")
87
+ if self.memory is not None and self.memory not in SIZES.values():
88
+ raise ValueError(f"memory is one of 4G, 8G, 16G or 32G, not {self.memory!r}")
89
+ if not _PREFIX.fullmatch(self.name_prefix):
90
+ raise ValueError(
91
+ "name_prefix is 1 to 20 lowercase letters, digits or '-', starting with a letter"
92
+ )
93
+ if self.working_dir is not None and not self.working_dir.startswith("/"):
94
+ raise ValueError("working_dir is an absolute path")
95
+ for key, value in self.labels.items():
96
+ if not _LABEL_KEY.fullmatch(key) or key.startswith("boxd/"):
97
+ raise ValueError(
98
+ f"label key {key!r} is 1 to 63 of a-z, 0-9, '.', '_', '/', '-', "
99
+ "starting and ending with a letter or digit, and not under 'boxd/'"
100
+ )
101
+ if len(value) > 256 or any(ord(c) < 32 or ord(c) == 127 for c in value):
102
+ raise ValueError(
103
+ f"label {key} has a value over 256 characters or a control character"
104
+ )
105
+ if self.compose is not None and not (
106
+ is_compose_yaml(self.compose) or is_dockerfile(self.compose)
107
+ ):
108
+ raise ValueError(f"compose is a compose.yaml or a Dockerfile, not {self.compose!r}")
109
+
110
+ def machine_memory(self) -> str | None:
111
+ """``memory``, or the size that goes with ``vcpu``."""
112
+ if self.memory is not None:
113
+ return self.memory
114
+ return SIZES[self.vcpu] if self.vcpu is not None else None
115
+
116
+
117
+ Resolved = tuple[BoxdSandboxEnvironmentConfig, "str | ComposeConfig | None"]
118
+
119
+
120
+ def resolve_config(config: object) -> Resolved:
121
+ """Turn what Inspect passes as ``config`` into our settings and a compose source.
122
+
123
+ Inspect passes nothing, our object, a ``ComposeConfig``, or a path: a
124
+ ``compose.yaml`` or ``Dockerfile`` (run with Docker in the machine) or a
125
+ JSON file holding our settings.
126
+ """
127
+ compose: str | ComposeConfig | None = None
128
+ if config is None:
129
+ resolved = BoxdSandboxEnvironmentConfig()
130
+ elif isinstance(config, BoxdSandboxEnvironmentConfig):
131
+ resolved = config
132
+ elif isinstance(config, ComposeConfig) or (
133
+ isinstance(config, str) and (is_compose_yaml(config) or is_dockerfile(config))
134
+ ):
135
+ resolved = BoxdSandboxEnvironmentConfig()
136
+ compose = config
137
+ elif isinstance(config, str) and config.endswith(".json"):
138
+ resolved = BoxdSandboxEnvironmentConfig.model_validate(json.loads(Path(config).read_text()))
139
+ else:
140
+ raise TypeError(
141
+ "The boxd sandbox takes a BoxdSandboxEnvironmentConfig, a ComposeConfig, "
142
+ "or the path of a compose.yaml, a Dockerfile or a JSON file, "
143
+ f"not {config!r}"
144
+ )
145
+ resolved.check()
146
+ if compose is None and resolved.compose is not None:
147
+ compose = resolved.compose
148
+ return resolved, compose