kobako 0.5.0-aarch64-linux → 0.6.1-aarch64-linux

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4cb09500c0d738f071220c5215397900660f874e14bbfb4c2cef2756593abee
4
- data.tar.gz: d28285d4ceaa35b1ee9e221b56ce6fdfc81c43a196fa2ae81b44492d93e87f73
3
+ metadata.gz: 8b460c2be9e6eeb516b5492e05676316abb742909e530906796aae786c53eb9c
4
+ data.tar.gz: 9476b065878b756c380bcad2c8c5bb33d485688ebd61cbf23aeb9506e4c361c8
5
5
  SHA512:
6
- metadata.gz: 76da159ded092c8accc52cacbe5f90df34066efe938b266ff9d2e441c36befba4190c9e7a744f9f8809a56049698b1e46b921d81ab78772facf082789c0e8f74
7
- data.tar.gz: d221399b54ae39fb775b619d74ea3038437e5a590ce717656c0c0c37900f6ce56e3dacfa1e8fb60d930d42a97212949e6e34186364e594a1e568df57547c6b97
6
+ metadata.gz: dd53cd4662f4f5792adc3126342eb804a4d67566decb465136e0ea35265eea5961f6f24adb9d2bcd1157dbe4381d7e41a969811009bf5c573cc00e97e6a4b677
7
+ data.tar.gz: 6431e0c9bfd3d71c5ebcf77e4c47e379044b1452dbaeb994c9c9526492e3bf6847276596861ea40d844f113d849b87f23e3b58ea63eee183b54ab535e64e631d
@@ -1 +1 @@
1
- {".":"0.5.0"}
1
+ {".":"0.6.1"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.1](https://github.com/elct9620/kobako/compare/v0.6.0...v0.6.1) (2026-05-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **loader:** try Ruby-ABI subdir before bare path ([51017eb](https://github.com/elct9620/kobako/commit/51017eb5ee40fa722ec962ce3b9a5d016b128d41))
9
+
10
+ ## [0.6.0](https://github.com/elct9620/kobako/compare/v0.5.0...v0.6.0) (2026-05-28)
11
+
12
+
13
+ ### Features
14
+
15
+ * **bench:** gate against a committed anchor baseline ([ed8b30e](https://github.com/elct9620/kobako/commit/ed8b30e0940736cbabcca18227590d07c3bf94d3))
16
+ * **handle:** restore guest-returned Capability Handles to host objects (B-37) ([092815d](https://github.com/elct9620/kobako/commit/092815d610d3595db82b406d4b67880c84f11900))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **bench:** harden the gate guards and split judgment from the runner ([d6eaae2](https://github.com/elct9620/kobako/commit/d6eaae2de44d14a4735fbb544da712c659144a86))
22
+ * **ci:** chain release.yml from release-please via workflow_call ([711665d](https://github.com/elct9620/kobako/commit/711665d29a8c8445b1e26ca08e4b0efc5b24982c))
23
+ * **handle:** don't restore a Handle broken out of a guest block (B-37) ([ea25ab9](https://github.com/elct9620/kobako/commit/ea25ab9793f376f15e8d668077ad58f8d67e5a63))
24
+
3
25
  ## [0.5.0](https://github.com/elct9620/kobako/compare/v0.4.0...v0.5.0) (2026-05-27)
4
26
 
5
27
 
data/README.md CHANGED
@@ -1,15 +1,17 @@
1
1
  # Kobako
2
2
 
3
+ [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/elct9620/kobako)
4
+
3
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.
4
6
 
5
- The host (`wasmtime`) runs a precompiled `kobako.wasm` guest containing mruby and an RPC client. The only way a guest script can reach the outside world is through Host App-declared **Services** — named Ruby objects you explicitly inject into the sandbox.
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 Ruby 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.
6
8
 
7
9
  ```
8
10
  Host process Wasm guest
9
11
  ┌──────────────────────┐ ┌──────────────────────┐
10
12
  │ Kobako::Sandbox │ ─eval─▶ │ mruby interpreter │
11
13
  │ │ ─run──▶ │ │
12
- │ Services │ ◀──RPC─ │ KV::Lookup.call(k) │
14
+ │ Services │ ◀─call─ │ KV::Lookup.call(k) │
13
15
  │ KV::Lookup │ ─resp─▶ │ │
14
16
  │ │ │ │
15
17
  │ stdout / stderr buf │ ◀─pipe─ │ puts / warn │
@@ -19,21 +21,6 @@ The host (`wasmtime`) runs a precompiled `kobako.wasm` guest containing mruby an
19
21
  trusted untrusted
20
22
  ```
21
23
 
22
- ## Features
23
-
24
- | Feature | Description |
25
- |---|---|
26
- | In-process Wasm sandbox | No subprocess, no container. Both invocation verbs (`Sandbox#eval` for ad-hoc source, `Sandbox#run` for entrypoint dispatch) are synchronous Ruby calls. |
27
- | Per-invocation caps | Every invocation enforces a wall-clock `timeout` (default 60 s) and a per-invocation linear-memory `memory_limit` (default 1 MiB); exhaustion raises `Kobako::TimeoutError` / `Kobako::MemoryLimitError`. |
28
- | Capability injection via Services | Guest scripts can only call Ruby objects you explicitly `bind` under a two-level `Namespace::Member` path. |
29
- | Preloaded snippets | `Sandbox#preload` registers source or RITE bytecode for setup-once dispatch via `Sandbox#run(:Entrypoint, *args, **kwargs)`. |
30
- | Capability Handles | Services may return stateful host objects; the guest receives an opaque `Kobako::Handle` proxy it can use as the target of follow-up RPC calls, with no way to dereference it. `Sandbox#run` also accepts non-wire-representable Ruby objects as args and auto-wraps them into Handles, so the guest can use any host object the script needs. |
31
- | Three-class error taxonomy | Every failure is exactly one of `TrapError`, `SandboxError`, or `ServiceError`, so you can route errors without inspecting messages. |
32
- | Per-invocation state reset | Handles issued during one invocation are invalidated before the next; Service bindings and preloaded snippets remain. |
33
- | Separated stdout / stderr capture | Guest writes to `$stdout` / `$stderr` are buffered per-channel (1 MiB default cap, configurable); overflow is clipped and reported by `#stdout_truncated?` / `#stderr_truncated?`. |
34
- | Per-invocation usage readout | `Sandbox#usage` returns the most recent invocation's `wall_time` (Float seconds spent inside the wasm guest) and `memory_peak` (high-water `memory.grow` delta in bytes), populated on every outcome including `TrapError`, for budget diagnostics. |
35
- | Curated mruby stdlib | Core extensions plus `mruby-onig-regexp` for full Onigmo `Regexp` support; no mrbgem with I/O, network, or syscall access is bundled. |
36
-
37
24
  ## Requirements
38
25
 
39
26
  - **Ruby ≥ 3.3.0**
@@ -61,88 +48,75 @@ result = sandbox.eval(<<~RUBY)
61
48
  1 + 2
62
49
  RUBY
63
50
 
64
- result # => 3
65
- sandbox.stdout # => ""
51
+ result # => 3
66
52
  ```
67
53
 
68
54
  The script executes inside the Wasm guest. It cannot read your filesystem, open sockets, or touch your `ENV`.
69
55
 
70
- ## Injecting Services
56
+ ## Usage
71
57
 
72
- Guest scripts reach host resources only through Services. Declare a **Namespace**, then `bind` named **Members** on it — each member can be any Ruby object that responds to the methods the guest will call.
58
+ ### Injecting Services
59
+
60
+ Declare a Namespace, then `bind` any Ruby object as a Member; the guest reaches it as a `<Namespace>::<Member>` proxy and invokes its public methods through the Transport wire. See [`docs/behavior.md`](docs/behavior.md) B-07..B-12.
73
61
 
74
62
  ```ruby
75
- sandbox = Kobako::Sandbox.new
63
+ class User
64
+ attr_reader :name
76
65
 
77
- sandbox.define(:KV).bind(:Lookup, ->(key) { redis.get(key) })
78
- sandbox.define(:Log).bind(:Sink, ->(msg) { logger.info(msg) })
66
+ def initialize(name:)
67
+ @name = name
68
+ end
69
+ end
70
+
71
+ sandbox.define(:Project).bind(:User, User.new(name: "alice"))
72
+ sandbox.define(:KV) .bind(:Lookup, ->(key) { redis.get(key) })
79
73
 
80
74
  sandbox.eval(<<~RUBY)
81
- Log::Sink.call("starting")
82
- KV::Lookup.call("user_42")
75
+ Project::User.name # => "alice"
76
+ KV::Lookup.call("user_42") # => "..."
83
77
  RUBY
84
- # => "..." (the redis value)
85
78
  ```
86
79
 
87
- Names must match the Ruby constant pattern `/\A[A-Z]\w*\z/`. Services declared before the first invocation remain active across subsequent invocations; `define` after the first invocation (`#eval` or `#run`) raises `ArgumentError`.
80
+ Names 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; later `#define` raises `ArgumentError`.
88
81
 
89
- ### Keyword arguments
82
+ ### Yielding to guest blocks
90
83
 
91
- Keyword keys travel as Symbols and reach the host method as keyword arguments:
84
+ 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 [`docs/behavior.md`](docs/behavior.md) B-23..B-30.
92
85
 
93
86
  ```ruby
94
- sandbox.define(:Geo).bind(:Lookup, ->(name:, region:) { "#{region}/#{name}" })
87
+ sandbox.define(:Seq).bind(:Map, ->(items, &blk) { items.map(&blk) })
95
88
 
96
- sandbox.eval('Geo::Lookup.call(name: "alice", region: "us")')
97
- # => "us/alice"
89
+ sandbox.eval('Seq::Map.call([1, 2, 3]) { |x| x * 2 }')
90
+ # => [2, 4, 6]
98
91
  ```
99
92
 
100
- ## Per-invocation caps
93
+ ### Per-invocation caps
101
94
 
102
- Each Sandbox enforces a wall-clock timeout and a guest linear-memory cap on every invocation (`#eval` or `#run`). Both default to safe values; pass `nil` to `timeout` or `memory_limit` to disable that cap. The output caps (`stdout_limit` / `stderr_limit`) cannot be disabledpass a large Integer instead.
95
+ 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 [`Sandbox#usage`](lib/kobako/sandbox.rb) after the callpopulated on every outcome including traps — for actual consumption ([`docs/behavior.md`](docs/behavior.md) B-35).
103
96
 
104
97
  ```ruby
105
98
  sandbox = Kobako::Sandbox.new(
106
- timeout: 5.0, # seconds, default 60.0
107
- memory_limit: 10 * 1024 * 1024, # bytes, default 1 MiB
108
- stdout_limit: 64 * 1024, # bytes, default 1 MiB
99
+ timeout: 5.0, # seconds, default 60.0
100
+ memory_limit: 10 * 1024 * 1024, # bytes, default 1 MiB
101
+ stdout_limit: 64 * 1024, # bytes, default 1 MiB
109
102
  stderr_limit: 64 * 1024
110
103
  )
111
104
  ```
112
105
 
113
- | Cap | Raises (subclass of `TrapError`) | Default |
114
- |----------------|------------------------------------|----------|
115
- | `timeout` | `Kobako::TimeoutError` | 60.0 s |
116
- | `memory_limit` | `Kobako::MemoryLimitError` | 1 MiB |
117
- | `stdout_limit` | output silently clipped at cap | 1 MiB |
118
- | `stderr_limit` | output silently clipped at cap | 1 MiB |
119
-
120
- The timeout deadline is absolute wall-clock from invocation entry and is checked at guest Wasm safepoints. Long-running host Service callbacks still consume wall-clock time but do not themselves trap — the next guest safepoint will trap immediately on return if the deadline has passed.
121
-
122
- `memory_limit` is scoped to the **per-invocation linear-memory delta** — the budget covers how much the current `#eval` / `#run` may grow `memory.grow` past the size observed at invocation entry. The mruby image's initial allocation and prior invocations' high-water mark are folded into that entry baseline, so a Sandbox reused across many invocations does not silently accumulate against a global budget.
106
+ | Cap | Raises | Default |
107
+ |----------------|----------------------------|---------|
108
+ | `timeout` | `Kobako::TimeoutError` | 60.0 s |
109
+ | `memory_limit` | `Kobako::MemoryLimitError` | 1 MiB |
110
+ | `stdout_limit` | output clipped (no raise) | 1 MiB |
111
+ | `stderr_limit` | output clipped (no raise) | 1 MiB |
123
112
 
124
- The 1 MiB default targets lightweight dynamic RPC workloads — short scripts that orchestrate Service calls, return small structured values, or replace a tool-calling layer in an AI Agent's Code Mode dispatch. Bump `memory_limit` when scripts compose multi-hundred-KiB strings, hold large composite return values, or run computations that allocate substantial intermediate state. Because the cap resets every invocation, multi-call patterns on one Sandbox do not need a budget that covers their cumulative footprint — only the largest single invocation's working set.
125
-
126
- To see how much of the cap an invocation actually consumed, read `Sandbox#usage` after the call. It returns a `Kobako::Usage` value object with `wall_time` (Float seconds the guest export call spent inside wasmtime, aligned with the `timeout` accounting) and `memory_peak` (Integer high-water `memory.grow` delta in bytes, aligned with the `memory_limit` accounting). The fields are populated on every outcome, including the `TrapError` branches, so you can read them after rescuing a trap to diagnose which budget the failing invocation chewed through.
127
-
128
- ```ruby
129
- sandbox = Kobako::Sandbox.new(timeout: 1.0, memory_limit: 4 * 1024 * 1024)
130
-
131
- begin
132
- sandbox.eval("'x' * 5_000_000")
133
- rescue Kobako::MemoryLimitError
134
- sandbox.usage.memory_peak # => the largest delta accepted before the trap
135
- sandbox.usage.wall_time # => seconds spent before the cap fired
136
- end
137
- ```
113
+ `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.
138
114
 
139
- ## Capturing stdout and stderr
115
+ ### Capturing stdout / stderr
140
116
 
141
- Guest output is captured into per-invocation buffers and exposed independently from the return value. The buffers cover the full Ruby IO surface `puts`, `print`, `printf`, `p`, `<<`, and writes through `$stdout` / `$stderr` — all routed through the host-captured WASI pipe.
117
+ Guest writes through `puts` / `print` / `p` / `$stdout` / `$stderr` are buffered per-channel and exposed independently of the return value ([`docs/behavior.md`](docs/behavior.md) B-04). Buffers clear at the start of each invocation; overflow is clipped at the cap and flagged by `#stdout_truncated?` / `#stderr_truncated?`.
142
118
 
143
119
  ```ruby
144
- sandbox = Kobako::Sandbox.new
145
-
146
120
  result = sandbox.eval(<<~RUBY)
147
121
  puts "hello"
148
122
  warn "be careful"
@@ -154,49 +128,34 @@ sandbox.stdout # => "hello\n"
154
128
  sandbox.stderr # => "be careful\n"
155
129
  ```
156
130
 
157
- Each invocation clears the buffers at start. Output past the per-channel cap is clipped at the cap boundary — the invocation still returns normally, the bytes carry no truncation sentinel, and `#stdout_truncated?` / `#stderr_truncated?` flip to `true`.
158
-
159
- ```ruby
160
- sandbox = Kobako::Sandbox.new(stdout_limit: 64 * 1024)
161
- sandbox.eval('puts "a" * 100_000')
162
- sandbox.stdout.bytesize # => 65_536
163
- sandbox.stdout_truncated? # => true
164
- ```
165
-
166
- ## Error handling
131
+ ### Error handling
167
132
 
168
- Every invocation (`#eval` or `#run`) either returns a value or raises exactly one of three classes:
133
+ Every invocation either returns a value or raises exactly one of three classes, so you can route faults without inspecting messages. The full taxonomy lives in [`lib/kobako/errors.rb`](lib/kobako/errors.rb).
169
134
 
170
135
  ```ruby
171
136
  begin
172
137
  sandbox.eval(script)
173
- rescue Kobako::TrapError => e
174
- # Wasm engine fault OR per-invocation cap exhaustion:
175
- # - Kobako::TimeoutError (wall-clock timeout)
176
- # - Kobako::MemoryLimitError (memory_limit exceeded)
177
- # - Kobako::TrapError (engine crash / wire-violation fallback)
178
- # The Sandbox is unrecoverable discard and recreate it.
179
- rescue Kobako::ServiceError => e
180
- # A Service call failed and the script did not rescue it.
181
- # Treat like any other downstream-service failure in your app.
182
- rescue Kobako::SandboxError => e
183
- # The script itself raised, failed to compile, or produced an
184
- # unrepresentable value. A script-level fault, not infrastructure.
138
+ rescue Kobako::TrapError
139
+ # Wasm engine fault or cap exhaustion. Discard the Sandbox.
140
+ rescue Kobako::ServiceError
141
+ # A host Service call failed and the script did not rescue it.
142
+ rescue Kobako::SandboxError
143
+ # The script raised, failed to compile, or returned an unrepresentable value.
185
144
  end
186
145
  ```
187
146
 
188
- `SandboxError` and `ServiceError` carry structured fields (`origin`, `klass`, `backtrace_lines`, `details`) when the guest produced a panic envelope. Named subclasses:
147
+ | Class | Parent | Trigger |
148
+ |---------------------------------|----------------|------------------------------------------------------|
149
+ | `Kobako::TimeoutError` | `TrapError` | Per-invocation `timeout` exhausted |
150
+ | `Kobako::MemoryLimitError` | `TrapError` | Per-invocation `memory_limit` exhausted |
151
+ | `Kobako::HandlerExhaustedError` | `SandboxError` | Handle counter reached its 2³¹ − 1 cap |
152
+ | `Kobako::BytecodeError` | `SandboxError` | `#preload(binary:)` failed RITE validation at replay |
189
153
 
190
- | Class | Parent | Trigger |
191
- |----------------------------------------|--------------------|------------------------------------------------------------------------------------------|
192
- | `Kobako::TimeoutError` | `TrapError` | Per-invocation `timeout` exhausted |
193
- | `Kobako::MemoryLimitError` | `TrapError` | Per-invocation `memory_limit` exhausted |
194
- | `Kobako::HandleTableExhausted` | `SandboxError` | Per-invocation Handle counter reached its 2³¹ − 1 cap |
195
- | `Kobako::BytecodeError` | `SandboxError` | `#preload(binary:)` payload failed RITE structural validation at first invocation replay |
154
+ `SandboxError` and `ServiceError` carry structured `origin` / `klass` / `backtrace_lines` / `details` fields when the guest produced a panic envelope.
196
155
 
197
- ## Capability Handles
156
+ ### Capability Handles
198
157
 
199
- When a Service returns a stateful host object (anything beyond `nil` / Boolean / Integer / Float / String / Symbol / Array / Hash), the wire layer transparently allocates an opaque Handle. The guest receives a `Kobako::Handle` proxy it can use as the target of further RPC calls but cannot dereference, forge from an integer, or smuggle across runs.
158
+ A non-wire-representable host object returned from a Service (B-14), passed to `#run` (B-34), or handed back from the guest (B-37) 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 ([`docs/behavior.md`](docs/behavior.md) B-13..B-21, B-34, B-37).
200
159
 
201
160
  ```ruby
202
161
  class Greeter
@@ -206,30 +165,15 @@ end
206
165
 
207
166
  sandbox.define(:Factory).bind(:Make, ->(name) { Greeter.new(name) })
208
167
 
209
- sandbox.eval(<<~RUBY)
210
- g = Factory::Make.call("Bob") # g is a Kobako::Handle proxy
211
- g.greet # second RPC, routed to the Greeter
212
- RUBY
213
- # => "hi, Bob"
214
- ```
215
-
216
- `Sandbox#run` accepts non-wire-representable host objects as args / kwargs values too: the host walks the argument tree, wraps every non-wire leaf through the same Handle path, and the guest sees a `Kobako::Handle` proxy in its place. This lets you pass framework objects (a Rack `env` Hash containing an `IO`-like body, an active record, an enumerator) into the entrypoint without first marshalling them into primitives.
217
-
218
- ```ruby
219
- require "stringio"
220
-
221
- sandbox = Kobako::Sandbox.new
222
- sandbox.preload(code: "Echo = ->(body) { body.read.upcase }", name: :Echo)
223
-
224
- sandbox.run(:Echo, StringIO.new("hello world"))
225
- # => "HELLO WORLD"
168
+ sandbox.eval('Factory::Make.call("Bob").greet') # => "hi, Bob" (Handle round-trip inside guest)
169
+ sandbox.eval('Factory::Make.call("Bob")') # => #<Greeter @name="Bob"> (B-37 restoration)
226
170
  ```
227
171
 
228
- Handles are scoped to a single invocation a Handle obtained in invocation N is invalid in invocation N+1, even on the same Sandbox.
172
+ A `break` value from a guest block is the one exception: it unwinds back to the guest Member 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.
229
173
 
230
- ## Setup-once, run-many
174
+ ### Setup-once, run-many
231
175
 
232
- A single Sandbox can serve many invocations. Service bindings and preloaded snippets persist; capability state (Handles, stdout, stderr) resets between invocations.
176
+ One Sandbox serves many invocations. Service bindings and preloaded snippets persist across calls; capability state (Handles, stdout, stderr, memory delta) resets between them.
233
177
 
234
178
  ```
235
179
  ───────────── setup phase (mutable) ─────────────
@@ -270,40 +214,21 @@ A single Sandbox can serve many invocations. Service bindings and preloaded snip
270
214
  Services + snippets persist; invocation N+1 repeats.
271
215
  ```
272
216
 
273
- ```ruby
274
- sandbox = Kobako::Sandbox.new
275
- sandbox.define(:Data).bind(:Fetch, ->(id) { records[id] })
276
-
277
- sandbox.eval('Data::Fetch.call("a")') # => "..."
278
- sandbox.eval('Data::Fetch.call("b")') # => "..." (same bindings, fresh state)
279
- ```
217
+ 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.
280
218
 
281
- For workloads that must be isolated from each other (e.g., one Sandbox per tenant, per student submission), 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.
219
+ ### Preloaded snippets and entrypoint dispatch
282
220
 
283
- ## Preloaded snippets and entrypoint dispatch
284
-
285
- `Sandbox#preload` registers named mruby snippets that replay against the fresh `mrb_state` before every invocation; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets and returns the value of `Target.call(*args, **kwargs)`. Together they cover setup-once / dispatch-many workloads where the same logic is exercised across many requests.
221
+ `Sandbox#preload` registers named mruby snippets that replay against the fresh `mrb_state` before every invocation; `Sandbox#run(:Target, *args, **kwargs)` dispatches into a top-level `Object` constant defined by those snippets ([`docs/behavior.md`](docs/behavior.md) B-31..B-33).
286
222
 
287
223
  ```ruby
288
224
  sandbox = Kobako::Sandbox.new
289
- sandbox.preload(code: "Adder = ->(a, b) { a + b }", name: :Adder)
225
+ sandbox.preload(code: "Adder = ->(a, b) { a + b }", name: :Adder)
290
226
  sandbox.preload(code: 'Greeter = ->(name:) { "hello, #{name}" }', name: :Greeter)
291
227
 
292
- sandbox.run(:Adder, 2, 3) # => 5
293
- sandbox.run(:Greeter, name: "world") # => "hello, world"
228
+ sandbox.run(:Adder, 2, 3) # => 5
229
+ sandbox.run(:Greeter, name: "world") # => "hello, world"
294
230
  ```
295
231
 
296
- `#preload` accepts two payload forms:
297
-
298
- | Form | Signature | Snippet name source | Validation timing |
299
- |----------|----------------------------------------|-------------------------------------|------------------------------------------------------------------------------------------|
300
- | Source | `preload(code: "...", name: :Const)` | The `name:` keyword | Trial-compiled at preload time; compile errors raise immediately |
301
- | Bytecode | `preload(binary: bytes)` | Read from the bytecode's `debug_info` | Structural validation runs at first invocation; failure raises `Kobako::BytecodeError` |
302
-
303
- The source form trial-compiles each snippet against a fresh `mrb_state` at preload time, so compile errors surface immediately at the `#preload` call. The bytecode form treats `binary:` as opaque bytes and defers RITE version / body validation to the first invocation's replay, because that is when the payload loads into a fresh `mrb_state`. Bytecode compiled without `debug_info` (`mrbc` without `-g`) is still accepted — only its backtrace frames are omitted, while exception class, message, and `origin` attribution are preserved.
304
-
305
- Snippets replay in insertion order, so later snippets can reference constants defined by earlier ones. The snippet table is sealed by the first invocation alongside Service registration; additional `#preload` calls after the first `#eval` or `#run` raise `ArgumentError`.
306
-
307
232
  ```
308
233
  per-invocation replay (every #eval / #run, snippets in insertion order):
309
234
 
@@ -319,65 +244,33 @@ Snippets replay in insertion order, so later snippets can reference constants de
319
244
  return value, then mrb_state discarded
320
245
  ```
321
246
 
322
- `#run` resolves `target` (Symbol or String, normalized to Symbol) only as a top-level `Object` constant — `::`-segmented names and lowercase forms fail at host pre-flight with `ArgumentError`. A `Kobako::SandboxError` surfaces when the constant is missing or does not respond to `#call`.
323
-
324
- ### Choosing between source and bytecode
325
-
326
- Use the **source form** when snippets are authored in your repo or generated at boot — compile errors land at the `#preload` call so a misbehaving snippet fails fast at setup time, and no separate `mrbc` toolchain is needed. The trial-compile happens once per snippet (~2.5 µs per snippet) and is paid at preload, not on the request hot path.
247
+ `#preload` accepts two payload forms:
327
248
 
328
- Use the **bytecode form** when snippets ship as build artifacts from a pipeline that runs `mrbc` separately — for example, when source bodies should not be embedded in the running process, when you want a build step that compiles and packages snippets ahead of release, or when you want `Exception#backtrace` frames attributed to the bytecode's `debug_info` filename rather than a host-supplied `name:` keyword. Structural validation (RITE version, body integrity) is deferred to the first invocation, so a malformed bytecode payload surfaces as `Kobako::BytecodeError` on the first `#eval` or `#run`, not at `#preload`.
249
+ | Form | Signature | Snippet name source | Validation timing |
250
+ |----------|--------------------------------------|---------------------------------------|----------------------------------------------------------------------------|
251
+ | Source | `preload(code: "...", name: :Const)` | The `name:` keyword | Trial-compiled at preload; compile errors raise immediately |
252
+ | Bytecode | `preload(binary: bytes)` | Read from the bytecode's `debug_info` | Deferred to first invocation; failure raises `Kobako::BytecodeError` |
329
253
 
330
- Both forms behave identically at dispatch time and replay through the same per-invocation path, so the choice between them is about your build / distribution pipeline and where you want errors to land, not about runtime cost.
254
+ 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.
331
255
 
332
256
  ## Performance
333
257
 
334
- Order-of-magnitude figures for capacity planning on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values vary by hardware but the ratios are stable across machines. Detailed numbers and methodology live in [`benchmark/README.md`](benchmark/README.md).
335
-
336
- ### Lifecycle costs
337
-
338
- | Phase | Cost |
339
- |-------------------------------------------------------------|-------------------------------------------------|
340
- | First `Sandbox.new` in a fresh process (Engine + Module JIT) | ~600 ms one-time |
341
- | Subsequent `Sandbox.new` (Engine cache warm) | ~130 µs |
342
- | Reusing a Sandbox for one `#eval("nil")` | ~135 µs |
343
- | Fresh `Sandbox.new` per request | ~275 µs (≈ +140 µs vs reuse) |
344
- | Warm `#run(:Entrypoint, ...)` dispatch | ~165 µs |
345
- | Per-RPC cost amortized inside one invocation | ~6.6 µs (1 000 RPCs in one `#eval` ≈ 6.6 ms) |
346
- | 100 000-iteration integer XOR loop in mruby | ~43 ms |
347
- | 1 000 Onigmo `Regexp =~` matches | ~3 µs each |
258
+ Order-of-magnitude figures on macOS arm64, Ruby 3.4.7, YJIT off. Absolute values vary by hardware but ratios are stable across machines. Full numbers, methodology, and the +10%-regression gate live in [`benchmark/README.md`](benchmark/README.md).
348
259
 
349
- The ~600 ms cold start dominates the first Sandbox in a process — wasmtime JIT-compiles the precompiled `kobako.wasm` Module and the result is cached at process scope. Construct one Sandbox at boot before serving requests so the JIT cost lands off the hot path.
260
+ | Phase | Cost |
261
+ |--------------------------------------------------------------|-----------------------|
262
+ | First `Sandbox.new` in a fresh process (Engine + Module JIT) | ~600 ms one-time |
263
+ | Subsequent `Sandbox.new` (Engine cache warm) | ~125 µs |
264
+ | Warm `#eval("nil")` on a reused Sandbox | ~135 µs |
265
+ | Warm `#run(:Entrypoint, ...)` dispatch | ~165 µs |
266
+ | Service call amortized inside one invocation | ~6.7 µs |
267
+ | Snippet replay per invocation | ~7-9 µs each |
268
+ | Per additional Sandbox (RSS) | ~570 KB |
350
269
 
351
- ### Memory budget
352
-
353
- | Allocation | Cost |
354
- |---------------------------------------------|----------------------------------------------------------------------------|
355
- | Process RSS after first `Sandbox.new` | ~165-195 MB (one-time engine + module + first instance) |
356
- | Per additional Sandbox | ~580 KB (Wasm instance + linear memory + WASI capture pipes) |
357
- | 1 000 isolated tenants in one process | ~765 MB total |
358
-
359
- Use these as upper-bound budgets for capacity planning, not lower bounds — actual RSS shifts ~30% with host process load and macOS allocator state.
360
-
361
- ### Choosing your pattern
362
-
363
- When the script is ad-hoc (LLM-generated, untrusted user input) and only runs once, use `Sandbox#eval(source)`. Per-invocation cost is ~135 µs of setup plus the script's own runtime; mruby parses the source on every call.
364
-
365
- When you have a fixed set of entrypoints exercised many times — a stable AI Agent tool-call protocol, a plug-in registry loaded at boot, a small library of host-side commands — preload the entrypoints via `Sandbox#preload(code:, name:)` once at setup and dispatch via `Sandbox#run(:Target, *args, **kwargs)`. The mruby source compile (~2.5 µs per snippet) lands once at preload, not on every request, and warm dispatch costs ~165 µs.
366
-
367
- Mind the snippet replay cost. Every preloaded snippet replays into a fresh `mrb_state` before **every** invocation, whether the invocation is `#eval` or `#run`, at ~7-9 µs per snippet per invocation. Preloading 8 helpers adds ~60 µs to every subsequent invocation; preloading 64 helpers adds ~565 µs. Keep the snippet count proportionate to how often the helpers are actually used — preloading rarely-touched helpers is more expensive than inlining or re-eval'ing them.
368
-
369
- For tenant isolation between mutually untrusted scopes, construct a fresh `Kobako::Sandbox` per scope. Per-request construction costs ~140 µs over reuse plus ~580 KB of RSS — comfortably affordable for 1 000+ isolated tenants in one Sidekiq / Puma worker. Reuse a Sandbox when all requests share one trust scope; isolate when scripts come from many.
370
-
371
- ### Concurrency
372
-
373
- `ext/` does not release the GVL during wasmtime execution, so wasm work is GVL-serialized: aggregate throughput across N Threads stays around 7-8k `#eval`/s regardless of N. Ruby-side `#eval` setup can still overlap, so a short `#eval` running while another Thread is in a long `#eval` is slowed by ~2× (not 10×) — host-side synchronization yields the GVL and the contending Thread interleaves. Mixed short / long workloads in one process do not deadlock.
374
-
375
- ### Regression gate
376
-
377
- A +10% regression on any of the five SPEC-mandated benchmarks (cold_start, RPC roundtrip, codec, mruby VM, HandleTable) blocks release. Full per-suite breakdown in [`benchmark/README.md`](benchmark/README.md).
270
+ Construct one Sandbox at boot so the ~600 ms JIT cost lands off the request hot path. `ext/` does not release the GVL during wasmtime execution, so wasm work is GVL-serialized: aggregate throughput stays around 7-8k `#eval`/s regardless of Thread count, though Ruby-side `#eval` setup still overlaps. A +10% regression on any of the six SPEC-mandated benchmarks blocks release.
378
271
 
379
272
  ```bash
380
- bundle exec rake bench # five gated regression benchmarks (~5-8 min, ≤ 1 MiB payloads)
273
+ bundle exec rake bench # six gated regression benchmarks (~5-8 min)
381
274
  ```
382
275
 
383
276
  ## Development
@@ -385,19 +278,11 @@ bundle exec rake bench # five gated regression benchmarks (~5-8 min, ≤ 1 MiB
385
278
  After checking out the repo:
386
279
 
387
280
  ```bash
388
- bin/setup # install dependencies
389
- bundle exec rake # default: compile + test + rubocop + steep
390
- ```
391
-
392
- Building from source requires a WASI-capable Rust toolchain in addition to the standard host toolchain. The first compile walks the full vendor / mruby / wasm chain:
393
-
394
- ```bash
395
- bundle exec rake compile # build the native extension
396
- bundle exec rake wasm:build # rebuild data/kobako.wasm
397
- bundle exec rake test # run the Ruby test suite
281
+ bin/setup # install dependencies
282
+ bundle exec rake # default: compile + test + rubocop + steep
398
283
  ```
399
284
 
400
- `bin/console` opens an IRB session with the gem preloaded for experimentation. To install the local checkout as a gem, run `bundle exec rake install`.
285
+ Building from source requires a WASI-capable Rust toolchain in addition to the standard host toolchain; the first compile walks the full vendor / mruby / wasm chain. 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.
401
286
 
402
287
  ## Contributing
403
288
 
data/data/kobako.wasm CHANGED
Binary file
Binary file
Binary file
Binary file
@@ -59,10 +59,11 @@ module Kobako
59
59
  @namespaces[name_str] ||= Namespace.new(name_str)
60
60
  end
61
61
 
62
- # Resolve a +target+ path of the form +"Namespace::Member"+ to the
63
- # bound Host object. +target+ is a two-level path using the +::+
64
- # separator. Returns the bound Host object. Raises +KeyError+ when the
65
- # namespace or the member is not bound.
62
+ # Resolve a +target+ path of the form +"<Namespace>::<Member>"+
63
+ # (e.g. +"MyService::KV"+) to the bound Host object. +target+ is a
64
+ # two-level path using the +::+ separator. Returns the bound Host
65
+ # object. Raises +KeyError+ when the namespace or the member is not
66
+ # bound.
66
67
  def lookup(target)
67
68
  namespace_name, member_name = target.to_s.split("::", 2)
68
69
  namespace = @namespaces[namespace_name]
@@ -116,6 +116,33 @@ module Kobako
116
116
  end
117
117
  end
118
118
 
119
+ # Deep-walk Array / Hash containers in +value+ and replace every
120
+ # +Kobako::Handle+ leaf with the host-side object +handler+ resolves
121
+ # it to ({docs/behavior.md B-37}[link:../../../docs/behavior.md]).
122
+ # The symmetric inverse of {deep_wrap}: that walk allocates objects
123
+ # into Handles on the host→guest argument path; this walk resolves
124
+ # Handles back to their objects on every guest→host value path — the
125
+ # +#eval+ / +#run+ result and the yield-block result alike. The walk
126
+ # descends through Array elements and Hash keys and values one
127
+ # structural level at a time; any non-Handle leaf passes through
128
+ # unchanged.
129
+ #
130
+ # +value+ is a decoded Ruby value (a Handle here is a wire-decoded
131
+ # +Kobako::Handle+, never a guest-forged one — B-20); +handler+ must
132
+ # respond to +#fetch(id) -> object+ (a host-side
133
+ # +Kobako::Catalog::Handles+). +handler.fetch+ raises
134
+ # +Kobako::SandboxError+ for an id with no live binding, which is the
135
+ # corrupted-runtime fallback B-37 specifies.
136
+ def deep_restore(value, handler)
137
+ case value
138
+ when ::Array then value.map { |element| Utils.deep_restore(element, handler) }
139
+ when ::Hash
140
+ value.to_h { |key, val| [Utils.deep_restore(key, handler), Utils.deep_restore(val, handler)] }
141
+ when Kobako::Handle then handler.fetch(value.id)
142
+ else value
143
+ end
144
+ end
145
+
119
146
  # Predicate split out of {representable?} for cyclomatic
120
147
  # budget — the closed-set non-container branch. Returns +true+ for
121
148
  # the scalar leaves and an existing Handle. Not part of the
@@ -3,6 +3,7 @@
3
3
  require "forwardable"
4
4
 
5
5
  require_relative "capture"
6
+ require_relative "codec"
6
7
  require_relative "errors"
7
8
  require_relative "outcome"
8
9
  require_relative "sandbox_options"
@@ -304,7 +305,11 @@ module Kobako
304
305
  snapshot = yield
305
306
  @stdout_capture = snapshot.stdout
306
307
  @stderr_capture = snapshot.stderr
307
- Outcome.decode(snapshot.return_bytes)
308
+ # A Capability Handle in the result is decoded as a Kobako::Handle
309
+ # token; restore it to the host object the guest referenced before
310
+ # handing the value to the Host App (B-37). @handler still holds this
311
+ # invocation's table — reset only happens at the next #begin_invocation!.
312
+ Codec::Utils.deep_restore(Outcome.decode(snapshot.return_bytes), @handler)
308
313
  rescue Kobako::TrapError => e
309
314
  raise trap_class_for(e), "Sandbox##{verb} failed: #{e.message}"
310
315
  ensure
@@ -59,7 +59,7 @@ module Kobako
59
59
  request = Kobako::Transport::Request.decode(request_bytes)
60
60
  target = resolve_target(request.target, namespaces, handler)
61
61
  args, kwargs = resolve_call_args(request, handler)
62
- yielder = Yielder.new(yield_to_guest, BREAK_THROW) if request.block_given
62
+ yielder = Yielder.new(yield_to_guest, BREAK_THROW, handler) if request.block_given
63
63
  value = catch(BREAK_THROW) { invoke(target, request.method_name, args, kwargs, yielder) }
64
64
  encode_ok(value, handler)
65
65
  rescue StandardError => e
@@ -20,11 +20,12 @@ module Kobako
20
20
  #
21
21
  # 5-element msgpack array:
22
22
  # +[target, method_name, args, kwargs, block_given]+. +target+ is
23
- # either a +String+ (+"Namespace::Member"+) or a {Handle}. SPEC pins
24
- # +kwargs+ map keys to ext 0x00 Symbol; enforced at construction so
25
- # the Value Object is the single source of truth. +block_given+ is a
26
- # Boolean signalling whether the guest call site supplied a block
27
- # (B-23); the block body itself never crosses the wire.
23
+ # either a +String+ (+"<Namespace>::<Member>"+, e.g. +"MyService::KV"+)
24
+ # or a {Handle}. SPEC pins +kwargs+ map keys to ext 0x00 Symbol;
25
+ # enforced at construction so the Value Object is the single source of
26
+ # truth. +block_given+ is a Boolean signalling whether the guest call
27
+ # site supplied a block (B-23); the block body itself never crosses the
28
+ # wire.
28
29
  #
29
30
  # Built on the +class X < Data.define(...)+ subclass form so the
30
31
  # class body is fully Steep-visible; see +lib/kobako/outcome/panic.rb+
@@ -36,21 +36,29 @@ module Kobako
36
36
  # +Runtime#yield_to_active_invocation+ bound through a lambda) that
37
37
  # {#yield} invokes to re-enter the guest; +break_tag+ is the +catch+
38
38
  # throw tag the Dispatcher matches against to unwind the Service on
39
- # +tag 0x02+.
40
- def initialize(yield_to_guest, break_tag)
39
+ # +tag 0x02+. +handler+ is the Sandbox's +Kobako::Catalog::Handles+,
40
+ # used to restore a Capability Handle in the block's ok value back to
41
+ # its host object before it reaches the Service +yield+ site
42
+ # ({docs/behavior.md B-37}[link:../../../docs/behavior.md]).
43
+ def initialize(yield_to_guest, break_tag, handler)
41
44
  @yield_to_guest = yield_to_guest
42
45
  @break_tag = break_tag
46
+ @handler = handler
43
47
  @active = true
44
48
  end
45
49
 
46
50
  # Re-enter the guest with +args+ and reify the YieldResponse into
47
51
  # Ruby control flow. Raises +LocalJumpError+ if called after
48
- # {#invalidate!} (E-23).
52
+ # {#invalidate!} (E-23). The ok value is consumed by the host Service
53
+ # method, so a Capability Handle in it is restored to its host object
54
+ # (B-37). The break value unwinds past the Service back to the guest
55
+ # Member call (B-25), so it passes through verbatim — a Handle stays a
56
+ # Handle and rides back on the same id rather than churning a new one.
49
57
  def yield(*args)
50
58
  raise LocalJumpError, "guest block invoked after host dispatch frame returned" unless @active
51
59
 
52
60
  response = Kobako::Transport::Yield.decode(@yield_to_guest.call(Kobako::Codec::Encoder.encode(args)))
53
- return response.value if response.ok?
61
+ return restore(response.value) if response.ok?
54
62
 
55
63
  throw @break_tag, response.value if response.break?
56
64
 
@@ -72,6 +80,17 @@ module Kobako
72
80
 
73
81
  private
74
82
 
83
+ # Restore any Capability Handle in a block's ok value to its host
84
+ # object via the injected +Catalog::Handles+
85
+ # ({docs/behavior.md B-37}[link:../../../docs/behavior.md]). Only the
86
+ # ok path calls this — host code consumes the ok value, whereas a
87
+ # break value returns to the guest and stays a Handle. Walks nested
88
+ # Array / Hash one level at a time; a plain value passes through
89
+ # unchanged.
90
+ def restore(value)
91
+ Kobako::Codec::Utils.deep_restore(value, @handler)
92
+ end
93
+
75
94
  # Reify a +YieldResponse+ tag 0x04 payload into a +RuntimeError+ the
76
95
  # Service method observes at its +yield+ site. The +{class, message,
77
96
  # backtrace}+ shape mirrors the +Kobako::Transport::Yield+ tag 0x04
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kobako
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.1"
5
5
  end
data/lib/kobako.rb CHANGED
@@ -1,7 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "kobako/version"
4
- require "kobako/kobako"
4
+
5
+ begin
6
+ RUBY_VERSION =~ /(\d+\.\d+)/
7
+ require "kobako/#{Regexp.last_match(1)}/kobako"
8
+ rescue LoadError
9
+ require "kobako/kobako"
10
+ end
11
+
5
12
  require_relative "kobako/errors"
6
13
  require_relative "kobako/transport"
7
14
  require_relative "kobako/catalog"
@@ -11,6 +11,8 @@ module Kobako
11
11
 
12
12
  def self?.deep_wrap: (untyped value, Kobako::Catalog::Handles handler) -> untyped
13
13
 
14
+ def self?.deep_restore: (untyped value, Kobako::Catalog::Handles handler) -> untyped
15
+
14
16
  def self?.primitive_type?: (untyped value) -> bool
15
17
 
16
18
  def self?.container_representable?: (untyped value) -> bool
@@ -3,9 +3,10 @@ module Kobako
3
3
  class Yielder
4
4
  @yield_to_guest: ^(String) -> String
5
5
  @break_tag: Symbol
6
+ @handler: Kobako::Catalog::Handles
6
7
  @active: bool
7
8
 
8
- def initialize: (^(String) -> String yield_to_guest, Symbol break_tag) -> void
9
+ def initialize: (^(String) -> String yield_to_guest, Symbol break_tag, Kobako::Catalog::Handles handler) -> void
9
10
 
10
11
  def yield: (*untyped args) -> untyped
11
12
 
@@ -15,6 +16,8 @@ module Kobako
15
16
 
16
17
  private
17
18
 
19
+ def restore: (untyped value) -> untyped
20
+
18
21
  def yield_failure: (untyped payload, default: String) -> RuntimeError
19
22
  end
20
23
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kobako
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - Aotokitsuruya
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-05-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: msgpack