kobako 0.12.2 → 0.14.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +41 -0
  4. data/Cargo.lock +126 -137
  5. data/README.md +5 -3
  6. data/ROADMAP.md +26 -0
  7. data/crates/kobako-runtime/CHANGELOG.md +15 -0
  8. data/crates/kobako-runtime/Cargo.toml +1 -1
  9. data/crates/kobako-runtime/README.md +1 -1
  10. data/crates/kobako-runtime/src/error.rs +8 -0
  11. data/crates/kobako-runtime/src/lib.rs +4 -2
  12. data/crates/kobako-runtime/src/profile.rs +35 -0
  13. data/crates/kobako-runtime/src/runtime.rs +7 -0
  14. data/crates/kobako-wasmtime/CHANGELOG.md +15 -0
  15. data/crates/kobako-wasmtime/Cargo.toml +4 -4
  16. data/crates/kobako-wasmtime/README.md +1 -1
  17. data/crates/kobako-wasmtime/src/ambient.rs +5 -2
  18. data/crates/kobako-wasmtime/src/config.rs +10 -2
  19. data/crates/kobako-wasmtime/src/driver.rs +13 -1
  20. data/crates/kobako-wasmtime/src/frames.rs +105 -5
  21. data/crates/kobako-wasmtime/src/instance_pre.rs +7 -6
  22. data/crates/kobako-wasmtime/src/invocation.rs +4 -5
  23. data/crates/kobako-wasmtime/src/trap.rs +42 -21
  24. data/data/kobako.wasm +0 -0
  25. data/ext/kobako/Cargo.toml +1 -1
  26. data/ext/kobako/src/lib.rs +0 -2
  27. data/ext/kobako/src/runtime.rs +114 -38
  28. data/lib/kobako/catalog/handles.rb +2 -2
  29. data/lib/kobako/catalog/namespaces.rb +2 -2
  30. data/lib/kobako/catalog/snippets.rb +1 -1
  31. data/lib/kobako/codec/decoder.rb +7 -7
  32. data/lib/kobako/codec/encoder.rb +2 -2
  33. data/lib/kobako/codec/error.rb +3 -3
  34. data/lib/kobako/codec/factory.rb +10 -10
  35. data/lib/kobako/codec/handle_walk.rb +17 -21
  36. data/lib/kobako/codec/utils.rb +9 -9
  37. data/lib/kobako/codec.rb +4 -4
  38. data/lib/kobako/errors.rb +14 -39
  39. data/lib/kobako/handle.rb +5 -2
  40. data/lib/kobako/namespace.rb +3 -3
  41. data/lib/kobako/outcome.rb +1 -0
  42. data/lib/kobako/sandbox.rb +61 -33
  43. data/lib/kobako/sandbox_options.rb +51 -9
  44. data/lib/kobako/transport/dispatcher.rb +17 -14
  45. data/lib/kobako/transport/request.rb +2 -2
  46. data/lib/kobako/transport/response.rb +2 -2
  47. data/lib/kobako/transport/run.rb +1 -1
  48. data/lib/kobako/transport/yield.rb +3 -3
  49. data/lib/kobako/transport/yielder.rb +6 -6
  50. data/lib/kobako/version.rb +1 -1
  51. data/lib/kobako.rb +0 -1
  52. data/release-please-config.json +66 -6
  53. data/sig/kobako/codec/handle_walk.rbs +2 -2
  54. data/sig/kobako/codec.rbs +11 -0
  55. data/sig/kobako/handle.rbs +0 -2
  56. data/sig/kobako/runtime.rbs +8 -3
  57. data/sig/kobako/sandbox.rbs +9 -5
  58. data/sig/kobako/sandbox_options.rbs +11 -2
  59. data/sig/kobako/transport/dispatcher.rbs +8 -8
  60. data/sig/kobako/transport/run.rbs +1 -1
  61. data/sig/kobako/transport/yielder.rbs +2 -2
  62. data/sig/kobako/transport.rbs +8 -0
  63. metadata +3 -4
  64. data/ext/kobako/src/snapshot.rs +0 -75
  65. data/lib/kobako/snapshot.rb +0 -32
  66. data/sig/kobako/snapshot.rbs +0 -12
data/README.md CHANGED
@@ -154,6 +154,8 @@ sandbox = Kobako::Sandbox.new(
154
154
 
155
155
  `memory_limit` covers the per-invocation `memory.grow` delta from the entry baseline, so a Sandbox reused across invocations does not silently accumulate against a global budget.
156
156
 
157
+ A fifth option, `profile:`, requests the Sandbox's isolation posture on the `:permissive` < `:hermetic` ladder (default `:hermetic`). `:hermetic` denies the guest ambient time and entropy; `:permissive` lets the guest's `wasi:clocks` / `wasi:random` read live host sources — an explicit trade of reproducibility, with filesystem, environment, and network still unreachable. The request is also a floor: construction fails with `Kobako::SetupError` on a runtime that declares a weaker posture than requested. See [`docs/security-model.md`](docs/security-model.md) § Isolation profiles.
158
+
157
159
  ### Invocation Lifecycle
158
160
 
159
161
  One Sandbox serves many invocations. Service bindings and preloaded snippets persist across calls; capability state (Handles, stdout, stderr, memory delta) resets between them.
@@ -300,10 +302,10 @@ sandbox.run(:Greeter, name: "world") # => "hello, world"
300
302
 
301
303
  | Form | Signature | Snippet name source | Validation timing |
302
304
  |----------|--------------------------------------|---------------------------------------|----------------------------------------------------------------------------|
303
- | Source | `preload(code: "...", name: :Const)` | The `name:` keyword | Trial-compiled at preload; compile errors raise immediately |
304
- | Bytecode | `preload(binary: bytes)` | Read from the bytecode's `debug_info` | Deferred to first invocation; failure raises `Kobako::BytecodeError` |
305
+ | Source | `preload(code: "...", name: :Const)` | The `name:` keyword | First invocation's replay; compile errors raise `Kobako::SandboxError` |
306
+ | Bytecode | `preload(binary: bytes)` | Read from the bytecode's `debug_info` | First invocation's replay; structural failure raises `Kobako::BytecodeError` |
305
307
 
306
- Use the source form for snippets authored in your repo (compile errors fail fast at `#preload`); use the bytecode form when snippets ship as build artifacts from a separate `mrbc` pipeline. Both replay through the same per-invocation path.
308
+ Use the source form for snippets authored in your repo; use the bytecode form when snippets ship as build artifacts from a separate `mrbc` pipeline. Both replay through the same per-invocation path, so no snippet content failure surfaces at `#preload` — force the first replay with a no-op invocation (e.g. `sandbox.eval("nil")`) when you want validation before real traffic.
307
309
 
308
310
  ## Security
309
311
 
data/ROADMAP.md ADDED
@@ -0,0 +1,26 @@
1
+ # Roadmap
2
+
3
+ kobako is a Ruby gem providing an in-process Wasm sandbox for untrusted mruby
4
+ code: a wasmtime host runs a precompiled `kobako.wasm` guest, with host↔guest
5
+ Transport over a MessagePack wire. Features cover one-shot `#eval`, preload +
6
+ `#run` dispatch, Service injection through Namespaces / Members, opaque
7
+ Capability Handles, block yield re-entry, three-class error attribution,
8
+ output capture, and a warm Sandbox pool.
9
+
10
+ | Feature | Entry Points | Notes |
11
+ |---------|-------------|-------|
12
+ | ✅ [F-01 Sandbox instantiation](docs/behavior/lifecycle.md) | [lib/kobako/sandbox.rb](lib/kobako/sandbox.rb) | B-22 (per-Thread isolation) is exercised only indirectly through the pool contention tests; no test cites it |
13
+ | ✅ [F-02 Namespace declaration](docs/behavior/registration.md) | [lib/kobako/catalog/namespaces.rb](lib/kobako/catalog/namespaces.rb) | — |
14
+ | ✅ [F-03 Member binding](docs/behavior/registration.md) | [lib/kobako/namespace.rb](lib/kobako/namespace.rb) | — |
15
+ | ✅ [F-04 Synchronous mruby source execution (`#eval`)](docs/behavior/lifecycle.md) | [lib/kobako/sandbox.rb](lib/kobako/sandbox.rb) | — |
16
+ | ✅ [F-05 Guest-initiated Transport dispatch](docs/behavior/dispatch.md) | [lib/kobako/transport/dispatcher.rb](lib/kobako/transport/dispatcher.rb) | — |
17
+ | ✅ [F-06 Capability Handle encoding and referencing](docs/behavior/dispatch.md) | [lib/kobako/catalog/handles.rb](lib/kobako/catalog/handles.rb) | — |
18
+ | ✅ [F-07 Three-class error attribution and raising](docs/behavior/errors.md) | [lib/kobako/outcome.rb](lib/kobako/outcome.rb) | E-26 (guest-entry envelope decode failure) has no exercising test — not reachable through the public API |
19
+ | ✅ [F-08 Guest output capture](docs/behavior/lifecycle.md) | [lib/kobako/capture.rb](lib/kobako/capture.rb) | — |
20
+ | ✅ [F-09 Host–guest message codec](docs/wire-codec.md) | [lib/kobako/codec/](lib/kobako/codec/) | — |
21
+ | ✅ [F-10 Reproducible build pipeline](SPEC.md#code-organization) | [tasks/wasm/build.rake](tasks/wasm/build.rake) | Verified by build-time gates (`rake anchors`, double-bake byte-identity, gemspec whitelist), not `test/` |
22
+ | ✅ [F-11 Multi-layer test and benchmark suite](SPEC.md#testing-style) | [test/](test/) | Benchmarks live in [benchmark/](benchmark/) with the gate in `tasks/bench/`; the anchor baseline advances only by deliberate re-bless |
23
+ | ✅ [F-12 Guest block reception and yield re-entry](docs/behavior/yield.md) | [lib/kobako/transport/yielder.rb](lib/kobako/transport/yielder.rb) | — |
24
+ | ✅ [F-13 Snippet preloading (`#preload`)](docs/behavior/invocation.md) | [lib/kobako/catalog/snippets.rb](lib/kobako/catalog/snippets.rb) | — |
25
+ | ✅ [F-14 Synchronous entrypoint dispatch (`#run`)](docs/behavior/invocation.md) | [lib/kobako/sandbox.rb](lib/kobako/sandbox.rb) | — |
26
+ | ✅ [F-15 Warm Sandbox pool checkout (`Kobako::Pool`)](docs/behavior/runtime.md) | [lib/kobako/pool.rb](lib/kobako/pool.rb) | — |
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.7.0...kobako-runtime-v0.8.0) (2026-07-08)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **crates:** give SetupError a Display so Error::Setup reads cleanly ([bc8b128](https://github.com/elct9620/kobako/commit/bc8b128c9e296319c7fef47441b412d2ce345dff))
9
+
10
+ ## [0.7.0](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.6.1...kobako-runtime-v0.7.0) (2026-07-03)
11
+
12
+
13
+ ### Features
14
+
15
+ * **crates:** build the requested isolation profile into the WASI context ([63c25d8](https://github.com/elct9620/kobako/commit/63c25d835d4d03010c1658217cee412318e6b5d8))
16
+ * **runtime:** runtimes declare their isolation profile ([f89717a](https://github.com/elct9620/kobako/commit/f89717a6bc9809f0de0df78f97da33a49a1474ac))
17
+
3
18
  ## [0.6.1](https://github.com/elct9620/kobako/compare/kobako-runtime-v0.6.0...kobako-runtime-v0.6.1) (2026-07-02)
4
19
 
5
20
 
@@ -13,7 +13,7 @@
13
13
 
14
14
  [package]
15
15
  name = "kobako-runtime"
16
- version = "0.6.1"
16
+ version = "0.8.0"
17
17
  edition = "2021"
18
18
  description = "Engine-neutral host runtime contract for embedding kobako Wasm guests."
19
19
  license = "Apache-2.0"
@@ -26,7 +26,7 @@ map the neutral types onto their own language surface.
26
26
 
27
27
  ```toml
28
28
  [dependencies]
29
- kobako-runtime = "0.6.1" # x-release-please-version
29
+ kobako-runtime = "0.8.0" # x-release-please-version
30
30
  ```
31
31
 
32
32
  ## License
@@ -62,3 +62,11 @@ impl fmt::Display for Trap {
62
62
  f.write_str(msg)
63
63
  }
64
64
  }
65
+
66
+ impl fmt::Display for SetupError {
67
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68
+ let (SetupError::ModuleNotBuilt(msg) | SetupError::Dead(msg) | SetupError::Intact(msg)) =
69
+ self;
70
+ f.write_str(msg)
71
+ }
72
+ }
@@ -1,8 +1,9 @@
1
1
  //! kobako-runtime — engine-neutral host runtime contract.
2
2
  //!
3
3
  //! The surface where a wasm engine implementation and a host frontend
4
- //! meet: the `Runtime` trait, the neutral per-invocation value types,
5
- //! and the dispatch / yield re-entry traits a frontend supplies.
4
+ //! meet: the `Runtime` trait, the isolation `Profile` a runtime
5
+ //! declares, the neutral per-invocation value types, and the
6
+ //! dispatch / yield re-entry traits a frontend supplies.
6
7
  //! Nothing here depends on an engine or a frontend type — each engine
7
8
  //! hides its own machinery behind `Runtime`, and each frontend maps
8
9
  //! these shapes onto its own host-language surface at its boundary
@@ -11,6 +12,7 @@
11
12
 
12
13
  pub mod dispatch;
13
14
  pub mod error;
15
+ pub mod profile;
14
16
  pub mod runtime;
15
17
  pub mod snapshot;
16
18
  pub mod yielder;
@@ -0,0 +1,35 @@
1
+ //! Isolation profile — the posture a frontend requests and a runtime
2
+ //! builds and declares.
3
+ //!
4
+ //! A rung on an ordered ladder: the host application requests the
5
+ //! posture it wants, the runtime builds it and declares the posture it
6
+ //! actually built, and the frontend refuses a declaration below the
7
+ //! request — so the request is also the floor. The governing contract
8
+ //! lives in the spec corpus (docs/behavior/security.md).
9
+
10
+ /// The ordered isolation ladder a runtime builds one rung of.
11
+ ///
12
+ /// `Hermetic` is the full ambient-denial posture: ambient time and
13
+ /// entropy denied at the WASI boundary, no filesystem / environment /
14
+ /// network reachability, and no host import beyond the wire ABI's
15
+ /// `__kobako_dispatch`. `Permissive` differs in exactly one grant —
16
+ /// live ambient time and entropy at the WASI boundary. Ordering
17
+ /// follows strength (`Permissive < Hermetic`), so a floor check is a
18
+ /// plain comparison.
19
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
20
+ pub enum Profile {
21
+ Permissive,
22
+ Hermetic,
23
+ }
24
+
25
+ #[cfg(test)]
26
+ mod tests {
27
+ use super::*;
28
+
29
+ #[test]
30
+ fn a_declaration_satisfies_any_floor_at_or_below_it() {
31
+ assert!(Profile::Hermetic >= Profile::Hermetic);
32
+ assert!(Profile::Hermetic >= Profile::Permissive);
33
+ assert!(Profile::Permissive < Profile::Hermetic);
34
+ }
35
+ }
@@ -11,6 +11,7 @@ use std::sync::Arc;
11
11
 
12
12
  use crate::dispatch::DispatchHandler;
13
13
  use crate::error::Error;
14
+ use crate::profile::Profile;
14
15
  use crate::snapshot::Snapshot;
15
16
 
16
17
  /// The per-invocation entry: a one-shot mruby source (`Eval`) or an
@@ -47,4 +48,10 @@ pub trait Runtime {
47
48
  frames: Frames<'_>,
48
49
  handler: Option<Arc<dyn DispatchHandler>>,
49
50
  ) -> Result<Snapshot, Error>;
51
+
52
+ /// The isolation profile this runtime provides. Deliberately
53
+ /// without a default: every engine states its posture explicitly,
54
+ /// and a frontend refuses construction when the declaration falls
55
+ /// below the floor its host application requested.
56
+ fn profile(&self) -> Profile;
50
57
  }
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.7.0...kobako-wasmtime-v0.8.0) (2026-07-08)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **crates:** keep the no-timeout epoch deadline within range ([a3255df](https://github.com/elct9620/kobako/commit/a3255df98a77825bb39b571a60fe6ff83d269d19))
9
+
10
+ ## [0.7.0](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.6.1...kobako-wasmtime-v0.7.0) (2026-07-03)
11
+
12
+
13
+ ### Features
14
+
15
+ * **crates:** build the requested isolation profile into the WASI context ([63c25d8](https://github.com/elct9620/kobako/commit/63c25d835d4d03010c1658217cee412318e6b5d8))
16
+ * **runtime:** runtimes declare their isolation profile ([f89717a](https://github.com/elct9620/kobako/commit/f89717a6bc9809f0de0df78f97da33a49a1474ac))
17
+
3
18
  ## [0.6.1](https://github.com/elct9620/kobako/compare/kobako-wasmtime-v0.6.0...kobako-wasmtime-v0.6.1) (2026-07-02)
4
19
 
5
20
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  [package]
17
17
  name = "kobako-wasmtime"
18
- version = "0.6.1"
18
+ version = "0.8.0"
19
19
  edition = "2021"
20
20
  description = "wasmtime implementation of the kobako host runtime contract."
21
21
  license = "Apache-2.0"
@@ -28,14 +28,14 @@ categories = ["wasm", "virtualization"]
28
28
  # The engine-neutral contract this crate implements. The version pin
29
29
  # rides the linked release group; the path keeps in-tree builds (and
30
30
  # the Ruby gem, which ships both crates) resolving locally.
31
- kobako-runtime = { version = "0.6.1", path = "../kobako-runtime" }
31
+ kobako-runtime = { version = "0.8.0", path = "../kobako-runtime" }
32
32
  # wasmtime — host-side embedder for kobako.wasm. We disable default-features
33
33
  # and opt back in only what kobako needs: a Cranelift-backed runtime that can
34
34
  # compile a pre-built wasm32-wasip1 module on the host triple, plus the `wat`
35
35
  # feature so test fixtures can be expressed as text.
36
36
  # `cache` / `parallel-compilation` / `pooling` / `component-model` / `async`
37
37
  # are intentionally off — kobako runs short-lived synchronous sandboxes.
38
- wasmtime = { version = "45.0.0", default-features = false, features = [
38
+ wasmtime = { version = "46.0.1", default-features = false, features = [
39
39
  "cranelift",
40
40
  "runtime",
41
41
  "gc",
@@ -49,7 +49,7 @@ wasmtime = { version = "45.0.0", default-features = false, features = [
49
49
  # WasiCtxBuilder + preview1 adapter which wires fd 1/2 to pipes. We omit
50
50
  # `p2` (component-model) and `p0`/`p3` (async) because kobako runs
51
51
  # synchronous sandboxes only.
52
- wasmtime-wasi = { version = "45.0.0", default-features = false, features = ["p1"] }
52
+ wasmtime-wasi = { version = "46.0.1", default-features = false, features = ["p1"] }
53
53
  # sha2 keys the on-disk compiled-module cache by Guest Binary content
54
54
  # (see cache.rs); a collision would load the wrong artifact, so the
55
55
  # hash must be cryptographic.
@@ -24,7 +24,7 @@ SDK consumes the same surface.
24
24
 
25
25
  ```toml
26
26
  [dependencies]
27
- kobako-wasmtime = "0.6.1" # x-release-please-version
27
+ kobako-wasmtime = "0.8.0" # x-release-please-version
28
28
  ```
29
29
 
30
30
  ## License
@@ -1,11 +1,14 @@
1
- //! Defense-in-depth denial of guest ambient authority at the WASI layer.
1
+ //! Denial of guest ambient authority at the WASI layer — the one grant
2
+ //! that separates the hermetic rung from permissive;
3
+ //! `frames::install_wasi_frames` wires these sources on `Hermetic` only.
2
4
  //!
3
5
  //! `wasmtime-wasi`'s `WasiCtxBuilder` defaults the guest's `wasi:clocks` to
4
6
  //! the host wall / monotonic clock and `wasi:random` to a fresh per-context
5
7
  //! seed. No allowlisted mrbgem reaches these preview1 imports today
6
8
  //! (`build_config/wasi.rb`), but a future libc-backed gem would silently
7
9
  //! obtain real time and host entropy — a covert timing channel and a
8
- //! nondeterminism source the sandbox deliberately excludes (docs/security-model.md).
10
+ //! nondeterminism source the hermetic posture deliberately excludes
11
+ //! (docs/security-model.md).
9
12
  //! Pinning the clocks to the Unix epoch and the RNG to a constant stream
10
13
  //! makes that denial a property of the host, not merely of the gem allowlist.
11
14
  //!
@@ -10,8 +10,10 @@
10
10
 
11
11
  use std::time::Duration;
12
12
 
13
- /// Wall-clock and output caps for one `Driver`. `None` on any field
14
- /// disables that cap.
13
+ use kobako_runtime::profile::Profile;
14
+
15
+ /// Wall-clock and output caps plus the requested isolation profile for
16
+ /// one `Driver`. `None` on any cap field disables that cap.
15
17
  pub struct Config {
16
18
  /// Wall-clock cap for one guest `#eval` / `#run`. Stamped into a
17
19
  /// per-run `Instant` deadline by `Driver::prime_caps`.
@@ -22,4 +24,10 @@ pub struct Config {
22
24
  pub stdout_limit_bytes: Option<usize>,
23
25
  /// Byte cap for guest stderr capture. Mirror of `stdout_limit_bytes`.
24
26
  pub stderr_limit_bytes: Option<usize>,
27
+ /// Isolation posture the frontend requested. The per-invocation
28
+ /// WASI context is built to this rung — `Hermetic` freezes ambient
29
+ /// time and entropy (`crate::ambient`), `Permissive` leaves the
30
+ /// live WASI sources — and `Driver::profile` declares it back as
31
+ /// the built posture.
32
+ pub profile: Profile,
25
33
  }
@@ -22,6 +22,7 @@ use crate::invocation::Invocation;
22
22
  use crate::{capture, frames, instance_pre, trap};
23
23
  use kobako_runtime::dispatch::DispatchHandler;
24
24
  use kobako_runtime::error::{Error, SetupError, Trap};
25
+ use kobako_runtime::profile::Profile;
25
26
  use kobako_runtime::runtime::{Entry, Frames, Runtime as ContractRuntime};
26
27
  use kobako_runtime::snapshot::{Capture, Completion, Snapshot, Usage};
27
28
 
@@ -182,7 +183,7 @@ impl Driver {
182
183
  }
183
184
  None => {
184
185
  store.data_mut().set_deadline(None);
185
- store.set_epoch_deadline(u64::MAX);
186
+ store.set_epoch_deadline(trap::NO_TIMEOUT_EPOCH_DELTA);
186
187
  }
187
188
  }
188
189
  let baseline = match exports.memory {
@@ -271,6 +272,17 @@ impl ContractRuntime for Driver {
271
272
  };
272
273
  Ok(self.build_snapshot(&store, completion))
273
274
  }
275
+
276
+ /// The posture this driver built — exactly the rung requested via
277
+ /// `Config`. Every per-invocation WASI context is built to it
278
+ /// (`frames::install_wasi_frames`): `Hermetic` freezes ambient time
279
+ /// and entropy (`crate::ambient`), `Permissive` leaves the live WASI
280
+ /// sources. Both rungs wire no filesystem, environment, or network,
281
+ /// and the linker adds only the wire ABI's `__kobako_dispatch`
282
+ /// beyond that confined WASI surface (`crate::instance_pre`).
283
+ fn profile(&self) -> Profile {
284
+ self.config.profile
285
+ }
274
286
  }
275
287
 
276
288
  /// Drop the memory cap as soon as the guest call returns so that
@@ -14,6 +14,7 @@ use crate::exports::Exports;
14
14
  use crate::invocation::Invocation;
15
15
  use crate::{ambient, capture, guest_mem};
16
16
  use kobako_runtime::error::{Error, SetupError, Trap};
17
+ use kobako_runtime::profile::Profile;
17
18
 
18
19
  /// Return the resolved `memory` export handle, or a `Trap` when the loaded
19
20
  /// module exports no linear memory — the "not a Kobako-shaped runtime"
@@ -98,11 +99,20 @@ pub(crate) fn install_wasi_frames(
98
99
  builder.stdin(stdin_pipe);
99
100
  builder.stdout(stdout_pipe.clone());
100
101
  builder.stderr(stderr_pipe.clone());
101
- // Deny the preview1 ambient-authority imports the guest never legitimately
102
- // reaches but the WASI layer would otherwise grant (see `ambient`).
103
- builder.wall_clock(ambient::FrozenWallClock);
104
- builder.monotonic_clock(ambient::FrozenMonotonicClock);
105
- builder.secure_random(ambient::deterministic_rng());
102
+ // The requested profile decides the ambient-authority grant: the
103
+ // hermetic rung denies the preview1 time and entropy imports (see
104
+ // `ambient`), the permissive rung leaves the live WASI sources.
105
+ // Filesystem, environment, and network stay absent on both rungs —
106
+ // the builder grants none unless asked. The exhaustive match makes
107
+ // a future ladder rung a compile error here, not a silent grant.
108
+ match config.profile {
109
+ Profile::Hermetic => {
110
+ builder.wall_clock(ambient::FrozenWallClock);
111
+ builder.monotonic_clock(ambient::FrozenMonotonicClock);
112
+ builder.secure_random(ambient::deterministic_rng());
113
+ }
114
+ Profile::Permissive => {}
115
+ }
106
116
  let wasi = builder.build_p1();
107
117
 
108
118
  store
@@ -174,3 +184,93 @@ where
174
184
  {
175
185
  export.ok_or_else(|| Trap::Other(SANDBOX_RUNTIME_MISSING_HOOKS.to_string()))
176
186
  }
187
+
188
+ #[cfg(test)]
189
+ mod tests {
190
+ //! Witness the B-54 rung split at the WASI boundary: one probe
191
+ //! module reads `wasi:clocks` / `wasi:random` through the context
192
+ //! `install_wasi_frames` builds — frozen under `Hermetic`, live
193
+ //! under `Permissive`.
194
+ use wasmtime::{Linker, Module};
195
+ use wasmtime_wasi::p1;
196
+
197
+ use super::*;
198
+ use crate::cache::shared_engine;
199
+
200
+ /// Preview1 probe with one export per ambient source: `clock_ns`
201
+ /// reads the realtime clock, `random_word` reads eight entropy bytes.
202
+ const AMBIENT_PROBE_WAT: &str = r#"
203
+ (module
204
+ (import "wasi_snapshot_preview1" "clock_time_get"
205
+ (func $clock (param i32 i64 i32) (result i32)))
206
+ (import "wasi_snapshot_preview1" "random_get"
207
+ (func $random (param i32 i32) (result i32)))
208
+ (memory (export "memory") 1)
209
+ (func (export "clock_ns") (result i64)
210
+ (drop (call $clock (i32.const 0) (i64.const 1) (i32.const 0)))
211
+ (i64.load (i32.const 0)))
212
+ (func (export "random_word") (result i64)
213
+ (drop (call $random (i32.const 8) (i32.const 8)))
214
+ (i64.load (i32.const 8))))
215
+ "#;
216
+
217
+ /// Instantiate the probe over a WASI context built at `profile` and
218
+ /// return the `(clock, random)` readings it observes.
219
+ fn probe_ambient(profile: Profile) -> (i64, i64) {
220
+ let engine = shared_engine().expect("shared engine must be constructible");
221
+ let config = Config {
222
+ timeout: None,
223
+ stdout_limit_bytes: None,
224
+ stderr_limit_bytes: None,
225
+ profile,
226
+ };
227
+ let mut store = WtStore::new(engine, Invocation::new(None));
228
+ store.set_epoch_deadline(crate::trap::NO_TIMEOUT_EPOCH_DELTA);
229
+ install_wasi_frames(&mut store, &config, &[]).expect("WASI context must install");
230
+
231
+ let mut linker: Linker<Invocation> = Linker::new(engine);
232
+ p1::add_to_linker_sync(&mut linker, |state: &mut Invocation| state.wasi_mut())
233
+ .expect("WASI imports must link");
234
+ let module = Module::new(engine, AMBIENT_PROBE_WAT).expect("probe module must compile");
235
+ let instance = linker
236
+ .instantiate(&mut store, &module)
237
+ .expect("probe module must instantiate");
238
+
239
+ let read = |store: &mut WtStore<Invocation>, name: &str| -> i64 {
240
+ instance
241
+ .get_typed_func::<(), i64>(store.as_context_mut(), name)
242
+ .expect("probe export must resolve")
243
+ .call(store.as_context_mut(), ())
244
+ .expect("probe export must run")
245
+ };
246
+ let clock = read(&mut store, "clock_ns");
247
+ let random = read(&mut store, "random_word");
248
+ (clock, random)
249
+ }
250
+
251
+ #[test]
252
+ fn hermetic_denies_ambient_time_and_entropy() {
253
+ let (clock, random) = probe_ambient(Profile::Hermetic);
254
+ assert_eq!(
255
+ clock, 0,
256
+ "a hermetic guest's wasi:clocks must read the Unix epoch, not host time"
257
+ );
258
+ assert_eq!(
259
+ random, 0,
260
+ "a hermetic guest's wasi:random must yield the constant stream, not host entropy"
261
+ );
262
+ }
263
+
264
+ #[test]
265
+ fn permissive_grants_live_ambient_time_and_entropy() {
266
+ let (clock, random) = probe_ambient(Profile::Permissive);
267
+ assert!(
268
+ clock > 0,
269
+ "a permissive guest's wasi:clocks must read live host time"
270
+ );
271
+ assert_ne!(
272
+ random, 0,
273
+ "a permissive guest's wasi:random must yield host entropy"
274
+ );
275
+ }
276
+ }
@@ -70,12 +70,13 @@ fn build_linker() -> Result<Linker<Invocation>, SetupError> {
70
70
  // `__kobako_dispatch` host import. Signature per docs/wire-codec.md
71
71
  // § ABI Signatures:
72
72
  // (req_ptr: i32, req_len: i32) -> i64
73
- // Decodes the Request bytes, dispatches via the Ruby-side
74
- // dispatch Proc (bound per-Sandbox through `Runtime#on_dispatch=`),
75
- // allocates a guest buffer through `__kobako_alloc`, writes
76
- // the Response bytes there, and returns the packed
77
- // `(ptr<<32)|len`. The dispatcher returns 0 on any wire-layer
78
- // fault (including no Proc bound); see `dispatch::handle`.
73
+ // Reads the Request bytes from guest memory and hands them —
74
+ // undecoded to the bound `DispatchHandler` (the frontend's
75
+ // dispatch bridge, e.g. a Ruby Proc), then allocates a guest
76
+ // buffer through `__kobako_alloc`, writes the handler's Response
77
+ // bytes there, and returns the packed `(ptr<<32)|len`. The
78
+ // dispatcher returns 0 on any wire-layer fault (including no
79
+ // handler bound); see `dispatch::handle`.
79
80
  linker
80
81
  .func_wrap(
81
82
  "env",
@@ -318,10 +318,9 @@ impl ResourceLimiter for MemoryLimiter {
318
318
 
319
319
  /// Marker error returned from `MemoryLimiter::memory_growing` when the
320
320
  /// per-invocation memory cap is exceeded. Downcast from the wasmtime
321
- /// trap error to surface as
322
- /// `Kobako::MemoryLimitError` on the Ruby side. Callers use the
323
- /// `Display` impl below — no field is read directly — so the inner
324
- /// state stays private.
321
+ /// trap error to classify the failure as a memory-limit `Trap`. Callers
322
+ /// use the `Display` impl below no field is read directly — so the
323
+ /// inner state stays private.
325
324
  #[derive(Debug)]
326
325
  pub(crate) struct MemoryLimitTrap {
327
326
  desired: usize,
@@ -354,7 +353,7 @@ impl std::error::Error for MemoryLimitTrap {}
354
353
 
355
354
  /// Marker error returned from the epoch-deadline callback when the
356
355
  /// wall-clock deadline is exceeded. Downcast from the wasmtime trap
357
- /// error to surface as `Kobako::TimeoutError` on the Ruby side.
356
+ /// error to classify the failure as a timeout `Trap`.
358
357
  #[derive(Debug)]
359
358
  pub(crate) struct TimeoutTrap;
360
359
 
@@ -1,12 +1,13 @@
1
1
  //! Trap classification for the run path.
2
2
  //!
3
- //! Maps a `wasmtime` run error to the right top-level `Kobako::*` Ruby
4
- //! exception (`TimeoutError` / `MemoryLimitError` / `TrapError`), and
5
- //! hosts the epoch-deadline callback that raises the wall-clock
6
- //! `TimeoutTrap`. The classification is a pure function over the error's
7
- //! downcast chain so it can be exercised from `cargo test` without the
8
- //! magnus surface; the trap marker types themselves live in
9
- //! `crate::invocation` (where the limiter / callback construct them).
3
+ //! Classifies a `wasmtime` run error into the engine-neutral `Trap`
4
+ //! kind (`Timeout` / `MemoryLimit` / `Other`) that each frontend maps
5
+ //! onto its own error surface, and hosts the epoch-deadline callback
6
+ //! that raises the wall-clock `TimeoutTrap`. The classification is a
7
+ //! pure function over the error's downcast chain so it can be exercised
8
+ //! from `cargo test` without any frontend; the trap marker types
9
+ //! themselves live in `crate::invocation` (where the limiter / callback
10
+ //! construct them).
10
11
 
11
12
  use std::time::Instant;
12
13
 
@@ -15,43 +16,48 @@ use wasmtime::{StoreContextMut, UpdateDeadline};
15
16
  use crate::invocation::{Invocation, MemoryLimitTrap, TimeoutTrap};
16
17
  use kobako_runtime::error::{SetupError, Trap};
17
18
 
19
+ /// Epoch delta that keeps the deadline effectively unreachable when no
20
+ /// wall-clock cap is configured. Half the epoch range rather than
21
+ /// `u64::MAX`: wasmtime adds the delta to the engine's current epoch,
22
+ /// which the process-wide ticker advances for the engine's whole
23
+ /// lifetime, so the full range overflows the sum (a panic under debug
24
+ /// overflow checks).
25
+ pub(crate) const NO_TIMEOUT_EPOCH_DELTA: u64 = u64::MAX / 2;
26
+
18
27
  /// Epoch-deadline callback installed on every Store. Read the per-run
19
28
  /// wall-clock deadline from `Invocation` and trap with
20
29
  /// `TimeoutTrap` once the deadline has passed; otherwise extend the
21
30
  /// next check by one tick of the process-wide epoch ticker. When the
22
31
  /// deadline is `None` the callback should not fire under the normal
23
32
  /// `Driver` invoke flow because
24
- /// `set_epoch_deadline(u64::MAX)` is used; returning a long extension
25
- /// keeps the callback inert as a defence in depth.
33
+ /// `NO_TIMEOUT_EPOCH_DELTA` is primed; returning the same long
34
+ /// extension keeps the callback inert as a defence in depth.
26
35
  pub(crate) fn epoch_deadline_callback(
27
36
  ctx: StoreContextMut<'_, Invocation>,
28
37
  ) -> wasmtime::Result<UpdateDeadline> {
29
38
  match ctx.data().deadline() {
30
39
  Some(deadline) if Instant::now() >= deadline => Err(wasmtime::Error::new(TimeoutTrap)),
31
40
  Some(_) => Ok(UpdateDeadline::Continue(1)),
32
- None => Ok(UpdateDeadline::Continue(u64::MAX / 2)),
41
+ None => Ok(UpdateDeadline::Continue(NO_TIMEOUT_EPOCH_DELTA)),
33
42
  }
34
43
  }
35
44
 
36
45
  /// Configured-cap path classification for a wasmtime error. The
37
- /// downcast logic stays in a pure helper so the
38
- /// `Kobako::TimeoutError` / `MemoryLimitError` /
39
- /// `Kobako::TrapError` mapping can be exercised from `cargo test`
40
- /// without the magnus surface.
46
+ /// downcast logic stays in a pure helper so the `Trap` kind routing
47
+ /// can be exercised from `cargo test` without any frontend.
41
48
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42
49
  enum TrapClass {
43
50
  /// Wall-clock cap path.
44
51
  Timeout,
45
52
  /// Linear-memory cap path.
46
53
  MemoryLimit,
47
- /// Any other wasmtime error — surfaces as the base
48
- /// `Kobako::TrapError`.
54
+ /// Any other wasmtime error — surfaces as the base `Trap::Other`.
49
55
  Other,
50
56
  }
51
57
 
52
- /// Inspect a wasmtime error to decide which top-level `Kobako::*` trap
53
- /// class it should map to. Pure function — operates on the error's
54
- /// downcast chain only, no magnus / Ruby state required.
58
+ /// Inspect a wasmtime error to decide which neutral trap kind it
59
+ /// belongs to. Pure function — operates on the error's downcast chain
60
+ /// only, no frontend state required.
55
61
  fn classify_trap(err: &wasmtime::Error) -> TrapClass {
56
62
  if err.downcast_ref::<TimeoutTrap>().is_some() {
57
63
  TrapClass::Timeout
@@ -123,8 +129,23 @@ pub(crate) fn instantiate_err(err: wasmtime::Error) -> SetupError {
123
129
 
124
130
  #[cfg(test)]
125
131
  mod tests {
126
- use super::{classify_trap, other_trap_message, TrapClass};
127
- use crate::invocation::{MemoryLimitTrap, TimeoutTrap};
132
+ use super::{classify_trap, other_trap_message, TrapClass, NO_TIMEOUT_EPOCH_DELTA};
133
+ use crate::invocation::{Invocation, MemoryLimitTrap, TimeoutTrap};
134
+
135
+ // The no-timeout priming delta is added to the engine's current
136
+ // epoch inside wasmtime, and the process-wide ticker advances that
137
+ // epoch from the first `shared_engine` call on — so the sum must
138
+ // stay in range for a long-lived engine, not just a fresh one.
139
+ // `increment_epoch` stands in for the ticker to make the ticked
140
+ // state deterministic; under debug overflow checks an overflowing
141
+ // delta panics right here.
142
+ #[test]
143
+ fn no_timeout_delta_survives_a_ticked_engine_epoch() {
144
+ let engine = crate::cache::shared_engine().expect("shared engine must be constructible");
145
+ engine.increment_epoch();
146
+ let mut store = wasmtime::Store::new(engine, Invocation::new(None));
147
+ store.set_epoch_deadline(NO_TIMEOUT_EPOCH_DELTA);
148
+ }
128
149
 
129
150
  #[test]
130
151
  fn classify_trap_routes_timeout_trap_to_timeout() {
data/data/kobako.wasm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kobako"
3
- version = "0.12.2"
3
+ version = "0.14.0"
4
4
  edition = "2021"
5
5
  authors = ["Aotokitsuruya <contact@aotoki.me>"]
6
6
  license = "Apache-2.0"
@@ -1,12 +1,10 @@
1
1
  use magnus::{Error, Ruby};
2
2
 
3
3
  mod runtime;
4
- mod snapshot;
5
4
 
6
5
  #[magnus::init]
7
6
  fn init(ruby: &Ruby) -> Result<(), Error> {
8
7
  let module = ruby.define_module("Kobako")?;
9
8
  runtime::init(ruby, module)?;
10
- snapshot::init(ruby, module)?;
11
9
  Ok(())
12
10
  }