security_box 0.2.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.
@@ -55,11 +55,20 @@ module SecurityBox
55
55
  return false unless error.is_a?(Hash)
56
56
  return false unless error["class"].is_a?(String)
57
57
  return false unless error["message"].is_a?(String)
58
+ return false unless valid_backtrace?(error["backtrace"])
58
59
  end
59
60
 
60
61
  duration = envelope["duration_ms"]
61
62
  duration.nil? || duration.is_a?(Numeric)
62
63
  end
64
+
65
+ # Optional: an array of backtrace frame strings (guest-side errors).
66
+ def valid_backtrace?(backtrace)
67
+ return true if backtrace.nil?
68
+ return false unless backtrace.is_a?(Array)
69
+
70
+ backtrace.all?(String)
71
+ end
63
72
  end
64
73
  end
65
74
  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,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
@@ -43,7 +43,26 @@ module SB
43
43
  def evaluate(code)
44
44
  { ok: true, value: jsonable(eval(code, TOPLEVEL_BINDING, "sandbox")) } # rubocop:disable Security/Eval,Style/EvalWithLocation
45
45
  rescue Exception => e # rubocop:disable Lint/RescueException
46
- { ok: false, value: nil, error: { "class" => e.class.name, "message" => e.message.to_s } }
46
+ {
47
+ ok: false, value: nil,
48
+ error: {
49
+ "class" => e.class.name,
50
+ "message" => e.message.to_s,
51
+ "backtrace" => guest_backtrace(e)
52
+ }
53
+ }
54
+ end
55
+
56
+ # Frames of the user code only: guest protocol internals (main.rb /
57
+ # prelude.rb) are stripped and the list is capped so a deep recursion
58
+ # cannot flood the result channel.
59
+ def guest_backtrace(exception)
60
+ frames = (exception.backtrace || []).reject do |frame|
61
+ frame.include?("main.rb") || frame.include?("prelude.rb")
62
+ end
63
+ frames.first(20)
64
+ rescue StandardError
65
+ []
47
66
  end
48
67
 
49
68
  # Values must survive JSON; the round-trip normalizes what the host will read.
@@ -55,11 +74,22 @@ module SB
55
74
 
56
75
  # Preferred: /work/out.json. Fallback (no /work): sentinel on stdout.
57
76
  # Both carry the sandbox token so the host can reject forged results.
77
+ # The write is verified by reading it back — a silent truncation or
78
+ # partial write falls through to the sentinel instead of losing the
79
+ # result.
58
80
  def emit(out, token)
59
81
  json = JSON.generate(out.merge(token: token))
60
- File.write(File.join(WORK_DIR, "out.json"), json)
82
+ unless write_verified(json)
83
+ $stdout.puts "#{SENTINEL}:#{token}:#{json}"
84
+ end
85
+ end
86
+
87
+ def write_verified(json)
88
+ path = File.join(WORK_DIR, "out.json")
89
+ File.write(path, json)
90
+ File.read(path) == json
61
91
  rescue StandardError, SystemCallError
62
- $stdout.puts "#{SENTINEL}:#{token}:#{json}"
92
+ false
63
93
  end
64
94
 
65
95
  def fatal(message, token)
@@ -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
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecurityBox
4
+ # Named, reusable profiles: `SecurityBox.register(:name, from: :base) { |c| ... }`
5
+ # stores an immutable Configuration built through the builder DSL; `spawn`
6
+ # resolves a profile and derives per-call overrides with `#with`.
7
+ #
8
+ # Profiles must be registered before they are referenced by `from:`, cannot
9
+ # be redefined (immutability avoids accidental shadowing) and live for the
10
+ # process lifetime; tests can reset the registry with `clear!`.
11
+ module Registry
12
+ MUTEX = Mutex.new
13
+
14
+ @profiles = {}
15
+
16
+ class << self
17
+ def register(name, from: nil, &block)
18
+ key = normalize(name)
19
+ MUTEX.synchronize do
20
+ raise InvalidConfiguration, "profile #{key.inspect} is already registered" if @profiles.key?(key)
21
+
22
+ builder = Configuration::Builder.new
23
+ block&.call(builder)
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)
30
+ end
31
+ end
32
+
33
+ def resolve(name)
34
+ key = normalize(name)
35
+ MUTEX.synchronize do
36
+ @profiles.fetch(key) do
37
+ raise InvalidConfiguration, "unknown profile #{key.inspect}; register it first with SecurityBox.register"
38
+ end
39
+ end
40
+ end
41
+
42
+ def profiles
43
+ MUTEX.synchronize { @profiles.dup.freeze }
44
+ end
45
+
46
+ def clear!
47
+ MUTEX.synchronize { @profiles.clear }
48
+ end
49
+
50
+ private
51
+
52
+ def normalize(name)
53
+ unless name.is_a?(Symbol) || name.is_a?(String)
54
+ raise InvalidConfiguration, "profile name must be a Symbol or String, got #{name.class}"
55
+ end
56
+
57
+ name.to_sym
58
+ end
59
+
60
+ def base_of(from)
61
+ return {} if from.nil?
62
+
63
+ key = normalize(from)
64
+ @profiles.fetch(key) do
65
+ raise InvalidConfiguration, "unknown base profile #{key.inspect}; register it before deriving from it"
66
+ end.to_h
67
+ end
68
+ end
69
+ end
70
+ end
@@ -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,