Class: Woods::Console::EvalGuard

Inherits:
Object
  • Object
show all
Defined in:
lib/woods/console/eval_guard.rb

Overview

Parse-time refusal layer for console_eval.

Reachability (v0.2)

EvalGuard is the first of five controls on the embedded console_eval opt-in path. EmbeddedExecutor#handle_eval calls check! before anything else — ahead of the Confirmation prompt, the SafeContext rollback, the timeout, and the audit log. When the opt-in is off (the default), refusal_for('eval') still short-circuits with the eval_disabled payload and this guard is not reached. See docs/CONSOLE_MCP_SETUP.md "console_eval opt-in" and backlog B-053.

Bridge-process mode (in development) will call the same guard before shipping the payload to the remote Rails worker.

Behaviour

Walks the normalized Ast::Parser tree of the proposed Ruby snippet and refuses any expression that reaches a known credential or reflection escape — so an LLM-generated Rails.application.credentials .stripe.secret_key or a reflection escape is rejected before the bridge ever sees it.

This is defense in depth, not the only line: the bridge process must re-enforce the same rules at execution time. The gem-side check exists so the LLM sees a fast, visible refusal instead of relying on the host app's bridge configuration.

Examples:

EvalGuard.check!('User.count')                                # => true
EvalGuard.check!('Rails.application.credentials.stripe.key')  # raises

Constant Summary collapse

DENIED_CALL_CHAINS =

Receivers/calls whose presence in the AST is always a refusal. Each entry is matched against the dotted source text of every send node's receiver (and qualified call name) — so a denial of Rails.application.credentials catches every chained access through it (e.g. Rails.application.credentials.dig(:stripe)).

%w[
  Rails.application.credentials
  Rails.application.secrets
  Rails::Secrets
  Devise.secret_key
].freeze
DENIED_CONSTANTS =

Constants whose bare reference (or use as a receiver) is denied.

  • ENV — reads host secrets as a string-keyed hash.
  • Threading: Thread, Fiber, Ractor, Process — concurrent execution escapes the rolled-back transaction (the spawned block leases its own connection outside SafeContext's tx).
  • Deserialization: Marshal, YAML, Psych — unsafe load paths can execute arbitrary code during object instantiation.
  • Network: Net, Socket, TCPSocket, UDPSocket, URI, OpenURI, Resolv, Faraday, HTTP — every HTTP/network egress point available in a standard Rails install.
  • File I/O: File, FileUtils, IO, Dir, Pathname, Tempfile, StringIO, BasicObject — broad filesystem access.
  • Kernel-ish: Kernel, Object, ObjectSpace, GC, RubyVM, TracePoint, Gem, Bundler. File/IO/Pathname are intentionally NOT in this list — legitimate non-credential file reads are a core use case. Credential-path access is handled by CREDENTIAL_FILE_READERS below, and shell-exec

attempts (Kernel.open("|cmd"), backticks, %x{}) are caught by the backtick textual check in #check! and the DENIED_REFLECTION entries for system/exec/popen/etc.

%w[
  ENV
  Thread Fiber Ractor Process Mutex ConditionVariable Queue SizedQueue
  Marshal YAML Psych
  Net Socket TCPSocket UDPSocket UNIXSocket URI OpenURI Resolv Faraday HTTP
  ObjectSpace GC RubyVM TracePoint
  Gem Bundler
].freeze
DENIED_REFLECTION =

Method names that escape the AST sandbox regardless of receiver.

Covers, in order:

  • Eval family: the classic eval/instance_eval/class_eval/ module_eval plus binding (which enables reconstructing an eval in the caller's scope).
  • Dynamic dispatch: send / public_send / __send__ / method / public_method (returns a callable, indirect dispatch) and the const_get / const_set / remove_const / define_method / define_singleton_method / alias_method / undef_method / remove_method / method_defined? / prepend / include_module reflection family.
  • State mutation: instance_variable_set / instance_variable_get, class_variable_set / class_variable_get / freeze / taint.
  • Object-space escapes: _id2ref, each_object, const_source_location.
  • System / process: system, exec, spawn, fork, popen, %x{} (AST method name backtick / xstr) so they can't be invoked implicitly.
  • File / IO: open (bare Kernel#open — the File-specific reader is handled separately via CREDENTIAL_FILE_READERS, but the bare Kernel.open("|shell-command") form is how most shellshock-style escapes slip through).
  • Network: URI.open (when called as open on URI, the AST method name is open so the string match above catches it). HTTP / Socket constants are denied separately via DENIED_CONSTANTS.
  • Loader: load, require, require_relative, autoload.
  • Unsafe deserialization: unsafe_load / _load (Marshal.load and YAML.load are denied via DENIED_CONSTANTS + method gate below).
  • Threading escapes from SafeContext's rollback: new on Thread / Fiber / Process is denied via DENIED_CONSTANTS so the Thread.new pair can't slip past.
%w[
  eval instance_eval class_eval module_eval binding
  instance_exec class_exec module_exec
  send public_send __send__ method public_method
  const_get const_set remove_const define_method define_singleton_method
  alias_method undef_method remove_method method_defined? singleton_method
  instance_variable_get instance_variable_set
  class_variable_get class_variable_set
  _id2ref each_object const_source_location instance_variables
  prepend include_module
  system exec spawn fork popen popen2 popen2e popen3 backtick
  require require_relative autoload
  unsafe_load _load
  taint untaint
].freeze
CREDENTIAL_FILE_READERS =

Receivers + method-name pairs that read credential files from disk. Triggers when the receiver matches AND any literal argument source contains a known credential path fragment. Pathname.new(...) is included so Pathname.new(...).read chains are caught at construction.

open is included for File and IO to catch chained patterns like File.open("config/master.key").read — the inner File.open(path) node is visited by scan_send_nodes and refused here before the outer .read call is even examined (PR #34 review medium #3).

{
  'File' => %w[read binread readlines open],
  'IO' => %w[read binread readlines open],
  'Pathname' => %w[read binread new open]
}.freeze
CREDENTIAL_PATH_HINTS =
%w[
  master.key credentials.yml.enc credentials/
  secrets.yml secrets.yml.enc
].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(parser: Woods::Ast::Parser.new) ⇒ EvalGuard

Returns a new instance of EvalGuard.



162
163
164
# File 'lib/woods/console/eval_guard.rb', line 162

def initialize(parser: Woods::Ast::Parser.new)
  @parser = parser
end

Class Method Details

.check!(code) ⇒ Object

Parameters:

  • code (String)

    Ruby source proposed for console_eval.

Raises:



157
158
159
# File 'lib/woods/console/eval_guard.rb', line 157

def check!(code)
  new.check!(code)
end

Instance Method Details

#check!(code) ⇒ Object

Parameters:

  • code (String)

Raises:



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/woods/console/eval_guard.rb', line 190

def check!(code)
  raise ForbiddenExpressionError, 'payload is empty' if code.nil? || code.strip.empty?

  # Fail-safe textual check for backtick literals (` `cmd` ` and
  # `%x{cmd}`) — the AST flavor of these is `:xstr`/`:xstr_heredoc`,
  # which {Woods::Ast::Parser} may normalize differently across
  # Prism/parser-gem backends. A source-level refusal is both cheap
  # and impossible to evade via AST normalization. Ruby accepts ANY
  # non-word character as the %x delimiter — including whitespace:
  # `%x\ncmd\n` (newline-delimited) is valid and executes. Match
  # every non-word char (`[^\w]`, which spans newline/space/tab, not
  # `[^\w\s]` which excluded them and let `%x\n…` slip through). As a
  # fail-safe, over-refusing odd-but-harmless source beats letting an
  # unlisted delimiter through.
  if code.include?('`') || code =~ /%x[^\w]/
    raise ForbiddenExpressionError, 'payload contains a shell-execution literal (backtick or %x)'
  end

  refuse_class_or_global_var_assignment!(code)

  tree = parse_or_refuse(code)
  scan_send_nodes(tree)
  scan_const_nodes(tree)
  scan_assignment_nodes(tree)
end