Module: SB
- Defined in:
- lib/security_box/guest/rpc.rb,
lib/security_box/guest/main.rb
Overview
Guest-side host RPC bridge (loaded by main.rb before user code).
User code calls SB.call(name, args) and it behaves like a regular, blocking Ruby function: the request is written to /work/rpc_req.json, the wasm import (SBExt.call — the sb_rpc gem statically linked into the image) is invoked, and the guest blocks while the host executes the registered handler. The response is read back from /work/rpc_resp.json.
Handler failures never cross the wasm boundary: the host encodes them as false, "error": {"class", "message"} responses, surfaced here as SB::ToolError (SB::UnknownTool for unregistered names) so user code can rescue them.
Defined Under Namespace
Classes: RpcUnavailable, ToolError, UnknownTool
Constant Summary collapse
- SENTINEL =
"__SECURITY_BOX_RESULT__"- WORK_DIR =
"/work"
Class Method Summary collapse
-
.call(name, args = nil, **kwargs) ⇒ Object
Calls a host-registered handler and returns its result.
-
.emit(out, token) ⇒ Object
Preferred: /work/out.json.
- .evaluate(code) ⇒ Object
- .fatal(message, token) ⇒ Object
- .fetch_code ⇒ Object
-
.guest_backtrace(exception) ⇒ Object
Frames of the user code only: guest protocol internals (main.rb / prelude.rb) are stripped and the list is capped so a deep recursion cannot flood the result channel.
-
.jsonable(value) ⇒ Object
Values must survive JSON; the round-trip normalizes what the host will read.
- .run ⇒ Object
- .write_verified(json) ⇒ Object
Class Method Details
.call(name, args = nil, **kwargs) ⇒ Object
Calls a host-registered handler and returns its result. Behaves as a blocking function call; the LLM-facing contract is plain Ruby.
SB.call("github.search", q: "x") # kwargs form
SB.call("github.search", { "q" => "x" }) # positional hash form
name and args must be JSON-serializable; handlers receive the
JSON-parsed args (string keys) and return a JSON-serializable value.
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/security_box/guest/rpc.rb', line 47 def call(name, args = nil, **kwargs) unless name.is_a?(String) && !name.empty? raise ArgumentError, "rpc name must be a non-empty String, got #{name.inspect}" end unless defined?(SBExt) raise RpcUnavailable, "rpc support is not available in this image (sb_rpc gem missing)" end merged = args.nil? ? {} : args merged = merged.merge(kwargs) unless kwargs.empty? request = begin JSON.generate({ name: name, args: merged }) rescue StandardError, TypeError => e raise TypeError, "rpc arguments must be JSON-serializable: #{e.message}" end File.write(File.join(WORK_DIR, "rpc_req.json"), request) status = SBExt.call unless status.zero? raise RpcUnavailable, "rpc transport failed (status #{status})" end response = JSON.parse(File.read(File.join(WORK_DIR, "rpc_resp.json"))) unless response["ok"] error = response["error"] || {} klass = error["class"] == "SB::UnknownTool" ? UnknownTool : ToolError raise klass, error["message"].to_s end response["result"] end |
.emit(out, token) ⇒ Object
Preferred: /work/out.json. Fallback (no /work): sentinel on stdout. Both carry the sandbox token so the host can reject forged results. The write is verified by reading it back — a silent truncation or partial write falls through to the sentinel instead of losing the result.
79 80 81 82 83 84 |
# File 'lib/security_box/guest/main.rb', line 79 def emit(out, token) json = JSON.generate(out.merge(token: token)) unless write_verified(json) $stdout.puts "#{SENTINEL}:#{token}:#{json}" end end |
.evaluate(code) ⇒ Object
42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/security_box/guest/main.rb', line 42 def evaluate(code) { ok: true, value: jsonable(eval(code, TOPLEVEL_BINDING, "sandbox")) } # rubocop:disable Security/Eval,Style/EvalWithLocation rescue Exception => e # rubocop:disable Lint/RescueException { ok: false, value: nil, error: { "class" => e.class.name, "message" => e..to_s, "backtrace" => guest_backtrace(e) } } end |
.fatal(message, token) ⇒ Object
94 95 96 |
# File 'lib/security_box/guest/main.rb', line 94 def fatal(, token) emit({ ok: false, value: nil, error: { "class" => "SecurityBox::Guest", "message" => } }, token) end |
.fetch_code ⇒ Object
33 34 35 36 37 38 39 40 |
# File 'lib/security_box/guest/main.rb', line 33 def fetch_code code_path = File.join(WORK_DIR, "code.rb") return File.read(code_path) if File.exist?(code_path) ARGV[0] rescue StandardError, SystemCallError ARGV[0] end |
.guest_backtrace(exception) ⇒ Object
Frames of the user code only: guest protocol internals (main.rb / prelude.rb) are stripped and the list is capped so a deep recursion cannot flood the result channel.
58 59 60 61 62 63 64 65 |
# File 'lib/security_box/guest/main.rb', line 58 def guest_backtrace(exception) frames = (exception.backtrace || []).reject do |frame| frame.include?("main.rb") || frame.include?("prelude.rb") end frames.first(20) rescue StandardError [] end |
.jsonable(value) ⇒ Object
Values must survive JSON; the round-trip normalizes what the host will read.
68 69 70 71 72 |
# File 'lib/security_box/guest/main.rb', line 68 def jsonable(value) JSON.parse(JSON.generate(value)) rescue StandardError, TypeError value.inspect end |
.run ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 |
# File 'lib/security_box/guest/main.rb', line 21 def run token = SBPrelude.apply! code = fetch_code return fatal("no code provided", token) unless code t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) out = evaluate(code) out[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2) emit(out, token) end |
.write_verified(json) ⇒ Object
86 87 88 89 90 91 92 |
# File 'lib/security_box/guest/main.rb', line 86 def write_verified(json) path = File.join(WORK_DIR, "out.json") File.write(path, json) File.read(path) == json rescue StandardError, SystemCallError false end |