harbor-glovebox 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.
- harbor_glovebox-0.1.0/.gitignore +192 -0
- harbor_glovebox-0.1.0/PKG-INFO +67 -0
- harbor_glovebox-0.1.0/README.md +49 -0
- harbor_glovebox-0.1.0/pyproject.toml +29 -0
- harbor_glovebox-0.1.0/src/harbor_glovebox/__init__.py +6 -0
- harbor_glovebox-0.1.0/src/harbor_glovebox/_image.py +201 -0
- harbor_glovebox-0.1.0/src/harbor_glovebox/_policy.py +83 -0
- harbor_glovebox-0.1.0/src/harbor_glovebox/_trial.py +329 -0
- harbor_glovebox-0.1.0/src/harbor_glovebox/environment.py +149 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
.worktrees
|
|
2
|
+
.claude/worktrees/
|
|
3
|
+
.agents/
|
|
4
|
+
.codex/
|
|
5
|
+
|
|
6
|
+
# vm agent-entrypoint boot trace — a runtime log appended into the workspace by
|
|
7
|
+
# gb_boot_trace; a diagnostic artifact, never committed.
|
|
8
|
+
.gb-agent-boot-trace
|
|
9
|
+
|
|
10
|
+
# Dependencies
|
|
11
|
+
# Tool-managed dirs use the BARE name (no trailing slash). A `foo/` pattern
|
|
12
|
+
# matches only a *directory*, so a symlink named `foo` — e.g. one linked into a
|
|
13
|
+
# git worktree to run tests — slips past it and `git add -A` stages it (how a
|
|
14
|
+
# node_modules symlink twice reached CI this repo's history). The bare name
|
|
15
|
+
# matches the dir AND a same-named symlink/file, so tool output can't be
|
|
16
|
+
# committed by accident. Only applied to names that are never a legitimately
|
|
17
|
+
# tracked file (dependency/venv/cache/report dirs); generic names like dist/ or
|
|
18
|
+
# build/ keep the trailing slash so they don't over-ignore a real file.
|
|
19
|
+
node_modules
|
|
20
|
+
.pnpm-store
|
|
21
|
+
|
|
22
|
+
# Build outputs
|
|
23
|
+
dist/
|
|
24
|
+
build/
|
|
25
|
+
out/
|
|
26
|
+
|
|
27
|
+
# Environment files
|
|
28
|
+
.env
|
|
29
|
+
.env.local
|
|
30
|
+
.env.*.local
|
|
31
|
+
|
|
32
|
+
# IDE
|
|
33
|
+
.idea/
|
|
34
|
+
.vscode/
|
|
35
|
+
*.swp
|
|
36
|
+
*.swo
|
|
37
|
+
|
|
38
|
+
# OS
|
|
39
|
+
.DS_Store
|
|
40
|
+
Thumbs.db
|
|
41
|
+
|
|
42
|
+
# Logs
|
|
43
|
+
*.log
|
|
44
|
+
npm-debug.log*
|
|
45
|
+
pnpm-debug.log*
|
|
46
|
+
|
|
47
|
+
# Coverage
|
|
48
|
+
coverage/
|
|
49
|
+
coverage.json
|
|
50
|
+
.c8-output
|
|
51
|
+
.coverage
|
|
52
|
+
.coverage.*
|
|
53
|
+
.py-coverage.lock
|
|
54
|
+
htmlcov
|
|
55
|
+
|
|
56
|
+
# Mutation testing (Stryker): sandbox, HTML report, and the incremental cache
|
|
57
|
+
.stryker-tmp
|
|
58
|
+
reports/
|
|
59
|
+
|
|
60
|
+
# Generated LaunchAgent (rendered from the .template by setup.bash at install time)
|
|
61
|
+
launchagents/*.generated.plist
|
|
62
|
+
|
|
63
|
+
# Python
|
|
64
|
+
__pycache__
|
|
65
|
+
*.pyc
|
|
66
|
+
.venv
|
|
67
|
+
.uv
|
|
68
|
+
.pytest_cache
|
|
69
|
+
|
|
70
|
+
# `uv run` inside inspect-glovebox/, glovebox-monitor/ or glovebox-driver/ writes a lock
|
|
71
|
+
# beside that pyproject.toml. Each wheel is a library, so it pins nothing; committing one
|
|
72
|
+
# adds a `uv.lock -merge` path that scripts/lib-regen-rules.mjs owns no rule for, which
|
|
73
|
+
# reds the JS suite.
|
|
74
|
+
/inspect-glovebox/uv.lock
|
|
75
|
+
/glovebox-monitor/uv.lock
|
|
76
|
+
/glovebox-driver/uv.lock
|
|
77
|
+
# pytest scratch (repo-relative --basetemp): throwaway fixture files, including
|
|
78
|
+
# fake executables the suite writes (keyprobe, docker stubs). Ignored so they never
|
|
79
|
+
# surface as uncommitted changes or get captured onto a session-end review branch.
|
|
80
|
+
.pytest-tmp
|
|
81
|
+
|
|
82
|
+
# Fuzzer crash reproducers (jazzer.js / atheris drop these in the repo root, which
|
|
83
|
+
# is their cwd). Anchored with a leading slash: an unanchored `crash-*` matches at
|
|
84
|
+
# ANY depth, so it silently swallowed bin/checks/vm/crash-resilience-int.bash — a
|
|
85
|
+
# `git add` of a legitimately-named source file that reports success and stages
|
|
86
|
+
# nothing.
|
|
87
|
+
/crash-*
|
|
88
|
+
/timeout-*
|
|
89
|
+
/oom-*
|
|
90
|
+
/leak-*
|
|
91
|
+
|
|
92
|
+
# History working files — each lives on the perf-history or ci-timings branch and is
|
|
93
|
+
# seeded into the checkout by persist-perf-history.sh read or record-setup-timing.sh;
|
|
94
|
+
# never committed to main or a feature branch. A glob, not a list: a tool that walks
|
|
95
|
+
# "tracked plus untracked-not-ignored" takes a seeded file for a repository file, and
|
|
96
|
+
# a per-metric list leaves the next metric's file out of the pattern.
|
|
97
|
+
.github/*-history.json
|
|
98
|
+
.github/*-history.jsonl
|
|
99
|
+
|
|
100
|
+
# pytest --basetemp droppings (used where /tmp is noexec)
|
|
101
|
+
.pytest-exec-tmp
|
|
102
|
+
|
|
103
|
+
# Hypothesis property-testing example database (regenerated locally; never committed)
|
|
104
|
+
.hypothesis/
|
|
105
|
+
|
|
106
|
+
# CI timing maps that balance the shard fan-outs. They live in R2 (uploaded only by
|
|
107
|
+
# main runs, fetched best-effort by CI — see .github/ci-durations.json) and are never
|
|
108
|
+
# committed; ignored so a CI fetch or a local `uv run pytest --store-durations` /
|
|
109
|
+
# vm-live run can't accidentally stage them.
|
|
110
|
+
tests/.gb-test-durations.json
|
|
111
|
+
tests/.gb-test-uninstrumented-durations.json
|
|
112
|
+
tests/.gb-kcov-durations.json
|
|
113
|
+
tests/.gb-drvfs-durations.json
|
|
114
|
+
tests/.gb-macos-durations.json
|
|
115
|
+
# The plan job's published shard assignment, one per leg (keyed by that leg's
|
|
116
|
+
# duration-map name): derived from the fetched map, shipped to the shards in the
|
|
117
|
+
# same artifact, never committed.
|
|
118
|
+
tests/.gb-*-shard-assignment.json
|
|
119
|
+
tests/.gb-select-maps.json
|
|
120
|
+
.github/vm-live/durations.json
|
|
121
|
+
.github/kata-live/durations.json
|
|
122
|
+
|
|
123
|
+
# The pinned scanner binary that .github/scripts/python-deps-vuln-scan.sh downloads
|
|
124
|
+
# into the repo root (~56 MB). Running that script locally to reproduce a red
|
|
125
|
+
# osv-scanner check is the documented response to one, so the artifact lands in
|
|
126
|
+
# every contributor's tree; ignored so it can't be staged by a `git add -A`.
|
|
127
|
+
/osv-scanner
|
|
128
|
+
|
|
129
|
+
# esbuild output — every artifact scripts/build-hook-bundles.mjs or
|
|
130
|
+
# scripts/build-vm-dispatcher.mjs writes, wherever its home. Built once per tree by
|
|
131
|
+
# `pnpm install`'s postinstall, by setup.bash at install time, in the nix buildPhase and
|
|
132
|
+
# in the image's own bundler stage, so the reviewable surface is the sources plus
|
|
133
|
+
# pnpm-lock.yaml. Suffix, not a directory or a member list: a bundle whose home the
|
|
134
|
+
# pattern misses leaves every tree that builds it permanently dirty.
|
|
135
|
+
*.bundle.mjs
|
|
136
|
+
|
|
137
|
+
# Sharded mutation configs are deterministic build output. The planner and
|
|
138
|
+
# `pnpm install` render them from config/mutation-sessions.json.
|
|
139
|
+
/tools/mutation/*of*.toml
|
|
140
|
+
|
|
141
|
+
# The pinned kcov binary that tests/install-kcov-local.sh builds into the repo
|
|
142
|
+
# root — the same path .github/actions/install-kcov caches to, so tests/run-kcov.sh
|
|
143
|
+
# finds a local build with no PATH edit. Reproducing a red "Bash coverage (kcov)"
|
|
144
|
+
# check locally is the documented response to one, so the artifact lands in every
|
|
145
|
+
# contributor's tree; ignored so it can't be staged by a `git add -A`.
|
|
146
|
+
/kcov-bin/
|
|
147
|
+
.claude/.hookpin-*/
|
|
148
|
+
|
|
149
|
+
# synced-deps.test.mjs's untracked-file probe, mkdtemp'd under a synced path on
|
|
150
|
+
# purpose. Ignored so a concurrent `git add -A` elsewhere in this checkout can't
|
|
151
|
+
# stage it mid-test: staged, it would pass trackedUnder()'s check and leave an
|
|
152
|
+
# AD entry in the shared index once the test deletes it from disk.
|
|
153
|
+
.claude/consumer-probe-*/
|
|
154
|
+
|
|
155
|
+
# `ct settings pull` writes this per-checkout pin cache into the repo root. The pin this
|
|
156
|
+
# harness measures lives in evals/control_tower/ct-settings-pin.txt; a committed copy here
|
|
157
|
+
# would be a second one that drifts.
|
|
158
|
+
.settings.local.yml
|
|
159
|
+
|
|
160
|
+
# `gh` writes its per-machine state here when it runs with the repo root as the
|
|
161
|
+
# XDG state home — a device identifier, which no checkout should carry.
|
|
162
|
+
/.local/
|
|
163
|
+
|
|
164
|
+
# Generated doc pages, published to the CDN instead of committed
|
|
165
|
+
# (scripts/render-doc-site.py, .github/workflows/docs-publish.yaml). A committed
|
|
166
|
+
# page is re-derived by every branch that touches its generator's inputs, which
|
|
167
|
+
# made these the busiest files in the history and conflicted branches on bytes
|
|
168
|
+
# nobody typed. Their generators still run, and still refuse a bad input; only
|
|
169
|
+
# the output stays out of the tree.
|
|
170
|
+
/docs/architecture-callgraph.md
|
|
171
|
+
/docs/ci-map.generated.md
|
|
172
|
+
/docs/inspect-evals-coverage.generated.md
|
|
173
|
+
/docs/tla/configs.md
|
|
174
|
+
/docs/tla/reachable-subsets.md
|
|
175
|
+
/docs/tla/diagrams/
|
|
176
|
+
/docs/tla/modules/
|
|
177
|
+
# TLC's own scratch metadir, written here by a run given no -metadir.
|
|
178
|
+
/docs/tla/states/
|
|
179
|
+
|
|
180
|
+
# The eval time series live on the `metrics-history` branch, which `main` never
|
|
181
|
+
# merges (.github/scripts/eval-series.sh). A tracker or a local run materializes its
|
|
182
|
+
# own working copy here; committing one would put the series back on the merge path.
|
|
183
|
+
metrics/monitor-eval.jsonl
|
|
184
|
+
metrics/sabotage-eval.jsonl
|
|
185
|
+
metrics/breakout-ctf.jsonl
|
|
186
|
+
|
|
187
|
+
# `nix build` links its output here.
|
|
188
|
+
/result
|
|
189
|
+
/result-*
|
|
190
|
+
|
|
191
|
+
# go build output of the guest network daemon
|
|
192
|
+
bin/lib/kata/netd/netd
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: harbor-glovebox
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run a Harbor trial in a hypervisor-isolated, egress-filtered glovebox microVM
|
|
5
|
+
Project-URL: Homepage, https://github.com/AlexanderMattTurner/agent-glovebox
|
|
6
|
+
Project-URL: Source, https://github.com/AlexanderMattTurner/agent-glovebox
|
|
7
|
+
Author: AlexanderMattTurner
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: ai-control,evals,harbor,microvm,sandbox,terminal-bench
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.12
|
|
15
|
+
Requires-Dist: glovebox-driver>=0.6.0
|
|
16
|
+
Requires-Dist: harbor<0.24,>=0.23
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# harbor-glovebox
|
|
20
|
+
|
|
21
|
+
Run a [Harbor](https://github.com/harbor-framework/harbor) trial inside a glovebox microVM instead of a shared-kernel container. Each trial boots one hardware-isolated VM behind glovebox's outgoing-traffic filter, and the filter's records land beside the trial's other artifacts.
|
|
22
|
+
|
|
23
|
+
## Status
|
|
24
|
+
|
|
25
|
+
The `harbor-trial` live check drives one trial's environment calls on a real microVM in CI, then runs `harbor run` with Harbor's oracle agent over a one-task dataset through this environment. No run over a published dataset with a model-backed agent has used it yet.
|
|
26
|
+
|
|
27
|
+
## Use
|
|
28
|
+
|
|
29
|
+
Install the released package:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install harbor-glovebox
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
To run the unreleased code on `main` instead, install all three packages from the repository at one revision. That needs `git` on the machine. Set `R` to the same tag or full commit for all three, since two people who install `main` on different days get different code. `--upgrade --force-reinstall` replaces a released `glovebox-driver` or `glovebox-hosts` that already satisfies the version pins:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
R=git+https://github.com/AlexanderMattTurner/agent-glovebox.git@<tag-or-full-commit>
|
|
39
|
+
pip install --upgrade --force-reinstall \
|
|
40
|
+
"glovebox-hosts @ $R#subdirectory=glovebox-hosts" \
|
|
41
|
+
"glovebox-driver @ $R#subdirectory=glovebox-driver" \
|
|
42
|
+
"harbor-glovebox @ $R#subdirectory=harbor-glovebox"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Then run a trial:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
harbor run --dataset <dataset> --agent claude-code \
|
|
49
|
+
--env harbor_glovebox.environment:GloveboxEnvironment
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The host needs what any glovebox launch needs. `glovebox sandbox preflight` checks it, and each trial runs that check before it builds anything.
|
|
53
|
+
|
|
54
|
+
## Users
|
|
55
|
+
|
|
56
|
+
A command runs as the `user` Harbor names, else the task's `[agent].user` or `[verifier].user`. With neither named, it runs as glovebox's de-privileged account, never as the image's own `USER`. Before that account's first command, it takes over the image's working directory and what the image's `USER` owned in its home. A task that names the image's own account keeps those files as they are. Every command inherits the image's `ENV`, as `docker exec` would carry it. Harbor names root itself for its own setup steps, such as installing the agent. Every command, root's included, sends its traffic through the in-VM filter and then the host filter outside the VM.
|
|
57
|
+
|
|
58
|
+
## What it refuses, and why
|
|
59
|
+
|
|
60
|
+
- **`network_mode = "public"`.** glovebox has no unfiltered mode. Use `allowlist` and list every host the task needs. Put the agent's model endpoint in Harbor's `extra_allowed_hosts`.
|
|
61
|
+
- **Wildcard and IP allowlist entries.** The filter matches hostnames only. Harbor rejects such a task before it starts.
|
|
62
|
+
- **A bare top-level name as an allowlist entry**, such as `com`. A public suffix with a dot, such as `github.io`, is accepted, but it reaches that one host alone. An ordinary entry also reaches one label below it, so `example.com` admits `api.example.com`. Under a public suffix that label is a stranger's name.
|
|
63
|
+
- **A download larger than 1 GiB.** The guest's archive arrives in the host's memory whole, so the transfer fails rather than grow without bound.
|
|
64
|
+
- **A task image that already ships `/logs`, `/tests`, `/solution` or `/harbor`.** The guest's root is read-only, so each of Harbor's fixed trees is a link onto the guest's writable `/tmp` disk, and the image build refuses to replace a tree the image carries.
|
|
65
|
+
- **Multi-container tasks** (a `docker-compose.yaml` in the task's environment directory). <!-- # allow-dangling-path: a file in a Harbor task's own directory, not in this repo -->
|
|
66
|
+
|
|
67
|
+
`storage_mb` is ignored, as Harbor's own Docker environment ignores it.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# harbor-glovebox
|
|
2
|
+
|
|
3
|
+
Run a [Harbor](https://github.com/harbor-framework/harbor) trial inside a glovebox microVM instead of a shared-kernel container. Each trial boots one hardware-isolated VM behind glovebox's outgoing-traffic filter, and the filter's records land beside the trial's other artifacts.
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
The `harbor-trial` live check drives one trial's environment calls on a real microVM in CI, then runs `harbor run` with Harbor's oracle agent over a one-task dataset through this environment. No run over a published dataset with a model-backed agent has used it yet.
|
|
8
|
+
|
|
9
|
+
## Use
|
|
10
|
+
|
|
11
|
+
Install the released package:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install harbor-glovebox
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
To run the unreleased code on `main` instead, install all three packages from the repository at one revision. That needs `git` on the machine. Set `R` to the same tag or full commit for all three, since two people who install `main` on different days get different code. `--upgrade --force-reinstall` replaces a released `glovebox-driver` or `glovebox-hosts` that already satisfies the version pins:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
R=git+https://github.com/AlexanderMattTurner/agent-glovebox.git@<tag-or-full-commit>
|
|
21
|
+
pip install --upgrade --force-reinstall \
|
|
22
|
+
"glovebox-hosts @ $R#subdirectory=glovebox-hosts" \
|
|
23
|
+
"glovebox-driver @ $R#subdirectory=glovebox-driver" \
|
|
24
|
+
"harbor-glovebox @ $R#subdirectory=harbor-glovebox"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then run a trial:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
harbor run --dataset <dataset> --agent claude-code \
|
|
31
|
+
--env harbor_glovebox.environment:GloveboxEnvironment
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The host needs what any glovebox launch needs. `glovebox sandbox preflight` checks it, and each trial runs that check before it builds anything.
|
|
35
|
+
|
|
36
|
+
## Users
|
|
37
|
+
|
|
38
|
+
A command runs as the `user` Harbor names, else the task's `[agent].user` or `[verifier].user`. With neither named, it runs as glovebox's de-privileged account, never as the image's own `USER`. Before that account's first command, it takes over the image's working directory and what the image's `USER` owned in its home. A task that names the image's own account keeps those files as they are. Every command inherits the image's `ENV`, as `docker exec` would carry it. Harbor names root itself for its own setup steps, such as installing the agent. Every command, root's included, sends its traffic through the in-VM filter and then the host filter outside the VM.
|
|
39
|
+
|
|
40
|
+
## What it refuses, and why
|
|
41
|
+
|
|
42
|
+
- **`network_mode = "public"`.** glovebox has no unfiltered mode. Use `allowlist` and list every host the task needs. Put the agent's model endpoint in Harbor's `extra_allowed_hosts`.
|
|
43
|
+
- **Wildcard and IP allowlist entries.** The filter matches hostnames only. Harbor rejects such a task before it starts.
|
|
44
|
+
- **A bare top-level name as an allowlist entry**, such as `com`. A public suffix with a dot, such as `github.io`, is accepted, but it reaches that one host alone. An ordinary entry also reaches one label below it, so `example.com` admits `api.example.com`. Under a public suffix that label is a stranger's name.
|
|
45
|
+
- **A download larger than 1 GiB.** The guest's archive arrives in the host's memory whole, so the transfer fails rather than grow without bound.
|
|
46
|
+
- **A task image that already ships `/logs`, `/tests`, `/solution` or `/harbor`.** The guest's root is read-only, so each of Harbor's fixed trees is a link onto the guest's writable `/tmp` disk, and the image build refuses to replace a tree the image carries.
|
|
47
|
+
- **Multi-container tasks** (a `docker-compose.yaml` in the task's environment directory). <!-- # allow-dangling-path: a file in a Harbor task's own directory, not in this repo -->
|
|
48
|
+
|
|
49
|
+
`storage_mb` is ignored, as Harbor's own Docker environment ignores it.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "harbor-glovebox"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Run a Harbor trial in a hypervisor-isolated, egress-filtered glovebox microVM"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "AlexanderMattTurner" }]
|
|
9
|
+
keywords = ["harbor", "terminal-bench", "evals", "sandbox", "ai-control", "microvm"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Security",
|
|
15
|
+
]
|
|
16
|
+
# Harbor is pinned to one minor release: it is pre-1.0 and its environment interface moves
|
|
17
|
+
# between minors, so a wider range would admit a release this adapter was never run against.
|
|
18
|
+
dependencies = ["harbor>=0.23,<0.24", "glovebox-driver>=0.6.0"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/AlexanderMattTurner/agent-glovebox"
|
|
22
|
+
Source = "https://github.com/AlexanderMattTurner/agent-glovebox"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling==1.32.0"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/harbor_glovebox"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Run a Harbor trial inside a glovebox microVM instead of a shared-kernel container.
|
|
2
|
+
|
|
3
|
+
Harbor loads the environment with `--env harbor_glovebox.environment:GloveboxEnvironment`.
|
|
4
|
+
That module is the only one that imports Harbor, so importing this package needs neither
|
|
5
|
+
Harbor nor a glovebox host.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""A Harbor task's image as the microVM's root filesystem.
|
|
2
|
+
|
|
3
|
+
PROBLEM CLASS — a task names a prebuilt `docker_image` or ships a Dockerfile, and that image
|
|
4
|
+
IS the environment the trial is scored in. A microVM boots ONE root filesystem, so glovebox's
|
|
5
|
+
guest layers are built on top of the task's image and the result is booted. Every step here
|
|
6
|
+
fails loud: a task whose image cannot be built must not fall back to glovebox's own guest
|
|
7
|
+
image, which would score it against an environment missing its setup.
|
|
8
|
+
|
|
9
|
+
INVARIANT: every image is named by its content id, never by the tag the task wrote. A tag is
|
|
10
|
+
repointable, so a concurrent trial or a re-push could otherwise hand one trial another's bytes.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
import threading
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from hashlib import sha256
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from uuid import uuid4
|
|
21
|
+
|
|
22
|
+
from glovebox_driver import cli
|
|
23
|
+
|
|
24
|
+
# The names this module writes, so `docker image ls harbor-glovebox-*` shows what a run left.
|
|
25
|
+
_BASE_REPO = "harbor-glovebox-base"
|
|
26
|
+
_ROOTFS_REPO = "harbor-glovebox-rootfs"
|
|
27
|
+
_STAGING_REPO = "harbor-glovebox-staging"
|
|
28
|
+
|
|
29
|
+
# The guest reads its own startup file through this name, so a task's value would replace it.
|
|
30
|
+
_GUEST_OWNED_ENV = frozenset({"BASH_ENV"})
|
|
31
|
+
|
|
32
|
+
# Harbor writes these fixed root-level trees, and the guest's root is read-only. Each is baked
|
|
33
|
+
# as a link into this scratch directory on the guest's writable /tmp disk.
|
|
34
|
+
HARBOR_SCRATCH = "/tmp/gb-harbor" # noqa: S108 — a guest path, made by root before any task command
|
|
35
|
+
HARBOR_TREES = ("harbor", "logs", "solution", "tests")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
39
|
+
class ImageConfig:
|
|
40
|
+
"""What `docker exec` inherits from an image: its `WORKDIR`, `USER` and `ENV`."""
|
|
41
|
+
|
|
42
|
+
# "" when no base in the chain set one, which is docker's own answer for "the root".
|
|
43
|
+
working_dir: str
|
|
44
|
+
# "" when no base set one, which is docker's own answer for "root" too.
|
|
45
|
+
user: str
|
|
46
|
+
environment: tuple[tuple[str, str], ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pulled_image(image: str, *, refresh: bool = False) -> str:
|
|
50
|
+
"""`image`, present on this host, as a tag bound to its content id; `refresh` pulls even when present.
|
|
51
|
+
|
|
52
|
+
The presence check, the pull and the binding run under one lock per name, so a concurrent
|
|
53
|
+
trial's pull cannot retag `image` between this trial's check and its binding."""
|
|
54
|
+
with _lock_for(_tag_locks, image):
|
|
55
|
+
if refresh or not _image_id(image):
|
|
56
|
+
_docker(["pull", image], announce=f"harbor-glovebox: pulling {image}.")
|
|
57
|
+
return _bound(image)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def image_from_dockerfile(dockerfile: Path, *, no_cache: bool = False) -> str:
|
|
61
|
+
"""The task's Dockerfile built with its own directory as context, as a tag bound to the result's content id.
|
|
62
|
+
|
|
63
|
+
The build lands under a one-use tag, so a concurrent build of the same task cannot retag
|
|
64
|
+
it before this one reads it back. `no_cache` rebuilds every layer and re-pulls the base,
|
|
65
|
+
which is what Harbor's `force_build` asks for."""
|
|
66
|
+
resolved = dockerfile.resolve()
|
|
67
|
+
staging = f"{_STAGING_REPO}:{uuid4().hex}"
|
|
68
|
+
fresh = ["--no-cache", "--pull"] if no_cache else []
|
|
69
|
+
try:
|
|
70
|
+
_docker(
|
|
71
|
+
[
|
|
72
|
+
"build",
|
|
73
|
+
*fresh,
|
|
74
|
+
"--tag",
|
|
75
|
+
staging,
|
|
76
|
+
"--file",
|
|
77
|
+
str(resolved),
|
|
78
|
+
str(resolved.parent),
|
|
79
|
+
],
|
|
80
|
+
announce=f"harbor-glovebox: building {resolved}.",
|
|
81
|
+
)
|
|
82
|
+
return _bound(staging)
|
|
83
|
+
finally:
|
|
84
|
+
# Not `_docker`: this name decides nothing, and a failed removal must not mask the build.
|
|
85
|
+
subprocess.run(
|
|
86
|
+
["docker", "image", "rm", staging],
|
|
87
|
+
capture_output=True,
|
|
88
|
+
timeout=cli.IMAGE_BUILD_TIMEOUT_S,
|
|
89
|
+
check=False,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def with_harbor_trees(base: str) -> str:
|
|
94
|
+
"""`base` with each of Harbor's root-level trees linked into `HARBOR_SCRATCH`.
|
|
95
|
+
|
|
96
|
+
COPY carries the links as links and needs no shell in the task's image. An image that
|
|
97
|
+
already ships one of these trees fails the build rather than lose it silently."""
|
|
98
|
+
with tempfile.TemporaryDirectory() as context:
|
|
99
|
+
root = Path(context) / "root"
|
|
100
|
+
root.mkdir()
|
|
101
|
+
for tree in HARBOR_TREES:
|
|
102
|
+
(root / tree).symlink_to(f"{HARBOR_SCRATCH}/{tree}")
|
|
103
|
+
dockerfile = Path(context) / "Dockerfile"
|
|
104
|
+
dockerfile.write_text(f"FROM {base}\nCOPY root/ /\n", encoding="utf-8")
|
|
105
|
+
return image_from_dockerfile(dockerfile)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def image_config(image: str) -> ImageConfig:
|
|
109
|
+
"""`image`'s own `WORKDIR`, `USER` and `ENV`, as docker resolved them through every base."""
|
|
110
|
+
config = json.loads(
|
|
111
|
+
_docker(["image", "inspect", "--format", "{{json .Config}}", image])
|
|
112
|
+
)
|
|
113
|
+
pairs = (str(entry).partition("=") for entry in config.get("Env") or ())
|
|
114
|
+
return ImageConfig(
|
|
115
|
+
working_dir=str(config.get("WorkingDir") or ""),
|
|
116
|
+
# Docker records "user:group"; only the user half names the account.
|
|
117
|
+
user=str(config.get("User") or "").split(":", maxsplit=1)[0],
|
|
118
|
+
environment=tuple(
|
|
119
|
+
(name, value)
|
|
120
|
+
for name, _, value in pairs
|
|
121
|
+
if name and name not in _GUEST_OWNED_ENV
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_builds: dict[str, str] = {}
|
|
127
|
+
_build_locks: dict[str, threading.Lock] = {}
|
|
128
|
+
_tag_locks: dict[str, threading.Lock] = {}
|
|
129
|
+
_builds_guard = threading.Lock()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _lock_for(locks: dict[str, threading.Lock], key: str) -> threading.Lock:
|
|
133
|
+
with _builds_guard:
|
|
134
|
+
return locks.setdefault(key, threading.Lock())
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rootfs_from_image(base: str, working_dir: str) -> str:
|
|
138
|
+
"""`base` with glovebox's guest layers on top, loaded into the backend's image store, as the tag a boot names.
|
|
139
|
+
|
|
140
|
+
Keyed on the base's content id and `working_dir`, which the build bakes aside for the disk
|
|
141
|
+
the cell mounts there. Concurrent trials of one image wait on one build rather than each
|
|
142
|
+
running their own."""
|
|
143
|
+
identity = _image_id(base)
|
|
144
|
+
if not identity:
|
|
145
|
+
raise RuntimeError(f"docker holds no image for {base}.")
|
|
146
|
+
key = f"{identity}\n{working_dir}"
|
|
147
|
+
with _lock_for(_build_locks, key):
|
|
148
|
+
if key not in _builds:
|
|
149
|
+
tag = _tag(_ROOTFS_REPO, key)
|
|
150
|
+
print(
|
|
151
|
+
f"harbor-glovebox: building {base} into the microVM's root filesystem. "
|
|
152
|
+
"This takes minutes; later trials of this image reuse it."
|
|
153
|
+
)
|
|
154
|
+
cli.build_rootfs(base, tag, working_dir)
|
|
155
|
+
_builds[key] = tag
|
|
156
|
+
return _builds[key]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _bound(image: str) -> str:
|
|
160
|
+
"""A tag this module owns, bound to `image`'s content id, so no later retag of `image` moves it."""
|
|
161
|
+
identity = _image_id(image)
|
|
162
|
+
if not identity:
|
|
163
|
+
raise RuntimeError(f"docker holds no image for {image} after producing it.")
|
|
164
|
+
tag = _tag(_BASE_REPO, identity)
|
|
165
|
+
_docker(["tag", identity, tag])
|
|
166
|
+
return tag
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _image_id(image: str) -> str:
|
|
170
|
+
"""`image`'s content id on this host, or "" when this host holds no such image."""
|
|
171
|
+
held = subprocess.run(
|
|
172
|
+
["docker", "image", "inspect", "--format", "{{.Id}}", image],
|
|
173
|
+
capture_output=True,
|
|
174
|
+
timeout=cli.IMAGE_BUILD_TIMEOUT_S,
|
|
175
|
+
check=False,
|
|
176
|
+
)
|
|
177
|
+
if held.returncode != 0:
|
|
178
|
+
return ""
|
|
179
|
+
return held.stdout.decode("utf-8", errors="replace").strip()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _tag(repo: str, key: str) -> str:
|
|
183
|
+
return f"{repo}:{sha256(key.encode('utf-8')).hexdigest()[:16]}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _docker(args: list[str], *, announce: str | None = None) -> str:
|
|
187
|
+
"""One `docker` call's stdout, raising on any failure."""
|
|
188
|
+
if announce is not None:
|
|
189
|
+
print(announce)
|
|
190
|
+
proc = subprocess.run(
|
|
191
|
+
["docker", *args],
|
|
192
|
+
capture_output=True,
|
|
193
|
+
timeout=cli.IMAGE_BUILD_TIMEOUT_S,
|
|
194
|
+
check=False,
|
|
195
|
+
)
|
|
196
|
+
if proc.returncode != 0:
|
|
197
|
+
detail = proc.stderr.decode("utf-8", errors="replace").strip()
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
f"docker {' '.join(args)} failed (exit {proc.returncode}).\n{detail}"
|
|
200
|
+
)
|
|
201
|
+
return proc.stdout.decode("utf-8", errors="replace")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""How a Harbor trial's network policy and exec users map onto glovebox, with no Harbor import.
|
|
2
|
+
|
|
3
|
+
Kept free of `harbor` so the rules are unit-tested without Harbor's closure, and so
|
|
4
|
+
`environment.py` stays a thin translation of Harbor's calls onto them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from ipaddress import ip_network
|
|
9
|
+
|
|
10
|
+
from glovebox_driver.config import DEFAULT_GUEST_USER, Access
|
|
11
|
+
|
|
12
|
+
# Harbor's `NetworkMode` values, as strings so this module needs no Harbor import.
|
|
13
|
+
PUBLIC = "public"
|
|
14
|
+
NO_NETWORK = "no-network"
|
|
15
|
+
ALLOWLIST = "allowlist"
|
|
16
|
+
|
|
17
|
+
_ROOT_NAMES: frozenset[str | int] = frozenset({"root", "0", 0})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedPolicy(ValueError):
|
|
21
|
+
"""A Harbor setting this environment cannot enforce exactly, refused before it takes effect."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def allowlist_pairs(mode: str, hosts: Sequence[str]) -> tuple[tuple[str, Access], ...]:
|
|
25
|
+
"""Harbor's network policy as the host-to-access pairs glovebox's allowlist file takes.
|
|
26
|
+
|
|
27
|
+
INVARIANT: Harbor requires a provider to enforce a policy exactly or refuse it. glovebox
|
|
28
|
+
has no unfiltered mode, so `public` is refused rather than widened to a guess. An allowed
|
|
29
|
+
host gets `rw`, because Harbor's allowlist admits every request to that host.
|
|
30
|
+
|
|
31
|
+
A glovebox grant also reaches one label below its host, so `example.com` admits
|
|
32
|
+
`api.example.com`. A public suffix such as `github.io`, whose labels strangers
|
|
33
|
+
register, is held to its own host by glovebox's render instead
|
|
34
|
+
(`glovebox_hosts.grant_is_exact`). A bare top-level name like `com` is refused.
|
|
35
|
+
"""
|
|
36
|
+
if mode == PUBLIC:
|
|
37
|
+
raise UnsupportedPolicy(
|
|
38
|
+
"glovebox has no unfiltered network mode, so it cannot run "
|
|
39
|
+
"network_mode='public'. Set network_mode='allowlist' and list every host the "
|
|
40
|
+
"task needs, including the agent's model endpoint (Harbor's "
|
|
41
|
+
"`extra_allowed_hosts`)."
|
|
42
|
+
)
|
|
43
|
+
if mode == NO_NETWORK:
|
|
44
|
+
return ()
|
|
45
|
+
if mode != ALLOWLIST:
|
|
46
|
+
raise UnsupportedPolicy(f"unknown Harbor network_mode {mode!r}.")
|
|
47
|
+
# Judged as glovebox reads them: surrounding whitespace is stripped before a host is
|
|
48
|
+
# matched, so a check on the raw string would let " *.example.com" through.
|
|
49
|
+
hosts = [host.strip() for host in hosts]
|
|
50
|
+
for host in hosts:
|
|
51
|
+
if host.startswith("*.") or _is_address(host):
|
|
52
|
+
raise UnsupportedPolicy(
|
|
53
|
+
f"allowlist entry {host!r} is not an exact hostname, and glovebox's "
|
|
54
|
+
"filter matches exact hostnames only."
|
|
55
|
+
)
|
|
56
|
+
if "." not in host.strip("."):
|
|
57
|
+
raise UnsupportedPolicy(
|
|
58
|
+
f"allowlist entry {host!r} is a bare top-level name, which glovebox "
|
|
59
|
+
"cannot grant. List the one host the task needs instead."
|
|
60
|
+
)
|
|
61
|
+
return tuple((host, "rw") for host in dict.fromkeys(hosts))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_address(host: str) -> bool:
|
|
65
|
+
"""True for an IP address or a CIDR range, which Harbor accepts and glovebox cannot match."""
|
|
66
|
+
try:
|
|
67
|
+
ip_network(host, strict=False)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def exec_user(requested: str | int | None, default_user: str | int | None) -> str:
|
|
74
|
+
"""The guest account one Harbor `exec` runs as.
|
|
75
|
+
|
|
76
|
+
An explicit `user` wins, then the task's own `[agent].user` or `[verifier].user`. With
|
|
77
|
+
neither named, the command runs as glovebox's de-privileged account, never as the image's
|
|
78
|
+
`USER`, which is usually root. Harbor names root itself for its own setup steps.
|
|
79
|
+
"""
|
|
80
|
+
user = requested if requested is not None else default_user
|
|
81
|
+
if user is None:
|
|
82
|
+
return DEFAULT_GUEST_USER
|
|
83
|
+
return "root" if user in _ROOT_NAMES else str(user)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""One Harbor trial's microVM: build its root filesystem, boot it, run commands, move files, reap it.
|
|
2
|
+
|
|
3
|
+
Everything `environment.py` does, with no Harbor import, so it runs under this repo's own
|
|
4
|
+
tests and type checker. Harbor's dependency closure pins `openai<3`, which this repo's
|
|
5
|
+
development environment cannot hold beside its own pin.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import io
|
|
10
|
+
import posixpath
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tarfile
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path, PurePosixPath
|
|
17
|
+
|
|
18
|
+
from glovebox_driver import aio, cli
|
|
19
|
+
from glovebox_driver.config import Access, GloveboxSandboxConfig
|
|
20
|
+
from glovebox_driver.guest_account import ensure_working_dir, hand_contract_home_over
|
|
21
|
+
from glovebox_driver.guest_exec import ExecOutcome
|
|
22
|
+
from glovebox_driver.guest_prelude import EGRESS_FILTER_ENV_FILE
|
|
23
|
+
from glovebox_driver.run import DisposableRun, EvidenceRequest
|
|
24
|
+
|
|
25
|
+
from ._image import (
|
|
26
|
+
HARBOR_SCRATCH,
|
|
27
|
+
HARBOR_TREES,
|
|
28
|
+
image_config,
|
|
29
|
+
image_from_dockerfile,
|
|
30
|
+
pulled_image,
|
|
31
|
+
rootfs_from_image,
|
|
32
|
+
with_harbor_trees,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# A tar that writes into a guest directory as root-owned files, and one that reads out of it.
|
|
36
|
+
# Paths bind to `$1`/`$2` and are never spliced into the script text. `--` keeps a member
|
|
37
|
+
# named like `-C` a member. The read stops one byte past `$3`, so the host holds at most that.
|
|
38
|
+
_UNPACK = 'mkdir -p "$1" && tar -x --no-same-owner -C "$1"'
|
|
39
|
+
_PACK = 'set -o pipefail; tar -c -C "$1" -- "$2" | head -c "$3"'
|
|
40
|
+
|
|
41
|
+
# The most one download may carry. The guest's tar arrives as one buffered reply, so a
|
|
42
|
+
# directory the agent inflates must not be able to take the host's memory with it.
|
|
43
|
+
MAX_DOWNLOAD_BYTES = 1 << 30
|
|
44
|
+
|
|
45
|
+
# INVARIANT: every Harbor command, root's included, sources the in-VM filter's routing after
|
|
46
|
+
# the caller's env, so no exec leaves the guest except through that filter. Root's exec sources
|
|
47
|
+
# none of its own. With the file absent the command still runs, with no route out at all.
|
|
48
|
+
_ROUTED = '[ -r "$1" ] && . "$1"; exec bash -c "$2"'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TransferFailed(RuntimeError):
|
|
52
|
+
"""A file copy into or out of the guest did not complete."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def base_image(
|
|
56
|
+
environment_dir: Path, docker_image: str | None, *, force_build: bool = False
|
|
57
|
+
) -> str:
|
|
58
|
+
"""The Docker image the trial's root filesystem is layered on: the task's prebuilt one, else its Dockerfile built here.
|
|
59
|
+
|
|
60
|
+
A compose file is refused before either source is read: its sidecars would otherwise be
|
|
61
|
+
dropped silently, whichever image the primary service names."""
|
|
62
|
+
if (environment_dir / "docker-compose.yaml").exists():
|
|
63
|
+
raise NotImplementedError(
|
|
64
|
+
"harbor-glovebox runs single-container tasks only; this task ships a "
|
|
65
|
+
"docker-compose.yaml."
|
|
66
|
+
)
|
|
67
|
+
if docker_image:
|
|
68
|
+
return pulled_image(docker_image, refresh=force_build)
|
|
69
|
+
return image_from_dockerfile(environment_dir / "Dockerfile", no_cache=force_build)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def evidence_request(evidence_dir: Path) -> EvidenceRequest:
|
|
73
|
+
"""Where the decision log and the host-side filter log land, captured before teardown reaps the run."""
|
|
74
|
+
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
return EvidenceRequest(
|
|
76
|
+
traffic=evidence_dir / "egress.jsonl",
|
|
77
|
+
host_filter=evidence_dir / "egress-host-filter.jsonl",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pack(members: list[tuple[Path, str]]) -> bytes:
|
|
82
|
+
"""Each `(path, arcname)` as a tar entry, with no `.` entry, so a target directory keeps its own owner and mode."""
|
|
83
|
+
buffer = io.BytesIO()
|
|
84
|
+
with tarfile.open(fileobj=buffer, mode="w") as archive:
|
|
85
|
+
for path, arcname in members:
|
|
86
|
+
archive.add(path, arcname=arcname)
|
|
87
|
+
return buffer.getvalue()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def unpack(data: bytes, into: Path) -> None:
|
|
91
|
+
"""Extract a tar the guest produced under `into`, refusing any member that would land outside it."""
|
|
92
|
+
into.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
# nosemgrep: tarfile-extractall-traversal — filter="data" is the refusal the rule asks for
|
|
94
|
+
with tarfile.open(fileobj=io.BytesIO(data), mode="r") as archive:
|
|
95
|
+
archive.extractall(into, filter="data")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _land(data: bytes, target: Path, name: str, *, is_dir: bool) -> None:
|
|
99
|
+
"""A guest tar at `target`: a directory's members under it, or the one member `name` as it."""
|
|
100
|
+
if is_dir:
|
|
101
|
+
unpack(data, target)
|
|
102
|
+
return
|
|
103
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
104
|
+
unpack(data, Path(scratch))
|
|
105
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
shutil.move(Path(scratch) / name, target)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class GloveboxTrial:
|
|
110
|
+
"""One booted microVM, driven through `glovebox_driver`, with Harbor's operations on top."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, *, config: GloveboxSandboxConfig, evidence_dir: Path) -> None:
|
|
113
|
+
self._config = config
|
|
114
|
+
self._evidence_dir = evidence_dir
|
|
115
|
+
self._run: DisposableRun | None = None
|
|
116
|
+
self._handed_over = False
|
|
117
|
+
self._hand_over_lock = asyncio.Lock()
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def run(self) -> DisposableRun:
|
|
121
|
+
"""The booted run, which exists only between `start` and `stop`."""
|
|
122
|
+
if self._run is None:
|
|
123
|
+
raise RuntimeError("the glovebox microVM is not running.")
|
|
124
|
+
return self._run
|
|
125
|
+
|
|
126
|
+
async def start(self) -> None:
|
|
127
|
+
"""Stage an empty workspace, boot the microVM onto it, and make the trees Harbor writes.
|
|
128
|
+
|
|
129
|
+
Root makes them before any other command runs, so no later account owns their roots."""
|
|
130
|
+
staged = await aio.stage_run(
|
|
131
|
+
self._config, evidence=evidence_request(self._evidence_dir)
|
|
132
|
+
)
|
|
133
|
+
self._run = await aio.boot(staged)
|
|
134
|
+
try:
|
|
135
|
+
await self._make_trees()
|
|
136
|
+
except BaseException:
|
|
137
|
+
# The caller's cleanup begins only once this returns, so the booted VM is reaped here.
|
|
138
|
+
await self.stop(failed=True)
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
async def _make_trees(self) -> None:
|
|
142
|
+
trees = [f"{HARBOR_SCRATCH}/{tree}" for tree in HARBOR_TREES]
|
|
143
|
+
made = await self._guest(["mkdir", "-p", "-m", "0755", *trees], "root")
|
|
144
|
+
if made.returncode != 0:
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
"could not make Harbor's directories in the guest: "
|
|
147
|
+
+ made.stderr.decode("utf-8", "replace").strip()
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def _hand_over_once(self) -> None:
|
|
151
|
+
"""Give the de-privileged account what the image's own `USER` owned, before its first command.
|
|
152
|
+
|
|
153
|
+
Deferred to that command, never run at boot: a task whose `[agent].user` names the
|
|
154
|
+
image's own account runs as that account, which already owns its files."""
|
|
155
|
+
async with self._hand_over_lock:
|
|
156
|
+
if self._handed_over:
|
|
157
|
+
return
|
|
158
|
+
await asyncio.to_thread(self._hand_task_dirs_over, self.run)
|
|
159
|
+
self._handed_over = True
|
|
160
|
+
|
|
161
|
+
def _hand_task_dirs_over(self, run: DisposableRun) -> None:
|
|
162
|
+
config = self._config
|
|
163
|
+
hand_contract_home_over(run.session, config.user, config.working_dir)
|
|
164
|
+
if config.working_dir:
|
|
165
|
+
ensure_working_dir(run.session, config.working_dir, config.user)
|
|
166
|
+
|
|
167
|
+
async def stop(self, *, failed: bool = False) -> None:
|
|
168
|
+
"""Export the egress records, then reap the VM. A second call does nothing."""
|
|
169
|
+
run, self._run = self._run, None
|
|
170
|
+
if run is not None:
|
|
171
|
+
await aio.close(run, failed=failed)
|
|
172
|
+
|
|
173
|
+
async def exec( # pylint: disable=too-many-arguments # Harbor's exec signature
|
|
174
|
+
self,
|
|
175
|
+
command: str,
|
|
176
|
+
*,
|
|
177
|
+
user: str,
|
|
178
|
+
cwd: str | None = None,
|
|
179
|
+
env: dict[str, str] | None = None,
|
|
180
|
+
timeout_sec: int | None = None,
|
|
181
|
+
) -> ExecOutcome:
|
|
182
|
+
"""Run `command` under bash in the guest as `user`, a name or a uid, as Harbor's Docker environment does.
|
|
183
|
+
|
|
184
|
+
`timeout_sec` bounds the account lookup and the command together."""
|
|
185
|
+
deadline = None if timeout_sec is None else time.monotonic() + timeout_sec
|
|
186
|
+
try:
|
|
187
|
+
account = await self._account(user, deadline)
|
|
188
|
+
if account == self._config.user:
|
|
189
|
+
await self._hand_over_once()
|
|
190
|
+
routed = ["bash", "-c", _ROUTED, "_", EGRESS_FILTER_ENV_FILE, command]
|
|
191
|
+
return await self._guest(
|
|
192
|
+
routed, account, cwd=cwd, env=env, timeout=_remaining(deadline)
|
|
193
|
+
)
|
|
194
|
+
except subprocess.TimeoutExpired as exc:
|
|
195
|
+
raise RuntimeError(
|
|
196
|
+
f"Command timed out after {timeout_sec} seconds"
|
|
197
|
+
) from exc
|
|
198
|
+
|
|
199
|
+
async def _account(self, user: str, deadline: float | None) -> str:
|
|
200
|
+
"""`user` as the account name `runuser` takes; a bare uid is looked up in the guest."""
|
|
201
|
+
if not user.isdigit():
|
|
202
|
+
return user
|
|
203
|
+
found = await self._guest(
|
|
204
|
+
["getent", "passwd", user], "root", timeout=_remaining(deadline)
|
|
205
|
+
)
|
|
206
|
+
name = found.stdout.decode("utf-8", "replace").split(":", 1)[0]
|
|
207
|
+
if found.returncode != 0 or not name:
|
|
208
|
+
raise RuntimeError(f"uid {user} names no account in the guest.")
|
|
209
|
+
return name
|
|
210
|
+
|
|
211
|
+
async def upload(self, source: Path, target: str) -> None:
|
|
212
|
+
"""Copy a host file or directory to `target` in the guest, as root."""
|
|
213
|
+
dest = PurePosixPath(target)
|
|
214
|
+
if source.is_dir():
|
|
215
|
+
into, members = dest, [(c, c.name) for c in sorted(source.iterdir())]
|
|
216
|
+
else:
|
|
217
|
+
into, members = dest.parent, [(source, dest.name)]
|
|
218
|
+
script = ["sh", "-c", _UNPACK, "_", str(into)]
|
|
219
|
+
# Packed off the event loop, so a large task directory stalls no other trial.
|
|
220
|
+
data = await asyncio.to_thread(pack, members)
|
|
221
|
+
outcome = await self._guest(script, "root", data=data)
|
|
222
|
+
if outcome.returncode != 0:
|
|
223
|
+
raise TransferFailed(
|
|
224
|
+
f"copying {source} to {target} failed in the guest: "
|
|
225
|
+
+ outcome.stderr.decode("utf-8", "replace").strip()
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
async def download(self, source: str, target: Path, *, is_dir: bool) -> None:
|
|
229
|
+
"""Copy a guest file or directory to `target` on the host, refusing one past `MAX_DOWNLOAD_BYTES`."""
|
|
230
|
+
src = PurePosixPath(source)
|
|
231
|
+
parent, member = (src, ".") if is_dir else (src.parent, src.name)
|
|
232
|
+
cap = str(MAX_DOWNLOAD_BYTES + 1)
|
|
233
|
+
script = ["bash", "-c", _PACK, "_", str(parent), member, cap]
|
|
234
|
+
outcome = await self._guest(script, "root")
|
|
235
|
+
if len(outcome.stdout) > MAX_DOWNLOAD_BYTES:
|
|
236
|
+
raise TransferFailed(
|
|
237
|
+
f"{source} packs to more than {MAX_DOWNLOAD_BYTES} bytes, the most one "
|
|
238
|
+
"download carries."
|
|
239
|
+
)
|
|
240
|
+
if outcome.returncode != 0:
|
|
241
|
+
raise TransferFailed(
|
|
242
|
+
f"reading {source} out of the guest failed: "
|
|
243
|
+
+ outcome.stderr.decode("utf-8", "replace").strip()
|
|
244
|
+
)
|
|
245
|
+
await asyncio.to_thread(_land, outcome.stdout, target, src.name, is_dir=is_dir)
|
|
246
|
+
|
|
247
|
+
async def _guest( # pylint: disable=too-many-arguments # the run's own exec seams
|
|
248
|
+
self,
|
|
249
|
+
cmd: list[str],
|
|
250
|
+
user: str,
|
|
251
|
+
*,
|
|
252
|
+
cwd: str | None = None,
|
|
253
|
+
env: dict[str, str] | None = None,
|
|
254
|
+
data: bytes | None = None,
|
|
255
|
+
timeout: float | None = None,
|
|
256
|
+
) -> ExecOutcome:
|
|
257
|
+
"""`cmd` run through the run's own exec, off the event loop."""
|
|
258
|
+
return await asyncio.to_thread(
|
|
259
|
+
self.run.exec,
|
|
260
|
+
cmd,
|
|
261
|
+
user=user,
|
|
262
|
+
cwd=cwd,
|
|
263
|
+
env=env,
|
|
264
|
+
input_bytes=data,
|
|
265
|
+
timeout=timeout,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _remaining(deadline: float | None) -> float | None:
|
|
270
|
+
"""The seconds left before `deadline`, raising as Harbor does once none are."""
|
|
271
|
+
if deadline is None:
|
|
272
|
+
return None
|
|
273
|
+
left = deadline - time.monotonic()
|
|
274
|
+
if left <= 0:
|
|
275
|
+
raise RuntimeError("Command timed out before it started")
|
|
276
|
+
return left
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _canonical_dir(path: str) -> str:
|
|
280
|
+
"""One spelling of an absolute directory, so the hand-over's textual refusal of system paths holds.
|
|
281
|
+
|
|
282
|
+
`/opt/../etc` would otherwise read as living under `/opt`. POSIX keeps a leading `//`
|
|
283
|
+
through `normpath`, and Linux resolves it to `/`, so that run is collapsed first."""
|
|
284
|
+
if path.startswith("//"):
|
|
285
|
+
path = "/" + path.lstrip("/")
|
|
286
|
+
return posixpath.normpath(path)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def task_config( # pylint: disable=too-many-arguments # one per task field a boot reads
|
|
290
|
+
*,
|
|
291
|
+
environment_dir: Path,
|
|
292
|
+
docker_image: str | None,
|
|
293
|
+
workdir: str | None,
|
|
294
|
+
allowlist: tuple[tuple[str, Access], ...],
|
|
295
|
+
cpus: int | None,
|
|
296
|
+
memory_mb: int | None,
|
|
297
|
+
force_build: bool = False,
|
|
298
|
+
) -> GloveboxSandboxConfig:
|
|
299
|
+
"""A task's boot config: its image under glovebox's guest layers, with the image's `WORKDIR` and `ENV`.
|
|
300
|
+
|
|
301
|
+
Refuses a host that cannot boot a microVM before the image costs minutes to build. The
|
|
302
|
+
image's own startup command never runs, as under Harbor's own compose files. Its `ENV`
|
|
303
|
+
reaches every Harbor command, as `docker exec` would carry it, under the caller's own."""
|
|
304
|
+
cli.preflight()
|
|
305
|
+
base = base_image(environment_dir, docker_image, force_build=force_build)
|
|
306
|
+
inherited = image_config(base)
|
|
307
|
+
booted = with_harbor_trees(base)
|
|
308
|
+
working_dir = _canonical_dir(workdir or inherited.working_dir or "")
|
|
309
|
+
fields: dict[str, object] = {
|
|
310
|
+
"allowlist": allowlist,
|
|
311
|
+
"cpus": cpus,
|
|
312
|
+
"environment": inherited.environment,
|
|
313
|
+
}
|
|
314
|
+
if memory_mb:
|
|
315
|
+
fields["memory"] = f"{memory_mb}m"
|
|
316
|
+
if working_dir != ".":
|
|
317
|
+
fields["working_dir"] = working_dir
|
|
318
|
+
fields["rootfs_image"] = rootfs_from_image(
|
|
319
|
+
booted, str(fields.get("working_dir", ""))
|
|
320
|
+
)
|
|
321
|
+
return GloveboxSandboxConfig.model_validate(fields)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
__all__ = [
|
|
325
|
+
"MAX_DOWNLOAD_BYTES",
|
|
326
|
+
"GloveboxTrial",
|
|
327
|
+
"TransferFailed",
|
|
328
|
+
"task_config",
|
|
329
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Harbor's `BaseEnvironment` on a glovebox microVM: `--env harbor_glovebox.environment:GloveboxEnvironment`.
|
|
2
|
+
|
|
3
|
+
A translation layer only. The policy mapping lives in `_policy` and the VM lifecycle in
|
|
4
|
+
`_trial`, both free of Harbor so this repo's own tests reach them. This module is the one
|
|
5
|
+
file that imports Harbor, whose dependency closure this repo's environment cannot install.
|
|
6
|
+
"""
|
|
7
|
+
# pyright: reportMissingImports=false
|
|
8
|
+
# pylint: disable=import-error # Harbor pins openai<3, which this repo's dev environment cannot hold
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from harbor.environments.base import BaseEnvironment, EnvironmentPath, ExecResult
|
|
15
|
+
from harbor.environments.capabilities import EnvironmentCapabilities
|
|
16
|
+
from harbor.models.task.config import NetworkPolicy
|
|
17
|
+
|
|
18
|
+
from . import _policy
|
|
19
|
+
from ._trial import GloveboxTrial, task_config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GloveboxEnvironment(BaseEnvironment):
|
|
23
|
+
"""One Harbor trial in one hypervisor-isolated, egress-filtered glovebox microVM."""
|
|
24
|
+
|
|
25
|
+
_trial: GloveboxTrial | None = None
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def type() -> str:
|
|
29
|
+
return "glovebox"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def capabilities(self) -> EnvironmentCapabilities:
|
|
33
|
+
"""What Harbor may ask of this environment; Harbor refuses a task needing more before start."""
|
|
34
|
+
return EnvironmentCapabilities(
|
|
35
|
+
disable_internet=True,
|
|
36
|
+
network_allowlist=True,
|
|
37
|
+
network_allowlist_hostnames=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _validate_definition(self) -> None:
|
|
41
|
+
if self.task_env_config.docker_image:
|
|
42
|
+
return
|
|
43
|
+
if not (self.environment_dir / "Dockerfile").exists():
|
|
44
|
+
raise FileNotFoundError(
|
|
45
|
+
f"{self.environment_dir} has no Dockerfile and the task names no docker_image."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def validate_network_policy_support(
|
|
49
|
+
self, network_policy: NetworkPolicy | None = None
|
|
50
|
+
) -> None:
|
|
51
|
+
super().validate_network_policy_support(network_policy)
|
|
52
|
+
policy = network_policy or self.network_policy
|
|
53
|
+
_policy.allowlist_pairs(policy.network_mode.value, policy.allowed_hosts)
|
|
54
|
+
|
|
55
|
+
async def start(self, force_build: bool) -> None:
|
|
56
|
+
policy = self.network_policy
|
|
57
|
+
config = await asyncio.to_thread(
|
|
58
|
+
task_config,
|
|
59
|
+
environment_dir=self.environment_dir,
|
|
60
|
+
docker_image=self.task_env_config.docker_image,
|
|
61
|
+
workdir=self.task_env_config.workdir,
|
|
62
|
+
allowlist=_policy.allowlist_pairs(
|
|
63
|
+
policy.network_mode.value, policy.allowed_hosts
|
|
64
|
+
),
|
|
65
|
+
cpus=self.task_env_config.cpus,
|
|
66
|
+
memory_mb=self.task_env_config.memory_mb,
|
|
67
|
+
force_build=force_build,
|
|
68
|
+
)
|
|
69
|
+
self._trial = GloveboxTrial(
|
|
70
|
+
config=config, evidence_dir=self.trial_paths.artifacts_dir / "glovebox"
|
|
71
|
+
)
|
|
72
|
+
await self._trial.start()
|
|
73
|
+
try:
|
|
74
|
+
# Nothing bind-mounts Harbor's log directories here, so they are made in the guest.
|
|
75
|
+
await self.ensure_dirs(self._mount_targets(writable_only=True))
|
|
76
|
+
await self._upload_environment_dir_after_start()
|
|
77
|
+
except BaseException:
|
|
78
|
+
await self._trial.stop(failed=True)
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
async def ensure_dirs(
|
|
82
|
+
self, dirs: Sequence[EnvironmentPath], *, chmod: bool = True
|
|
83
|
+
) -> ExecResult | None:
|
|
84
|
+
"""Harbor's directory setup, raised on failure rather than returned and ignored."""
|
|
85
|
+
return _checked(await super().ensure_dirs(dirs, chmod=chmod), dirs)
|
|
86
|
+
|
|
87
|
+
async def empty_dirs(
|
|
88
|
+
self, dirs: Sequence[EnvironmentPath], *, chmod: bool = True
|
|
89
|
+
) -> ExecResult | None:
|
|
90
|
+
"""Harbor's directory reset, raised on failure: a stale `/tests` would change the score."""
|
|
91
|
+
return _checked(await super().empty_dirs(dirs, chmod=chmod), dirs)
|
|
92
|
+
|
|
93
|
+
async def stop(self, delete: bool) -> None: # noqa: ARG002 # pylint: disable=unused-argument # a microVM is always deleted
|
|
94
|
+
if self._trial is not None:
|
|
95
|
+
await self._trial.stop()
|
|
96
|
+
|
|
97
|
+
async def exec( # pylint: disable=too-many-arguments,too-many-positional-arguments # Harbor's exec signature
|
|
98
|
+
self,
|
|
99
|
+
command: str,
|
|
100
|
+
cwd: str | None = None,
|
|
101
|
+
env: dict[str, str] | None = None,
|
|
102
|
+
timeout_sec: int | None = None,
|
|
103
|
+
user: str | int | None = None,
|
|
104
|
+
) -> ExecResult:
|
|
105
|
+
trial = self._require_trial()
|
|
106
|
+
run_user = _policy.exec_user(user, self.default_user)
|
|
107
|
+
outcome = await trial.exec(
|
|
108
|
+
command,
|
|
109
|
+
user=run_user,
|
|
110
|
+
cwd=cwd,
|
|
111
|
+
env=self._merge_env(env),
|
|
112
|
+
timeout_sec=timeout_sec,
|
|
113
|
+
)
|
|
114
|
+
return ExecResult(
|
|
115
|
+
stdout=outcome.stdout.decode("utf-8", "replace") or None,
|
|
116
|
+
stderr=outcome.stderr.decode("utf-8", "replace") or None,
|
|
117
|
+
return_code=outcome.returncode,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def upload_file(self, source_path: Path | str, target_path: str) -> None:
|
|
121
|
+
await self._require_trial().upload(Path(source_path), target_path)
|
|
122
|
+
|
|
123
|
+
async def upload_dir(self, source_dir: Path | str, target_dir: str) -> None:
|
|
124
|
+
await self._require_trial().upload(Path(source_dir), target_dir)
|
|
125
|
+
|
|
126
|
+
async def download_file(self, source_path: str, target_path: Path | str) -> None:
|
|
127
|
+
await self._require_trial().download(
|
|
128
|
+
source_path, Path(target_path), is_dir=False
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def download_dir(self, source_dir: str, target_dir: Path | str) -> None:
|
|
132
|
+
await self._require_trial().download(source_dir, Path(target_dir), is_dir=True)
|
|
133
|
+
|
|
134
|
+
def _require_trial(self) -> GloveboxTrial:
|
|
135
|
+
if self._trial is None:
|
|
136
|
+
raise RuntimeError("the glovebox environment has not been started.")
|
|
137
|
+
return self._trial
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _checked(
|
|
141
|
+
result: ExecResult | None, dirs: Sequence[EnvironmentPath]
|
|
142
|
+
) -> ExecResult | None:
|
|
143
|
+
"""`result`, raising when the guest command behind it failed."""
|
|
144
|
+
if result is not None and result.return_code != 0:
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"preparing {dirs} in the guest failed (exit {result.return_code}): "
|
|
147
|
+
f"{(result.stderr or '').strip()}"
|
|
148
|
+
)
|
|
149
|
+
return result
|