glovebox-driver 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.
- glovebox_driver-0.1.0/.gitignore +180 -0
- glovebox_driver-0.1.0/PKG-INFO +73 -0
- glovebox_driver-0.1.0/README.md +56 -0
- glovebox_driver-0.1.0/pyproject.toml +29 -0
- glovebox_driver-0.1.0/src/glovebox_driver/__init__.py +8 -0
- glovebox_driver-0.1.0/src/glovebox_driver/_bootstrap.py +161 -0
- glovebox_driver-0.1.0/src/glovebox_driver/_pinned_release.py +13 -0
- glovebox_driver-0.1.0/src/glovebox_driver/cli.py +355 -0
- glovebox_driver-0.1.0/src/glovebox_driver/config.py +191 -0
- glovebox_driver-0.1.0/src/glovebox_driver/evidence.py +172 -0
- glovebox_driver-0.1.0/src/glovebox_driver/guest_exec.py +161 -0
- glovebox_driver-0.1.0/src/glovebox_driver/leak.py +251 -0
- glovebox_driver-0.1.0/src/glovebox_driver/session.py +584 -0
- glovebox_driver-0.1.0/src/glovebox_driver/wedge.py +185 -0
- glovebox_driver-0.1.0/update-pin.sh +65 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
.worktrees
|
|
2
|
+
.claude/worktrees/
|
|
3
|
+
.agents/
|
|
4
|
+
.codex/
|
|
5
|
+
|
|
6
|
+
# sbx agent boot trace: a runtime diagnostic the in-VM entrypoint writes into the
|
|
7
|
+
# workspace (read back by bin/checks/sbx/egress.bash); never a committed source file.
|
|
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
|
+
htmlcov
|
|
54
|
+
|
|
55
|
+
# Mutation testing (Stryker): sandbox, HTML report, and the incremental cache
|
|
56
|
+
.stryker-tmp
|
|
57
|
+
reports/
|
|
58
|
+
|
|
59
|
+
# Generated LaunchAgent (rendered from the .template by setup.bash at install time)
|
|
60
|
+
launchagents/*.generated.plist
|
|
61
|
+
|
|
62
|
+
# Python
|
|
63
|
+
__pycache__
|
|
64
|
+
*.pyc
|
|
65
|
+
.venv
|
|
66
|
+
.uv
|
|
67
|
+
.pytest_cache
|
|
68
|
+
|
|
69
|
+
# `uv run` inside inspect-glovebox/ writes a lock beside its pyproject.toml. The wheel is
|
|
70
|
+
# a library, so it pins nothing; committing one adds a `uv.lock -merge` path that
|
|
71
|
+
# scripts/resolve-generated.mjs owns no rule for, which reds the JS suite.
|
|
72
|
+
/inspect-glovebox/uv.lock
|
|
73
|
+
# pytest scratch (repo-relative --basetemp): throwaway fixture files, including
|
|
74
|
+
# fake executables the suite writes (keyprobe, docker stubs). Ignored so they never
|
|
75
|
+
# surface as uncommitted changes or get captured onto a session-end review branch.
|
|
76
|
+
.pytest-tmp
|
|
77
|
+
|
|
78
|
+
# Fuzzer crash reproducers (jazzer.js / atheris drop these in the repo root, which
|
|
79
|
+
# is their cwd). Anchored with a leading slash: an unanchored `crash-*` matches at
|
|
80
|
+
# ANY depth, so it silently swallowed bin/checks/sbx/crash-resilience-int.bash — a
|
|
81
|
+
# `git add` of a legitimately-named source file that reports success and stages
|
|
82
|
+
# nothing.
|
|
83
|
+
/crash-*
|
|
84
|
+
/timeout-*
|
|
85
|
+
/oom-*
|
|
86
|
+
/leak-*
|
|
87
|
+
|
|
88
|
+
# cosmic-ray mutation sessions + HTML reports. mutation-floor.sh writes
|
|
89
|
+
# <session>.html at the repo root for the run's artifact, one per tools/mutation/
|
|
90
|
+
# toml, so this is a glob: a per-session list leaves the next session's report
|
|
91
|
+
# untracked-but-not-ignored, which is what egress-filter and mcpgw-derive were.
|
|
92
|
+
*.sqlite
|
|
93
|
+
/*.html
|
|
94
|
+
|
|
95
|
+
# History working files — each lives on the perf-history or ci-timings branch and is
|
|
96
|
+
# seeded into the checkout by persist-perf-history.sh read or record-setup-timing.sh;
|
|
97
|
+
# never committed to main or a feature branch. A glob, not a list: a tool that walks
|
|
98
|
+
# "tracked plus untracked-not-ignored" takes a seeded file for a repository file, and
|
|
99
|
+
# a per-metric list leaves the next metric's file out of the pattern. That is what
|
|
100
|
+
# widened perf-gates.yaml's generated paths-regex on the runner and nowhere else.
|
|
101
|
+
.github/*-history.json
|
|
102
|
+
.github/*-history.jsonl
|
|
103
|
+
|
|
104
|
+
# pytest --basetemp droppings (used where /tmp is noexec)
|
|
105
|
+
.pytest-exec-tmp
|
|
106
|
+
|
|
107
|
+
# sbx agent-entrypoint boot trace — a runtime log appended into the workspace by
|
|
108
|
+
# gb_boot_trace; a diagnostic artifact, never committed.
|
|
109
|
+
.gb-agent-boot-trace
|
|
110
|
+
|
|
111
|
+
# Hypothesis property-testing example database (regenerated locally; never committed)
|
|
112
|
+
.hypothesis/
|
|
113
|
+
|
|
114
|
+
# CI timing maps that balance the shard fan-outs. They live in R2 (uploaded only by
|
|
115
|
+
# main runs, fetched best-effort by CI — see .github/ci-durations.json) and are never
|
|
116
|
+
# committed; ignored so a CI fetch or a local `uv run pytest --store-durations` /
|
|
117
|
+
# sbx-live run can't accidentally stage them.
|
|
118
|
+
tests/.gb-test-durations.json
|
|
119
|
+
tests/.gb-kcov-durations.json
|
|
120
|
+
tests/.gb-drvfs-durations.json
|
|
121
|
+
tests/.gb-macos-durations.json
|
|
122
|
+
# The plan job's published shard assignment, one per leg (keyed by that leg's
|
|
123
|
+
# duration-map name): derived from the fetched map, shipped to the shards in the
|
|
124
|
+
# same artifact, never committed.
|
|
125
|
+
tests/.gb-*-shard-assignment.json
|
|
126
|
+
.github/sbx-live/durations.json
|
|
127
|
+
|
|
128
|
+
# The pinned scanner binary that .github/scripts/python-deps-vuln-scan.sh downloads
|
|
129
|
+
# into the repo root (~56 MB). Running that script locally to reproduce a red
|
|
130
|
+
# osv-scanner check is the documented response to one, so the artifact lands in
|
|
131
|
+
# every contributor's tree; ignored so it can't be staged by a `git add -A`.
|
|
132
|
+
/osv-scanner
|
|
133
|
+
|
|
134
|
+
# esbuild output of sbx-kit/image/{monitor-dispatch,redact-output}.mjs. The image
|
|
135
|
+
# builds these in its own bundler stage (sbx-kit/image/Dockerfile); the local copies
|
|
136
|
+
# exist so the CT eval harness can subprocess the dispatcher without a docker build.
|
|
137
|
+
sbx-kit/image/monitor-dispatch.bundle.mjs
|
|
138
|
+
sbx-kit/image/redact-output.bundle.mjs
|
|
139
|
+
|
|
140
|
+
# esbuild output of the host guardrail hooks (scripts/build-hook-bundles.mjs), which
|
|
141
|
+
# settings.json launches. Built once per tree by `pnpm install`'s postinstall, by
|
|
142
|
+
# setup.bash at install time, and in the image's own bundler stage — so the reviewable
|
|
143
|
+
# surface is the hook sources plus pnpm-lock.yaml, not a 12k-line generated diff.
|
|
144
|
+
# The hand-written gate-hooks.bundle.d.mts beside them stays tracked.
|
|
145
|
+
.claude/hooks/*.bundle.mjs
|
|
146
|
+
|
|
147
|
+
# The pinned kcov binary that tests/install-kcov-local.sh builds into the repo
|
|
148
|
+
# root — the same path .github/actions/install-kcov caches to, so tests/run-kcov.sh
|
|
149
|
+
# finds a local build with no PATH edit. Reproducing a red "Bash coverage (kcov)"
|
|
150
|
+
# check locally is the documented response to one, so the artifact lands in every
|
|
151
|
+
# contributor's tree; ignored so it can't be staged by a `git add -A`.
|
|
152
|
+
/kcov-bin/
|
|
153
|
+
.claude/.hookpin-*/
|
|
154
|
+
|
|
155
|
+
# synced-deps.test.mjs's untracked-file probe, mkdtemp'd under a synced path on
|
|
156
|
+
# purpose. Ignored so a concurrent `git add -A` elsewhere in this checkout can't
|
|
157
|
+
# stage it mid-test: staged, it would pass trackedUnder()'s check and leave an
|
|
158
|
+
# AD entry in the shared index once the test deletes it from disk.
|
|
159
|
+
.claude/consumer-probe-*/
|
|
160
|
+
|
|
161
|
+
# `ct settings pull` writes this per-checkout pin cache into the repo root. The pin this
|
|
162
|
+
# harness measures lives in evals/control_tower/ct-settings-pin.txt; a committed copy here
|
|
163
|
+
# would be a second one that drifts.
|
|
164
|
+
.settings.local.yml
|
|
165
|
+
|
|
166
|
+
# `gh` writes its per-machine state here when it runs with the repo root as the
|
|
167
|
+
# XDG state home — a device identifier, which no checkout should carry.
|
|
168
|
+
/.local/
|
|
169
|
+
|
|
170
|
+
# Generated doc pages, published to the CDN instead of committed
|
|
171
|
+
# (scripts/render-doc-site.py, .github/workflows/docs-publish.yaml). A committed
|
|
172
|
+
# page is re-derived by every branch that touches its generator's inputs, which
|
|
173
|
+
# made these the busiest files in the history and conflicted branches on bytes
|
|
174
|
+
# nobody typed. Their generators still run, and still refuse a bad input; only
|
|
175
|
+
# the output stays out of the tree.
|
|
176
|
+
/docs/architecture-callgraph.md
|
|
177
|
+
/docs/ci-map.generated.md
|
|
178
|
+
/docs/tla/configs.md
|
|
179
|
+
/docs/tla/reachable-subsets.md
|
|
180
|
+
/docs/tla/diagrams/
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: glovebox-driver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Host-side Python driver for a glovebox microVM session
|
|
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,glovebox,microvm,sandbox
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: pydantic>=2
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# glovebox-driver
|
|
19
|
+
|
|
20
|
+
The host-side Python that drives one [glovebox](https://github.com/AlexanderMattTurner/agent-glovebox) microVM: boot it, run commands in it, read what it let out, and tear it down. It imports no eval framework, so an integration picks the framework and this package holds the VM.
|
|
21
|
+
|
|
22
|
+
`inspect-glovebox` and `shepherd-glovebox` are both built on it.
|
|
23
|
+
|
|
24
|
+
## What the host needs
|
|
25
|
+
|
|
26
|
+
The driver runs the `glovebox` command, which drives Docker's `sbx` sandbox runtime. Install glovebox and sign in to `sbx`, then ask whether this host qualifies:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
glovebox sandbox preflight
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Set `GLOVEBOX_BIN` when the executable is not on `PATH`.
|
|
33
|
+
|
|
34
|
+
## The modules
|
|
35
|
+
|
|
36
|
+
| Module | Owns |
|
|
37
|
+
| ------------ | ----------------------------------------------------------------------- |
|
|
38
|
+
| `cli` | resolving the `glovebox` executable and running one `sandbox` verb |
|
|
39
|
+
| `config` | `GloveboxSandboxConfig` — what a VM may be and which hosts it may reach |
|
|
40
|
+
| `session` | `GloveboxSession` — one live microVM, from boot to teardown |
|
|
41
|
+
| `wedge` | booting again when the HOST, not the work, wedged the first attempt |
|
|
42
|
+
| `guest_exec` | the argv for one command inside the guest, as the de-privileged user |
|
|
43
|
+
| `evidence` | reading the policy decision log the sandbox wrote |
|
|
44
|
+
| `leak` | reaping a microVM whose owner died before teardown |
|
|
45
|
+
|
|
46
|
+
## Boot one
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import os
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
|
|
52
|
+
from glovebox_driver import cli
|
|
53
|
+
from glovebox_driver.session import GloveboxSession
|
|
54
|
+
|
|
55
|
+
workspace = Path("/path/to/workspace")
|
|
56
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
os.chmod(workspace, 0o755)
|
|
58
|
+
ready_path = workspace / ".ready"
|
|
59
|
+
ready_path.touch()
|
|
60
|
+
|
|
61
|
+
cli.preflight()
|
|
62
|
+
session = GloveboxSession.boot(str(workspace), str(ready_path), boot_timeout=300)
|
|
63
|
+
try:
|
|
64
|
+
...
|
|
65
|
+
finally:
|
|
66
|
+
session.teardown()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`boot` waits for `ready_path` to exist; it never creates it. Every in-tree caller
|
|
70
|
+
touches its own marker first, and the guest user needs permission to traverse the
|
|
71
|
+
workspace, so a freshly created directory needs its mode set too.
|
|
72
|
+
|
|
73
|
+
Apache-2.0.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# glovebox-driver
|
|
2
|
+
|
|
3
|
+
The host-side Python that drives one [glovebox](https://github.com/AlexanderMattTurner/agent-glovebox) microVM: boot it, run commands in it, read what it let out, and tear it down. It imports no eval framework, so an integration picks the framework and this package holds the VM.
|
|
4
|
+
|
|
5
|
+
`inspect-glovebox` and `shepherd-glovebox` are both built on it.
|
|
6
|
+
|
|
7
|
+
## What the host needs
|
|
8
|
+
|
|
9
|
+
The driver runs the `glovebox` command, which drives Docker's `sbx` sandbox runtime. Install glovebox and sign in to `sbx`, then ask whether this host qualifies:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
glovebox sandbox preflight
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Set `GLOVEBOX_BIN` when the executable is not on `PATH`.
|
|
16
|
+
|
|
17
|
+
## The modules
|
|
18
|
+
|
|
19
|
+
| Module | Owns |
|
|
20
|
+
| ------------ | ----------------------------------------------------------------------- |
|
|
21
|
+
| `cli` | resolving the `glovebox` executable and running one `sandbox` verb |
|
|
22
|
+
| `config` | `GloveboxSandboxConfig` — what a VM may be and which hosts it may reach |
|
|
23
|
+
| `session` | `GloveboxSession` — one live microVM, from boot to teardown |
|
|
24
|
+
| `wedge` | booting again when the HOST, not the work, wedged the first attempt |
|
|
25
|
+
| `guest_exec` | the argv for one command inside the guest, as the de-privileged user |
|
|
26
|
+
| `evidence` | reading the policy decision log the sandbox wrote |
|
|
27
|
+
| `leak` | reaping a microVM whose owner died before teardown |
|
|
28
|
+
|
|
29
|
+
## Boot one
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import os
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from glovebox_driver import cli
|
|
36
|
+
from glovebox_driver.session import GloveboxSession
|
|
37
|
+
|
|
38
|
+
workspace = Path("/path/to/workspace")
|
|
39
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
os.chmod(workspace, 0o755)
|
|
41
|
+
ready_path = workspace / ".ready"
|
|
42
|
+
ready_path.touch()
|
|
43
|
+
|
|
44
|
+
cli.preflight()
|
|
45
|
+
session = GloveboxSession.boot(str(workspace), str(ready_path), boot_timeout=300)
|
|
46
|
+
try:
|
|
47
|
+
...
|
|
48
|
+
finally:
|
|
49
|
+
session.teardown()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`boot` waits for `ready_path` to exist; it never creates it. Every in-tree caller
|
|
53
|
+
touches its own marker first, and the guest user needs permission to traverse the
|
|
54
|
+
workspace, so a freshly created directory needs its mode set too.
|
|
55
|
+
|
|
56
|
+
Apache-2.0.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "glovebox-driver"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Host-side Python driver for a glovebox microVM session"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
authors = [{ name = "AlexanderMattTurner" }]
|
|
9
|
+
keywords = ["glovebox", "sandbox", "microvm", "ai-control"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Security",
|
|
15
|
+
]
|
|
16
|
+
# Only pydantic: this package speaks to the `glovebox` CLI over argv and stdio, so it
|
|
17
|
+
# imports no eval framework and pins no version of one.
|
|
18
|
+
dependencies = ["pydantic>=2"]
|
|
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"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/glovebox_driver"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The host-side lifecycle of one glovebox microVM, with no eval framework attached.
|
|
2
|
+
|
|
3
|
+
`glovebox sandbox` is the supported contract; this package is the Python that drives it.
|
|
4
|
+
Import the module that owns the step you need: `cli` resolves and runs the executable,
|
|
5
|
+
`config` carries what a VM may be and reach, `session` boots one and tears it down,
|
|
6
|
+
`wedge` retries a boot the host wedged, `guest_exec` builds the in-guest exec argv,
|
|
7
|
+
`evidence` reads the policy decision log, and `leak` reaps a VM whose owner died.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Installing the pinned glovebox release when the host has none.
|
|
2
|
+
|
|
3
|
+
PROBLEM CLASS — `pip install` delivers this driver and nothing it drives: the glovebox
|
|
4
|
+
CLI is a bash tree installed by Homebrew, apt or a git clone. When no
|
|
5
|
+
install resolves, this module downloads the release source tarball the wheel pins, refuses
|
|
6
|
+
it unless its SHA-256 matches the pinned digest, and extracts it into a per-user cache —
|
|
7
|
+
so the pip package is the only thing a user consciously installs. An install the user made
|
|
8
|
+
always wins: the caller consults this only after ``$GLOVEBOX_BIN`` and PATH both miss.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ._pinned_release import RELEASE_TAG, TARBALL_SHA256
|
|
22
|
+
|
|
23
|
+
_REPO = "AlexanderMattTurner/agent-glovebox"
|
|
24
|
+
|
|
25
|
+
# Bounds on the whole download, not on one socket read. A per-read timeout leaves a server
|
|
26
|
+
# that dribbles a byte inside every window free to block the first sample forever, and a
|
|
27
|
+
# response with no end blocks it just as long while the file grows.
|
|
28
|
+
DOWNLOAD_DEADLINE_S = 600
|
|
29
|
+
DOWNLOAD_MAX_BYTES = 256 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tarball_url(tag: str) -> str:
|
|
33
|
+
"""The GitHub source tarball for one release tag — the same artifact the AUR and Homebrew packages pin."""
|
|
34
|
+
return f"https://github.com/{_REPO}/archive/refs/tags/{tag}.tar.gz"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cache_root() -> Path:
|
|
38
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
39
|
+
base = Path(xdg) if xdg else Path.home() / ".cache"
|
|
40
|
+
return base / "inspect-glovebox"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BootstrapFailed(RuntimeError):
|
|
44
|
+
"""Raised when the pinned release could not be downloaded, verified, or extracted."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ensure_pinned_glovebox() -> str:
|
|
48
|
+
"""The cached pinned release's `bin/glovebox`, downloading and verifying it first when absent."""
|
|
49
|
+
return str(
|
|
50
|
+
ensure_glovebox(
|
|
51
|
+
tag=RELEASE_TAG,
|
|
52
|
+
sha256=TARBALL_SHA256,
|
|
53
|
+
url=_tarball_url(RELEASE_TAG),
|
|
54
|
+
cache_root=_cache_root(),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ensure_glovebox(*, tag: str, sha256: str, url: str, cache_root: Path) -> Path:
|
|
60
|
+
"""`bin/glovebox` of the `tag` install under `cache_root`, downloading `url` on a miss.
|
|
61
|
+
|
|
62
|
+
The install lands in a per-tag directory, so a wheel upgrade that moves the pin
|
|
63
|
+
installs beside the old release rather than over it. A concurrent first use is safe:
|
|
64
|
+
each extracts into its own scratch directory and one rename wins.
|
|
65
|
+
"""
|
|
66
|
+
wrapper = cache_root / f"glovebox-{tag}" / "bin" / "glovebox"
|
|
67
|
+
if os.access(wrapper, os.X_OK):
|
|
68
|
+
return wrapper
|
|
69
|
+
archive = _fetch_verified(url, sha256)
|
|
70
|
+
try:
|
|
71
|
+
_install(archive, wrapper.parent.parent, cache_root)
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
# An unwritable or full cache is a host state the caller has a remedy for, so it
|
|
74
|
+
# reaches them as the same refusal as a bad download rather than as a traceback.
|
|
75
|
+
raise BootstrapFailed(f"could not install into {cache_root}: {exc}") from exc
|
|
76
|
+
finally:
|
|
77
|
+
archive.unlink(missing_ok=True)
|
|
78
|
+
if not os.access(wrapper, os.X_OK):
|
|
79
|
+
raise BootstrapFailed(
|
|
80
|
+
f"the verified {tag} tarball holds no executable bin/glovebox — "
|
|
81
|
+
f"remove {wrapper.parent.parent} and report this"
|
|
82
|
+
)
|
|
83
|
+
return wrapper
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fetch_verified(url: str, sha256: str) -> Path:
|
|
87
|
+
"""Download `url` to a scratch file and return it only when its digest matches `sha256`.
|
|
88
|
+
|
|
89
|
+
INVARIANT: no byte of the download is extracted or executed before this digest check
|
|
90
|
+
passes — the bootstrap runs code from the network, and the wheel-pinned digest is the
|
|
91
|
+
whole of its authority to do so.
|
|
92
|
+
"""
|
|
93
|
+
print(
|
|
94
|
+
f"inspect-glovebox: no glovebox install found — downloading the pinned release "
|
|
95
|
+
f"({url})",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
handle, name = tempfile.mkstemp(prefix="glovebox-bootstrap-", suffix=".tar.gz")
|
|
99
|
+
archive = Path(name)
|
|
100
|
+
digest = hashlib.sha256()
|
|
101
|
+
deadline = time.monotonic() + DOWNLOAD_DEADLINE_S
|
|
102
|
+
read = 0
|
|
103
|
+
overran = False
|
|
104
|
+
try:
|
|
105
|
+
# The per-read timeout stops a socket that says nothing; the deadline and the size
|
|
106
|
+
# cap below stop one that says a little forever. TimeoutError is an OSError.
|
|
107
|
+
# read1, not read: read blocks until it has the whole megabyte, so a server
|
|
108
|
+
# dribbling smaller pieces holds one call open past the deadline nobody rechecks.
|
|
109
|
+
with (
|
|
110
|
+
os.fdopen(handle, "wb") as out,
|
|
111
|
+
urllib.request.urlopen(url, timeout=60) as response,
|
|
112
|
+
): # noqa: S310 # the https URL is module-built from the pinned tag
|
|
113
|
+
while chunk := response.read1(1 << 20):
|
|
114
|
+
read += len(chunk)
|
|
115
|
+
if read > DOWNLOAD_MAX_BYTES or time.monotonic() > deadline:
|
|
116
|
+
overran = True
|
|
117
|
+
break
|
|
118
|
+
digest.update(chunk)
|
|
119
|
+
out.write(chunk)
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
archive.unlink(missing_ok=True)
|
|
122
|
+
raise BootstrapFailed(f"could not download {url}: {exc}") from exc
|
|
123
|
+
if overran:
|
|
124
|
+
archive.unlink(missing_ok=True)
|
|
125
|
+
raise BootstrapFailed(
|
|
126
|
+
f"the download from {url} passed its bound ({DOWNLOAD_MAX_BYTES} bytes or "
|
|
127
|
+
f"{DOWNLOAD_DEADLINE_S}s) at {read} bytes — refusing to keep reading"
|
|
128
|
+
)
|
|
129
|
+
if digest.hexdigest() != sha256:
|
|
130
|
+
archive.unlink(missing_ok=True)
|
|
131
|
+
raise BootstrapFailed(
|
|
132
|
+
f"the download from {url} hashes to {digest.hexdigest()}, not the pinned "
|
|
133
|
+
f"{sha256} — refusing to install unverified bytes"
|
|
134
|
+
)
|
|
135
|
+
return archive
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _install(archive: Path, dest: Path, cache_root: Path) -> None:
|
|
139
|
+
"""Extract the verified `archive` into `dest`, atomically enough for concurrent callers."""
|
|
140
|
+
cache_root.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
scratch = Path(tempfile.mkdtemp(prefix=f"{dest.name}.", dir=cache_root))
|
|
142
|
+
try:
|
|
143
|
+
# nosemgrep: tarfile-extractall-traversal — the extractall below passes filter="data", which is the refusal the rule asks for
|
|
144
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
145
|
+
try:
|
|
146
|
+
# GitHub wraps the tree in one <repo>-<version>/ directory; the data
|
|
147
|
+
# filter is what refuses absolute paths, `..` and out-of-tree links.
|
|
148
|
+
tar.extractall(scratch, filter="data")
|
|
149
|
+
except TypeError as exc:
|
|
150
|
+
raise BootstrapFailed(
|
|
151
|
+
"this Python's tarfile lacks extraction filters (PEP 706; 3.11.4+) — "
|
|
152
|
+
"upgrade Python, or install glovebox yourself"
|
|
153
|
+
) from exc
|
|
154
|
+
[tree] = scratch.iterdir()
|
|
155
|
+
try:
|
|
156
|
+
tree.rename(dest)
|
|
157
|
+
except OSError:
|
|
158
|
+
if not dest.is_dir():
|
|
159
|
+
raise
|
|
160
|
+
finally:
|
|
161
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The glovebox release this wheel bootstraps when no install is found.
|
|
2
|
+
|
|
3
|
+
GENERATED by glovebox-driver/update-pin.sh — edit nothing here by hand. The updater runs
|
|
4
|
+
from sync-packaging.sh on each release, beside the AUR/Homebrew updaters that pin the same
|
|
5
|
+
tarball, and it verifies the download against `git archive` before writing the digest.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# The vX.Y.Z release tag whose source tarball the bootstrap downloads.
|
|
9
|
+
RELEASE_TAG = "v0.47.0"
|
|
10
|
+
|
|
11
|
+
# SHA-256 of that tag's GitHub source tarball. The bootstrap refuses bytes that do not
|
|
12
|
+
# hash to this, so the runtime it installs is exactly the one this wheel was cut against.
|
|
13
|
+
TARBALL_SHA256 = "10482b2f8d224bb1b2e74622cc2658765336dccb19bbe8e9a6fc8d39346234ff"
|