Class: Woods::Console::EvalGuard
- Inherits:
-
Object
- Object
- Woods::Console::EvalGuard
- 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.
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
- #check!(code) ⇒ Object
-
#initialize(parser: Woods::Ast::Parser.new) ⇒ EvalGuard
constructor
A new instance of EvalGuard.
Constructor Details
Class Method Details
.check!(code) ⇒ Object
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
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 |