kobako 0.12.2 → 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.
@@ -5,7 +5,7 @@
5
5
  //! Kobako::Runtime — wraps a `kobako_wasmtime::Driver` + the Ruby seams
6
6
  //!
7
7
  //! constructed via `Kobako::Runtime.from_path(path, timeout, memory_limit,
8
- //! stdout_limit, stderr_limit)`. Every invocation (`#eval` / `#run`)
8
+ //! stdout_limit, stderr_limit, profile)`. Every invocation (`#eval` / `#run`)
9
9
  //! instantiates a fresh instance and discards the whole Store afterwards —
10
10
  //! the per-invocation instance discipline. The run mechanics —
11
11
  //! engine/module caches, caps, trap classification — live in the
@@ -21,24 +21,25 @@
21
21
  //!
22
22
  //! This file owns the `Kobako::Runtime` magnus class itself — the Ruby
23
23
  //! init() that registers the class, the byte↔`RString` shuttling, the
24
- //! dispatch-Proc GC root, and the per-invocation usage readout.
24
+ //! dispatch-Proc GC root, and the per-invocation usage / capture readouts.
25
25
 
26
26
  mod bridge;
27
27
  mod errors;
28
28
 
29
- use magnus::{function, method, prelude::*, Error as MagnusError, RModule, RString, Ruby};
29
+ use magnus::{
30
+ function, gc, method, prelude::*, typed_data::DataTypeFunctions, value::Opaque,
31
+ Error as MagnusError, RArray, RModule, RString, Ruby, Symbol, TypedData, Value,
32
+ };
30
33
 
31
- use std::cell::Cell;
34
+ use std::cell::{Cell, RefCell};
32
35
  use std::path::Path;
33
36
  use std::sync::Arc;
34
37
  use std::time::Duration;
35
38
 
36
- use magnus::{gc, typed_data::DataTypeFunctions, value::Opaque, RArray, TypedData, Value};
37
-
38
- use crate::snapshot::Snapshot;
39
39
  use kobako_runtime::dispatch::DispatchHandler;
40
+ use kobako_runtime::profile::Profile;
40
41
  use kobako_runtime::runtime::{Entry, Frames, Runtime as ContractRuntime};
41
- use kobako_runtime::snapshot::{Completion, Snapshot as RuntimeSnapshot, Usage};
42
+ use kobako_runtime::snapshot::{Capture, Completion, Snapshot as RuntimeSnapshot, Usage};
42
43
  use kobako_wasmtime::{Config, Driver};
43
44
 
44
45
  /// Copy the bytes of `s` into a fresh `Vec<u8>`. Single safe entry to
@@ -52,6 +53,15 @@ fn rstring_to_vec(s: RString) -> Vec<u8> {
52
53
  unsafe { s.as_slice() }.to_vec()
53
54
  }
54
55
 
56
+ /// The pre-invocation sentinel for one capture channel: no bytes, cap
57
+ /// not reached. Fresh `Vec`s per call because `Capture` owns its buffer.
58
+ fn empty_capture() -> Capture {
59
+ Capture {
60
+ bytes: Vec::new(),
61
+ truncated: false,
62
+ }
63
+ }
64
+
55
65
  // ---------------------------------------------------------------------------
56
66
  // Ruby init
57
67
  // ---------------------------------------------------------------------------
@@ -65,11 +75,13 @@ pub fn init(ruby: &Ruby, kobako: RModule) -> Result<(), MagnusError> {
65
75
  // intermediate hierarchy is registered.
66
76
 
67
77
  let runtime = kobako.define_class("Runtime", ruby.class_object())?;
68
- runtime.define_singleton_method("from_path", function!(Runtime::from_path, 5))?;
78
+ runtime.define_singleton_method("from_path", function!(Runtime::from_path, 6))?;
69
79
  runtime.define_method("on_dispatch=", method!(Runtime::set_on_dispatch, 1))?;
70
80
  runtime.define_method("eval", method!(Runtime::eval, 3))?;
71
81
  runtime.define_method("run", method!(Runtime::run, 3))?;
72
82
  runtime.define_method("usage", method!(Runtime::usage, 0))?;
83
+ runtime.define_method("captures", method!(Runtime::captures, 0))?;
84
+ runtime.define_method("profile", method!(Runtime::profile, 0))?;
73
85
  // The guest re-enters for a block yield through a frame-scoped
74
86
  // `Kobako::Runtime::GuestYielder` the dispatcher hands the Proc, not a
75
87
  // method on Runtime.
@@ -93,11 +105,17 @@ struct Runtime {
93
105
  // reference the one Proc this `Opaque` pins. `Cell` is sound under the
94
106
  // GVL (see the `unsafe impl Sync` below).
95
107
  on_dispatch: Cell<Option<Opaque<Value>>>,
96
- // Usage of the most recent invocation, kept apart from the returned
97
- // `Snapshot` so `#usage` reads survive the per-invocation Store
98
- // teardown and the trap path's raise. Zeroed before the first
99
- // invocation.
108
+ // Usage of the most recent invocation, stashed here so `#usage` reads
109
+ // survive the per-invocation Store teardown and the trap path's
110
+ // raise. Zeroed before the first invocation.
100
111
  last_usage: Cell<Usage>,
112
+ // Output captures of the most recent invocation, stashed for the same
113
+ // reason as `last_usage`: the trap path raises, and this readout is
114
+ // what keeps the guest's partial output readable after a rescue.
115
+ // `RefCell` (not `Cell`) because `Capture` owns its byte buffer; the
116
+ // same GVL single-thread discipline applies (see the `unsafe impl
117
+ // Sync` below). Empty before the first invocation.
118
+ last_captures: RefCell<(Capture, Capture)>,
101
119
  }
102
120
 
103
121
  impl DataTypeFunctions for Runtime {
@@ -118,11 +136,11 @@ impl DataTypeFunctions for Runtime {
118
136
  }
119
137
 
120
138
  // SAFETY: magnus requires `Send + Sync` on TypedData types. The
121
- // `on_dispatch` / `last_usage` `Cell`s make the auto-derived `Sync`
122
- // unavailable, but every access to them happens under the GVL on a single
123
- // thread at a time — Ruby method calls, and a GC `mark` pass that also
124
- // holds the GVL. No cross-thread access to either Cell can occur. `Send`
125
- // stays auto-derived.
139
+ // `on_dispatch` / `last_usage` `Cell`s and the `last_captures` `RefCell`
140
+ // make the auto-derived `Sync` unavailable, but every access to them
141
+ // happens under the GVL on a single thread at a time — Ruby method calls,
142
+ // and a GC `mark` pass that also holds the GVL. No cross-thread access to
143
+ // any of them can occur. `Send` stays auto-derived.
126
144
  unsafe impl Sync for Runtime {}
127
145
 
128
146
  impl Runtime {
@@ -135,15 +153,18 @@ impl Runtime {
135
153
  /// (`None` disables); `memory_limit` is the linear-memory cap in
136
154
  /// bytes (`None` disables); `stdout_limit_bytes` / `stderr_limit_bytes`
137
155
  /// are the per-channel output caps (`None`
138
- /// disables). All four are validated by the caller
139
- /// (`Kobako::Sandbox`); this method only refuses non-finite or
140
- /// non-positive timeouts as a defence in depth.
156
+ /// disables); `profile` is the isolation rung the driver builds
157
+ /// (`:permissive` / `:hermetic`). All five are validated by the
158
+ /// caller (`Kobako::Sandbox`); this method only refuses non-finite
159
+ /// or non-positive timeouts and off-ladder profiles as a defence in
160
+ /// depth.
141
161
  fn from_path(
142
162
  path: String,
143
163
  timeout_seconds: Option<f64>,
144
164
  memory_limit: Option<usize>,
145
165
  stdout_limit_bytes: Option<usize>,
146
166
  stderr_limit_bytes: Option<usize>,
167
+ profile: Symbol,
147
168
  ) -> Result<Self, MagnusError> {
148
169
  let ruby = Ruby::get().expect("Ruby thread");
149
170
  let timeout = match timeout_seconds {
@@ -161,6 +182,19 @@ impl Runtime {
161
182
  ));
162
183
  }
163
184
  };
185
+ // Fail closed on an off-ladder rung: an unrecognized posture must
186
+ // never fall back to a grant. Same defence-in-depth posture as the
187
+ // timeout guard above — `SandboxOptions` is the primary validator.
188
+ let profile = match profile.name()?.as_ref() {
189
+ "hermetic" => Profile::Hermetic,
190
+ "permissive" => Profile::Permissive,
191
+ other => {
192
+ return Err(MagnusError::new(
193
+ ruby.exception_arg_error(),
194
+ format!("profile must be :permissive or :hermetic, got :{other}"),
195
+ ));
196
+ }
197
+ };
164
198
 
165
199
  let driver = Driver::new(
166
200
  Path::new(&path),
@@ -169,6 +203,7 @@ impl Runtime {
169
203
  timeout,
170
204
  stdout_limit_bytes,
171
205
  stderr_limit_bytes,
206
+ profile,
172
207
  },
173
208
  )
174
209
  .map_err(|e| errors::setup_to_magnus(&ruby, e))?;
@@ -179,6 +214,7 @@ impl Runtime {
179
214
  wall_time: 0.0,
180
215
  memory_peak: 0,
181
216
  }),
217
+ last_captures: RefCell::new((empty_capture(), empty_capture())),
182
218
  })
183
219
  }
184
220
 
@@ -204,7 +240,7 @@ impl Runtime {
204
240
  /// One-shot mruby source execution (`#eval`). The Ruby-facing entry:
205
241
  /// builds the dispatch handler from the registered Proc, hands the
206
242
  /// three stdin frames (`preamble`, `source`, `snippets`) and the source
207
- /// to the driver, and settles the returned `Snapshot` through
243
+ /// to the driver, and settles the invocation through
208
244
  /// `finish_invocation` — or maps a could-not-start `Error` onto its
209
245
  /// `Kobako::*` exception. The run mechanics — frames, caps, trap
210
246
  /// classification — live in `kobako_wasmtime::Driver`.
@@ -213,7 +249,7 @@ impl Runtime {
213
249
  preamble: RString,
214
250
  source: RString,
215
251
  snippets: RString,
216
- ) -> Result<Snapshot, MagnusError> {
252
+ ) -> Result<RString, MagnusError> {
217
253
  let ruby = Ruby::get().expect("Ruby thread");
218
254
  let handler = self.build_handler();
219
255
  let preamble = rstring_to_vec(preamble);
@@ -233,8 +269,8 @@ impl Runtime {
233
269
  self.finish_invocation(&ruby, snapshot)
234
270
  }
235
271
 
236
- /// Execute one entrypoint dispatch (`__kobako_run`) and return a
237
- /// `Snapshot` bundling every per-invocation observable.
272
+ /// Execute one entrypoint dispatch (`__kobako_run`) and return the
273
+ /// guest's raw outcome bytes.
238
274
  ///
239
275
  /// The two-frame stdin protocol (preamble + snippets; no user source
240
276
  /// frame — docs/wire-codec.md § Invocation channels) plus the
@@ -246,7 +282,7 @@ impl Runtime {
246
282
  preamble: RString,
247
283
  snippets: RString,
248
284
  envelope: RString,
249
- ) -> Result<Snapshot, MagnusError> {
285
+ ) -> Result<RString, MagnusError> {
250
286
  let ruby = Ruby::get().expect("Ruby thread");
251
287
  let handler = self.build_handler();
252
288
  let preamble = rstring_to_vec(preamble);
@@ -268,20 +304,27 @@ impl Runtime {
268
304
  self.finish_invocation(&ruby, snapshot)
269
305
  }
270
306
 
271
- /// Settle one invocation's `Snapshot` at the Ruby boundary: usage is
272
- /// recorded even when the completion is a trap and this call raises,
273
- /// while trap-path captures are deliberately dropped exposing them
274
- /// is a SPEC decision the Ruby surface has not taken.
307
+ /// Settle one invocation's `Snapshot` at the Ruby boundary: usage and
308
+ /// the two output captures are recorded on every outcome, so the
309
+ /// `#usage` / `#captures` readouts survive the trap path's raise —
310
+ /// that is what keeps the guest's partial output readable after the
311
+ /// Host App rescues the trap. A completed guest invocation returns
312
+ /// its raw outcome bytes; the Sandbox layer decodes them.
275
313
  fn finish_invocation(
276
314
  &self,
277
315
  ruby: &Ruby,
278
316
  snapshot: RuntimeSnapshot,
279
- ) -> Result<Snapshot, MagnusError> {
280
- self.last_usage.set(snapshot.usage);
281
- match snapshot.completion {
282
- Completion::Outcome(bytes) => {
283
- Ok(Snapshot::new(bytes, snapshot.stdout, snapshot.stderr))
284
- }
317
+ ) -> Result<RString, MagnusError> {
318
+ let RuntimeSnapshot {
319
+ completion,
320
+ stdout,
321
+ stderr,
322
+ usage,
323
+ } = snapshot;
324
+ self.last_usage.set(usage);
325
+ self.last_captures.replace((stdout, stderr));
326
+ match completion {
327
+ Completion::Outcome(bytes) => Ok(ruby.str_from_slice(&bytes)),
285
328
  Completion::Trap(trap) => Err(errors::trap_to_magnus(ruby, trap)),
286
329
  }
287
330
  }
@@ -313,8 +356,8 @@ impl Runtime {
313
356
  /// size captured at invocation entry. `0` before the first
314
357
  /// invocation.
315
358
  ///
316
- /// Reads the `last_usage` Cell `finish_invocation` populated from the
317
- /// returned `Snapshot` before the per-invocation Store was discarded.
359
+ /// Reads the `last_usage` Cell `finish_invocation` populated before
360
+ /// the per-invocation Store was discarded.
318
361
  fn usage(&self) -> Result<RArray, MagnusError> {
319
362
  let ruby = Ruby::get().expect("Ruby thread");
320
363
  let usage = self.last_usage.get();
@@ -323,4 +366,37 @@ impl Runtime {
323
366
  arr.push(usage.memory_peak)?;
324
367
  Ok(arr)
325
368
  }
369
+
370
+ /// Return the per-last-invocation output captures as a Ruby 4-tuple
371
+ /// `[stdout_bytes, stdout_truncated, stderr_bytes, stderr_truncated]`
372
+ /// — the flat positional layout mirrors `#usage`, and the element
373
+ /// order matches the destructure in `Kobako::Sandbox#read_captures!`;
374
+ /// reorder both sides together.
375
+ ///
376
+ /// Reads the `last_captures` pair `finish_invocation` stashed on
377
+ /// every outcome, so the readout also covers the trap path, where
378
+ /// `#eval` / `#run` raise instead of returning outcome bytes.
379
+ /// Empty bytes and `false` flags before the first invocation.
380
+ fn captures(&self) -> Result<RArray, MagnusError> {
381
+ let ruby = Ruby::get().expect("Ruby thread");
382
+ let captures = self.last_captures.borrow();
383
+ let (stdout, stderr) = &*captures;
384
+ let arr = ruby.ary_new_capa(4);
385
+ arr.push(ruby.str_from_slice(&stdout.bytes))?;
386
+ arr.push(stdout.truncated)?;
387
+ arr.push(ruby.str_from_slice(&stderr.bytes))?;
388
+ arr.push(stderr.truncated)?;
389
+ Ok(arr)
390
+ }
391
+
392
+ /// Return the isolation profile the driver built, as a Symbol
393
+ /// (`:hermetic` / `:permissive`) — the declaration the Sandbox
394
+ /// compares against the posture its `profile:` option requested.
395
+ fn profile(&self) -> Symbol {
396
+ let ruby = Ruby::get().expect("Ruby thread");
397
+ match self.driver.profile() {
398
+ Profile::Hermetic => ruby.to_symbol("hermetic"),
399
+ Profile::Permissive => ruby.to_symbol("permissive"),
400
+ }
401
+ }
326
402
  }
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.
@@ -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,25 +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,
93
- stdout_limit: SandboxOptions::DEFAULT_OUTPUT_LIMIT,
94
- stderr_limit: SandboxOptions::DEFAULT_OUTPUT_LIMIT,
95
- timeout: SandboxOptions::DEFAULT_TIMEOUT_SECONDS,
96
- 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, **)
97
100
  @wasm_path = wasm_path || Kobako::Runtime.default_path
98
- @options = SandboxOptions.new(timeout: timeout, memory_limit: memory_limit, stdout_limit: stdout_limit,
99
- stderr_limit: stderr_limit)
101
+ @options = SandboxOptions.new(**)
100
102
  @handler = Catalog::Handles.new
101
103
  @services = Kobako::Catalog::Namespaces.new(handler: @handler)
102
104
  @snippets = Catalog::Snippets.new
103
- @runtime = Kobako::Runtime.from_path(@wasm_path, @options.timeout, @options.memory_limit,
104
- @options.stdout_limit, @options.stderr_limit)
105
+ @runtime = build_runtime!
105
106
  install_dispatch_proc!
106
107
  reset_invocation_state!
107
108
  end
@@ -209,6 +210,17 @@ module Kobako
209
210
 
210
211
  private
211
212
 
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
+
212
224
  # Configure the +Runtime+'s host↔guest dispatch wiring. Registers a
213
225
  # dispatch +Proc+ that routes guest→host calls through the stateless
214
226
  # +Transport::Dispatcher+, capturing +@services+ / +@handler+ in the
@@ -242,8 +254,8 @@ module Kobako
242
254
  # +TimeoutError+ / +MemoryLimitError+), +Kobako::SandboxError+,
243
255
  # and +Kobako::ServiceError+. +Runtime#usage+ is the single source for
244
256
  # both paths: the figures are stashed in the ext on every outcome, so
245
- # unlike the +Snapshot+ (built only on success) the readout here also
246
- # covers the trap path.
257
+ # the readout here also covers the trap path, where +Runtime#eval+ /
258
+ # +#run+ raise instead of returning outcome bytes.
247
259
  #
248
260
  # The ext returns a positional 2-tuple +[wall_time, memory_peak]+
249
261
  # whose order matches the +Kobako::Usage+ field order; the
@@ -269,33 +281,51 @@ module Kobako
269
281
  end
270
282
  end
271
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
+
272
302
  # Shared prologue / epilogue + trap-class translator for both
273
303
  # invocation verbs. +verb+ is +:eval+ or +:run+; it tags the
274
304
  # TrapError message so the failing export is identifiable.
275
305
  #
276
- # The yielded block must return a +Kobako::Snapshot+ i.e. the
277
- # value of +Runtime#eval+ / +#run+. The success path unpacks
278
- # +#stdout+ / +#stderr+ into
279
- # +Capture+ and feeds +#return_bytes+ to +Outcome.decode+; usage is
280
- # 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.
281
312
  # The rescue chain is the single trap-translation boundary —
282
313
  # configured-cap paths surface as named TrapError subclasses
283
314
  # (+TimeoutError+ / +MemoryLimitError+); everything else surfaces as
284
315
  # the base +TrapError+.
285
316
  def invoke!(verb)
286
317
  begin_invocation!
287
- snapshot = yield
288
- @stdout_capture = snapshot.stdout
289
- @stderr_capture = snapshot.stderr
318
+ return_bytes = yield
290
319
  # A Capability Handle in the result is decoded as a Kobako::Handle
291
320
  # token; restore it to the host object the guest referenced before
292
321
  # handing the value to the Host App. @handler still holds this
293
322
  # invocation's table — reset only happens at the next #begin_invocation!.
294
- Codec::HandleWalk.deep_restore(Outcome.decode(snapshot.return_bytes), @handler)
323
+ Codec::HandleWalk.deep_restore(Outcome.decode(return_bytes), @handler)
295
324
  rescue Kobako::TrapError => e
296
325
  raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
297
326
  ensure
298
327
  read_usage!
328
+ read_captures!
299
329
  end
300
330
  end
301
331
  end
@@ -1,19 +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+ normalises every cap before delegating to Data's
11
+ # The +initialize+ normalises every option before delegating to Data's
10
12
  # +super+: +timeout+ to Float seconds, +memory_limit+ / +stdout_limit+ /
11
13
  # +stderr_limit+ to positive Integer bytes. Each cap is +nil+-disablable
12
14
  # (an absent argument takes its DEFAULT; an explicit +nil+ leaves the
13
- # bound off), so all four behave uniformly. Anything that survives
14
- # +SandboxOptions.new+ is a wire-ready cap bundle the +Kobako::Runtime+
15
- # constructor consumes as-is.
16
- class SandboxOptions < Data.define(:timeout, :memory_limit, :stdout_limit, :stderr_limit)
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)
17
25
  # Default wall-clock timeout for a single invocation: 60 seconds.
18
26
  DEFAULT_TIMEOUT_SECONDS = 60.0
19
27
 
@@ -25,17 +33,41 @@ module Kobako
25
33
  # Default per-channel capture ceiling: 1 MiB.
26
34
  DEFAULT_OUTPUT_LIMIT = 1 << 20
27
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
+
28
44
  def initialize(timeout: DEFAULT_TIMEOUT_SECONDS,
29
45
  memory_limit: DEFAULT_MEMORY_LIMIT,
30
46
  stdout_limit: DEFAULT_OUTPUT_LIMIT,
31
- stderr_limit: DEFAULT_OUTPUT_LIMIT)
47
+ stderr_limit: DEFAULT_OUTPUT_LIMIT,
48
+ profile: DEFAULT_PROFILE)
32
49
  timeout = normalize_timeout(timeout)
33
50
  memory_limit = normalize_memory_limit(memory_limit)
34
51
  stdout_limit = normalize_output_limit(stdout_limit, "stdout_limit")
35
52
  stderr_limit = normalize_output_limit(stderr_limit, "stderr_limit")
53
+ profile = normalize_profile(profile)
36
54
  super
37
55
  end
38
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
+
39
71
  private
40
72
 
41
73
  # Coerce +timeout+ into the Float seconds the ext expects, or +nil+
@@ -78,5 +110,15 @@ module Kobako
78
110
 
79
111
  limit
80
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
81
123
  end
82
124
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.12.2"
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"
@@ -18,6 +18,11 @@
18
18
  "path": "/wasm/Cargo.lock",
19
19
  "jsonpath": "$.package[?(@.name=='kobako-core')].version"
20
20
  },
21
+ {
22
+ "type": "toml",
23
+ "path": "/wasm/kobako-core/Cargo.toml",
24
+ "jsonpath": "$.dependencies['kobako-codec'].version"
25
+ },
21
26
  {
22
27
  "type": "generic",
23
28
  "path": "/wasm/kobako-core/README.md"
@@ -38,6 +43,11 @@
38
43
  "path": "/wasm/kobako/Cargo.toml",
39
44
  "jsonpath": "$.dependencies['kobako-core'].version"
40
45
  },
46
+ {
47
+ "type": "toml",
48
+ "path": "/wasm/kobako/Cargo.toml",
49
+ "jsonpath": "$.dependencies['kobako-codec'].version"
50
+ },
41
51
  {
42
52
  "type": "generic",
43
53
  "path": "/wasm/kobako/README.md"
@@ -104,6 +114,26 @@
104
114
  }
105
115
  ]
106
116
  },
117
+ "crates/kobako-codec": {
118
+ "component": "kobako-codec",
119
+ "release-type": "rust",
120
+ "extra-files": [
121
+ {
122
+ "type": "toml",
123
+ "path": "/crates/Cargo.lock",
124
+ "jsonpath": "$.package[?(@.name=='kobako-codec')].version"
125
+ },
126
+ {
127
+ "type": "toml",
128
+ "path": "/wasm/Cargo.lock",
129
+ "jsonpath": "$.package[?(@.name=='kobako-codec')].version"
130
+ },
131
+ {
132
+ "type": "generic",
133
+ "path": "/crates/kobako-codec/README.md"
134
+ }
135
+ ]
136
+ },
107
137
  "crates/kobako-runtime": {
108
138
  "component": "kobako-runtime",
109
139
  "release-type": "rust",
@@ -154,7 +184,7 @@
154
184
  {
155
185
  "type": "linked-versions",
156
186
  "groupName": "kobako crates",
157
- "components": ["kobako-core", "kobako-rs", "kobako-io", "kobako-json", "kobako-regexp", "kobako-baker", "kobako-runtime", "kobako-wasmtime"]
187
+ "components": ["kobako-codec", "kobako-core", "kobako-rs", "kobako-io", "kobako-json", "kobako-regexp", "kobako-baker", "kobako-runtime", "kobako-wasmtime"]
158
188
  }
159
189
  ],
160
190
  "extra-files": [
@@ -7,17 +7,22 @@ module Kobako
7
7
  (Float | Integer)? timeout_seconds,
8
8
  Integer? memory_limit,
9
9
  Integer? stdout_limit_bytes,
10
- Integer? stderr_limit_bytes
10
+ Integer? stderr_limit_bytes,
11
+ Symbol profile
11
12
  ) -> Runtime
12
13
 
13
14
  def on_dispatch=: (^(String, Kobako::Transport::_GuestYielder) -> String dispatch_proc) -> void
14
15
 
15
- def eval: (String preamble, String source, String snippets) -> Kobako::Snapshot
16
+ def eval: (String preamble, String source, String snippets) -> String
16
17
 
17
- def run: (String preamble, String snippets, String envelope) -> Kobako::Snapshot
18
+ def run: (String preamble, String snippets, String envelope) -> String
18
19
 
19
20
  def usage: () -> [Float, Integer]
20
21
 
22
+ def captures: () -> [String, bool, String, bool]
23
+
24
+ def profile: () -> Symbol
25
+
21
26
  # Frame-scoped guest re-entry handle the ext hands the dispatch Proc as
22
27
  # its second argument; stands in as the +String -> String+ callable the
23
28
  # host +Transport::Yielder+ invokes for a block yield.