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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdcc33a7689dd5d68b5e9ca6c8cd36a83d8da9aa5a44c9b29a5994bd991c5d9e
4
- data.tar.gz: a2f4fbf283527b9fcf484a9dc06adb3a5d34d2a1c97f355412a880c5204c9283
3
+ metadata.gz: 6c7d1c1387ec5b1dffdb9781552e489d4b3d45e7066fdd3c7932e6ba39a8432f
4
+ data.tar.gz: 7770652a22e98d5067c851b5a1c790896b986b6dc7a68ca03ea4a0c3c4bb2110
5
5
  SHA512:
6
- metadata.gz: 86158b7097c7e53602f80949350d2f2aebaf9f672400f908127fb995fbf829dc5a16928873231d1c2b8539660a0f7c5a0c2f600f40a225558afaf39f83a9ee77
7
- data.tar.gz: e08ee261ac36c91b3fe6761a01e655410d60c3023094e852c7e1fa9ddc8728efe646366bd840c6863264b1f41826874794b52e6a1125dddbca8f662ae3f76fe4
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('raise ArgumentError, "boom"')
148
+ result = SecurityBox.eval("def boom; raise ArgumentError, 'boom'; end; boom")
148
149
 
149
- result.status # => :error
150
- result.error["class"] # => "ArgumentError"
151
- result.error["message"] # => "boom"
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" } # guest environment (empty by default)
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
- - Concurrency: `invoke` holds the GVL, so executions serialize per host process. Scale
206
- horizontally with multiple processes (e.g., Puma workers); a stuck guest still can't
207
- hang the process thanks to the epoch deadline.
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: 115876739 bytes
4
- sha256: 335531ec6194a2bae2f94f6d0d24188c3f2f15b00cd1bda4a1e2460bb033e1e0
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
- attr_reader :image_path, :fuel, :timeout_ms, :memory_size,
22
- :stdout_limit, :stderr_limit, :epoch_interval_ms, :env
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