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.credentialscatches 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 forsystem/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_evalplusbinding(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 theconst_get/const_set/remove_const/define_method/define_singleton_method/alias_method/undef_method/remove_method/method_defined?/prepend/include_modulereflection 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 namebacktick/ 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 bareKernel.open("|shell-command")form is how most shellshock-style escapes slip through). - Network:
URI.open(when called asopenon URI, the AST method name isopenso 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:
newon Thread / Fiber / Process is denied via DENIED_CONSTANTS so the Thread.new pair can't slip past.
- Eval family: the classic
%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 soPathname.new(...).readchains are caught at construction.openis included for File and IO to catch chained patterns likeFile.open("config/master.key").read— the innerFile.open(path)node is visited byscan_send_nodesand refused here before the outer.readcall 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 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 |