hermetic 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/EXECUTOR.md +123 -0
- data/LICENSE +21 -0
- data/README.md +157 -0
- data/lib/hermetic/backends/base.rb +52 -0
- data/lib/hermetic/backends/docker.rb +104 -0
- data/lib/hermetic/backends/firecracker.rb +114 -0
- data/lib/hermetic/backends/gvisor.rb +23 -0
- data/lib/hermetic/backends/hosted/e2b.rb +96 -0
- data/lib/hermetic/backends/hosted.rb +27 -0
- data/lib/hermetic/backends/null.rb +19 -0
- data/lib/hermetic/credentials.rb +41 -0
- data/lib/hermetic/errors.rb +34 -0
- data/lib/hermetic/limits.rb +44 -0
- data/lib/hermetic/network_policy.rb +52 -0
- data/lib/hermetic/remote.rb +96 -0
- data/lib/hermetic/request.rb +127 -0
- data/lib/hermetic/result.rb +15 -0
- data/lib/hermetic/silas.rb +23 -0
- data/lib/hermetic/transport/http.rb +52 -0
- data/lib/hermetic/transport/subprocess.rb +64 -0
- data/lib/hermetic/version.rb +3 -0
- data/lib/hermetic.rb +67 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e50e07dce0f7e63f6ea83d61587f840678ef14edd404baae27dcff3ce3d17ec0
|
|
4
|
+
data.tar.gz: 29864ba40a25577dfd78cddc63390f11cc1cee694670b77462f1b965ba0ec12f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 88608057e05e00f071b205e8862f082c41f0488caf06888cba205d296c2764e8e5230d0db04f5b5fcaeb03cc61b4fc1c74e8a4b512bfbfc8573685afb45edf74
|
|
7
|
+
data.tar.gz: 9468982803c9caba9bb3ca00c605ca99d68b5b150fa4434fb167602ad04809edece8bdf750e9003fa5a36ebc06811f34966b3a23bf54bbf434bca579b91bff70
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] — 2026-07-16
|
|
4
|
+
|
|
5
|
+
- Core seam: `Hermetic::Request` normalization (shell vs argv, traversal-safe
|
|
6
|
+
`files:`, clean default-deny env, host-enforced timeout, idempotency key),
|
|
7
|
+
`Result` (Silas-duck-compatible superset), `Limits`, `NetworkPolicy`,
|
|
8
|
+
`Credentials` (`:env` mode; `:proxy` deferred to v1.1)
|
|
9
|
+
- Transports: `Subprocess` (host-timeout reaper, exit 124) and `Http`
|
|
10
|
+
(request-spec hash -> response), both with injectable fakes for tests
|
|
11
|
+
- Backends: `Base` (shared Result assembly + timeout mapping), `Null`
|
|
12
|
+
(disabled, fails loud), hardened `Docker` (pure argv incl. in-argv `files:`
|
|
13
|
+
bootstrap, `--interactive` stdin, timeout reap), `Gvisor` (`--runtime runsc`),
|
|
14
|
+
`Firecracker` (pure machine-config + jailer argv builders; boot smoke-gated
|
|
15
|
+
behind an injected runner), `Hosted::E2B` (`:vendor`, best-effort wire shape
|
|
16
|
+
pending `:hosted` smoke pinning; Daytona/Modal named but deferred)
|
|
17
|
+
- `Remote` executor client (`:remote`, off-host) with deterministic
|
|
18
|
+
idempotency keys and explicit in-doubt `TransportError`s; `EXECUTOR.md`
|
|
19
|
+
documents the server side of the wire contract
|
|
20
|
+
- Facade constructors: `.null` / `.docker` / `.gvisor` / `.firecracker` /
|
|
21
|
+
`.hosted` / `.wrap`; any local constructor + `executor:`/`token:` reroutes
|
|
22
|
+
to a `Remote` client for that isolation kind
|
|
23
|
+
- Optional Silas shim (`require "hermetic/silas"`): refuses a sandbox exec
|
|
24
|
+
inside a ledger transaction
|
|
25
|
+
- Smoke layer (`:smoke`, opt-in via `HERMETIC_SMOKE`): docker/gvisor/hosted
|
data/EXECUTOR.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# EXECUTOR — the off-host sandbox executor server
|
|
2
|
+
|
|
3
|
+
The server side of `Hermetic::Remote` (spec §3, §7 risk 2). A small HTTP
|
|
4
|
+
daemon on a **dedicated sandbox host** that runs guests through a local
|
|
5
|
+
backend (gVisor / Firecracker / hardened Docker) and holds **none of the
|
|
6
|
+
app's secrets** — no database creds, no `RAILS_MASTER_KEY`, no ledger. The
|
|
7
|
+
app talks to it over authenticated HTTPS; a guest escape's blast radius is
|
|
8
|
+
this host, which holds nothing.
|
|
9
|
+
|
|
10
|
+
v1 scope: a reference contract, not a production daemon (that's deferred,
|
|
11
|
+
spec §6). Anything that speaks this contract works with `Hermetic::Remote`.
|
|
12
|
+
|
|
13
|
+
## Auth
|
|
14
|
+
|
|
15
|
+
- Every request carries `Authorization: Bearer <token>`. Static tokens,
|
|
16
|
+
compared in constant time. Missing/wrong token → `401`. No other auth.
|
|
17
|
+
- TLS required in any real deployment. The token is the only secret either
|
|
18
|
+
side holds.
|
|
19
|
+
|
|
20
|
+
## Endpoints
|
|
21
|
+
|
|
22
|
+
### `POST /run` — execute one sandboxed run, synchronously
|
|
23
|
+
|
|
24
|
+
Request body (exactly what `Hermetic::Remote#run_spec` sends):
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"v": 1,
|
|
29
|
+
"backend": "gvisor", // "docker" | "gvisor" | "firecracker"
|
|
30
|
+
"backend_options": { "image": "ruby:3.3-slim" },
|
|
31
|
+
"argv": ["/bin/sh", "-c", "echo hi"], // full exec argv; shell already resolved client-side
|
|
32
|
+
"stdin": null,
|
|
33
|
+
"files": { "main.rb": "puts 6*7" }, // workdir-relative; client rejects traversal, server MUST re-reject
|
|
34
|
+
"env": { "GITHUB_TOKEN": "..." }, // the guest's ENTIRE environment (default-deny; credentials pre-resolved)
|
|
35
|
+
"network": { "egress": [] }, // empty = no network; entries are "host:port" allowlist
|
|
36
|
+
"timeout": 30, // wall-clock seconds, EXECUTOR-enforced — never trust the guest
|
|
37
|
+
"limits": { "memory": "512m", "cpus": "1", "pids": 256 }
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Headers: `Idempotency-Key: <32 hex chars>` (required — see Dedup),
|
|
42
|
+
`Content-Type: application/json`.
|
|
43
|
+
|
|
44
|
+
Response `200` (always `200` when the run *completed*, including guest
|
|
45
|
+
failure and timeout — a non-zero exit is data, not an error):
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{ "stdout": "hi\n", "stderr": "", "exit_status": 0, "timed_out": false }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Semantics:
|
|
52
|
+
|
|
53
|
+
- Timeout expiry → kill the guest, respond `exit_status: 124`,
|
|
54
|
+
`timed_out: true` (coreutils convention; the client re-maps to 124 anyway).
|
|
55
|
+
- The env in the payload is the guest's whole environment. Never merge the
|
|
56
|
+
executor host's env in.
|
|
57
|
+
- Unsupported `backend` for this host → `422` (run not started).
|
|
58
|
+
|
|
59
|
+
### `GET /runs/:idempotency_key` — reconcile an in-doubt run
|
|
60
|
+
|
|
61
|
+
- `200` + the stored `/run` response body: the run completed; here is its
|
|
62
|
+
result. This is how a client resolves `TransportError(in_doubt: true)`
|
|
63
|
+
without re-executing.
|
|
64
|
+
- `404`: this key never started a run — retrying is safe.
|
|
65
|
+
- `409`: the run is still executing.
|
|
66
|
+
|
|
67
|
+
### `GET /healthz`
|
|
68
|
+
|
|
69
|
+
`200` + `{ "ok": true, "backends": ["gvisor"] }`. Unauthenticated allowed.
|
|
70
|
+
|
|
71
|
+
## Dedup by idempotency key (the in-doubt contract)
|
|
72
|
+
|
|
73
|
+
A sandbox run is a non-replayable external effect. The wire can fail after
|
|
74
|
+
the guest ran (response lost) — the client cannot distinguish "never ran"
|
|
75
|
+
from "ran, reply lost", so it raises `Hermetic::TransportError` with
|
|
76
|
+
`in_doubt: true`. The executor makes retries safe:
|
|
77
|
+
|
|
78
|
+
1. On `POST /run`, atomically record the `Idempotency-Key` as *running*
|
|
79
|
+
**before** exec. Duplicate key while running → `409`.
|
|
80
|
+
2. On completion, store the result under the key.
|
|
81
|
+
3. A replayed `POST /run` with a completed key returns the **stored** result
|
|
82
|
+
(`200`, plus `Idempotent-Replay: true`) — the guest does not run again.
|
|
83
|
+
4. Retain results for a documented TTL (`>= 24h` recommended). After the
|
|
84
|
+
TTL, a replay re-executes.
|
|
85
|
+
|
|
86
|
+
Caveat: the key is derived deterministically from the request content
|
|
87
|
+
(credential *values* excluded, so token rotation between retries still
|
|
88
|
+
dedups). Two *intentionally identical* runs inside the TTL therefore
|
|
89
|
+
collapse into one. Callers that need genuine re-execution must vary the
|
|
90
|
+
request — e.g. `env: { "HERMETIC_RUN_NONCE" => SecureRandom.hex }`.
|
|
91
|
+
|
|
92
|
+
## Status-code contract
|
|
93
|
+
|
|
94
|
+
| Code | Meaning | Run started? | Client behavior |
|
|
95
|
+
|---|---|---|---|
|
|
96
|
+
| 200 | completed (or deduped replay) | yes | `Hermetic::Result` |
|
|
97
|
+
| 400 | malformed payload | **no** | `TransportError(in_doubt: false)` |
|
|
98
|
+
| 401/403 | auth failure | **no** | `TransportError(in_doubt: false)` |
|
|
99
|
+
| 409 | duplicate key still running | yes (in flight) | in doubt; poll `GET /runs/:key` |
|
|
100
|
+
| 422 | backend unsupported here | **no** | `TransportError(in_doubt: false)` |
|
|
101
|
+
| 5xx | executor broke mid-flight | unknown | `TransportError(in_doubt: true)` |
|
|
102
|
+
|
|
103
|
+
The load-bearing rule: **a 4xx MUST mean the run was never started.** Any
|
|
104
|
+
failure after exec begins must be a `5xx` (or a stored result), or the
|
|
105
|
+
in-doubt contract breaks. (Exception: `409` — started by an earlier request,
|
|
106
|
+
not this one.)
|
|
107
|
+
|
|
108
|
+
Wire failure (no HTTP status at all) is always in doubt; the client raises
|
|
109
|
+
`TransportError(in_doubt: true)` with the idempotency key in the message.
|
|
110
|
+
v1 default is **no automatic retry** — surface in-doubt to a human or replay
|
|
111
|
+
the same key deliberately.
|
|
112
|
+
|
|
113
|
+
## Security posture (deployment checklist)
|
|
114
|
+
|
|
115
|
+
- The executor host holds no app secrets and cannot reach the app's
|
|
116
|
+
database. Its only inbound is the app over TLS; its only standing secret
|
|
117
|
+
is the bearer token.
|
|
118
|
+
- Guest network is default-deny; an `egress` allowlist is enforced by the
|
|
119
|
+
backend (gVisor/Docker network config, Firecracker tap + nftables).
|
|
120
|
+
- `files` paths are re-validated server-side (workdir-relative, no `..`,
|
|
121
|
+
no absolute) even though the client already rejects traversal.
|
|
122
|
+
- Response bodies are the guest's stdout/stderr verbatim — treat as
|
|
123
|
+
untrusted data, never interpolate into shell or HTML.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel St Paul
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# hermetic
|
|
2
|
+
|
|
3
|
+
> **hermetic** — the missing Ruby sandbox. Pick your isolation strength (gVisor, a
|
|
4
|
+
> Firecracker microVM, a hosted sandbox, or hardened Docker) behind one `run` call,
|
|
5
|
+
> and run it off your app host so an escape can't reach your database or secrets.
|
|
6
|
+
|
|
7
|
+
Ruby today has exactly one honest option for running untrusted or model-generated
|
|
8
|
+
code: a locked-down `docker run`. `$SAFE` was removed in Ruby 3.0; there is no Ruby
|
|
9
|
+
client for E2B/Daytona/Modal and no Ruby wrapper for Firecracker or gVisor.
|
|
10
|
+
`hermetic` fills that whitespace as an ecosystem building block: one interface,
|
|
11
|
+
swappable isolation backends, zero runtime dependencies (stdlib only).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "hermetic"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
box = Hermetic.hosted(:e2b, api_key: ENV["E2B_API_KEY"])
|
|
23
|
+
|
|
24
|
+
result = box.run("python -c 'print(2+2)'",
|
|
25
|
+
files: { "data.txt" => "..." }, # materialized in the workdir
|
|
26
|
+
stdin: nil,
|
|
27
|
+
env: { "MODE" => "batch" }, # guest env is default-deny otherwise
|
|
28
|
+
network: :none, # default deny; opt-in egress allowlist
|
|
29
|
+
timeout: 10, # host-enforced wall clock
|
|
30
|
+
limits: { memory: "512m", cpus: "1", pids: 256 })
|
|
31
|
+
|
|
32
|
+
result.stdout # => "4\n"
|
|
33
|
+
result.success? # => true
|
|
34
|
+
result.timed_out? # => false (a timeout maps to exit_status 124)
|
|
35
|
+
result.trust # => :vendor
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A `String` command runs via `/bin/sh -c`; an `Array` argv execs directly — no shell,
|
|
39
|
+
no injection surface.
|
|
40
|
+
|
|
41
|
+
## Backend matrix
|
|
42
|
+
|
|
43
|
+
| Backend | Constructor | Isolation | Trust | Off-host? |
|
|
44
|
+
|---|---|---|---|---|
|
|
45
|
+
| **Hosted** (E2B) | `Hermetic.hosted(:e2b, api_key:)` | vendor microVM, in the vendor's cloud | `:vendor` | **yes**, by construction |
|
|
46
|
+
| **Remote** | `Hermetic.wrap(executor:, token:)` or any local constructor + `executor:` | whatever the executor runs (gVisor/Firecracker/Docker) | `:remote` | **yes** — dedicated sandbox host |
|
|
47
|
+
| **Firecracker** | `Hermetic.firecracker(kernel:, rootfs:)` | own guest kernel via KVM | `:vm` | no (pair with `executor:`) |
|
|
48
|
+
| **gVisor** | `Hermetic.gvisor(image:)` | user-space kernel (`runsc`), real syscall boundary | `:host` | no (pair with `executor:`) |
|
|
49
|
+
| **Docker** (hardened) | `Hermetic.docker(image:)` | container namespaces + caps/seccomp | `:host` | no — dev default |
|
|
50
|
+
| **Null** | `Hermetic.null` | none — disabled, fails loud on `run` | `:none` | no |
|
|
51
|
+
|
|
52
|
+
## Isolation is not the same thing as a trust boundary
|
|
53
|
+
|
|
54
|
+
Two independent axes, never conflated:
|
|
55
|
+
|
|
56
|
+
1. **Isolation strength** — namespaces < gVisor < microVM. (Which backend.)
|
|
57
|
+
2. **Trust domain** — does the guest run *next to* your `RAILS_MASTER_KEY`, database,
|
|
58
|
+
and job queue, or in a separate security context?
|
|
59
|
+
|
|
60
|
+
A container escape on your app host lands next to everything you care about, however
|
|
61
|
+
strong the container was. So every box (and every `Result`) carries the posture:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
box.trust # => :vendor | :remote | :vm | :host | :none
|
|
65
|
+
box.off_host? # => true only for :vendor and :remote
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
And the one-line guard that keeps it honest:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
raise "untrusted code requires an off-host sandbox" unless box.off_host?
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Getting off-host is either free (hosted vendors) or one keyword: any local
|
|
75
|
+
constructor takes `executor:` + `token:` and becomes a client for a dedicated
|
|
76
|
+
sandbox host that holds no app secrets (see `EXECUTOR.md` for the server contract):
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
box = Hermetic.gvisor(image: "ruby:3.3-slim",
|
|
80
|
+
executor: "https://sbx.internal:8443", token: ENV["SBX_TOKEN"])
|
|
81
|
+
box.off_host? # => true — same isolation tech, different blast radius
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Credentials: the model never sees raw tokens
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
box = Hermetic.hosted(:e2b, api_key: ENV["E2B_API_KEY"],
|
|
88
|
+
credentials: { "GITHUB_TOKEN" => -> { CredentialVault.fetch(:github) } })
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Credentials are callables resolved lazily per run and injected into the guest's
|
|
92
|
+
environment at exec time — never printed into the model-visible command, never
|
|
93
|
+
cached on the box, never part of the idempotency key.
|
|
94
|
+
|
|
95
|
+
## Silas drop-in
|
|
96
|
+
|
|
97
|
+
`Hermetic::Result` is a superset of `Silas::Sandbox::Result` (`stdout` / `stderr` /
|
|
98
|
+
`exit_status` / `success?`), and every box implements the `run` / `enabled?` duck
|
|
99
|
+
type Silas's `config.sandbox` already accepts — zero Silas changes:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
Silas.configure do |c|
|
|
103
|
+
c.sandbox = Hermetic.gvisor(image: "ruby:3.3-slim",
|
|
104
|
+
executor: "https://sbx.internal:8443",
|
|
105
|
+
token: ENV["SBX_TOKEN"])
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Running Silas? Also `require "hermetic/silas"` — an optional shim that refuses to
|
|
110
|
+
execute a sandbox run inside a ledger transaction (a sandbox exec is a
|
|
111
|
+
non-replayable external effect; `run_code` stays `at_most_once!`).
|
|
112
|
+
|
|
113
|
+
## In-doubt runs (remote/hosted)
|
|
114
|
+
|
|
115
|
+
If the wire fails after a request may have executed guest-side, `hermetic` raises
|
|
116
|
+
`Hermetic::TransportError` with `in_doubt?: true` rather than silently retrying.
|
|
117
|
+
Every run carries a deterministic `Idempotency-Key`, so a deliberate retry of the
|
|
118
|
+
same logical run can be deduplicated executor-side. v1 never retries automatically.
|
|
119
|
+
|
|
120
|
+
## Honest v1 scope
|
|
121
|
+
|
|
122
|
+
Shipped: Null, hardened Docker, gVisor, Hosted::E2B, Firecracker (pure
|
|
123
|
+
config/jailer builders + injectable single-shot cold boot), Remote executor client
|
|
124
|
+
with `EXECUTOR.md`, full `run` contract (`files:`/`stdin:`/`env:`/`network:`/
|
|
125
|
+
`timeout:`/`limits:`), `:env` credentials, `trust`/`off_host?`, the Silas shim.
|
|
126
|
+
|
|
127
|
+
Deferred (v1.1+):
|
|
128
|
+
|
|
129
|
+
- `:proxy` credential mode (guest gets no token at all; egress proxy attaches auth)
|
|
130
|
+
- Firecracker orchestration: warm pools/snapshots, vsock I/O, tap+nftables egress,
|
|
131
|
+
rootfs builder — v1's default Firecracker runner refuses to boot; inject your own
|
|
132
|
+
- Hosted Daytona/Modal (constructors name them and fail with the roadmap)
|
|
133
|
+
- The executor server daemon itself (the client + wire contract ship now)
|
|
134
|
+
- Persistent sandboxes, streaming stdout, artifact read-back
|
|
135
|
+
|
|
136
|
+
Known caveat: `Hosted::E2B`'s endpoint/body shapes are a best-effort design-time
|
|
137
|
+
approximation, to be pinned against the live API via the `:hosted` smoke layer
|
|
138
|
+
before first real use.
|
|
139
|
+
|
|
140
|
+
## Testing
|
|
141
|
+
|
|
142
|
+
The default suite is hermetic (the name is a promise): pure argv/VM-config/HTTP-spec
|
|
143
|
+
builders plus injected fake runners and transports — no Docker, no network, no KVM.
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
bundle exec rspec # unit layer only, runs anywhere
|
|
147
|
+
HERMETIC_SMOKE=docker bundle exec rspec # + real `docker run` round-trips
|
|
148
|
+
HERMETIC_SMOKE=gvisor bundle exec rspec # + proves runsc boots (needs runsc runtime)
|
|
149
|
+
HERMETIC_SMOKE=hosted bundle exec rspec # + E2B round-trip (needs E2B_API_KEY)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Smoke specs are tagged `:smoke`, excluded unless `HERMETIC_SMOKE` is set, and skip
|
|
153
|
+
themselves when their infrastructure is absent.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module Hermetic
|
|
2
|
+
module Backends
|
|
3
|
+
# Shared plumbing for every backend: normalize the run kwargs into a
|
|
4
|
+
# Request, delegate to the subclass's #execute (-> [stdout, stderr,
|
|
5
|
+
# exit_status, timed_out]), assemble the Result. Timeout always maps to
|
|
6
|
+
# exit 124 here so no backend can disagree about the convention.
|
|
7
|
+
class Base
|
|
8
|
+
TIMEOUT_EXIT = 124 # coreutils convention, matches Silas
|
|
9
|
+
|
|
10
|
+
OFF_HOST_TRUST = %i[vendor remote].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :trust, :limits
|
|
13
|
+
|
|
14
|
+
def initialize(trust:, limits: nil, credentials: nil, credential_mode: :env, env_allowlist: [])
|
|
15
|
+
@trust = trust
|
|
16
|
+
@limits = Limits.build(limits)
|
|
17
|
+
@credentials = Credentials.build(credentials, mode: credential_mode)
|
|
18
|
+
@env_allowlist = Array(env_allowlist).map(&:to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def enabled? = true
|
|
22
|
+
|
|
23
|
+
# Off-host = a guest escape cannot reach the app's secrets (spec §3).
|
|
24
|
+
# Co-location is a visible, refusable property — guard with:
|
|
25
|
+
# raise "untrusted code requires an off-host sandbox" unless box.off_host?
|
|
26
|
+
def off_host? = OFF_HOST_TRUST.include?(trust)
|
|
27
|
+
|
|
28
|
+
def run(command, files: {}, stdin: nil, env: {}, network: :none, timeout: 30, limits: nil)
|
|
29
|
+
request = Request.build(command, files: files, stdin: stdin, env: env,
|
|
30
|
+
network: network, timeout: timeout, limits: limits,
|
|
31
|
+
default_limits: @limits, credentials: @credentials,
|
|
32
|
+
env_allowlist: @env_allowlist)
|
|
33
|
+
started = clock_ms
|
|
34
|
+
stdout, stderr, exit_status, timed_out = execute(request)
|
|
35
|
+
exit_status = TIMEOUT_EXIT if timed_out
|
|
36
|
+
Result.new(stdout: stdout, stderr: stderr, exit_status: exit_status,
|
|
37
|
+
timed_out: !!timed_out, duration_ms: clock_ms - started,
|
|
38
|
+
backend: backend_name, trust: trust)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def backend_name = self.class.name.split("::").last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def execute(request)
|
|
46
|
+
raise NotImplementedError, "#{self.class}#execute"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def clock_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "shellwords"
|
|
3
|
+
|
|
4
|
+
module Hermetic
|
|
5
|
+
module Backends
|
|
6
|
+
# Hardened `docker run` — Silas's interim adapter ported onto the Request
|
|
7
|
+
# contract and extended with what it lacked (files:, stdin:, clean env,
|
|
8
|
+
# per-run limits). Container namespaces are weaker than a microVM and the
|
|
9
|
+
# daemon this adapter shells to lives on the app host, so this is
|
|
10
|
+
# trust :host — dev-only for untrusted code (spec §3).
|
|
11
|
+
#
|
|
12
|
+
# The argv is a pure function of the Request — files: included — so the
|
|
13
|
+
# whole wire form is unit-testable without Docker present; the runner is
|
|
14
|
+
# injectable and a real run is a single `docker run` leg.
|
|
15
|
+
class Docker < Base
|
|
16
|
+
attr_reader :image, :workdir
|
|
17
|
+
|
|
18
|
+
def initialize(image:, workdir: "/workspace", docker_bin: "docker", runner: nil, **base_opts)
|
|
19
|
+
raise ConfigError, "image: is required" if image.to_s.strip.empty?
|
|
20
|
+
|
|
21
|
+
super(trust: :host, **base_opts)
|
|
22
|
+
@image = image
|
|
23
|
+
@workdir = workdir
|
|
24
|
+
@docker_bin = docker_bin
|
|
25
|
+
@runner = runner || method(:shell_run)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Pure: Request -> locked-down `docker run` argv. The command rides as
|
|
29
|
+
# argv elements, never interpolated into a shell string — no injection
|
|
30
|
+
# surface. Docker passes no host vars unless named in --env, so the
|
|
31
|
+
# guest env is exactly Request#clean_env by construction. The rootfs is
|
|
32
|
+
# read-only; the workdir is a tmpfs — the guest's only writable scratch,
|
|
33
|
+
# which is also where the files: bootstrap lands.
|
|
34
|
+
def container_argv(request, name:)
|
|
35
|
+
[ @docker_bin, "run", "--rm", "--name", name, *runtime_flags,
|
|
36
|
+
*stdin_flags(request),
|
|
37
|
+
"--network", request.network.docker_flag,
|
|
38
|
+
"--memory", request.limits.memory, "--cpus", request.limits.cpus,
|
|
39
|
+
"--pids-limit", request.limits.pids.to_s,
|
|
40
|
+
"--read-only", "--tmpfs", @workdir, "--workdir", @workdir,
|
|
41
|
+
"--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
|
42
|
+
*env_flags(request),
|
|
43
|
+
@image, *guest_argv(request) ]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Pure: the files: delivery. `docker cp` can't reach a read-only rootfs
|
|
47
|
+
# and a workdir tmpfs only exists once the container starts (the smoke
|
|
48
|
+
# layer proved both), so files are written by the guest itself at exec
|
|
49
|
+
# time: each one a base64 blob decoded into the workdir tmpfs, then
|
|
50
|
+
# `exec "$@"` hands off to the real command. User data enters only as
|
|
51
|
+
# base64 ([A-Za-z0-9+/=], inert in single quotes) and escaped paths;
|
|
52
|
+
# the command stays positional-args — still no injection surface.
|
|
53
|
+
# Needs /bin/sh and base64 in the image (any debian/alpine/busybox).
|
|
54
|
+
def bootstrap_script(request)
|
|
55
|
+
writes = request.files.map do |path, content|
|
|
56
|
+
dir = File.dirname(path)
|
|
57
|
+
write = "printf '%s' '#{[ content ].pack("m0")}' | base64 -d > #{Shellwords.escape(path)}"
|
|
58
|
+
dir == "." ? write : "mkdir -p #{Shellwords.escape(dir)} && #{write}"
|
|
59
|
+
end
|
|
60
|
+
(writes + [ 'exec "$@"' ]).join(" && ")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def runtime_flags = [] # Gvisor overrides with --runtime runsc
|
|
66
|
+
|
|
67
|
+
# stdin only attaches when there is something to pipe; otherwise the
|
|
68
|
+
# guest sees a closed stdin immediately.
|
|
69
|
+
def stdin_flags(request) = request.stdin ? [ "--interactive" ] : []
|
|
70
|
+
|
|
71
|
+
def guest_argv(request)
|
|
72
|
+
return request.exec_argv if request.files.empty?
|
|
73
|
+
|
|
74
|
+
[ "/bin/sh", "-c", bootstrap_script(request), "hermetic", *request.exec_argv ]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def env_flags(request)
|
|
78
|
+
request.clean_env.flat_map { |key, value| [ "--env", "#{key}=#{value}" ] }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def execute(request)
|
|
82
|
+
name = "hermetic-#{SecureRandom.hex(6)}"
|
|
83
|
+
@runner.call(container_argv(request, name: name),
|
|
84
|
+
stdin: request.stdin, timeout: request.timeout, name: name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The real runner (exercised only by the :smoke layer — unit specs
|
|
88
|
+
# inject a fake). On timeout the container is reaped by name, because
|
|
89
|
+
# killing the docker CLI leaves the daemon-side container alive
|
|
90
|
+
# (Silas's reaper lesson).
|
|
91
|
+
def shell_run(argv, stdin:, timeout:, name:)
|
|
92
|
+
tuple = transport.call(argv, stdin: stdin, timeout: timeout)
|
|
93
|
+
reap(name) if tuple[3]
|
|
94
|
+
tuple
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def reap(name)
|
|
98
|
+
system(@docker_bin, "rm", "-f", name, out: File::NULL, err: File::NULL)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def transport = @transport ||= Transport::Subprocess.new
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Hermetic
|
|
4
|
+
module Backends
|
|
5
|
+
# Strongest self-host option: each run cold-boots a Firecracker microVM —
|
|
6
|
+
# its own guest kernel behind KVM (~125 ms boot). v1 ships the pure half
|
|
7
|
+
# (machine_config hash + jailer argv, unit-tested with zero infra) and a
|
|
8
|
+
# single-shot cold-boot path behind an injectable runner; real
|
|
9
|
+
# orchestration (jail materialization, vsock I/O, tap+nftables egress,
|
|
10
|
+
# warm pools) is deferred per spec §7, so the default runner refuses —
|
|
11
|
+
# actually booting is smoke-gated on a KVM host. trust :vm but
|
|
12
|
+
# co-located: off_host? is false. Pair with Hermetic::Remote to put the
|
|
13
|
+
# KVM host outside the app's trust domain.
|
|
14
|
+
class Firecracker < Base
|
|
15
|
+
BOOT_ARGS = "console=ttyS0 reboot=k panic=1 pci=off"
|
|
16
|
+
CONFIG_FILE = "vm-config.json" # jail-relative; jailer chroots firecracker
|
|
17
|
+
|
|
18
|
+
attr_reader :kernel, :rootfs
|
|
19
|
+
|
|
20
|
+
def initialize(kernel:, rootfs:, boot_args: BOOT_ARGS,
|
|
21
|
+
firecracker_bin: "firecracker", jailer_bin: "jailer",
|
|
22
|
+
uid: 65_534, gid: 65_534, chroot_base: "/srv/jailer",
|
|
23
|
+
runner: nil, **opts)
|
|
24
|
+
raise ConfigError, "kernel: (vmlinux path) is required for firecracker" if kernel.to_s.strip.empty?
|
|
25
|
+
raise ConfigError, "rootfs: (ext4 image path) is required for firecracker" if rootfs.to_s.strip.empty?
|
|
26
|
+
|
|
27
|
+
super(trust: :vm, **opts)
|
|
28
|
+
@kernel = kernel
|
|
29
|
+
@rootfs = rootfs
|
|
30
|
+
@boot_args = boot_args
|
|
31
|
+
@firecracker_bin = firecracker_bin
|
|
32
|
+
@jailer_bin = jailer_bin
|
|
33
|
+
@uid = uid
|
|
34
|
+
@gid = gid
|
|
35
|
+
@chroot_base = chroot_base
|
|
36
|
+
@runner = runner
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Pure: the firecracker --config-file hash for one run. Rootfs is
|
|
40
|
+
# attached read-only (writable scratch is guest-init's job — same
|
|
41
|
+
# posture as docker --read-only + tmpfs); default-deny network attaches
|
|
42
|
+
# no NIC at all. pids has no seat in the VM config — it's enforced by
|
|
43
|
+
# guest init, deferred with the rest of orchestration.
|
|
44
|
+
def machine_config(request)
|
|
45
|
+
{
|
|
46
|
+
"boot-source" => {
|
|
47
|
+
"kernel_image_path" => @kernel,
|
|
48
|
+
"boot_args" => @boot_args
|
|
49
|
+
},
|
|
50
|
+
"drives" => [ {
|
|
51
|
+
"drive_id" => "rootfs",
|
|
52
|
+
"path_on_host" => @rootfs,
|
|
53
|
+
"is_root_device" => true,
|
|
54
|
+
"is_read_only" => true
|
|
55
|
+
} ],
|
|
56
|
+
"machine-config" => {
|
|
57
|
+
"vcpu_count" => vcpu_count(request.limits.cpus),
|
|
58
|
+
"mem_size_mib" => mem_size_mib(request.limits.memory),
|
|
59
|
+
"smt" => false
|
|
60
|
+
},
|
|
61
|
+
"network-interfaces" => request.network.firecracker_ifaces
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Pure: jailer chroots + re-namespaces firecracker and drops to an
|
|
66
|
+
# unprivileged uid/gid before the VMM runs. Everything after "--" goes
|
|
67
|
+
# to firecracker itself; the config path is relative to the jail root.
|
|
68
|
+
def jailer_argv(id:)
|
|
69
|
+
[ @jailer_bin,
|
|
70
|
+
"--id", id,
|
|
71
|
+
"--exec-file", @firecracker_bin,
|
|
72
|
+
"--uid", @uid.to_s,
|
|
73
|
+
"--gid", @gid.to_s,
|
|
74
|
+
"--chroot-base-dir", @chroot_base,
|
|
75
|
+
"--",
|
|
76
|
+
"--no-api",
|
|
77
|
+
"--config-file", CONFIG_FILE ]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Single-shot cold boot: a fresh VM id per run, never reused. The
|
|
83
|
+
# runner owns everything host-side — materializing the config and
|
|
84
|
+
# files: into the jail, booting, guest I/O, and the wall clock — and
|
|
85
|
+
# returns [stdout, stderr, exit_status, timed_out].
|
|
86
|
+
def execute(request)
|
|
87
|
+
raise ConfigError, "firecracker execution requires a KVM host (smoke-gated)" unless @runner
|
|
88
|
+
|
|
89
|
+
id = "hermetic-fc-#{SecureRandom.hex(6)}"
|
|
90
|
+
@runner.call(jailer_argv(id: id), config: machine_config(request), request: request, id: id)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def vcpu_count(cpus)
|
|
94
|
+
count = Float(cpus).ceil
|
|
95
|
+
raise ConfigError, "cpus must be positive, got #{cpus.inspect}" unless count.positive?
|
|
96
|
+
|
|
97
|
+
count
|
|
98
|
+
rescue ArgumentError, TypeError
|
|
99
|
+
raise ConfigError, "cpus must be numeric for firecracker vcpu_count, got #{cpus.inspect}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def mem_size_mib(memory)
|
|
103
|
+
case memory.to_s
|
|
104
|
+
when /\A(\d+)m\z/i then Regexp.last_match(1).to_i
|
|
105
|
+
when /\A(\d+)g\z/i then Regexp.last_match(1).to_i * 1024
|
|
106
|
+
when /\A\d+\z/ then memory.to_i
|
|
107
|
+
else
|
|
108
|
+
raise ConfigError,
|
|
109
|
+
%(memory must be "<n>m"/"<n>g" to map to firecracker mem_size_mib, got #{memory.inspect})
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "hermetic/backends/docker"
|
|
2
|
+
|
|
3
|
+
module Hermetic
|
|
4
|
+
module Backends
|
|
5
|
+
# gVisor: Docker's hardened front-end, but the container runs under the
|
|
6
|
+
# runsc user-space kernel — a real syscall boundary instead of shared
|
|
7
|
+
# host-kernel namespaces. Still trust :host: the isolation is stronger,
|
|
8
|
+
# but the daemon this adapter shells to lives on the app host — isolation
|
|
9
|
+
# strength and trust domain are independent axes (spec §3).
|
|
10
|
+
class Gvisor < Docker
|
|
11
|
+
RUNSC = "runsc"
|
|
12
|
+
|
|
13
|
+
def initialize(image:, runtime: RUNSC, **opts)
|
|
14
|
+
super(image: image, **opts)
|
|
15
|
+
@runtime = runtime
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def runtime_flags = [ "--runtime", @runtime ]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|