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

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.message.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(message, token)
  emit({ ok: false, value: nil, error: { "class" => "SecurityBox::Guest", "message" => 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