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 +4 -4
- data/CHANGELOG.md +128 -0
- data/README.md +187 -22
- data/lib/security_box/assets/security_box.wasm +2 -2
- data/lib/security_box/configuration.rb +286 -7
- data/lib/security_box/errors.rb +2 -0
- data/lib/security_box/eval_run.rb +201 -0
- data/lib/security_box/guest/main.rb +4 -1
- data/lib/security_box/guest/rpc.rb +77 -0
- data/lib/security_box/guest_ext/Gemfile +10 -0
- data/lib/security_box/guest_ext/Gemfile.lock +31 -0
- data/lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/extconf.rb +5 -0
- data/lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/sb_rpc.c +29 -0
- data/lib/security_box/guest_ext/sb_rpc/lib/sb_rpc.rb +6 -0
- data/lib/security_box/guest_ext/sb_rpc/sb_rpc.gemspec +17 -0
- data/lib/security_box/guest_rpc.rb +132 -0
- data/lib/security_box/pool.rb +138 -0
- data/lib/security_box/ractor_pool.rb +214 -0
- data/lib/security_box/registry.rb +6 -1
- data/lib/security_box/result.rb +17 -3
- data/lib/security_box/runtime.rb +17 -0
- data/lib/security_box/sandbox.rb +26 -151
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +19 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 999342421a33fdaba7863952bafad78dc8e87bac98a1ec02269fb2903be9d414
|
|
4
|
+
data.tar.gz: b119d9d14854ba07b2f99bd1c5d8c8e2e25db4f5f50370d1ecea41491ee8b673
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
19
|
-
|
|
20
|
-
(`lib/security_box/guest/
|
|
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
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
committed to
|
|
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" }
|
|
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
|
-
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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:
|
|
4
|
-
sha256:
|
|
3
|
+
size: 52249212 bytes
|
|
4
|
+
sha256: 6a6b265e2c1e79f871ccee925a8ea63310cdf433fcffb061369c72f64fd5301c
|