security_box 0.3.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 +78 -0
- data/README.md +115 -9
- data/lib/security_box/configuration.rb +193 -6
- data/lib/security_box/errors.rb +2 -0
- data/lib/security_box/eval_run.rb +191 -0
- data/lib/security_box/pool.rb +138 -0
- data/lib/security_box/ractor_pool.rb +199 -0
- data/lib/security_box/registry.rb +6 -1
- data/lib/security_box/result.rb +7 -0
- data/lib/security_box/runtime.rb +17 -0
- data/lib/security_box/sandbox.rb +22 -151
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +18 -0
- metadata +4 -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,84 @@ 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
|
+
|
|
8
86
|
## [0.3.0] - 2026-09-14
|
|
9
87
|
|
|
10
88
|
### 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
|
|
@@ -182,6 +183,49 @@ Profiles are immutable: duplicate names and unknown names raise
|
|
|
182
183
|
`SecurityBox::Configuration#fingerprint` gives every configuration a stable
|
|
183
184
|
identity (equal settings → equal fingerprint), used to share runtime artifacts.
|
|
184
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
|
+
|
|
185
229
|
### Configuration
|
|
186
230
|
|
|
187
231
|
`SecurityBox::Configuration` is immutable; use `.build` to create and `#with` to derive:
|
|
@@ -189,12 +233,14 @@ identity (equal settings → equal fingerprint), used to share runtime artifacts
|
|
|
189
233
|
```ruby
|
|
190
234
|
config = SecurityBox::Configuration.build(
|
|
191
235
|
fuel: 10_000_000_000, # deterministic CPU budget
|
|
236
|
+
fuel_ms: nil, # or a millisecond-based budget (mutually exclusive with fuel)
|
|
192
237
|
timeout_ms: 2_000, # wall-clock limit (epoch interruption)
|
|
193
238
|
memory_size: 512 * 1024 * 1024, # wasm linear memory limit
|
|
194
239
|
stdout_limit: 1 << 20, # stdout capture capacity in bytes
|
|
195
240
|
stderr_limit: 1 << 16, # stderr capture capacity in bytes
|
|
196
241
|
epoch_interval_ms: 25, # epoch timer granularity
|
|
197
|
-
env: { "LANG" => "C" }
|
|
242
|
+
env: { "LANG" => "C" }, # guest environment (empty by default)
|
|
243
|
+
mounts: [] # host-folder mounts (see "Folder mounts")
|
|
198
244
|
)
|
|
199
245
|
|
|
200
246
|
lean = config.with(timeout_ms: 500, fuel: 5_000_000)
|
|
@@ -221,6 +267,8 @@ string.
|
|
|
221
267
|
|---|---|
|
|
222
268
|
| `File.write("/etc/passwd", ...)` | `Errno::ENOENT` (guest does not see the host FS) |
|
|
223
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) |
|
|
224
272
|
| `system("ls")` / backticks / `IO.popen` / `Process.spawn` | `SecurityError` (hardening prelude) |
|
|
225
273
|
| `Kernel#open(...)` | `SecurityError` (pipe form; use `File.open`) |
|
|
226
274
|
| `fork` | `NotImplementedError` |
|
|
@@ -233,12 +281,16 @@ string.
|
|
|
233
281
|
Notes:
|
|
234
282
|
|
|
235
283
|
- The guest Ruby version is the one from ruby.wasm (4.0), not necessarily the host's.
|
|
236
|
-
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
-
|
|
240
|
-
|
|
241
|
-
|
|
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`).
|
|
242
294
|
- Memory: the packed image declares a 1528-page (~95.5 MiB) minimum; `memory_size`
|
|
243
295
|
below that fails instantiation (reported as `:sandbox_error`). Practical minimum is
|
|
244
296
|
~128–144MB for small workloads; the 512MB default leaves comfortable headroom.
|
|
@@ -246,7 +298,57 @@ Notes:
|
|
|
246
298
|
and every eval costs ~1e9 fuel for boot — see the calibration table in
|
|
247
299
|
`docs/plan/stages/stage_3.md`. Consequently `fuel` below ~1e9 cannot even boot, and
|
|
248
300
|
`timeout_ms` below ~300ms times out during the guest boot (~500ms is a practical
|
|
249
|
-
floor).
|
|
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: ...)`.
|
|
250
352
|
|
|
251
353
|
## Running the tests
|
|
252
354
|
|
|
@@ -284,3 +386,7 @@ bundle exec ruby bin/spike.rb
|
|
|
284
386
|
integrity, compiled-module disk cache)
|
|
285
387
|
- `docs/plan/stages/stage_3.md` — stage 3 findings (named profiles, Ractor
|
|
286
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)
|
|
@@ -13,35 +13,70 @@ module SecurityBox
|
|
|
13
13
|
DEFAULTS = {
|
|
14
14
|
image_path: nil, # resolved dynamically (project's build/security_box.wasm)
|
|
15
15
|
fuel: 10_000_000_000,
|
|
16
|
+
fuel_ms: nil, # rate-based fuel ergonomics (see FUEL_PER_MS); nil = use :fuel
|
|
16
17
|
timeout_ms: 2_000,
|
|
17
18
|
memory_size: 512 * 1024 * 1024,
|
|
18
19
|
stdout_limit: 1 << 20,
|
|
19
20
|
stderr_limit: 1 << 16,
|
|
20
21
|
epoch_interval_ms: 25,
|
|
21
|
-
env: {}.freeze
|
|
22
|
+
env: {}.freeze,
|
|
23
|
+
mounts: [].freeze
|
|
22
24
|
}.freeze
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
26
35
|
|
|
27
36
|
def self.build(**options)
|
|
28
37
|
new(**DEFAULTS.merge(options)).freeze
|
|
29
38
|
end
|
|
30
39
|
|
|
31
|
-
def initialize(image_path: nil, fuel:, timeout_ms:, memory_size:,
|
|
32
|
-
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:)
|
|
33
42
|
@image_path = image_path || default_image_path
|
|
34
43
|
@fuel = Integer(fuel)
|
|
44
|
+
@fuel_ms = fuel_ms.nil? ? nil : Integer(fuel_ms)
|
|
35
45
|
@timeout_ms = Integer(timeout_ms)
|
|
36
46
|
@memory_size = Integer(memory_size)
|
|
37
47
|
@stdout_limit = Integer(stdout_limit)
|
|
38
48
|
@stderr_limit = Integer(stderr_limit)
|
|
39
49
|
@epoch_interval_ms = Integer(epoch_interval_ms)
|
|
40
50
|
@env = env.freeze
|
|
51
|
+
@mounts = Mounts.normalize(mounts)
|
|
41
52
|
freeze
|
|
42
53
|
end
|
|
43
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).
|
|
44
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
|
+
|
|
45
80
|
self.class.build(**to_h.merge(changes))
|
|
46
81
|
end
|
|
47
82
|
|
|
@@ -49,12 +84,14 @@ module SecurityBox
|
|
|
49
84
|
{
|
|
50
85
|
image_path: @image_path,
|
|
51
86
|
fuel: @fuel,
|
|
87
|
+
fuel_ms: @fuel_ms,
|
|
52
88
|
timeout_ms: @timeout_ms,
|
|
53
89
|
memory_size: @memory_size,
|
|
54
90
|
stdout_limit: @stdout_limit,
|
|
55
91
|
stderr_limit: @stderr_limit,
|
|
56
92
|
epoch_interval_ms: @epoch_interval_ms,
|
|
57
|
-
env: @env
|
|
93
|
+
env: @env,
|
|
94
|
+
mounts: @mounts
|
|
58
95
|
}
|
|
59
96
|
end
|
|
60
97
|
|
|
@@ -87,9 +124,20 @@ module SecurityBox
|
|
|
87
124
|
end
|
|
88
125
|
|
|
89
126
|
def fuel(value)
|
|
127
|
+
raise InvalidConfiguration, "fuel and fuel_ms are mutually exclusive" if @changes.key?(:fuel_ms)
|
|
128
|
+
|
|
90
129
|
@changes[:fuel] = value
|
|
91
130
|
end
|
|
92
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
|
+
|
|
93
141
|
def timeout_ms(value)
|
|
94
142
|
@changes[:timeout_ms] = value
|
|
95
143
|
end
|
|
@@ -114,6 +162,32 @@ module SecurityBox
|
|
|
114
162
|
def env(value)
|
|
115
163
|
@changes[:env] = value
|
|
116
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
|
|
117
191
|
end
|
|
118
192
|
|
|
119
193
|
private
|
|
@@ -135,4 +209,117 @@ module SecurityBox
|
|
|
135
209
|
"or pass image_path in the configuration."
|
|
136
210
|
end
|
|
137
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
|
|
138
325
|
end
|