inspect-openshell-sandbox 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,10 @@
1
+ """OpenShell sandbox environment for Inspect AI.
2
+
3
+ Use ``sandbox="openshell"`` in a Task, with an ``openshell.yaml`` next to the
4
+ task file to choose the image, working directory and policy.
5
+ """
6
+
7
+ from ._sandbox import OpenShellSandboxEnvironment
8
+
9
+ __all__ = ["OpenShellSandboxEnvironment"]
10
+ __version__ = "0.1.0"
@@ -0,0 +1,266 @@
1
+ """Inspect sandbox provider backed by NVIDIA OpenShell.
2
+
3
+ Each Inspect sample gets its own OpenShell sandbox, created from an image and
4
+ governed by an OpenShell policy. Commands run through ``openshell sandbox exec``
5
+ and files move through the same channel, so the kernel-level policy applies to
6
+ everything the agent does, including the scorer's reads.
7
+
8
+ Config (``openshell.yaml`` beside the task file, all keys optional):
9
+
10
+ image: python:3.12-slim # passed to `openshell sandbox create --from`
11
+ build: images/python # optional: docker build this dir (relative to the
12
+ # config file) and tag it as `image` at task_init
13
+ workdir: /sandbox # where sample files land and where exec runs.
14
+ # Must exist in the image and be writable by the
15
+ # sandbox user (UID 1000); /sandbox always is. On
16
+ # the Docker driver the image's WORKDIR becomes the
17
+ # workspace, so set both to the same path.
18
+ policy: policy.yaml # passed to `openshell sandbox create --policy`
19
+ create_args: [] # extra flags for `openshell sandbox create`
20
+ exec_args: [] # extra flags for every `openshell sandbox exec`
21
+ hide_env: [OPENSHELL_SANDBOX] # unset these in the agent's shell so the
22
+ # environment does not announce itself; [] to keep
23
+
24
+ Verified against the OpenShell CLI documentation (sandbox overview):
25
+ ``create --from <image>``, ``exec -n <name> [--env K=V] [--no-login-shell] -- cmd``
26
+ with stdin piped and the command's exit status returned, ``delete <name>``.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import base64
33
+ import os
34
+ import shlex
35
+ import shutil
36
+ import uuid
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+ from typing import Any, Literal, Union, overload
40
+
41
+ import yaml
42
+ from inspect_ai.util import (
43
+ ExecResult,
44
+ SandboxConnection,
45
+ SandboxEnvironment,
46
+ sandboxenv,
47
+ )
48
+
49
+ CLI = "openshell"
50
+ STDIN_LIMIT = 4 * 1024 * 1024 # the CLI caps piped stdin at 4 MiB
51
+
52
+
53
+ @dataclass
54
+ class OpenShellConfig:
55
+ image: str = "python:3.12-slim"
56
+ build: str | None = None
57
+ workdir: str = "/sandbox"
58
+ policy: str | None = None
59
+ create_args: list[str] = field(default_factory=list)
60
+ exec_args: list[str] = field(default_factory=list)
61
+ hide_env: list[str] = field(default_factory=lambda: ["OPENSHELL_SANDBOX"])
62
+
63
+ @classmethod
64
+ def load(cls, config: str | None) -> "OpenShellConfig":
65
+ if config is None:
66
+ return cls()
67
+ path = Path(config)
68
+ data = yaml.safe_load(path.read_text()) or {}
69
+ for key in ("policy", "build"):
70
+ if data.get(key) and not os.path.isabs(data[key]):
71
+ data[key] = str((path.parent / data[key]).resolve())
72
+ return cls(**data)
73
+
74
+
75
+ async def _run(args: list[str], *, input: bytes | None = None, timeout: float | None = None) -> ExecResult[str]:
76
+ """Run a local `openshell ...` command and capture its result."""
77
+ proc = await asyncio.create_subprocess_exec(
78
+ *args,
79
+ stdin=asyncio.subprocess.PIPE if input is not None else asyncio.subprocess.DEVNULL,
80
+ stdout=asyncio.subprocess.PIPE,
81
+ stderr=asyncio.subprocess.PIPE,
82
+ )
83
+ try:
84
+ out, err = await asyncio.wait_for(proc.communicate(input), timeout=timeout)
85
+ except asyncio.TimeoutError:
86
+ proc.kill()
87
+ await proc.wait()
88
+ raise TimeoutError(f"timed out after {timeout}s: {' '.join(args[:6])} ...")
89
+ return ExecResult(
90
+ success=proc.returncode == 0,
91
+ returncode=proc.returncode or 0,
92
+ stdout=out.decode("utf-8", errors="replace"),
93
+ stderr=err.decode("utf-8", errors="replace"),
94
+ )
95
+
96
+
97
+ @sandboxenv(name="openshell")
98
+ class OpenShellSandboxEnvironment(SandboxEnvironment):
99
+ """One OpenShell sandbox per sample."""
100
+
101
+ def __init__(self, name: str, config: OpenShellConfig) -> None:
102
+ super().__init__()
103
+ self.name = name
104
+ self.config = config
105
+
106
+ # ---- lifecycle -------------------------------------------------------
107
+
108
+ @classmethod
109
+ def config_files(cls) -> list[str]:
110
+ return ["openshell.yaml", "openshell.yml"]
111
+
112
+ @classmethod
113
+ def default_concurrency(cls) -> int | None:
114
+ return 4 # sandbox creation is heavier than docker; tune per gateway
115
+
116
+ @classmethod
117
+ async def task_init(cls, task_name: str, config: str | None) -> None:
118
+ if shutil.which(CLI) is None:
119
+ raise RuntimeError(
120
+ "the `openshell` CLI is not on PATH; install it from "
121
+ "https://github.com/NVIDIA/OpenShell and start a gateway"
122
+ )
123
+ cfg = OpenShellConfig.load(config)
124
+ if cfg.policy and not Path(cfg.policy).exists():
125
+ raise FileNotFoundError(f"openshell policy file not found: {cfg.policy}")
126
+ if cfg.build:
127
+ if not Path(cfg.build, "Dockerfile").exists():
128
+ raise FileNotFoundError(f"no Dockerfile in openshell build dir: {cfg.build}")
129
+ if shutil.which("docker") is None:
130
+ raise RuntimeError("`build:` is set but the docker CLI is not on PATH")
131
+ result = await _run(["docker", "build", "-t", cfg.image, cfg.build], timeout=1800)
132
+ if not result.success:
133
+ raise RuntimeError(f"docker build of {cfg.image} failed:\n{result.stderr[-2000:]}")
134
+
135
+ @classmethod
136
+ async def sample_init(
137
+ cls, task_name: str, config: str | None, metadata: dict[str, str]
138
+ ) -> dict[str, SandboxEnvironment]:
139
+ cfg = OpenShellConfig.load(config)
140
+ # The gateway caps sandbox names at 19 characters, so the task name
141
+ # cannot be part of it; "insp-" + 12 hex = 17.
142
+ name = f"insp-{uuid.uuid4().hex[:12]}"
143
+ args = [CLI, "sandbox", "create", "--name", name, "--from", cfg.image]
144
+ if cfg.policy:
145
+ args += ["--policy", cfg.policy]
146
+ args += cfg.create_args
147
+ result = await _run(args, timeout=600)
148
+ if not result.success:
149
+ raise RuntimeError(f"openshell sandbox create failed for {name}:\n{result.stderr or result.stdout}")
150
+ env = cls(name, cfg)
151
+ made = await env._exec_raw(["mkdir", "-p", cfg.workdir], timeout=60)
152
+ if not made.success:
153
+ await _run([CLI, "sandbox", "delete", name], timeout=120)
154
+ raise RuntimeError(
155
+ f"cannot create workdir {cfg.workdir!r} in the sandbox (runs as an unprivileged user; "
156
+ f"/sandbox is writable): {made.stderr.strip()}"
157
+ )
158
+ return {"default": env}
159
+
160
+ @classmethod
161
+ async def sample_cleanup(
162
+ cls,
163
+ task_name: str,
164
+ config: str | None,
165
+ environments: dict[str, SandboxEnvironment],
166
+ interrupted: bool,
167
+ ) -> None:
168
+ for env in environments.values():
169
+ if isinstance(env, OpenShellSandboxEnvironment):
170
+ await _run([CLI, "sandbox", "delete", env.name], timeout=300)
171
+
172
+ @classmethod
173
+ async def task_cleanup(cls, task_name: str, config: str | None, cleanup: bool) -> None:
174
+ return None
175
+
176
+ # ---- exec ------------------------------------------------------------
177
+
178
+ def _resolve(self, path: str) -> str:
179
+ return path if os.path.isabs(path) else os.path.join(self.config.workdir, path)
180
+
181
+ async def _exec_raw(
182
+ self,
183
+ cmd: list[str],
184
+ *,
185
+ input: bytes | None = None,
186
+ env: dict[str, str] | None = None,
187
+ timeout: float | None = None,
188
+ ) -> ExecResult[str]:
189
+ args = [CLI, "sandbox", "exec", "-n", self.name, "--no-login-shell"]
190
+ for k, v in (env or {}).items():
191
+ args += ["--env", f"{k}={v}"]
192
+ args += self.config.exec_args
193
+ args += ["--", *cmd]
194
+ return await _run(args, input=input, timeout=timeout)
195
+
196
+ async def exec(
197
+ self,
198
+ cmd: list[str],
199
+ input: str | bytes | None = None,
200
+ cwd: str | None = None,
201
+ env: dict[str, str] | None = None,
202
+ user: str | None = None,
203
+ timeout: int | None = None,
204
+ timeout_retry: bool = True,
205
+ concurrency: bool = True,
206
+ ) -> ExecResult[str]:
207
+ if user is not None:
208
+ raise NotImplementedError("openshell sandbox: per-command `user` is not supported")
209
+ workdir = self._resolve(cwd) if cwd else self.config.workdir
210
+ # Run inside a shell so cwd applies; the CLI itself already wraps in bash -c.
211
+ # OPENSHELL_* variables cannot be overridden via --env (reserved prefix), so
212
+ # the ones in hide_env are unset here, for the agent's process only.
213
+ unset = f"unset {' '.join(shlex.quote(v) for v in self.config.hide_env)} && " if self.config.hide_env else ""
214
+ script = f"{unset}cd {shlex.quote(workdir)} && {shlex.join(cmd)}"
215
+ data = input.encode() if isinstance(input, str) else input
216
+ if data is not None and len(data) > STDIN_LIMIT:
217
+ raise ValueError("openshell sandbox: piped stdin is limited to 4 MiB")
218
+ return await self._exec_raw(["bash", "-c", script], input=data, env=env, timeout=timeout)
219
+
220
+ # ---- files -----------------------------------------------------------
221
+
222
+ async def write_file(self, file: str, contents: str | bytes) -> None:
223
+ path = self._resolve(file)
224
+ data = contents.encode() if isinstance(contents, str) else contents
225
+ # base64 over stdin keeps binary safe and avoids any upload path semantics.
226
+ b64 = base64.b64encode(data)
227
+ if len(b64) > STDIN_LIMIT:
228
+ raise ValueError("openshell sandbox: write_file over 3 MiB is not supported yet; use upload")
229
+ script = f"mkdir -p {shlex.quote(os.path.dirname(path) or '/')} && base64 -d > {shlex.quote(path)}"
230
+ result = await self._exec_raw(["bash", "-c", script], input=b64, timeout=120)
231
+ if not result.success:
232
+ if "Permission denied" in result.stderr:
233
+ raise PermissionError(f"{file}: {result.stderr.strip()}")
234
+ if "Is a directory" in result.stderr:
235
+ raise IsADirectoryError(file)
236
+ raise RuntimeError(f"write_file {file} failed: {result.stderr.strip()}")
237
+
238
+ @overload
239
+ async def read_file(self, file: str, text: Literal[True] = True) -> str: ...
240
+ @overload
241
+ async def read_file(self, file: str, text: Literal[False]) -> bytes: ...
242
+
243
+ async def read_file(self, file: str, text: bool = True) -> Union[str, bytes]:
244
+ path = self._resolve(file)
245
+ result = await self._exec_raw(["bash", "-c", f"base64 < {shlex.quote(path)}"], timeout=120)
246
+ if not result.success:
247
+ err = result.stderr
248
+ if "No such file" in err:
249
+ raise FileNotFoundError(file)
250
+ if "Is a directory" in err:
251
+ raise IsADirectoryError(file)
252
+ if "Permission denied" in err:
253
+ raise PermissionError(file)
254
+ raise RuntimeError(f"read_file {file} failed: {err.strip()}")
255
+ data = base64.b64decode(result.stdout)
256
+ if text:
257
+ return data.decode("utf-8")
258
+ return data
259
+
260
+ # ---- interactive -----------------------------------------------------
261
+
262
+ async def connection(self, *, user: str | None = None) -> SandboxConnection:
263
+ return SandboxConnection(
264
+ type="openshell",
265
+ command=f"{CLI} sandbox exec -n {self.name} --tty -- /bin/bash",
266
+ )
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.5
2
+ Name: inspect-openshell-sandbox
3
+ Version: 0.1.0
4
+ Summary: An NVIDIA OpenShell sandbox environment for Inspect AI
5
+ Project-URL: Homepage, https://github.com/32bitsret/inspect-openshell-sandbox
6
+ Project-URL: Repository, https://github.com/32bitsret/inspect-openshell-sandbox
7
+ Project-URL: Issues, https://github.com/32bitsret/inspect-openshell-sandbox/issues
8
+ Project-URL: Changelog, https://github.com/32bitsret/inspect-openshell-sandbox/blob/main/CHANGELOG.md
9
+ Author: Retnan Daser
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,evals,inspect-ai,openshell,sandbox
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Testing
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: inspect-ai>=0.3.236
23
+ Requires-Dist: pyyaml>=6.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # inspect-openshell-sandbox
30
+
31
+ An [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandbox environment
32
+ for [Inspect AI](https://inspect.aisi.org.uk/). Each sample runs in its own
33
+ OpenShell sandbox: kernel-enforced file, syscall and network policy, with
34
+ credentials that only work at approved endpoints. Inspect tasks need no change
35
+ beyond `sandbox="openshell"`.
36
+
37
+ ## Status
38
+
39
+ 0.1.0, alpha. Tested with OpenShell 0.1.x on Docker Desktop (macOS, Apple
40
+ Silicon) with the `grpc_endpoint` workaround for host networking, and Inspect
41
+ AI 0.3.266. Linux hosts and the Podman, Kubernetes and VM drivers are
42
+ untested; the CLI mapping should hold but defaults may differ.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ # OpenShell CLI + local gateway (see the OpenShell README for options)
48
+ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
49
+
50
+ # this provider, into the venv that runs inspect
51
+ uv pip install inspect-openshell-sandbox # from PyPI
52
+ # or, from a checkout of this repo:
53
+ uv pip install -e .
54
+ ```
55
+
56
+ ## Use
57
+
58
+ Put an `openshell.yaml` next to the task file:
59
+
60
+ ```yaml
61
+ image: spec-games-openshell:python # openshell sandbox create --from
62
+ build: ./images/python # optional: docker build this dir + tag as `image` at task_init
63
+ workdir: /space # must exist in the image, writable by UID 1000
64
+ # policy: policy.yaml # optional openshell policy for this task
65
+ ```
66
+
67
+ Without `build:` and `workdir:` the defaults are `python:3.12-slim` and
68
+ `/sandbox`, the unprivileged user's home. To use any other directory, the
69
+ image must create it and `chown 1000:1000` it, and set it as `WORKDIR`: on the
70
+ Docker driver OpenShell adopts the image's `WORKDIR` as the workspace. The
71
+ bundled `images/python/Dockerfile` does this for `/space`.
72
+
73
+ and in the task:
74
+
75
+ ```python
76
+ Task(..., sandbox="openshell")
77
+ ```
78
+
79
+ Inspect finds `openshell.yaml` automatically, the same way it finds
80
+ `compose.yaml` for docker.
81
+
82
+ ## What it does
83
+
84
+ | Inspect call | OpenShell |
85
+ |---|---|
86
+ | `sample_init` | `openshell sandbox create --name inspect-<task>-<id> --from <image> [--policy ...]` |
87
+ | `exec(cmd, cwd, env, input, timeout)` | `openshell sandbox exec -n <name> --no-login-shell [--env K=V] -- bash -c "cd <cwd> && <cmd>"` |
88
+ | `write_file` | base64 over stdin, `base64 -d > file` |
89
+ | `read_file` | `base64 < file`, decoded locally |
90
+ | `sample_cleanup` | `openshell sandbox delete <name>` |
91
+
92
+ Files and commands all go through `exec`, so the sandbox policy governs the
93
+ scorer's reads as well as the agent's writes.
94
+
95
+ ## Limits, first version
96
+
97
+ - Sandboxes run as an unprivileged user (UID 1000); only `/sandbox` and below is writable unless the image says otherwise. A `workdir` outside it fails at `sample_init` with a clear error.
98
+ - Sandbox names are capped at 19 characters by the gateway, so the task name is not part of the name.
99
+ - The sandbox exposes `OPENSHELL_SANDBOX` in the environment. Since a model that can see it knows it is being sandboxed, the provider unsets it in the agent's shell by default (`hide_env`). `HOME` is the workdir. OpenShell's own directories under `/run/openshell` and similar are still present.
100
+ - Per-command `user` is not supported.
101
+ - `write_file` is capped at about 3 MiB (the CLI's 4 MiB stdin limit after
102
+ base64). Larger files should use `openshell sandbox upload`; not wired yet.
103
+ - Sandbox creation is slower than docker. `default_concurrency` is 4.
104
+ - No policy is applied unless `policy:` is set. The interesting use is a policy
105
+ that makes the test file read-only, so a spec edit is refused by the kernel
106
+ instead of detected afterwards.
107
+
108
+ ## Smoke test without a model
109
+
110
+ ```bash
111
+ python scripts/smoke.py
112
+ ```
113
+
114
+ Creates a sandbox from `python:3.12-slim`, writes a file, runs a command,
115
+ reads the file back, deletes the sandbox, and prints each step.
116
+
117
+ ## Origin
118
+
119
+ Built for [spec-games](https://github.com/32bitsret/spec-games), where it runs a
120
+ contradictory-spec task with the test file locked read-only by an OpenShell
121
+ policy. That task's `policy.yaml` and `Dockerfile` are a worked example.
@@ -0,0 +1,7 @@
1
+ inspect_openshell/__init__.py,sha256=6HDtrK4C6O2UdE_7HbJjDXyeRBX0EXRgXT-0XUz0Hkk,309
2
+ inspect_openshell/_sandbox.py,sha256=R-qor8_AB_Nm9EAOHHZ6loYjSwd6K1pkFrWTgO2e9Lo,11445
3
+ inspect_openshell_sandbox-0.1.0.dist-info/METADATA,sha256=f9Q2U12zRhPz4-1893_cSh8XhV0bIkpHhx-tS_uEWI0,5256
4
+ inspect_openshell_sandbox-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ inspect_openshell_sandbox-0.1.0.dist-info/entry_points.txt,sha256=8TjuobMAPIMUupX2POViMVO5Ugm2nwfhbJcBSnr4DOw,60
6
+ inspect_openshell_sandbox-0.1.0.dist-info/licenses/LICENSE,sha256=PZfQ2kFRaVXfTfFFLfXflXy21OVtYekhkdjRxKM6yz8,1069
7
+ inspect_openshell_sandbox-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [inspect_ai]
2
+ openshell-sandbox = inspect_openshell._sandbox
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Retnan Daser
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.