security_box 0.3.0 → 0.5.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.
@@ -0,0 +1,191 @@
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
+ envelope = nil
48
+ status = nil
49
+ fuel_used = nil
50
+
51
+ store = nil
52
+ begin
53
+ store = Wasmtime::Store.new(
54
+ engine,
55
+ wasi_p1_config: build_wasi(workdir, stdout, stderr, config, token),
56
+ limits: { memory_size: config.memory_size }
57
+ )
58
+ store.set_fuel(config.effective_fuel)
59
+ instance = linker.instantiate(store, module_)
60
+ store.set_epoch_deadline(epoch_ticks(config))
61
+ status = invoke_guest(instance, store)
62
+ fuel_used = config.effective_fuel - store.get_fuel unless status == :timeout
63
+ envelope = read_envelope(workdir, stdout, token)
64
+ rescue Wasmtime::Error => e
65
+ # Instantiation can fail before the guest ever runs (e.g. a
66
+ # memory_size below the module's declared minimum pages).
67
+ status = :sandbox_error
68
+ stderr << "security_box: #{e.class}: #{e.message}\n"
69
+ ensure
70
+ store&.close
71
+ end
72
+
73
+ build_result(status, envelope, stdout, stderr, fuel_used, monotonic_ms - t0)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def build_wasi(workdir, stdout, stderr, config, token)
80
+ wasi = Wasmtime::WasiConfig.new
81
+ .set_stdin_string("")
82
+ .set_stdout_buffer(stdout, config.stdout_limit)
83
+ .set_stderr_buffer(stderr, config.stderr_limit)
84
+ .set_argv(["ruby", GUEST_ENTRYPOINT])
85
+ .set_env(config.env.merge(TOKEN_ENV_VAR => token))
86
+ .set_mapped_directory(workdir, "/work", :read_write)
87
+ # Explicit mounts come after /work; Configuration validation already
88
+ # guarantees no guest-path overlap with /work, /usr or /src.
89
+ config.mounts.reduce(wasi) do |wasi_config, mount|
90
+ wasi_config.set_mapped_directory(mount[:host], mount[:guest], mount[:mode])
91
+ end
92
+ end
93
+
94
+ # Host-side mount validation for this eval (configuration-level shape
95
+ # and collision rules are enforced in Configuration::Mounts).
96
+ def mount_errors(config)
97
+ config.mounts.reject { |mount| File.directory?(mount[:host]) }.map do |mount|
98
+ "security_box: mount #{mount[:host].inspect} (guest #{mount[:guest].inspect}): " \
99
+ "host path does not exist or is not a directory"
100
+ end
101
+ end
102
+
103
+ def epoch_ticks(config)
104
+ [config.timeout_ms / config.epoch_interval_ms + 1, 1].max
105
+ end
106
+
107
+ def invoke_guest(instance, store)
108
+ instance.invoke("_start")
109
+ :ok
110
+ rescue Wasmtime::WasiExit => e
111
+ handle_wasi_exit(e, store)
112
+ rescue Wasmtime::Trap => e
113
+ map_trap(e, store)
114
+ rescue Wasmtime::Error
115
+ # Unexpected runtime error (non-trap): treated as a sandbox failure.
116
+ :sandbox_error
117
+ end
118
+
119
+ def handle_wasi_exit(_exit_error, store)
120
+ # The guest rescues SystemExit; a WasiExit here means exit! or a guest
121
+ # crash before the envelope was written.
122
+ store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
123
+ end
124
+
125
+ def map_trap(trap, store)
126
+ case trap.code
127
+ when :interrupt then :timeout
128
+ when :out_of_fuel then :fuel_exhausted
129
+ when :memory_out_of_bounds then :memory_limit
130
+ else
131
+ store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
132
+ end
133
+ end
134
+
135
+ def read_envelope(workdir, stdout, token)
136
+ # Preferred channel: /work/out.json. Fallback: sentinel line on stdout.
137
+ # Both go through the same strict validation (schema + token).
138
+ Envelope.parse(read_result_file(workdir), token) ||
139
+ Envelope.from_stdout(stdout, token)
140
+ end
141
+
142
+ def read_result_file(workdir)
143
+ File.read(File.join(workdir, RESULT_FILE))
144
+ rescue SystemCallError
145
+ nil
146
+ end
147
+
148
+ def build_result(status, envelope, stdout, stderr, fuel_used, duration_ms)
149
+ if envelope.nil?
150
+ status = :sandbox_error if status == :ok
151
+ return Result.new(
152
+ status: status, stdout: stdout, stderr: stderr,
153
+ fuel_used: fuel_used, duration_ms: duration_ms
154
+ )
155
+ end
156
+
157
+ guest_error = envelope["error"]
158
+ if envelope["ok"]
159
+ Result.new(
160
+ status: status, value: envelope["value"], stdout: stdout, stderr: stderr,
161
+ fuel_used: fuel_used, duration_ms: duration_ms,
162
+ guest_duration_ms: envelope["duration_ms"]
163
+ )
164
+ elsif guest_memory_error?(guest_error)
165
+ # The guest hit the store memory limit through the Ruby interpreter
166
+ # (NoMemoryError instead of a wasm trap) — report it as a limit, not
167
+ # a user error.
168
+ Result.new(
169
+ status: :memory_limit, error: guest_error, stdout: stdout, stderr: stderr,
170
+ fuel_used: fuel_used, duration_ms: duration_ms,
171
+ guest_duration_ms: envelope["duration_ms"]
172
+ )
173
+ else
174
+ Result.new(
175
+ status: :error, error: guest_error, stdout: stdout, stderr: stderr,
176
+ fuel_used: fuel_used, duration_ms: duration_ms,
177
+ guest_duration_ms: envelope["duration_ms"]
178
+ )
179
+ end
180
+ end
181
+
182
+ def guest_memory_error?(guest_error)
183
+ guest_error.is_a?(Hash) && guest_error["class"] == "NoMemoryError"
184
+ end
185
+
186
+ def monotonic_ms
187
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
188
+ end
189
+ end
190
+ end
191
+ 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,199 @@
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
+ @mutex = Mutex.new
49
+ @pending = {} # request id => Queue
50
+ @in_flight = Array.new(@size, 0)
51
+ @closed = false
52
+ @next_id = 0
53
+ @metrics = { evals: 0, total_ms: 0.0, timeouts: 0 }
54
+
55
+ engine, module_ = Runtime.build_shareable(
56
+ image_path: @config.image_path,
57
+ epoch_interval_ms: @config.epoch_interval_ms
58
+ )
59
+ @workers = Array.new(@size) do |index|
60
+ build_worker(engine, module_, index)
61
+ end
62
+ start_collector_thread
63
+ end
64
+
65
+ # Runs `code` on one of the workers and blocks until its Result arrives.
66
+ # Per-call overrides follow Sandbox#eval (same Configuration#with rules).
67
+ def eval(code, **overrides)
68
+ raise PoolClosed, "pool is closed" if closed?
69
+
70
+ config = overrides.empty? ? @config : @config.with(**overrides)
71
+ request = {
72
+ id: next_id,
73
+ code: code,
74
+ token: SecureRandom.hex(16),
75
+ config: config
76
+ }
77
+ queue = Queue.new
78
+ worker_index = @mutex.synchronize do
79
+ @pending[request[:id]] = queue
80
+ index = @in_flight.each_with_index.min_by { |count, _| count }.last
81
+ @in_flight[index] += 1
82
+ index
83
+ end
84
+
85
+ begin
86
+ @workers[worker_index] << request
87
+ deadline_ms = config.timeout_ms + POP_SLACK_MS
88
+ result = queue.pop(timeout: deadline_ms / 1000.0)
89
+ raise ThreadError, "no result" if result.nil? # Queue#pop timeout
90
+ record(result)
91
+ result
92
+ rescue ThreadError, Ractor::ClosedError
93
+ # Worker died, stalled beyond the deadline, or was shut down mid-flight:
94
+ # report instead of hang or leak the error.
95
+ @mutex.synchronize { @metrics[:timeouts] += 1 }
96
+ Result.worker_unavailable(worker_index)
97
+ ensure
98
+ @mutex.synchronize do
99
+ @in_flight[worker_index] -= 1 if @in_flight[worker_index] > 0
100
+ @pending.delete(request[:id])
101
+ end
102
+ end
103
+ end
104
+
105
+ # Snapshot of the counters: {size:, evals:, total_ms:, avg_ms:, timeouts:}.
106
+ def metrics
107
+ @mutex.synchronize do
108
+ evals = @metrics[:evals]
109
+ { size: @size, evals: evals, total_ms: @metrics[:total_ms].round(2),
110
+ avg_ms: evals.zero? ? 0.0 : (@metrics[:total_ms] / evals).round(2),
111
+ timeouts: @metrics[:timeouts] }
112
+ end
113
+ end
114
+
115
+ def closed?
116
+ @mutex.synchronize { @closed }
117
+ end
118
+
119
+ # Asks all workers to stop. Evals already in flight finish; new evals
120
+ # raise PoolClosed.
121
+ def shutdown
122
+ @mutex.synchronize { @closed = true }
123
+ @workers.each { |worker| worker << STOP }
124
+ end
125
+
126
+ private
127
+
128
+ def resolve_config(profile, options)
129
+ case profile
130
+ when nil then Configuration.build(**options)
131
+ when Symbol, String then Registry.resolve(profile).with(**options)
132
+ when Configuration then profile.with(**options)
133
+ else raise ArgumentError, "profile must be a registered name or a Configuration, got #{profile.class}"
134
+ end
135
+ end
136
+
137
+ def build_worker(engine, module_, worker_index)
138
+ 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) }
140
+ loop do
141
+ request = Ractor.receive
142
+ break if request == :security_box_stop
143
+
144
+ begin
145
+ result = SecurityBox::EvalRun.run(
146
+ engine: eng, module_: mod, linker: linker,
147
+ config: request[:config], code: request[:code], token: request[:token]
148
+ )
149
+ rescue StandardError => e
150
+ result = SecurityBox::Result.new(
151
+ status: :sandbox_error,
152
+ stderr: "security_box: worker #{index} crashed: #{e.class}: #{e.message}\n"
153
+ )
154
+ end
155
+ Ractor.main << { id: request[:id], worker: index, result: result }
156
+ end
157
+ Ractor.main << { type: :stopped, worker: index }
158
+ end
159
+ end
160
+
161
+ def start_collector_thread
162
+ @collector_thread = Thread.new do
163
+ stopped = 0
164
+ loop do
165
+ message = Ractor.receive
166
+ if message[:type] == :stopped
167
+ stopped += 1
168
+ break if stopped >= @size
169
+ else
170
+ route(message)
171
+ end
172
+ end
173
+ end
174
+ @collector_thread.name = "security_box-collector"
175
+ end
176
+
177
+ def route(message)
178
+ @mutex.synchronize do
179
+ @in_flight[message[:worker]] -= 1 if @in_flight[message[:worker]] > 0
180
+ queue = @pending.delete(message[:id])
181
+ queue << message[:result] if queue
182
+ end
183
+ end
184
+
185
+ def record(result)
186
+ @mutex.synchronize do
187
+ @metrics[:evals] += 1
188
+ @metrics[:total_ms] += result.duration_ms.to_f
189
+ end
190
+ end
191
+
192
+ def next_id
193
+ @mutex.synchronize do
194
+ @next_id += 1
195
+ @next_id
196
+ end
197
+ end
198
+ end
199
+ end
@@ -21,7 +21,12 @@ module SecurityBox
21
21
 
22
22
  builder = Configuration::Builder.new
23
23
  block&.call(builder)
24
- @profiles[key] = Configuration.build(**base_of(from), **builder.changes)
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
 
@@ -29,6 +29,13 @@ module SecurityBox
29
29
  @status == :ok
30
30
  end
31
31
 
32
+ # Factory for the "the worker never answered" sandbox failure (dead or
33
+ # stalled Ractor worker, or the pop deadline elapsed).
34
+ def self.worker_unavailable(worker_index)
35
+ new(status: :sandbox_error,
36
+ stderr: "security_box: RactorPool worker #{worker_index} did not answer\n")
37
+ end
38
+
32
39
  def to_h
33
40
  {
34
41
  status: @status,
@@ -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)