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 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