security_box 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdcc33a7689dd5d68b5e9ca6c8cd36a83d8da9aa5a44c9b29a5994bd991c5d9e
4
- data.tar.gz: a2f4fbf283527b9fcf484a9dc06adb3a5d34d2a1c97f355412a880c5204c9283
3
+ metadata.gz: 9949a172b6bf1ddf1eddd9756c8dcbad273d8f2409669304227bb2f981c9fef6
4
+ data.tar.gz: 499c948e3fd57c221f8aef7f8ad6dd63757b36e9d5a4a62d8c8a70823908f86d
5
5
  SHA512:
6
- metadata.gz: 86158b7097c7e53602f80949350d2f2aebaf9f672400f908127fb995fbf829dc5a16928873231d1c2b8539660a0f7c5a0c2f600f40a225558afaf39f83a9ee77
7
- data.tar.gz: e08ee261ac36c91b3fe6761a01e655410d60c3023094e852c7e1fa9ddc8728efe646366bd840c6863264b1f41826874794b52e6a1125dddbca8f662ae3f76fe4
6
+ metadata.gz: 1cf51781bb108aa6849ae24bd1db5b812c7e0a7e94f0d1876eb1a860cabc4a6b00241fdc19b4b142b88da8bdf86ed6e9c5dc53009237f36706a4ff637fe96d5f
7
+ data.tar.gz: 39dfd3af95bc8bfd2e3bd79ac57b118ee706b2f7da3aee98854aea832094b29342e751b2a41bf630c593fd07289ecf7ac041b1d416682924f276a29be4a7259b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-09-14
9
+
10
+ ### Added
11
+
12
+ - Named, reusable profiles: `SecurityBox.register(:name, from: :base) { |c| c.fuel 100 }`
13
+ and `SecurityBox.spawn(:name, **overrides)` (a `Configuration` or the defaults also
14
+ work). Profiles are immutable; duplicate/unknown names raise
15
+ `SecurityBox::InvalidConfiguration`.
16
+ - `SecurityBox::Configuration#fingerprint`: a stable identity for equal settings
17
+ (SHA-256 of the canonical configuration), ready for artifact caches.
18
+ - Guest error envelopes now carry a `backtrace`: user frames only, capped at 20,
19
+ sandbox-internal locations (no host paths). Surfaced via `Result#error["backtrace"]`;
20
+ malformed backtraces invalidate the envelope (`:sandbox_error`).
21
+ - `Result#fuel_used` is now `nil` for `:timeout`: epoch traps restore fuel to the last
22
+ checkpoint, so the restored value understated consumption by orders of magnitude.
23
+ (Truthful on completion, fuel exhaustion and memory-limit traps.)
24
+
25
+ ### Changed
26
+
27
+ - A guest `NoMemoryError` (interpreter OOM against the store limit) is reported as
28
+ `:memory_limit` instead of `:error`.
29
+ - A `memory_size` below the image's declared minimum (1528 pages ≈ 95.5 MiB) now
30
+ returns a `:sandbox_error` Result with a `security_box:` note on stderr instead of
31
+ raising `Wasmtime::Error` out of `#eval`.
32
+ - The sandbox image must be repacked (`rake security_box:build_image`) when
33
+ upgrading: the guest entrypoint protocol changed.
34
+
8
35
  ## [0.2.0] - 2026-09-14
9
36
 
10
37
  ### Added
data/README.md CHANGED
@@ -144,13 +144,44 @@ SecurityBox.eval('1000.times { print "x" * 1000 }', stdout_limit: 4096).stdout.b
144
144
  Exceptions raised by guest code are a *result*, not a sandbox failure:
145
145
 
146
146
  ```ruby
147
- result = SecurityBox.eval('raise ArgumentError, "boom"')
147
+ result = SecurityBox.eval("def boom; raise ArgumentError, 'boom'; end; boom")
148
148
 
149
- result.status # => :error
150
- result.error["class"] # => "ArgumentError"
151
- result.error["message"] # => "boom"
149
+ result.status # => :error
150
+ result.error["class"] # => "ArgumentError"
151
+ result.error["message"] # => "boom"
152
+ result.error["backtrace"] # => ["sandbox:1:in 'Object#boom'", "sandbox:1:in '<main>'"]
152
153
  ```
153
154
 
155
+ The backtrace contains guest frames only (sandbox-internal locations, capped at
156
+ 20 frames) — nothing from the host filesystem leaks.
157
+
158
+ ### Named profiles
159
+
160
+ Reusable configurations registered once and spawned as often as needed:
161
+
162
+ ```ruby
163
+ SecurityBox.register(:default) do |c|
164
+ c.fuel 10_000_000_000
165
+ c.timeout_ms 2_000
166
+ end
167
+
168
+ SecurityBox.register(:lean, from: :default) do |c|
169
+ c.fuel 2_000_000_000 # boot alone costs ~1e9; see the fuel notes below
170
+ c.timeout_ms 500
171
+ end
172
+
173
+ box = SecurityBox.spawn(:lean) # Sandbox from the profile
174
+ box.eval("40 + 2").value # => 42
175
+
176
+ SecurityBox.spawn(:lean, timeout_ms: 100) # per-call override (profile unchanged)
177
+ SecurityBox.spawn # default configuration
178
+ ```
179
+
180
+ Profiles are immutable: duplicate names and unknown names raise
181
+ `SecurityBox::InvalidConfiguration`; overrides never mutate the profile.
182
+ `SecurityBox::Configuration#fingerprint` gives every configuration a stable
183
+ identity (equal settings → equal fingerprint), used to share runtime artifacts.
184
+
154
185
  ### Configuration
155
186
 
156
187
  `SecurityBox::Configuration` is immutable; use `.build` to create and `#with` to derive:
@@ -175,11 +206,11 @@ sandbox = SecurityBox::Sandbox.new(lean)
175
206
  | Status | Meaning |
176
207
  |---|---|
177
208
  | `:ok` | guest code ran and returned a value |
178
- | `:error` | guest code raised an exception (see `#error`) |
179
- | `:timeout` | interrupted by the epoch deadline (wall clock) |
209
+ | `:error` | guest code raised an exception (see `#error`; includes `backtrace`) |
210
+ | `:timeout` | interrupted by the epoch deadline (wall clock); `fuel_used` is `nil` (epoch traps restore fuel to the checkpoint) |
180
211
  | `:fuel_exhausted` | CPU budget exhausted |
181
- | `:memory_limit` | exceeded the store `memory_size` |
182
- | `:sandbox_error` | sandbox failure (unexpected trap, missing/invalid envelope) |
212
+ | `:memory_limit` | exceeded the store `memory_size` (wasm trap or guest `NoMemoryError`) |
213
+ | `:sandbox_error` | sandbox failure (unexpected trap, missing/invalid envelope, `memory_size` below the image's minimum) |
183
214
 
184
215
  Values are JSON-serialized; non-serializable objects are returned as their `inspect`
185
216
  string.
@@ -205,6 +236,17 @@ Notes:
205
236
  - Concurrency: `invoke` holds the GVL, so executions serialize per host process. Scale
206
237
  horizontally with multiple processes (e.g., Puma workers); a stuck guest still can't
207
238
  hang the process thanks to the epoch deadline.
239
+ - Ractor note: wasmtime `Engine`/`Module` are Ractor-shareable and Ractors run wasm in
240
+ parallel (measured ≈3.6x with 4 Ractors on 6 cores); a supported Ractor pool is on
241
+ the roadmap (see `docs/plan/stages/stage_3.md`).
242
+ - Memory: the packed image declares a 1528-page (~95.5 MiB) minimum; `memory_size`
243
+ below that fails instantiation (reported as `:sandbox_error`). Practical minimum is
244
+ ~128–144MB for small workloads; the 512MB default leaves comfortable headroom.
245
+ - Fuel budgeting: compute workloads burn ~4–8e9 fuel/s (tight loops up to ~8.4e9/s)
246
+ and every eval costs ~1e9 fuel for boot — see the calibration table in
247
+ `docs/plan/stages/stage_3.md`. Consequently `fuel` below ~1e9 cannot even boot, and
248
+ `timeout_ms` below ~300ms times out during the guest boot (~500ms is a practical
249
+ floor).
208
250
 
209
251
  ## Running the tests
210
252
 
@@ -240,3 +282,5 @@ bundle exec ruby bin/spike.rb
240
282
  - `docs/plan/stages/stage_1.md` — stage 1 findings and measured numbers
241
283
  - `docs/plan/stages/stage_2.md` — stage 2 findings (hardening prelude, result-channel
242
284
  integrity, compiled-module disk cache)
285
+ - `docs/plan/stages/stage_3.md` — stage 3 findings (named profiles, Ractor
286
+ parallelism, memory floor, fuel calibration, backtrace)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "json"
5
+
3
6
  module SecurityBox
4
7
  # Immutable sandbox configuration. Use .build to create and #with to derive.
5
8
  class Configuration
@@ -55,6 +58,64 @@ module SecurityBox
55
58
  }
56
59
  end
57
60
 
61
+ # Stable identity of the configuration values (SHA-256 of the normalized
62
+ # hash). Two configurations with equal settings — regardless of how they
63
+ # were built — share the same fingerprint; any #with change produces a
64
+ # different one. Used to key profiles and, later, cached artifacts.
65
+ def fingerprint
66
+ Digest::SHA256.hexdigest(JSON.generate(canonical))
67
+ end
68
+
69
+ def canonical
70
+ to_h.merge(env: @env.sort.to_h)
71
+ end
72
+
73
+ # Mutable collector for the register DSL. Setter names match the
74
+ # Configuration options (no `=`, e.g. `c.fuel 100`); only changed values
75
+ # are collected and merged over the base profile.
76
+ class Builder
77
+ def initialize
78
+ @changes = {}
79
+ end
80
+
81
+ def changes
82
+ @changes
83
+ end
84
+
85
+ def image_path(value)
86
+ @changes[:image_path] = value
87
+ end
88
+
89
+ def fuel(value)
90
+ @changes[:fuel] = value
91
+ end
92
+
93
+ def timeout_ms(value)
94
+ @changes[:timeout_ms] = value
95
+ end
96
+
97
+ def memory_size(value)
98
+ @changes[:memory_size] = value
99
+ end
100
+
101
+ def stdout_limit(value)
102
+ @changes[:stdout_limit] = value
103
+ end
104
+
105
+ def stderr_limit(value)
106
+ @changes[:stderr_limit] = value
107
+ end
108
+
109
+ def epoch_interval_ms(value)
110
+ @changes[:epoch_interval_ms] = value
111
+ end
112
+
113
+ # Replaces the guest environment (it is not merged with the base).
114
+ def env(value)
115
+ @changes[:env] = value
116
+ end
117
+ end
118
+
58
119
  private
59
120
 
60
121
  # Resolution order (first existing path wins):
@@ -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
@@ -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,65 @@
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
+ @profiles[key] = Configuration.build(**base_of(from), **builder.changes)
25
+ end
26
+ end
27
+
28
+ def resolve(name)
29
+ key = normalize(name)
30
+ MUTEX.synchronize do
31
+ @profiles.fetch(key) do
32
+ raise InvalidConfiguration, "unknown profile #{key.inspect}; register it first with SecurityBox.register"
33
+ end
34
+ end
35
+ end
36
+
37
+ def profiles
38
+ MUTEX.synchronize { @profiles.dup.freeze }
39
+ end
40
+
41
+ def clear!
42
+ MUTEX.synchronize { @profiles.clear }
43
+ end
44
+
45
+ private
46
+
47
+ def normalize(name)
48
+ unless name.is_a?(Symbol) || name.is_a?(String)
49
+ raise InvalidConfiguration, "profile name must be a Symbol or String, got #{name.class}"
50
+ end
51
+
52
+ name.to_sym
53
+ end
54
+
55
+ def base_of(from)
56
+ return {} if from.nil?
57
+
58
+ key = normalize(from)
59
+ @profiles.fetch(key) do
60
+ raise InvalidConfiguration, "unknown base profile #{key.inspect}; register it before deriving from it"
61
+ end.to_h
62
+ end
63
+ end
64
+ end
65
+ end
@@ -56,8 +56,13 @@ module SecurityBox
56
56
  instance = build_linker.instantiate(store, @module)
57
57
  store.set_epoch_deadline(epoch_ticks(config))
58
58
  status = invoke_guest(instance, store)
59
- fuel_used = config.fuel - store.get_fuel
59
+ fuel_used = config.fuel - store.get_fuel unless status == :timeout
60
60
  envelope = read_envelope(workdir, stdout, token)
61
+ rescue Wasmtime::Error => e
62
+ # Instantiation can fail before the guest ever runs (e.g. a
63
+ # memory_size below the module's declared minimum pages).
64
+ status = :sandbox_error
65
+ stderr << "security_box: #{e.class}: #{e.message}\n"
61
66
  ensure
62
67
  store.close
63
68
  end
@@ -145,6 +150,15 @@ module SecurityBox
145
150
  fuel_used: fuel_used, duration_ms: duration_ms,
146
151
  guest_duration_ms: envelope["duration_ms"]
147
152
  )
153
+ elsif guest_memory_error?(guest_error)
154
+ # The guest hit the store memory limit through the Ruby interpreter
155
+ # (NoMemoryError instead of a wasm trap) — report it as a limit, not
156
+ # a user error.
157
+ Result.new(
158
+ status: :memory_limit, error: guest_error, stdout: stdout, stderr: stderr,
159
+ fuel_used: fuel_used, duration_ms: duration_ms,
160
+ guest_duration_ms: envelope["duration_ms"]
161
+ )
148
162
  else
149
163
  Result.new(
150
164
  status: :error, error: guest_error, stdout: stdout, stderr: stderr,
@@ -154,6 +168,10 @@ module SecurityBox
154
168
  end
155
169
  end
156
170
 
171
+ def guest_memory_error?(guest_error)
172
+ guest_error.is_a?(Hash) && guest_error["class"] == "NoMemoryError"
173
+ end
174
+
157
175
  def monotonic_ms
158
176
  Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
159
177
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecurityBox
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/security_box.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "security_box/version"
4
4
  require_relative "security_box/errors"
5
5
  require_relative "security_box/configuration"
6
+ require_relative "security_box/registry"
6
7
  require_relative "security_box/result"
7
8
  require_relative "security_box/module_cache"
8
9
  require_relative "security_box/runtime"
@@ -17,6 +18,33 @@ module SecurityBox
17
18
  Sandbox.new.eval(code, **overrides)
18
19
  end
19
20
 
21
+ # Registers a named, reusable profile (see Registry).
22
+ # SecurityBox.register(:lean, from: :default) { |c| c.fuel 5_000_000 }
23
+ def self.register(name, from: nil, &block)
24
+ Registry.register(name, from: from, &block)
25
+ end
26
+
27
+ # Builds a Sandbox from a registered profile, a Configuration, or the
28
+ # defaults:
29
+ # SecurityBox.spawn(:lean) # named profile
30
+ # SecurityBox.spawn(:lean, fuel: 1_000) # profile + per-call overrides
31
+ # SecurityBox.spawn(my_config) # explicit configuration
32
+ # SecurityBox.spawn # default configuration
33
+ def self.spawn(profile = nil, **overrides)
34
+ config = case profile
35
+ when nil then Configuration.build(**overrides)
36
+ when Symbol, String
37
+ resolved = Registry.resolve(profile)
38
+ overrides.empty? ? resolved : resolved.with(**overrides)
39
+ when Configuration
40
+ overrides.empty? ? profile : profile.with(**overrides)
41
+ else
42
+ raise ArgumentError,
43
+ "profile must be a registered name or a Configuration, got #{profile.class}"
44
+ end
45
+ Sandbox.new(config)
46
+ end
47
+
20
48
  # Prepares the shared runtime artifacts (Engine + compiled Module) ahead of
21
49
  # time so the first #eval skips the cold module compilation (~15s per
22
50
  # process). #eval warms these caches lazily on first use, so calling
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: security_box
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marcelo Junior
@@ -44,6 +44,7 @@ files:
44
44
  - lib/security_box/guest/main.rb
45
45
  - lib/security_box/guest/prelude.rb
46
46
  - lib/security_box/module_cache.rb
47
+ - lib/security_box/registry.rb
47
48
  - lib/security_box/result.rb
48
49
  - lib/security_box/runtime.rb
49
50
  - lib/security_box/sandbox.rb