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.
- inspect_openshell_sandbox-0.1.0/.github/workflows/publish.yml +44 -0
- inspect_openshell_sandbox-0.1.0/.github/workflows/test.yml +15 -0
- inspect_openshell_sandbox-0.1.0/.gitignore +6 -0
- inspect_openshell_sandbox-0.1.0/CHANGELOG.md +13 -0
- inspect_openshell_sandbox-0.1.0/LICENSE +21 -0
- inspect_openshell_sandbox-0.1.0/PKG-INFO +121 -0
- inspect_openshell_sandbox-0.1.0/README.md +93 -0
- inspect_openshell_sandbox-0.1.0/images/python/Dockerfile +9 -0
- inspect_openshell_sandbox-0.1.0/pyproject.toml +44 -0
- inspect_openshell_sandbox-0.1.0/scripts/smoke.py +92 -0
- inspect_openshell_sandbox-0.1.0/src/inspect_openshell/__init__.py +10 -0
- inspect_openshell_sandbox-0.1.0/src/inspect_openshell/_sandbox.py +266 -0
- inspect_openshell_sandbox-0.1.0/tests/test_config.py +37 -0
|
@@ -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,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 == []
|