woods 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +169 -0
- data/README.md +20 -8
- data/exe/woods-console +51 -6
- data/exe/woods-console-mcp +24 -4
- data/exe/woods-mcp +30 -7
- data/exe/woods-mcp-http +47 -6
- data/lib/generators/woods/install_generator.rb +13 -4
- data/lib/generators/woods/templates/woods.rb.tt +155 -0
- data/lib/tasks/woods.rake +15 -50
- data/lib/woods/builder.rb +174 -9
- data/lib/woods/cache/cache_middleware.rb +360 -31
- data/lib/woods/chunking/semantic_chunker.rb +334 -7
- data/lib/woods/console/adapters/job_adapter.rb +10 -4
- data/lib/woods/console/audit_logger.rb +76 -4
- data/lib/woods/console/bridge.rb +48 -15
- data/lib/woods/console/bridge_protocol.rb +44 -0
- data/lib/woods/console/confirmation.rb +3 -4
- data/lib/woods/console/console_response_renderer.rb +56 -18
- data/lib/woods/console/credential_index.rb +201 -0
- data/lib/woods/console/credential_scanner.rb +302 -0
- data/lib/woods/console/dispatch_pipeline.rb +138 -0
- data/lib/woods/console/embedded_executor.rb +682 -35
- data/lib/woods/console/eval_guard.rb +319 -0
- data/lib/woods/console/model_validator.rb +1 -3
- data/lib/woods/console/rack_middleware.rb +185 -29
- data/lib/woods/console/redactor.rb +161 -0
- data/lib/woods/console/response_context.rb +127 -0
- data/lib/woods/console/safe_context.rb +220 -23
- data/lib/woods/console/scope_predicate_parser.rb +131 -0
- data/lib/woods/console/server.rb +417 -486
- data/lib/woods/console/sql_noise_stripper.rb +87 -0
- data/lib/woods/console/sql_table_scanner.rb +213 -0
- data/lib/woods/console/sql_validator.rb +81 -31
- data/lib/woods/console/table_gate.rb +93 -0
- data/lib/woods/console/tool_specs.rb +552 -0
- data/lib/woods/console/tools/tier1.rb +3 -3
- data/lib/woods/console/tools/tier4.rb +7 -1
- data/lib/woods/dependency_graph.rb +66 -7
- data/lib/woods/embedding/indexer.rb +190 -6
- data/lib/woods/embedding/openai.rb +40 -4
- data/lib/woods/embedding/provider.rb +104 -8
- data/lib/woods/embedding/text_preparer.rb +23 -3
- data/lib/woods/embedding/token_counter.rb +133 -0
- data/lib/woods/evaluation/baseline_runner.rb +20 -2
- data/lib/woods/evaluation/metrics.rb +4 -1
- data/lib/woods/extracted_unit.rb +1 -0
- data/lib/woods/extractor.rb +7 -1
- data/lib/woods/extractors/controller_extractor.rb +6 -0
- data/lib/woods/extractors/mailer_extractor.rb +16 -2
- data/lib/woods/extractors/model_extractor.rb +6 -1
- data/lib/woods/extractors/phlex_extractor.rb +13 -4
- data/lib/woods/extractors/rails_source_extractor.rb +2 -0
- data/lib/woods/extractors/route_helper_resolver.rb +130 -0
- data/lib/woods/extractors/shared_dependency_scanner.rb +130 -2
- data/lib/woods/extractors/view_component_extractor.rb +12 -1
- data/lib/woods/extractors/view_engines/base.rb +141 -0
- data/lib/woods/extractors/view_engines/erb.rb +145 -0
- data/lib/woods/extractors/view_template_extractor.rb +92 -133
- data/lib/woods/flow_assembler.rb +23 -15
- data/lib/woods/flow_precomputer.rb +21 -2
- data/lib/woods/graph_analyzer.rb +3 -4
- data/lib/woods/index_artifact.rb +173 -0
- data/lib/woods/mcp/bearer_auth.rb +45 -0
- data/lib/woods/mcp/bootstrap_state.rb +94 -0
- data/lib/woods/mcp/bootstrapper.rb +337 -16
- data/lib/woods/mcp/config_resolver.rb +288 -0
- data/lib/woods/mcp/errors.rb +134 -0
- data/lib/woods/mcp/index_reader.rb +265 -30
- data/lib/woods/mcp/origin_guard.rb +132 -0
- data/lib/woods/mcp/provider_probe.rb +166 -0
- data/lib/woods/mcp/renderers/claude_renderer.rb +6 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +39 -3
- data/lib/woods/mcp/renderers/plain_renderer.rb +16 -2
- data/lib/woods/mcp/server.rb +737 -137
- data/lib/woods/model_name_cache.rb +78 -2
- data/lib/woods/notion/client.rb +25 -2
- data/lib/woods/notion/mappers/model_mapper.rb +36 -2
- data/lib/woods/railtie.rb +55 -15
- data/lib/woods/resilience/circuit_breaker.rb +9 -2
- data/lib/woods/resilience/retryable_provider.rb +40 -3
- data/lib/woods/resolved_config.rb +299 -0
- data/lib/woods/retrieval/context_assembler.rb +112 -5
- data/lib/woods/retrieval/query_classifier.rb +1 -1
- data/lib/woods/retrieval/ranker.rb +55 -6
- data/lib/woods/retrieval/search_executor.rb +42 -13
- data/lib/woods/retriever.rb +330 -24
- data/lib/woods/session_tracer/middleware.rb +35 -1
- data/lib/woods/storage/graph_store.rb +39 -0
- data/lib/woods/storage/inapplicable_backend.rb +14 -0
- data/lib/woods/storage/metadata_store.rb +129 -1
- data/lib/woods/storage/pgvector.rb +70 -8
- data/lib/woods/storage/qdrant.rb +196 -5
- data/lib/woods/storage/snapshotter/metadata.rb +172 -0
- data/lib/woods/storage/snapshotter/vector.rb +238 -0
- data/lib/woods/storage/snapshotter.rb +24 -0
- data/lib/woods/storage/vector_store.rb +184 -35
- data/lib/woods/tasks.rb +85 -0
- data/lib/woods/temporal/snapshot_store.rb +49 -1
- data/lib/woods/token_utils.rb +44 -5
- data/lib/woods/unblocked/client.rb +1 -1
- data/lib/woods/unblocked/document_builder.rb +35 -10
- data/lib/woods/unblocked/exporter.rb +1 -1
- data/lib/woods/util/host_guard.rb +61 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods.rb +126 -6
- metadata +69 -4
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
require_relative 'audit_logger'
|
|
6
|
+
require_relative 'bridge_protocol'
|
|
7
|
+
require_relative 'confirmation'
|
|
8
|
+
require_relative 'eval_guard'
|
|
4
9
|
require_relative 'model_validator'
|
|
5
10
|
require_relative 'safe_context'
|
|
11
|
+
require_relative 'scope_predicate_parser'
|
|
12
|
+
require_relative 'sql_noise_stripper'
|
|
13
|
+
require_relative 'table_gate'
|
|
6
14
|
|
|
7
15
|
module Woods
|
|
8
16
|
module Console
|
|
9
|
-
# Drop-in replacement for ConnectionManager +
|
|
10
|
-
# queries directly via ActiveRecord instead of
|
|
17
|
+
# Drop-in replacement for ConnectionManager + the bridge process that
|
|
18
|
+
# executes queries directly via ActiveRecord instead of going over the
|
|
19
|
+
# JSON-lines protocol (see {StubBridge} for the protocol scaffold).
|
|
11
20
|
#
|
|
12
21
|
# Implements the same `send_request(Hash) -> Hash` interface as
|
|
13
22
|
# ConnectionManager, so all existing tool definitions in Server work
|
|
@@ -19,9 +28,9 @@ module Woods
|
|
|
19
28
|
# # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
|
|
20
29
|
#
|
|
21
30
|
class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
|
|
22
|
-
AGGREGATE_FUNCTIONS = %w[sum average minimum maximum].freeze
|
|
31
|
+
AGGREGATE_FUNCTIONS = %w[sum average minimum maximum count].freeze
|
|
23
32
|
|
|
24
|
-
TIER1_TOOLS =
|
|
33
|
+
TIER1_TOOLS = BridgeProtocol::TIER1_TOOLS
|
|
25
34
|
|
|
26
35
|
# Tools gated behind the read_tools_enabled flag.
|
|
27
36
|
# sql/query have existing safety gates (SqlValidator, SafeContext rollback)
|
|
@@ -31,15 +40,44 @@ module Woods
|
|
|
31
40
|
MAX_SQL_LIMIT = 10_000
|
|
32
41
|
MAX_QUERY_LIMIT = 10_000
|
|
33
42
|
|
|
43
|
+
MIN_EVAL_TIMEOUT = 1
|
|
44
|
+
MAX_EVAL_TIMEOUT = 30
|
|
45
|
+
DEFAULT_EVAL_TIMEOUT = 10
|
|
46
|
+
|
|
34
47
|
# @param model_validator [ModelValidator] Validates model/column names
|
|
35
48
|
# @param safe_context [SafeContext] Wraps execution in rolled-back transaction
|
|
36
49
|
# @param connection [Object, nil] Database connection for adapter detection
|
|
37
50
|
# @param read_tools_enabled [Boolean] Enable sql/query tools in embedded mode (default: false)
|
|
38
|
-
|
|
51
|
+
# @param table_gate [TableGate, nil] Enforces console_blocked_tables on every
|
|
52
|
+
# model/join/association/SQL access pre-execution. When nil, no table-level
|
|
53
|
+
# gate runs — an explicit signal from the server builder that no tables are
|
|
54
|
+
# configured as blocked. Callers should pass the live gate from
|
|
55
|
+
# {Server#build_response_context} so embedded mode matches the bridge's
|
|
56
|
+
# defense-in-depth posture.
|
|
57
|
+
# @param eval_guard [#check!, nil] EvalGuard for the `console_eval` opt-in
|
|
58
|
+
# path. Required when `unsafe_eval_enabled` is true; nil otherwise.
|
|
59
|
+
# @param confirmation [Confirmation, nil] Human-in-the-loop approval for
|
|
60
|
+
# `console_eval`. Required when `unsafe_eval_enabled` is true; nil
|
|
61
|
+
# otherwise.
|
|
62
|
+
# @param audit_logger [AuditLogger, nil] Logs every `console_eval` attempt
|
|
63
|
+
# (refused, denied, or executed). Required when `unsafe_eval_enabled` is
|
|
64
|
+
# true; nil otherwise.
|
|
65
|
+
# @param unsafe_eval_enabled [Boolean] When true, the executor wires the
|
|
66
|
+
# five-control eval path (guard + confirmation + SafeContext + timeout +
|
|
67
|
+
# audit). When false, `console_eval` returns the hard `eval_disabled`
|
|
68
|
+
# refusal as before.
|
|
69
|
+
def initialize(model_validator:, safe_context:, connection: nil, read_tools_enabled: false, # rubocop:disable Metrics/ParameterLists
|
|
70
|
+
table_gate: nil, eval_guard: nil, confirmation: nil, audit_logger: nil,
|
|
71
|
+
unsafe_eval_enabled: false)
|
|
39
72
|
@model_validator = model_validator
|
|
40
73
|
@safe_context = safe_context
|
|
41
74
|
@connection = connection
|
|
42
75
|
@read_tools_enabled = read_tools_enabled
|
|
76
|
+
@table_gate = table_gate
|
|
77
|
+
@eval_guard = eval_guard
|
|
78
|
+
@confirmation = confirmation
|
|
79
|
+
@audit_logger = audit_logger
|
|
80
|
+
@unsafe_eval_enabled = unsafe_eval_enabled
|
|
43
81
|
end
|
|
44
82
|
|
|
45
83
|
# Execute a tool request and return a response hash.
|
|
@@ -56,11 +94,8 @@ module Woods
|
|
|
56
94
|
tool = request['tool']
|
|
57
95
|
params = request['params'] || {}
|
|
58
96
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
'error' => 'Not yet implemented in embedded mode',
|
|
62
|
-
'error_type' => 'unsupported' }
|
|
63
|
-
end
|
|
97
|
+
refusal = refusal_for(tool)
|
|
98
|
+
return refusal if refusal
|
|
64
99
|
|
|
65
100
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
101
|
result = @safe_context.execute { dispatch(tool, params) }
|
|
@@ -68,13 +103,281 @@ module Woods
|
|
|
68
103
|
|
|
69
104
|
{ 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
|
|
70
105
|
rescue ValidationError => e
|
|
106
|
+
# Validation messages are author-controlled — safe to return as-is so
|
|
107
|
+
# callers can correct their request.
|
|
71
108
|
{ 'ok' => false, 'error' => e.message, 'error_type' => 'validation' }
|
|
72
109
|
rescue StandardError => e
|
|
73
|
-
|
|
110
|
+
# Execution errors come from adapters and can embed fragments of the
|
|
111
|
+
# rejected SQL, schema names, column names, or partial table contents
|
|
112
|
+
# (`PG::UndefinedColumn`, `Mysql2::Error`, etc.). Return a generic
|
|
113
|
+
# reason to the client; log the full detail via Rails.logger when
|
|
114
|
+
# available so operators can still debug.
|
|
115
|
+
log_execution_error(e)
|
|
116
|
+
{ 'ok' => false, 'error' => sanitize_execution_error(e), 'error_type' => 'execution' }
|
|
74
117
|
end
|
|
75
118
|
|
|
76
119
|
private
|
|
77
120
|
|
|
121
|
+
def sanitize_execution_error(error)
|
|
122
|
+
klass = error.class.name
|
|
123
|
+
# Well-known AR wrappers that contain the adapter error as their cause —
|
|
124
|
+
# still surface the class name so logs can route, but don't echo the
|
|
125
|
+
# message.
|
|
126
|
+
"#{klass}: execution failed (details logged server-side)"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def log_execution_error(error)
|
|
130
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
131
|
+
|
|
132
|
+
Rails.logger.warn(
|
|
133
|
+
"[Woods::Console] execution error: #{error.class}: #{error.message}"
|
|
134
|
+
)
|
|
135
|
+
rescue StandardError
|
|
136
|
+
# Never let logging break the request path.
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Return a pre-dispatch refusal hash for tools the executor cannot or
|
|
140
|
+
# will not run, else nil to let dispatch proceed.
|
|
141
|
+
#
|
|
142
|
+
# `eval` is refused unconditionally when the opt-in is off. When the
|
|
143
|
+
# opt-in is on, this returns nil and `handle_eval` runs the full
|
|
144
|
+
# five-control path (guard → confirmation → SafeContext → timeout →
|
|
145
|
+
# audit). See `Server.unsafe_eval_enabled?` for the flag semantics.
|
|
146
|
+
#
|
|
147
|
+
# @param tool [String] Tool name
|
|
148
|
+
# @return [Hash, nil]
|
|
149
|
+
def refusal_for(tool)
|
|
150
|
+
if tool == 'eval'
|
|
151
|
+
return nil if @unsafe_eval_enabled
|
|
152
|
+
|
|
153
|
+
{ 'ok' => false, 'error' => eval_disabled_message, 'error_type' => 'eval_disabled' }
|
|
154
|
+
elsif !(TIER1_TOOLS.include?(tool) || (@read_tools_enabled && EMBEDDED_READ_TOOLS.include?(tool)))
|
|
155
|
+
{ 'ok' => false, 'error' => unsupported_message(tool), 'error_type' => 'unsupported' }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Self-describing error for tools the embedded executor cannot run.
|
|
160
|
+
#
|
|
161
|
+
# `sql`/`query` are gated behind `embedded_read_tools: true` — point the
|
|
162
|
+
# caller at the flag. Everything else (Tier 2–4 domain/analytics tools)
|
|
163
|
+
# requires the bridge architecture.
|
|
164
|
+
#
|
|
165
|
+
# @param tool [String] Tool name that was rejected
|
|
166
|
+
# @return [String] Actionable error message
|
|
167
|
+
def unsupported_message(tool)
|
|
168
|
+
if EMBEDDED_READ_TOOLS.include?(tool)
|
|
169
|
+
"Tool '#{tool}' requires embedded_read_tools: true on " \
|
|
170
|
+
'Woods::Console::RackMiddleware, or use the bridge (Option D). ' \
|
|
171
|
+
'See docs/CONSOLE_MCP_SETUP.md.'
|
|
172
|
+
else
|
|
173
|
+
"Tool '#{tool}' is not available in embedded mode — it requires the " \
|
|
174
|
+
'bridge architecture (Option D in docs/CONSOLE_MCP_SETUP.md).'
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Instructional error payload for console_eval when the opt-in is off.
|
|
179
|
+
# The default posture is still "refused" — the opt-in must be enabled
|
|
180
|
+
# explicitly (env var + fail-closed collaborators) before any Ruby runs.
|
|
181
|
+
# The message:
|
|
182
|
+
# 1. names why eval is off (default-off opt-in),
|
|
183
|
+
# 2. points the agent at console_query / console_sql as the usual
|
|
184
|
+
# substitute (both already handle group_by/having/aggregates),
|
|
185
|
+
# 3. tells the agent to surface its proposed Ruby snippet to the
|
|
186
|
+
# human before any retry — never silently re-invoke,
|
|
187
|
+
# 4. tells operators exactly which flag + collaborators to wire to
|
|
188
|
+
# turn it on.
|
|
189
|
+
#
|
|
190
|
+
# @return [String] Multi-line actionable message.
|
|
191
|
+
def eval_disabled_message
|
|
192
|
+
<<~MSG.strip
|
|
193
|
+
console_eval is disabled — the unsafe-eval opt-in is off by default.
|
|
194
|
+
Use console_query (model + select + joins/group_by/having/order) or console_sql
|
|
195
|
+
for anything you were about to run. Both already support aggregates and scoping.
|
|
196
|
+
If you believe eval is still necessary, SHOW your proposed Ruby snippet to the
|
|
197
|
+
user first and let them run it manually — do not retry console_eval automatically.
|
|
198
|
+
Operators: set WOODS_CONSOLE_UNSAFE_EVAL=true (or console_unsafe_eval_enabled = true)
|
|
199
|
+
AND wire console_unsafe_eval_confirmation + console_unsafe_eval_audit_log_path.
|
|
200
|
+
The server refuses to boot with the flag on in Rails.env.production?, and refuses
|
|
201
|
+
to boot with the flag on but any collaborator missing (fail-closed).
|
|
202
|
+
See docs/CONSOLE_MCP_SETUP.md "console_eval opt-in" for the full checklist.
|
|
203
|
+
MSG
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Handle the `console_eval` request on the opt-in path.
|
|
207
|
+
#
|
|
208
|
+
# Runs the five-control contract in order:
|
|
209
|
+
# 1. EvalGuard.check! — parse-time AST denylist (credentials,
|
|
210
|
+
# reflection escapes, network, file-IO for credentials, shell).
|
|
211
|
+
# 2. Confirmation.request_confirmation — human-in-the-loop approval.
|
|
212
|
+
# Callback mode lets the host route through a real approval UI;
|
|
213
|
+
# auto-deny is the fail-closed default when the host didn't wire one.
|
|
214
|
+
# 3. SafeContext.execute — runs the code inside a rolled-back
|
|
215
|
+
# transaction so any writes are discarded.
|
|
216
|
+
# 4. Timeout.timeout — clamps wall-clock time to MIN..MAX seconds.
|
|
217
|
+
# 5. AuditLogger.log — records every outcome (refused/denied/ok/error)
|
|
218
|
+
# with CredentialScanner redaction on params and result_summary.
|
|
219
|
+
#
|
|
220
|
+
# Every exit path writes exactly one audit entry. Refusals caused by
|
|
221
|
+
# missing collaborators ("eval_misconfigured") never reach here — the
|
|
222
|
+
# server refuses to boot in that state.
|
|
223
|
+
#
|
|
224
|
+
# @param params [Hash] Must contain 'code'; optional 'timeout'
|
|
225
|
+
# @return [Hash] { 'result' => <inspect of return value> }
|
|
226
|
+
# @raise [ValidationError] on guard refusal, confirmation denial, or
|
|
227
|
+
# timeout. `send_request` turns these into { ok: false } responses.
|
|
228
|
+
def handle_eval(params)
|
|
229
|
+
code = params['code']
|
|
230
|
+
raise ValidationError, 'Missing required parameter: code' if code.nil? || code.to_s.strip.empty?
|
|
231
|
+
|
|
232
|
+
timeout = eval_timeout_from(params['timeout'])
|
|
233
|
+
audit_params = { code: code, timeout: timeout }
|
|
234
|
+
|
|
235
|
+
# guard_check! / confirm! each audit on refusal before re-raising,
|
|
236
|
+
# so we only need an execution-time rescue around the eval itself.
|
|
237
|
+
guard_check!(code, audit_params)
|
|
238
|
+
confirm!(code, audit_params)
|
|
239
|
+
|
|
240
|
+
execute_and_audit(code, timeout, audit_params)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def execute_and_audit(code, timeout, audit_params)
|
|
244
|
+
result = run_eval_with_timeout(code, timeout)
|
|
245
|
+
summary = audit_summary(result)
|
|
246
|
+
audit(params: audit_params, confirmed: true, result_summary: summary)
|
|
247
|
+
{ 'result' => summary }
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
audit(params: audit_params, confirmed: true,
|
|
250
|
+
result_summary: "error:#{e.class}:#{truncate(e.message)}")
|
|
251
|
+
raise
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Validate + clamp the user-supplied timeout. Accepts a positive
|
|
255
|
+
# Integer (or nil → default). Everything else is rejected so a
|
|
256
|
+
# caller passing `timeout: 0` or `timeout: "forever"` hears about
|
|
257
|
+
# it instead of silently getting MIN_EVAL_TIMEOUT.
|
|
258
|
+
def eval_timeout_from(raw)
|
|
259
|
+
return DEFAULT_EVAL_TIMEOUT if raw.nil?
|
|
260
|
+
|
|
261
|
+
unless raw.is_a?(Integer) && raw.positive?
|
|
262
|
+
raise ValidationError,
|
|
263
|
+
"timeout must be a positive integer (#{MIN_EVAL_TIMEOUT}..#{MAX_EVAL_TIMEOUT})"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
raw.clamp(MIN_EVAL_TIMEOUT, MAX_EVAL_TIMEOUT)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def guard_check!(code, audit_params)
|
|
270
|
+
@eval_guard.check!(code)
|
|
271
|
+
rescue ForbiddenExpressionError => e
|
|
272
|
+
audit(params: audit_params, confirmed: false,
|
|
273
|
+
result_summary: "guard-refused:#{truncate(e.message)}")
|
|
274
|
+
raise ValidationError, "console_eval refused by EvalGuard: #{e.message}"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Route approval through the host-supplied {Confirmation}. Passes
|
|
278
|
+
# the FULL code (bounded at 1 KB) as the description so the
|
|
279
|
+
# approval UI renders what was actually proposed, not just the
|
|
280
|
+
# first line. `params:` carries the same full code so callbacks
|
|
281
|
+
# that want to show more can dig into it.
|
|
282
|
+
def confirm!(code, audit_params)
|
|
283
|
+
@confirmation.request_confirmation(
|
|
284
|
+
tool: 'console_eval',
|
|
285
|
+
description: truncate(code, 1024),
|
|
286
|
+
params: audit_params
|
|
287
|
+
)
|
|
288
|
+
rescue ConfirmationDeniedError => e
|
|
289
|
+
audit(params: audit_params, confirmed: false,
|
|
290
|
+
result_summary: "denied:#{truncate(e.message)}")
|
|
291
|
+
raise ValidationError, 'console_eval denied by confirmation callback'
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Wrap the eval in a wall-clock timeout.
|
|
295
|
+
#
|
|
296
|
+
# `Timeout.timeout` on MRI uses a watchdog thread that calls
|
|
297
|
+
# `Thread#raise` — a well-known footgun because the target thread can
|
|
298
|
+
# be interrupted mid-operation and leak resource state. We accept the
|
|
299
|
+
# risk here because the only resource held is the AR connection inside
|
|
300
|
+
# SafeContext's transaction, which rolls back on any exception; the
|
|
301
|
+
# connection is returned to the pool by the surrounding
|
|
302
|
+
# `pool.with_connection` block. There is no safer stdlib primitive
|
|
303
|
+
# for "bound arbitrary Ruby to N seconds wall-clock" on MRI today.
|
|
304
|
+
def run_eval_with_timeout(code, timeout)
|
|
305
|
+
Timeout.timeout(timeout) { eval_in_sandbox(code) }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The literal `eval` call. Kept in its own method so the policy
|
|
309
|
+
# decision (we *do* run arbitrary Ruby on the opt-in path) is visible
|
|
310
|
+
# at one grep-able location. All five controls must have passed to
|
|
311
|
+
# reach this method — callers other than `handle_eval` must not
|
|
312
|
+
# invoke it.
|
|
313
|
+
#
|
|
314
|
+
# We eval via `Object.new.instance_eval` rather than `eval(code,
|
|
315
|
+
# binding)` so `self` is a throwaway receiver, not the executor.
|
|
316
|
+
# Without this isolation, a payload like `@audit_logger = nil; 1`
|
|
317
|
+
# would silence the audit log by writing to the executor's own
|
|
318
|
+
# instance variables (EvalGuard denies the reflection APIs but does
|
|
319
|
+
# not catch the syntactic `@ivar = value` form on its own — that's
|
|
320
|
+
# plugged separately in {EvalGuard#scan_assignment_nodes}).
|
|
321
|
+
# Top-level constants (User, Rails, ActiveRecord::Base, etc.) still
|
|
322
|
+
# resolve because constant lookup on `instance_eval(String)` uses
|
|
323
|
+
# the receiver's class hierarchy, and Object (the throwaway's class)
|
|
324
|
+
# holds every top-level constant.
|
|
325
|
+
#
|
|
326
|
+
# SyntaxError / ScriptError don't descend from StandardError, so
|
|
327
|
+
# they'd otherwise escape every rescue in `execute_and_audit` and
|
|
328
|
+
# `send_request` — crashing the MCP dispatch loop. EvalGuard's
|
|
329
|
+
# parser should reject unparseable payloads upstream, but Prism's
|
|
330
|
+
# parser and Ruby's parser don't always agree; we translate the
|
|
331
|
+
# script-level errors to ValidationError so the normal refusal
|
|
332
|
+
# path owns them.
|
|
333
|
+
def eval_in_sandbox(code)
|
|
334
|
+
Object.new.instance_eval(code, '(console_eval)', 1)
|
|
335
|
+
rescue ScriptError => e
|
|
336
|
+
raise ValidationError, "console_eval payload could not be parsed by Ruby: #{e.class}: #{e.message}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def audit(params:, confirmed:, result_summary:)
|
|
340
|
+
return unless @audit_logger
|
|
341
|
+
|
|
342
|
+
@audit_logger.log(
|
|
343
|
+
tool: 'console_eval',
|
|
344
|
+
params: params,
|
|
345
|
+
confirmed: confirmed,
|
|
346
|
+
result_summary: result_summary
|
|
347
|
+
)
|
|
348
|
+
rescue StandardError
|
|
349
|
+
# Never let audit failures break the request path; a separate
|
|
350
|
+
# operator alert covers audit-log write failures.
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Build the audit-log `result_summary` without triggering side-effects.
|
|
354
|
+
#
|
|
355
|
+
# Naive `result.inspect` on an `ActiveRecord::Relation` materializes
|
|
356
|
+
# the query — a second SQL round-trip that happens *after* the
|
|
357
|
+
# `Timeout.timeout` clamp and that can be arbitrarily expensive. We
|
|
358
|
+
# stringify primitives (safe, informative) and reduce complex
|
|
359
|
+
# objects to their class name so the audit entry is useful without
|
|
360
|
+
# costing extra I/O.
|
|
361
|
+
PRIMITIVE_AUDIT_TYPES = [
|
|
362
|
+
String, Numeric, Symbol, TrueClass, FalseClass, NilClass
|
|
363
|
+
].freeze
|
|
364
|
+
private_constant :PRIMITIVE_AUDIT_TYPES
|
|
365
|
+
|
|
366
|
+
def audit_summary(result)
|
|
367
|
+
if PRIMITIVE_AUDIT_TYPES.any? { |type| result.is_a?(type) }
|
|
368
|
+
truncate(result.inspect)
|
|
369
|
+
else
|
|
370
|
+
"#<#{result.class.name}>"
|
|
371
|
+
end
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
"inspect-failed:#{e.class}"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def truncate(str, limit = 512)
|
|
377
|
+
s = str.to_s
|
|
378
|
+
s.length > limit ? "#{s[0, limit]}…" : s
|
|
379
|
+
end
|
|
380
|
+
|
|
78
381
|
# Route a tool name to its handler.
|
|
79
382
|
#
|
|
80
383
|
# @param tool [String] Tool name
|
|
@@ -86,6 +389,7 @@ module Woods
|
|
|
86
389
|
when 'schema' then handle_schema(params)
|
|
87
390
|
when 'sql' then handle_sql(params)
|
|
88
391
|
when 'query' then handle_query(params)
|
|
392
|
+
when 'eval' then handle_eval(params)
|
|
89
393
|
else
|
|
90
394
|
validate_model!(params)
|
|
91
395
|
send(:"handle_#{tool}", params)
|
|
@@ -99,6 +403,42 @@ module Woods
|
|
|
99
403
|
raise ValidationError, 'Missing required parameter: model' unless model
|
|
100
404
|
|
|
101
405
|
@model_validator.validate_model!(model)
|
|
406
|
+
# Pre-execution table-gate check: refuse every tool invocation that
|
|
407
|
+
# targets a model backed by a blocked table.
|
|
408
|
+
gate_model!(model)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Apply the TableGate (if wired) to model/SQL/join access. Raises
|
|
412
|
+
# {ValidationError} with the TableGate's message so refusals look
|
|
413
|
+
# identical to ModelValidator violations at the protocol boundary.
|
|
414
|
+
def gate_model!(model)
|
|
415
|
+
return unless @table_gate
|
|
416
|
+
|
|
417
|
+
begin
|
|
418
|
+
@table_gate.check_model!(model)
|
|
419
|
+
rescue TableGateError => e
|
|
420
|
+
raise ValidationError, e.message
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def gate_sql!(sql)
|
|
425
|
+
return unless @table_gate
|
|
426
|
+
|
|
427
|
+
begin
|
|
428
|
+
@table_gate.check_sql!(sql)
|
|
429
|
+
rescue TableGateError => e
|
|
430
|
+
raise ValidationError, e.message
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def gate_joins!(model, joins)
|
|
435
|
+
return unless @table_gate && joins
|
|
436
|
+
|
|
437
|
+
begin
|
|
438
|
+
@table_gate.check_joins!(model, joins)
|
|
439
|
+
rescue TableGateError => e
|
|
440
|
+
raise ValidationError, e.message
|
|
441
|
+
end
|
|
102
442
|
end
|
|
103
443
|
|
|
104
444
|
# Resolve a model name string to an ActiveRecord class.
|
|
@@ -113,14 +453,14 @@ module Woods
|
|
|
113
453
|
|
|
114
454
|
def handle_count(params)
|
|
115
455
|
model = resolve_model(params['model'])
|
|
116
|
-
scope = apply_scope(model, params['scope'])
|
|
456
|
+
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
117
457
|
{ 'count' => scope.count }
|
|
118
458
|
end
|
|
119
459
|
|
|
120
460
|
def handle_sample(params)
|
|
121
461
|
model = resolve_model(params['model'])
|
|
122
462
|
limit = [params.fetch('limit', 5).to_i, 25].min
|
|
123
|
-
scope = apply_scope(model, params['scope'])
|
|
463
|
+
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
124
464
|
scope = apply_columns(scope, params['columns'])
|
|
125
465
|
records = scope.order(random_function).limit(limit)
|
|
126
466
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
@@ -141,10 +481,10 @@ module Woods
|
|
|
141
481
|
@model_validator.validate_columns!(params['model'], columns) if columns
|
|
142
482
|
model = resolve_model(params['model'])
|
|
143
483
|
limit = [params.fetch('limit', 100).to_i, 1000].min
|
|
144
|
-
scope = apply_scope(model, params['scope'])
|
|
484
|
+
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
145
485
|
scope = scope.distinct if params['distinct']
|
|
146
486
|
values = scope.limit(limit).pluck(*columns.map(&:to_sym))
|
|
147
|
-
{ 'values' => values }
|
|
487
|
+
{ 'columns' => Array(columns), 'values' => values }
|
|
148
488
|
end
|
|
149
489
|
|
|
150
490
|
def handle_aggregate(params)
|
|
@@ -158,8 +498,14 @@ module Woods
|
|
|
158
498
|
end
|
|
159
499
|
|
|
160
500
|
model = resolve_model(params['model'])
|
|
161
|
-
scope = apply_scope(model, params['scope'])
|
|
162
|
-
|
|
501
|
+
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
502
|
+
|
|
503
|
+
value = if function == 'count'
|
|
504
|
+
column ? scope.count(column.to_sym) : scope.count
|
|
505
|
+
else
|
|
506
|
+
scope.send(function.to_sym, column.to_sym)
|
|
507
|
+
end
|
|
508
|
+
{ 'value' => value }
|
|
163
509
|
end
|
|
164
510
|
|
|
165
511
|
def handle_association_count(params)
|
|
@@ -171,11 +517,28 @@ module Woods
|
|
|
171
517
|
raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}"
|
|
172
518
|
end
|
|
173
519
|
|
|
520
|
+
# Defense-in-depth: the parent model passed validate_model!'s
|
|
521
|
+
# gate_model! check, but the association may target a different
|
|
522
|
+
# table that's on console_blocked_tables (e.g. `Post belongs_to
|
|
523
|
+
# :user` where `users` is blocked). Gate the association target
|
|
524
|
+
# explicitly before reading any rows from it.
|
|
525
|
+
gate_association!(params['model'], association_name)
|
|
526
|
+
|
|
174
527
|
scope = record.public_send(association_name)
|
|
175
528
|
scope = apply_scope(scope, params['scope'])
|
|
176
529
|
{ 'count' => scope.count }
|
|
177
530
|
end
|
|
178
531
|
|
|
532
|
+
def gate_association!(model_name, association)
|
|
533
|
+
return unless @table_gate && association
|
|
534
|
+
|
|
535
|
+
begin
|
|
536
|
+
@table_gate.check_association!(model_name, association)
|
|
537
|
+
rescue TableGateError => e
|
|
538
|
+
raise ValidationError, e.message
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
179
542
|
def handle_schema(params)
|
|
180
543
|
model_name = params['model']
|
|
181
544
|
raise ValidationError, 'Missing required parameter: model' unless model_name
|
|
@@ -208,7 +571,7 @@ module Woods
|
|
|
208
571
|
@model_validator.validate_column!(params['model'], order_by)
|
|
209
572
|
direction = 'desc' unless %w[asc desc].include?(direction)
|
|
210
573
|
|
|
211
|
-
scope = apply_scope(model, params['scope'])
|
|
574
|
+
scope = apply_scope(model, params['scope'], model_name: params['model'])
|
|
212
575
|
scope = apply_columns(scope, params['columns'])
|
|
213
576
|
records = scope.order(order_by => direction.to_sym).limit(limit)
|
|
214
577
|
{ 'records' => serialize_records(records, params['columns']) }
|
|
@@ -235,6 +598,9 @@ module Woods
|
|
|
235
598
|
|
|
236
599
|
require_relative 'sql_validator'
|
|
237
600
|
SqlValidator.new.validate!(sql)
|
|
601
|
+
# Post-validation, pre-execution TableGate — blocks every configured
|
|
602
|
+
# table even if the sql is otherwise well-formed.
|
|
603
|
+
gate_sql!(sql)
|
|
238
604
|
|
|
239
605
|
limit = params['limit'] ? [params['limit'].to_i, MAX_SQL_LIMIT].min : nil
|
|
240
606
|
query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
|
|
@@ -251,9 +617,16 @@ module Woods
|
|
|
251
617
|
# @return [Hash] Columns and rows
|
|
252
618
|
def handle_query(params)
|
|
253
619
|
validate_model!(params)
|
|
620
|
+
gate_joins!(params['model'], params['joins'])
|
|
254
621
|
model = resolve_model(params['model'])
|
|
255
622
|
relation = build_query_relation(model, params)
|
|
256
|
-
|
|
623
|
+
sql = relation.to_sql
|
|
624
|
+
# Defense-in-depth: re-run TableGate on the final rendered SQL. The
|
|
625
|
+
# per-clause validators above are the primary defense; this catches
|
|
626
|
+
# anything they missed (e.g. AR rewriting a scope into a subquery
|
|
627
|
+
# that touches a blocked table through a less-obvious join).
|
|
628
|
+
gate_sql!(sql)
|
|
629
|
+
result = active_connection.select_all(sql)
|
|
257
630
|
{ 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
|
|
258
631
|
end
|
|
259
632
|
|
|
@@ -263,48 +636,298 @@ module Woods
|
|
|
263
636
|
# @param params [Hash] Query parameters (select, joins, scope, group_by, having, order, limit)
|
|
264
637
|
# @return [ActiveRecord::Relation]
|
|
265
638
|
def build_query_relation(model, params)
|
|
266
|
-
relation = apply_query_clauses(model
|
|
639
|
+
relation = apply_query_clauses(model, params)
|
|
267
640
|
limit = params['limit'] ? [params['limit'].to_i, MAX_QUERY_LIMIT].min : MAX_QUERY_LIMIT
|
|
268
641
|
relation.limit(limit)
|
|
269
642
|
end
|
|
270
643
|
|
|
644
|
+
# Optional aggregate-expression wrappers accepted inside a `select`.
|
|
645
|
+
# Anything else must be a bare column name validated against the model.
|
|
646
|
+
# Matching is case-insensitive; the trailing `AS alias` is optional and
|
|
647
|
+
# the alias itself must be an identifier — it can't carry SQL.
|
|
648
|
+
SAFE_SELECT_EXPR = /
|
|
649
|
+
\A\s*
|
|
650
|
+
(?:(SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(\*|\w+(?:\.\w+)?)\s*\)|(\w+(?:\.\w+)?))
|
|
651
|
+
(?:\s+AS\s+(\w+))?
|
|
652
|
+
\s*\z
|
|
653
|
+
/ix
|
|
654
|
+
private_constant :SAFE_SELECT_EXPR
|
|
655
|
+
|
|
271
656
|
# Apply select/joins/scope/group/having/order clauses to a relation.
|
|
272
657
|
#
|
|
273
|
-
#
|
|
658
|
+
# Validates every user-supplied column/alias through the ModelValidator
|
|
659
|
+
# and limits aggregate expressions to a small allowlist. Without this
|
|
660
|
+
# pass, `select`/`having`/`order`/`group_by` strings reach AR as raw SQL
|
|
661
|
+
# fragments and an attacker can exfiltrate columns via a crafted
|
|
662
|
+
# `having: "1=1 UNION SELECT password_digest FROM users"`. SafeContext
|
|
663
|
+
# rollback does not stop SELECT-based reads.
|
|
664
|
+
#
|
|
665
|
+
# @param model [Class] ActiveRecord model class
|
|
274
666
|
# @param params [Hash]
|
|
275
667
|
# @return [ActiveRecord::Relation]
|
|
276
|
-
|
|
277
|
-
|
|
668
|
+
# @raise [ValidationError] on unsafe column/expression input
|
|
669
|
+
def apply_query_clauses(model, params) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
670
|
+
model_name = params['model']
|
|
671
|
+
relation = model.all
|
|
672
|
+
|
|
673
|
+
relation = relation.select(*validated_select(params['select'], model_name)) if params['select']
|
|
278
674
|
relation = relation.joins(params['joins'].map(&:to_sym)) if params['joins']&.any?
|
|
279
|
-
relation = apply_scope(relation, params['scope'])
|
|
280
|
-
relation = relation.group(params['group_by']) if params['group_by']&.any?
|
|
281
|
-
relation = relation.having(params['having']) if params['having']
|
|
282
|
-
relation = relation.order(params['order']) if params['order']
|
|
675
|
+
relation = apply_scope(relation, params['scope'], model_name: model_name)
|
|
676
|
+
relation = relation.group(*validated_columns(params['group_by'], model_name)) if params['group_by']&.any?
|
|
677
|
+
relation = relation.having(*validated_having(params['having'], model_name)) if params['having']
|
|
678
|
+
relation = relation.order(validated_order(params['order'], model_name)) if params['order']
|
|
283
679
|
relation
|
|
284
680
|
end
|
|
285
681
|
|
|
682
|
+
# Normalize `select:` into an array of safe expressions. Each element
|
|
683
|
+
# must be a column name (optionally qualified and/or aliased) or a
|
|
684
|
+
# whitelisted aggregate call over a column.
|
|
685
|
+
#
|
|
686
|
+
# @param select [String, Array<String>]
|
|
687
|
+
# @param model_name [String]
|
|
688
|
+
# @return [Array<String>]
|
|
689
|
+
def validated_select(select, model_name)
|
|
690
|
+
Array(select).flat_map { |s| s.to_s.split(',') }.map do |expr|
|
|
691
|
+
validate_select_expression!(expr.strip, model_name)
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def validate_select_expression!(expr, model_name)
|
|
696
|
+
match = SAFE_SELECT_EXPR.match(expr)
|
|
697
|
+
raise ValidationError, "Rejected select expression: #{expr.inspect}" unless match
|
|
698
|
+
|
|
699
|
+
_fn, fn_arg, bare_col, _alias = match.captures
|
|
700
|
+
column = bare_col || fn_arg
|
|
701
|
+
validate_column_reference!(column, model_name) unless column == '*'
|
|
702
|
+
expr
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Validate group_by entries — bare columns only (no functions, no SQL).
|
|
706
|
+
#
|
|
707
|
+
# @param columns [String, Array<String>]
|
|
708
|
+
# @param model_name [String]
|
|
709
|
+
# @return [Array<String>]
|
|
710
|
+
def validated_columns(columns, model_name)
|
|
711
|
+
Array(columns).flat_map { |c| c.to_s.split(',') }.map do |col|
|
|
712
|
+
col = col.strip
|
|
713
|
+
validate_column_reference!(col, model_name)
|
|
714
|
+
col
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Validate and bind-wrap `having:` so nothing reaches SQL as raw string
|
|
719
|
+
# fragments. Accepts:
|
|
720
|
+
# - Hash (e.g. `{total: 100}`) — AR builds a `=` predicate safely.
|
|
721
|
+
# - Array `[sql, *binds]` where the sql string is either
|
|
722
|
+
# `"<col> <op> ?"` or `"<AGG>(<col_or_*>) <op> ?"` with a known
|
|
723
|
+
# operator and validated column.
|
|
724
|
+
# Anything else is rejected — raw strings (e.g. `"1=1 UNION SELECT
|
|
725
|
+
# password_digest FROM users"`) used to flow straight through and
|
|
726
|
+
# enable SELECT-based exfiltration despite the SafeContext rollback.
|
|
727
|
+
HAVING_AGG_TEMPLATE = /
|
|
728
|
+
\A\s*
|
|
729
|
+
(?:
|
|
730
|
+
(?<col>\w+(?:\.\w+)?)
|
|
731
|
+
|
|
|
732
|
+
(?<agg>SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(?<arg>\*|\w+(?:\.\w+)?)\s*\)
|
|
733
|
+
)
|
|
734
|
+
\s*(?<op>=|!=|<>|<=|>=|<|>)\s*\?\s*\z
|
|
735
|
+
/ix
|
|
736
|
+
private_constant :HAVING_AGG_TEMPLATE
|
|
737
|
+
|
|
738
|
+
def validated_having(having, model_name) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
739
|
+
case having
|
|
740
|
+
when Hash
|
|
741
|
+
raise ValidationError, 'having: empty hash' if having.empty?
|
|
742
|
+
|
|
743
|
+
having.each_key { |k| validate_column_reference!(k.to_s, model_name) }
|
|
744
|
+
[having]
|
|
745
|
+
when Array
|
|
746
|
+
raise ValidationError, 'having: array must be [sql_with_placeholders, *binds]' if having.empty?
|
|
747
|
+
|
|
748
|
+
template = having.first.to_s
|
|
749
|
+
match = HAVING_AGG_TEMPLATE.match(template)
|
|
750
|
+
raise ValidationError, "having: unsupported SQL template #{template.inspect}" unless match
|
|
751
|
+
|
|
752
|
+
# Validate any referenced columns through ModelValidator so
|
|
753
|
+
# aggregate args can't reach the db without a column check.
|
|
754
|
+
col = match[:col] || match[:arg]
|
|
755
|
+
validate_column_reference!(col, model_name) if col && col != '*'
|
|
756
|
+
|
|
757
|
+
having
|
|
758
|
+
else
|
|
759
|
+
raise ValidationError, "having: unsupported type #{having.class}"
|
|
760
|
+
end
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Validate `order:` — only Hash `{col => :asc|:desc}` or bare column name.
|
|
764
|
+
def validated_order(order, model_name)
|
|
765
|
+
case order
|
|
766
|
+
when Hash
|
|
767
|
+
order.each_key { |k| validate_column_reference!(k.to_s, model_name) }
|
|
768
|
+
order.transform_values do |dir|
|
|
769
|
+
dir_sym = dir.to_s.downcase.to_sym
|
|
770
|
+
unless %i[asc desc].include?(dir_sym)
|
|
771
|
+
raise ValidationError, "order direction must be :asc or :desc (got #{dir.inspect})"
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
dir_sym
|
|
775
|
+
end
|
|
776
|
+
when String, Symbol
|
|
777
|
+
col = order.to_s.strip
|
|
778
|
+
validate_column_reference!(col, model_name)
|
|
779
|
+
col
|
|
780
|
+
else
|
|
781
|
+
raise ValidationError, "order: unsupported type #{order.class}"
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# Strict column-reference check. `table.col` is allowed only when the
|
|
786
|
+
# table identifier is a safe Ruby identifier AND is not on
|
|
787
|
+
# `console_blocked_tables` (via TableGate). Earlier iterations checked
|
|
788
|
+
# only `safe_identifier?` on both halves, so a caller could smuggle a
|
|
789
|
+
# blocked-table reference into `select`/`order`/`having` via a
|
|
790
|
+
# qualified column like `users.password_digest`. Bare columns validate
|
|
791
|
+
# against the active model through ModelValidator.
|
|
792
|
+
def validate_column_reference!(column, model_name)
|
|
793
|
+
if column.include?('.')
|
|
794
|
+
table, col = column.split('.', 2)
|
|
795
|
+
unless safe_identifier?(table) && safe_identifier?(col)
|
|
796
|
+
raise ValidationError, "Rejected column reference: #{column.inspect}"
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
# Gate the table side through TableGate if one is configured.
|
|
800
|
+
begin
|
|
801
|
+
@table_gate&.check_table!(table)
|
|
802
|
+
rescue TableGateError => e
|
|
803
|
+
raise ValidationError, e.message
|
|
804
|
+
end
|
|
805
|
+
else
|
|
806
|
+
raise ValidationError, "Rejected column reference: #{column.inspect}" unless safe_identifier?(column)
|
|
807
|
+
|
|
808
|
+
@model_validator.validate_column!(model_name, column)
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def safe_identifier?(name)
|
|
813
|
+
name.is_a?(String) && name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
814
|
+
end
|
|
815
|
+
|
|
286
816
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
287
817
|
|
|
288
818
|
# Apply scope conditions (WHERE clauses) to a relation.
|
|
289
819
|
#
|
|
290
|
-
# Accepts Hash form for
|
|
291
|
-
#
|
|
820
|
+
# Accepts Hash form for equality or Ransack-style predicate suffixes
|
|
821
|
+
# (e.g., `{total_refund_gt: 0, status_in: ['paid','refunded']}`), or
|
|
822
|
+
# Array form for parameterized SQL (e.g., JSON column queries like
|
|
292
823
|
# ["preferences->>'theme' = ?", "dark"]).
|
|
293
824
|
#
|
|
825
|
+
# When `model_name` is supplied and the Hash contains at least one key
|
|
826
|
+
# with a recognised predicate suffix, the ScopePredicateParser builds
|
|
827
|
+
# safe Arel nodes. Plain equality hashes skip the parser entirely.
|
|
828
|
+
#
|
|
294
829
|
# @param relation [ActiveRecord::Relation, Class] Model or relation
|
|
295
830
|
# @param scope [Hash, Array, nil] Filter conditions
|
|
831
|
+
# @param model_name [String, nil] Model name for column validation (predicate path only)
|
|
296
832
|
# @return [ActiveRecord::Relation]
|
|
297
|
-
def apply_scope(relation, scope)
|
|
833
|
+
def apply_scope(relation, scope, model_name: nil)
|
|
298
834
|
case scope
|
|
299
835
|
when Hash
|
|
300
|
-
scope.any?
|
|
836
|
+
return relation unless scope.any?
|
|
837
|
+
|
|
838
|
+
if model_name && predicate_suffix?(scope)
|
|
839
|
+
parser = ScopePredicateParser.new(model_name: model_name, model_validator: @model_validator)
|
|
840
|
+
parser.parse(relation, scope)
|
|
841
|
+
else
|
|
842
|
+
relation.where(scope)
|
|
843
|
+
end
|
|
301
844
|
when Array
|
|
302
|
-
scope.any?
|
|
845
|
+
return relation unless scope.any?
|
|
846
|
+
|
|
847
|
+
# Array form is `[template, *binds]`. The previous implementation
|
|
848
|
+
# splatted directly into `where(*scope)`, which is the
|
|
849
|
+
# `where(raw_sql_string)` arity — unbounded SQL injection. A
|
|
850
|
+
# caller could pass `["EXISTS (SELECT 1 FROM users WHERE
|
|
851
|
+
# password_digest LIKE 'a%')"]` and turn `console_count` /
|
|
852
|
+
# `console_pluck` into a boolean exfiltration oracle against
|
|
853
|
+
# any table the DB user can read (TableGate doesn't fire on
|
|
854
|
+
# the rendered Tier-1 SQL). Validate the template now.
|
|
855
|
+
validate_scope_array!(scope)
|
|
856
|
+
relation.where(*scope)
|
|
303
857
|
else
|
|
304
858
|
relation
|
|
305
859
|
end
|
|
306
860
|
end
|
|
307
861
|
|
|
862
|
+
# Forbidden SQL keywords inside a scope template — same set as
|
|
863
|
+
# SqlValidator's body check, sized to the WHERE-fragment context
|
|
864
|
+
# (no DML/DDL keywords because AR will syntax-fail on them anyway,
|
|
865
|
+
# but a SELECT subquery is the live exfiltration vector).
|
|
866
|
+
SCOPE_TEMPLATE_FORBIDDEN = /
|
|
867
|
+
\b(?:
|
|
868
|
+
SELECT | INSERT | UPDATE | DELETE | MERGE | UPSERT |
|
|
869
|
+
UNION | INTERSECT | EXCEPT | INTO |
|
|
870
|
+
DROP | ALTER | TRUNCATE | CREATE | RENAME |
|
|
871
|
+
EXEC | EXECUTE | CALL | DO | COPY |
|
|
872
|
+
GRANT | REVOKE | SET | RESET | LISTEN | NOTIFY |
|
|
873
|
+
PG_SLEEP | PG_TERMINATE_BACKEND | PG_CANCEL_BACKEND |
|
|
874
|
+
LOAD_FILE | INTO\s+OUTFILE | INTO\s+DUMPFILE |
|
|
875
|
+
BENCHMARK | SLEEP
|
|
876
|
+
)\b
|
|
877
|
+
/ix
|
|
878
|
+
private_constant :SCOPE_TEMPLATE_FORBIDDEN
|
|
879
|
+
|
|
880
|
+
# Validate an `[template, *binds]` scope array. Rejects when:
|
|
881
|
+
# - The first element isn't a String (Arel.sql / raw nodes are
|
|
882
|
+
# indistinguishable from raw SQL once they reach AR).
|
|
883
|
+
# - The template contains any forbidden keyword (subquery
|
|
884
|
+
# exfiltration, multi-statement, time-based oracles).
|
|
885
|
+
# - The template contains a semicolon (statement chaining).
|
|
886
|
+
# - The number of `?` placeholders doesn't match the number of
|
|
887
|
+
# binds — AR would error anyway, but failing fast surfaces the
|
|
888
|
+
# mismatch as a validation error rather than an execution one.
|
|
889
|
+
def validate_scope_array!(scope)
|
|
890
|
+
template = scope.first
|
|
891
|
+
unless template.is_a?(String)
|
|
892
|
+
raise ValidationError, "scope[0] must be a String template (got #{template.class})"
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
raise ValidationError, 'scope template must not contain `;` (statement chaining)' if template.include?(';')
|
|
896
|
+
|
|
897
|
+
# Strip SQL comments + string literals BEFORE the keyword scan.
|
|
898
|
+
# Defense-in-depth: a payload like `id IN (/* x */ SELECT password
|
|
899
|
+
# FROM users)` would actually be rejected by the database parser
|
|
900
|
+
# too (SQL treats block comments as whitespace, so the SELECT is
|
|
901
|
+
# tokenised correctly there) — but stripping comments first lets
|
|
902
|
+
# the validator give a clear "forbidden SQL keywords" error
|
|
903
|
+
# instead of a confusing adapter-level syntax failure. It also
|
|
904
|
+
# neutralises `--` line comments and PostgreSQL dollar-quoted
|
|
905
|
+
# strings that could carry forbidden keywords past a naive scan.
|
|
906
|
+
# `SqlNoiseStripper` is the same module SqlValidator uses.
|
|
907
|
+
stripped = SqlNoiseStripper.strip_literals(SqlNoiseStripper.strip_comments(template))
|
|
908
|
+
if SCOPE_TEMPLATE_FORBIDDEN.match?(stripped)
|
|
909
|
+
raise ValidationError,
|
|
910
|
+
'scope template contains forbidden SQL keywords ' \
|
|
911
|
+
'(subqueries, UNION, time-based functions, DML/DDL are not allowed). ' \
|
|
912
|
+
'Use a parameterised comparison like `["col = ?", value]`.'
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
placeholder_count = template.scan('?').size
|
|
916
|
+
bind_count = scope.length - 1
|
|
917
|
+
return if placeholder_count == bind_count
|
|
918
|
+
|
|
919
|
+
raise ValidationError,
|
|
920
|
+
"scope template expects #{placeholder_count} bind(s), got #{bind_count}"
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
# Returns true if any key in the hash has a recognised predicate suffix.
|
|
924
|
+
#
|
|
925
|
+
# @param scope [Hash]
|
|
926
|
+
# @return [Boolean]
|
|
927
|
+
def predicate_suffix?(scope)
|
|
928
|
+
scope.any? { |k, _| ScopePredicateParser::SUFFIX_PATTERN.match?(k.to_s) }
|
|
929
|
+
end
|
|
930
|
+
|
|
308
931
|
# Apply column selection to a relation.
|
|
309
932
|
#
|
|
310
933
|
# @param relation [ActiveRecord::Relation] The relation
|
|
@@ -347,11 +970,35 @@ module Woods
|
|
|
347
970
|
Arel.sql("#{func}()")
|
|
348
971
|
end
|
|
349
972
|
|
|
350
|
-
# Return the database connection
|
|
973
|
+
# Return the database connection for the in-flight request.
|
|
974
|
+
#
|
|
975
|
+
# Resolution order:
|
|
976
|
+
# 1. The connection leased by SafeContext for the current execute
|
|
977
|
+
# block, published via `Thread.current[:woods_console_leased_connection]`.
|
|
978
|
+
# This is the normal path under RackMiddleware — every dispatch
|
|
979
|
+
# runs inside `SafeContext#execute`, which leases from
|
|
980
|
+
# `ActiveRecord::Base.connection_pool` once per request and keeps
|
|
981
|
+
# that connection on the calling thread until the rolled-back
|
|
982
|
+
# transaction completes.
|
|
983
|
+
# 2. The connection injected at construction time (test fixtures,
|
|
984
|
+
# bridge mode, anywhere SafeContext was built with `connection:`).
|
|
985
|
+
# 3. As a last resort, lease one from the writing pool. We do *not*
|
|
986
|
+
# return the leased connection out of its `with_connection` block
|
|
987
|
+
# (that would release it back to the pool while the caller still
|
|
988
|
+
# holds the reference); instead, when nothing else has leased a
|
|
989
|
+
# connection, fall through to `ActiveRecord::Base.lease_connection`
|
|
990
|
+
# if it exists (Rails 7.2+) or `connection` (Rails <= 7.1).
|
|
991
|
+
# Both paths keep the connection checked out for the calling
|
|
992
|
+
# thread, which is correct outside the SafeContext lease.
|
|
351
993
|
#
|
|
352
994
|
# @return [Object] Database connection
|
|
353
995
|
def active_connection
|
|
354
|
-
|
|
996
|
+
leased = Thread.current[SafeContext::LEASED_CONNECTION_KEY]
|
|
997
|
+
return leased if leased
|
|
998
|
+
return @connection if @connection
|
|
999
|
+
|
|
1000
|
+
pool = ActiveRecord::Base.connection_pool
|
|
1001
|
+
pool.respond_to?(:lease_connection) ? pool.lease_connection : ActiveRecord::Base.connection
|
|
355
1002
|
end
|
|
356
1003
|
|
|
357
1004
|
# Recursively convert all Hash keys to strings.
|