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
# 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.
  if code.include?('`') || code =~ /%x[{<|!@#(\[]/
    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