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.
@@ -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)
@@ -1,161 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "securerandom"
5
- require "tmpdir"
3
+ require_relative "eval_run"
6
4
 
7
5
  module SecurityBox
8
6
  # :oneshot mode sandbox — one wasm instance per #eval.
9
7
  #
10
- # Flow:
11
- # 1. exclusive tmpdir mounted as /work (read-write)
12
- # 2. user code written to /work/code.rb
13
- # 3. a per-eval random token is passed via ENV (SB_TOKEN); the guest
14
- # prelude captures it and scrubs ENV before user code runs
15
- # 4. guest (/src/main.rb) evaluates the code and writes /work/out.json,
16
- # embedding the token (stdout sentinel fallback does the same)
17
- # 5. host reads the envelope, validates schema + token (untrusted results
18
- # become a sandbox failure) and returns a Result
8
+ # The sandbox itself is stateless and cheap: it holds references to the
9
+ # shared runtime artifacts (Engine + compiled Module, both memoized in
10
+ # Runtime) and a WASI Linker. Each #eval runs the shared EvalRun core: an
11
+ # exclusive tmpdir mounted as /work, a per-eval token, fuel + epoch
12
+ # deadlines, and a validated result envelope.
13
+ #
14
+ # Thread safety: Engine/Module/Linker are thread-safe and the eval core is
15
+ # self-contained, so a single Sandbox can be used from multiple threads
16
+ # (evals serialize on the GVL — see RactorPool for real parallelism).
19
17
  class Sandbox
20
- GUEST_ENTRYPOINT = "/src/main.rb"
21
- RESULT_FILE = "out.json"
22
- CODE_FILE = "code.rb"
23
- TOKEN_ENV_VAR = "SB_TOKEN"
24
-
25
18
  def initialize(configuration = Configuration.build)
26
19
  @config = configuration
27
20
  @engine = Runtime.engine(epoch_interval_ms: @config.epoch_interval_ms)
28
21
  @module = Runtime.module_for(@engine, @config.image_path)
22
+ @linker_mutex = Mutex.new
29
23
  end
30
24
 
31
25
  # Runs `code` in the sandbox and returns a Result.
32
26
  # Per-call options (derived from the configuration without mutating it):
33
- # timeout_ms:, fuel:, memory_size:, stdout_limit:, stderr_limit:
27
+ # timeout_ms:, fuel:, fuel_ms:, memory_size:, stdout_limit:, stderr_limit:,
28
+ # mounts: (replaces the configuration's mounts, like env:)
34
29
  def eval(code, **overrides)
35
30
  raise ArgumentError, "code is required" if code.nil? || code.empty?
36
31
 
37
32
  config = overrides.empty? ? @config : @config.with(**overrides)
38
- stdout = +""
39
- stderr = +""
33
+ # SecureRandom is not guaranteed Ractor-safe; EvalRun callers generate
34
+ # the token in the main Ractor/thread.
40
35
  token = SecureRandom.hex(16)
41
- t0 = monotonic_ms
42
-
43
- Dir.mktmpdir("security_box") do |workdir|
44
- File.write(File.join(workdir, CODE_FILE), code)
45
- envelope = nil
46
- status = nil
47
- fuel_used = nil
48
-
49
- store = Wasmtime::Store.new(
50
- @engine,
51
- wasi_p1_config: build_wasi(workdir, stdout, stderr, config, token),
52
- limits: { memory_size: config.memory_size }
53
- )
54
- begin
55
- store.set_fuel(config.fuel)
56
- instance = build_linker.instantiate(store, @module)
57
- store.set_epoch_deadline(epoch_ticks(config))
58
- status = invoke_guest(instance, store)
59
- fuel_used = config.fuel - store.get_fuel
60
- envelope = read_envelope(workdir, stdout, token)
61
- ensure
62
- store.close
63
- end
64
-
65
- build_result(status, envelope, stdout, stderr, fuel_used, monotonic_ms - t0)
66
- end
36
+ EvalRun.run(engine: @engine, module_: @module, linker: linker,
37
+ config: config, code: code, token: token)
67
38
  end
68
39
 
69
40
  private
70
41
 
71
- def build_wasi(workdir, stdout, stderr, config, token)
72
- Wasmtime::WasiConfig.new
73
- .set_stdin_string("")
74
- .set_stdout_buffer(stdout, config.stdout_limit)
75
- .set_stderr_buffer(stderr, config.stderr_limit)
76
- .set_argv(["ruby", GUEST_ENTRYPOINT])
77
- .set_env(config.env.merge(TOKEN_ENV_VAR => token))
78
- .set_mapped_directory(workdir, "/work", :read_write)
79
- end
80
-
81
- def build_linker
82
- @linker ||= Wasmtime::Linker.new(@engine).tap do |linker|
83
- Wasmtime::WASI::P1.add_to_linker_sync(linker)
84
- end
85
- end
86
-
87
- def epoch_ticks(config)
88
- [config.timeout_ms / config.epoch_interval_ms + 1, 1].max
89
- end
90
-
91
- def invoke_guest(instance, store)
92
- instance.invoke("_start")
93
- :ok
94
- rescue Wasmtime::WasiExit => e
95
- handle_wasi_exit(e, store)
96
- rescue Wasmtime::Trap => e
97
- map_trap(e, store)
98
- rescue Wasmtime::Error
99
- # Unexpected runtime error (non-trap): treated as a sandbox failure.
100
- :sandbox_error
101
- end
102
-
103
- def handle_wasi_exit(exit_error, store)
104
- # The guest rescues SystemExit; a WasiExit here means exit! or a guest crash
105
- # before the envelope was written.
106
- store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
107
- end
108
-
109
- def map_trap(trap, store)
110
- case trap.code
111
- when :interrupt then :timeout
112
- when :out_of_fuel then :fuel_exhausted
113
- when :memory_out_of_bounds then :memory_limit
114
- else
115
- store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
116
- end
117
- end
118
-
119
- def read_envelope(workdir, stdout, token)
120
- # Preferred channel: /work/out.json. Fallback: sentinel line on stdout.
121
- # Both go through the same strict validation (schema + token).
122
- Envelope.parse(read_result_file(workdir), token) ||
123
- Envelope.from_stdout(stdout, token)
124
- end
125
-
126
- def read_result_file(workdir)
127
- File.read(File.join(workdir, RESULT_FILE))
128
- rescue SystemCallError
129
- nil
130
- end
131
-
132
- def build_result(status, envelope, stdout, stderr, fuel_used, duration_ms)
133
- if envelope.nil?
134
- status = :sandbox_error if status == :ok
135
- return Result.new(
136
- status: status, stdout: stdout, stderr: stderr,
137
- fuel_used: fuel_used, duration_ms: duration_ms
138
- )
139
- end
140
-
141
- guest_error = envelope["error"]
142
- if envelope["ok"]
143
- Result.new(
144
- status: status, value: envelope["value"], stdout: stdout, stderr: stderr,
145
- fuel_used: fuel_used, duration_ms: duration_ms,
146
- guest_duration_ms: envelope["duration_ms"]
147
- )
148
- else
149
- Result.new(
150
- status: :error, error: guest_error, stdout: stdout, stderr: stderr,
151
- fuel_used: fuel_used, duration_ms: duration_ms,
152
- guest_duration_ms: envelope["duration_ms"]
153
- )
42
+ def linker
43
+ @linker_mutex.synchronize do
44
+ @linker ||= Wasmtime::Linker.new(@engine).tap do |linker|
45
+ Wasmtime::WASI::P1.add_to_linker_sync(linker)
46
+ end
154
47
  end
155
48
  end
156
-
157
- def monotonic_ms
158
- Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
159
- end
160
49
  end
161
50
  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.5.0"
5
5
  end
data/lib/security_box.rb CHANGED
@@ -3,11 +3,15 @@
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"
9
10
  require_relative "security_box/envelope"
10
11
  require_relative "security_box/sandbox"
12
+ require_relative "security_box/eval_run"
13
+ require_relative "security_box/pool"
14
+ require_relative "security_box/ractor_pool"
11
15
 
12
16
  module SecurityBox
13
17
  # Convenience shortcut:
@@ -17,6 +21,48 @@ module SecurityBox
17
21
  Sandbox.new.eval(code, **overrides)
18
22
  end
19
23
 
24
+ # Registers a named, reusable profile (see Registry).
25
+ # SecurityBox.register(:lean, from: :default) { |c| c.fuel 5_000_000 }
26
+ def self.register(name, from: nil, &block)
27
+ Registry.register(name, from: from, &block)
28
+ end
29
+
30
+ # Builds a Sandbox from a registered profile, a Configuration, or the
31
+ # defaults:
32
+ # SecurityBox.spawn(:lean) # named profile
33
+ # SecurityBox.spawn(:lean, fuel: 1_000) # profile + per-call overrides
34
+ # SecurityBox.spawn(my_config) # explicit configuration
35
+ # SecurityBox.spawn # default configuration
36
+ def self.spawn(profile = nil, **overrides)
37
+ config = case profile
38
+ when nil then Configuration.build(**overrides)
39
+ when Symbol, String
40
+ resolved = Registry.resolve(profile)
41
+ overrides.empty? ? resolved : resolved.with(**overrides)
42
+ when Configuration
43
+ overrides.empty? ? profile : profile.with(**overrides)
44
+ else
45
+ raise ArgumentError,
46
+ "profile must be a registered name or a Configuration, got #{profile.class}"
47
+ end
48
+ Sandbox.new(config)
49
+ end
50
+
51
+ # Builds a Pool of :oneshot sandboxes (bounded concurrency on threads).
52
+ # NOTE: evals serialize on the GVL — see RactorPool for parallelism.
53
+ # SecurityBox.pool(:lean, size: 4).eval("1 + 1")
54
+ def self.pool(profile = nil, size: 4, **overrides)
55
+ Pool.new(profile, size: size, **overrides)
56
+ end
57
+
58
+ # Builds a RactorPool (real parallelism: worker Ractors sharing one
59
+ # Engine+Module). Costs one module deserialize at creation (~0.5s via the
60
+ # disk cache); each eval still pays the guest boot (~240ms).
61
+ # SecurityBox.ractor_pool(:lean, size: 4).eval("1 + 1")
62
+ def self.ractor_pool(profile = nil, size: 4, **overrides)
63
+ RactorPool.new(profile, size: size, **overrides)
64
+ end
65
+
20
66
  # Prepares the shared runtime artifacts (Engine + compiled Module) ahead of
21
67
  # time so the first #eval skips the cold module compilation (~15s per
22
68
  # 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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marcelo Junior
@@ -41,9 +41,13 @@ files:
41
41
  - lib/security_box/configuration.rb
42
42
  - lib/security_box/envelope.rb
43
43
  - lib/security_box/errors.rb
44
+ - lib/security_box/eval_run.rb
44
45
  - lib/security_box/guest/main.rb
45
46
  - lib/security_box/guest/prelude.rb
46
47
  - lib/security_box/module_cache.rb
48
+ - lib/security_box/pool.rb
49
+ - lib/security_box/ractor_pool.rb
50
+ - lib/security_box/registry.rb
47
51
  - lib/security_box/result.rb
48
52
  - lib/security_box/runtime.rb
49
53
  - lib/security_box/sandbox.rb