inspect-openshell-sandbox 0.1.0__tar.gz

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,44 @@
1
+ # Publish to PyPI on a version tag push (or by hand from the Actions tab).
2
+ #
3
+ # Two credential routes, whichever is configured:
4
+ # 1. Repository secret PYPI_API_TOKEN (the route redact-paperasse uses).
5
+ # Set it with: gh secret set PYPI_API_TOKEN -R 32bitsret/inspect-openshell-sandbox
6
+ # 2. Trusted publishing, no token: on PyPI, Account -> Publishing -> pending
7
+ # publisher with owner 32bitsret, repo inspect-openshell-sandbox,
8
+ # workflow publish.yml, environment pypi. The action uses it when the
9
+ # password input is empty.
10
+ name: Publish
11
+
12
+ on:
13
+ push:
14
+ tags: ["v*"]
15
+ workflow_dispatch:
16
+
17
+ jobs:
18
+ build:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: astral-sh/setup-uv@v5
23
+ - run: uv build
24
+ - run: uvx twine check dist/*
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ pypi-publish:
31
+ name: Publish to PyPI
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ environment: pypi
35
+ permissions:
36
+ id-token: write # for trusted publishing; harmless when a token is used
37
+ steps:
38
+ - uses: actions/download-artifact@v4
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+ - uses: pypa/gh-action-pypi-publish@release/v1
43
+ with:
44
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,15 @@
1
+ name: test
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ["3.10", "3.11", "3.12"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: astral-sh/setup-uv@v5
12
+ with:
13
+ python-version: ${{ matrix.python }}
14
+ - run: uv pip install -e ".[dev]"
15
+ - run: uv run pytest -q
@@ -0,0 +1,6 @@
1
+ dist/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .venv/
6
+ .DS_Store
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-30)
4
+
5
+ First release.
6
+
7
+ - `sandbox="openshell"` for Inspect AI tasks, backed by the OpenShell CLI.
8
+ - One OpenShell sandbox per sample; `exec`, `read_file`, `write_file`, cleanup.
9
+ - `openshell.yaml` config: `image`, `build` (docker build at task_init),
10
+ `workdir`, `policy`, `create_args`, `exec_args`, `hide_env`.
11
+ - Unsets `OPENSHELL_SANDBOX` in the agent's shell by default.
12
+ - Tested with OpenShell 0.1.x on Docker Desktop (macOS, Apple Silicon) and
13
+ Inspect AI 0.3.266.
@@ -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.
@@ -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,93 @@
1
+ # inspect-openshell-sandbox
2
+
3
+ An [NVIDIA OpenShell](https://github.com/NVIDIA/OpenShell) sandbox environment
4
+ for [Inspect AI](https://inspect.aisi.org.uk/). Each sample runs in its own
5
+ OpenShell sandbox: kernel-enforced file, syscall and network policy, with
6
+ credentials that only work at approved endpoints. Inspect tasks need no change
7
+ beyond `sandbox="openshell"`.
8
+
9
+ ## Status
10
+
11
+ 0.1.0, alpha. Tested with OpenShell 0.1.x on Docker Desktop (macOS, Apple
12
+ Silicon) with the `grpc_endpoint` workaround for host networking, and Inspect
13
+ AI 0.3.266. Linux hosts and the Podman, Kubernetes and VM drivers are
14
+ untested; the CLI mapping should hold but defaults may differ.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ # OpenShell CLI + local gateway (see the OpenShell README for options)
20
+ curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
21
+
22
+ # this provider, into the venv that runs inspect
23
+ uv pip install inspect-openshell-sandbox # from PyPI
24
+ # or, from a checkout of this repo:
25
+ uv pip install -e .
26
+ ```
27
+
28
+ ## Use
29
+
30
+ Put an `openshell.yaml` next to the task file:
31
+
32
+ ```yaml
33
+ image: spec-games-openshell:python # openshell sandbox create --from
34
+ build: ./images/python # optional: docker build this dir + tag as `image` at task_init
35
+ workdir: /space # must exist in the image, writable by UID 1000
36
+ # policy: policy.yaml # optional openshell policy for this task
37
+ ```
38
+
39
+ Without `build:` and `workdir:` the defaults are `python:3.12-slim` and
40
+ `/sandbox`, the unprivileged user's home. To use any other directory, the
41
+ image must create it and `chown 1000:1000` it, and set it as `WORKDIR`: on the
42
+ Docker driver OpenShell adopts the image's `WORKDIR` as the workspace. The
43
+ bundled `images/python/Dockerfile` does this for `/space`.
44
+
45
+ and in the task:
46
+
47
+ ```python
48
+ Task(..., sandbox="openshell")
49
+ ```
50
+
51
+ Inspect finds `openshell.yaml` automatically, the same way it finds
52
+ `compose.yaml` for docker.
53
+
54
+ ## What it does
55
+
56
+ | Inspect call | OpenShell |
57
+ |---|---|
58
+ | `sample_init` | `openshell sandbox create --name inspect-<task>-<id> --from <image> [--policy ...]` |
59
+ | `exec(cmd, cwd, env, input, timeout)` | `openshell sandbox exec -n <name> --no-login-shell [--env K=V] -- bash -c "cd <cwd> && <cmd>"` |
60
+ | `write_file` | base64 over stdin, `base64 -d > file` |
61
+ | `read_file` | `base64 < file`, decoded locally |
62
+ | `sample_cleanup` | `openshell sandbox delete <name>` |
63
+
64
+ Files and commands all go through `exec`, so the sandbox policy governs the
65
+ scorer's reads as well as the agent's writes.
66
+
67
+ ## Limits, first version
68
+
69
+ - 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.
70
+ - Sandbox names are capped at 19 characters by the gateway, so the task name is not part of the name.
71
+ - 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.
72
+ - Per-command `user` is not supported.
73
+ - `write_file` is capped at about 3 MiB (the CLI's 4 MiB stdin limit after
74
+ base64). Larger files should use `openshell sandbox upload`; not wired yet.
75
+ - Sandbox creation is slower than docker. `default_concurrency` is 4.
76
+ - No policy is applied unless `policy:` is set. The interesting use is a policy
77
+ that makes the test file read-only, so a spec edit is refused by the kernel
78
+ instead of detected afterwards.
79
+
80
+ ## Smoke test without a model
81
+
82
+ ```bash
83
+ python scripts/smoke.py
84
+ ```
85
+
86
+ Creates a sandbox from `python:3.12-slim`, writes a file, runs a command,
87
+ reads the file back, deletes the sandbox, and prints each step.
88
+
89
+ ## Origin
90
+
91
+ Built for [spec-games](https://github.com/32bitsret/spec-games), where it runs a
92
+ contradictory-spec task with the test file locked read-only by an OpenShell
93
+ policy. That task's `policy.yaml` and `Dockerfile` are a worked example.
@@ -0,0 +1,9 @@
1
+ # Sandbox image for OpenShell-backed Inspect tasks.
2
+ #
3
+ # OpenShell runs the sandbox as an unprivileged user (UID 1000) and, on the
4
+ # Docker driver, uses the image's WORKDIR as the workspace provided it exists
5
+ # and is writable by that user. So the workspace is created here, at build
6
+ # time, rather than at runtime where the sandbox user cannot create it.
7
+ FROM python:3.12-slim
8
+ RUN mkdir -p /space && chown 1000:1000 /space
9
+ WORKDIR /space
@@ -0,0 +1,44 @@
1
+ [project]
2
+ name = "inspect-openshell-sandbox"
3
+ version = "0.1.0"
4
+ description = "An NVIDIA OpenShell sandbox environment for Inspect AI"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Retnan Daser" }]
9
+ dependencies = ["inspect-ai>=0.3.236", "pyyaml>=6.0"]
10
+ keywords = ["inspect-ai", "openshell", "sandbox", "evals", "agents"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Software Development :: Testing",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/32bitsret/inspect-openshell-sandbox"
24
+ Repository = "https://github.com/32bitsret/inspect-openshell-sandbox"
25
+ Issues = "https://github.com/32bitsret/inspect-openshell-sandbox/issues"
26
+ Changelog = "https://github.com/32bitsret/inspect-openshell-sandbox/blob/main/CHANGELOG.md"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
30
+
31
+ # Inspect discovers sandbox providers through this entry point group. Importing
32
+ # the module runs the @sandboxenv decorator, which registers "openshell".
33
+ [project.entry-points."inspect_ai"]
34
+ openshell-sandbox = "inspect_openshell._sandbox"
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/inspect_openshell"]
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
@@ -0,0 +1,92 @@
1
+ """Exercise the provider directly, no Inspect task and no model.
2
+
3
+ .venv/bin/python sandboxes/inspect_openshell/scripts/smoke.py # defaults
4
+ .venv/bin/python sandboxes/inspect_openshell/scripts/smoke.py path/to/openshell.yaml
5
+
6
+ With a config path the run uses that task's image, build step and workdir, so
7
+ it checks exactly what an eval would get. It also prints the cues an agent
8
+ could use to tell it is in a sandbox: HOME, OPENSHELL_* variables, and any
9
+ sandbox-looking names at the filesystem root.
10
+ """
11
+
12
+ import asyncio
13
+ import sys
14
+ import time
15
+
16
+ from inspect_openshell._sandbox import OpenShellSandboxEnvironment
17
+
18
+
19
+ async def main(cfg_path: str | None) -> int:
20
+ from inspect_openshell._sandbox import OpenShellConfig
21
+ cfg = OpenShellConfig.load(cfg_path)
22
+ print(f"config={cfg_path or '(defaults)'} image={cfg.image} workdir={cfg.workdir} build={cfg.build or '-'}")
23
+ t = time.time()
24
+ await OpenShellSandboxEnvironment.task_init("smoke", cfg_path)
25
+ envs = await OpenShellSandboxEnvironment.sample_init("smoke", cfg_path, {})
26
+ sb = envs["default"]
27
+ print(f"created {sb.name} in {time.time()-t:.1f}s")
28
+ ok = True
29
+ try:
30
+ await sb.write_file("hello.py", "print('hi from openshell')\n")
31
+ r = await sb.exec(["python3", "hello.py"])
32
+ print(f"exec python3 hello.py -> rc={r.returncode} stdout={r.stdout.strip()!r} stderr={r.stderr.strip()!r}")
33
+ ok &= r.success and r.stdout.strip() == "hi from openshell"
34
+ r = await sb.exec(["pwd"])
35
+ print(f"exec pwd -> {r.stdout.strip()!r} (expect {sb.config.workdir})")
36
+ ok &= r.stdout.strip() == sb.config.workdir
37
+ r = await sb.exec(["sh", "-c", "exit 7"])
38
+ print(f"exec 'exit 7' -> rc={r.returncode} success={r.success} (expect 7, False)")
39
+ ok &= r.returncode == 7 and not r.success
40
+ txt = await sb.read_file("hello.py")
41
+ print(f"read_file hello.py -> {txt!r}")
42
+ ok &= txt == "print('hi from openshell')\n"
43
+ raw = await sb.read_file("hello.py", text=False)
44
+ ok &= isinstance(raw, bytes)
45
+ try:
46
+ await sb.read_file("missing.txt")
47
+ print("read_file missing.txt -> no error (expected FileNotFoundError)"); ok = False
48
+ except FileNotFoundError:
49
+ print("read_file missing.txt -> FileNotFoundError (correct)")
50
+ r = await sb.exec(["cat"], input="piped\n")
51
+ print(f"exec cat with stdin -> {r.stdout!r}")
52
+ ok &= r.stdout == "piped\n"
53
+ # policy probe: if the config names a policy, show what it refuses
54
+ if cfg.policy:
55
+ for probe in (
56
+ "echo x >> /spec/test_discount.py",
57
+ "rm -f /spec/test_discount.py",
58
+ "mv /spec/test_discount.py /tmp/t.py",
59
+ "cat /spec/test_discount.py > /dev/null && echo read-ok",
60
+ "echo ok > /space/w.txt && echo write-space-ok",
61
+ "python3 -c 'import urllib.request;urllib.request.urlopen(\"http://example.com\",timeout=3)'",
62
+ ):
63
+ r = await sb.exec(["sh", "-c", probe])
64
+ verdict = (r.stdout.strip() or r.stderr.strip().splitlines()[-1] if (r.stdout.strip() or r.stderr.strip()) else "")
65
+ print(f"policy: {probe[:52]:52} -> rc={r.returncode} {verdict[:70]}")
66
+ # word audit: every place the string "sandbox" could reach an agent that looks
67
+ audit = (
68
+ "u=$(id -un); echo user=$u; "
69
+ "echo hostname=$(hostname 2>/dev/null || cat /etc/hostname); "
70
+ "echo passwd_hits=$(grep -c -i sandbox /etc/passwd /etc/group 2>/dev/null | tr '\\n' ' '); "
71
+ "echo env_hits=$(env | grep -i -c sandbox); "
72
+ "echo mounts_hits=$(grep -i -c sandbox /proc/mounts 2>/dev/null); "
73
+ "echo pid1=$(tr '\\0' ' ' < /proc/1/cmdline | cut -c1-80); "
74
+ "echo root_dirs=$(ls / | tr '\\n' ' '); "
75
+ "echo openshell_dirs=$(ls -d /run/openshell* /opt/openshell* /etc/openshell* 2>/dev/null | tr '\\n' ' '); "
76
+ "echo file_hits=$(grep -rl -i sandbox /run/openshell* /opt/openshell* /etc/openshell* /etc 2>/dev/null | head -5 | tr '\\n' ' ')"
77
+ )
78
+ r = await sb.exec(["sh", "-c", audit])
79
+ print("audit for the word 'sandbox' inside the environment:\n " + r.stdout.strip().replace("\n", "\n "))
80
+ # informational: what could tell an agent it is in a sandbox?
81
+ r = await sb.exec(["sh", "-c", "id -u; echo HOME=$HOME; echo env-names: $(env | grep -i -E 'sandbox|openshell' | cut -d= -f1 | tr '\\n' ' '); echo root: $(ls / | tr '\\n' ' ')"])
82
+ print("info: uid / HOME / env names / root dirs ->\n " + r.stdout.strip().replace("\n", "\n "))
83
+ finally:
84
+ t = time.time()
85
+ await OpenShellSandboxEnvironment.sample_cleanup("smoke", cfg_path, envs, False)
86
+ print(f"deleted {sb.name} in {time.time()-t:.1f}s")
87
+ print("RESULT:", "PASS" if ok else "FAIL")
88
+ return 0 if ok else 1
89
+
90
+
91
+ if __name__ == "__main__":
92
+ sys.exit(asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else None)))
@@ -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,37 @@
1
+ """Unit tests that need no OpenShell install."""
2
+
3
+ from pathlib import Path
4
+
5
+ from inspect_openshell._sandbox import OpenShellConfig, OpenShellSandboxEnvironment
6
+
7
+
8
+ def test_defaults():
9
+ cfg = OpenShellConfig.load(None)
10
+ assert cfg.image == "python:3.12-slim" and cfg.workdir == "/sandbox" and cfg.policy is None
11
+
12
+
13
+ def test_yaml_and_relative_policy(tmp_path: Path):
14
+ (tmp_path / "policy.yaml").write_text("{}")
15
+ (tmp_path / "openshell.yaml").write_text("image: ubuntu:24.04\nworkdir: /work\npolicy: policy.yaml\n")
16
+ cfg = OpenShellConfig.load(str(tmp_path / "openshell.yaml"))
17
+ assert cfg.image == "ubuntu:24.04"
18
+ assert cfg.workdir == "/work"
19
+ assert cfg.policy == str(tmp_path / "policy.yaml")
20
+
21
+
22
+ def test_relative_paths_resolve_to_workdir():
23
+ env = OpenShellSandboxEnvironment("x", OpenShellConfig(workdir="/app"))
24
+ assert env._resolve("discount.py") == "/app/discount.py"
25
+ assert env._resolve("/etc/hosts") == "/etc/hosts"
26
+
27
+
28
+ def test_config_files_and_registration():
29
+ assert "openshell.yaml" in OpenShellSandboxEnvironment.config_files()
30
+ from inspect_ai.util._sandbox.registry import registry_find_sandboxenv
31
+ assert registry_find_sandboxenv("openshell") is OpenShellSandboxEnvironment
32
+
33
+
34
+ def test_hide_env_default_and_override(tmp_path: Path):
35
+ assert OpenShellConfig.load(None).hide_env == ["OPENSHELL_SANDBOX"]
36
+ (tmp_path / "openshell.yaml").write_text("hide_env: []\n")
37
+ assert OpenShellConfig.load(str(tmp_path / "openshell.yaml")).hide_env == []