kobako 0.27.0 → 0.29.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 (183) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +241 -0
  4. data/Cargo.lock +132 -126
  5. data/README.md +240 -147
  6. data/ROADMAP.md +28 -22
  7. data/SECURITY.md +2 -2
  8. data/crates/kobako-runtime/CHANGELOG.md +117 -0
  9. data/crates/kobako-runtime/Cargo.toml +27 -6
  10. data/crates/kobako-runtime/README.md +16 -10
  11. data/crates/kobako-runtime/src/dispatch.rs +8 -0
  12. data/crates/kobako-runtime/src/error.rs +8 -9
  13. data/crates/kobako-runtime/src/filesystem/directory/spelling.rs +194 -0
  14. data/crates/kobako-runtime/src/filesystem/directory.rs +1082 -0
  15. data/crates/kobako-runtime/src/filesystem/memory.rs +1100 -0
  16. data/crates/kobako-runtime/src/filesystem/overlay/exclusive.rs +88 -0
  17. data/crates/kobako-runtime/src/filesystem/overlay/stack.rs +808 -0
  18. data/crates/kobako-runtime/src/filesystem/overlay.rs +1955 -0
  19. data/crates/kobako-runtime/src/filesystem/path.rs +60 -0
  20. data/crates/kobako-runtime/src/filesystem/temporary.rs +167 -0
  21. data/crates/kobako-runtime/src/filesystem.rs +392 -0
  22. data/crates/kobako-runtime/src/grant.rs +20 -0
  23. data/crates/kobako-runtime/src/lib.rs +12 -2
  24. data/crates/kobako-runtime/src/mount.rs +178 -0
  25. data/crates/kobako-runtime/src/profile.rs +4 -4
  26. data/crates/kobako-runtime/src/runtime.rs +43 -17
  27. data/crates/kobako-runtime/src/snapshot.rs +33 -15
  28. data/crates/kobako-runtime/src/yielder.rs +14 -6
  29. data/crates/kobako-transport/CHANGELOG.md +45 -0
  30. data/crates/kobako-transport/Cargo.toml +1 -1
  31. data/crates/kobako-transport/README.md +21 -12
  32. data/crates/kobako-transport/src/abi.rs +11 -14
  33. data/crates/kobako-transport/src/envelope/bytes.rs +18 -6
  34. data/crates/kobako-transport/src/envelope/compile_reply.rs +139 -0
  35. data/crates/kobako-transport/src/envelope/fault.rs +2 -1
  36. data/crates/kobako-transport/src/envelope/outcome.rs +17 -16
  37. data/crates/kobako-transport/src/envelope/preamble.rs +513 -0
  38. data/crates/kobako-transport/src/envelope/reply.rs +103 -61
  39. data/crates/kobako-transport/src/envelope/request.rs +305 -0
  40. data/crates/kobako-transport/src/envelope/run.rs +1 -2
  41. data/crates/kobako-transport/src/envelope/tag.rs +49 -0
  42. data/crates/kobako-transport/src/envelope/unsupported.rs +105 -0
  43. data/crates/kobako-transport/src/envelope.rs +19 -12
  44. data/crates/kobako-transport/src/lib.rs +0 -2
  45. data/crates/kobako-wasmtime/CHANGELOG.md +163 -0
  46. data/crates/kobako-wasmtime/Cargo.toml +23 -16
  47. data/crates/kobako-wasmtime/README.md +6 -4
  48. data/crates/kobako-wasmtime/src/abi.rs +67 -36
  49. data/crates/kobako-wasmtime/src/ambient.rs +64 -5
  50. data/crates/kobako-wasmtime/src/cache.rs +204 -127
  51. data/crates/kobako-wasmtime/src/capture.rs +4 -11
  52. data/crates/kobako-wasmtime/src/config.rs +69 -15
  53. data/crates/kobako-wasmtime/src/descriptors.rs +167 -0
  54. data/crates/kobako-wasmtime/src/dispatch.rs +95 -87
  55. data/crates/kobako-wasmtime/src/driver.rs +174 -125
  56. data/crates/kobako-wasmtime/src/exports.rs +88 -34
  57. data/crates/kobako-wasmtime/src/guest_mem.rs +151 -138
  58. data/crates/kobako-wasmtime/src/imports.rs +46 -0
  59. data/crates/kobako-wasmtime/src/instance_pre.rs +19 -58
  60. data/crates/kobako-wasmtime/src/invocation.rs +84 -412
  61. data/crates/kobako-wasmtime/src/io.rs +89 -0
  62. data/crates/kobako-wasmtime/src/lib.rs +7 -24
  63. data/crates/kobako-wasmtime/src/limiter.rs +253 -0
  64. data/crates/kobako-wasmtime/src/preview1/args.rs +60 -0
  65. data/crates/kobako-wasmtime/src/preview1/beneath.rs +188 -0
  66. data/crates/kobako-wasmtime/src/preview1/differential/mount.rs +609 -0
  67. data/crates/kobako-wasmtime/src/preview1/differential.rs +372 -0
  68. data/crates/kobako-wasmtime/src/preview1/errno.rs +267 -0
  69. data/crates/kobako-wasmtime/src/preview1/fd.rs +672 -0
  70. data/crates/kobako-wasmtime/src/preview1/memory.rs +205 -0
  71. data/crates/kobako-wasmtime/src/preview1/mount_view.rs +2499 -0
  72. data/crates/kobako-wasmtime/src/preview1/path.rs +421 -0
  73. data/crates/kobako-wasmtime/src/preview1/poll.rs +333 -0
  74. data/crates/kobako-wasmtime/src/preview1/sock.rs +30 -0
  75. data/crates/kobako-wasmtime/src/preview1.rs +461 -0
  76. data/crates/kobako-wasmtime/src/trap.rs +66 -59
  77. data/crates/kobako-wasmtime/src/wasi.rs +317 -0
  78. data/crates/kobako-wasmtime/tests/compile_posture.rs +43 -0
  79. data/crates/kobako-wasmtime/tests/guest_file.rs +1054 -0
  80. data/crates/kobako-wasmtime/tests/preamble_flags.rs +70 -0
  81. data/crates/kobako-wasmtime/tests/snippet_compile.rs +95 -0
  82. data/crates/kobako-wasmtime/tests/support/mod.rs +30 -0
  83. data/crates/kobako-wasmtime/tests/unanswered_call.rs +86 -0
  84. data/crates/kobako-wasmtime/tests/unsupported_call.rs +51 -0
  85. data/data/kobako.wasm +0 -0
  86. data/ext/kobako/Cargo.toml +4 -2
  87. data/ext/kobako/src/runtime/bridge.rs +177 -113
  88. data/ext/kobako/src/runtime/errors.rs +26 -32
  89. data/ext/kobako/src/runtime/escape.rs +53 -0
  90. data/ext/kobako/src/runtime/filesystem/grants.rs +91 -0
  91. data/ext/kobako/src/runtime/filesystem/ruby.rs +400 -0
  92. data/ext/kobako/src/runtime/filesystem.rs +236 -0
  93. data/ext/kobako/src/runtime/gvl.rs +11 -13
  94. data/ext/kobako/src/runtime/pinned_value.rs +31 -0
  95. data/ext/kobako/src/runtime/snapshot.rs +127 -0
  96. data/ext/kobako/src/runtime.rs +190 -312
  97. data/lib/kobako/capture.rb +1 -4
  98. data/lib/kobako/catalog/extensions.rb +21 -59
  99. data/lib/kobako/catalog/flags.rb +29 -0
  100. data/lib/kobako/catalog/grants.rb +30 -0
  101. data/lib/kobako/catalog/handles.rb +7 -54
  102. data/lib/kobako/catalog/mount.rb +27 -0
  103. data/lib/kobako/catalog/services.rb +17 -60
  104. data/lib/kobako/catalog/snippets.rb +41 -69
  105. data/lib/kobako/catalog.rb +13 -10
  106. data/lib/kobako/codec/decoder.rb +10 -28
  107. data/lib/kobako/codec/encoder.rb +3 -22
  108. data/lib/kobako/codec/error.rb +6 -27
  109. data/lib/kobako/codec/ext_types.rb +72 -94
  110. data/lib/kobako/codec/handle_walk.rb +65 -130
  111. data/lib/kobako/codec/nesting.rb +3 -8
  112. data/lib/kobako/codec/state.rb +21 -11
  113. data/lib/kobako/codec/utils.rb +6 -34
  114. data/lib/kobako/codec/value_budget.rb +86 -0
  115. data/lib/kobako/codec.rb +10 -22
  116. data/lib/kobako/constant_name.rb +8 -0
  117. data/lib/kobako/context.rb +83 -117
  118. data/lib/kobako/errors.rb +57 -132
  119. data/lib/kobako/execution.rb +16 -21
  120. data/lib/kobako/extension.rb +23 -40
  121. data/lib/kobako/filesystem.rb +38 -0
  122. data/lib/kobako/handle.rb +7 -12
  123. data/lib/kobako/outcome.rb +51 -80
  124. data/lib/kobako/payload/arguments.rb +1 -16
  125. data/lib/kobako/payload.rb +4 -12
  126. data/lib/kobako/pool.rb +25 -55
  127. data/lib/kobako/runtime.rb +7 -21
  128. data/lib/kobako/sandbox.rb +128 -137
  129. data/lib/kobako/sandbox_options.rb +71 -90
  130. data/lib/kobako/snippet/bytecode.rb +11 -0
  131. data/lib/kobako/snippet/source.rb +1 -16
  132. data/lib/kobako/snippet.rb +2 -10
  133. data/lib/kobako/transport/block_failure.rb +35 -0
  134. data/lib/kobako/transport/call.rb +1 -14
  135. data/lib/kobako/transport/dispatcher.rb +127 -206
  136. data/lib/kobako/transport/error.rb +6 -20
  137. data/lib/kobako/transport/exposure.rb +10 -17
  138. data/lib/kobako/transport/reflection.rb +108 -63
  139. data/lib/kobako/transport/run.rb +17 -71
  140. data/lib/kobako/transport/yielder.rb +20 -94
  141. data/lib/kobako/transport.rb +4 -15
  142. data/lib/kobako/unresolved.rb +4 -11
  143. data/lib/kobako/usage.rb +2 -6
  144. data/lib/kobako/version.rb +1 -1
  145. data/lib/kobako.rb +1 -0
  146. data/sig/kobako/catalog/extensions.rbs +2 -2
  147. data/sig/kobako/catalog/flags.rbs +13 -0
  148. data/sig/kobako/catalog/grants.rbs +13 -0
  149. data/sig/kobako/catalog/mount.rbs +28 -0
  150. data/sig/kobako/catalog/services.rbs +3 -5
  151. data/sig/kobako/catalog/snippets.rbs +5 -5
  152. data/sig/kobako/catalog.rbs +1 -0
  153. data/sig/kobako/codec/ext_types.rbs +10 -8
  154. data/sig/kobako/codec/handle_walk.rbs +9 -7
  155. data/sig/kobako/codec/state.rbs +7 -1
  156. data/sig/kobako/codec/utils.rbs +2 -2
  157. data/sig/kobako/codec/value_budget.rbs +23 -0
  158. data/sig/kobako/codec.rbs +4 -2
  159. data/sig/kobako/constant_name.rbs +3 -0
  160. data/sig/kobako/context.rbs +15 -8
  161. data/sig/kobako/errors.rbs +10 -4
  162. data/sig/kobako/filesystem.rbs +88 -0
  163. data/sig/kobako/guest_yielder.rbs +9 -0
  164. data/sig/kobako/outcome.rbs +6 -8
  165. data/sig/kobako/pool.rbs +1 -1
  166. data/sig/kobako/runtime.rbs +21 -45
  167. data/sig/kobako/sandbox.rbs +11 -1
  168. data/sig/kobako/sandbox_options.rbs +25 -7
  169. data/sig/kobako/snapshot.rbs +20 -0
  170. data/sig/kobako/snippet/bytecode.rbs +12 -0
  171. data/sig/kobako/transport/block_failure.rbs +13 -0
  172. data/sig/kobako/transport/dispatcher.rbs +15 -19
  173. data/sig/kobako/transport/exposure.rbs +1 -1
  174. data/sig/kobako/transport/reflection.rbs +19 -4
  175. data/sig/kobako/transport/run.rbs +1 -3
  176. data/sig/kobako/transport/yielder.rbs +5 -9
  177. data/sig/kobako/transport.rbs +9 -8
  178. metadata +75 -10
  179. data/crates/kobako-transport/src/envelope/invocation_frames.rs +0 -241
  180. data/crates/kobako-wasmtime/src/frames.rs +0 -273
  181. data/lib/kobako/snippet/binary.rb +0 -26
  182. data/rust-toolchain.toml +0 -15
  183. data/sig/kobako/snippet/binary.rbs +0 -12
data/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/elct9620/kobako)
4
4
 
5
- Kobako is a Ruby gem that embeds a Wasm-isolated mruby interpreter inside your application, so you can execute untrusted Ruby scripts (LLM-generated code, user formulas, student submissions, third-party plugins) in-process without giving them access to host memory, files, network, or credentials. Its home is the Ruby ecosystem; a Rust SDK offers the same sandbox to hosts written in other languages.
5
+ Kobako is a Ruby gem that embeds a Wasm-isolated mruby interpreter inside your application. It runs untrusted Ruby scripts in-process — LLM-generated code, user formulas, student submissions, third-party plugins. Those scripts get no access to host memory, files, network, or credentials. Its home is the Ruby ecosystem; a Rust SDK offers the same sandbox to hosts written in other languages.
6
6
 
7
- The host (`wasmtime`) runs a precompiled `kobako.wasm` guest containing mruby and a Transport proxy. The only way a guest script can reach the outside world is through Host App-declared **Services** — named host objects you explicitly inject into the sandbox; the guest sees each one as a proxy that forwards calls back to the host over the Transport wire.
7
+ The host (`wasmtime`) runs a precompiled `kobako.wasm` guest containing mruby and a Transport proxy. A guest script reaches the outside world only through Host App-declared **Services**, the named host objects you explicitly inject into the sandbox. The guest sees each one as a proxy that forwards calls back to the host over the Transport wire.
8
8
 
9
9
  ```
10
10
  Host process Wasm guest
@@ -35,6 +35,8 @@ The precompiled `kobako.wasm` Guest Binary ships inside the gem, so end users do
35
35
 
36
36
  ## Installation
37
37
 
38
+ Install the gem; it ships its Guest Binary prebuilt.
39
+
38
40
  ```bash
39
41
  bundle add kobako
40
42
  # or
@@ -43,7 +45,7 @@ gem install kobako
43
45
 
44
46
  ## Quick Start
45
47
 
46
- The same untrusted script runs identically from either host frontend. It executes inside the Wasm guest and cannot read your filesystem, open sockets, or touch your environment.
48
+ The same untrusted script runs identically from either host frontend. It executes inside the Wasm guest and reaches no file you have not mounted for it, no socket, and none of your environment.
47
49
 
48
50
  ```ruby
49
51
  require "kobako"
@@ -55,12 +57,12 @@ sandbox.eval("1 + 2").value # => 3
55
57
  ```rust
56
58
  use kobako::{Options, Sandbox};
57
59
 
58
- // Options::default() is secure by default: no caps, hermetic isolation.
60
+ // Options::default() is secure by default: the same caps as Ruby, hermetic isolation.
59
61
  let sandbox = Sandbox::new("kobako.wasm", Options::default())?;
60
62
  sandbox.eval("1 + 2")?.value()?; // => Value::Int(3)
61
63
  ```
62
64
 
63
- Each invocation returns the record of that one run — `Kobako::Execution` in Ruby, `Execution` in Rust — carrying the guest value alongside the run's output and resource usage. Nothing a run observes is stored on the Sandbox, so the same Sandbox serves concurrent invocations without them seeing each other.
65
+ Each invocation returns the record of that one run: `Kobako::Execution` in Ruby, `Execution` in Rust. It carries the guest value alongside the run's output and resource usage. Nothing a run observes is stored on the Sandbox, so the same Sandbox serves concurrent invocations without them seeing each other.
64
66
 
65
67
  The gem bundles its Guest Binary; a Rust host loads one explicitly — see [Frontends & Guest Binaries](#frontends--guest-binaries) for the packages and the Guest Binary variants.
66
68
 
@@ -75,31 +77,23 @@ host frontend Guest Binary
75
77
  your own ──┘ └── kobako+json.wasm · your own
76
78
  ```
77
79
 
78
- They compose freely — any frontend loads any Guest Binary, so a Ruby host can run a JSON-enabled guest and a Rust host can run the pure default. How far down those choices go — and which ones a given starting point quietly makes for you — is laid out in [`docs/architecture.md`](docs/architecture.md).
80
+ They compose freely, since any frontend loads any Guest Binary. A Ruby host can run a JSON-enabled guest, and a Rust host can run the pure default. How far down those choices go — and which ones a given starting point quietly makes for you — is laid out in [`docs/guides/assembly-levels.md`](docs/guides/assembly-levels.md).
79
81
 
80
82
  ### Host frontends
81
83
 
82
- The host embeds the sandbox and owns the SPEC wire codec. Choose by your host language and how much of the contract you want pre-assembled.
84
+ The host embeds the sandbox and owns the wire codec. Choose by your host language and how much of the contract you want pre-assembled.
83
85
 
84
86
  | Frontend | Package | Add it | Best for |
85
87
  |----------|---------|--------|----------|
86
88
  | Ruby gem | `kobako` (RubyGems) | `gem install kobako` | A Ruby host — Services, Handles, snippets, and pooling out of the box |
87
- | Rust SDK | `kobako` (crates.io) | `kobako = "0.12"` | A Rust host — the same behavior contract behind an idiomatic Rust API |
88
- | Low-level crates | `kobako-wasmtime` + `kobako-runtime` + `kobako-transport` + `kobako-codec` | Cargo deps | A custom host, or driving the wire directly in another language |
89
+ | Rust SDK | `kobako` (crates.io) | `cargo add kobako` | A Rust host — the same behavior contract behind an idiomatic Rust API |
90
+ | Low-level crates | `kobako-wasmtime` + `kobako-runtime` + `kobako-transport` + `kobako-codec` | `cargo add` each | A custom host, or driving the wire directly in another language |
89
91
 
90
- The Rust crates are documented on [crates.io](https://crates.io/crates/kobako); the Ruby gem is this README. Three runnable Rust hosts show the choice: [`plugin-rs`](examples/plugin-rs) builds on the SDK, [`wire-rs`](examples/wire-rs) assembles a host by hand on the low-level crates, and [`fixed-schema-rs`](examples/fixed-schema-rs) replaces what both of those keep — the payload schema — with a guest and a host that agree on protobuf.
92
+ The Rust crates are documented on [crates.io](https://crates.io/crates/kobako); the Ruby gem is this README. Three runnable Rust hosts show the choice. [`plugin-rs`](examples/plugin-rs) builds on the SDK, and [`wire-rs`](examples/wire-rs) assembles a host by hand on the low-level crates. [`fixed-schema-rs`](examples/fixed-schema-rs) replaces the payload schema both of those keep, with a guest and a host that agree on protobuf.
91
93
 
92
94
  ### Pre-built Guest Binaries
93
95
 
94
- The gem bundles the pure `kobako.wasm`. Regexp and JSON are opt-in capabilities compiled into separate variants, each attached to every [GitHub Release](https://github.com/elct9620/kobako/releases). Download a variant and point your host at it — `Sandbox.new(wasm_path:)` in Ruby, `Sandbox::new(path, ...)` in Rust.
95
-
96
- | Variant | File | Adds | Distribution |
97
- |---------|------|------|--------------|
98
- | Pure (default) | `kobako.wasm` | mruby + IO | Bundled in the gem, and a Release asset |
99
- | +regexp | `kobako+regexp.wasm` | Regexp / MatchData (ASCII) | Release asset |
100
- | +regexp-unicode | `kobako+regexp-unicode.wasm` | Regexp / MatchData (Unicode) | Release asset |
101
- | +json | `kobako+json.wasm` | JSON | Release asset |
102
- | +full | `kobako+full.wasm` | JSON + Regexp (ASCII) | Release asset |
96
+ The gem bundles the pure `kobako.wasm`. Regexp and JSON are opt-in capabilities compiled into separate variants, each attached to every [GitHub Release](https://github.com/elct9620/kobako/releases). Download a variant and point your host at it — `Sandbox.new(wasm_path:)` in Ruby, `Sandbox::new(path, ...)` in Rust. [`docs/guides/variants.md`](docs/guides/variants.md) lists every variant and what it adds.
103
97
 
104
98
  ```ruby
105
99
  sandbox = Kobako::Sandbox.new(wasm_path: "kobako+full.wasm")
@@ -108,54 +102,40 @@ sandbox.eval('JSON.generate({ n: "42".to_i })').value # => "{\"n\":42}"
108
102
 
109
103
  ### Custom Guest Binaries
110
104
 
111
- When no pre-built variant matches your capability set, assemble a guest in Rust. `kobako-mruby` is the harness; its `init_gems` hook is where you install exactly the capability gems you want — the shipped `kobako-io` / `kobako-regexp` / `kobako-json`, or your own `beni::Gem`. `kobako-core`'s `export_guest!` emits the wasm ABI. `wasm/kobako-wasm/src/guest.rs` is the worked example.
112
-
113
- | Guest crate | Role |
114
- |-------------|------|
115
- | `kobako-mruby` | mruby guest harness — the `MrbGuest` trait and provided flows |
116
- | `kobako-core` | Guest ABI contract — the `Guest` trait and the `export_guest!` macro |
117
- | `kobako-io` | IO / Kernel capability gem |
118
- | `kobako-regexp` | Regexp / MatchData capability gem |
119
- | `kobako-json` | JSON capability gem |
120
-
121
- ```rust
122
- use beni::{Error, Mrb};
105
+ When no variant matches your capability set, build a Guest Binary of your own in Rust. A shell over `kobako-mruby` installs exactly the capability gems you choose: the shipped `kobako-io`, `kobako-regexp`, and `kobako-json`, or your own `beni::Gem`. `kobako-core` exports the wasm ABI. The Ruby gem drives it unchanged.
123
106
 
124
- struct MyGuest;
107
+ | To decide | Read |
108
+ |---|---|
109
+ | which parts you own at each level | [`docs/guides/assembly-levels.md`](docs/guides/assembly-levels.md) |
110
+ | what a guest shell implements | [`docs/guides/customization.md`](docs/guides/customization.md) |
111
+ | a working shell | [`wasm/kobako-wasm/src/guest.rs`](wasm/kobako-wasm/src/guest.rs) |
125
112
 
126
- // Pick the capability gems the guest exposes.
127
- impl kobako_mruby::MrbGuest for MyGuest {
128
- fn init_gems(mrb: &Mrb) -> Result<(), Error> {
129
- mrb.init_gem::<kobako_io::KobakoIo>()?;
130
- mrb.init_gem::<kobako_json::KobakoJson>()?;
131
- Ok(())
132
- }
133
- }
113
+ Build the shell as a `cdylib` for `wasm32-wasip1`, then bake its boot state into the artifact with `kobako-baker input.wasm output.wasm`.
134
114
 
135
- // Forward the ABI contract to the harness flows.
136
- impl kobako_core::Guest for MyGuest {
137
- fn eval() { <MyGuest as kobako_mruby::MrbGuest>::eval() }
138
- fn run(env: &[u8]) { <MyGuest as kobako_mruby::MrbGuest>::run(env) }
139
- fn yield_to_block(req: &[u8]) -> u64 { <MyGuest as kobako_mruby::MrbGuest>::yield_to_block(req) }
140
- }
115
+ A shell may also define flags, switches the Host App sets for every invocation. A flag is critical by default, so a Guest Binary that does not recognise it raises `Kobako::UnsupportedFlagError` rather than run without it. Mark it `advisory: true` only when a script cannot tell the flag was skipped.
141
116
 
142
- kobako_core::export_guest!(MyGuest);
117
+ ```ruby
118
+ sandbox.flag(:strict_mode) # critical: refused where unrecognised
119
+ sandbox.flag(:compile_cache, "on", advisory: true) # skipped where unrecognised
143
120
  ```
144
121
 
145
- Build the crate as a `cdylib` for `wasm32-wasip1`, then bake the canonical boot state into the artifact (see [`CLAUDE.md`](CLAUDE.md) § Build Pipeline).
146
-
147
122
  ## Glossary
148
123
 
149
- | Term | Meaning |
150
- |------|---------|
151
- | Sandbox | The reusable unit (`Kobako::Sandbox`) that runs guest code and returns a result or raises a typed error. It holds configuration only — no state from any run. |
152
- | Service | A host object bound at a constant-path name (`MyService::KV`) — the guest's only path to host resources. |
153
- | Invocation | One `#eval` or `#run`; capability state is scoped to it and ends with it. |
154
- | Execution | The frozen record one invocation returns (`Kobako::Execution`): its `#value`, output captures, and `#usage`. A failed run raises, carrying the same record on the error's `#execution`. |
155
- | Context | The per-invocation object the optional `#eval` / `#run` block receives; its `ctx.bind` supplies a Service object for that one run. |
156
- | Snippet | Named mruby code (source or bytecode) replayed into a fresh state before every invocation. |
157
- | Handle | An opaque token the guest holds for a host object the wire cannot transmit directly. |
158
- | Block | A guest mruby block passed to a Service; each `yield` is a synchronous round-trip into the guest. |
124
+ These terms name the concepts the rest of this README builds on.
125
+
126
+ | Term | Ruby class | Meaning |
127
+ |------|------------|---------|
128
+ | Sandbox | `Kobako::Sandbox` | The unit you configure once and invoke many times. |
129
+ | Invocation | — | One run of guest code, by `#eval` or `#run`. |
130
+ | Execution | `Kobako::Execution` | The frozen record an Invocation leaves. |
131
+ | Service | — | A host object the guest reaches by name, such as `MyService::KV`. |
132
+ | Mount | — | A `Kobako::Filesystem` placed at a guest path by `Sandbox#mount`, for the guest's `File`. |
133
+ | Context | `Kobako::Context` | The per-Invocation object the optional block receives. |
134
+ | Snippet | — | Guest source or bytecode replayed at the start of every Invocation. |
135
+ | Handle | `Kobako::Handle` | An opaque reference to a host object the wire cannot carry by value. |
136
+ | Block | — | Guest code passed alongside a Service call. |
137
+
138
+ A Sandbox keeps nothing from one Invocation to the next. An Execution holds what the run produced, wrote, and consumed: `#value`, output captures, and `#usage`. A failed run raises, carrying its Execution on the error's `#execution`. A Service or a Mount is the guest's only route to a host resource. A Context's `ctx.bind` supplies a Service object for that one run. A Handle names its object only within the Invocation that issued it. A Block stays in the guest; each `yield` to it is a synchronous round-trip.
159
139
 
160
140
  ## Usage
161
141
 
@@ -164,6 +144,7 @@ Each section below stands on its own; this is the order they build in.
164
144
  | If you want to | Read |
165
145
  |---|---|
166
146
  | let guest code call into your host | [Services](#services), [Per-Invocation Bindings](#per-invocation-bindings), [Service Blocks](#service-blocks) |
147
+ | let guest code read and write files | [Mounts](#mounts) |
167
148
  | see what a run produced or why it failed | [Output Capture](#output-capture), [Error Handling](#error-handling), [Invocation Lifecycle](#invocation-lifecycle) |
168
149
  | bound what a run may consume | [Resource Limits](#resource-limits), [Concurrency](#concurrency), [Pooling](#pooling) |
169
150
  | hand host objects across the boundary | [Handle Management](#handle-management) |
@@ -171,7 +152,7 @@ Each section below stands on its own; this is the order they build in.
171
152
 
172
153
  ### Services
173
154
 
174
- `bind` any Ruby object as a Service at a constant-path name; the guest reaches it as a `MyService::KV` (or top-level `File`) proxy and invokes the public methods its own class defines through the Transport wire. See [`SV-006`](docs/spec/behavior/services.md) and [`SV-007`](docs/spec/behavior/services.md).
155
+ `bind` any Ruby object as a Service at a constant-path name. The guest reaches it as a `MyService::KV` (or top-level `Clock`) proxy. Through the Transport wire, the guest invokes the public methods the object's own class defines.
175
156
 
176
157
  ```ruby
177
158
  class User
@@ -191,11 +172,11 @@ sandbox.eval(<<~RUBY)
191
172
  RUBY
192
173
  ```
193
174
 
194
- Each `::`-separated path segment must match `/\A[A-Z]\w*\z/`. Symbol kwargs travel transparently to the host method's keyword arguments. The registry seals at the first invocation (see [Invocation Lifecycle](#invocation-lifecycle)); later `#bind` raises `ArgumentError`.
175
+ Each `::`-separated path segment must match `/\A[A-Z]\w{0,65533}\z/`. A path under `Kobako`, which the Guest Binary keeps for itself, raises `ArgumentError`. Symbol kwargs travel transparently to the host method's keyword arguments. The registry seals at the first invocation (see [Invocation Lifecycle](#invocation-lifecycle)); later `#bind` raises `ArgumentError`.
195
176
 
196
177
  ### Per-Invocation Bindings
197
178
 
198
- A setup-time `bind` fixes one object for the Sandbox's life. When the object belongs to a single run instead — the current request, the acting user, a per-tenant store — declare the path at setup and fill it per invocation. `bind(path)` with no object reserves the name as a *fillable*: the guest sees the constant, while an unfilled dispatch fails closed as `Kobako::ServiceError`. The optional `#eval` / `#run` block fills it ([`SV-017`](docs/spec/behavior/services.md), [`SV-024`](docs/spec/behavior/services.md)).
179
+ A setup-time `bind` fixes one object for the Sandbox's life. Some objects belong to a single run instead: the current request, the acting user, a per-tenant store. For those, declare the path at setup and fill it per invocation. `bind(path)` with no object reserves the name as a *fillable*: the guest sees the constant, while an unfilled dispatch fails closed as `Kobako::ServiceError`. The optional `#eval` / `#run` block fills it.
199
180
 
200
181
  ```ruby
201
182
  sandbox.bind("Req::Current") # declared, unfilled — stands for Kobako::Unresolved
@@ -203,11 +184,89 @@ sandbox.bind("Req::Current") # declared, unfilled — stands for Kobako::Unreso
203
184
  sandbox.eval("Req::Current.user_id") { |ctx| ctx.bind("Req::Current", request) }
204
185
  ```
205
186
 
206
- `ctx.bind` also shadows an already-bound path for that one run. The Context is spent once the block returns, so a `ctx` captured out of it raises `ArgumentError`. Choosing the object per invocation instead of sharing one is what lets concurrent Threads invoke a single Sandbox and still keep their identities apart — the [multi-tenant example](examples/multi-tenant/) runs that shape end to end.
187
+ `ctx.bind` also shadows an already-bound path for that one run. The Context is spent once the block returns, so a `ctx` captured out of it raises `ArgumentError`. Choosing the object per invocation lets concurrent Threads invoke a single Sandbox and still keep their identities apart. The [multi-tenant example](examples/multi-tenant/) runs that shape end to end.
188
+
189
+ ### Mounts
190
+
191
+ `mount` places a `Kobako::Filesystem` at an absolute guest path, where the guest's `File` reads it. The default access is `:read`, so a write beneath it raises `Errno::EROFS` in the guest.
192
+
193
+ ```ruby
194
+ sandbox.mount("/config", Kobako::Filesystem::Memory.new("app.yml" => yaml))
195
+ sandbox.mount("/tmp", Kobako::Filesystem::Temporary.new, access: :read_write)
196
+ sandbox.mount("/data", Kobako::Filesystem::Directory.new("var/data")) { |path, _access| path.end_with?(".csv") }
197
+
198
+ sandbox.eval('File.read("/config/app.yml")')
199
+ ```
200
+
201
+ | Kind | Holds |
202
+ |---|---|
203
+ | `Memory.new(files = {}, limit:, inodes:)` | files in memory, shared by every invocation |
204
+ | `Temporary.new(limit:, inodes:)` | files in memory, empty at every invocation |
205
+ | `Directory.new(path)` | a host directory; nothing outside it is reachable |
206
+ | `Overlay.new.layer(fs, access:)` | Layers stacked into one view, the first added on top |
207
+
208
+ A Memory or Temporary is bounded as a tmpfs mount is. Its files hold 64 MiB unless `limit:` gives another size in bytes. It holds one file or directory for each 4 KiB of that limit, and its root, unless `inodes:` gives another count. `nil` lifts either bound, and the guest raises `Errno::ENOSPC` past one.
209
+
210
+ A block decides each open and each change that reaches the Mount in the access's place, asked at most once for each. A falsy answer or a `StandardError` fails that call as `Errno::EACCES`. Mounts seal with the Sandbox like a `bind`, and `ctx.mount` replaces one for a single run.
211
+
212
+ #### Guest File
213
+
214
+ The guest's `File.open` takes a mode String such as `"r"`, `"w+"`, or `"wx"`, or an Integer of `File::Constants` flags, valued as CRuby's on Linux. An exclusive create lets Sandboxes sharing a directory agree on a lock file. The critical flag `disable_file` removes `File` from the guest altogether.
215
+
216
+ Beneath a mount, `File.open` and `IO.new` answer as CRuby does over a Linux directory. Where they differ, they only refuse more:
217
+
218
+ | When | The guest sees |
219
+ |---|---|
220
+ | nothing is mounted | `NotImplementedError` |
221
+ | a block refuses a change | `Errno::EACCES` where a read-only mount says `Errno::EROFS` |
222
+ | a Ruby Filesystem's file without `append` is asked to append | `Errno::ENOTSUP` on the write |
223
+ | an Integer mode holds a flag kobako does not honor, or `WRONLY` beside `RDWR` | `Errno::EINVAL` |
224
+ | a mode names an encoding, such as `"r:utf-8"` | `ArgumentError` |
225
+ | `IO.new` wraps stdout or stderr in a mode other than `"w"` | `ArgumentError` |
226
+ | `disable_file` is set | no `File` constant |
227
+
228
+ #### Overlays
229
+
230
+ `layer` adds a Filesystem beneath the Layers already there and answers a new Overlay. Each Layer is `:read`, the default, or `:read_write` on its own.
231
+
232
+ ```ruby
233
+ overlay = Kobako::Filesystem::Overlay.new
234
+ .layer(Kobako::Filesystem::Temporary.new, access: :read_write) # empty every run
235
+ .layer(Kobako::Filesystem::Directory.new("app")) # never written
236
+ sandbox.mount("/app", overlay, access: :read_write)
237
+ ```
238
+
239
+ A change to a file in a writable Layer stays there. A read-only Layer's file is first copied up to the nearest writable Layer above it, and a new or renamed file lands in the topmost one. A lower writable Layer's file does not move, raising `Errno::EXDEV`, as across devices. A removal hides what a lower Layer keeps with an [OCI whiteout](https://github.com/opencontainers/image-spec/blob/main/layer.md#whiteouts), `.wh.<name>`, kept in the topmost writable Layer. Names starting with `.wh.`, in any case, are therefore reserved.
240
+
241
+ #### Filesystems in Ruby
242
+
243
+ Any object answering `open` and `stat` mounts as a Filesystem, directly or as a Layer. Its methods follow Ruby's `File`, `Dir`, and `IO`, so a real `::File` can answer as an open file.
244
+
245
+ | Method | Answers |
246
+ |---|---|
247
+ | `open(path, flags)` | an open file, below |
248
+ | `stat(path)` | an object answering `directory?`, `size`, and `ino`, as `File::Stat` does |
249
+ | `children(path)` | entry names, as `Dir.children` gives them |
250
+ | `mkdir` · `rmdir` · `unlink` · `rename(from, to, directory:)` | optional; without one, that change raises `Errno::EROFS` |
251
+
252
+ A `stat` may also answer `dev`; without one, the Filesystem stands for a single device. An `ino` names one directory while the guest holds it open, and names match exactly, as a block judges them. A path is relative to the mount, the empty path being its root, and `flags` are `File::Constants`. A `rename` told `directory: false` moves only a file and `true` only a directory; `nil` moves either. An `Errno` the guest has a class for reaches the script as itself, and any other `StandardError` as `Errno::EIO`. Every call re-enters Ruby, and its time counts toward `timeout:` without being interrupted.
253
+
254
+ ##### Open Files
255
+
256
+ The file `open` answers takes the guest's reads and writes, each method named as `IO` names it.
257
+
258
+ | Method | Answers | Without it |
259
+ |---|---|---|
260
+ | `pread(length, offset)` · `size` | bytes, `nil` or `EOFError` at the end; the length | required |
261
+ | `pwrite(data, offset)` · `truncate(length)` | bytes written; nothing | that change raises `Errno::EROFS` |
262
+ | `append(data)` | where the file then ends, as `IO#pos` does | an append raises `Errno::ENOTSUP` |
263
+ | `fsync` · `close` | nothing | nothing to flush or release |
264
+
265
+ An `append` writes all of `data` at the end in one step, so another writer's append never lands over it.
207
266
 
208
267
  ### Output Capture
209
268
 
210
- Guest writes through `puts` / `print` / `p` / `$stdout` / `$stderr` are buffered per-channel and read off the run's Execution, independently of its `#value` ([`S-023`](docs/spec/behavior/sandbox.md), [`S-028`](docs/spec/behavior/sandbox.md)). Each invocation captures its own; overflow is clipped at the cap and flagged by `#stdout_truncated?` / `#stderr_truncated?`.
269
+ Guest writes through `puts` / `print` / `p` / `$stdout` / `$stderr` are buffered per-channel and read off the run's Execution, independently of its `#value`. Each invocation captures its own; overflow is clipped at the cap and flagged by `#stdout_truncated?` / `#stderr_truncated?`.
211
270
 
212
271
  ```ruby
213
272
  execution = sandbox.eval(<<~RUBY)
@@ -229,69 +288,96 @@ Every invocation either returns an Execution or raises exactly one of three clas
229
288
  begin
230
289
  sandbox.eval(script)
231
290
  rescue Kobako::TrapError
232
- # Wasm engine fault or cap exhaustion. Discard the Sandbox.
291
+ # Wasm engine fault or cap exhaustion. The invocation is over.
233
292
  rescue Kobako::ServiceError
234
293
  # A host Service call failed and the script did not rescue it.
235
294
  rescue Kobako::SandboxError => e
236
- # The script raised, failed to compile, or returned an unrepresentable value.
295
+ # The script raised, failed to compile, or returned an unrepresentable value,
296
+ # or the Guest Binary could not start the run.
237
297
  logger.warn(e.execution.stderr) # the failed run's own captures and usage
238
298
  end
239
299
  ```
240
300
 
241
- Each of these carries the failed run's Execution on `#execution`, so a rescue reads that run's output and usage exactly as a successful caller reads them off the returned one. `#failed?` keeps the two apart when both `#value` are `nil` — a script whose last expression was `nil` versus one that never produced a value. A failure caught before the guest ran at all — a host pre-flight refusal — leaves `#execution` `nil`, since there is no run to report.
301
+ Each of these carries the failed run's Execution on `#execution`. A rescue reads that run's output and usage exactly as a successful caller reads them off the returned one. `#failed?` keeps the two apart when both `#value` are `nil` — a script whose last expression was `nil` versus one that never produced a value. A failure caught before the guest ran at all — a host pre-flight refusal — leaves `#execution` `nil`, since there is no run to report.
242
302
 
243
303
  | Class | Parent | Trigger |
244
304
  |---------------------------------|----------------|------------------------------------------------------|
245
305
  | `Kobako::TimeoutError` | `TrapError` | Per-invocation `timeout` exhausted |
246
306
  | `Kobako::MemoryLimitError` | `TrapError` | Per-invocation `memory_limit` exhausted |
307
+ | `Kobako::NoServiceError` | `ServiceError` | Nothing bound there, a dead Handle, or a method the guest may not call |
308
+ | `Kobako::ServiceArgumentError` | `ServiceError` | The call would not bind, or the Service raised `ArgumentError` |
247
309
  | `Kobako::HandleExhaustedError` | `SandboxError` | Handle counter reached its 2³¹ − 1 cap |
248
310
  | `Kobako::BytecodeError` | `SandboxError` | `#preload(binary:)` failed RITE validation at replay |
311
+ | `Kobako::UnsupportedFlagError` | `SandboxError` | The Guest Binary does not recognise a critical `#flag`; no script ran |
249
312
  | `Kobako::UndefinedEntrypointError` | `SandboxError` | `#run` named a constant no snippet defined; carries `#name` and `#available` |
250
313
 
251
314
  `SandboxError` and `ServiceError` carry structured `origin` / `klass` / `backtrace_lines` fields when the guest produced a panic envelope.
252
315
 
316
+ #### Host Exceptions
317
+
318
+ A Service, a mount's block, or a Filesystem written in Ruby is your code running inside the run. What it raises splits by class.
319
+
320
+ | Raised by your code | Result |
321
+ |-----------------------------------------|-----------------------------------------------|
322
+ | a `StandardError` | the guest sees a failure it may rescue |
323
+ | `Interrupt`, `exit`, `throw`, `Timeout` | the run ends; the invocation raises it as is |
324
+
253
325
  ### Resource Limits
254
326
 
255
- Each invocation enforces a wall-clock `timeout` and a per-invocation linear-memory `memory_limit`; exhaustion raises a `TrapError` subclass. Pass `nil` to `timeout` / `memory_limit` to disable that cap. Read [`Execution#usage`](lib/kobako/execution.rb) for actual consumption — populated on every outcome, so a rescued trap reports it just as a completed run does ([`S-058`](docs/spec/behavior/sandbox.md), [`S-061`](docs/spec/behavior/sandbox.md)).
327
+ Each invocation enforces a wall-clock `timeout` and a per-invocation linear-memory `memory_limit`; exhaustion raises a `TrapError` subclass. Pass `nil` to `timeout` / `memory_limit` to disable that cap. Read [`Execution#usage`](lib/kobako/execution.rb) for actual consumption — populated on every outcome, so a rescued trap reports it just as a completed run does.
256
328
 
257
329
  ```ruby
258
330
  sandbox = Kobako::Sandbox.new(
259
331
  timeout: 5.0, # seconds, default 60.0
260
- memory_limit: 10 * 1024 * 1024, # bytes, default 1 MiB
332
+ setup_timeout: 2.0, # seconds, default 10.0
333
+ memory_limit: 10 * 1024 * 1024, # bytes, default 4 MiB
261
334
  stdout_limit: 64 * 1024, # bytes, default 1 MiB
262
- stderr_limit: 64 * 1024
335
+ stderr_limit: 64 * 1024,
336
+ open_file_limit: 16 # descriptors, default 1024
263
337
  )
264
338
  ```
265
339
 
266
- | Cap | Raises | Default |
267
- |----------------|----------------------------|---------|
268
- | `timeout` | `Kobako::TimeoutError` | 60.0 s |
269
- | `memory_limit` | `Kobako::MemoryLimitError` | 1 MiB |
270
- | `stdout_limit` | output clipped (no raise) | 1 MiB |
271
- | `stderr_limit` | output clipped (no raise) | 1 MiB |
340
+ | Cap | Raises | Default |
341
+ |-------------------|--------------------------------|---------|
342
+ | `timeout` | `Kobako::TimeoutError` | 60.0 s |
343
+ | `setup_timeout` | `Kobako::SetupError` at `new` | 10.0 s |
344
+ | `memory_limit` | `Kobako::MemoryLimitError` | 4 MiB |
345
+ | `stdout_limit` | output clipped (no raise) | 1 MiB |
346
+ | `stderr_limit` | output clipped (no raise) | 1 MiB |
347
+ | `open_file_limit` | `Errno::EMFILE` in the guest | 1024 |
272
348
 
273
- `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.
349
+ `setup_timeout` bounds instantiating a Guest Binary and reading its ABI version, not compiling it. It applies the first time a path is loaded; later Sandboxes on that path skip the check. A Pool builds each Sandbox at its first checkout, so its `SetupError` raises from `with`. `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. `open_file_limit` counts every descriptor one invocation holds, the three standard ones and each Mount's included, so a guest cannot exhaust the host process's own.
350
+
351
+ #### Isolation Profile
352
+
353
+ Beyond the caps, `profile:` requests the Sandbox's isolation posture on a two-rung ladder.
354
+
355
+ ```
356
+ :permissive < :hermetic (default)
357
+ live clock frozen clock
358
+ live entropy constant entropy
359
+ ```
274
360
 
275
- Beyond the four caps, `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.
361
+ `: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. The guest reaches no ambient filesystem, environment, or network under either. The request is also a floor: construction fails with `Kobako::SetupError` on a runtime that declares a weaker posture than requested. See [`docs/guides/security.md`](docs/guides/security.md) § Isolation Profiles.
276
362
 
277
363
  ### Concurrency
278
364
 
279
- A Sandbox keeps no state from any run, so concurrent Threads may invoke distinct Sandboxes or share a single one; each invocation owns its Handles, captures, and usage either way ([`RT-001`](docs/spec/behavior/runtime.md), [`RT-002`](docs/spec/behavior/runtime.md)). One Thread still runs one invocation at a time. Sharing a Sandbox adds a single obligation.
365
+ A Sandbox keeps no state from any run, so concurrent Threads may invoke distinct Sandboxes or share a single one. Each invocation owns its Handles, captures, and usage either way. One Thread still runs one invocation at a time. Sharing a Sandbox adds a single obligation.
280
366
 
281
367
  | What you bind | Who reaches it | What it owes |
282
368
  |---|---|---|
283
369
  | Bound once at setup | every Thread sharing the Sandbox | it must itself be thread-safe |
284
370
  | Supplied per invocation — `ctx.bind`, or an Extension `provider:` | that invocation alone | nothing |
285
371
 
286
- #### Choosing a GVL mode
372
+ #### GVL Modes
287
373
 
288
- By default an invocation holds Ruby's GVL for its whole span, so guest execution across Threads serializes. `gvl: :release` drops the GVL for the guest span and re-acquires it for each guest→host dispatch, running guest code in parallel across Threads ([`RT-024`](docs/spec/behavior/runtime.md), [`RT-026`](docs/spec/behavior/runtime.md)).
374
+ By default an invocation holds Ruby's GVL for its whole span, so guest execution across Threads serializes. `gvl: :release` drops the GVL for the guest span and re-acquires it for each guest→host dispatch, running guest code in parallel across Threads.
289
375
 
290
376
  ```ruby
291
377
  sandbox = Kobako::Sandbox.new(gvl: :release)
292
378
  ```
293
379
 
294
- The mode is per-Sandbox and fixed at construction; it changes scheduling only, leaving isolation, Handle lifetimes, captures, and outcomes identical. `:hold` remains the default because releasing pays a handoff cost at every dispatch: compute-bound scripts scale with Thread count, while dispatch-heavy ones match or trail `:hold`. `rake bench:gvl_scheduling` measures both ends on your own hardware.
380
+ The mode is per-Sandbox and fixed at construction; it changes scheduling only, leaving isolation, Handle lifetimes, captures, and outcomes identical. `:hold` remains the default because releasing pays a handoff cost at every dispatch. Compute-bound scripts scale with Thread count, while dispatch-heavy ones match or trail `:hold`. `rake bench:gvl_scheduling` measures both ends on your own hardware.
295
381
 
296
382
  ### Invocation Lifecycle
297
383
 
@@ -342,11 +428,11 @@ One Sandbox serves many invocations. Service bindings and preloaded snippets per
342
428
  Services + snippets persist; invocation N+1 repeats.
343
429
  ```
344
430
 
345
- For workloads that must be isolated from each other (one Sandbox per tenant, per student submission, per agent session), construct a fresh `Kobako::Sandbox` per scope — wasmtime's Engine and the compiled Module are cached at process scope, so additional Sandboxes amortize cold-start cost automatically.
431
+ For workloads that must be isolated from each other, construct a fresh `Kobako::Sandbox` per scope: per tenant, per student submission, per agent session. wasmtime's Engine and the compiled Module are cached at process scope, so additional Sandboxes amortize cold-start cost automatically.
346
432
 
347
433
  ### Pooling
348
434
 
349
- For hosts that serve many short invocations, `Kobako::Pool` keeps a bounded set of warm, identically set-up Sandboxes and hands each one to a single exclusive holder at a time ([`PL-003`](docs/spec/behavior/pool.md), [`PL-011`](docs/spec/behavior/pool.md)). Construction forwards every `Sandbox.new` keyword verbatim; the optional block is the per-Sandbox setup window and runs exactly once per constructed Sandbox.
435
+ For hosts that serve many short invocations, `Kobako::Pool` keeps a bounded set of warm, identically set-up Sandboxes. It hands each one to a single exclusive holder at a time. Construction forwards every `Sandbox.new` keyword verbatim; the optional block is the per-Sandbox setup window and runs exactly once per constructed Sandbox.
350
436
 
351
437
  ```ruby
352
438
  pool = Kobako::Pool.new(slots: 4) do |sandbox|
@@ -363,19 +449,19 @@ pool.with { |sandbox| sandbox.eval(%(KV::Lookup.call("user_42"))).value }
363
449
  | `slots:` | Upper bound on constructed Sandboxes | required |
364
450
  | `checkout_timeout:` | Seconds `#with` waits for a free Sandbox; `nil` waits indefinitely | 5.0 |
365
451
 
366
- Sandboxes construct lazily on first demand. `#with` yields a Sandbox and returns the block's value; at block exit the Sandbox returns to the pool, except a block that raises `Kobako::TrapError` discards its Sandbox and the slot refills by a fresh construction on next demand. A checkout that waits past `checkout_timeout` raises `Kobako::PoolTimeoutError`. There is no teardown verb — a Pool releases everything with its own reachability.
452
+ Sandboxes construct lazily on first demand. `#with` yields a Sandbox and returns the block's value, and at block exit the Sandbox returns to the pool, whatever the block raised. A checkout that waits past `checkout_timeout` raises `Kobako::PoolTimeoutError`. There is no teardown verb — a Pool releases everything with its own reachability.
367
453
 
368
- #### What a Pool buys
454
+ #### Pool Trade-offs
369
455
 
370
456
  | It gives you | It does not give you |
371
457
  |---|---|
372
- | warm, pre-configured Sandboxes and exclusive checkout | isolation — a Sandbox holds no state from any run, so Threads sharing one are equally safe (see [Concurrency](#concurrency)) |
458
+ | warm, pre-configured Sandboxes and exclusive checkout | isolation (see [Concurrency](#concurrency)) |
373
459
 
374
- `Kobako::Pool` is experimental today and is best treated as a convenience for warm, pre-configured reuse rather than a throughput optimisation. The build bakes the shared boot state into the artifact ([`mruby.md`](docs/spec/behavior/mruby.md)) and every dynamic script still compiles and runs per invocation, so all a pool actually saves is the host-side `Sandbox.new` — now about 3 µs, an order of magnitude below the invocation that follows it. For the workload kobako is built for — many small, short-lived Sandboxes running dynamic scripts — that is not a gain worth the coupling.
460
+ A Sandbox holds no state from any run, so Threads sharing one are equally safe. `Kobako::Pool` is experimental today and is best treated as a convenience for warm, pre-configured reuse rather than a throughput optimisation. The build bakes the shared boot state into the artifact, and every dynamic script still compiles and runs per invocation. All a pool actually saves is the host-side `Sandbox.new` — now about 3 µs, an order of magnitude below the invocation that follows it. For the workload kobako is built for — many small, short-lived Sandboxes running dynamic scripts — that is not a gain worth the coupling.
375
461
 
376
462
  ### Service Blocks
377
463
 
378
- A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest; `break` / `next` / exceptions follow normal Ruby semantics, scoped to the single dispatch. See [`T-085`](docs/spec/behavior/transport-yield.md) and [`T-089`](docs/spec/behavior/transport-yield.md).
464
+ A Service method can accept a guest-supplied block via `&blk` and `yield` into it. The block body runs inside the Wasm guest. Its `break` / `next` / `return` / exceptions follow normal Ruby semantics: an exit lands where it would through a plain method.
379
465
 
380
466
  ```ruby
381
467
  sandbox.bind("Seq::Map", ->(items, &blk) { items.map(&blk) })
@@ -384,9 +470,32 @@ sandbox.eval('Seq::Map.call([1, 2, 3]) { |x| x * 2 }').value
384
470
  # => [2, 4, 6]
385
471
  ```
386
472
 
473
+ A `yield` that cannot complete raises at the Service's call site, so the Service may rescue it. Left unrescued, a `BlockError` hands the guest its own exception back; the others fail the Service call.
474
+
475
+ | Class | Trigger at `yield` |
476
+ |----------------------------|---------------------------------------|
477
+ | `Kobako::BlockError` | The guest block raised |
478
+ | `Kobako::YieldValueError` | An argument cannot cross to the guest |
479
+ | `Kobako::UnsupportedError` | The Guest Binary serves no block |
480
+ | `ThreadError` | The block runs off the calling Fiber |
481
+
482
+ #### Block Fiber
483
+
484
+ A block runs only on the Fiber that called the Service. The guest's Wasm stack lives on that Fiber's native stack, so entering it from another raises `ThreadError` before the block runs. CRuby itself lets a block run anywhere; kobako narrows that to keep the guest's stack checks sound.
485
+
486
+ ```
487
+ Service on the calling Fiber
488
+ ├─ blk.call / yield ─> runs in the guest
489
+ ├─ Thread.new { blk.call } ─> ThreadError
490
+ └─ Enumerator.new { |y| y << blk.call }
491
+ .next (runs its body on a Fiber) ─> ThreadError
492
+ ```
493
+
494
+ `Enumerator#next` counts as another Fiber, because it runs the enumerator's body on one. Internal iteration such as `each` or `map` stays on the calling Fiber and runs the block normally.
495
+
387
496
  ### Handle Management
388
497
 
389
- A non-wire-representable host object — returned from a Service ([`T-001`](docs/spec/behavior/transport-dispatch.md)), passed to `#run` ([`T-066`](docs/spec/behavior/transport-dispatch.md)), or handed back from the guest ([`T-054`](docs/spec/behavior/transport-dispatch.md)) — crosses the boundary as an opaque `Kobako::Handle` proxy and is restored to the original object before host code sees it; any other unrepresentable value raises `Kobako::SandboxError`. Handles are scoped to a single invocation ([`T-038`](docs/spec/behavior/transport-dispatch.md)).
498
+ A host object the wire cannot represent may be returned from a Service, passed to `#run`, or handed back from the guest. It crosses the boundary as an opaque `Kobako::Handle` proxy and is restored to the original object before host code sees it. Any other unrepresentable value raises `Kobako::SandboxError`. Handles are scoped to a single invocation.
390
499
 
391
500
  ```ruby
392
501
  class Greeter
@@ -400,11 +509,11 @@ sandbox.eval('Factory::Make.call("Bob").greet').value # => "hi, Bob" (Handle r
400
509
  sandbox.eval('Factory::Make.call("Bob")').value # => #<Greeter @name="Bob"> (Handle restoration)
401
510
  ```
402
511
 
403
- A `break` value from a guest block is the one exception: it unwinds back to the guest Service call rather than to host code, so a Handle in it stays a Handle — restoring would just re-wrap the same object into a new id on the return trip.
512
+ A `break` value from a guest block never reaches host code. It stays in the guest while the Service unwinds, so a Handle in it stays a Handle.
404
513
 
405
- #### One Handle per dispatch
514
+ #### Per-dispatch Handles
406
515
 
407
- Each dispatch that hands back a non-wire-representable object allocates a *new* Handle — kobako never deduplicates by object identity ([`T-007`](docs/spec/behavior/transport-dispatch.md), [`T-009`](docs/spec/behavior/transport-dispatch.md)). This is most visible with fluent / builder APIs. An `ActiveRecord::Relation` chain `spawn`s a fresh relation at each step, so every hop is an independent dispatch that binds its own Handle:
516
+ Each dispatch that hands back a non-wire-representable object allocates a *new* Handle — kobako never deduplicates by object identity. This is most visible with fluent / builder APIs. An `ActiveRecord::Relation` chain `spawn`s a fresh relation at each step, so every hop is an independent dispatch that binds its own Handle:
408
517
 
409
518
  ```
410
519
  guest chain host (Catalog::Handles, one invocation)
@@ -420,11 +529,11 @@ Each dispatch that hands back a non-wire-representable object allocates a *new*
420
529
  all stay live until the invocation ends, then reset together
421
530
  ```
422
531
 
423
- This is deliberate, not a leak. Handle IDs run to 2³¹ − 1 per invocation and reset between invocations, so even deep chains stay far inside the range. Two consequences are worth keeping in mind: the same host object handed back twice yields two *different* Handles — the guest cannot tell they alias — and every intermediate Handle stays live until the invocation ends, since there is no per-Handle release ([`transport-dispatch.md`](docs/spec/behavior/transport-dispatch.md)).
532
+ This is deliberate, not a leak. Handle IDs run to 2³¹ − 1 per invocation and reset between invocations, so even deep chains stay far inside the range. Two consequences are worth keeping in mind. The same host object handed back twice yields two *different* Handles, and the guest cannot tell they alias. Every intermediate Handle stays live until the invocation ends, since there is no per-Handle release.
424
533
 
425
534
  ### Snippets & Entrypoints
426
535
 
427
- `Sandbox#preload` registers named mruby snippets that replay into every invocation's canonical boot state; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets ([`S-053`](docs/spec/behavior/sandbox.md), [`S-045`](docs/spec/behavior/sandbox.md)).
536
+ `Sandbox#preload` registers named mruby snippets that replay into every invocation's canonical boot state; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets.
428
537
 
429
538
  ```ruby
430
539
  sandbox = Kobako::Sandbox.new
@@ -435,9 +544,9 @@ sandbox.run(:Adder, 2, 3).value # => 5
435
544
  sandbox.run(:Greeter, name: "world").value # => "hello, world"
436
545
  ```
437
546
 
438
- An entrypoint's `kwargs` arrive as a trailing positional Hash — mruby's C-side call path carries no keyword arguments — so declare a Hash parameter and unpack it yourself.
547
+ An entrypoint's `kwargs` arrive as a trailing positional Hash, because mruby's C-side call path carries no keyword arguments. Declare a Hash parameter and unpack it yourself.
439
548
 
440
- A target no snippet defined raises `Kobako::UndefinedEntrypointError`, whose `#available` lists the top-level constants the snippets did contribute — so the name is corrected from the error rather than by reading the guest source.
549
+ A target no snippet defined raises `Kobako::UndefinedEntrypointError`. Its `#available` lists the top-level constants the snippets did contribute, so the name is corrected from the error rather than by reading the guest source.
441
550
 
442
551
  ```
443
552
  per-invocation replay (every #eval / #run, snippets in insertion order):
@@ -454,7 +563,7 @@ A target no snippet defined raises `Kobako::UndefinedEntrypointError`, whose `#a
454
563
  return the Execution, then discard the instance
455
564
  ```
456
565
 
457
- #### Choosing a payload form
566
+ #### Payload Forms
458
567
 
459
568
  `#preload` accepts two payload forms:
460
569
 
@@ -463,15 +572,15 @@ A target no snippet defined raises `Kobako::UndefinedEntrypointError`, whose `#a
463
572
  | Source | `preload(code: "...", name: :Const)` | The `name:` keyword | First invocation's replay; compile errors raise `Kobako::SandboxError` |
464
573
  | Bytecode | `preload(binary: bytes)` | Read from the bytecode's `debug_info` | First invocation's replay; structural failure raises `Kobako::BytecodeError` |
465
574
 
466
- 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.
575
+ 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. Source is compiled once, before the first invocation, and replays as bytecode from then on, so the bytecode form saves only that one compile. 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.
467
576
 
468
577
  ### Extensions
469
578
 
470
- An Extension teaches the guest a native-style constant by pairing a guest idiom (`source`) with an optional host `backend`. `Sandbox#install` composes the two through the existing `#preload` and `#bind` verbs, adding no wire or Guest Binary surface: pure operations run in-guest with no round-trip, while the rest dispatch to the backend under the same isolation and reflection guarantees as any bound Service ([`docs/extensions.md`](docs/extensions.md), [`EX-005`](docs/spec/behavior/extension.md), [`EX-006`](docs/spec/behavior/extension.md)).
579
+ An Extension teaches the guest a native-style constant by pairing a guest idiom (`source`) with an optional host `backend`. `Sandbox#install` composes the two through the existing `#preload` and `#bind` verbs, adding no wire or Guest Binary surface. Pure operations run in-guest with no round-trip. The rest dispatch to the backend under the same isolation and reflection guarantees as any bound Service ([`docs/guides/extensions.md`](docs/guides/extensions.md)).
471
580
 
472
581
  ```ruby
473
- FILE = <<~'MRUBY'
474
- class File
582
+ VFS = <<~'MRUBY'
583
+ class Vfs
475
584
  extend Kobako::Proxy
476
585
  def self.basename(path) = path.split("/").last || "" # pure: runs in-guest
477
586
  # read / write are undefined here, so they dispatch to the backend
@@ -480,16 +589,16 @@ MRUBY
480
589
 
481
590
  sandbox.install(
482
591
  Kobako::Extension.new(
483
- name: :File,
484
- source: FILE,
592
+ name: :Vfs,
593
+ source: VFS,
485
594
  backend: Kobako::Extension::Backend.new(
486
- path: "File",
487
- provider: -> { OverlayFileSystem.new(root) } # invoked once per invocation
595
+ path: "Vfs",
596
+ provider: -> { CopyOnWriteFiles.new(root) } # invoked once per invocation
488
597
  )
489
598
  )
490
599
  )
491
600
 
492
- sandbox.eval('File.read("sample.txt")') # dispatches to the backend's #read
601
+ sandbox.eval('Vfs.read("sample.txt")') # dispatches to the backend's #read
493
602
  ```
494
603
 
495
604
  A backend declares the bound object's lifetime by keyword, never by inference — so a static object that happens to be callable stays unambiguous.
@@ -497,17 +606,17 @@ A backend declares the bound object's lifetime by keyword, never by inference
497
606
  | Keyword | Lifetime |
498
607
  |-------------|-----------------------------------------------------------------------------|
499
608
  | `object:` | One object, shared by every invocation |
500
- | `provider:` | A no-argument callable invoked per invocation — what a writable backend needs, so its state cannot leak across runs |
609
+ | `provider:` | A no-argument callable invoked per invocation |
501
610
  | neither | A fillable, standing for `Kobako::Unresolved` until `ctx.bind` supplies the run's object |
502
611
 
503
- kobako ships no concrete Extension; the idiom and backend are yours. The [overlay VFS example](examples/vfs/) is a worked `File` that reads through to disk while protecting it from guest writes.
612
+ A writable backend takes `provider:`, so its state cannot leak across runs. kobako ships no concrete Extension; the idiom and backend are yours. The [overlay VFS example](examples/vfs/) is a worked virtual file that reads through to disk while protecting it from guest writes.
504
613
 
505
614
  ## Security
506
615
 
507
- kobako isolates the guest, but **what it may reach is whatever you `bind`** — and `bind`
508
- exposes every public method the object's own class defines. Inherited, mixed-in, and built-in
509
- methods stay out of reach, but the class's own surface does not, so bind a purpose-built
510
- object scoped to the task, not a capable one whose other methods leak more than you intend.
616
+ kobako isolates the guest, but **what it may reach is whatever you `bind` or `mount`**.
617
+ `bind` exposes every public method the object's own class defines. Inherited, mixed-in, and built-in
618
+ methods stay out of reach, but the class's own surface does not. Bind a purpose-built object
619
+ scoped to the task, not a capable one whose other methods leak more than you intend.
511
620
 
512
621
  ```ruby
513
622
  class ThemeReader # only #color is reachable; AppConfig.secret_key is not
@@ -517,30 +626,14 @@ end
517
626
  sandbox = Kobako::Sandbox.new
518
627
  sandbox.bind("Cfg::Settings", ThemeReader.new) # not: bind("Cfg::Settings", AppConfig)
519
628
 
520
- sandbox.eval('Cfg::Settings.color').value # => "#3366ff" — every other method raises NoMethodError
521
- ```
522
-
523
- #### Gating an object's own surface
524
-
525
- When a purpose-built wrapper is more than you need, an object can gate its own surface in
526
- place: a private `respond_to_guest?(name)` answers, per method, whether the guest may call
527
- it. Returning `false` for every name makes the object opaque — a credential the guest
528
- forwards to another Service but never reads — while permitting a named subset exposes exactly those.
529
-
530
- ```ruby
531
- class Credential
532
- def initialize(token) = @token = token
533
- def to_s = @token
534
-
535
- private def respond_to_guest?(_name) = false # forwardable, never readable
536
- end
629
+ sandbox.eval('Cfg::Settings.color').value # => "#3366ff" — any other method raises Kobako::NoServiceError
537
630
  ```
538
631
 
539
632
  Guest code can name any `MyService::KV` path, but a forged name only resolves to
540
- something you bound — the real authorization gate is this host-side allowlist. Give each
541
- trust context its own Sandbox, and see [`docs/security-model.md`](docs/security-model.md) for the rest
542
- as security-design concerns: validating untrusted input, default-deny external effects,
543
- and controlling the return surface.
633
+ something you bound. What you bind is the real authorization gate. An object can also
634
+ narrow its own surface with a private `respond_to_guest?`. Give each trust context its own
635
+ Sandbox. [`docs/guides/security.md`](docs/guides/security.md) covers the rest: self-gating
636
+ objects, validating untrusted input, default-deny external effects, and the return surface.
544
637
 
545
638
  ## Performance
546
639
 
@@ -549,25 +642,25 @@ Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values
549
642
  | Phase | Cost |
550
643
  |--------------------------------------------------------------|-----------------------|
551
644
  | First `Sandbox.new` ever for a Guest Binary (Module JIT, then disk-cached) | ~500 ms once per machine |
552
- | First `Sandbox.new` in a fresh process (`.cwasm` cache warm) | ~3 ms one-time |
645
+ | First `Sandbox.new` in a fresh process (`.cwasm` cache warm) | ~1.4 ms one-time |
553
646
  | Subsequent `Sandbox.new` (caches warm) | ~3 µs |
554
- | Warm `#eval` with one Service call on a reused Sandbox | ~70 µs |
555
- | Warm `#run(:Entrypoint, ...)` dispatch | ~79 µs |
556
- | Service call amortized inside one invocation | ~5.8 µs |
557
- | Snippet replay per invocation | ~7.0 µs each |
558
- | Per additional idle Sandbox (RSS) | ~1 KB |
647
+ | Warm `#eval` with one Service call on a reused Sandbox | ~62 µs |
648
+ | Warm `#run(:Entrypoint, ...)` dispatch | ~57 µs |
649
+ | Service call amortized inside one invocation | ~4.9 µs |
650
+ | Snippet replay per invocation | ~2.7 µs each |
651
+ | Per additional idle Sandbox (RSS) | < 1 KB |
559
652
 
560
- The Cranelift JIT runs once per machine and gem version — the compiled artifact persists in a `.cwasm` disk cache, so later processes deserialize in milliseconds. An idle Sandbox holds no wasm instance (the canonical boot state is baked into the artifact and instantiated per invocation), which is why a thousand idle tenants cost ~34 MB total. A +10% regression on any SPEC-mandated benchmark blocks release.
653
+ The Cranelift JIT runs once per machine and wasmtime release — the compiled artifact persists in a `.cwasm` disk cache, so later processes deserialize in milliseconds. An idle Sandbox holds no wasm instance, which is why a thousand idle tenants cost ~37 MB total. The canonical boot state is baked into the artifact and instantiated per invocation. A +10% regression on any gated benchmark blocks release.
561
654
 
562
- #### What moves the ceiling
655
+ #### Throughput Levers
563
656
 
564
657
  | Choice | What it changes |
565
658
  |---|---|
566
- | `gvl: :hold` (default) | wasm work is GVL-serialized: aggregate throughput stays around 16k `#eval`/s regardless of Thread count, though Ruby-side `#eval` setup still overlaps |
659
+ | `gvl: :hold` (default) | ~15–20k `#eval`/s in aggregate, at any Thread count |
567
660
  | `gvl: :release` | lifts that ceiling for compute-bound scripts (see [Concurrency](#concurrency)) |
568
- | `match?` over `=~` | `=~` (~5 µs/match) costs about 5× `match?` (~1.0 µs), because it eagerly builds the `MatchData` and match globals — prefer `match?` for boolean tests |
661
+ | `match?` over `=~` | `=~` (~5.6 µs/match) costs about 6× `match?` (~0.9 µs) |
569
662
 
570
- Regexp is an opt-in capability gem, excluded from the default binary and the gated set; its throughput is tracked in a separate non-gated characterization (`#11` in [`benchmark/README.md`](benchmark/README.md)).
663
+ Under `:hold` the GVL serializes wasm work, though Ruby-side `#eval` setup still overlaps. `=~` eagerly builds the `MatchData` and match globals, so prefer `match?` for boolean tests. Regexp is an opt-in capability gem, excluded from the default binary and the gated set. Its throughput is tracked in a separate non-gated characterization (`#11` in [`benchmark/README.md`](benchmark/README.md)).
571
664
 
572
665
  ```bash
573
666
  bundle exec rake bench # every gated regression benchmark (~5-8 min)
@@ -579,10 +672,10 @@ After checking out the repo:
579
672
 
580
673
  ```bash
581
674
  bin/setup # install dependencies
582
- bundle exec rake # default: compile + test + rubocop + steep
675
+ bundle exec rake # the release gate: compile, tests, rubocop, steep, gate checks
583
676
  ```
584
677
 
585
- Building from source requires a WASI-capable Rust toolchain in addition to the standard host toolchain; the first compile walks the full chain — the [beni](https://github.com/elct9620/beni) gem vendors wasi-sdk + mruby and builds `libmruby.a` (`rake beni:build`), then `rake wasm:build` produces the Guest Binary. See [`CLAUDE.md`](CLAUDE.md) for the rake task map and pipeline layout. `bin/console` opens an IRB session with the gem preloaded; `bundle exec rake install` installs the local checkout as a gem.
678
+ Building from source requires a WASI-capable Rust toolchain in addition to the standard host toolchain. The first compile walks the full chain. The [beni](https://github.com/elct9620/beni) gem vendors wasi-sdk + mruby and builds `libmruby.a` (`rake beni:build`), then `rake wasm:build` produces the Guest Binary. See [`AGENTS.md`](AGENTS.md) for the rake task map and pipeline layout. `bin/console` opens an IRB session with the gem preloaded; `bundle exec rake install` installs the local checkout as a gem.
586
679
 
587
680
  ## Contributing
588
681