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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c7d1c1387ec5b1dffdb9781552e489d4b3d45e7066fdd3c7932e6ba39a8432f
4
- data.tar.gz: 7770652a22e98d5067c851b5a1c790896b986b6dc7a68ca03ea4a0c3c4bb2110
3
+ metadata.gz: 999342421a33fdaba7863952bafad78dc8e87bac98a1ec02269fb2903be9d414
4
+ data.tar.gz: b119d9d14854ba07b2f99bd1c5d8c8e2e25db4f5f50370d1ecea41491ee8b673
5
5
  SHA512:
6
- metadata.gz: 2d4f7f357bc27692a392251edfd887c310f94f6b0e5f47dccd01c9ea420c427f307888791205d82ee54a12ad117351464b6deefc06211cdabcf6c2bdeb86779e
7
- data.tar.gz: b61989d1e43f6f801b1522643239a96118128825837f680bc57122ea8874be4f6229bde01c8af81f53bcbc8d9985e3100f329e2bcbc56797067d6584ed58cca8
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 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
@@ -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: [] # host-folder mounts (see "Folder 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 packed image declares a 1528-page (~95.5 MiB) minimum; `memory_size`
295
- below that fails instantiation (reported as `:sandbox_error`). Practical minimum is
296
- ~128–144MB for small workloads; the 512MB default leaves comfortable headroom.
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: 115877509 bytes
4
- sha256: d7af08b7b11f5ac8bf9b4b8e9cb2ac6ca3e5c977f0dcdfea3551627247589a70
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, monotonic_ms - t0)
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mkmf"
4
+
5
+ create_makefile("sb_rpc")
@@ -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,6 @@
1
+ # Loaded by `require "sb_rpc"` inside the security_box guest image.
2
+ #
3
+ # The extension is statically linked into the ruby.wasm binary; requiring
4
+ # the .so feature name triggers its Init_sb_rpc hook through the static
5
+ # extension registry.
6
+ require "sb_rpc.so"
@@ -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 { |l| Wasmtime::WASI::P1.add_to_linker_sync(l) }
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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecurityBox
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
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.5.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