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.
@@ -1,179 +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 unless status == :timeout
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"
66
- ensure
67
- store.close
68
- end
69
-
70
- build_result(status, envelope, stdout, stderr, fuel_used, monotonic_ms - t0)
71
- end
36
+ EvalRun.run(engine: @engine, module_: @module, linker: linker,
37
+ config: config, code: code, token: token)
72
38
  end
73
39
 
74
40
  private
75
41
 
76
- def build_wasi(workdir, stdout, stderr, config, token)
77
- Wasmtime::WasiConfig.new
78
- .set_stdin_string("")
79
- .set_stdout_buffer(stdout, config.stdout_limit)
80
- .set_stderr_buffer(stderr, config.stderr_limit)
81
- .set_argv(["ruby", GUEST_ENTRYPOINT])
82
- .set_env(config.env.merge(TOKEN_ENV_VAR => token))
83
- .set_mapped_directory(workdir, "/work", :read_write)
84
- end
85
-
86
- def build_linker
87
- @linker ||= Wasmtime::Linker.new(@engine).tap do |linker|
88
- Wasmtime::WASI::P1.add_to_linker_sync(linker)
89
- end
90
- end
91
-
92
- def epoch_ticks(config)
93
- [config.timeout_ms / config.epoch_interval_ms + 1, 1].max
94
- end
95
-
96
- def invoke_guest(instance, store)
97
- instance.invoke("_start")
98
- :ok
99
- rescue Wasmtime::WasiExit => e
100
- handle_wasi_exit(e, store)
101
- rescue Wasmtime::Trap => e
102
- map_trap(e, store)
103
- rescue Wasmtime::Error
104
- # Unexpected runtime error (non-trap): treated as a sandbox failure.
105
- :sandbox_error
106
- end
107
-
108
- def handle_wasi_exit(exit_error, store)
109
- # The guest rescues SystemExit; a WasiExit here means exit! or a guest crash
110
- # before the envelope was written.
111
- store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
112
- end
113
-
114
- def map_trap(trap, store)
115
- case trap.code
116
- when :interrupt then :timeout
117
- when :out_of_fuel then :fuel_exhausted
118
- when :memory_out_of_bounds then :memory_limit
119
- else
120
- store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
121
- end
122
- end
123
-
124
- def read_envelope(workdir, stdout, token)
125
- # Preferred channel: /work/out.json. Fallback: sentinel line on stdout.
126
- # Both go through the same strict validation (schema + token).
127
- Envelope.parse(read_result_file(workdir), token) ||
128
- Envelope.from_stdout(stdout, token)
129
- end
130
-
131
- def read_result_file(workdir)
132
- File.read(File.join(workdir, RESULT_FILE))
133
- rescue SystemCallError
134
- nil
135
- end
136
-
137
- def build_result(status, envelope, stdout, stderr, fuel_used, duration_ms)
138
- if envelope.nil?
139
- status = :sandbox_error if status == :ok
140
- return Result.new(
141
- status: status, stdout: stdout, stderr: stderr,
142
- fuel_used: fuel_used, duration_ms: duration_ms
143
- )
144
- end
145
-
146
- guest_error = envelope["error"]
147
- if envelope["ok"]
148
- Result.new(
149
- status: status, value: envelope["value"], stdout: stdout, stderr: stderr,
150
- fuel_used: fuel_used, duration_ms: duration_ms,
151
- guest_duration_ms: envelope["duration_ms"]
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
- )
162
- else
163
- Result.new(
164
- status: :error, error: guest_error, stdout: stdout, stderr: stderr,
165
- fuel_used: fuel_used, duration_ms: duration_ms,
166
- guest_duration_ms: envelope["duration_ms"]
167
- )
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
168
47
  end
169
48
  end
170
-
171
- def guest_memory_error?(guest_error)
172
- guest_error.is_a?(Hash) && guest_error["class"] == "NoMemoryError"
173
- end
174
-
175
- def monotonic_ms
176
- Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
177
- end
178
49
  end
179
50
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecurityBox
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/security_box.rb CHANGED
@@ -9,6 +9,9 @@ require_relative "security_box/module_cache"
9
9
  require_relative "security_box/runtime"
10
10
  require_relative "security_box/envelope"
11
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"
12
15
 
13
16
  module SecurityBox
14
17
  # Convenience shortcut:
@@ -45,6 +48,21 @@ module SecurityBox
45
48
  Sandbox.new(config)
46
49
  end
47
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
+
48
66
  # Prepares the shared runtime artifacts (Engine + compiled Module) ahead of
49
67
  # time so the first #eval skips the cold module compilation (~15s per
50
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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marcelo Junior
@@ -41,9 +41,12 @@ 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
47
50
  - lib/security_box/registry.rb
48
51
  - lib/security_box/result.rb
49
52
  - lib/security_box/runtime.rb