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.
@@ -13,35 +13,74 @@ module SecurityBox
13
13
  DEFAULTS = {
14
14
  image_path: nil, # resolved dynamically (project's build/security_box.wasm)
15
15
  fuel: 10_000_000_000,
16
+ fuel_ms: nil, # rate-based fuel ergonomics (see FUEL_PER_MS); nil = use :fuel
16
17
  timeout_ms: 2_000,
17
18
  memory_size: 512 * 1024 * 1024,
18
19
  stdout_limit: 1 << 20,
19
20
  stderr_limit: 1 << 16,
20
21
  epoch_interval_ms: 25,
21
- env: {}.freeze
22
+ env: {}.freeze,
23
+ mounts: [].freeze,
24
+ rpcs: {}.freeze
22
25
  }.freeze
23
26
 
24
- attr_reader :image_path, :fuel, :timeout_ms, :memory_size,
25
- :stdout_limit, :stderr_limit, :epoch_interval_ms, :env
27
+ # Fuel-per-ms conversion for #fuel_ms, from the stage-3 calibration table
28
+ # (docs/plan/stages/stage_3.md): compute workloads burn 3.2e9–8.4e9 fuel/s;
29
+ # 4e9/s is the conservative floor. The guest boot baseline (~9.1e8 fuel)
30
+ # is added on top so the budget covers boot even for trivial evals.
31
+ FUEL_PER_MS = 4_000_000
32
+ BOOT_FUEL_ALLOWANCE = 1_000_000_000
33
+
34
+ attr_reader :image_path, :fuel, :fuel_ms, :timeout_ms, :memory_size,
35
+ :stdout_limit, :stderr_limit, :epoch_interval_ms, :env, :mounts,
36
+ :rpcs
26
37
 
27
38
  def self.build(**options)
28
39
  new(**DEFAULTS.merge(options)).freeze
29
40
  end
30
41
 
31
- def initialize(image_path: nil, fuel:, timeout_ms:, memory_size:,
32
- stdout_limit:, stderr_limit:, epoch_interval_ms:, env:)
42
+ def initialize(image_path: nil, fuel:, fuel_ms:, timeout_ms:, memory_size:,
43
+ stdout_limit:, stderr_limit:, epoch_interval_ms:, env:, mounts:,
44
+ rpcs:)
33
45
  @image_path = image_path || default_image_path
34
46
  @fuel = Integer(fuel)
47
+ @fuel_ms = fuel_ms.nil? ? nil : Integer(fuel_ms)
35
48
  @timeout_ms = Integer(timeout_ms)
36
49
  @memory_size = Integer(memory_size)
37
50
  @stdout_limit = Integer(stdout_limit)
38
51
  @stderr_limit = Integer(stderr_limit)
39
52
  @epoch_interval_ms = Integer(epoch_interval_ms)
40
53
  @env = env.freeze
54
+ @mounts = Mounts.normalize(mounts)
55
+ @rpcs = Rpcs.normalize(rpcs)
41
56
  freeze
42
57
  end
43
58
 
59
+ # The fuel budget actually applied to each evaluation. When :fuel_ms is
60
+ # set it takes precedence: an approximate millisecond-based budget
61
+ # (fuel_ms × FUEL_PER_MS + boot allowance) instead of a raw fuel count.
62
+ # The epoch timeout remains the mandatory wall-clock backstop either way.
63
+ def effective_fuel
64
+ return fuel unless fuel_ms
65
+
66
+ fuel_ms * FUEL_PER_MS + BOOT_FUEL_ALLOWANCE
67
+ end
68
+
69
+ # Derives a copy with `changes` applied (never mutates the receiver).
70
+ # Passing both :fuel and a non-nil :fuel_ms is ambiguous and rejected;
71
+ # overriding :fuel on a configuration that uses :fuel_ms is rejected too
72
+ # (pass fuel_ms: nil first to switch to a raw fuel budget).
44
73
  def with(**changes)
74
+ if changes.key?(:fuel_ms)
75
+ if changes[:fuel_ms] && changes.key?(:fuel)
76
+ raise InvalidConfiguration,
77
+ "fuel and fuel_ms are mutually exclusive overrides"
78
+ end
79
+ elsif changes.key?(:fuel) && @fuel_ms
80
+ raise InvalidConfiguration,
81
+ "configuration uses fuel_ms (#{fuel_ms}); override fuel_ms or clear it with fuel_ms: nil instead of fuel"
82
+ end
83
+
45
84
  self.class.build(**to_h.merge(changes))
46
85
  end
47
86
 
@@ -49,12 +88,15 @@ module SecurityBox
49
88
  {
50
89
  image_path: @image_path,
51
90
  fuel: @fuel,
91
+ fuel_ms: @fuel_ms,
52
92
  timeout_ms: @timeout_ms,
53
93
  memory_size: @memory_size,
54
94
  stdout_limit: @stdout_limit,
55
95
  stderr_limit: @stderr_limit,
56
96
  epoch_interval_ms: @epoch_interval_ms,
57
- env: @env
97
+ env: @env,
98
+ mounts: @mounts,
99
+ rpcs: @rpcs
58
100
  }
59
101
  end
60
102
 
@@ -62,12 +104,16 @@ module SecurityBox
62
104
  # hash). Two configurations with equal settings — regardless of how they
63
105
  # were built — share the same fingerprint; any #with change produces a
64
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.
65
111
  def fingerprint
66
112
  Digest::SHA256.hexdigest(JSON.generate(canonical))
67
113
  end
68
114
 
69
115
  def canonical
70
- to_h.merge(env: @env.sort.to_h)
116
+ to_h.except(:rpcs).merge(env: @env.sort.to_h)
71
117
  end
72
118
 
73
119
  # Mutable collector for the register DSL. Setter names match the
@@ -87,9 +133,20 @@ module SecurityBox
87
133
  end
88
134
 
89
135
  def fuel(value)
136
+ raise InvalidConfiguration, "fuel and fuel_ms are mutually exclusive" if @changes.key?(:fuel_ms)
137
+
90
138
  @changes[:fuel] = value
91
139
  end
92
140
 
141
+ # Rate-based fuel ergonomics: sets fuel from an approximate millisecond
142
+ # budget (see Configuration#effective_fuel). Mutually exclusive with
143
+ # an explicit :fuel.
144
+ def fuel_ms(value)
145
+ raise InvalidConfiguration, "fuel and fuel_ms are mutually exclusive" if @changes.key?(:fuel)
146
+
147
+ @changes[:fuel_ms] = value
148
+ end
149
+
93
150
  def timeout_ms(value)
94
151
  @changes[:timeout_ms] = value
95
152
  end
@@ -114,6 +171,52 @@ module SecurityBox
114
171
  def env(value)
115
172
  @changes[:env] = value
116
173
  end
174
+
175
+ # Mounts a host directory into the guest, read-only by default:
176
+ # c.mount "host/path" => "/data"
177
+ # Each call appends one mount; use #mount_rw for a writable mount.
178
+ def mount(mapping)
179
+ add_mount(mapping, :read_only)
180
+ end
181
+
182
+ # Opt-in writable mount — the only way to expose a writable host path
183
+ # besides the sandbox-private /work tmpdir:
184
+ # c.mount_rw "host/path" => "/data"
185
+ def mount_rw(mapping)
186
+ add_mount(mapping, :read_write)
187
+ end
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
+
209
+ private
210
+
211
+ def add_mount(mapping, mode)
212
+ unless mapping.is_a?(Hash) && mapping.size == 1
213
+ raise InvalidConfiguration,
214
+ 'mount expects exactly one pair: c.mount "host/path" => "/data"'
215
+ end
216
+
217
+ host, guest = mapping.first
218
+ @changes[:mounts] = Array(@changes[:mounts]) + [{ host: host, guest: guest, mode: mode }]
219
+ end
117
220
  end
118
221
 
119
222
  private
@@ -135,4 +238,180 @@ module SecurityBox
135
238
  "or pass image_path in the configuration."
136
239
  end
137
240
  end
241
+
242
+ # Validated collection of host-folder mounts (stage 5). A mount is a frozen
243
+ # {host:, guest:, mode:} hash; `mounts` in a Configuration is a frozen array
244
+ # of these, so the value survives #with, #fingerprint and Ractor ports.
245
+ #
246
+ # Validation (InvalidConfiguration on any violation):
247
+ # - mode is :read_only or :read_write (wasmtime silently accepts unknown
248
+ # symbols, so the mode is checked here, never trusted to the runtime)
249
+ # - host: non-empty string; relative paths are expanded against Dir.pwd
250
+ # (existence/directory checks are per-eval, in EvalRun — dirs can vanish)
251
+ # - guest: absolute, normalized path (no "..", no trailing slash), not "/"
252
+ # - guest paths must not overlap /work, /usr or /src (exact or nested):
253
+ # a collision with the embedded VFS is silently shadowed (stage-5 spike:
254
+ # the mount content is invisible), a mount inside /usr even breaks the
255
+ # guest boot, and inside /work it would create a read-only subtree
256
+ # - duplicate guest paths are rejected (wasmtime's last-mount-wins is
257
+ # silently surprising)
258
+ # - at most MAX_MOUNTS mounts
259
+ module Mounts
260
+ MODES = %i[read_only read_write].freeze
261
+ RESERVED_GUEST_PATHS = %w[/work /usr /src].freeze
262
+ MAX_MOUNTS = 16
263
+
264
+ class << self
265
+ # Validates + normalizes `raw` (an array of {host:, guest:, mode:} hashes
266
+ # or nil) and returns a frozen array of frozen, normalized hashes.
267
+ def normalize(raw)
268
+ return [].freeze if raw.nil?
269
+
270
+ raise InvalidConfiguration,
271
+ "mounts must be an Array of {host:, guest:, mode:} hashes, got #{raw.class}" unless raw.is_a?(Array)
272
+
273
+ mounts = raw.map { |entry| normalize_entry(entry) }
274
+ check_duplicates(mounts)
275
+ check_reserved(mounts)
276
+ check_count(mounts)
277
+ mounts.freeze
278
+ end
279
+
280
+ private
281
+
282
+ def normalize_entry(entry)
283
+ unless entry.is_a?(Hash) && entry.keys.sort == %i[guest host mode]
284
+ raise InvalidConfiguration,
285
+ "each mount must be a Hash with exactly :host, :guest and :mode keys, got #{entry.inspect}"
286
+ end
287
+
288
+ mode = entry[:mode]
289
+ unless MODES.include?(mode)
290
+ raise InvalidConfiguration,
291
+ "mount mode must be one of #{MODES.map(&:inspect).join(' or ')}, got #{mode.inspect}"
292
+ end
293
+
294
+ { host: normalize_host(entry[:host]),
295
+ guest: normalize_guest(entry[:guest]),
296
+ mode: mode }.freeze
297
+ end
298
+
299
+ def normalize_host(host)
300
+ unless host.is_a?(String) && !host.empty?
301
+ raise InvalidConfiguration, "mount host path must be a non-empty String, got #{host.inspect}"
302
+ end
303
+
304
+ File.expand_path(host).freeze
305
+ end
306
+
307
+ def normalize_guest(guest)
308
+ unless guest.is_a?(String) && !guest.empty?
309
+ raise InvalidConfiguration, "mount guest path must be a non-empty String, got #{guest.inspect}"
310
+ end
311
+ unless guest.start_with?("/")
312
+ raise InvalidConfiguration, "mount guest path must be absolute, got #{guest.inspect}"
313
+ end
314
+
315
+ guest = guest.chomp("/")
316
+ if guest.empty? || File.expand_path(guest, "/") != guest
317
+ raise InvalidConfiguration,
318
+ "mount guest path must be a normalized absolute path (no '..', '.' or trailing '/'), got #{guest.inspect}"
319
+ end
320
+ raise InvalidConfiguration, "mount guest path contains a NUL byte" if guest.include?("\0")
321
+
322
+ guest.freeze
323
+ end
324
+
325
+ def check_duplicates(mounts)
326
+ guests = mounts.map { |mount| mount[:guest] }
327
+ duplicate = guests.find { |guest| guests.count(guest) > 1 }
328
+ return unless duplicate
329
+
330
+ raise InvalidConfiguration, "duplicate guest mount path #{duplicate.inspect}"
331
+ end
332
+
333
+ def check_reserved(mounts)
334
+ mounts.each do |mount|
335
+ guest = mount[:guest]
336
+ conflict = RESERVED_GUEST_PATHS.find do |reserved|
337
+ guest == reserved || guest.start_with?("#{reserved}/") || reserved.start_with?("#{guest}/")
338
+ end
339
+ next unless conflict
340
+
341
+ raise InvalidConfiguration,
342
+ "mount guest path #{guest.inspect} overlaps the reserved path #{conflict.inspect}"
343
+ end
344
+ end
345
+
346
+ def check_count(mounts)
347
+ return if mounts.size <= MAX_MOUNTS
348
+
349
+ raise InvalidConfiguration,
350
+ "too many mounts (#{mounts.size}); the limit is #{MAX_MOUNTS}"
351
+ end
352
+ end
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
138
417
  end
@@ -6,4 +6,6 @@ module SecurityBox
6
6
  class ImageMissing < Error; end
7
7
 
8
8
  class InvalidConfiguration < Error; end
9
+
10
+ class PoolClosed < Error; end
9
11
  end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "tmpdir"
6
+
7
+ module SecurityBox
8
+ # One :oneshot evaluation against a pre-built runtime (Engine + Module +
9
+ # Linker). This is the shared core used by Sandbox (main Ractor, threads)
10
+ # and by RactorPool workers (one Ractor per worker) — both follow the same
11
+ # protocol: sandbox-exclusive tmpdir mounted as /work, per-eval token via
12
+ # ENV, envelope read back from /work/out.json (stdout sentinel fallback),
13
+ # fuel + epoch deadlines per evaluation, and explicit read-only-by-default
14
+ # host-folder mounts (stage 5) validated per eval.
15
+ #
16
+ # Everything here is self-contained per call: no shared mutable state, safe
17
+ # to run concurrently from multiple Ractors/threads as long as each caller
18
+ # owns its Engine/Module/Linker (Ractors) or shares them thread-safely
19
+ # (wasmtime Engine/Module/Linker are thread-safe).
20
+ module EvalRun
21
+ GUEST_ENTRYPOINT = "/src/main.rb"
22
+ CODE_FILE = "code.rb"
23
+ RESULT_FILE = "out.json"
24
+ TOKEN_ENV_VAR = "SB_TOKEN"
25
+
26
+ class << self
27
+ # Runs `code` once and returns a Result. `config` is a Configuration
28
+ # (read-only); `token` is the per-eval random token generated by the
29
+ # caller (SecureRandom is not guaranteed Ractor-safe, so workers
30
+ # receive it pre-generated).
31
+ def run(engine:, module_:, linker:, config:, code:, token:)
32
+ stdout = +""
33
+ stderr = +""
34
+ t0 = monotonic_ms
35
+
36
+ # Mounts reference host paths that can disappear between building the
37
+ # configuration and this eval; fail here with a clear note instead of
38
+ # letting Wasmtime::Store.new raise an opaque error mid-setup.
39
+ mount_errors = mount_errors(config)
40
+ unless mount_errors.empty?
41
+ return Result.new(status: :sandbox_error, stderr: mount_errors.join("\n") << "\n",
42
+ duration_ms: monotonic_ms - t0)
43
+ end
44
+
45
+ Dir.mktmpdir("security_box") do |workdir|
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
+ )
54
+ envelope = nil
55
+ status = nil
56
+ fuel_used = nil
57
+
58
+ store = nil
59
+ begin
60
+ store = Wasmtime::Store.new(
61
+ engine,
62
+ rpc_data,
63
+ wasi_p1_config: build_wasi(workdir, stdout, stderr, config, token),
64
+ limits: { memory_size: config.memory_size }
65
+ )
66
+ store.set_fuel(config.effective_fuel)
67
+ instance = linker.instantiate(store, module_)
68
+ store.set_epoch_deadline(epoch_ticks(config))
69
+ status = invoke_guest(instance, store)
70
+ fuel_used = config.effective_fuel - store.get_fuel unless status == :timeout
71
+ envelope = read_envelope(workdir, stdout, token)
72
+ rescue Wasmtime::Error => e
73
+ # Instantiation can fail before the guest ever runs (e.g. a
74
+ # memory_size below the module's declared minimum pages).
75
+ status = :sandbox_error
76
+ stderr << "security_box: #{e.class}: #{e.message}\n"
77
+ ensure
78
+ store&.close
79
+ end
80
+
81
+ build_result(status, envelope, stdout, stderr, fuel_used,
82
+ monotonic_ms - t0, rpc_data[:rpc]&.fetch(:calls))
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def build_wasi(workdir, stdout, stderr, config, token)
89
+ wasi = Wasmtime::WasiConfig.new
90
+ .set_stdin_string("")
91
+ .set_stdout_buffer(stdout, config.stdout_limit)
92
+ .set_stderr_buffer(stderr, config.stderr_limit)
93
+ .set_argv(["ruby", GUEST_ENTRYPOINT])
94
+ .set_env(config.env.merge(TOKEN_ENV_VAR => token))
95
+ .set_mapped_directory(workdir, "/work", :read_write)
96
+ # Explicit mounts come after /work; Configuration validation already
97
+ # guarantees no guest-path overlap with /work, /usr or /src.
98
+ config.mounts.reduce(wasi) do |wasi_config, mount|
99
+ wasi_config.set_mapped_directory(mount[:host], mount[:guest], mount[:mode])
100
+ end
101
+ end
102
+
103
+ # Host-side mount validation for this eval (configuration-level shape
104
+ # and collision rules are enforced in Configuration::Mounts).
105
+ def mount_errors(config)
106
+ config.mounts.reject { |mount| File.directory?(mount[:host]) }.map do |mount|
107
+ "security_box: mount #{mount[:host].inspect} (guest #{mount[:guest].inspect}): " \
108
+ "host path does not exist or is not a directory"
109
+ end
110
+ end
111
+
112
+ def epoch_ticks(config)
113
+ [config.timeout_ms / config.epoch_interval_ms + 1, 1].max
114
+ end
115
+
116
+ def invoke_guest(instance, store)
117
+ instance.invoke("_start")
118
+ :ok
119
+ rescue Wasmtime::WasiExit => e
120
+ handle_wasi_exit(e, store)
121
+ rescue Wasmtime::Trap => e
122
+ map_trap(e, store)
123
+ rescue Wasmtime::Error
124
+ # Unexpected runtime error (non-trap): treated as a sandbox failure.
125
+ :sandbox_error
126
+ end
127
+
128
+ def handle_wasi_exit(_exit_error, store)
129
+ # The guest rescues SystemExit; a WasiExit here means exit! or a guest
130
+ # crash before the envelope was written.
131
+ store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
132
+ end
133
+
134
+ def map_trap(trap, store)
135
+ case trap.code
136
+ when :interrupt then :timeout
137
+ when :out_of_fuel then :fuel_exhausted
138
+ when :memory_out_of_bounds then :memory_limit
139
+ else
140
+ store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
141
+ end
142
+ end
143
+
144
+ def read_envelope(workdir, stdout, token)
145
+ # Preferred channel: /work/out.json. Fallback: sentinel line on stdout.
146
+ # Both go through the same strict validation (schema + token).
147
+ Envelope.parse(read_result_file(workdir), token) ||
148
+ Envelope.from_stdout(stdout, token)
149
+ end
150
+
151
+ def read_result_file(workdir)
152
+ File.read(File.join(workdir, RESULT_FILE))
153
+ rescue SystemCallError
154
+ nil
155
+ end
156
+
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
159
+ if envelope.nil?
160
+ status = :sandbox_error if status == :ok
161
+ return Result.new(
162
+ status: status, stdout: stdout, stderr: stderr,
163
+ fuel_used: fuel_used, duration_ms: duration_ms, rpcs: rpcs
164
+ )
165
+ end
166
+
167
+ guest_error = envelope["error"]
168
+ if envelope["ok"]
169
+ Result.new(
170
+ status: status, value: envelope["value"], stdout: stdout, stderr: stderr,
171
+ fuel_used: fuel_used, duration_ms: duration_ms,
172
+ guest_duration_ms: envelope["duration_ms"], rpcs: rpcs
173
+ )
174
+ elsif guest_memory_error?(guest_error)
175
+ # The guest hit the store memory limit through the Ruby interpreter
176
+ # (NoMemoryError instead of a wasm trap) — report it as a limit, not
177
+ # a user error.
178
+ Result.new(
179
+ status: :memory_limit, error: guest_error, stdout: stdout, stderr: stderr,
180
+ fuel_used: fuel_used, duration_ms: duration_ms,
181
+ guest_duration_ms: envelope["duration_ms"], rpcs: rpcs
182
+ )
183
+ else
184
+ Result.new(
185
+ status: :error, error: guest_error, stdout: stdout, stderr: stderr,
186
+ fuel_used: fuel_used, duration_ms: duration_ms,
187
+ guest_duration_ms: envelope["duration_ms"], rpcs: rpcs
188
+ )
189
+ end
190
+ end
191
+
192
+ def guest_memory_error?(guest_error)
193
+ guest_error.is_a?(Hash) && guest_error["class"] == "NoMemoryError"
194
+ end
195
+
196
+ def monotonic_ms
197
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
198
+ end
199
+ end
200
+ end
201
+ 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"