security_box 0.2.0 → 0.5.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 +4 -4
- data/CHANGELOG.md +105 -0
- data/README.md +163 -13
- data/lib/security_box/assets/security_box.wasm +2 -2
- data/lib/security_box/configuration.rb +254 -6
- data/lib/security_box/envelope.rb +9 -0
- data/lib/security_box/errors.rb +2 -0
- data/lib/security_box/eval_run.rb +191 -0
- data/lib/security_box/guest/main.rb +33 -3
- data/lib/security_box/pool.rb +138 -0
- data/lib/security_box/ractor_pool.rb +199 -0
- data/lib/security_box/registry.rb +70 -0
- data/lib/security_box/result.rb +7 -0
- data/lib/security_box/runtime.rb +17 -0
- data/lib/security_box/sandbox.rb +22 -133
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +46 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c7d1c1387ec5b1dffdb9781552e489d4b3d45e7066fdd3c7932e6ba39a8432f
|
|
4
|
+
data.tar.gz: 7770652a22e98d5067c851b5a1c790896b986b6dc7a68ca03ea4a0c3c4bb2110
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d4f7f357bc27692a392251edfd887c310f94f6b0e5f47dccd01c9ea420c427f307888791205d82ee54a12ad117351464b6deefc06211cdabcf6c2bdeb86779e
|
|
7
|
+
data.tar.gz: b61989d1e43f6f801b1522643239a96118128825837f680bc57122ea8874be4f6229bde01c8af81f53bcbc8d9985e3100f329e2bcbc56797067d6584ed58cca8
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,111 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - 2026-09-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Folder mounts (read-only by default): `c.mount "host/path" => "/data"` in the
|
|
13
|
+
register DSL (or `mounts:` in `Configuration.build`), with `c.mount_rw` for an
|
|
14
|
+
opt-in writable mount. Guest code can read a mounted host directory
|
|
15
|
+
(`File.read`, `Dir[]`, `File.open`); write attempts into a read-only mount fail
|
|
16
|
+
guest-side with `Errno::EPERM` and leave the host directory untouched.
|
|
17
|
+
- `Configuration#mounts`: a frozen array of frozen `{host:, guest:, mode:}` hashes.
|
|
18
|
+
Mounts are config values — they flow through `#with`, `#fingerprint` (equal
|
|
19
|
+
mounts → equal fingerprint) and RactorPool requests. Relative host paths are
|
|
20
|
+
expanded against `Dir.pwd` at DSL time.
|
|
21
|
+
- Mount validation (`SecurityBox::InvalidConfiguration` on violation): mode must be
|
|
22
|
+
`:read_only`/`:read_write` (wasmtime silently accepts unknown symbols, so the mode
|
|
23
|
+
is never trusted to the runtime); guest paths must be absolute and normalized
|
|
24
|
+
(no `..`, no trailing `/`, not `/`); guest paths must not overlap the reserved
|
|
25
|
+
`/work`, `/usr` or `/src` trees (exact or nested — a collision with the embedded
|
|
26
|
+
VFS is silently shadowed, a mount inside `/usr` breaks the guest boot, and inside
|
|
27
|
+
`/work` it would create a read-only subtree); duplicate guest paths are rejected
|
|
28
|
+
(wasmtime's last-mount-wins is silently surprising); at most 16 mounts.
|
|
29
|
+
- Per-eval host-path validation: a mount whose host directory vanished returns a
|
|
30
|
+
`:sandbox_error` Result with a `security_box:` note instead of raising
|
|
31
|
+
`Wasmtime::Error` out of `#eval` (the store creation now also sits inside the
|
|
32
|
+
eval's rescue, so wasmtime setup failures can never raise).
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `Sandbox#eval` (and pool equivalents) accept `mounts:` as a per-call override
|
|
37
|
+
(replaces the configuration's mounts, like `env:`).
|
|
38
|
+
- README: new "Folder mounts" section + updated isolation matrix and defense notes
|
|
39
|
+
(a mounted folder's content is fully readable by guest code).
|
|
40
|
+
|
|
41
|
+
### Investigation (no change needed, documented)
|
|
42
|
+
|
|
43
|
+
- Q6 probe (stage 5): the embedded VFS is **not** guest-writable (`File.write`
|
|
44
|
+
into `/usr`, `/src` or `/` fails with `Errno::ENOENT`; deletes with `ENOTSUP`),
|
|
45
|
+
so no prelude File-write restriction is warranted; WASI remains the primary
|
|
46
|
+
enforcement. Symlink escapes from a mounted directory are blocked by wasmtime
|
|
47
|
+
(`Errno::EPERM`); the guest cannot even create symlinks. Measured mount cost:
|
|
48
|
+
none (warm-boot p50 ~260ms with 0/1/4/8 mounts). Evidence and probes in
|
|
49
|
+
`docs/plan/stages/stage_5.md` and `bin/spike_stage5_mounts.rb`.
|
|
50
|
+
|
|
51
|
+
## [0.4.0] - 2026-09-15
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- `SecurityBox::Pool` (`SecurityBox.pool`): a bounded pool of `:oneshot` sandboxes —
|
|
56
|
+
hard cap on concurrent evals (`size:`), sandbox reuse, `checkout` block and metrics
|
|
57
|
+
(`created`/`evals`/`total_ms`/`avg_ms`). Evals serialize on the GVL (documented).
|
|
58
|
+
- `SecurityBox::RactorPool` (`SecurityBox.ractor_pool`): real parallelism — worker
|
|
59
|
+
Ractors sharing one Ractor-shareable Engine + compiled Module (stage-3 "Plan C").
|
|
60
|
+
Measured ≈0.52–0.53 parallel/serial wall ratio at 4 workers on 6 cores (~1.9x),
|
|
61
|
+
RSS ≈57MB; per-call overrides, statuses and trap recovery behave like
|
|
62
|
+
`Sandbox#eval`; a dead worker degrades to a `:sandbox_error` Result instead of a
|
|
63
|
+
hang.
|
|
64
|
+
- `Configuration#fuel_ms`: rate-based fuel budgeting (~4e6 fuel/ms from the stage-3
|
|
65
|
+
calibration table + ~1e9 boot allowance), exposed via `Configuration#effective_fuel`
|
|
66
|
+
and usable per call. `fuel` and `fuel_ms` are mutually exclusive in the builder DSL
|
|
67
|
+
and in `#with`; switch back with `with(fuel_ms: nil, fuel: ...)`.
|
|
68
|
+
- `docs/plan/stages/stage_4.md` with the worker-mode feasibility investigation.
|
|
69
|
+
|
|
70
|
+
### Changed
|
|
71
|
+
|
|
72
|
+
- The eval core was extracted from `Sandbox` into `SecurityBox::EvalRun` (shared by
|
|
73
|
+
`Sandbox` and `RactorPool` workers); `Sandbox` is now a thin, thread-safe wrapper.
|
|
74
|
+
- README: the "Ractor note" roadmap item is delivered; concurrency guidance rewritten
|
|
75
|
+
around `Pool`/`RactorPool`.
|
|
76
|
+
|
|
77
|
+
### Investigation (negative result, documented)
|
|
78
|
+
|
|
79
|
+
- `:worker` mode (long-lived instance amortizing the ~240ms boot) is infeasible on
|
|
80
|
+
wasmtime-rb 48 (sync WASI): `invoke` holds the GVL during compute *and* while the
|
|
81
|
+
guest is blocked on a WASI read; epoch deadlines never fire inside a blocking
|
|
82
|
+
syscall; and per-request limits would require mutating a live Store from another
|
|
83
|
+
thread (unsafe). Evidence and probes in `docs/plan/stages/stage_4.md` and
|
|
84
|
+
`bin/spike_stage4_worker.rb`.
|
|
85
|
+
|
|
86
|
+
## [0.3.0] - 2026-09-14
|
|
87
|
+
|
|
88
|
+
### Added
|
|
89
|
+
|
|
90
|
+
- Named, reusable profiles: `SecurityBox.register(:name, from: :base) { |c| c.fuel 100 }`
|
|
91
|
+
and `SecurityBox.spawn(:name, **overrides)` (a `Configuration` or the defaults also
|
|
92
|
+
work). Profiles are immutable; duplicate/unknown names raise
|
|
93
|
+
`SecurityBox::InvalidConfiguration`.
|
|
94
|
+
- `SecurityBox::Configuration#fingerprint`: a stable identity for equal settings
|
|
95
|
+
(SHA-256 of the canonical configuration), ready for artifact caches.
|
|
96
|
+
- Guest error envelopes now carry a `backtrace`: user frames only, capped at 20,
|
|
97
|
+
sandbox-internal locations (no host paths). Surfaced via `Result#error["backtrace"]`;
|
|
98
|
+
malformed backtraces invalidate the envelope (`:sandbox_error`).
|
|
99
|
+
- `Result#fuel_used` is now `nil` for `:timeout`: epoch traps restore fuel to the last
|
|
100
|
+
checkpoint, so the restored value understated consumption by orders of magnitude.
|
|
101
|
+
(Truthful on completion, fuel exhaustion and memory-limit traps.)
|
|
102
|
+
|
|
103
|
+
### Changed
|
|
104
|
+
|
|
105
|
+
- A guest `NoMemoryError` (interpreter OOM against the store limit) is reported as
|
|
106
|
+
`:memory_limit` instead of `:error`.
|
|
107
|
+
- A `memory_size` below the image's declared minimum (1528 pages ≈ 95.5 MiB) now
|
|
108
|
+
returns a `:sandbox_error` Result with a `security_box:` note on stderr instead of
|
|
109
|
+
raising `Wasmtime::Error` out of `#eval`.
|
|
110
|
+
- The sandbox image must be repacked (`rake security_box:build_image`) when
|
|
111
|
+
upgrading: the guest entrypoint protocol changed.
|
|
112
|
+
|
|
8
113
|
## [0.2.0] - 2026-09-14
|
|
9
114
|
|
|
10
115
|
### Added
|
data/README.md
CHANGED
|
@@ -119,7 +119,8 @@ sandbox.eval("3 * 3").value # => 9
|
|
|
119
119
|
### Per-call limits
|
|
120
120
|
|
|
121
121
|
Options can be passed per call (derived from the configuration without mutating it):
|
|
122
|
-
`timeout_ms`, `fuel`, `memory_size`, `stdout_limit`, `stderr_limit
|
|
122
|
+
`timeout_ms`, `fuel`, `fuel_ms`, `memory_size`, `stdout_limit`, `stderr_limit`,
|
|
123
|
+
`mounts`.
|
|
123
124
|
|
|
124
125
|
```ruby
|
|
125
126
|
# Wall-clock limit via epoch interruption
|
|
@@ -144,13 +145,87 @@ SecurityBox.eval('1000.times { print "x" * 1000 }', stdout_limit: 4096).stdout.b
|
|
|
144
145
|
Exceptions raised by guest code are a *result*, not a sandbox failure:
|
|
145
146
|
|
|
146
147
|
```ruby
|
|
147
|
-
result = SecurityBox.eval(
|
|
148
|
+
result = SecurityBox.eval("def boom; raise ArgumentError, 'boom'; end; boom")
|
|
148
149
|
|
|
149
|
-
result.status
|
|
150
|
-
result.error["class"]
|
|
151
|
-
result.error["message"]
|
|
150
|
+
result.status # => :error
|
|
151
|
+
result.error["class"] # => "ArgumentError"
|
|
152
|
+
result.error["message"] # => "boom"
|
|
153
|
+
result.error["backtrace"] # => ["sandbox:1:in 'Object#boom'", "sandbox:1:in '<main>'"]
|
|
152
154
|
```
|
|
153
155
|
|
|
156
|
+
The backtrace contains guest frames only (sandbox-internal locations, capped at
|
|
157
|
+
20 frames) — nothing from the host filesystem leaks.
|
|
158
|
+
|
|
159
|
+
### Named profiles
|
|
160
|
+
|
|
161
|
+
Reusable configurations registered once and spawned as often as needed:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
SecurityBox.register(:default) do |c|
|
|
165
|
+
c.fuel 10_000_000_000
|
|
166
|
+
c.timeout_ms 2_000
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
SecurityBox.register(:lean, from: :default) do |c|
|
|
170
|
+
c.fuel 2_000_000_000 # boot alone costs ~1e9; see the fuel notes below
|
|
171
|
+
c.timeout_ms 500
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
box = SecurityBox.spawn(:lean) # Sandbox from the profile
|
|
175
|
+
box.eval("40 + 2").value # => 42
|
|
176
|
+
|
|
177
|
+
SecurityBox.spawn(:lean, timeout_ms: 100) # per-call override (profile unchanged)
|
|
178
|
+
SecurityBox.spawn # default configuration
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Profiles are immutable: duplicate names and unknown names raise
|
|
182
|
+
`SecurityBox::InvalidConfiguration`; overrides never mutate the profile.
|
|
183
|
+
`SecurityBox::Configuration#fingerprint` gives every configuration a stable
|
|
184
|
+
identity (equal settings → equal fingerprint), used to share runtime artifacts.
|
|
185
|
+
|
|
186
|
+
### Folder mounts
|
|
187
|
+
|
|
188
|
+
Host folders can be mounted into the sandbox explicitly. **Mounts are read-only
|
|
189
|
+
by default**; `mount_rw` is the opt-in writable form:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
SecurityBox.register(:reader) do |c|
|
|
193
|
+
c.mount "./data" => "/data" # read-only (host path expanded at DSL time)
|
|
194
|
+
c.mount_rw "./state" => "/state" # opt-in writable
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
box = SecurityBox.spawn(:reader)
|
|
198
|
+
box.eval('File.read("/data/input.csv")') # guest reads mounted content
|
|
199
|
+
box.eval('File.write("/state/last.txt", Time.now.to_i)')
|
|
200
|
+
|
|
201
|
+
# Per-call override (replaces the configuration's mounts, like env:)
|
|
202
|
+
SecurityBox.eval('File.read("/data/a.txt")',
|
|
203
|
+
mounts: [{ host: "./data", guest: "/data", mode: :read_only }])
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Rules (violations raise `SecurityBox::InvalidConfiguration` at registration):
|
|
207
|
+
|
|
208
|
+
- A mount is one `{host:, guest:, mode:}` pair; `mode` is `:read_only` or
|
|
209
|
+
`:read_write` and the DSL defaults to read-only.
|
|
210
|
+
- Guest paths must be absolute and normalized, must not overlap the reserved
|
|
211
|
+
`/work` (sandbox tmpdir), `/usr` (embedded stdlib) or `/src` (entrypoint)
|
|
212
|
+
trees — exact or nested — and must be unique.
|
|
213
|
+
- At most 16 mounts per configuration.
|
|
214
|
+
- The host path must exist and be a directory at eval time; otherwise the eval
|
|
215
|
+
returns `:sandbox_error` with a `security_box:` note on stderr (instead of
|
|
216
|
+
raising).
|
|
217
|
+
|
|
218
|
+
Security notes:
|
|
219
|
+
|
|
220
|
+
- **A mounted folder's content is fully readable by guest code** — only mount
|
|
221
|
+
directories whose content you are willing to expose to the executed code.
|
|
222
|
+
- Read-only mounts are enforced by wasmtime: writes fail guest-side with
|
|
223
|
+
`Errno::EPERM` and the host directory is never touched. Symlinks inside a
|
|
224
|
+
mounted directory cannot escape it (wasmtime caps path resolution at the
|
|
225
|
+
preopen root), and guest code cannot create symlinks at all.
|
|
226
|
+
- Mounted content can also be **written by the guest** in `mount_rw` mounts —
|
|
227
|
+
treat a writable mount as part of the guest's blast radius.
|
|
228
|
+
|
|
154
229
|
### Configuration
|
|
155
230
|
|
|
156
231
|
`SecurityBox::Configuration` is immutable; use `.build` to create and `#with` to derive:
|
|
@@ -158,12 +233,14 @@ result.error["message"] # => "boom"
|
|
|
158
233
|
```ruby
|
|
159
234
|
config = SecurityBox::Configuration.build(
|
|
160
235
|
fuel: 10_000_000_000, # deterministic CPU budget
|
|
236
|
+
fuel_ms: nil, # or a millisecond-based budget (mutually exclusive with fuel)
|
|
161
237
|
timeout_ms: 2_000, # wall-clock limit (epoch interruption)
|
|
162
238
|
memory_size: 512 * 1024 * 1024, # wasm linear memory limit
|
|
163
239
|
stdout_limit: 1 << 20, # stdout capture capacity in bytes
|
|
164
240
|
stderr_limit: 1 << 16, # stderr capture capacity in bytes
|
|
165
241
|
epoch_interval_ms: 25, # epoch timer granularity
|
|
166
|
-
env: { "LANG" => "C" }
|
|
242
|
+
env: { "LANG" => "C" }, # guest environment (empty by default)
|
|
243
|
+
mounts: [] # host-folder mounts (see "Folder mounts")
|
|
167
244
|
)
|
|
168
245
|
|
|
169
246
|
lean = config.with(timeout_ms: 500, fuel: 5_000_000)
|
|
@@ -175,11 +252,11 @@ sandbox = SecurityBox::Sandbox.new(lean)
|
|
|
175
252
|
| Status | Meaning |
|
|
176
253
|
|---|---|
|
|
177
254
|
| `:ok` | guest code ran and returned a value |
|
|
178
|
-
| `:error` | guest code raised an exception (see `#error`) |
|
|
179
|
-
| `:timeout` | interrupted by the epoch deadline (wall clock) |
|
|
255
|
+
| `:error` | guest code raised an exception (see `#error`; includes `backtrace`) |
|
|
256
|
+
| `:timeout` | interrupted by the epoch deadline (wall clock); `fuel_used` is `nil` (epoch traps restore fuel to the checkpoint) |
|
|
180
257
|
| `:fuel_exhausted` | CPU budget exhausted |
|
|
181
|
-
| `:memory_limit` | exceeded the store `memory_size` |
|
|
182
|
-
| `:sandbox_error` | sandbox failure (unexpected trap, missing/invalid envelope) |
|
|
258
|
+
| `:memory_limit` | exceeded the store `memory_size` (wasm trap or guest `NoMemoryError`) |
|
|
259
|
+
| `:sandbox_error` | sandbox failure (unexpected trap, missing/invalid envelope, `memory_size` below the image's minimum) |
|
|
183
260
|
|
|
184
261
|
Values are JSON-serialized; non-serializable objects are returned as their `inspect`
|
|
185
262
|
string.
|
|
@@ -190,6 +267,8 @@ string.
|
|
|
190
267
|
|---|---|
|
|
191
268
|
| `File.write("/etc/passwd", ...)` | `Errno::ENOENT` (guest does not see the host FS) |
|
|
192
269
|
| `Dir["/*"]` | `[]` (empty root) |
|
|
270
|
+
| `File.write` into a read-only mount | `Errno::EPERM` (wasmtime-enforced; host dir untouched) |
|
|
271
|
+
| symlink escape from a mounted directory | `Errno::EPERM` (path resolution capped at the preopen root) |
|
|
193
272
|
| `system("ls")` / backticks / `IO.popen` / `Process.spawn` | `SecurityError` (hardening prelude) |
|
|
194
273
|
| `Kernel#open(...)` | `SecurityError` (pipe form; use `File.open`) |
|
|
195
274
|
| `fork` | `NotImplementedError` |
|
|
@@ -202,9 +281,74 @@ string.
|
|
|
202
281
|
Notes:
|
|
203
282
|
|
|
204
283
|
- The guest Ruby version is the one from ruby.wasm (4.0), not necessarily the host's.
|
|
205
|
-
-
|
|
206
|
-
|
|
207
|
-
|
|
284
|
+
- Mounts widen the trust surface: a mounted folder's content is fully readable by
|
|
285
|
+
guest code (see "Folder mounts" above). Read-only by default; nothing outside
|
|
286
|
+
`/work` is writable without an explicit `mount_rw`.
|
|
287
|
+
- Concurrency: `invoke` holds the GVL, so executions on threads serialize — use
|
|
288
|
+
`RactorPool` (below) for parallelism inside one process, or scale horizontally with
|
|
289
|
+
multiple processes (e.g., Puma workers). A stuck guest still can't hang the process
|
|
290
|
+
thanks to the epoch deadline.
|
|
291
|
+
- Ractor: wasmtime `Engine`/`Module` are Ractor-shareable and Ractors run wasm in
|
|
292
|
+
parallel — measured ≈1.9x wall-time speedup at 4 workers on 6 cores through
|
|
293
|
+
`RactorPool` (see `docs/plan/stages/stage_3.md` and `stage_4.md`).
|
|
294
|
+
- Memory: the packed image declares a 1528-page (~95.5 MiB) minimum; `memory_size`
|
|
295
|
+
below that fails instantiation (reported as `:sandbox_error`). Practical minimum is
|
|
296
|
+
~128–144MB for small workloads; the 512MB default leaves comfortable headroom.
|
|
297
|
+
- Fuel budgeting: compute workloads burn ~4–8e9 fuel/s (tight loops up to ~8.4e9/s)
|
|
298
|
+
and every eval costs ~1e9 fuel for boot — see the calibration table in
|
|
299
|
+
`docs/plan/stages/stage_3.md`. Consequently `fuel` below ~1e9 cannot even boot, and
|
|
300
|
+
`timeout_ms` below ~300ms times out during the guest boot (~500ms is a practical
|
|
301
|
+
floor). Prefer thinking in milliseconds? Use `fuel_ms` (below).
|
|
302
|
+
|
|
303
|
+
## Concurrency
|
|
304
|
+
|
|
305
|
+
Two pools are available, with honestly different guarantees (measured in stage 4 —
|
|
306
|
+
`invoke` holds the GVL, so a long-lived `:worker` instance is impossible on the current
|
|
307
|
+
wasmtime-rb; one wasm boot per eval stays on the hot path):
|
|
308
|
+
|
|
309
|
+
### `Pool` — bounded concurrency on threads
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
pool = SecurityBox.pool(:lean, size: 4)
|
|
313
|
+
pool.eval("40 + 2").value # => 42
|
|
314
|
+
pool.checkout { |sandbox| sandbox.eval("1 + 1") }
|
|
315
|
+
pool.metrics # => {size:, created:, evals:, total_ms:, avg_ms:}
|
|
316
|
+
pool.shutdown
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Caps concurrent evals at `size` and reuses sandbox objects, but evals serialize on the
|
|
320
|
+
GVL — one at a time.
|
|
321
|
+
|
|
322
|
+
### `RactorPool` — real parallelism
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
pool = SecurityBox.ractor_pool(:lean, size: 4)
|
|
326
|
+
pool.eval("40 + 2").value # => 42
|
|
327
|
+
pool.shutdown
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Worker Ractors share one Engine + compiled Module and run wasm truly in parallel
|
|
331
|
+
(~1.9x wall-time speedup at 4 workers on 6 cores; measured ≈0.53 parallel/serial wall
|
|
332
|
+
ratio across 1M–4M-iteration workloads). Per-call overrides, user errors, timeouts and
|
|
333
|
+
fuel exhaustion behave exactly like `Sandbox#eval`, and the worker survives traps and
|
|
334
|
+
keeps serving. Costs one module deserialize at creation (~0.5s via the disk cache);
|
|
335
|
+
each eval still pays the ~240ms guest boot.
|
|
336
|
+
|
|
337
|
+
### `fuel_ms` — rate-based fuel budgeting
|
|
338
|
+
|
|
339
|
+
Instead of counting raw fuel, budget approximate wall-time of compute:
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
config = SecurityBox::Configuration.build(fuel_ms: 100) # ≈100ms of compute + boot allowance
|
|
343
|
+
SecurityBox.spawn(config).eval("i = 0; while i < 5_000_000; i += 1; end").status
|
|
344
|
+
# => :fuel_exhausted (5M tight iterations ≈ 2.4e9 fuel > the 100ms budget)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
`fuel_ms` converts to fuel with the conservative stage-3 calibration rate
|
|
348
|
+
(4e6 fuel/ms ≈ 4e9 fuel/s) plus a ~1e9 boot allowance (`Configuration#effective_fuel`).
|
|
349
|
+
The epoch `timeout_ms` remains the mandatory wall-clock backstop. `fuel` and `fuel_ms`
|
|
350
|
+
are mutually exclusive in the builder DSL and in `#with` overrides; a `fuel_ms`
|
|
351
|
+
configuration can be switched back with `with(fuel_ms: nil, fuel: ...)`.
|
|
208
352
|
|
|
209
353
|
## Running the tests
|
|
210
354
|
|
|
@@ -240,3 +384,9 @@ bundle exec ruby bin/spike.rb
|
|
|
240
384
|
- `docs/plan/stages/stage_1.md` — stage 1 findings and measured numbers
|
|
241
385
|
- `docs/plan/stages/stage_2.md` — stage 2 findings (hardening prelude, result-channel
|
|
242
386
|
integrity, compiled-module disk cache)
|
|
387
|
+
- `docs/plan/stages/stage_3.md` — stage 3 findings (named profiles, Ractor
|
|
388
|
+
parallelism, memory floor, fuel calibration, backtrace)
|
|
389
|
+
- `docs/plan/stages/stage_4.md` — stage 4 findings (worker-mode feasibility, pools,
|
|
390
|
+
`fuel_ms`)
|
|
391
|
+
- `docs/plan/stages/stage_5.md` — stage 5 findings (folder mounts: read-only
|
|
392
|
+
enforcement, reserved-path collisions, symlink escapes, mount cost)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
[diffend] Oversized file quarantined before diffing.
|
|
2
2
|
name: data/lib/security_box/assets/security_box.wasm
|
|
3
|
-
size:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 115877509 bytes
|
|
4
|
+
sha256: d7af08b7b11f5ac8bf9b4b8e9cb2ac6ca3e5c977f0dcdfea3551627247589a70
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
3
6
|
module SecurityBox
|
|
4
7
|
# Immutable sandbox configuration. Use .build to create and #with to derive.
|
|
5
8
|
class Configuration
|
|
@@ -10,35 +13,70 @@ module SecurityBox
|
|
|
10
13
|
DEFAULTS = {
|
|
11
14
|
image_path: nil, # resolved dynamically (project's build/security_box.wasm)
|
|
12
15
|
fuel: 10_000_000_000,
|
|
16
|
+
fuel_ms: nil, # rate-based fuel ergonomics (see FUEL_PER_MS); nil = use :fuel
|
|
13
17
|
timeout_ms: 2_000,
|
|
14
18
|
memory_size: 512 * 1024 * 1024,
|
|
15
19
|
stdout_limit: 1 << 20,
|
|
16
20
|
stderr_limit: 1 << 16,
|
|
17
21
|
epoch_interval_ms: 25,
|
|
18
|
-
env: {}.freeze
|
|
22
|
+
env: {}.freeze,
|
|
23
|
+
mounts: [].freeze
|
|
19
24
|
}.freeze
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
# Fuel-per-ms conversion for #fuel_ms, from the stage-3 calibration table
|
|
27
|
+
# (docs/plan/stages/stage_3.md): compute workloads burn 3.2e9–8.4e9 fuel/s;
|
|
28
|
+
# 4e9/s is the conservative floor. The guest boot baseline (~9.1e8 fuel)
|
|
29
|
+
# is added on top so the budget covers boot even for trivial evals.
|
|
30
|
+
FUEL_PER_MS = 4_000_000
|
|
31
|
+
BOOT_FUEL_ALLOWANCE = 1_000_000_000
|
|
32
|
+
|
|
33
|
+
attr_reader :image_path, :fuel, :fuel_ms, :timeout_ms, :memory_size,
|
|
34
|
+
:stdout_limit, :stderr_limit, :epoch_interval_ms, :env, :mounts
|
|
23
35
|
|
|
24
36
|
def self.build(**options)
|
|
25
37
|
new(**DEFAULTS.merge(options)).freeze
|
|
26
38
|
end
|
|
27
39
|
|
|
28
|
-
def initialize(image_path: nil, fuel:, timeout_ms:, memory_size:,
|
|
29
|
-
stdout_limit:, stderr_limit:, epoch_interval_ms:, env:)
|
|
40
|
+
def initialize(image_path: nil, fuel:, fuel_ms:, timeout_ms:, memory_size:,
|
|
41
|
+
stdout_limit:, stderr_limit:, epoch_interval_ms:, env:, mounts:)
|
|
30
42
|
@image_path = image_path || default_image_path
|
|
31
43
|
@fuel = Integer(fuel)
|
|
44
|
+
@fuel_ms = fuel_ms.nil? ? nil : Integer(fuel_ms)
|
|
32
45
|
@timeout_ms = Integer(timeout_ms)
|
|
33
46
|
@memory_size = Integer(memory_size)
|
|
34
47
|
@stdout_limit = Integer(stdout_limit)
|
|
35
48
|
@stderr_limit = Integer(stderr_limit)
|
|
36
49
|
@epoch_interval_ms = Integer(epoch_interval_ms)
|
|
37
50
|
@env = env.freeze
|
|
51
|
+
@mounts = Mounts.normalize(mounts)
|
|
38
52
|
freeze
|
|
39
53
|
end
|
|
40
54
|
|
|
55
|
+
# The fuel budget actually applied to each evaluation. When :fuel_ms is
|
|
56
|
+
# set it takes precedence: an approximate millisecond-based budget
|
|
57
|
+
# (fuel_ms × FUEL_PER_MS + boot allowance) instead of a raw fuel count.
|
|
58
|
+
# The epoch timeout remains the mandatory wall-clock backstop either way.
|
|
59
|
+
def effective_fuel
|
|
60
|
+
return fuel unless fuel_ms
|
|
61
|
+
|
|
62
|
+
fuel_ms * FUEL_PER_MS + BOOT_FUEL_ALLOWANCE
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Derives a copy with `changes` applied (never mutates the receiver).
|
|
66
|
+
# Passing both :fuel and a non-nil :fuel_ms is ambiguous and rejected;
|
|
67
|
+
# overriding :fuel on a configuration that uses :fuel_ms is rejected too
|
|
68
|
+
# (pass fuel_ms: nil first to switch to a raw fuel budget).
|
|
41
69
|
def with(**changes)
|
|
70
|
+
if changes.key?(:fuel_ms)
|
|
71
|
+
if changes[:fuel_ms] && changes.key?(:fuel)
|
|
72
|
+
raise InvalidConfiguration,
|
|
73
|
+
"fuel and fuel_ms are mutually exclusive overrides"
|
|
74
|
+
end
|
|
75
|
+
elsif changes.key?(:fuel) && @fuel_ms
|
|
76
|
+
raise InvalidConfiguration,
|
|
77
|
+
"configuration uses fuel_ms (#{fuel_ms}); override fuel_ms or clear it with fuel_ms: nil instead of fuel"
|
|
78
|
+
end
|
|
79
|
+
|
|
42
80
|
self.class.build(**to_h.merge(changes))
|
|
43
81
|
end
|
|
44
82
|
|
|
@@ -46,15 +84,112 @@ module SecurityBox
|
|
|
46
84
|
{
|
|
47
85
|
image_path: @image_path,
|
|
48
86
|
fuel: @fuel,
|
|
87
|
+
fuel_ms: @fuel_ms,
|
|
49
88
|
timeout_ms: @timeout_ms,
|
|
50
89
|
memory_size: @memory_size,
|
|
51
90
|
stdout_limit: @stdout_limit,
|
|
52
91
|
stderr_limit: @stderr_limit,
|
|
53
92
|
epoch_interval_ms: @epoch_interval_ms,
|
|
54
|
-
env: @env
|
|
93
|
+
env: @env,
|
|
94
|
+
mounts: @mounts
|
|
55
95
|
}
|
|
56
96
|
end
|
|
57
97
|
|
|
98
|
+
# Stable identity of the configuration values (SHA-256 of the normalized
|
|
99
|
+
# hash). Two configurations with equal settings — regardless of how they
|
|
100
|
+
# were built — share the same fingerprint; any #with change produces a
|
|
101
|
+
# different one. Used to key profiles and, later, cached artifacts.
|
|
102
|
+
def fingerprint
|
|
103
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def canonical
|
|
107
|
+
to_h.merge(env: @env.sort.to_h)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Mutable collector for the register DSL. Setter names match the
|
|
111
|
+
# Configuration options (no `=`, e.g. `c.fuel 100`); only changed values
|
|
112
|
+
# are collected and merged over the base profile.
|
|
113
|
+
class Builder
|
|
114
|
+
def initialize
|
|
115
|
+
@changes = {}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def changes
|
|
119
|
+
@changes
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def image_path(value)
|
|
123
|
+
@changes[:image_path] = value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def fuel(value)
|
|
127
|
+
raise InvalidConfiguration, "fuel and fuel_ms are mutually exclusive" if @changes.key?(:fuel_ms)
|
|
128
|
+
|
|
129
|
+
@changes[:fuel] = value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Rate-based fuel ergonomics: sets fuel from an approximate millisecond
|
|
133
|
+
# budget (see Configuration#effective_fuel). Mutually exclusive with
|
|
134
|
+
# an explicit :fuel.
|
|
135
|
+
def fuel_ms(value)
|
|
136
|
+
raise InvalidConfiguration, "fuel and fuel_ms are mutually exclusive" if @changes.key?(:fuel)
|
|
137
|
+
|
|
138
|
+
@changes[:fuel_ms] = value
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def timeout_ms(value)
|
|
142
|
+
@changes[:timeout_ms] = value
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def memory_size(value)
|
|
146
|
+
@changes[:memory_size] = value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def stdout_limit(value)
|
|
150
|
+
@changes[:stdout_limit] = value
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def stderr_limit(value)
|
|
154
|
+
@changes[:stderr_limit] = value
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def epoch_interval_ms(value)
|
|
158
|
+
@changes[:epoch_interval_ms] = value
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Replaces the guest environment (it is not merged with the base).
|
|
162
|
+
def env(value)
|
|
163
|
+
@changes[:env] = value
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Mounts a host directory into the guest, read-only by default:
|
|
167
|
+
# c.mount "host/path" => "/data"
|
|
168
|
+
# Each call appends one mount; use #mount_rw for a writable mount.
|
|
169
|
+
def mount(mapping)
|
|
170
|
+
add_mount(mapping, :read_only)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Opt-in writable mount — the only way to expose a writable host path
|
|
174
|
+
# besides the sandbox-private /work tmpdir:
|
|
175
|
+
# c.mount_rw "host/path" => "/data"
|
|
176
|
+
def mount_rw(mapping)
|
|
177
|
+
add_mount(mapping, :read_write)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def add_mount(mapping, mode)
|
|
183
|
+
unless mapping.is_a?(Hash) && mapping.size == 1
|
|
184
|
+
raise InvalidConfiguration,
|
|
185
|
+
'mount expects exactly one pair: c.mount "host/path" => "/data"'
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
host, guest = mapping.first
|
|
189
|
+
@changes[:mounts] = Array(@changes[:mounts]) + [{ host: host, guest: guest, mode: mode }]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
58
193
|
private
|
|
59
194
|
|
|
60
195
|
# Resolution order (first existing path wins):
|
|
@@ -74,4 +209,117 @@ module SecurityBox
|
|
|
74
209
|
"or pass image_path in the configuration."
|
|
75
210
|
end
|
|
76
211
|
end
|
|
212
|
+
|
|
213
|
+
# Validated collection of host-folder mounts (stage 5). A mount is a frozen
|
|
214
|
+
# {host:, guest:, mode:} hash; `mounts` in a Configuration is a frozen array
|
|
215
|
+
# of these, so the value survives #with, #fingerprint and Ractor ports.
|
|
216
|
+
#
|
|
217
|
+
# Validation (InvalidConfiguration on any violation):
|
|
218
|
+
# - mode is :read_only or :read_write (wasmtime silently accepts unknown
|
|
219
|
+
# symbols, so the mode is checked here, never trusted to the runtime)
|
|
220
|
+
# - host: non-empty string; relative paths are expanded against Dir.pwd
|
|
221
|
+
# (existence/directory checks are per-eval, in EvalRun — dirs can vanish)
|
|
222
|
+
# - guest: absolute, normalized path (no "..", no trailing slash), not "/"
|
|
223
|
+
# - guest paths must not overlap /work, /usr or /src (exact or nested):
|
|
224
|
+
# a collision with the embedded VFS is silently shadowed (stage-5 spike:
|
|
225
|
+
# the mount content is invisible), a mount inside /usr even breaks the
|
|
226
|
+
# guest boot, and inside /work it would create a read-only subtree
|
|
227
|
+
# - duplicate guest paths are rejected (wasmtime's last-mount-wins is
|
|
228
|
+
# silently surprising)
|
|
229
|
+
# - at most MAX_MOUNTS mounts
|
|
230
|
+
module Mounts
|
|
231
|
+
MODES = %i[read_only read_write].freeze
|
|
232
|
+
RESERVED_GUEST_PATHS = %w[/work /usr /src].freeze
|
|
233
|
+
MAX_MOUNTS = 16
|
|
234
|
+
|
|
235
|
+
class << self
|
|
236
|
+
# Validates + normalizes `raw` (an array of {host:, guest:, mode:} hashes
|
|
237
|
+
# or nil) and returns a frozen array of frozen, normalized hashes.
|
|
238
|
+
def normalize(raw)
|
|
239
|
+
return [].freeze if raw.nil?
|
|
240
|
+
|
|
241
|
+
raise InvalidConfiguration,
|
|
242
|
+
"mounts must be an Array of {host:, guest:, mode:} hashes, got #{raw.class}" unless raw.is_a?(Array)
|
|
243
|
+
|
|
244
|
+
mounts = raw.map { |entry| normalize_entry(entry) }
|
|
245
|
+
check_duplicates(mounts)
|
|
246
|
+
check_reserved(mounts)
|
|
247
|
+
check_count(mounts)
|
|
248
|
+
mounts.freeze
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private
|
|
252
|
+
|
|
253
|
+
def normalize_entry(entry)
|
|
254
|
+
unless entry.is_a?(Hash) && entry.keys.sort == %i[guest host mode]
|
|
255
|
+
raise InvalidConfiguration,
|
|
256
|
+
"each mount must be a Hash with exactly :host, :guest and :mode keys, got #{entry.inspect}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
mode = entry[:mode]
|
|
260
|
+
unless MODES.include?(mode)
|
|
261
|
+
raise InvalidConfiguration,
|
|
262
|
+
"mount mode must be one of #{MODES.map(&:inspect).join(' or ')}, got #{mode.inspect}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
{ host: normalize_host(entry[:host]),
|
|
266
|
+
guest: normalize_guest(entry[:guest]),
|
|
267
|
+
mode: mode }.freeze
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def normalize_host(host)
|
|
271
|
+
unless host.is_a?(String) && !host.empty?
|
|
272
|
+
raise InvalidConfiguration, "mount host path must be a non-empty String, got #{host.inspect}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
File.expand_path(host).freeze
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def normalize_guest(guest)
|
|
279
|
+
unless guest.is_a?(String) && !guest.empty?
|
|
280
|
+
raise InvalidConfiguration, "mount guest path must be a non-empty String, got #{guest.inspect}"
|
|
281
|
+
end
|
|
282
|
+
unless guest.start_with?("/")
|
|
283
|
+
raise InvalidConfiguration, "mount guest path must be absolute, got #{guest.inspect}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
guest = guest.chomp("/")
|
|
287
|
+
if guest.empty? || File.expand_path(guest, "/") != guest
|
|
288
|
+
raise InvalidConfiguration,
|
|
289
|
+
"mount guest path must be a normalized absolute path (no '..', '.' or trailing '/'), got #{guest.inspect}"
|
|
290
|
+
end
|
|
291
|
+
raise InvalidConfiguration, "mount guest path contains a NUL byte" if guest.include?("\0")
|
|
292
|
+
|
|
293
|
+
guest.freeze
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def check_duplicates(mounts)
|
|
297
|
+
guests = mounts.map { |mount| mount[:guest] }
|
|
298
|
+
duplicate = guests.find { |guest| guests.count(guest) > 1 }
|
|
299
|
+
return unless duplicate
|
|
300
|
+
|
|
301
|
+
raise InvalidConfiguration, "duplicate guest mount path #{duplicate.inspect}"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def check_reserved(mounts)
|
|
305
|
+
mounts.each do |mount|
|
|
306
|
+
guest = mount[:guest]
|
|
307
|
+
conflict = RESERVED_GUEST_PATHS.find do |reserved|
|
|
308
|
+
guest == reserved || guest.start_with?("#{reserved}/") || reserved.start_with?("#{guest}/")
|
|
309
|
+
end
|
|
310
|
+
next unless conflict
|
|
311
|
+
|
|
312
|
+
raise InvalidConfiguration,
|
|
313
|
+
"mount guest path #{guest.inspect} overlaps the reserved path #{conflict.inspect}"
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def check_count(mounts)
|
|
318
|
+
return if mounts.size <= MAX_MOUNTS
|
|
319
|
+
|
|
320
|
+
raise InvalidConfiguration,
|
|
321
|
+
"too many mounts (#{mounts.size}); the limit is #{MAX_MOUNTS}"
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
77
325
|
end
|