security_box 0.5.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 +50 -0
- data/README.md +73 -14
- data/lib/security_box/assets/security_box.wasm +2 -2
- data/lib/security_box/configuration.rb +97 -5
- data/lib/security_box/eval_run.rb +16 -6
- 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/ractor_pool.rb +16 -1
- data/lib/security_box/result.rb +10 -3
- data/lib/security_box/sandbox.rb +4 -0
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +1 -0
- metadata +9 -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,56 @@ 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
|
+
|
|
8
58
|
## [0.5.0] - 2026-09-20
|
|
9
59
|
|
|
10
60
|
### 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
|
|
@@ -156,6 +160,45 @@ result.error["backtrace"] # => ["sandbox:1:in 'Object#boom'", "sandbox:1:
|
|
|
156
160
|
The backtrace contains guest frames only (sandbox-internal locations, capped at
|
|
157
161
|
20 frames) — nothing from the host filesystem leaks.
|
|
158
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
|
+
|
|
159
202
|
### Named profiles
|
|
160
203
|
|
|
161
204
|
Reusable configurations registered once and spawned as often as needed:
|
|
@@ -240,7 +283,8 @@ config = SecurityBox::Configuration.build(
|
|
|
240
283
|
stderr_limit: 1 << 16, # stderr capture capacity in bytes
|
|
241
284
|
epoch_interval_ms: 25, # epoch timer granularity
|
|
242
285
|
env: { "LANG" => "C" }, # guest environment (empty by default)
|
|
243
|
-
mounts: []
|
|
286
|
+
mounts: [], # host-folder mounts (see "Folder mounts")
|
|
287
|
+
rpcs: {} # name => callable host RPC handlers
|
|
244
288
|
)
|
|
245
289
|
|
|
246
290
|
lean = config.with(timeout_ms: 500, fuel: 5_000_000)
|
|
@@ -275,6 +319,10 @@ string.
|
|
|
275
319
|
| `Thread.new` | `NotImplementedError` (WASI p1 has no threads) |
|
|
276
320
|
| `require "socket"` | `LoadError` (no network) |
|
|
277
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) |
|
|
278
326
|
| forge `out.json` via `at_exit` / fake sentinel | rejected (token mismatch → `:sandbox_error`) |
|
|
279
327
|
| infinite loop | killed by epoch deadline or fuel budget |
|
|
280
328
|
|
|
@@ -291,9 +339,10 @@ Notes:
|
|
|
291
339
|
- Ractor: wasmtime `Engine`/`Module` are Ractor-shareable and Ractors run wasm in
|
|
292
340
|
parallel — measured ≈1.9x wall-time speedup at 4 workers on 6 cores through
|
|
293
341
|
`RactorPool` (see `docs/plan/stages/stage_3.md` and `stage_4.md`).
|
|
294
|
-
- Memory: the
|
|
295
|
-
below that fails instantiation (reported as
|
|
296
|
-
~
|
|
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.
|
|
297
346
|
- Fuel budgeting: compute workloads burn ~4–8e9 fuel/s (tight loops up to ~8.4e9/s)
|
|
298
347
|
and every eval costs ~1e9 fuel for boot — see the calibration table in
|
|
299
348
|
`docs/plan/stages/stage_3.md`. Consequently `fuel` below ~1e9 cannot even boot, and
|
|
@@ -378,6 +427,13 @@ prints a timing report:
|
|
|
378
427
|
bundle exec ruby bin/spike.rb
|
|
379
428
|
```
|
|
380
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
|
+
|
|
381
437
|
## Documentation
|
|
382
438
|
|
|
383
439
|
- `docs/PLAN.md` — architecture, roadmap and threat model
|
|
@@ -390,3 +446,6 @@ bundle exec ruby bin/spike.rb
|
|
|
390
446
|
`fuel_ms`)
|
|
391
447
|
- `docs/plan/stages/stage_5.md` — stage 5 findings (folder mounts: read-only
|
|
392
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
|
|
@@ -20,7 +20,8 @@ module SecurityBox
|
|
|
20
20
|
stderr_limit: 1 << 16,
|
|
21
21
|
epoch_interval_ms: 25,
|
|
22
22
|
env: {}.freeze,
|
|
23
|
-
mounts: [].freeze
|
|
23
|
+
mounts: [].freeze,
|
|
24
|
+
rpcs: {}.freeze
|
|
24
25
|
}.freeze
|
|
25
26
|
|
|
26
27
|
# Fuel-per-ms conversion for #fuel_ms, from the stage-3 calibration table
|
|
@@ -31,14 +32,16 @@ module SecurityBox
|
|
|
31
32
|
BOOT_FUEL_ALLOWANCE = 1_000_000_000
|
|
32
33
|
|
|
33
34
|
attr_reader :image_path, :fuel, :fuel_ms, :timeout_ms, :memory_size,
|
|
34
|
-
:stdout_limit, :stderr_limit, :epoch_interval_ms, :env, :mounts
|
|
35
|
+
:stdout_limit, :stderr_limit, :epoch_interval_ms, :env, :mounts,
|
|
36
|
+
:rpcs
|
|
35
37
|
|
|
36
38
|
def self.build(**options)
|
|
37
39
|
new(**DEFAULTS.merge(options)).freeze
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
def initialize(image_path: nil, fuel:, fuel_ms:, timeout_ms:, memory_size:,
|
|
41
|
-
stdout_limit:, stderr_limit:, epoch_interval_ms:, env:, mounts
|
|
43
|
+
stdout_limit:, stderr_limit:, epoch_interval_ms:, env:, mounts:,
|
|
44
|
+
rpcs:)
|
|
42
45
|
@image_path = image_path || default_image_path
|
|
43
46
|
@fuel = Integer(fuel)
|
|
44
47
|
@fuel_ms = fuel_ms.nil? ? nil : Integer(fuel_ms)
|
|
@@ -49,6 +52,7 @@ module SecurityBox
|
|
|
49
52
|
@epoch_interval_ms = Integer(epoch_interval_ms)
|
|
50
53
|
@env = env.freeze
|
|
51
54
|
@mounts = Mounts.normalize(mounts)
|
|
55
|
+
@rpcs = Rpcs.normalize(rpcs)
|
|
52
56
|
freeze
|
|
53
57
|
end
|
|
54
58
|
|
|
@@ -91,7 +95,8 @@ module SecurityBox
|
|
|
91
95
|
stderr_limit: @stderr_limit,
|
|
92
96
|
epoch_interval_ms: @epoch_interval_ms,
|
|
93
97
|
env: @env,
|
|
94
|
-
mounts: @mounts
|
|
98
|
+
mounts: @mounts,
|
|
99
|
+
rpcs: @rpcs
|
|
95
100
|
}
|
|
96
101
|
end
|
|
97
102
|
|
|
@@ -99,12 +104,16 @@ module SecurityBox
|
|
|
99
104
|
# hash). Two configurations with equal settings — regardless of how they
|
|
100
105
|
# were built — share the same fingerprint; any #with change produces a
|
|
101
106
|
# different one. Used to key profiles and, later, cached artifacts.
|
|
107
|
+
#
|
|
108
|
+
# RPC handlers are deliberately excluded: they are host-side callables
|
|
109
|
+
# (Procs) with no stable serialized identity, and including them would
|
|
110
|
+
# make the fingerprint depend on object addresses.
|
|
102
111
|
def fingerprint
|
|
103
112
|
Digest::SHA256.hexdigest(JSON.generate(canonical))
|
|
104
113
|
end
|
|
105
114
|
|
|
106
115
|
def canonical
|
|
107
|
-
to_h.merge(env: @env.sort.to_h)
|
|
116
|
+
to_h.except(:rpcs).merge(env: @env.sort.to_h)
|
|
108
117
|
end
|
|
109
118
|
|
|
110
119
|
# Mutable collector for the register DSL. Setter names match the
|
|
@@ -177,6 +186,26 @@ module SecurityBox
|
|
|
177
186
|
add_mount(mapping, :read_write)
|
|
178
187
|
end
|
|
179
188
|
|
|
189
|
+
# Registers a guest-callable RPC handler (stage 6):
|
|
190
|
+
# c.rpc "github.search" => ->(args) { ... }
|
|
191
|
+
# c.rpc github_search: ->(args) { ... }
|
|
192
|
+
# Each call appends one handler; the per-call :rpcs override replaces
|
|
193
|
+
# the whole set. See SecurityBox::Rpcs for the validation rules.
|
|
194
|
+
def rpc(mapping = nil, **kwargs)
|
|
195
|
+
unless kwargs.empty?
|
|
196
|
+
raise InvalidConfiguration, 'rpc expects exactly one pair: c.rpc "name" => handler' unless kwargs.size == 1 && mapping.nil?
|
|
197
|
+
|
|
198
|
+
name, handler = kwargs.first
|
|
199
|
+
mapping = { name.to_s => handler }
|
|
200
|
+
end
|
|
201
|
+
unless mapping.is_a?(Hash) && mapping.size == 1
|
|
202
|
+
raise InvalidConfiguration,
|
|
203
|
+
'rpc expects exactly one pair: c.rpc "name" => handler'
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
@changes[:rpcs] = Array(@changes[:rpcs]) + [mapping]
|
|
207
|
+
end
|
|
208
|
+
|
|
180
209
|
private
|
|
181
210
|
|
|
182
211
|
def add_mount(mapping, mode)
|
|
@@ -322,4 +351,67 @@ module SecurityBox
|
|
|
322
351
|
end
|
|
323
352
|
end
|
|
324
353
|
end
|
|
354
|
+
|
|
355
|
+
# Validated collection of guest-callable RPC handlers (stage 6). A
|
|
356
|
+
# handler is a name => callable pair; `rpcs` in a Configuration is a
|
|
357
|
+
# frozen hash, so the value survives #with and #to_h.
|
|
358
|
+
#
|
|
359
|
+
# Handlers execute on the host (between guest steps, inside the RPC
|
|
360
|
+
# import) and are therefore host-only state: they are excluded from
|
|
361
|
+
# #fingerprint, and a Configuration carrying Procs is not
|
|
362
|
+
# Ractor-shareable — RactorPool rejects rpcs and strips them before a
|
|
363
|
+
# config crosses a Ractor boundary.
|
|
364
|
+
#
|
|
365
|
+
# Validation (InvalidConfiguration on any violation):
|
|
366
|
+
# - raw is a Hash of name => handler, or an Array of one-pair hashes
|
|
367
|
+
# (the register DSL appends one pair per c.rpc call)
|
|
368
|
+
# - name: non-empty String, unique
|
|
369
|
+
# - handler: anything responding to #call; it receives the
|
|
370
|
+
# JSON-parsed request args and must return a JSON-serializable
|
|
371
|
+
# value (non-serializable results surface as inspect strings)
|
|
372
|
+
# - at most MAX_RPCS handlers
|
|
373
|
+
module Rpcs
|
|
374
|
+
MAX_RPCS = 64
|
|
375
|
+
|
|
376
|
+
class << self
|
|
377
|
+
# Validates + normalizes `raw` and returns a frozen
|
|
378
|
+
# name => handler hash.
|
|
379
|
+
def normalize(raw)
|
|
380
|
+
return {}.freeze if raw.nil?
|
|
381
|
+
|
|
382
|
+
pairs = case raw
|
|
383
|
+
when Hash then raw.entries
|
|
384
|
+
when Array
|
|
385
|
+
unless raw.all? { |entry| entry.is_a?(Hash) && entry.size == 1 }
|
|
386
|
+
raise InvalidConfiguration,
|
|
387
|
+
"rpcs must be a Hash or an Array of one-pair hashes, " \
|
|
388
|
+
"got #{raw.inspect[0, 80]}"
|
|
389
|
+
end
|
|
390
|
+
raw.flat_map(&:entries)
|
|
391
|
+
else
|
|
392
|
+
raise InvalidConfiguration,
|
|
393
|
+
"rpcs must be a Hash of name => handler, got #{raw.class}"
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
rpcs = {}
|
|
397
|
+
pairs.each do |name, handler|
|
|
398
|
+
unless name.is_a?(String) && !name.empty?
|
|
399
|
+
raise InvalidConfiguration, "rpc name must be a non-empty String, got #{name.inspect}"
|
|
400
|
+
end
|
|
401
|
+
unless handler.respond_to?(:call)
|
|
402
|
+
raise InvalidConfiguration,
|
|
403
|
+
"rpc handler for #{name.inspect} must respond to #call, " \
|
|
404
|
+
"got #{handler.inspect[0, 80]}"
|
|
405
|
+
end
|
|
406
|
+
raise InvalidConfiguration, "duplicate rpc name #{name.inspect}" if rpcs.key?(name)
|
|
407
|
+
|
|
408
|
+
rpcs[name] = handler
|
|
409
|
+
end
|
|
410
|
+
return rpcs.freeze if rpcs.size <= MAX_RPCS
|
|
411
|
+
|
|
412
|
+
raise InvalidConfiguration,
|
|
413
|
+
"too many rpcs (#{rpcs.size}); the limit is #{MAX_RPCS}"
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
325
417
|
end
|
|
@@ -44,6 +44,13 @@ module SecurityBox
|
|
|
44
44
|
|
|
45
45
|
Dir.mktmpdir("security_box") do |workdir|
|
|
46
46
|
File.write(File.join(workdir, CODE_FILE), code)
|
|
47
|
+
# Per-eval RPC state (stage 6): the /work tmpdir plus the
|
|
48
|
+
# configuration's handlers (nil when none are configured — the
|
|
49
|
+
# import is still defined, guest calls get a rescuable error).
|
|
50
|
+
# The calls array doubles as the Result transcript.
|
|
51
|
+
rpc_data = GuestRpc.store_data(
|
|
52
|
+
workdir, config.rpcs.empty? ? nil : config.rpcs
|
|
53
|
+
)
|
|
47
54
|
envelope = nil
|
|
48
55
|
status = nil
|
|
49
56
|
fuel_used = nil
|
|
@@ -52,6 +59,7 @@ module SecurityBox
|
|
|
52
59
|
begin
|
|
53
60
|
store = Wasmtime::Store.new(
|
|
54
61
|
engine,
|
|
62
|
+
rpc_data,
|
|
55
63
|
wasi_p1_config: build_wasi(workdir, stdout, stderr, config, token),
|
|
56
64
|
limits: { memory_size: config.memory_size }
|
|
57
65
|
)
|
|
@@ -70,7 +78,8 @@ module SecurityBox
|
|
|
70
78
|
store&.close
|
|
71
79
|
end
|
|
72
80
|
|
|
73
|
-
build_result(status, envelope, stdout, stderr, fuel_used,
|
|
81
|
+
build_result(status, envelope, stdout, stderr, fuel_used,
|
|
82
|
+
monotonic_ms - t0, rpc_data[:rpc]&.fetch(:calls))
|
|
74
83
|
end
|
|
75
84
|
end
|
|
76
85
|
|
|
@@ -145,12 +154,13 @@ module SecurityBox
|
|
|
145
154
|
nil
|
|
146
155
|
end
|
|
147
156
|
|
|
148
|
-
def build_result(status, envelope, stdout, stderr, fuel_used, duration_ms)
|
|
157
|
+
def build_result(status, envelope, stdout, stderr, fuel_used, duration_ms, rpc_calls = nil)
|
|
158
|
+
rpcs = rpc_calls && !rpc_calls.empty? ? rpc_calls.each(&:freeze).freeze : nil
|
|
149
159
|
if envelope.nil?
|
|
150
160
|
status = :sandbox_error if status == :ok
|
|
151
161
|
return Result.new(
|
|
152
162
|
status: status, stdout: stdout, stderr: stderr,
|
|
153
|
-
fuel_used: fuel_used, duration_ms: duration_ms
|
|
163
|
+
fuel_used: fuel_used, duration_ms: duration_ms, rpcs: rpcs
|
|
154
164
|
)
|
|
155
165
|
end
|
|
156
166
|
|
|
@@ -159,7 +169,7 @@ module SecurityBox
|
|
|
159
169
|
Result.new(
|
|
160
170
|
status: status, value: envelope["value"], stdout: stdout, stderr: stderr,
|
|
161
171
|
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
162
|
-
guest_duration_ms: envelope["duration_ms"]
|
|
172
|
+
guest_duration_ms: envelope["duration_ms"], rpcs: rpcs
|
|
163
173
|
)
|
|
164
174
|
elsif guest_memory_error?(guest_error)
|
|
165
175
|
# The guest hit the store memory limit through the Ruby interpreter
|
|
@@ -168,13 +178,13 @@ module SecurityBox
|
|
|
168
178
|
Result.new(
|
|
169
179
|
status: :memory_limit, error: guest_error, stdout: stdout, stderr: stderr,
|
|
170
180
|
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
171
|
-
guest_duration_ms: envelope["duration_ms"]
|
|
181
|
+
guest_duration_ms: envelope["duration_ms"], rpcs: rpcs
|
|
172
182
|
)
|
|
173
183
|
else
|
|
174
184
|
Result.new(
|
|
175
185
|
status: :error, error: guest_error, stdout: stdout, stderr: stderr,
|
|
176
186
|
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
177
|
-
guest_duration_ms: envelope["duration_ms"]
|
|
187
|
+
guest_duration_ms: envelope["duration_ms"], rpcs: rpcs
|
|
178
188
|
)
|
|
179
189
|
end
|
|
180
190
|
end
|
|
@@ -16,7 +16,6 @@ require_relative "prelude"
|
|
|
16
16
|
module SB
|
|
17
17
|
SENTINEL = "__SECURITY_BOX_RESULT__"
|
|
18
18
|
WORK_DIR = "/work"
|
|
19
|
-
|
|
20
19
|
module_function
|
|
21
20
|
|
|
22
21
|
def run
|
|
@@ -97,4 +96,8 @@ module SB
|
|
|
97
96
|
end
|
|
98
97
|
end
|
|
99
98
|
|
|
99
|
+
# Guest RPC bridge (SB.call + SB::ToolError) — reopens the SB module
|
|
100
|
+
# above; must load after it.
|
|
101
|
+
require_relative "rpc"
|
|
102
|
+
|
|
100
103
|
SB.run
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Guest-side host RPC bridge (loaded by main.rb before user code).
|
|
2
|
+
#
|
|
3
|
+
# User code calls SB.call(name, args) and it behaves like a regular,
|
|
4
|
+
# blocking Ruby function: the request is written to /work/rpc_req.json,
|
|
5
|
+
# the wasm import (SBExt.call — the sb_rpc gem statically linked into
|
|
6
|
+
# the image) is invoked, and the guest blocks while the host executes
|
|
7
|
+
# the registered handler. The response is read back from
|
|
8
|
+
# /work/rpc_resp.json.
|
|
9
|
+
#
|
|
10
|
+
# Handler failures never cross the wasm boundary: the host encodes them
|
|
11
|
+
# as {"ok": false, "error": {"class", "message"}} responses, surfaced
|
|
12
|
+
# here as SB::ToolError (SB::UnknownTool for unregistered names) so user
|
|
13
|
+
# code can rescue them.
|
|
14
|
+
module SB
|
|
15
|
+
# Raised when an RPC handler fails or its result is unusable.
|
|
16
|
+
class ToolError < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Raised when the code calls a name the host did not register —
|
|
19
|
+
# including when the sandbox has no handlers configured at all.
|
|
20
|
+
class UnknownTool < ToolError; end
|
|
21
|
+
|
|
22
|
+
# Raised when the transport itself fails (image without the guest
|
|
23
|
+
# gems, broken /work channel).
|
|
24
|
+
class RpcUnavailable < StandardError; end
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
# /bundle/setup.rb is generated by the image build; it adds the gem
|
|
28
|
+
# lib dirs to $LOAD_PATH. The gems are absent when the image was
|
|
29
|
+
# built without the guest_ext bundle; SB.call then reports it
|
|
30
|
+
# cleanly instead of crashing on NameError.
|
|
31
|
+
require "/bundle/setup.rb"
|
|
32
|
+
require "sb_rpc"
|
|
33
|
+
rescue LoadError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Calls a host-registered handler and returns its result. Behaves as a
|
|
40
|
+
# blocking function call; the LLM-facing contract is plain Ruby.
|
|
41
|
+
#
|
|
42
|
+
# SB.call("github.search", q: "x") # kwargs form
|
|
43
|
+
# SB.call("github.search", { "q" => "x" }) # positional hash form
|
|
44
|
+
#
|
|
45
|
+
# `name` and `args` must be JSON-serializable; handlers receive the
|
|
46
|
+
# JSON-parsed args (string keys) and return a JSON-serializable value.
|
|
47
|
+
def call(name, args = nil, **kwargs)
|
|
48
|
+
unless name.is_a?(String) && !name.empty?
|
|
49
|
+
raise ArgumentError, "rpc name must be a non-empty String, got #{name.inspect}"
|
|
50
|
+
end
|
|
51
|
+
unless defined?(SBExt)
|
|
52
|
+
raise RpcUnavailable, "rpc support is not available in this image (sb_rpc gem missing)"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
merged = args.nil? ? {} : args
|
|
56
|
+
merged = merged.merge(kwargs) unless kwargs.empty?
|
|
57
|
+
request = begin
|
|
58
|
+
JSON.generate({ name: name, args: merged })
|
|
59
|
+
rescue StandardError, TypeError => e
|
|
60
|
+
raise TypeError, "rpc arguments must be JSON-serializable: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
File.write(File.join(WORK_DIR, "rpc_req.json"), request)
|
|
64
|
+
status = SBExt.call
|
|
65
|
+
unless status.zero?
|
|
66
|
+
raise RpcUnavailable, "rpc transport failed (status #{status})"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
response = JSON.parse(File.read(File.join(WORK_DIR, "rpc_resp.json")))
|
|
70
|
+
unless response["ok"]
|
|
71
|
+
error = response["error"] || {}
|
|
72
|
+
klass = error["class"] == "SB::UnknownTool" ? UnknownTool : ToolError
|
|
73
|
+
raise klass, error["message"].to_s
|
|
74
|
+
end
|
|
75
|
+
response["result"]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
source "https://rubygems.org"
|
|
2
|
+
|
|
3
|
+
# Build tooling — excluded from the image by the packager (EXCLUDED_GEMS),
|
|
4
|
+
# present here so `bundle exec rbwasm` works inside this bundle.
|
|
5
|
+
gem "ruby_wasm", "~> 2.10.1"
|
|
6
|
+
|
|
7
|
+
# Guest image gems (built into the wasm image by `rbwasm build`; see
|
|
8
|
+
# Rakefile). Only guest-safe dependencies belong here — host-only gems
|
|
9
|
+
# (wasmtime) must stay in the project Gemfile.
|
|
10
|
+
gem "sb_rpc", path: "sb_rpc"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: sb_rpc
|
|
3
|
+
specs:
|
|
4
|
+
sb_rpc (0.1.0)
|
|
5
|
+
|
|
6
|
+
GEM
|
|
7
|
+
remote: https://rubygems.org/
|
|
8
|
+
specs:
|
|
9
|
+
logger (1.7.0)
|
|
10
|
+
ruby_wasm (2.10.1)
|
|
11
|
+
logger
|
|
12
|
+
ruby_wasm (2.10.1-x86_64-linux)
|
|
13
|
+
logger
|
|
14
|
+
|
|
15
|
+
PLATFORMS
|
|
16
|
+
ruby
|
|
17
|
+
x86_64-linux
|
|
18
|
+
|
|
19
|
+
DEPENDENCIES
|
|
20
|
+
ruby_wasm (~> 2.10.1)
|
|
21
|
+
sb_rpc!
|
|
22
|
+
|
|
23
|
+
CHECKSUMS
|
|
24
|
+
bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
|
|
25
|
+
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
26
|
+
ruby_wasm (2.10.1) sha256=874e0acb783a8326bfb0db892004816c26963a4276a9f719791a8828e53a05f1
|
|
27
|
+
ruby_wasm (2.10.1-x86_64-linux) sha256=728e29dc688246572b56dcef1c7b131431d07865320c9b8434dd1ccdfd63db2b
|
|
28
|
+
sb_rpc (0.1.0)
|
|
29
|
+
|
|
30
|
+
BUNDLED WITH
|
|
31
|
+
4.0.20
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#include <ruby.h>
|
|
2
|
+
#include <stdint.h>
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Host bridge for security_box RPC.
|
|
6
|
+
*
|
|
7
|
+
* sb_rpc_call is resolved as a wasm import by the host
|
|
8
|
+
* (Wasmtime::Linker#func_new, module "sb", function "call"). The guest
|
|
9
|
+
* blocks inside this call while the host executes the registered handler;
|
|
10
|
+
* the host returns 0 on success (any other value signals a transport
|
|
11
|
+
* failure). The request/response payload travels via /work JSON files
|
|
12
|
+
* written/read by the guest-side Ruby wrapper (sb_rpc.rb in the image),
|
|
13
|
+
* keeping this C code free of any protocol knowledge.
|
|
14
|
+
*/
|
|
15
|
+
__attribute__((import_module("sb"), import_name("call")))
|
|
16
|
+
extern int32_t sb_rpc_call(void);
|
|
17
|
+
|
|
18
|
+
static VALUE
|
|
19
|
+
sb_call(VALUE self)
|
|
20
|
+
{
|
|
21
|
+
return INT2NUM(sb_rpc_call());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
void
|
|
25
|
+
Init_sb_rpc(void)
|
|
26
|
+
{
|
|
27
|
+
VALUE mSBExt = rb_define_module("SBExt");
|
|
28
|
+
rb_define_singleton_method(mSBExt, "call", sb_call, 0);
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "sb_rpc"
|
|
5
|
+
spec.version = "0.1.0"
|
|
6
|
+
spec.summary = "Host RPC bridge for security_box guest images"
|
|
7
|
+
spec.description =
|
|
8
|
+
"Exposes SBExt.call, a wasm import bridge the guest uses to invoke " \
|
|
9
|
+
"host-registered RPC handlers. The call blocks inside the sandbox while " \
|
|
10
|
+
"the host executes the handler; the request/response travel via JSON " \
|
|
11
|
+
"files in /work."
|
|
12
|
+
spec.authors = ["security_box"]
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.0"
|
|
15
|
+
spec.files = Dir["lib/**/*.rb", "ext/**/*.{c,h,rb}"] + ["sb_rpc.gemspec"]
|
|
16
|
+
spec.extensions = ["ext/sb_rpc/extconf.rb"]
|
|
17
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SecurityBox
|
|
6
|
+
# Host side of the guest RPC channel (stage 6).
|
|
7
|
+
#
|
|
8
|
+
# The image statically links the sb_rpc gem, whose C extension declares
|
|
9
|
+
# the wasm import ("sb", "call"). The linker must define it for every
|
|
10
|
+
# instantiation — even when no handlers are configured, or instantiation
|
|
11
|
+
# fails on an unresolved import. Per-eval state (the /work tmpdir, the
|
|
12
|
+
# handler map and the call transcript) travels through Store data and
|
|
13
|
+
# reaches the closure via caller.store_data, so a single definition is
|
|
14
|
+
# safe to share across evaluations, threads and Ractor workers.
|
|
15
|
+
#
|
|
16
|
+
# Protocol (guest side: lib/security_box/guest/rpc.rb):
|
|
17
|
+
# guest writes /work/rpc_req.json {"name", "args"}
|
|
18
|
+
# guest calls SBExt.call (blocks inside the import)
|
|
19
|
+
# host executes handlers[name], writes /work/rpc_resp.json
|
|
20
|
+
# host returns 0 (any non-zero value is a transport failure)
|
|
21
|
+
#
|
|
22
|
+
# Nothing escapes the closure: a raising handler (or any failure in the
|
|
23
|
+
# bridge) becomes an {"ok": false, "error": {"class", "message"}}
|
|
24
|
+
# response the guest can rescue. Messages never include host details
|
|
25
|
+
# (no backtraces, no paths). Handler results are JSON round-tripped,
|
|
26
|
+
# like guest return values — never Marshal.
|
|
27
|
+
module GuestRpc
|
|
28
|
+
IMPORT_MODULE = "sb"
|
|
29
|
+
IMPORT_NAME = "call"
|
|
30
|
+
REQUEST_FILE = "rpc_req.json"
|
|
31
|
+
RESPONSE_FILE = "rpc_resp.json"
|
|
32
|
+
MAX_CALLS = 1_000
|
|
33
|
+
RESULT_LIMIT = 1 << 20
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Defines the import on `linker` (once per linker; state is
|
|
37
|
+
# per-Store). The closure captures nothing — Ractor-safe.
|
|
38
|
+
def define_import(linker)
|
|
39
|
+
linker.func_new(IMPORT_MODULE, IMPORT_NAME, [], [:i32]) do |caller|
|
|
40
|
+
serve(caller)
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Per-eval Store data. `handlers` is the configuration's
|
|
46
|
+
# name => callable map, or nil when no rpcs are configured (guest
|
|
47
|
+
# calls still get a clean, rescuable error).
|
|
48
|
+
def store_data(workdir, handlers)
|
|
49
|
+
{
|
|
50
|
+
workdir: workdir,
|
|
51
|
+
rpc: handlers.nil? ? nil : { handlers: handlers, calls: [] }
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def serve(caller)
|
|
58
|
+
data = caller.store_data
|
|
59
|
+
rpc = data[:rpc]
|
|
60
|
+
unless rpc
|
|
61
|
+
return fail_call(data, nil, "SB::UnknownTool",
|
|
62
|
+
"no RPC handlers are configured for this sandbox")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
request = JSON.parse(File.read(request_path(data)))
|
|
66
|
+
entry = { "name" => request["name"], "args" => request["args"] }
|
|
67
|
+
rpc[:calls] << entry
|
|
68
|
+
dispatch(data, rpc, request, entry)
|
|
69
|
+
0
|
|
70
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
71
|
+
# Transport/boundary failure (unparsable request, broken /work):
|
|
72
|
+
# encoded as a tool error so the guest gets a rescuable exception
|
|
73
|
+
# instead of the host crashing through the wasm boundary.
|
|
74
|
+
fail_call(data, nil, e.class.name, e.message.to_s)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dispatch(data, rpc, request, entry)
|
|
78
|
+
if rpc[:calls].size > MAX_CALLS
|
|
79
|
+
return fail_call(data, entry, "SB::ToolError",
|
|
80
|
+
"too many RPC calls (limit #{MAX_CALLS})")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
handler = rpc[:handlers][request["name"]]
|
|
84
|
+
if handler.nil?
|
|
85
|
+
return fail_call(data, entry, "SB::UnknownTool",
|
|
86
|
+
"unknown rpc: #{request["name"]}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = jsonable(handler.call(request["args"]))
|
|
90
|
+
payload = { "ok" => true, "result" => result }
|
|
91
|
+
if JSON.generate(payload).bytesize > RESULT_LIMIT
|
|
92
|
+
return fail_call(data, entry, "SB::ToolError",
|
|
93
|
+
"rpc result too large (limit #{RESULT_LIMIT} bytes)")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
entry["ok"] = true
|
|
97
|
+
entry["result"] = result
|
|
98
|
+
write_response(data, payload)
|
|
99
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
100
|
+
fail_call(data, entry, e.class.name, e.message.to_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fail_call(data, entry, klass, message)
|
|
104
|
+
error = { "class" => klass, "message" => message }
|
|
105
|
+
# The entry (when present) is already in the transcript — mutate
|
|
106
|
+
# it in place instead of pushing a duplicate.
|
|
107
|
+
if entry
|
|
108
|
+
entry["ok"] = false
|
|
109
|
+
entry["error"] = error
|
|
110
|
+
end
|
|
111
|
+
write_response(data, { "ok" => false, "error" => error })
|
|
112
|
+
0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def request_path(data)
|
|
116
|
+
File.join(data[:workdir], REQUEST_FILE)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def write_response(data, payload)
|
|
120
|
+
File.write(File.join(data[:workdir], RESPONSE_FILE), JSON.generate(payload))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Non-serializable handler results surface as inspect strings, like
|
|
124
|
+
# guest return values (PLAN.md: never Marshal guest/host payloads).
|
|
125
|
+
def jsonable(value)
|
|
126
|
+
JSON.parse(JSON.generate(value))
|
|
127
|
+
rescue StandardError, TypeError
|
|
128
|
+
value.inspect
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -45,6 +45,8 @@ module SecurityBox
|
|
|
45
45
|
raise ArgumentError, "size must be >= 1" if @size < 1
|
|
46
46
|
|
|
47
47
|
@config = resolve_config(profile, options)
|
|
48
|
+
raise InvalidConfiguration, "rpcs are not supported on RactorPool (handlers cannot cross a Ractor boundary)" unless @config.rpcs.empty?
|
|
49
|
+
|
|
48
50
|
@mutex = Mutex.new
|
|
49
51
|
@pending = {} # request id => Queue
|
|
50
52
|
@in_flight = Array.new(@size, 0)
|
|
@@ -64,10 +66,16 @@ module SecurityBox
|
|
|
64
66
|
|
|
65
67
|
# Runs `code` on one of the workers and blocks until its Result arrives.
|
|
66
68
|
# Per-call overrides follow Sandbox#eval (same Configuration#with rules).
|
|
69
|
+
#
|
|
70
|
+
# RPC handlers are not supported here (v1): they are host Procs that
|
|
71
|
+
# cannot cross a Ractor boundary. A configuration with rpcs raises
|
|
72
|
+
# InvalidConfiguration at construction.
|
|
67
73
|
def eval(code, **overrides)
|
|
68
74
|
raise PoolClosed, "pool is closed" if closed?
|
|
69
75
|
|
|
70
76
|
config = overrides.empty? ? @config : @config.with(**overrides)
|
|
77
|
+
raise InvalidConfiguration, "rpcs are not supported on RactorPool (handlers cannot cross a Ractor boundary)" unless config.rpcs.empty?
|
|
78
|
+
|
|
71
79
|
request = {
|
|
72
80
|
id: next_id,
|
|
73
81
|
code: code,
|
|
@@ -136,7 +144,14 @@ module SecurityBox
|
|
|
136
144
|
|
|
137
145
|
def build_worker(engine, module_, worker_index)
|
|
138
146
|
Ractor.new(engine, module_, worker_index, name: "security_box-worker-#{worker_index}") do |eng, mod, index|
|
|
139
|
-
linker = Wasmtime::Linker.new(eng).tap
|
|
147
|
+
linker = Wasmtime::Linker.new(eng).tap do |l|
|
|
148
|
+
Wasmtime::WASI::P1.add_to_linker_sync(l)
|
|
149
|
+
# The image statically links the sb_rpc extension, so the
|
|
150
|
+
# "sb"/"call" import must be defined even though configs are
|
|
151
|
+
# stripped of rpcs before crossing the Ractor boundary
|
|
152
|
+
# (guest calls get a rescuable error via caller.store_data).
|
|
153
|
+
SecurityBox::GuestRpc.define_import(l)
|
|
154
|
+
end
|
|
140
155
|
loop do
|
|
141
156
|
request = Ractor.receive
|
|
142
157
|
break if request == :security_box_stop
|
data/lib/security_box/result.rb
CHANGED
|
@@ -8,12 +8,17 @@ module SecurityBox
|
|
|
8
8
|
# :fuel_exhausted — CPU budget exhausted
|
|
9
9
|
# :memory_limit — exceeded the store memory_size
|
|
10
10
|
# :sandbox_error — sandbox failure (unexpected trap, missing/invalid envelope)
|
|
11
|
+
#
|
|
12
|
+
# `rpcs` (stage 6): the host-side transcript of guest RPC calls, one
|
|
13
|
+
# frozen {"name", "args", "ok", "result"|"error"} entry per call, or nil
|
|
14
|
+
# when no rpcs were configured (or none were called).
|
|
11
15
|
class Result
|
|
12
16
|
attr_reader :status, :value, :error, :stdout, :stderr,
|
|
13
|
-
:fuel_used, :duration_ms, :guest_duration_ms
|
|
17
|
+
:fuel_used, :duration_ms, :guest_duration_ms, :rpcs
|
|
14
18
|
|
|
15
19
|
def initialize(status:, value: nil, error: nil, stdout: "", stderr: "",
|
|
16
|
-
fuel_used: nil, duration_ms: nil, guest_duration_ms: nil
|
|
20
|
+
fuel_used: nil, duration_ms: nil, guest_duration_ms: nil,
|
|
21
|
+
rpcs: nil)
|
|
17
22
|
@status = status
|
|
18
23
|
@value = value
|
|
19
24
|
@error = error
|
|
@@ -22,6 +27,7 @@ module SecurityBox
|
|
|
22
27
|
@fuel_used = fuel_used
|
|
23
28
|
@duration_ms = duration_ms
|
|
24
29
|
@guest_duration_ms = guest_duration_ms
|
|
30
|
+
@rpcs = rpcs
|
|
25
31
|
freeze
|
|
26
32
|
end
|
|
27
33
|
|
|
@@ -45,7 +51,8 @@ module SecurityBox
|
|
|
45
51
|
stderr: @stderr,
|
|
46
52
|
fuel_used: @fuel_used,
|
|
47
53
|
duration_ms: @duration_ms,
|
|
48
|
-
guest_duration_ms: @guest_duration_ms
|
|
54
|
+
guest_duration_ms: @guest_duration_ms,
|
|
55
|
+
rpcs: @rpcs
|
|
49
56
|
}
|
|
50
57
|
end
|
|
51
58
|
end
|
data/lib/security_box/sandbox.rb
CHANGED
|
@@ -43,6 +43,10 @@ module SecurityBox
|
|
|
43
43
|
@linker_mutex.synchronize do
|
|
44
44
|
@linker ||= Wasmtime::Linker.new(@engine).tap do |linker|
|
|
45
45
|
Wasmtime::WASI::P1.add_to_linker_sync(linker)
|
|
46
|
+
# The image statically links the sb_rpc extension, so the
|
|
47
|
+
# "sb"/"call" import must be defined even when no rpcs are
|
|
48
|
+
# configured (state is per-Store; see GuestRpc).
|
|
49
|
+
GuestRpc.define_import(linker)
|
|
46
50
|
end
|
|
47
51
|
end
|
|
48
52
|
end
|
data/lib/security_box/version.rb
CHANGED
data/lib/security_box.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "security_box/result"
|
|
|
8
8
|
require_relative "security_box/module_cache"
|
|
9
9
|
require_relative "security_box/runtime"
|
|
10
10
|
require_relative "security_box/envelope"
|
|
11
|
+
require_relative "security_box/guest_rpc"
|
|
11
12
|
require_relative "security_box/sandbox"
|
|
12
13
|
require_relative "security_box/eval_run"
|
|
13
14
|
require_relative "security_box/pool"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: security_box
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marcelo Junior
|
|
@@ -44,6 +44,14 @@ files:
|
|
|
44
44
|
- lib/security_box/eval_run.rb
|
|
45
45
|
- lib/security_box/guest/main.rb
|
|
46
46
|
- lib/security_box/guest/prelude.rb
|
|
47
|
+
- lib/security_box/guest/rpc.rb
|
|
48
|
+
- lib/security_box/guest_ext/Gemfile
|
|
49
|
+
- lib/security_box/guest_ext/Gemfile.lock
|
|
50
|
+
- lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/extconf.rb
|
|
51
|
+
- lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/sb_rpc.c
|
|
52
|
+
- lib/security_box/guest_ext/sb_rpc/lib/sb_rpc.rb
|
|
53
|
+
- lib/security_box/guest_ext/sb_rpc/sb_rpc.gemspec
|
|
54
|
+
- lib/security_box/guest_rpc.rb
|
|
47
55
|
- lib/security_box/module_cache.rb
|
|
48
56
|
- lib/security_box/pool.rb
|
|
49
57
|
- lib/security_box/ractor_pool.rb
|