kobako 0.12.1 → 0.13.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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +22 -0
  4. data/Cargo.lock +138 -136
  5. data/Cargo.toml +6 -2
  6. data/README.md +3 -1
  7. data/crates/kobako-runtime/CHANGELOG.md +16 -0
  8. data/crates/kobako-runtime/Cargo.toml +23 -0
  9. data/crates/kobako-runtime/README.md +34 -0
  10. data/crates/kobako-runtime/src/dispatch.rs +22 -0
  11. data/crates/kobako-runtime/src/error.rs +64 -0
  12. data/crates/kobako-runtime/src/lib.rs +18 -0
  13. data/crates/kobako-runtime/src/profile.rs +35 -0
  14. data/crates/kobako-runtime/src/runtime.rs +57 -0
  15. data/crates/kobako-runtime/src/snapshot.rs +46 -0
  16. data/crates/kobako-runtime/src/yielder.rs +22 -0
  17. data/crates/kobako-wasmtime/CHANGELOG.md +16 -0
  18. data/crates/kobako-wasmtime/Cargo.toml +62 -0
  19. data/crates/kobako-wasmtime/README.md +32 -0
  20. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/ambient.rs +8 -5
  21. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/cache.rs +30 -41
  22. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/capture.rs +2 -2
  23. data/crates/kobako-wasmtime/src/config.rs +33 -0
  24. data/crates/kobako-wasmtime/src/dispatch.rs +110 -0
  25. data/crates/kobako-wasmtime/src/driver.rs +297 -0
  26. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/exports.rs +5 -6
  27. data/crates/kobako-wasmtime/src/frames.rs +276 -0
  28. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/guest_mem.rs +38 -15
  29. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/instance_pre.rs +13 -21
  30. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/invocation.rs +54 -49
  31. data/crates/kobako-wasmtime/src/lib.rs +47 -0
  32. data/{ext/kobako/src/runtime → crates/kobako-wasmtime/src}/trap.rs +29 -35
  33. data/data/kobako.wasm +0 -0
  34. data/ext/kobako/Cargo.toml +9 -32
  35. data/ext/kobako/src/lib.rs +0 -2
  36. data/ext/kobako/src/runtime/bridge.rs +150 -0
  37. data/ext/kobako/src/runtime/errors.rs +45 -13
  38. data/ext/kobako/src/runtime.rs +246 -420
  39. data/lib/kobako/catalog/handles.rb +3 -3
  40. data/lib/kobako/catalog/namespaces.rb +4 -0
  41. data/lib/kobako/catalog/snippets.rb +4 -0
  42. data/lib/kobako/codec/encoder.rb +5 -1
  43. data/lib/kobako/codec/factory.rb +41 -13
  44. data/lib/kobako/codec/handle_walk.rb +4 -0
  45. data/lib/kobako/codec.rb +1 -1
  46. data/lib/kobako/errors.rb +18 -16
  47. data/lib/kobako/sandbox.rb +71 -39
  48. data/lib/kobako/sandbox_options.rb +71 -13
  49. data/lib/kobako/transport/dispatcher.rb +2 -2
  50. data/lib/kobako/transport/response.rb +14 -14
  51. data/lib/kobako/transport/run.rb +2 -6
  52. data/lib/kobako/transport/yield.rb +1 -1
  53. data/lib/kobako/transport/yielder.rb +2 -2
  54. data/lib/kobako/version.rb +1 -1
  55. data/lib/kobako.rb +0 -1
  56. data/release-please-config.json +78 -3
  57. data/sig/kobako/codec/factory.rbs +3 -0
  58. data/sig/kobako/errors.rbs +7 -14
  59. data/sig/kobako/runtime.rbs +16 -6
  60. data/sig/kobako/sandbox.rbs +11 -7
  61. data/sig/kobako/sandbox_options.rbs +15 -4
  62. data/sig/kobako/transport/dispatcher.rbs +1 -1
  63. data/sig/kobako/transport/run.rbs +2 -2
  64. data/sig/kobako/transport/yielder.rbs +2 -2
  65. data/sig/kobako/transport.rbs +8 -0
  66. metadata +28 -15
  67. data/ext/kobako/src/runtime/config.rs +0 -25
  68. data/ext/kobako/src/runtime/dispatch.rs +0 -211
  69. data/ext/kobako/src/runtime/frames.rs +0 -188
  70. data/ext/kobako/src/snapshot.rs +0 -110
  71. data/lib/kobako/snapshot.rb +0 -38
  72. data/sig/kobako/snapshot.rbs +0 -15
@@ -38,7 +38,7 @@ module Kobako
38
38
  # for it. +object+ is any host-side Ruby object to bind. Returns a
39
39
  # freshly-allocated +Kobako::Handle+ whose +#id+ falls in
40
40
  # +[Kobako::Handle::MIN_ID, Kobako::Handle::MAX_ID]+. Raises
41
- # +Kobako::HandlerExhaustedError+ if the next ID would exceed the
41
+ # +Kobako::HandleExhaustedError+ if the next ID would exceed the
42
42
  # cap. The cap is anchored on +Kobako::Handle+ — the wire codec
43
43
  # and the allocator share the same invariant.
44
44
  #
@@ -96,12 +96,12 @@ module Kobako
96
96
  end
97
97
 
98
98
  # Guard {#alloc} against issuing an ID past the cap. Returns +nil+
99
- # on success; raises +Kobako::HandlerExhaustedError+ at exhaustion.
99
+ # on success; raises +Kobako::HandleExhaustedError+ at exhaustion.
100
100
  def ensure_capacity!
101
101
  cap = Kobako::Handle::MAX_ID
102
102
  return unless @next_id > cap
103
103
 
104
- raise HandlerExhaustedError,
104
+ raise HandleExhaustedError,
105
105
  "Out of handle allocations: too many host objects were referenced " \
106
106
  "in a single invocation (limit #{cap})"
107
107
  end
@@ -82,6 +82,10 @@ module Kobako
82
82
  # first invocation, so the preamble is exactly the bindings that
83
83
  # existed at that moment — a bind reaching a +Kobako::Namespace+
84
84
  # after the seal raises +ArgumentError+ and never alters Frame 1.
85
+ # The memo is gated on the seal rather than dropped per mutation (the
86
+ # +Catalog::Snippets#encode+ approach) because a +Member+ bind lands
87
+ # on a child +Kobako::Namespace+, invisible to this collection; only
88
+ # the seal guarantees nothing further can change.
85
89
  def encode
86
90
  return @encoded if @encoded
87
91
 
@@ -44,6 +44,10 @@ module Kobako
44
44
  # The bytes are memoized — the table is replayed verbatim on every
45
45
  # invocation after sealing, so Frame 3 never changes between
46
46
  # encodes; {#register} drops the memo while the table is still open.
47
+ # Unlike +Catalog::Namespaces#encode+, which gates its memo on the
48
+ # seal, this one can fill eagerly and invalidate in +#register+
49
+ # because every mutation funnels through that single method — there is
50
+ # no out-of-sight child object to change the result behind its back.
47
51
  def encode
48
52
  return @encoded if @encoded
49
53
 
@@ -26,7 +26,11 @@ module Kobako
26
26
  # mapping is a closed set, and anything outside it is rejected by
27
27
  # the msgpack gem itself (arbitrary objects raise +NoMethodError+
28
28
  # from missing +to_msgpack+, integers outside i64..u64 raise
29
- # +RangeError+).
29
+ # +RangeError+). The +NoMethodError+ catch is deliberately broad:
30
+ # MessagePack signals "no wire representation" only through that error,
31
+ # so there is no narrower discriminator — a packer-internal
32
+ # +NoMethodError+ is likewise reported as +UnsupportedType+ rather than
33
+ # propagating.
30
34
  def self.encode(value)
31
35
  Factory.dump(value)
32
36
  rescue ::RangeError, ::NoMethodError => e
@@ -48,6 +48,16 @@ module Kobako
48
48
  EXT_ERRENV = 0x02
49
49
  private_constant :EXT_SYMBOL, :EXT_HANDLE, :EXT_ERRENV
50
50
 
51
+ # An ext 0x02 (Fault) envelope nests through its +details+ field, and
52
+ # each level re-enters the codec with a fresh +MessagePack+ unpacker
53
+ # whose built-in stack guard resets — so ext-envelope depth is tracked
54
+ # here instead. The cap matches the wire's overall nesting bound and
55
+ # keeps a nested chain from exhausting the native stack: an over-deep
56
+ # chain fails as a clean wire error, never a stack-level trap.
57
+ MAX_EXT_DEPTH = 128
58
+ EXT_DEPTH_KEY = :__kobako_codec_ext_depth__
59
+ private_constant :MAX_EXT_DEPTH, :EXT_DEPTH_KEY
60
+
51
61
  # Instance-level pass-through onto the wrapped +MessagePack::Factory+.
52
62
  # Spelled +def_instance_delegators+ rather than +def_delegators+ because
53
63
  # the class also extends +SingleForwardable+ (see the +extend+ block
@@ -129,9 +139,13 @@ module Kobako
129
139
  # Encode the inner ext-0x02 map via {Encoder} (not +factory.dump+) so
130
140
  # the embedded payload flows through the same boundary as a top-level
131
141
  # encode — nested kobako values (Handle, nested Fault) reach the
132
- # registered ext-type packers via the cached singleton.
142
+ # registered ext-type packers via the cached singleton. A +details+
143
+ # chain nested past {MAX_EXT_DEPTH} has no wire representation and
144
+ # surfaces as +UnsupportedType+.
133
145
  def pack_fault(fault)
134
- Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
146
+ within_ext_frame(UnsupportedType) do
147
+ Encoder.encode("type" => fault.type, "message" => fault.message, "details" => fault.details)
148
+ end
135
149
  end
136
150
 
137
151
  # Peel the embedded msgpack map and hand it to +Kobako::Fault.new+
@@ -139,19 +153,33 @@ module Kobako
139
153
  # +ArgumentError+ invariants surface as +InvalidType+ through the
140
154
  # decoder boundary. Inner decode goes through {Decoder} (not
141
155
  # +factory.load+) so the embedded +str+ payloads flow through the
142
- # same UTF-8 validation as a top-level decode.
143
- #
144
- # This establishes a runtime cycle Factory Decoder Factory: the
145
- # singleton instance feeds +Decoder.decode+, which re-enters this
146
- # method when a nested ext 0x02 appears inside +details+. The recursion
147
- # is bounded by msgpack nesting depth — identical to nested Array /
148
- # Hash payloads — so no extra guard is needed. Do not switch back to
149
- # +factory.load+ to "simplify": that path bypasses UTF-8 validation.
156
+ # same UTF-8 validation as a top-level decode. A nested ext 0x02 in
157
+ # +details+ re-enters this method, so {#within_ext_frame} bounds the
158
+ # chain depth to keep it from exhausting the native stack.
150
159
  def unpack_fault(payload)
151
- Decoder.decode(payload) do |map|
152
- raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
160
+ within_ext_frame(InvalidType) do
161
+ Decoder.decode(payload) do |map|
162
+ raise InvalidType, "Fault payload must be a map" unless map.is_a?(Hash)
163
+
164
+ Kobako::Fault.new(type: map["type"], message: map["message"], details: map["details"])
165
+ end
166
+ end
167
+ end
153
168
 
154
- Kobako::Fault.new(type: map["type"], message: map["message"], details: map["details"])
169
+ # Track ext-envelope re-entry depth and refuse a chain past
170
+ # {MAX_EXT_DEPTH}, raising +over_limit+ so the failure lands in the
171
+ # caller's existing wire-error class. The counter is thread-scoped and
172
+ # balanced by the +ensure+, so a raise mid-chain still unwinds it to
173
+ # its entry value.
174
+ def within_ext_frame(over_limit)
175
+ depth = (Thread.current[EXT_DEPTH_KEY] || 0) + 1
176
+ raise over_limit, "ext envelope nesting exceeds #{MAX_EXT_DEPTH} levels" if depth > MAX_EXT_DEPTH
177
+
178
+ Thread.current[EXT_DEPTH_KEY] = depth
179
+ begin
180
+ yield
181
+ ensure
182
+ Thread.current[EXT_DEPTH_KEY] = depth - 1
155
183
  end
156
184
  end
157
185
  end
@@ -97,6 +97,10 @@ module Kobako
97
97
  case value
98
98
  when ::Array then value.map { |element| HandleWalk.deep_restore(element, handler) }
99
99
  when ::Hash
100
+ # Rebuilt with each key restored: two distinct Handle keys that
101
+ # resolve to equal host objects collapse to the later pair, as in
102
+ # any Ruby Hash. The guest authored this payload, so that collapse
103
+ # is its own concern, not a fidelity guarantee the host owes it.
100
104
  value.to_h { |key, val| [HandleWalk.deep_restore(key, handler), HandleWalk.deep_restore(val, handler)] }
101
105
  when Kobako::Handle then handler.fetch(value.id)
102
106
  else value
data/lib/kobako/codec.rb CHANGED
@@ -22,7 +22,7 @@ module Kobako
22
22
  # {Decoder} are thin wrappers that register the three kobako-specific
23
23
  # ext types (0x00 Symbol, 0x01 Capability Handle, 0x02 Exception
24
24
  # envelope) on a single +MessagePack::Factory+ instance. The Rust side
25
- # mirrors this layer as the +codec+ module in the +kobako-core+ crate;
25
+ # mirrors this layer as the +codec+ module in the +kobako-codec+ crate;
26
26
  # the ext-code constants live as module-private values on {Factory}
27
27
  # alongside +codec::EXT_SYMBOL+ / +codec::EXT_HANDLE+ /
28
28
  # +codec::EXT_ERRENV+ on that side.
data/lib/kobako/errors.rb CHANGED
@@ -35,7 +35,7 @@ module Kobako
35
35
  #
36
36
  # * {ModuleNotBuiltError} < {SetupError} — Guest Binary artifact absent
37
37
  # at +wasm_path+.
38
- # * {HandlerExhaustedError} < {SandboxError} — Handle id cap hit.
38
+ # * {HandleExhaustedError} < {SandboxError} — Handle id cap hit.
39
39
 
40
40
  # Base for all kobako-raised errors so callers that want to ignore the
41
41
  # taxonomy can rescue a single class.
@@ -89,10 +89,13 @@ module Kobako
89
89
  # +ModuleNotBuiltError+ first.
90
90
  class ModuleNotBuiltError < SetupError; end
91
91
 
92
- # Sandbox / wire layer. Raised when the guest ran to completion but
93
- # execution failed due to a mruby script error, a protocol fault, or a
94
- # host-side wire decode failure on an otherwise valid outcome tag.
95
- class SandboxError < Error
92
+ # The structured attribution the two invocation-failure classes carry
93
+ # from a decoded guest exception its +origin+, original +klass+,
94
+ # +backtrace_lines+, and +details+ so a Host App can inspect a failure
95
+ # beyond its message. Mixed into both rather than promoted to a shared
96
+ # superclass because +SandboxError+ and +ServiceError+ sit in distinct
97
+ # branches of the invocation-outcome taxonomy under +Kobako::Error+.
98
+ module StructuredError
96
99
  attr_reader :origin, :klass, :backtrace_lines, :details
97
100
 
98
101
  def initialize(message, origin: nil, klass: nil, backtrace_lines: nil, details: nil)
@@ -104,26 +107,25 @@ module Kobako
104
107
  end
105
108
  end
106
109
 
110
+ # Sandbox / wire layer. Raised when the guest ran to completion but
111
+ # execution failed due to a mruby script error, a protocol fault, or a
112
+ # host-side wire decode failure on an otherwise valid outcome tag.
113
+ class SandboxError < Error
114
+ include StructuredError
115
+ end
116
+
107
117
  # Service layer. Raised when a Service capability call inside a mruby
108
118
  # script reported an application-level failure that the script did not
109
119
  # rescue.
110
120
  class ServiceError < Error
111
- attr_reader :origin, :klass, :backtrace_lines, :details
112
-
113
- def initialize(message, origin: nil, klass: nil, backtrace_lines: nil, details: nil)
114
- super(message)
115
- @origin = origin
116
- @klass = klass
117
- @backtrace_lines = backtrace_lines
118
- @details = details
119
- end
121
+ include StructuredError
120
122
  end
121
123
 
122
- # HandlerExhaustedError is the canonical SandboxError subclass for the
124
+ # HandleExhaustedError is the canonical SandboxError subclass for the
123
125
  # id-cap-hit path. Raised when the per-invocation Handle ID counter in
124
126
  # Catalog::Handles reaches +0x7fff_ffff+ (2³¹ − 1) and further
125
127
  # allocation would exceed the cap.
126
- class HandlerExhaustedError < SandboxError; end
128
+ class HandleExhaustedError < SandboxError; end
127
129
 
128
130
  # BytecodeError is the SandboxError subclass raised when a
129
131
  # `#preload(binary:)` snippet fails structural validation during the
@@ -38,14 +38,16 @@ module Kobako
38
38
 
39
39
  attr_reader :wasm_path, :options
40
40
 
41
- # Per-cap accessors forward to the immutable +SandboxOptions+ Value
41
+ # Per-option accessors forward to the immutable +SandboxOptions+ Value
42
42
  # Object so the Host App still reads them off Sandbox directly.
43
- def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit
43
+ def_delegators :@options, :timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile
44
44
 
45
45
  # Returns the bytes the guest wrote to stdout during the most recent
46
46
  # invocation as a UTF-8 String, clipped at +stdout_limit+. Empty before
47
47
  # any invocation; the byte content never contains a truncation sentinel,
48
- # so use +#stdout_truncated?+ to observe overflow.
48
+ # so use +#stdout_truncated?+ to observe overflow. Populated on every
49
+ # outcome — including a rescued +TrapError+, after which it holds the
50
+ # bytes written before the trap fired — mirroring +#usage+.
49
51
  def stdout
50
52
  @stdout_capture.bytes
51
53
  end
@@ -83,23 +85,24 @@ module Kobako
83
85
  # Build a fresh Sandbox.
84
86
  #
85
87
  # +wasm_path+ is the absolute path to the Guest Binary; defaults to the
86
- # gem-bundled +data/kobako.wasm+. The four caps (+stdout_limit+,
87
- # +stderr_limit+, +timeout+, +memory_limit+) are forwarded verbatim to
88
- # +Kobako::SandboxOptions+, which owns their DEFAULT fallback and
88
+ # gem-bundled +data/kobako.wasm+. Every other keyword — the four caps
89
+ # (+stdout_limit+, +stderr_limit+, +timeout+, +memory_limit+) and the
90
+ # requested isolation profile (+profile+) is forwarded verbatim to
91
+ # +Kobako::SandboxOptions+, which owns the DEFAULT fallbacks and
89
92
  # normalisation. The constructed +SandboxOptions+ is exposed as
90
- # +#options+ and the four caps remain readable directly on Sandbox via
91
- # +Forwardable+ delegation.
92
- def initialize(wasm_path: nil, stdout_limit: nil, stderr_limit: nil,
93
- timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
94
- memory_limit: SandboxOptions::DEFAULT_MEMORY_LIMIT)
93
+ # +#options+ and every option remains readable directly on Sandbox via
94
+ # +Forwardable+ delegation. The runtime builds the requested profile —
95
+ # +:hermetic+ (the default) denies the guest ambient time and entropy,
96
+ # +:permissive+ leaves them live — and construction refuses a runtime
97
+ # whose declared profile falls below the request, raising
98
+ # +Kobako::SetupError+ before any invocation entry point runs.
99
+ def initialize(wasm_path: nil, **)
95
100
  @wasm_path = wasm_path || Kobako::Runtime.default_path
96
- @options = SandboxOptions.new(timeout: timeout, memory_limit: memory_limit, stdout_limit: stdout_limit,
97
- stderr_limit: stderr_limit)
101
+ @options = SandboxOptions.new(**)
98
102
  @handler = Catalog::Handles.new
99
103
  @services = Kobako::Catalog::Namespaces.new(handler: @handler)
100
104
  @snippets = Catalog::Snippets.new
101
- @runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
102
- @options.stdout_limit, @options.stderr_limit)
105
+ @runtime = build_runtime!
103
106
  install_dispatch_proc!
104
107
  reset_invocation_state!
105
108
  end
@@ -207,17 +210,28 @@ module Kobako
207
210
 
208
211
  private
209
212
 
210
- # Configure the +Runtime+'s host↔guest dispatch wiring. Builds a
211
- # lambda that re-enters the guest via
212
- # +Runtime#yield_to_active_invocation+ and a dispatch +Proc+ that routes
213
- # guest→host calls through the stateless +Transport::Dispatcher+,
214
- # capturing +@services+ / +@handler+ in the closure. Both are registered
215
- # on the +Runtime+ once at construction time so the wasm ext callback can
216
- # fire without further setup.
213
+ # Construct the +Runtime+ with the requested isolation profile and
214
+ # refuse one whose declared posture falls below the request
215
+ # +SandboxOptions#enforce_floor!+ owns the ladder comparison, so a
216
+ # runtime that cannot honor the request never runs guest code.
217
+ def build_runtime!
218
+ runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
219
+ @options.stdout_limit, @options.stderr_limit, profile)
220
+ @options.enforce_floor!(runtime.profile)
221
+ runtime
222
+ end
223
+
224
+ # Configure the +Runtime+'s host↔guest dispatch wiring. Registers a
225
+ # dispatch +Proc+ that routes guest→host calls through the stateless
226
+ # +Transport::Dispatcher+, capturing +@services+ / +@handler+ in the
227
+ # closure. The ext hands the +Proc+ a per-dispatch +guest_yielder+ — a
228
+ # +String → String+ callable that re-enters the in-flight guest to run a
229
+ # yielded block — which the +Dispatcher+ forwards to the +Transport::Yielder+
230
+ # it builds for the call. Registered once at construction time so the
231
+ # wasm ext callback can fire without further setup.
217
232
  def install_dispatch_proc!
218
- yield_to_guest = ->(args_bytes) { @runtime.yield_to_active_invocation(args_bytes) }
219
- @runtime.on_dispatch = lambda do |request_bytes|
220
- Transport::Dispatcher.dispatch(request_bytes, @services, @handler, yield_to_guest)
233
+ @runtime.on_dispatch = lambda do |request_bytes, guest_yielder|
234
+ Transport::Dispatcher.dispatch(request_bytes, @services, @handler, guest_yielder)
221
235
  end
222
236
  end
223
237
 
@@ -226,8 +240,7 @@ module Kobako
226
240
  # state — capture buffers, truncation predicates, and the
227
241
  # +Catalog::Handles+ counter — before the guest runs. The
228
242
  # +Catalog::Handles+ itself is held as +@handler+ and never exposed
229
- # beyond this class: SPEC.md Terminology pins it as "Not exposed to the
230
- # Host App".
243
+ # beyond this class it is not part of the Host App's surface.
231
244
  def begin_invocation!
232
245
  @services.seal!
233
246
  @handler.reset!
@@ -239,9 +252,10 @@ module Kobako
239
252
  # the +invoke!+ +ensure+ block so the usage record is populated on
240
253
  # every outcome — value return, +Kobako::TrapError+ (including
241
254
  # +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
242
- # and +Kobako::ServiceError+. On the success path the same figures
243
- # already arrive via +Snapshot#usage+; on the trap path the Snapshot
244
- # never reaches Ruby so the ext readout here is the only source.
255
+ # and +Kobako::ServiceError+. +Runtime#usage+ is the single source for
256
+ # both paths: the figures are stashed in the ext on every outcome, so
257
+ # the readout here also covers the trap path, where +Runtime#eval+ /
258
+ # +#run+ raise instead of returning outcome bytes.
245
259
  #
246
260
  # The ext returns a positional 2-tuple +[wall_time, memory_peak]+
247
261
  # whose order matches the +Kobako::Usage+ field order; the
@@ -267,33 +281,51 @@ module Kobako
267
281
  end
268
282
  end
269
283
 
284
+ # Read the per-last-invocation output captures from the ext and wrap
285
+ # them as +Kobako::Capture+ value objects. Runs in the +invoke!+
286
+ # +ensure+ block next to {#read_usage!} for the same reason: the ext
287
+ # stashes the captures on every outcome, so the readout also covers
288
+ # the trap path, where +Runtime#eval+ / +#run+ raise instead of
289
+ # returning outcome bytes — +#stdout+ / +#stderr+ keep the guest's
290
+ # partial output readable after a rescue.
291
+ #
292
+ # The ext returns a positional 4-tuple
293
+ # +[stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated]+;
294
+ # the destructure-then-kwargs handoff below is the explicit
295
+ # positional→keyword conversion point.
296
+ def read_captures!
297
+ stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated = @runtime.captures
298
+ @stdout_capture = Capture.new(bytes: stdout_bytes, truncated: stdout_truncated)
299
+ @stderr_capture = Capture.new(bytes: stderr_bytes, truncated: stderr_truncated)
300
+ end
301
+
270
302
  # Shared prologue / epilogue + trap-class translator for both
271
303
  # invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
272
304
  # TrapError message so the failing export is identifiable.
273
305
  #
274
- # The yielded block must return a +Kobako::Snapshot+ i.e. the
275
- # value of +Runtime#eval+ / +#run+ (SPEC.md Internal Concepts
276
- # Snapshot). The success path unpacks +#stdout+ / +#stderr+ into
277
- # +Capture+ and feeds +#return_bytes+ to +Outcome.decode+; usage is
278
- # populated by the +ensure+ readout ({#read_usage!}) on every outcome.
306
+ # The yielded block must return the invocation's raw outcome bytes —
307
+ # i.e. the value of +Runtime#eval+ / +#run+ which the success path
308
+ # feeds to +Outcome.decode+. Captures and usage are populated by the
309
+ # +ensure+ readouts ({#read_usage!} / {#read_captures!}) on every
310
+ # outcome, so +#stdout+ / +#stderr+ / +#usage+ stay readable after a
311
+ # rescued trap.
279
312
  # The rescue chain is the single trap-translation boundary —
280
313
  # configured-cap paths surface as named TrapError subclasses
281
314
  # (+TimeoutError+ / +MemoryLimitError+); everything else surfaces as
282
315
  # the base +TrapError+.
283
316
  def invoke!(verb)
284
317
  begin_invocation!
285
- snapshot = yield
286
- @stdout_capture = snapshot.stdout
287
- @stderr_capture = snapshot.stderr
318
+ return_bytes = yield
288
319
  # A Capability Handle in the result is decoded as a Kobako::Handle
289
320
  # token; restore it to the host object the guest referenced before
290
321
  # handing the value to the Host App. @handler still holds this
291
322
  # invocation's table — reset only happens at the next #begin_invocation!.
292
- Codec::HandleWalk.deep_restore(Outcome.decode(snapshot.return_bytes), @handler)
323
+ Codec::HandleWalk.deep_restore(Outcome.decode(return_bytes), @handler)
293
324
  rescue Kobako::TrapError => e
294
325
  raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
295
326
  ensure
296
327
  read_usage!
328
+ read_captures!
297
329
  end
298
330
  end
299
331
  end
@@ -1,17 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "errors"
4
+
3
5
  module Kobako
4
6
  # Kobako::SandboxOptions — immutable Value Object holding the four
5
- # per-Sandbox configuration caps. Built on the +class X <
6
- # Data.define(...)+ subclass form (the Steep-friendly shape — see
7
- # +lib/kobako/outcome/panic.rb+).
7
+ # per-Sandbox configuration caps and the requested isolation profile.
8
+ # Built on the +class X < Data.define(...)+ subclass form (the
9
+ # Steep-friendly shape — see +lib/kobako/outcome/panic.rb+).
8
10
  #
9
- # The +initialize+ method does double duty: it applies DEFAULT fallback
10
- # for absent values and normalises (timeout to Float seconds,
11
- # memory_limit to positive Integer bytes) before delegating to Data's
12
- # +super+. Anything that survives +SandboxOptions.new+ is a wire-ready
13
- # cap bundle the +Kobako::Runtime+ constructor consumes as-is.
14
- class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit)
11
+ # The +initialize+ normalises every option before delegating to Data's
12
+ # +super+: +timeout+ to Float seconds, +memory_limit+ / +stdout_limit+ /
13
+ # +stderr_limit+ to positive Integer bytes. Each cap is +nil+-disablable
14
+ # (an absent argument takes its DEFAULT; an explicit +nil+ leaves the
15
+ # bound off), so all four behave uniformly. +profile+ is the one
16
+ # non-cap: a Symbol on the PROFILES ladder naming the posture the
17
+ # runtime builds, which is also the weakest the Host App accepts —
18
+ # +nil+ is rejected because the weakest posture is requested as an
19
+ # explicit +:permissive+. Anything that survives +SandboxOptions.new+
20
+ # is a wire-ready bundle the +Kobako::Runtime+ constructor consumes
21
+ # as-is. The options also own the ladder comparison
22
+ # (+#enforce_floor!+) that +Kobako::Sandbox+ delegates its
23
+ # construction floor check to.
24
+ class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit, :profile)
15
25
  # Default wall-clock timeout for a single invocation: 60 seconds.
16
26
  DEFAULT_TIMEOUT_SECONDS = 60.0
17
27
 
@@ -23,17 +33,41 @@ module Kobako
23
33
  # Default per-channel capture ceiling: 1 MiB.
24
34
  DEFAULT_OUTPUT_LIMIT = 1 << 20
25
35
 
36
+ # The isolation ladder, weakest first — index order is rank order,
37
+ # so a floor check is an index comparison.
38
+ PROFILES = %i[permissive hermetic].freeze
39
+
40
+ # Default isolation profile: the strictest rung — opting down to
41
+ # +:permissive+ is the Host App's explicit trade.
42
+ DEFAULT_PROFILE = :hermetic
43
+
26
44
  def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
27
45
  memory_limit: DEFAULT_MEMORY_LIMIT,
28
- stdout_limit: nil,
29
- stderr_limit: nil)
46
+ stdout_limit: DEFAULT_OUTPUT_LIMIT,
47
+ stderr_limit: DEFAULT_OUTPUT_LIMIT,
48
+ profile: DEFAULT_PROFILE)
30
49
  timeout = normalize_timeout(timeout)
31
50
  memory_limit = normalize_memory_limit(memory_limit)
32
- stdout_limit ||= DEFAULT_OUTPUT_LIMIT
33
- stderr_limit ||= DEFAULT_OUTPUT_LIMIT
51
+ stdout_limit = normalize_output_limit(stdout_limit, "stdout_limit")
52
+ stderr_limit = normalize_output_limit(stderr_limit, "stderr_limit")
53
+ profile = normalize_profile(profile)
34
54
  super
35
55
  end
36
56
 
57
+ # Enforce the requested +profile+ as the floor against +declared+ —
58
+ # the posture a runtime reports having built — so a runtime that
59
+ # cannot honor the request fails construction with
60
+ # +Kobako::SetupError+ instead of weakening the posture silently.
61
+ # Both fallbacks fail closed: a declaration off the PROFILES ladder
62
+ # ranks below every request, and a request off the ladder
63
+ # (unreachable past +initialize+) refuses every declaration.
64
+ def enforce_floor!(declared)
65
+ return if (PROFILES.index(declared) || -1) >= (PROFILES.index(profile) || PROFILES.size)
66
+
67
+ raise Kobako::SetupError, "runtime declares isolation profile #{declared.inspect}, " \
68
+ "below the requested floor #{profile.inspect}"
69
+ end
70
+
37
71
  private
38
72
 
39
73
  # Coerce +timeout+ into the Float seconds the ext expects, or +nil+
@@ -62,5 +96,29 @@ module Kobako
62
96
 
63
97
  memory_limit
64
98
  end
99
+
100
+ # Coerce a per-channel output cap (+stdout_limit+ / +stderr_limit+)
101
+ # into the byte cap the ext expects, or +nil+ to leave the channel
102
+ # uncapped. Same shape as +normalize_memory_limit+: a positive Integer
103
+ # when set, Float / zero / negative rejected. +name+ tags the
104
+ # +ArgumentError+ with the offending keyword.
105
+ def normalize_output_limit(limit, name)
106
+ return nil if limit.nil?
107
+ unless limit.is_a?(Integer) && limit.positive?
108
+ raise ArgumentError, "#{name} must be a positive Integer or nil, got #{limit.inspect}"
109
+ end
110
+
111
+ limit
112
+ end
113
+
114
+ # Validate +profile+ against the PROFILES ladder. Unlike the caps
115
+ # there is no +nil+ form: the weakest posture is requested as an
116
+ # explicit +:permissive+, so anything off the ladder — +nil+
117
+ # included — is rejected.
118
+ def normalize_profile(profile)
119
+ return profile if PROFILES.include?(profile)
120
+
121
+ raise ArgumentError, "profile must be one of #{PROFILES.map(&:inspect).join(", ")}, got #{profile.inspect}"
122
+ end
65
123
  end
66
124
  end
@@ -72,8 +72,8 @@ module Kobako
72
72
  # closure so the Dispatcher stays stateless and the registry doesn't
73
73
  # need to publish accessors for the Sandbox-owned +Catalog::Handles+
74
74
  # or +Runtime+. +yield_to_guest+ is a +String → String+ callable
75
- # (typically +Runtime#yield_to_active_invocation+ bound as a lambda)
76
- # used only when the Request carries +block_given: true+. Always
75
+ # (the ext's per-dispatch +Kobako::Runtime::GuestYielder+) used only
76
+ # when the Request carries +block_given: true+. Always
77
77
  # returns a binary String — every failure path is reified as a
78
78
  # Response.error envelope so the guest sees a transport error rather
79
79
  # than a wasm trap.
@@ -38,20 +38,6 @@ module Kobako
38
38
  new(status: STATUS_ERROR, payload: fault)
39
39
  end
40
40
 
41
- # Decode +bytes+ into a {Response}. Raises +Codec::InvalidType+ when the
42
- # envelope is not the expected 2-element msgpack array, or when the
43
- # Value Object's construction invariants reject the decoded fields.
44
- def self.decode(bytes)
45
- Codec::Decoder.decode(bytes) do |arr|
46
- unless arr.is_a?(Array) && arr.length == 2
47
- raise Codec::InvalidType, "Response envelope is malformed (expected a 2-element array)"
48
- end
49
-
50
- status, payload = arr
51
- new(status: status, payload: payload)
52
- end
53
- end
54
-
55
41
  def initialize(status:, payload:)
56
42
  unless [STATUS_OK, STATUS_ERROR].include?(status)
57
43
  raise ArgumentError, "Response status must be 0 (ok) or 1 (error), got #{status.inspect}"
@@ -71,6 +57,20 @@ module Kobako
71
57
  def encode
72
58
  Codec::Encoder.encode([status, payload])
73
59
  end
60
+
61
+ # Decode +bytes+ into a {Response}. Raises +Codec::InvalidType+ when the
62
+ # envelope is not the expected 2-element msgpack array, or when the
63
+ # Value Object's construction invariants reject the decoded fields.
64
+ def self.decode(bytes)
65
+ Codec::Decoder.decode(bytes) do |arr|
66
+ unless arr.is_a?(Array) && arr.length == 2
67
+ raise Codec::InvalidType, "Response envelope is malformed (expected a 2-element array)"
68
+ end
69
+
70
+ status, payload = arr
71
+ new(status: status, payload: payload)
72
+ end
73
+ end
74
74
  end
75
75
  end
76
76
  end
@@ -43,8 +43,8 @@ module Kobako
43
43
 
44
44
  def initialize(entrypoint:, args: [], kwargs: {})
45
45
  entrypoint = normalize_entrypoint(entrypoint)
46
- args = validate_args!(args)
47
- kwargs = validate_kwargs!(kwargs)
46
+ validate_args!(args)
47
+ validate_kwargs!(kwargs)
48
48
  super
49
49
  end
50
50
 
@@ -100,8 +100,6 @@ module Kobako
100
100
  def validate_args!(args)
101
101
  raise ArgumentError, "arguments must be an Array" unless args.is_a?(Array)
102
102
  raise ArgumentError, forged_handle_message("arguments") if args.any?(Kobako::Handle)
103
-
104
- args
105
103
  end
106
104
 
107
105
  # Reject a non-Symbol kwargs key, and a +Kobako::Handle+ arriving
@@ -117,8 +115,6 @@ module Kobako
117
115
  "keyword argument keys must be Symbols (got #{bad_keys.inspect})"
118
116
  end
119
117
  raise ArgumentError, forged_handle_message("keyword argument values") if kwargs.each_value.any?(Kobako::Handle)
120
-
121
- kwargs
122
118
  end
123
119
 
124
120
  # Single source of truth for the forged-Handle reject message so the
@@ -67,7 +67,7 @@ module Kobako
67
67
  raise Codec::InvalidType, "YieldResponse must carry at least one byte" if bytes.empty?
68
68
 
69
69
  tag = bytes.getbyte(0) # : Integer
70
- body = bytes.byteslice(1, bytes.bytesize - 1) || +""
70
+ body = bytes.byteslice(1, bytes.bytesize - 1) # : String
71
71
 
72
72
  reject_dead_tag!(tag)
73
73
  new(tag: tag, value: Codec::Decoder.decode(body))
@@ -28,8 +28,8 @@ module Kobako
28
28
  # dispatch completes; any later call to a stashed Yielder then raises
29
29
  # +LocalJumpError+ — the observable shape of an escaped Yielder.
30
30
  class Yielder
31
- # +yield_to_guest+ is a +String → String+ callable (typically
32
- # +Runtime#yield_to_active_invocation+ bound through a lambda) that
31
+ # +yield_to_guest+ is a +String → String+ callable (the ext's
32
+ # per-dispatch +Kobako::Runtime::GuestYielder+) that
33
33
  # {#yield} invokes to re-enter the guest; +break_tag+ is the +catch+
34
34
  # throw tag the Dispatcher matches against to unwind the Service on
35
35
  # +tag 0x02+. +handler+ is the Sandbox's +Kobako::Catalog::Handles+,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.12.1"
4
+ VERSION = "0.13.0"
5
5
  end
data/lib/kobako.rb CHANGED
@@ -13,6 +13,5 @@ require_relative "kobako/errors"
13
13
  require_relative "kobako/transport"
14
14
  require_relative "kobako/catalog"
15
15
  require_relative "kobako/runtime"
16
- require_relative "kobako/snapshot"
17
16
  require_relative "kobako/sandbox"
18
17
  require_relative "kobako/pool"