security_box 0.3.0 → 0.6.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: 9949a172b6bf1ddf1eddd9756c8dcbad273d8f2409669304227bb2f981c9fef6
4
- data.tar.gz: 499c948e3fd57c221f8aef7f8ad6dd63757b36e9d5a4a62d8c8a70823908f86d
3
+ metadata.gz: 999342421a33fdaba7863952bafad78dc8e87bac98a1ec02269fb2903be9d414
4
+ data.tar.gz: b119d9d14854ba07b2f99bd1c5d8c8e2e25db4f5f50370d1ecea41491ee8b673
5
5
  SHA512:
6
- metadata.gz: 1cf51781bb108aa6849ae24bd1db5b812c7e0a7e94f0d1876eb1a860cabc4a6b00241fdc19b4b142b88da8bdf86ed6e9c5dc53009237f36706a4ff637fe96d5f
7
- data.tar.gz: 39dfd3af95bc8bfd2e3bd79ac57b118ee706b2f7da3aee98854aea832094b29342e751b2a41bf630c593fd07289ecf7ac041b1d416682924f276a29be4a7259b
6
+ metadata.gz: 20c96a86adc927a31e1abbe16a6a6cae1b49b015f7078917a0cd1fccfab61e3f5afbc7e2b0a1f71bf546aa21bd40db7d063fb138f542f3c1d2c14f968cd93e05
7
+ data.tar.gz: 8eddad9955d7801ce2ce93531ba92d581f1686f07288012501bc933d47034502dc3a7a4089f992340a2173ed2230e3698c1b5fdbb1b1e575465ccda0c53f3004
data/CHANGELOG.md CHANGED
@@ -5,6 +5,134 @@ 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.6.0] - 2026-09-21
9
+
10
+ ### Added
11
+
12
+ - **Host RPC for code mode (stage 6)**: guest code can call host-registered
13
+ handlers with a regular, blocking function call:
14
+ `SB.call("github.search", q: "x")`. The call invokes the wasm import
15
+ (`sb`/`call`, declared by the new `sb_rpc` C extension statically linked
16
+ into the image) and blocks while the host executes the handler — no
17
+ callbacks, no rounds, no replay: the code reads as plain sequential Ruby
18
+ to the LLM that generated it.
19
+ - `c.rpc "name" => handler` in the register DSL (and `rpcs:` in
20
+ `Configuration.build` / per-call `eval` overrides, replacing like `env:`).
21
+ Handlers receive the JSON-parsed request args (string keys) and must
22
+ return a JSON-serializable value (non-serializable results surface as
23
+ inspect strings). `SecurityBox::Rpcs` validates the set (non-empty unique
24
+ String names, `#call`-able handlers, at most 64); handlers are host-only
25
+ state — excluded from `#fingerprint` and unsupported on `RactorPool`
26
+ (Procs cannot cross a Ractor boundary; raises `InvalidConfiguration`).
27
+ - `Result#rpcs`: a frozen transcript of the calls
28
+ (`{"name", "args", "ok", "result"|"error"}`), for agent debugging.
29
+ - Sanitized failure mapping: a raising handler becomes a guest-rescuable
30
+ `SB::ToolError` (`SB::UnknownTool` for unregistered names, including the
31
+ "no handlers configured" case) carrying only `class` + `message` — no
32
+ backtrace, no host details. Unknown names never crash the host.
33
+ - Bridge limits: at most 1000 RPC calls per eval and 1 MiB per response
34
+ (`SecurityBox::GuestRpc::MAX_CALLS`/`RESULT_LIMIT`), enforced host-side.
35
+
36
+ ### Changed
37
+
38
+ - **Image build**: the image is now built from the pinned Ruby 4.0 source
39
+ with the guest gems of `lib/security_box/guest_ext` (Gemfile + `sb_rpc`)
40
+ statically linked (`rbwasm build` + `rbwasm pack`), replacing the packed
41
+ prebuilt release tarball. Size: 115MB → ~50MB; memory floor: ~95.5MiB →
42
+ ~36MiB (576 pages); first build downloads the Ruby source, wasi-sdk and
43
+ binaryen (cached in `build/`).
44
+ - Epoch timer: the engine's native `start_epoch_interval` (a Ruby timer
45
+ thread cannot run while `invoke` holds the GVL — stage-4 evidence
46
+ reconfirmed). With a working timer, `timeout_ms` measurably covers boot +
47
+ guest compute + RPC handler time (the guest traps at the first epoch
48
+ check after the deadline passes; the handler itself is not interrupted).
49
+
50
+ ### Notes
51
+
52
+ - The RPC import must be defined on every linker even when no handlers are
53
+ configured (the image declares the import); guest calls then receive a
54
+ clean, rescuable `SB::UnknownTool` instead of a failed instantiation.
55
+ - Guest boot fuel is ~0.9e9 (measured with the new image, consistent with
56
+ the stage-3 calibration).
57
+
58
+ ## [0.5.0] - 2026-09-20
59
+
60
+ ### Added
61
+
62
+ - Folder mounts (read-only by default): `c.mount "host/path" => "/data"` in the
63
+ register DSL (or `mounts:` in `Configuration.build`), with `c.mount_rw` for an
64
+ opt-in writable mount. Guest code can read a mounted host directory
65
+ (`File.read`, `Dir[]`, `File.open`); write attempts into a read-only mount fail
66
+ guest-side with `Errno::EPERM` and leave the host directory untouched.
67
+ - `Configuration#mounts`: a frozen array of frozen `{host:, guest:, mode:}` hashes.
68
+ Mounts are config values — they flow through `#with`, `#fingerprint` (equal
69
+ mounts → equal fingerprint) and RactorPool requests. Relative host paths are
70
+ expanded against `Dir.pwd` at DSL time.
71
+ - Mount validation (`SecurityBox::InvalidConfiguration` on violation): mode must be
72
+ `:read_only`/`:read_write` (wasmtime silently accepts unknown symbols, so the mode
73
+ is never trusted to the runtime); guest paths must be absolute and normalized
74
+ (no `..`, no trailing `/`, not `/`); guest paths must not overlap the reserved
75
+ `/work`, `/usr` or `/src` trees (exact or nested — a collision with the embedded
76
+ VFS is silently shadowed, a mount inside `/usr` breaks the guest boot, and inside
77
+ `/work` it would create a read-only subtree); duplicate guest paths are rejected
78
+ (wasmtime's last-mount-wins is silently surprising); at most 16 mounts.
79
+ - Per-eval host-path validation: a mount whose host directory vanished returns a
80
+ `:sandbox_error` Result with a `security_box:` note instead of raising
81
+ `Wasmtime::Error` out of `#eval` (the store creation now also sits inside the
82
+ eval's rescue, so wasmtime setup failures can never raise).
83
+
84
+ ### Changed
85
+
86
+ - `Sandbox#eval` (and pool equivalents) accept `mounts:` as a per-call override
87
+ (replaces the configuration's mounts, like `env:`).
88
+ - README: new "Folder mounts" section + updated isolation matrix and defense notes
89
+ (a mounted folder's content is fully readable by guest code).
90
+
91
+ ### Investigation (no change needed, documented)
92
+
93
+ - Q6 probe (stage 5): the embedded VFS is **not** guest-writable (`File.write`
94
+ into `/usr`, `/src` or `/` fails with `Errno::ENOENT`; deletes with `ENOTSUP`),
95
+ so no prelude File-write restriction is warranted; WASI remains the primary
96
+ enforcement. Symlink escapes from a mounted directory are blocked by wasmtime
97
+ (`Errno::EPERM`); the guest cannot even create symlinks. Measured mount cost:
98
+ none (warm-boot p50 ~260ms with 0/1/4/8 mounts). Evidence and probes in
99
+ `docs/plan/stages/stage_5.md` and `bin/spike_stage5_mounts.rb`.
100
+
101
+ ## [0.4.0] - 2026-09-15
102
+
103
+ ### Added
104
+
105
+ - `SecurityBox::Pool` (`SecurityBox.pool`): a bounded pool of `:oneshot` sandboxes —
106
+ hard cap on concurrent evals (`size:`), sandbox reuse, `checkout` block and metrics
107
+ (`created`/`evals`/`total_ms`/`avg_ms`). Evals serialize on the GVL (documented).
108
+ - `SecurityBox::RactorPool` (`SecurityBox.ractor_pool`): real parallelism — worker
109
+ Ractors sharing one Ractor-shareable Engine + compiled Module (stage-3 "Plan C").
110
+ Measured ≈0.52–0.53 parallel/serial wall ratio at 4 workers on 6 cores (~1.9x),
111
+ RSS ≈57MB; per-call overrides, statuses and trap recovery behave like
112
+ `Sandbox#eval`; a dead worker degrades to a `:sandbox_error` Result instead of a
113
+ hang.
114
+ - `Configuration#fuel_ms`: rate-based fuel budgeting (~4e6 fuel/ms from the stage-3
115
+ calibration table + ~1e9 boot allowance), exposed via `Configuration#effective_fuel`
116
+ and usable per call. `fuel` and `fuel_ms` are mutually exclusive in the builder DSL
117
+ and in `#with`; switch back with `with(fuel_ms: nil, fuel: ...)`.
118
+ - `docs/plan/stages/stage_4.md` with the worker-mode feasibility investigation.
119
+
120
+ ### Changed
121
+
122
+ - The eval core was extracted from `Sandbox` into `SecurityBox::EvalRun` (shared by
123
+ `Sandbox` and `RactorPool` workers); `Sandbox` is now a thin, thread-safe wrapper.
124
+ - README: the "Ractor note" roadmap item is delivered; concurrency guidance rewritten
125
+ around `Pool`/`RactorPool`.
126
+
127
+ ### Investigation (negative result, documented)
128
+
129
+ - `:worker` mode (long-lived instance amortizing the ~240ms boot) is infeasible on
130
+ wasmtime-rb 48 (sync WASI): `invoke` holds the GVL during compute *and* while the
131
+ guest is blocked on a WASI read; epoch deadlines never fire inside a blocking
132
+ syscall; and per-request limits would require mutating a live Store from another
133
+ thread (unsafe). Evidence and probes in `docs/plan/stages/stage_4.md` and
134
+ `bin/spike_stage4_worker.rb`.
135
+
8
136
  ## [0.3.0] - 2026-09-14
9
137
 
10
138
  ### Added
data/README.md CHANGED
@@ -15,9 +15,12 @@ tries to escape.
15
15
 
16
16
  ## How it works
17
17
 
18
- 1. A `ruby.wasm` image is packed with the Ruby runtime + stdlib + a small guest
19
- entrypoint (`lib/security_box/guest/main.rb`) plus a hardening prelude
20
- (`lib/security_box/guest/prelude.rb`).
18
+ 1. A `ruby.wasm` image is built from the pinned Ruby 4.0 source with the guest
19
+ gems of `lib/security_box/guest_ext` statically linked (`rbwasm build`),
20
+ then packed with the guest entrypoint (`lib/security_box/guest/main.rb`)
21
+ plus a hardening prelude (`lib/security_box/guest/prelude.rb`). The
22
+ `sb_rpc` gem declares the `sb`/`call` wasm import used by the host RPC
23
+ channel (see "Host RPC (code mode)" below).
21
24
  2. On every `#eval`, the host creates an exclusive tmpdir, writes the user code to it, and
22
25
  mounts it read-write as `/work` inside the sandbox. It also generates a per-eval
23
26
  random token and passes it to the guest via `SB_TOKEN`.
@@ -50,23 +53,24 @@ gem "security_box"
50
53
  ```
51
54
 
52
55
  The gem ships a prebuilt sandbox image (`lib/security_box/assets/security_box.wasm`,
53
- about 110MB), so no network access or build tools are needed at install or at
56
+ about 50MB), so no network access or build tools are needed at install or at
54
57
  runtime.
55
58
 
56
59
  ### Rebuilding the image (development only)
57
60
 
58
- If you change `lib/security_box/guest/*.rb` or bump the pinned ruby.wasm
59
- release, repack the sandbox image:
61
+ If you change `lib/security_box/guest/*.rb` or anything under
62
+ `lib/security_box/guest_ext/` (guest gems), rebuild the sandbox image:
60
63
 
61
64
  ```bash
62
65
  bundle install
63
66
  bundle exec rake security_box:build_image
64
67
  ```
65
68
 
66
- This downloads the pinned ruby.wasm release (`2.10.1`, see the `Rakefile`) and
67
- packs it with the guest script into `lib/security_box/assets/security_box.wasm`.
68
- The task skips repacking when the image is already fresh. The image is not
69
- committed to git.
69
+ The first build downloads the Ruby source tarball, wasi-sdk and binaryen into
70
+ `build/` (network required, cached afterwards), builds Ruby 4.0 with the guest
71
+ gems statically linked and packs `lib/security_box/guest` as `/src`. The task
72
+ skips rebuilding when the image is already fresh. The image is not committed to
73
+ git.
70
74
 
71
75
  You can point the library at a different image with the `SECURITY_BOX_IMAGE`
72
76
  environment variable or by passing `image_path:` in the configuration — useful
@@ -119,7 +123,8 @@ sandbox.eval("3 * 3").value # => 9
119
123
  ### Per-call limits
120
124
 
121
125
  Options can be passed per call (derived from the configuration without mutating it):
122
- `timeout_ms`, `fuel`, `memory_size`, `stdout_limit`, `stderr_limit`.
126
+ `timeout_ms`, `fuel`, `fuel_ms`, `memory_size`, `stdout_limit`, `stderr_limit`,
127
+ `mounts`.
123
128
 
124
129
  ```ruby
125
130
  # Wall-clock limit via epoch interruption
@@ -155,6 +160,45 @@ result.error["backtrace"] # => ["sandbox:1:in 'Object#boom'", "sandbox:1:
155
160
  The backtrace contains guest frames only (sandbox-internal locations, capped at
156
161
  20 frames) — nothing from the host filesystem leaks.
157
162
 
163
+ ### Host RPC (code mode)
164
+
165
+ Guest code can call host-registered handlers with a regular, **blocking**
166
+ function call — the shape model-generated "code mode" agents need:
167
+
168
+ ```ruby
169
+ SecurityBox.register(:agent) do |c|
170
+ c.rpc "github.search" => ->(args) { mcp.call_tool("github", "search", args) }
171
+ c.rpc "github.get" => ->(args) { mcp.call_tool("github", "get_file", args) }
172
+ c.fuel_ms 200
173
+ c.timeout_ms 5_000 # covers boot + guest compute + handler time
174
+ end
175
+
176
+ box = SecurityBox.spawn(:agent)
177
+ box.eval(<<~CODE)
178
+ hits = SB.call("github.search", q: "ruby wasm").items
179
+ SB.call("github.get", path: hits.first.path)
180
+ CODE
181
+ ```
182
+
183
+ - `SB.call(name, args)` blocks inside the sandbox (a wasm import provided by
184
+ the statically linked `sb_rpc` gem) while the host executes the handler;
185
+ from the guest's perspective it is just a function returning a value.
186
+ - Handlers receive the JSON-parsed args (string keys) and must return a
187
+ JSON-serializable value (non-serializable results surface as inspect
188
+ strings).
189
+ - A raising handler becomes a guest-rescuable `SB::ToolError`
190
+ (`SB::UnknownTool` for unregistered names) carrying only `class` +
191
+ `message` — no backtrace, no host details. Calling an RPC on a sandbox
192
+ with no handlers configured is also a clean, rescuable error.
193
+ - `Result#rpcs` carries the frozen per-eval transcript
194
+ (`{"name", "args", "ok", "result"|"error"}`) for agent debugging.
195
+ - Limits: at most 1000 calls per eval and 1MiB per response.
196
+ - Handlers are host-only state: excluded from `#fingerprint`, not supported
197
+ on `RactorPool` (they cannot cross a Ractor boundary), and must be
198
+ thread-safe when a `Pool` is used concurrently.
199
+ - Per-call override (replaces, like `env:`):
200
+ `SecurityBox.eval(code, rpcs: { "calc" => ->(args) { ... } })`.
201
+
158
202
  ### Named profiles
159
203
 
160
204
  Reusable configurations registered once and spawned as often as needed:
@@ -182,6 +226,49 @@ Profiles are immutable: duplicate names and unknown names raise
182
226
  `SecurityBox::Configuration#fingerprint` gives every configuration a stable
183
227
  identity (equal settings → equal fingerprint), used to share runtime artifacts.
184
228
 
229
+ ### Folder mounts
230
+
231
+ Host folders can be mounted into the sandbox explicitly. **Mounts are read-only
232
+ by default**; `mount_rw` is the opt-in writable form:
233
+
234
+ ```ruby
235
+ SecurityBox.register(:reader) do |c|
236
+ c.mount "./data" => "/data" # read-only (host path expanded at DSL time)
237
+ c.mount_rw "./state" => "/state" # opt-in writable
238
+ end
239
+
240
+ box = SecurityBox.spawn(:reader)
241
+ box.eval('File.read("/data/input.csv")') # guest reads mounted content
242
+ box.eval('File.write("/state/last.txt", Time.now.to_i)')
243
+
244
+ # Per-call override (replaces the configuration's mounts, like env:)
245
+ SecurityBox.eval('File.read("/data/a.txt")',
246
+ mounts: [{ host: "./data", guest: "/data", mode: :read_only }])
247
+ ```
248
+
249
+ Rules (violations raise `SecurityBox::InvalidConfiguration` at registration):
250
+
251
+ - A mount is one `{host:, guest:, mode:}` pair; `mode` is `:read_only` or
252
+ `:read_write` and the DSL defaults to read-only.
253
+ - Guest paths must be absolute and normalized, must not overlap the reserved
254
+ `/work` (sandbox tmpdir), `/usr` (embedded stdlib) or `/src` (entrypoint)
255
+ trees — exact or nested — and must be unique.
256
+ - At most 16 mounts per configuration.
257
+ - The host path must exist and be a directory at eval time; otherwise the eval
258
+ returns `:sandbox_error` with a `security_box:` note on stderr (instead of
259
+ raising).
260
+
261
+ Security notes:
262
+
263
+ - **A mounted folder's content is fully readable by guest code** — only mount
264
+ directories whose content you are willing to expose to the executed code.
265
+ - Read-only mounts are enforced by wasmtime: writes fail guest-side with
266
+ `Errno::EPERM` and the host directory is never touched. Symlinks inside a
267
+ mounted directory cannot escape it (wasmtime caps path resolution at the
268
+ preopen root), and guest code cannot create symlinks at all.
269
+ - Mounted content can also be **written by the guest** in `mount_rw` mounts —
270
+ treat a writable mount as part of the guest's blast radius.
271
+
185
272
  ### Configuration
186
273
 
187
274
  `SecurityBox::Configuration` is immutable; use `.build` to create and `#with` to derive:
@@ -189,12 +276,15 @@ identity (equal settings → equal fingerprint), used to share runtime artifacts
189
276
  ```ruby
190
277
  config = SecurityBox::Configuration.build(
191
278
  fuel: 10_000_000_000, # deterministic CPU budget
279
+ fuel_ms: nil, # or a millisecond-based budget (mutually exclusive with fuel)
192
280
  timeout_ms: 2_000, # wall-clock limit (epoch interruption)
193
281
  memory_size: 512 * 1024 * 1024, # wasm linear memory limit
194
282
  stdout_limit: 1 << 20, # stdout capture capacity in bytes
195
283
  stderr_limit: 1 << 16, # stderr capture capacity in bytes
196
284
  epoch_interval_ms: 25, # epoch timer granularity
197
- env: { "LANG" => "C" } # guest environment (empty by default)
285
+ env: { "LANG" => "C" }, # guest environment (empty by default)
286
+ mounts: [], # host-folder mounts (see "Folder mounts")
287
+ rpcs: {} # name => callable host RPC handlers
198
288
  )
199
289
 
200
290
  lean = config.with(timeout_ms: 500, fuel: 5_000_000)
@@ -221,32 +311,93 @@ string.
221
311
  |---|---|
222
312
  | `File.write("/etc/passwd", ...)` | `Errno::ENOENT` (guest does not see the host FS) |
223
313
  | `Dir["/*"]` | `[]` (empty root) |
314
+ | `File.write` into a read-only mount | `Errno::EPERM` (wasmtime-enforced; host dir untouched) |
315
+ | symlink escape from a mounted directory | `Errno::EPERM` (path resolution capped at the preopen root) |
224
316
  | `system("ls")` / backticks / `IO.popen` / `Process.spawn` | `SecurityError` (hardening prelude) |
225
317
  | `Kernel#open(...)` | `SecurityError` (pipe form; use `File.open`) |
226
318
  | `fork` | `NotImplementedError` |
227
319
  | `Thread.new` | `NotImplementedError` (WASI p1 has no threads) |
228
320
  | `require "socket"` | `LoadError` (no network) |
229
321
  | `ENV` | `{}` (token read and scrubbed by the prelude) |
322
+ | `SB.call("missing")` | `SB::UnknownTool` (guest-rescuable; host never crashes) |
323
+ | `SB.call` with no handlers configured | `SB::UnknownTool` ("no RPC handlers are configured…") |
324
+ | handler raising | guest sees `SB::ToolError` with `class` + `message` only |
325
+ | >1000 RPC calls or >1MiB result | `SB::ToolError` (guest-rescuable, host-side enforced) |
230
326
  | forge `out.json` via `at_exit` / fake sentinel | rejected (token mismatch → `:sandbox_error`) |
231
327
  | infinite loop | killed by epoch deadline or fuel budget |
232
328
 
233
329
  Notes:
234
330
 
235
331
  - The guest Ruby version is the one from ruby.wasm (4.0), not necessarily the host's.
236
- - Concurrency: `invoke` holds the GVL, so executions serialize per host process. Scale
237
- horizontally with multiple processes (e.g., Puma workers); a stuck guest still can't
238
- hang the process thanks to the epoch deadline.
239
- - Ractor note: wasmtime `Engine`/`Module` are Ractor-shareable and Ractors run wasm in
240
- parallel (measured ≈3.6x with 4 Ractors on 6 cores); a supported Ractor pool is on
241
- the roadmap (see `docs/plan/stages/stage_3.md`).
242
- - Memory: the packed image declares a 1528-page (~95.5 MiB) minimum; `memory_size`
243
- below that fails instantiation (reported as `:sandbox_error`). Practical minimum is
244
- ~128–144MB for small workloads; the 512MB default leaves comfortable headroom.
332
+ - Mounts widen the trust surface: a mounted folder's content is fully readable by
333
+ guest code (see "Folder mounts" above). Read-only by default; nothing outside
334
+ `/work` is writable without an explicit `mount_rw`.
335
+ - Concurrency: `invoke` holds the GVL, so executions on threads serialize — use
336
+ `RactorPool` (below) for parallelism inside one process, or scale horizontally with
337
+ multiple processes (e.g., Puma workers). A stuck guest still can't hang the process
338
+ thanks to the epoch deadline.
339
+ - Ractor: wasmtime `Engine`/`Module` are Ractor-shareable and Ractors run wasm in
340
+ parallel — measured ≈1.9x wall-time speedup at 4 workers on 6 cores through
341
+ `RactorPool` (see `docs/plan/stages/stage_3.md` and `stage_4.md`).
342
+ - Memory: the image (stage-6 `rbwasm build` flow) declares a ~576-page (~36 MiB)
343
+ minimum; `memory_size` below that fails instantiation (reported as
344
+ `:sandbox_error`). Practical minimum is ~48–64MB for small workloads; the
345
+ 512MB default leaves comfortable headroom.
245
346
  - Fuel budgeting: compute workloads burn ~4–8e9 fuel/s (tight loops up to ~8.4e9/s)
246
347
  and every eval costs ~1e9 fuel for boot — see the calibration table in
247
348
  `docs/plan/stages/stage_3.md`. Consequently `fuel` below ~1e9 cannot even boot, and
248
349
  `timeout_ms` below ~300ms times out during the guest boot (~500ms is a practical
249
- floor).
350
+ floor). Prefer thinking in milliseconds? Use `fuel_ms` (below).
351
+
352
+ ## Concurrency
353
+
354
+ Two pools are available, with honestly different guarantees (measured in stage 4 —
355
+ `invoke` holds the GVL, so a long-lived `:worker` instance is impossible on the current
356
+ wasmtime-rb; one wasm boot per eval stays on the hot path):
357
+
358
+ ### `Pool` — bounded concurrency on threads
359
+
360
+ ```ruby
361
+ pool = SecurityBox.pool(:lean, size: 4)
362
+ pool.eval("40 + 2").value # => 42
363
+ pool.checkout { |sandbox| sandbox.eval("1 + 1") }
364
+ pool.metrics # => {size:, created:, evals:, total_ms:, avg_ms:}
365
+ pool.shutdown
366
+ ```
367
+
368
+ Caps concurrent evals at `size` and reuses sandbox objects, but evals serialize on the
369
+ GVL — one at a time.
370
+
371
+ ### `RactorPool` — real parallelism
372
+
373
+ ```ruby
374
+ pool = SecurityBox.ractor_pool(:lean, size: 4)
375
+ pool.eval("40 + 2").value # => 42
376
+ pool.shutdown
377
+ ```
378
+
379
+ Worker Ractors share one Engine + compiled Module and run wasm truly in parallel
380
+ (~1.9x wall-time speedup at 4 workers on 6 cores; measured ≈0.53 parallel/serial wall
381
+ ratio across 1M–4M-iteration workloads). Per-call overrides, user errors, timeouts and
382
+ fuel exhaustion behave exactly like `Sandbox#eval`, and the worker survives traps and
383
+ keeps serving. Costs one module deserialize at creation (~0.5s via the disk cache);
384
+ each eval still pays the ~240ms guest boot.
385
+
386
+ ### `fuel_ms` — rate-based fuel budgeting
387
+
388
+ Instead of counting raw fuel, budget approximate wall-time of compute:
389
+
390
+ ```ruby
391
+ config = SecurityBox::Configuration.build(fuel_ms: 100) # ≈100ms of compute + boot allowance
392
+ SecurityBox.spawn(config).eval("i = 0; while i < 5_000_000; i += 1; end").status
393
+ # => :fuel_exhausted (5M tight iterations ≈ 2.4e9 fuel > the 100ms budget)
394
+ ```
395
+
396
+ `fuel_ms` converts to fuel with the conservative stage-3 calibration rate
397
+ (4e6 fuel/ms ≈ 4e9 fuel/s) plus a ~1e9 boot allowance (`Configuration#effective_fuel`).
398
+ The epoch `timeout_ms` remains the mandatory wall-clock backstop. `fuel` and `fuel_ms`
399
+ are mutually exclusive in the builder DSL and in `#with` overrides; a `fuel_ms`
400
+ configuration can be switched back with `with(fuel_ms: nil, fuel: ...)`.
250
401
 
251
402
  ## Running the tests
252
403
 
@@ -276,6 +427,13 @@ prints a timing report:
276
427
  bundle exec ruby bin/spike.rb
277
428
  ```
278
429
 
430
+ ## Samples
431
+
432
+ - [`samples/ruby_llm/reader_agent`](samples/ruby_llm/reader_agent/README.md) — an
433
+ LLM agent built with [RubyLLM](https://github.com/crmne/ruby_llm) whose code-execution
434
+ tool runs model-generated Ruby inside the sandbox, demonstrating folder mounts
435
+ (read-only vs read-write) end to end.
436
+
279
437
  ## Documentation
280
438
 
281
439
  - `docs/PLAN.md` — architecture, roadmap and threat model
@@ -284,3 +442,10 @@ bundle exec ruby bin/spike.rb
284
442
  integrity, compiled-module disk cache)
285
443
  - `docs/plan/stages/stage_3.md` — stage 3 findings (named profiles, Ractor
286
444
  parallelism, memory floor, fuel calibration, backtrace)
445
+ - `docs/plan/stages/stage_4.md` — stage 4 findings (worker-mode feasibility, pools,
446
+ `fuel_ms`)
447
+ - `docs/plan/stages/stage_5.md` — stage 5 findings (folder mounts: read-only
448
+ enforcement, reserved-path collisions, symlink escapes, mount cost)
449
+ - `docs/plan/stages/stage_6.md` — stage 6 findings (blocking host RPC via a
450
+ wasm import, `SB.call` code-mode contract, epoch semantics, new image
451
+ build flow)
@@ -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: 115877509 bytes
4
- sha256: d7af08b7b11f5ac8bf9b4b8e9cb2ac6ca3e5c977f0dcdfea3551627247589a70
3
+ size: 52249212 bytes
4
+ sha256: 6a6b265e2c1e79f871ccee925a8ea63310cdf433fcffb061369c72f64fd5301c