security_box 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -0
- data/README.md +187 -22
- data/lib/security_box/assets/security_box.wasm +2 -2
- data/lib/security_box/configuration.rb +286 -7
- data/lib/security_box/errors.rb +2 -0
- data/lib/security_box/eval_run.rb +201 -0
- data/lib/security_box/guest/main.rb +4 -1
- data/lib/security_box/guest/rpc.rb +77 -0
- data/lib/security_box/guest_ext/Gemfile +10 -0
- data/lib/security_box/guest_ext/Gemfile.lock +31 -0
- data/lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/extconf.rb +5 -0
- data/lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/sb_rpc.c +29 -0
- data/lib/security_box/guest_ext/sb_rpc/lib/sb_rpc.rb +6 -0
- data/lib/security_box/guest_ext/sb_rpc/sb_rpc.gemspec +17 -0
- data/lib/security_box/guest_rpc.rb +132 -0
- data/lib/security_box/pool.rb +138 -0
- data/lib/security_box/ractor_pool.rb +214 -0
- data/lib/security_box/registry.rb +6 -1
- data/lib/security_box/result.rb +17 -3
- data/lib/security_box/runtime.rb +17 -0
- data/lib/security_box/sandbox.rb +26 -151
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +19 -0
- metadata +12 -1
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
data/lib/security_box/errors.rb
CHANGED
|
@@ -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"
|