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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +169 -0
  3. data/README.md +20 -8
  4. data/exe/woods-console +51 -6
  5. data/exe/woods-console-mcp +24 -4
  6. data/exe/woods-mcp +30 -7
  7. data/exe/woods-mcp-http +47 -6
  8. data/lib/generators/woods/install_generator.rb +13 -4
  9. data/lib/generators/woods/templates/woods.rb.tt +155 -0
  10. data/lib/tasks/woods.rake +15 -50
  11. data/lib/woods/builder.rb +174 -9
  12. data/lib/woods/cache/cache_middleware.rb +360 -31
  13. data/lib/woods/chunking/semantic_chunker.rb +334 -7
  14. data/lib/woods/console/adapters/job_adapter.rb +10 -4
  15. data/lib/woods/console/audit_logger.rb +76 -4
  16. data/lib/woods/console/bridge.rb +48 -15
  17. data/lib/woods/console/bridge_protocol.rb +44 -0
  18. data/lib/woods/console/confirmation.rb +3 -4
  19. data/lib/woods/console/console_response_renderer.rb +56 -18
  20. data/lib/woods/console/credential_index.rb +201 -0
  21. data/lib/woods/console/credential_scanner.rb +302 -0
  22. data/lib/woods/console/dispatch_pipeline.rb +138 -0
  23. data/lib/woods/console/embedded_executor.rb +682 -35
  24. data/lib/woods/console/eval_guard.rb +319 -0
  25. data/lib/woods/console/model_validator.rb +1 -3
  26. data/lib/woods/console/rack_middleware.rb +185 -29
  27. data/lib/woods/console/redactor.rb +161 -0
  28. data/lib/woods/console/response_context.rb +127 -0
  29. data/lib/woods/console/safe_context.rb +220 -23
  30. data/lib/woods/console/scope_predicate_parser.rb +131 -0
  31. data/lib/woods/console/server.rb +417 -486
  32. data/lib/woods/console/sql_noise_stripper.rb +87 -0
  33. data/lib/woods/console/sql_table_scanner.rb +213 -0
  34. data/lib/woods/console/sql_validator.rb +81 -31
  35. data/lib/woods/console/table_gate.rb +93 -0
  36. data/lib/woods/console/tool_specs.rb +552 -0
  37. data/lib/woods/console/tools/tier1.rb +3 -3
  38. data/lib/woods/console/tools/tier4.rb +7 -1
  39. data/lib/woods/dependency_graph.rb +66 -7
  40. data/lib/woods/embedding/indexer.rb +190 -6
  41. data/lib/woods/embedding/openai.rb +40 -4
  42. data/lib/woods/embedding/provider.rb +104 -8
  43. data/lib/woods/embedding/text_preparer.rb +23 -3
  44. data/lib/woods/embedding/token_counter.rb +133 -0
  45. data/lib/woods/evaluation/baseline_runner.rb +20 -2
  46. data/lib/woods/evaluation/metrics.rb +4 -1
  47. data/lib/woods/extracted_unit.rb +1 -0
  48. data/lib/woods/extractor.rb +7 -1
  49. data/lib/woods/extractors/controller_extractor.rb +6 -0
  50. data/lib/woods/extractors/mailer_extractor.rb +16 -2
  51. data/lib/woods/extractors/model_extractor.rb +6 -1
  52. data/lib/woods/extractors/phlex_extractor.rb +13 -4
  53. data/lib/woods/extractors/rails_source_extractor.rb +2 -0
  54. data/lib/woods/extractors/route_helper_resolver.rb +130 -0
  55. data/lib/woods/extractors/shared_dependency_scanner.rb +130 -2
  56. data/lib/woods/extractors/view_component_extractor.rb +12 -1
  57. data/lib/woods/extractors/view_engines/base.rb +141 -0
  58. data/lib/woods/extractors/view_engines/erb.rb +145 -0
  59. data/lib/woods/extractors/view_template_extractor.rb +92 -133
  60. data/lib/woods/flow_assembler.rb +23 -15
  61. data/lib/woods/flow_precomputer.rb +21 -2
  62. data/lib/woods/graph_analyzer.rb +3 -4
  63. data/lib/woods/index_artifact.rb +173 -0
  64. data/lib/woods/mcp/bearer_auth.rb +45 -0
  65. data/lib/woods/mcp/bootstrap_state.rb +94 -0
  66. data/lib/woods/mcp/bootstrapper.rb +337 -16
  67. data/lib/woods/mcp/config_resolver.rb +288 -0
  68. data/lib/woods/mcp/errors.rb +134 -0
  69. data/lib/woods/mcp/index_reader.rb +265 -30
  70. data/lib/woods/mcp/origin_guard.rb +132 -0
  71. data/lib/woods/mcp/provider_probe.rb +166 -0
  72. data/lib/woods/mcp/renderers/claude_renderer.rb +6 -0
  73. data/lib/woods/mcp/renderers/markdown_renderer.rb +39 -3
  74. data/lib/woods/mcp/renderers/plain_renderer.rb +16 -2
  75. data/lib/woods/mcp/server.rb +737 -137
  76. data/lib/woods/model_name_cache.rb +78 -2
  77. data/lib/woods/notion/client.rb +25 -2
  78. data/lib/woods/notion/mappers/model_mapper.rb +36 -2
  79. data/lib/woods/railtie.rb +55 -15
  80. data/lib/woods/resilience/circuit_breaker.rb +9 -2
  81. data/lib/woods/resilience/retryable_provider.rb +40 -3
  82. data/lib/woods/resolved_config.rb +299 -0
  83. data/lib/woods/retrieval/context_assembler.rb +112 -5
  84. data/lib/woods/retrieval/query_classifier.rb +1 -1
  85. data/lib/woods/retrieval/ranker.rb +55 -6
  86. data/lib/woods/retrieval/search_executor.rb +42 -13
  87. data/lib/woods/retriever.rb +330 -24
  88. data/lib/woods/session_tracer/middleware.rb +35 -1
  89. data/lib/woods/storage/graph_store.rb +39 -0
  90. data/lib/woods/storage/inapplicable_backend.rb +14 -0
  91. data/lib/woods/storage/metadata_store.rb +129 -1
  92. data/lib/woods/storage/pgvector.rb +70 -8
  93. data/lib/woods/storage/qdrant.rb +196 -5
  94. data/lib/woods/storage/snapshotter/metadata.rb +172 -0
  95. data/lib/woods/storage/snapshotter/vector.rb +238 -0
  96. data/lib/woods/storage/snapshotter.rb +24 -0
  97. data/lib/woods/storage/vector_store.rb +184 -35
  98. data/lib/woods/tasks.rb +85 -0
  99. data/lib/woods/temporal/snapshot_store.rb +49 -1
  100. data/lib/woods/token_utils.rb +44 -5
  101. data/lib/woods/unblocked/client.rb +1 -1
  102. data/lib/woods/unblocked/document_builder.rb +35 -10
  103. data/lib/woods/unblocked/exporter.rb +1 -1
  104. data/lib/woods/util/host_guard.rb +61 -0
  105. data/lib/woods/version.rb +1 -1
  106. data/lib/woods.rb +126 -6
  107. metadata +69 -4
@@ -1,13 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'bridge'
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 + Bridge that executes
10
- # queries directly via ActiveRecord instead of a separate bridge process.
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 = Bridge::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
- def initialize(model_validator:, safe_context:, connection: nil, read_tools_enabled: false)
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
- unless TIER1_TOOLS.include?(tool) || (@read_tools_enabled && EMBEDDED_READ_TOOLS.include?(tool))
60
- return { 'ok' => false,
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
- { 'ok' => false, 'error' => e.message, 'error_type' => 'execution' }
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
- { 'value' => scope.send(function.to_sym, column.to_sym) }
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
- result = active_connection.select_all(relation.to_sql)
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.all, params)
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
- # @param relation [ActiveRecord::Relation]
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
- def apply_query_clauses(relation, params) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
277
- relation = relation.select(params['select']) if params['select']
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 simple equality conditions, or Array form
291
- # for parameterized SQL (e.g., JSON column queries like
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? ? relation.where(scope) : relation
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? ? relation.where(*scope) : relation
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 (injected or from ActiveRecord).
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
- @connection || ActiveRecord::Base.connection
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.