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
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: sb_rpc
|
|
3
|
+
specs:
|
|
4
|
+
sb_rpc (0.1.0)
|
|
5
|
+
|
|
6
|
+
GEM
|
|
7
|
+
remote: https://rubygems.org/
|
|
8
|
+
specs:
|
|
9
|
+
logger (1.7.0)
|
|
10
|
+
ruby_wasm (2.10.1)
|
|
11
|
+
logger
|
|
12
|
+
ruby_wasm (2.10.1-x86_64-linux)
|
|
13
|
+
logger
|
|
14
|
+
|
|
15
|
+
PLATFORMS
|
|
16
|
+
ruby
|
|
17
|
+
x86_64-linux
|
|
18
|
+
|
|
19
|
+
DEPENDENCIES
|
|
20
|
+
ruby_wasm (~> 2.10.1)
|
|
21
|
+
sb_rpc!
|
|
22
|
+
|
|
23
|
+
CHECKSUMS
|
|
24
|
+
bundler (4.0.20) sha256=7978a8ac648767f5e635bc522445b79e80a52b907a39a36c2d8085ed6bc762ae
|
|
25
|
+
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
|
26
|
+
ruby_wasm (2.10.1) sha256=874e0acb783a8326bfb0db892004816c26963a4276a9f719791a8828e53a05f1
|
|
27
|
+
ruby_wasm (2.10.1-x86_64-linux) sha256=728e29dc688246572b56dcef1c7b131431d07865320c9b8434dd1ccdfd63db2b
|
|
28
|
+
sb_rpc (0.1.0)
|
|
29
|
+
|
|
30
|
+
BUNDLED WITH
|
|
31
|
+
4.0.20
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#include <ruby.h>
|
|
2
|
+
#include <stdint.h>
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Host bridge for security_box RPC.
|
|
6
|
+
*
|
|
7
|
+
* sb_rpc_call is resolved as a wasm import by the host
|
|
8
|
+
* (Wasmtime::Linker#func_new, module "sb", function "call"). The guest
|
|
9
|
+
* blocks inside this call while the host executes the registered handler;
|
|
10
|
+
* the host returns 0 on success (any other value signals a transport
|
|
11
|
+
* failure). The request/response payload travels via /work JSON files
|
|
12
|
+
* written/read by the guest-side Ruby wrapper (sb_rpc.rb in the image),
|
|
13
|
+
* keeping this C code free of any protocol knowledge.
|
|
14
|
+
*/
|
|
15
|
+
__attribute__((import_module("sb"), import_name("call")))
|
|
16
|
+
extern int32_t sb_rpc_call(void);
|
|
17
|
+
|
|
18
|
+
static VALUE
|
|
19
|
+
sb_call(VALUE self)
|
|
20
|
+
{
|
|
21
|
+
return INT2NUM(sb_rpc_call());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
void
|
|
25
|
+
Init_sb_rpc(void)
|
|
26
|
+
{
|
|
27
|
+
VALUE mSBExt = rb_define_module("SBExt");
|
|
28
|
+
rb_define_singleton_method(mSBExt, "call", sb_call, 0);
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "sb_rpc"
|
|
5
|
+
spec.version = "0.1.0"
|
|
6
|
+
spec.summary = "Host RPC bridge for security_box guest images"
|
|
7
|
+
spec.description =
|
|
8
|
+
"Exposes SBExt.call, a wasm import bridge the guest uses to invoke " \
|
|
9
|
+
"host-registered RPC handlers. The call blocks inside the sandbox while " \
|
|
10
|
+
"the host executes the handler; the request/response travel via JSON " \
|
|
11
|
+
"files in /work."
|
|
12
|
+
spec.authors = ["security_box"]
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = ">= 3.0"
|
|
15
|
+
spec.files = Dir["lib/**/*.rb", "ext/**/*.{c,h,rb}"] + ["sb_rpc.gemspec"]
|
|
16
|
+
spec.extensions = ["ext/sb_rpc/extconf.rb"]
|
|
17
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SecurityBox
|
|
6
|
+
# Host side of the guest RPC channel (stage 6).
|
|
7
|
+
#
|
|
8
|
+
# The image statically links the sb_rpc gem, whose C extension declares
|
|
9
|
+
# the wasm import ("sb", "call"). The linker must define it for every
|
|
10
|
+
# instantiation — even when no handlers are configured, or instantiation
|
|
11
|
+
# fails on an unresolved import. Per-eval state (the /work tmpdir, the
|
|
12
|
+
# handler map and the call transcript) travels through Store data and
|
|
13
|
+
# reaches the closure via caller.store_data, so a single definition is
|
|
14
|
+
# safe to share across evaluations, threads and Ractor workers.
|
|
15
|
+
#
|
|
16
|
+
# Protocol (guest side: lib/security_box/guest/rpc.rb):
|
|
17
|
+
# guest writes /work/rpc_req.json {"name", "args"}
|
|
18
|
+
# guest calls SBExt.call (blocks inside the import)
|
|
19
|
+
# host executes handlers[name], writes /work/rpc_resp.json
|
|
20
|
+
# host returns 0 (any non-zero value is a transport failure)
|
|
21
|
+
#
|
|
22
|
+
# Nothing escapes the closure: a raising handler (or any failure in the
|
|
23
|
+
# bridge) becomes an {"ok": false, "error": {"class", "message"}}
|
|
24
|
+
# response the guest can rescue. Messages never include host details
|
|
25
|
+
# (no backtraces, no paths). Handler results are JSON round-tripped,
|
|
26
|
+
# like guest return values — never Marshal.
|
|
27
|
+
module GuestRpc
|
|
28
|
+
IMPORT_MODULE = "sb"
|
|
29
|
+
IMPORT_NAME = "call"
|
|
30
|
+
REQUEST_FILE = "rpc_req.json"
|
|
31
|
+
RESPONSE_FILE = "rpc_resp.json"
|
|
32
|
+
MAX_CALLS = 1_000
|
|
33
|
+
RESULT_LIMIT = 1 << 20
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# Defines the import on `linker` (once per linker; state is
|
|
37
|
+
# per-Store). The closure captures nothing — Ractor-safe.
|
|
38
|
+
def define_import(linker)
|
|
39
|
+
linker.func_new(IMPORT_MODULE, IMPORT_NAME, [], [:i32]) do |caller|
|
|
40
|
+
serve(caller)
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Per-eval Store data. `handlers` is the configuration's
|
|
46
|
+
# name => callable map, or nil when no rpcs are configured (guest
|
|
47
|
+
# calls still get a clean, rescuable error).
|
|
48
|
+
def store_data(workdir, handlers)
|
|
49
|
+
{
|
|
50
|
+
workdir: workdir,
|
|
51
|
+
rpc: handlers.nil? ? nil : { handlers: handlers, calls: [] }
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def serve(caller)
|
|
58
|
+
data = caller.store_data
|
|
59
|
+
rpc = data[:rpc]
|
|
60
|
+
unless rpc
|
|
61
|
+
return fail_call(data, nil, "SB::UnknownTool",
|
|
62
|
+
"no RPC handlers are configured for this sandbox")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
request = JSON.parse(File.read(request_path(data)))
|
|
66
|
+
entry = { "name" => request["name"], "args" => request["args"] }
|
|
67
|
+
rpc[:calls] << entry
|
|
68
|
+
dispatch(data, rpc, request, entry)
|
|
69
|
+
0
|
|
70
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
71
|
+
# Transport/boundary failure (unparsable request, broken /work):
|
|
72
|
+
# encoded as a tool error so the guest gets a rescuable exception
|
|
73
|
+
# instead of the host crashing through the wasm boundary.
|
|
74
|
+
fail_call(data, nil, e.class.name, e.message.to_s)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def dispatch(data, rpc, request, entry)
|
|
78
|
+
if rpc[:calls].size > MAX_CALLS
|
|
79
|
+
return fail_call(data, entry, "SB::ToolError",
|
|
80
|
+
"too many RPC calls (limit #{MAX_CALLS})")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
handler = rpc[:handlers][request["name"]]
|
|
84
|
+
if handler.nil?
|
|
85
|
+
return fail_call(data, entry, "SB::UnknownTool",
|
|
86
|
+
"unknown rpc: #{request["name"]}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result = jsonable(handler.call(request["args"]))
|
|
90
|
+
payload = { "ok" => true, "result" => result }
|
|
91
|
+
if JSON.generate(payload).bytesize > RESULT_LIMIT
|
|
92
|
+
return fail_call(data, entry, "SB::ToolError",
|
|
93
|
+
"rpc result too large (limit #{RESULT_LIMIT} bytes)")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
entry["ok"] = true
|
|
97
|
+
entry["result"] = result
|
|
98
|
+
write_response(data, payload)
|
|
99
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
100
|
+
fail_call(data, entry, e.class.name, e.message.to_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fail_call(data, entry, klass, message)
|
|
104
|
+
error = { "class" => klass, "message" => message }
|
|
105
|
+
# The entry (when present) is already in the transcript — mutate
|
|
106
|
+
# it in place instead of pushing a duplicate.
|
|
107
|
+
if entry
|
|
108
|
+
entry["ok"] = false
|
|
109
|
+
entry["error"] = error
|
|
110
|
+
end
|
|
111
|
+
write_response(data, { "ok" => false, "error" => error })
|
|
112
|
+
0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def request_path(data)
|
|
116
|
+
File.join(data[:workdir], REQUEST_FILE)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def write_response(data, payload)
|
|
120
|
+
File.write(File.join(data[:workdir], RESPONSE_FILE), JSON.generate(payload))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Non-serializable handler results surface as inspect strings, like
|
|
124
|
+
# guest return values (PLAN.md: never Marshal guest/host payloads).
|
|
125
|
+
def jsonable(value)
|
|
126
|
+
JSON.parse(JSON.generate(value))
|
|
127
|
+
rescue StandardError, TypeError
|
|
128
|
+
value.inspect
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecurityBox
|
|
4
|
+
# Bounded pool of :oneshot Sandboxes.
|
|
5
|
+
#
|
|
6
|
+
# What it gives you:
|
|
7
|
+
# - a hard cap on concurrent sandbox evaluations (`size`);
|
|
8
|
+
# - cheap checkout/checkin of pre-built Sandbox objects;
|
|
9
|
+
# - basic metrics (evals, total/average wall time).
|
|
10
|
+
#
|
|
11
|
+
# What it does NOT give you (measured in stage 4, docs/plan/stages/stage_4.md):
|
|
12
|
+
# parallelism. `invoke` holds the GVL for the whole guest lifetime, so
|
|
13
|
+
# concurrent evals on threads serialize. For real parallelism use
|
|
14
|
+
# RactorPool, which runs workers on Ractors with a shared Engine+Module.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# pool = SecurityBox::Pool.new(:lean, size: 4)
|
|
18
|
+
# pool.eval("1 + 1") # => Result (checks out a sandbox)
|
|
19
|
+
# pool.checkout { |sandbox| ... } # manual checkout block
|
|
20
|
+
# pool.shutdown
|
|
21
|
+
class Pool
|
|
22
|
+
attr_reader :size
|
|
23
|
+
|
|
24
|
+
def initialize(profile = nil, size: 4, **options)
|
|
25
|
+
@size = Integer(size)
|
|
26
|
+
raise ArgumentError, "size must be >= 1" if @size < 1
|
|
27
|
+
|
|
28
|
+
@config = resolve_config(profile, options)
|
|
29
|
+
@sandboxes = Queue.new
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@created = 0
|
|
32
|
+
@closed = false
|
|
33
|
+
@metrics = { evals: 0, total_ms: 0.0 }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Checks a sandbox out of the pool for the duration of the block.
|
|
37
|
+
# Blocks while all `size` sandboxes are busy.
|
|
38
|
+
def checkout
|
|
39
|
+
raise PoolClosed, "pool is closed" if closed?
|
|
40
|
+
|
|
41
|
+
sandbox = acquire
|
|
42
|
+
begin
|
|
43
|
+
yield sandbox
|
|
44
|
+
ensure
|
|
45
|
+
release(sandbox)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Checks a sandbox out and runs `code` on it. Per-call overrides are
|
|
50
|
+
# forwarded to Sandbox#eval.
|
|
51
|
+
def eval(code, **overrides)
|
|
52
|
+
t0 = monotonic_ms
|
|
53
|
+
result = checkout { |sandbox| sandbox.eval(code, **overrides) }
|
|
54
|
+
record(monotonic_ms - t0)
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Snapshot of the counters:
|
|
59
|
+
# {size:, created:, evals:, total_ms:, avg_ms:}.
|
|
60
|
+
def metrics
|
|
61
|
+
@mutex.synchronize do
|
|
62
|
+
evals = @metrics[:evals]
|
|
63
|
+
{ size: @size, created: @created, evals: evals,
|
|
64
|
+
total_ms: @metrics[:total_ms].round(2),
|
|
65
|
+
avg_ms: evals.zero? ? 0.0 : (@metrics[:total_ms] / evals).round(2) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def closed?
|
|
70
|
+
@mutex.synchronize { @closed }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Closes the pool. Sandboxes currently checked out keep working until
|
|
74
|
+
# released; threads blocked on an empty pool keep waiting (callers must
|
|
75
|
+
# ensure they do not shut down while checkouts are still being awaited).
|
|
76
|
+
def shutdown
|
|
77
|
+
@mutex.synchronize { @closed = true }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def resolve_config(profile, options)
|
|
83
|
+
case profile
|
|
84
|
+
when nil then Configuration.build(**options)
|
|
85
|
+
when Symbol, String then Registry.resolve(profile).with(**options)
|
|
86
|
+
when Configuration then profile.with(**options)
|
|
87
|
+
else raise ArgumentError, "profile must be a registered name or a Configuration, got #{profile.class}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Hands out an existing sandbox, creates a new one while under `size`, or
|
|
92
|
+
# blocks until another caller releases one. Sandbox creation happens
|
|
93
|
+
# outside the mutex (it may raise, e.g. ImageMissing).
|
|
94
|
+
def acquire
|
|
95
|
+
create = false
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
raise PoolClosed, "pool is closed" if @closed
|
|
98
|
+
|
|
99
|
+
if @created < @size
|
|
100
|
+
@created += 1
|
|
101
|
+
create = true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if create
|
|
106
|
+
begin
|
|
107
|
+
return Sandbox.new(@config)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
@mutex.synchronize { @created -= 1 }
|
|
110
|
+
raise
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# All created sandboxes are busy: block until one is released. (The
|
|
115
|
+
# closed check here mirrors checkout's — a shutdown racing an acquire
|
|
116
|
+
# is still resolved by the release that eventually unblocks this pop.)
|
|
117
|
+
sandbox = @sandboxes.pop
|
|
118
|
+
raise PoolClosed, "pool is closed" if closed?
|
|
119
|
+
|
|
120
|
+
sandbox
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def release(sandbox)
|
|
124
|
+
@sandboxes << sandbox
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def record(duration_ms)
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
@metrics[:evals] += 1
|
|
130
|
+
@metrics[:total_ms] += duration_ms
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def monotonic_ms
|
|
135
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module SecurityBox
|
|
6
|
+
# Pool of Ractor workers sharing one Engine + compiled Module (stage-3 Q2
|
|
7
|
+
# "Plan C"): the pool builds a dedicated runtime pair, makes it
|
|
8
|
+
# Ractor-shareable, and N worker Ractors each keep a private Linker. Per
|
|
9
|
+
# eval a worker creates Store + Instance (~0.2ms), applies the request's
|
|
10
|
+
# fuel/epoch/memory limits (safe: the worker owns its store), and invokes.
|
|
11
|
+
#
|
|
12
|
+
# This is the only supported way to run evaluations in parallel: `invoke`
|
|
13
|
+
# holds the GVL for the whole guest lifetime (stage-4 Q1), so threads
|
|
14
|
+
# serialize (see Pool).
|
|
15
|
+
#
|
|
16
|
+
# Protocol (Ruby 3.4+/4.0 Ractor port model):
|
|
17
|
+
# - eval builds a request {id, code, token, config} and sends it to the
|
|
18
|
+
# worker with the fewest in-flight requests (worker << request);
|
|
19
|
+
# - workers run the shared EvalRun core and push
|
|
20
|
+
# {id, worker, result} back to the main Ractor (Ractor.main << ...);
|
|
21
|
+
# - a main-side collector thread receives from the main Ractor port and
|
|
22
|
+
# routes each result to the Queue of the thread waiting for it (results
|
|
23
|
+
# are matched by request id; this thread must be the only main-port
|
|
24
|
+
# receiver in the process);
|
|
25
|
+
# - every request terminates (epoch + fuel), and a pop deadline converts a
|
|
26
|
+
# dead worker into a :sandbox_error result instead of a hang.
|
|
27
|
+
#
|
|
28
|
+
# Trade-offs (measured, stage-3 Q2 / stage-4): one module deserialize per
|
|
29
|
+
# pool (~0.5s via the disk cache); each worker's eval still pays the
|
|
30
|
+
# ~240ms guest boot. Ratios at 2/4 Ractors on 6 cores: 0.56 / 0.28 wall vs
|
|
31
|
+
# serial, RSS ~111MB with the shared runtime.
|
|
32
|
+
#
|
|
33
|
+
# Usage:
|
|
34
|
+
# pool = SecurityBox::RactorPool.new(:lean, size: 4)
|
|
35
|
+
# pool.eval("1 + 1") # => Result
|
|
36
|
+
# pool.shutdown
|
|
37
|
+
class RactorPool
|
|
38
|
+
STOP = :security_box_stop
|
|
39
|
+
POP_SLACK_MS = 10_000
|
|
40
|
+
|
|
41
|
+
attr_reader :size
|
|
42
|
+
|
|
43
|
+
def initialize(profile = nil, size: 4, **options)
|
|
44
|
+
@size = Integer(size)
|
|
45
|
+
raise ArgumentError, "size must be >= 1" if @size < 1
|
|
46
|
+
|
|
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
|
+
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
@pending = {} # request id => Queue
|
|
52
|
+
@in_flight = Array.new(@size, 0)
|
|
53
|
+
@closed = false
|
|
54
|
+
@next_id = 0
|
|
55
|
+
@metrics = { evals: 0, total_ms: 0.0, timeouts: 0 }
|
|
56
|
+
|
|
57
|
+
engine, module_ = Runtime.build_shareable(
|
|
58
|
+
image_path: @config.image_path,
|
|
59
|
+
epoch_interval_ms: @config.epoch_interval_ms
|
|
60
|
+
)
|
|
61
|
+
@workers = Array.new(@size) do |index|
|
|
62
|
+
build_worker(engine, module_, index)
|
|
63
|
+
end
|
|
64
|
+
start_collector_thread
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Runs `code` on one of the workers and blocks until its Result arrives.
|
|
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.
|
|
73
|
+
def eval(code, **overrides)
|
|
74
|
+
raise PoolClosed, "pool is closed" if closed?
|
|
75
|
+
|
|
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
|
+
|
|
79
|
+
request = {
|
|
80
|
+
id: next_id,
|
|
81
|
+
code: code,
|
|
82
|
+
token: SecureRandom.hex(16),
|
|
83
|
+
config: config
|
|
84
|
+
}
|
|
85
|
+
queue = Queue.new
|
|
86
|
+
worker_index = @mutex.synchronize do
|
|
87
|
+
@pending[request[:id]] = queue
|
|
88
|
+
index = @in_flight.each_with_index.min_by { |count, _| count }.last
|
|
89
|
+
@in_flight[index] += 1
|
|
90
|
+
index
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
@workers[worker_index] << request
|
|
95
|
+
deadline_ms = config.timeout_ms + POP_SLACK_MS
|
|
96
|
+
result = queue.pop(timeout: deadline_ms / 1000.0)
|
|
97
|
+
raise ThreadError, "no result" if result.nil? # Queue#pop timeout
|
|
98
|
+
record(result)
|
|
99
|
+
result
|
|
100
|
+
rescue ThreadError, Ractor::ClosedError
|
|
101
|
+
# Worker died, stalled beyond the deadline, or was shut down mid-flight:
|
|
102
|
+
# report instead of hang or leak the error.
|
|
103
|
+
@mutex.synchronize { @metrics[:timeouts] += 1 }
|
|
104
|
+
Result.worker_unavailable(worker_index)
|
|
105
|
+
ensure
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
@in_flight[worker_index] -= 1 if @in_flight[worker_index] > 0
|
|
108
|
+
@pending.delete(request[:id])
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Snapshot of the counters: {size:, evals:, total_ms:, avg_ms:, timeouts:}.
|
|
114
|
+
def metrics
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
evals = @metrics[:evals]
|
|
117
|
+
{ size: @size, evals: evals, total_ms: @metrics[:total_ms].round(2),
|
|
118
|
+
avg_ms: evals.zero? ? 0.0 : (@metrics[:total_ms] / evals).round(2),
|
|
119
|
+
timeouts: @metrics[:timeouts] }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def closed?
|
|
124
|
+
@mutex.synchronize { @closed }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Asks all workers to stop. Evals already in flight finish; new evals
|
|
128
|
+
# raise PoolClosed.
|
|
129
|
+
def shutdown
|
|
130
|
+
@mutex.synchronize { @closed = true }
|
|
131
|
+
@workers.each { |worker| worker << STOP }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def resolve_config(profile, options)
|
|
137
|
+
case profile
|
|
138
|
+
when nil then Configuration.build(**options)
|
|
139
|
+
when Symbol, String then Registry.resolve(profile).with(**options)
|
|
140
|
+
when Configuration then profile.with(**options)
|
|
141
|
+
else raise ArgumentError, "profile must be a registered name or a Configuration, got #{profile.class}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def build_worker(engine, module_, worker_index)
|
|
146
|
+
Ractor.new(engine, module_, worker_index, name: "security_box-worker-#{worker_index}") do |eng, mod, index|
|
|
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
|
|
155
|
+
loop do
|
|
156
|
+
request = Ractor.receive
|
|
157
|
+
break if request == :security_box_stop
|
|
158
|
+
|
|
159
|
+
begin
|
|
160
|
+
result = SecurityBox::EvalRun.run(
|
|
161
|
+
engine: eng, module_: mod, linker: linker,
|
|
162
|
+
config: request[:config], code: request[:code], token: request[:token]
|
|
163
|
+
)
|
|
164
|
+
rescue StandardError => e
|
|
165
|
+
result = SecurityBox::Result.new(
|
|
166
|
+
status: :sandbox_error,
|
|
167
|
+
stderr: "security_box: worker #{index} crashed: #{e.class}: #{e.message}\n"
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
Ractor.main << { id: request[:id], worker: index, result: result }
|
|
171
|
+
end
|
|
172
|
+
Ractor.main << { type: :stopped, worker: index }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def start_collector_thread
|
|
177
|
+
@collector_thread = Thread.new do
|
|
178
|
+
stopped = 0
|
|
179
|
+
loop do
|
|
180
|
+
message = Ractor.receive
|
|
181
|
+
if message[:type] == :stopped
|
|
182
|
+
stopped += 1
|
|
183
|
+
break if stopped >= @size
|
|
184
|
+
else
|
|
185
|
+
route(message)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
@collector_thread.name = "security_box-collector"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def route(message)
|
|
193
|
+
@mutex.synchronize do
|
|
194
|
+
@in_flight[message[:worker]] -= 1 if @in_flight[message[:worker]] > 0
|
|
195
|
+
queue = @pending.delete(message[:id])
|
|
196
|
+
queue << message[:result] if queue
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def record(result)
|
|
201
|
+
@mutex.synchronize do
|
|
202
|
+
@metrics[:evals] += 1
|
|
203
|
+
@metrics[:total_ms] += result.duration_ms.to_f
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def next_id
|
|
208
|
+
@mutex.synchronize do
|
|
209
|
+
@next_id += 1
|
|
210
|
+
@next_id
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -21,7 +21,12 @@ module SecurityBox
|
|
|
21
21
|
|
|
22
22
|
builder = Configuration::Builder.new
|
|
23
23
|
block&.call(builder)
|
|
24
|
-
|
|
24
|
+
base = base_of(from)
|
|
25
|
+
if base[:fuel_ms] && builder.changes.key?(:fuel)
|
|
26
|
+
raise InvalidConfiguration,
|
|
27
|
+
"base profile #{from.inspect} uses fuel_ms; override fuel_ms (or clear it with fuel_ms: nil) instead of fuel"
|
|
28
|
+
end
|
|
29
|
+
@profiles[key] = Configuration.build(**base, **builder.changes)
|
|
25
30
|
end
|
|
26
31
|
end
|
|
27
32
|
|
data/lib/security_box/result.rb
CHANGED
|
@@ -8,12 +8,17 @@ module SecurityBox
|
|
|
8
8
|
# :fuel_exhausted — CPU budget exhausted
|
|
9
9
|
# :memory_limit — exceeded the store memory_size
|
|
10
10
|
# :sandbox_error — sandbox failure (unexpected trap, missing/invalid envelope)
|
|
11
|
+
#
|
|
12
|
+
# `rpcs` (stage 6): the host-side transcript of guest RPC calls, one
|
|
13
|
+
# frozen {"name", "args", "ok", "result"|"error"} entry per call, or nil
|
|
14
|
+
# when no rpcs were configured (or none were called).
|
|
11
15
|
class Result
|
|
12
16
|
attr_reader :status, :value, :error, :stdout, :stderr,
|
|
13
|
-
:fuel_used, :duration_ms, :guest_duration_ms
|
|
17
|
+
:fuel_used, :duration_ms, :guest_duration_ms, :rpcs
|
|
14
18
|
|
|
15
19
|
def initialize(status:, value: nil, error: nil, stdout: "", stderr: "",
|
|
16
|
-
fuel_used: nil, duration_ms: nil, guest_duration_ms: nil
|
|
20
|
+
fuel_used: nil, duration_ms: nil, guest_duration_ms: nil,
|
|
21
|
+
rpcs: nil)
|
|
17
22
|
@status = status
|
|
18
23
|
@value = value
|
|
19
24
|
@error = error
|
|
@@ -22,6 +27,7 @@ module SecurityBox
|
|
|
22
27
|
@fuel_used = fuel_used
|
|
23
28
|
@duration_ms = duration_ms
|
|
24
29
|
@guest_duration_ms = guest_duration_ms
|
|
30
|
+
@rpcs = rpcs
|
|
25
31
|
freeze
|
|
26
32
|
end
|
|
27
33
|
|
|
@@ -29,6 +35,13 @@ module SecurityBox
|
|
|
29
35
|
@status == :ok
|
|
30
36
|
end
|
|
31
37
|
|
|
38
|
+
# Factory for the "the worker never answered" sandbox failure (dead or
|
|
39
|
+
# stalled Ractor worker, or the pop deadline elapsed).
|
|
40
|
+
def self.worker_unavailable(worker_index)
|
|
41
|
+
new(status: :sandbox_error,
|
|
42
|
+
stderr: "security_box: RactorPool worker #{worker_index} did not answer\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
32
45
|
def to_h
|
|
33
46
|
{
|
|
34
47
|
status: @status,
|
|
@@ -38,7 +51,8 @@ module SecurityBox
|
|
|
38
51
|
stderr: @stderr,
|
|
39
52
|
fuel_used: @fuel_used,
|
|
40
53
|
duration_ms: @duration_ms,
|
|
41
|
-
guest_duration_ms: @guest_duration_ms
|
|
54
|
+
guest_duration_ms: @guest_duration_ms,
|
|
55
|
+
rpcs: @rpcs
|
|
42
56
|
}
|
|
43
57
|
end
|
|
44
58
|
end
|
data/lib/security_box/runtime.rb
CHANGED
|
@@ -41,6 +41,23 @@ module SecurityBox
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Builds a dedicated (not memoized) Engine + Module pair prepared for
|
|
45
|
+
# use across Ractors: the epoch timer is started and the precompile
|
|
46
|
+
# key is touched BEFORE freezing, then both artifacts are made
|
|
47
|
+
# Ractor-shareable (stage-3 Q2 "Plan C"). The pair is not stored in the
|
|
48
|
+
# class memos — freezing shared artifacts would affect every Sandbox —
|
|
49
|
+
# so each RactorPool pays one module deserialize (~0.5s via the disk
|
|
50
|
+
# cache). Callers must treat the returned pair as immutable.
|
|
51
|
+
def build_shareable(image_path:, epoch_interval_ms:)
|
|
52
|
+
engine = build_engine(epoch_interval_ms)
|
|
53
|
+
module_ = ModuleCache.load_module(engine, image_path) ||
|
|
54
|
+
ModuleCache.compile_and_store(engine, image_path)
|
|
55
|
+
engine.precompile_compatibility_key # touch before make_shareable
|
|
56
|
+
Ractor.make_shareable(engine)
|
|
57
|
+
Ractor.make_shareable(module_)
|
|
58
|
+
[engine, module_]
|
|
59
|
+
end
|
|
60
|
+
|
|
44
61
|
private
|
|
45
62
|
|
|
46
63
|
def build_engine(epoch_interval_ms)
|