woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -5,22 +5,22 @@ require 'timeout'
5
5
  require_relative 'audit_logger'
6
6
  require_relative 'bridge_protocol'
7
7
  require_relative 'confirmation'
8
+ require_relative 'credential_scanner'
8
9
  require_relative 'eval_guard'
10
+ require_relative 'input_contract'
9
11
  require_relative 'model_validator'
10
12
  require_relative 'safe_context'
11
13
  require_relative 'scope_predicate_parser'
14
+ require_relative 'sql_validator'
12
15
  require_relative 'sql_noise_stripper'
13
16
  require_relative 'table_gate'
17
+ require_relative 'tool_specs'
14
18
 
15
19
  module Woods
16
20
  module Console
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).
20
- #
21
- # Implements the same `send_request(Hash) -> Hash` interface as
22
- # ConnectionManager, so all existing tool definitions in Server work
23
- # unchanged — just pass this where `conn_mgr` goes.
21
+ # Executes supported Console requests directly through ActiveRecord in a
22
+ # booted Rails process. Server registration limits callers to the subset
23
+ # this executor can run with the configured safety controls.
24
24
  #
25
25
  # @example
26
26
  # executor = EmbeddedExecutor.new(model_validator: validator, safe_context: ctx)
@@ -28,8 +28,6 @@ module Woods
28
28
  # # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
29
29
  #
30
30
  class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
31
- AGGREGATE_FUNCTIONS = %w[sum average minimum maximum count].freeze
32
-
33
31
  TIER1_TOOLS = BridgeProtocol::TIER1_TOOLS
34
32
 
35
33
  # Tools gated behind the read_tools_enabled flag.
@@ -37,11 +35,6 @@ module Woods
37
35
  # but require explicit opt-in for embedded mode.
38
36
  EMBEDDED_READ_TOOLS = %w[sql query].freeze
39
37
 
40
- MAX_SQL_LIMIT = 10_000
41
- MAX_QUERY_LIMIT = 10_000
42
-
43
- MIN_EVAL_TIMEOUT = 1
44
- MAX_EVAL_TIMEOUT = 30
45
38
  DEFAULT_EVAL_TIMEOUT = 10
46
39
 
47
40
  # @param model_validator [ModelValidator] Validates model/column names
@@ -97,8 +90,9 @@ module Woods
97
90
  refusal = refusal_for(tool)
98
91
  return refusal if refusal
99
92
 
93
+ normalize_params!(tool, params)
100
94
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
- result = @safe_context.execute { dispatch(tool, params) }
95
+ result = @safe_context.execute { with_mysql_quote_modes { dispatch(tool, params) } }
102
96
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
103
97
 
104
98
  { 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
@@ -130,12 +124,30 @@ module Woods
130
124
  return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
131
125
 
132
126
  Rails.logger.warn(
133
- "[Woods::Console] execution error: #{error.class}: #{error.message}"
127
+ "[Woods::Console] execution error: #{error.class}: #{scan_log_text(error.message)}"
134
128
  )
135
129
  rescue StandardError
136
130
  # Never let logging break the request path.
137
131
  end
138
132
 
133
+ # Layer 2 applied to server-side log text (L11). The client response for
134
+ # this branch is already sanitized down to the class name, but the log
135
+ # line carries the adapter's own message — and PG/Mysql2 errors embed the
136
+ # rejected SQL and, for constraint violations, the offending literal. A
137
+ # secret pasted into a WHERE clause therefore landed unscanned in the
138
+ # server log while the response path was scanned. Failure falls back to a
139
+ # sentinel rather than raw text, mirroring {AuditLogger#redact}.
140
+ def scan_log_text(text)
141
+ scanned, = error_log_scanner.scan(text.to_s)
142
+ scanned
143
+ rescue StandardError
144
+ '[REDACTION_FAILED]'
145
+ end
146
+
147
+ def error_log_scanner
148
+ @error_log_scanner ||= CredentialScanner.new
149
+ end
150
+
139
151
  # Return a pre-dispatch refusal hash for tools the executor cannot or
140
152
  # will not run, else nil to let dispatch proceed.
141
153
  #
@@ -158,20 +170,26 @@ module Woods
158
170
 
159
171
  # Self-describing error for tools the embedded executor cannot run.
160
172
  #
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.
173
+ # `sql`/`query` are gated behind `embedded_read_tools: true`. Everything
174
+ # else outside Tier 1 is unavailable through a supported server mode.
175
+ #
176
+ # The flag has two names depending on transport: `embedded_read_tools:`
177
+ # on `Woods::Console::RackMiddleware` (HTTP), or
178
+ # `config.console_embedded_read_tools` read by `exe/woods-console`
179
+ # (stdio). This executor runs under both, so the message names both —
180
+ # naming only one leaves an operator on the other transport with no
181
+ # idea what to set.
164
182
  #
165
183
  # @param tool [String] Tool name that was rejected
166
184
  # @return [String] Actionable error message
167
185
  def unsupported_message(tool)
168
186
  if EMBEDDED_READ_TOOLS.include?(tool)
169
187
  "Tool '#{tool}' requires embedded_read_tools: true on " \
170
- 'Woods::Console::RackMiddleware, or use the bridge (Option D). ' \
188
+ 'Woods::Console::RackMiddleware, or config.console_embedded_read_tools = true ' \
189
+ 'for the stdio server (exe/woods-console). ' \
171
190
  'See docs/CONSOLE_MCP_SETUP.md.'
172
191
  else
173
- "Tool '#{tool}' is not available in embedded mode it requires the " \
174
- 'bridge architecture (Option D in docs/CONSOLE_MCP_SETUP.md).'
192
+ "Tool '#{tool}' is not available in a supported Console MCP mode."
175
193
  end
176
194
  end
177
195
 
@@ -190,16 +208,12 @@ module Woods
190
208
  # @return [String] Multi-line actionable message.
191
209
  def eval_disabled_message
192
210
  <<~MSG.strip
193
- console_eval is disabled the unsafe-eval opt-in is off by default.
211
+ console_eval is not available in a supported Console MCP mode.
194
212
  Use console_query (model + select + joins/group_by/having/order) or console_sql
195
213
  for anything you were about to run. Both already support aggregates and scoping.
196
214
  If you believe eval is still necessary, SHOW your proposed Ruby snippet to the
197
215
  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.
216
+ WOODS_CONSOLE_UNSAFE_EVAL and the legacy collaborator options fail closed at boot.
203
217
  MSG
204
218
  end
205
219
 
@@ -251,19 +265,8 @@ module Woods
251
265
  raise
252
266
  end
253
267
 
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
268
  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)
269
+ raw || DEFAULT_EVAL_TIMEOUT
267
270
  end
268
271
 
269
272
  def guard_check!(code, audit_params)
@@ -396,6 +399,28 @@ module Woods
396
399
  end
397
400
  end
398
401
 
402
+ def normalize_params!(tool, params)
403
+ spec = Server::TOOL_SPECS.find { |candidate| candidate.name == "console_#{tool}" }
404
+ return unless spec
405
+
406
+ registered = Server::EXECUTABLE_MODES.values.any? { |names| names.include?(spec.name) }
407
+ if registered
408
+ spec.validate_arguments!(params)
409
+ else
410
+ # Unregistered specs (console_eval today) never run the full public
411
+ # JSON Schema here, so without this check a well-formed numeric
412
+ # string (e.g. `timeout: "15"`) sails straight into normalize!'s
413
+ # coercion below — silently accepting input the declared schema's
414
+ # `type: integer` would reject outright. This closes that gap
415
+ # without changing normalize!'s own messages for malformed/
416
+ # out-of-bounds values, which are asserted verbatim elsewhere.
417
+ InputContract.reject_string_typed_integers!(params, spec.properties)
418
+ end
419
+ InputContract.normalize!(params, spec.properties)
420
+ rescue InputContract::ValidationError => e
421
+ raise ValidationError, e.message
422
+ end
423
+
399
424
  # @param params [Hash] Must contain 'model' key
400
425
  # @raise [ValidationError]
401
426
  def validate_model!(params)
@@ -425,7 +450,7 @@ module Woods
425
450
  return unless @table_gate
426
451
 
427
452
  begin
428
- @table_gate.check_sql!(sql)
453
+ @table_gate.check_sql!(sql, dialect: sql_dialect, mysql_modes: mysql_quote_modes)
429
454
  rescue TableGateError => e
430
455
  raise ValidationError, e.message
431
456
  end
@@ -460,7 +485,7 @@ module Woods
460
485
  def handle_sample(params)
461
486
  validate_select_columns!(params)
462
487
  model = resolve_model(params['model'])
463
- limit = [params.fetch('limit', 5).to_i, 25].min
488
+ limit = params.fetch('limit', 5)
464
489
  scope = apply_scope(model, params['scope'], model_name: params['model'])
465
490
  scope = apply_columns(scope, params['columns'])
466
491
  records = scope.order(random_function).limit(limit)
@@ -468,20 +493,45 @@ module Woods
468
493
  end
469
494
 
470
495
  def handle_find(params)
496
+ validate_find_locator!(params)
497
+ if params['by']
498
+ by_columns = params['by'].keys.map(&:to_s)
499
+ @model_validator.validate_columns!(params['model'], by_columns)
500
+ by_columns.each { |column| refuse_protected_predicate_column!(column) }
501
+ end
502
+ validate_select_columns!(params)
471
503
  model = resolve_model(params['model'])
472
- record = if params['id']
473
- model.find_by(id: params['id'])
474
- elsif params['by']
475
- model.find_by(params['by'])
476
- end
504
+ record = params['id'] ? model.find_by(id: params['id']) : model.find_by(params['by'])
477
505
  { 'record' => record ? serialize_record(record, params['columns']) : nil }
478
506
  end
479
507
 
508
+ # Require exactly one non-empty locator: `id` or a non-empty `by` hash.
509
+ # `find_by({})` returns an arbitrary row when neither is supplied, and
510
+ # `find_by` with an empty `by` hash is indistinguishable from that:
511
+ # both must be refused before any query runs. Defense-in-depth against
512
+ # the public schema's `oneOf`/`minProperties` constraint (ToolSpec
513
+ # for console_find), for callers that reach this handler directly.
514
+ #
515
+ # @param params [Hash]
516
+ # @raise [ValidationError] when zero or both locator forms are present
517
+ def validate_find_locator!(params)
518
+ has_id = !params['id'].nil?
519
+ has_by = params['by'].is_a?(Hash) && params['by'].any?
520
+ return if has_id ^ has_by
521
+
522
+ raise ValidationError, 'console_find requires exactly one non-empty locator: id or by' unless has_id && has_by
523
+
524
+ raise ValidationError, 'console_find accepts only one locator at a time: id or by, not both'
525
+ end
526
+
480
527
  def handle_pluck(params)
481
528
  columns = params['columns']
529
+ raise ValidationError, 'columns must contain at least one item' if columns && columns.empty?
530
+
482
531
  @model_validator.validate_columns!(params['model'], columns) if columns
532
+ refuse_orphan_eav_value_selection!(Array(columns)) if columns
483
533
  model = resolve_model(params['model'])
484
- limit = [params.fetch('limit', 100).to_i, 1000].min
534
+ limit = params.fetch('limit', 100)
485
535
  scope = apply_scope(model, params['scope'], model_name: params['model'])
486
536
  scope = scope.distinct if params['distinct']
487
537
  values = scope.limit(limit).pluck(*columns.map(&:to_sym))
@@ -491,12 +541,16 @@ module Woods
491
541
  def handle_aggregate(params)
492
542
  column = params['column']
493
543
  function = params['function']
494
- @model_validator.validate_column!(params['model'], column) if column
544
+ if column
545
+ @model_validator.validate_column!(params['model'], column)
546
+ refuse_redacted_aggregate_expression!(column)
547
+ end
495
548
 
496
- unless AGGREGATE_FUNCTIONS.include?(function)
549
+ unless Server::AGGREGATE_FUNCTIONS.include?(function)
497
550
  raise ValidationError, "Invalid aggregate function: #{function}. " \
498
- "Allowed: #{AGGREGATE_FUNCTIONS.join(', ')}"
551
+ "Allowed: #{Server::AGGREGATE_FUNCTIONS.join(', ')}"
499
552
  end
553
+ raise ValidationError, "column is required for #{function} aggregate" if function != 'count' && column.nil?
500
554
 
501
555
  model = resolve_model(params['model'])
502
556
  scope = apply_scope(model, params['scope'], model_name: params['model'])
@@ -511,12 +565,10 @@ module Woods
511
565
 
512
566
  def handle_association_count(params)
513
567
  model = resolve_model(params['model'])
514
- record = model.find(params['id'])
515
568
  association_name = params['association']
569
+ reflection = model.reflect_on_association(association_name.to_sym)
516
570
 
517
- unless model.reflect_on_association(association_name.to_sym)
518
- raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}"
519
- end
571
+ raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}" unless reflection
520
572
 
521
573
  # Defense-in-depth: the parent model passed validate_model!'s
522
574
  # gate_model! check, but the association may target a different
@@ -525,11 +577,119 @@ module Woods
525
577
  # explicitly before reading any rows from it.
526
578
  gate_association!(params['model'], association_name)
527
579
 
580
+ # Validate every request-controlled scope column against the
581
+ # association's own model before any database I/O runs (not just
582
+ # before the association is read): `model.find` below is itself a
583
+ # query, and a request with a bad scope should never reach it.
584
+ validate_scope_columns!(params['scope'], reflection.klass.name) if params['scope']
585
+
586
+ record = model.find(params['id'])
528
587
  scope = record.public_send(association_name)
529
- scope = apply_scope(scope, params['scope'])
588
+ scope = apply_scope(scope, params['scope'], model_name: reflection.klass.name) if params['scope']
589
+ gate_association_sql!(scope)
530
590
  { 'count' => scope.count }
531
591
  end
532
592
 
593
+ # Defense-in-depth: gate_association! (called by
594
+ # {#handle_association_count} before this) only proves the
595
+ # association's OWN target table isn't blocked. A through-association,
596
+ # default_scope, or an applied scope can still render SQL that reaches
597
+ # a blocked table via a less-obvious join — mirrors handle_query's
598
+ # re-check of the final rendered SQL. Only renders `to_sql` when a
599
+ # gate is actually configured — it is otherwise unnecessary AR work on
600
+ # every request.
601
+ def gate_association_sql!(scope)
602
+ gate_sql!(scope.to_sql) if @table_gate
603
+ end
604
+
605
+ # Pure column-name validation for a scope Hash, no relation is built
606
+ # and no query runs. Reuses ScopePredicateParser's own suffix grammar
607
+ # so the two never drift.
608
+ #
609
+ # @param scope [Hash, nil]
610
+ # @param model_name [String]
611
+ # @raise [ValidationError] on an unknown column
612
+ def validate_scope_columns!(scope, model_name)
613
+ return unless scope.is_a?(Hash)
614
+
615
+ scope.each_key do |raw_key|
616
+ column = scope_key_column(raw_key)
617
+ @model_validator.validate_column!(model_name, column)
618
+ refuse_protected_predicate_column!(column)
619
+ end
620
+ end
621
+
622
+ # Strip a Ransack-style predicate suffix (e.g. `_matches`, `_gt`) from a
623
+ # scope Hash key, returning the bare column name underneath.
624
+ #
625
+ # @param raw_key [String, Symbol]
626
+ # @return [String]
627
+ def scope_key_column(raw_key)
628
+ key = raw_key.to_s
629
+ match = ScopePredicateParser::SUFFIX_PATTERN.match(key)
630
+ match ? key.delete_suffix(match[1]) : key
631
+ end
632
+
633
+ # Refuse a column that is configured as redacted
634
+ # (`console_redacted_columns`). {SafeContext#redact} only scrubs
635
+ # sensitive values from serialized *output* — accepting the same
636
+ # column as a scope/filter key, a find locator, an aggregate target,
637
+ # or an order_by column lets a caller read its plaintext value via a
638
+ # comparison, aggregate, or sort-order oracle before redaction ever
639
+ # runs.
640
+ #
641
+ # Matching is case-insensitive: unquoted SQL identifiers are
642
+ # case-insensitive, so a case variant of a redacted column (`AMOUNT`,
643
+ # `Password_Digest`) must get this typed refusal rather than fall
644
+ # through to an existence check.
645
+ #
646
+ # @param column [String] bare column name (no schema/table qualifier)
647
+ # @raise [ValidationError] if the column is on console_redacted_columns
648
+ def refuse_redacted_column!(column)
649
+ return unless @safe_context.redacted_columns.any? { |name| name.to_s.casecmp?(column.to_s) }
650
+
651
+ raise ValidationError,
652
+ "Rejected: column '#{column}' is redacted (console_redacted_columns) and cannot be used " \
653
+ 'as a scope, filter, aggregate, find, or order key.'
654
+ end
655
+
656
+ # Refuse a predicate column protected by either redaction layer. This
657
+ # is narrower than EAV alias/aggregate protection: the key column is
658
+ # the selector used to identify sensitive rows and remains a valid
659
+ # predicate, while the value column is the secret-bearing field.
660
+ #
661
+ # Both layers match case-insensitively (see {#refuse_redacted_column!}):
662
+ # a case variant of a protected column must keep the typed refusal.
663
+ #
664
+ # @param column [String, Symbol] already-normalized bare or qualified column name
665
+ # @raise [ValidationError] when the column is protected for predicates
666
+ def refuse_protected_predicate_column!(column)
667
+ base = base_column_name(column.to_s)
668
+ return refuse_redacted_column!(base) if @safe_context.redacted_columns.any? { |name| name.to_s.casecmp?(base) }
669
+ return unless redacted_eav_value_columns.any? { |name| name.to_s.casecmp?(base) }
670
+
671
+ raise ValidationError,
672
+ "Rejected: EAV value column '#{base}' is redacted (console_redacted_key_values) and cannot " \
673
+ 'be used as a scope, filter, or having predicate.'
674
+ end
675
+
676
+ # Refuse every column referenced by a scope Hash that is protected for
677
+ # predicates, honoring predicate suffixes (`email_matches`,
678
+ # `salary_gt`, etc.) the same way {#validate_scope_columns!} does.
679
+ #
680
+ # @param scope [Hash]
681
+ # @raise [ValidationError] on the first protected column found
682
+ def refuse_redacted_scope_keys!(scope)
683
+ scope.each_key { |raw_key| refuse_protected_predicate_column!(scope_key_column(raw_key)) }
684
+ end
685
+
686
+ # The value columns of every configured EAV redaction pair.
687
+ #
688
+ # @return [Array<String>]
689
+ def redacted_eav_value_columns
690
+ @safe_context.redacted_key_values.filter_map { |pattern| pattern['value_column'] }
691
+ end
692
+
533
693
  def gate_association!(model_name, association)
534
694
  return unless @table_gate && association
535
695
 
@@ -568,10 +728,13 @@ module Woods
568
728
  model = resolve_model(params['model'])
569
729
  order_by = params.fetch('order_by', 'created_at')
570
730
  direction = params.fetch('direction', 'desc')
571
- limit = [params.fetch('limit', 10).to_i, 50].min
731
+ limit = params.fetch('limit', 10)
572
732
 
573
733
  @model_validator.validate_column!(params['model'], order_by)
574
- direction = 'desc' unless %w[asc desc].include?(direction)
734
+ refuse_redacted_column!(order_by)
735
+ unless %w[asc desc].include?(direction)
736
+ raise ValidationError, "direction must be asc or desc (got #{direction.inspect})"
737
+ end
575
738
 
576
739
  scope = apply_scope(model, params['scope'], model_name: params['model'])
577
740
  scope = apply_columns(scope, params['columns'])
@@ -599,12 +762,23 @@ module Woods
599
762
  raise ValidationError, 'Missing required parameter: sql' unless sql
600
763
 
601
764
  require_relative 'sql_validator'
602
- SqlValidator.new.validate!(sql)
765
+ SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(sql)
766
+ validate_protected_sql_usage!(sql)
603
767
  # Post-validation, pre-execution TableGate — blocks every configured
604
768
  # table even if the sql is otherwise well-formed.
605
769
  gate_sql!(sql)
606
770
 
607
- limit = params['limit'] ? [params['limit'].to_i, MAX_SQL_LIMIT].min : nil
771
+ limit = params['limit']
772
+ # EXPLAIN's output is plan rows, not the query's own row set: wrapping
773
+ # it as `SELECT * FROM (EXPLAIN ...) AS _limited LIMIT n` is invalid
774
+ # SQL that fails as a generic adapter error. Reject the combination
775
+ # with a typed error instead of advertising a limit EXPLAIN can't honor.
776
+ if limit && explain_statement?(sql)
777
+ raise ValidationError, 'limit is not supported with EXPLAIN (EXPLAIN output is plan rows, ' \
778
+ 'not the query result set, so it cannot be wrapped and limited). ' \
779
+ 'Resubmit without limit.'
780
+ end
781
+
608
782
  query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
609
783
  result = active_connection.select_all(query_sql)
610
784
 
@@ -613,6 +787,56 @@ module Woods
613
787
  raise ValidationError, e.message
614
788
  end
615
789
 
790
+ # @param sql [String] Validated SQL (already passed SqlValidator)
791
+ # @return [Boolean] true when the statement starts with EXPLAIN
792
+ def explain_statement?(sql)
793
+ sql.strip.match?(/\AEXPLAIN\b/i)
794
+ end
795
+
796
+ # The SQL dialect the active connection will parse the statement
797
+ # under, for {SqlValidator}'s dialect-aware lock-clause check. MySQL
798
+ # and PostgreSQL quote/comment grammars differ (`\'` escapes, `#`
799
+ # comments); validating with the matching dialect accepts dialect-valid
800
+ # literals and still rejects every known bypass form. Unknown adapters
801
+ # return nil and get the conservative both-dialect union.
802
+ #
803
+ # @return [Symbol, nil]
804
+ def sql_dialect
805
+ adapter = active_connection.adapter_name.to_s.downcase
806
+ return :mysql if adapter.include?('mysql')
807
+ return :postgres if adapter.include?('postgre')
808
+
809
+ nil
810
+ end
811
+
812
+ # Keep session reads local to this request and execution context. Restore
813
+ # the previous cache for nested requests, including when dispatch raises.
814
+ def with_mysql_quote_modes
815
+ previous = Thread.current[:woods_console_mysql_quote_modes]
816
+ Thread.current[:woods_console_mysql_quote_modes] = {}
817
+ yield
818
+ ensure
819
+ Thread.current[:woods_console_mysql_quote_modes] = previous
820
+ end
821
+
822
+ # Read the executing session, not adapter defaults or a cached boot value.
823
+ # The request runs inside SafeContext on active_connection throughout.
824
+ def mysql_quote_modes
825
+ return {} unless sql_dialect == :mysql
826
+
827
+ connection = active_connection
828
+ cache = Thread.current[:woods_console_mysql_quote_modes]
829
+ return cache[connection] if cache&.key?(connection)
830
+
831
+ modes = connection.select_value('SELECT @@SESSION.sql_mode').to_s.upcase.split(',')
832
+ result = {
833
+ ansi_quotes: modes.include?('ANSI_QUOTES'),
834
+ no_backslash_escapes: modes.include?('NO_BACKSLASH_ESCAPES')
835
+ }
836
+ cache[connection] = result if cache
837
+ result
838
+ end
839
+
616
840
  # Build and execute a structured ActiveRecord query.
617
841
  #
618
842
  # @param params [Hash] Must contain 'model' and 'select'
@@ -638,23 +862,42 @@ module Woods
638
862
  # @param params [Hash] Query parameters (select, joins, scope, group_by, having, order, limit)
639
863
  # @return [ActiveRecord::Relation]
640
864
  def build_query_relation(model, params)
641
- relation = apply_query_clauses(model, params)
642
- limit = params['limit'] ? [params['limit'].to_i, MAX_QUERY_LIMIT].min : MAX_QUERY_LIMIT
865
+ clauses = validated_query_clauses(model, params)
866
+ relation = apply_query_clauses(model, params, clauses)
867
+ limit = params.fetch('limit', 10_000)
643
868
  relation.limit(limit)
644
869
  end
645
870
 
871
+ # Validate every structured query clause before any ActiveRecord
872
+ # relation method can run. This keeps redaction-oracle refusals typed
873
+ # and prevents malformed predicates from reaching Arel/adapter code.
874
+ #
875
+ # @param model [Class] ActiveRecord model class
876
+ # @param params [Hash]
877
+ # @return [Hash] normalized, validated query clauses
878
+ def validated_query_clauses(model, params)
879
+ model_name = params['model']
880
+ {
881
+ select: params['select'] ? validated_select(params['select'], model_name) : nil,
882
+ joins: validated_query_joins(model, params['joins']),
883
+ scope: params.key?('scope') ? validated_query_scope(params['scope'], model_name, model) : nil,
884
+ group_by: params['group_by']&.any? ? validated_columns(params['group_by'], model_name) : nil,
885
+ having: params['having'] ? validated_having(params['having'], model_name) : nil,
886
+ order: params['order'] ? validated_order(params['order'], model_name) : nil
887
+ }
888
+ end
889
+
890
+ def validated_query_joins(model, joins)
891
+ return nil unless joins&.any?
892
+
893
+ validate_joins!(model, joins)
894
+ joins.map(&:to_sym)
895
+ end
896
+
646
897
  # Optional aggregate-expression wrappers accepted inside a `select`.
647
898
  # Anything else must be a bare column name validated against the model.
648
899
  # Matching is case-insensitive; the trailing `AS alias` is optional and
649
900
  # the alias itself must be an identifier — it can't carry SQL.
650
- SAFE_SELECT_EXPR = /
651
- \A\s*
652
- (?:(SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(\*|\w+(?:\.\w+)?)\s*\)|(\w+(?:\.\w+)?))
653
- (?:\s+AS\s+(\w+))?
654
- \s*\z
655
- /ix
656
- private_constant :SAFE_SELECT_EXPR
657
-
658
901
  # Apply select/joins/scope/group/having/order clauses to a relation.
659
902
  #
660
903
  # Validates every user-supplied column/alias through the ModelValidator
@@ -666,42 +909,242 @@ module Woods
666
909
  #
667
910
  # @param model [Class] ActiveRecord model class
668
911
  # @param params [Hash]
912
+ # @param clauses [Hash] prevalidated query clauses
669
913
  # @return [ActiveRecord::Relation]
670
914
  # @raise [ValidationError] on unsafe column/expression input
671
- def apply_query_clauses(model, params) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
915
+ def apply_query_clauses(model, params, clauses)
672
916
  model_name = params['model']
673
917
  relation = model.all
674
918
 
675
- relation = relation.select(*validated_select(params['select'], model_name)) if params['select']
676
- relation = relation.joins(params['joins'].map(&:to_sym)) if params['joins']&.any?
677
- relation = apply_scope(relation, params['scope'], model_name: model_name)
678
- relation = relation.group(*validated_columns(params['group_by'], model_name)) if params['group_by']&.any?
679
- relation = relation.having(*validated_having(params['having'], model_name)) if params['having']
680
- relation = relation.order(validated_order(params['order'], model_name)) if params['order']
919
+ relation = relation.select(*clauses[:select]) if clauses[:select]
920
+ relation = relation.joins(clauses[:joins]) if clauses[:joins]
921
+ relation = apply_scope(relation, clauses[:scope], model_name: model_name) if params.key?('scope')
922
+ relation = relation.group(*clauses[:group_by]) if clauses[:group_by]
923
+ relation = relation.having(*clauses[:having]) if clauses[:having]
924
+ relation = relation.order(clauses[:order]) if clauses[:order]
681
925
  relation
682
926
  end
683
927
 
684
928
  # Normalize `select:` into an array of safe expressions. Each element
685
929
  # must be a column name (optionally qualified and/or aliased) or a
686
- # whitelisted aggregate call over a column.
930
+ # whitelisted aggregate call over a column. The list is then validated
931
+ # as a set: positional EAV redaction resolves key/value columns by
932
+ # header name and needs BOTH headers present, so a value column
933
+ # selected without its paired key column would return plaintext.
687
934
  #
688
935
  # @param select [String, Array<String>]
689
936
  # @param model_name [String]
690
937
  # @return [Array<String>]
691
938
  def validated_select(select, model_name)
692
- Array(select).flat_map { |s| s.to_s.split(',') }.map do |expr|
939
+ expressions = Array(select).map do |expr|
693
940
  validate_select_expression!(expr.strip, model_name)
694
941
  end
942
+ refuse_orphan_eav_value_selection!(expressions)
943
+ expressions
944
+ end
945
+
946
+ # Refuse an EAV value column whose paired key column is missing from
947
+ # the select set. The per-expression checks ({#validate_select_expression!})
948
+ # already refused aliases and aggregates over protected columns, so
949
+ # what reaches here unaliased can only be direct column selection —
950
+ # and the positional redactor masks the value cell only when the key
951
+ # column is in the same header. Requiring the key column keeps direct
952
+ # EAV reads working ({Woods::Console::Redactor} masks the value);
953
+ # refusing outright would break legitimate key/value reads.
954
+ #
955
+ # @param expressions [Array<String>] validated select expressions
956
+ # @raise [ValidationError] when a value column is selected without its key
957
+ def refuse_orphan_eav_value_selection!(expressions)
958
+ selected = directly_selected_columns(expressions)
959
+
960
+ @safe_context.redacted_key_values.each do |pattern|
961
+ next unless selected.include?(pattern['value_column'])
962
+ next if selected.include?(pattern['key_column'])
963
+
964
+ raise ValidationError,
965
+ "Rejected: selecting EAV value column '#{pattern['value_column']}' without its paired " \
966
+ "key column '#{pattern['key_column']}' bypasses redaction; select both columns so the " \
967
+ 'value can be masked.'
968
+ end
695
969
  end
696
970
 
971
+ # The bare, unaliased columns referenced by a validated select list.
972
+ # Aggregates and aliases cannot appear here — the per-expression
973
+ # validation refuses them over protected columns before this runs.
974
+ #
975
+ # @param expressions [Array<String>]
976
+ # @return [Array<String>]
977
+ def directly_selected_columns(expressions)
978
+ expressions.filter_map do |expr|
979
+ match = Server::SELECT_EXPRESSION_REGEXP.match(expr)
980
+ next unless match
981
+
982
+ fn_arg, bare_col, alias_name = match.captures[1..]
983
+ next if fn_arg || alias_name
984
+
985
+ base_column_name(bare_col)
986
+ end
987
+ end
988
+
989
+ # Validate one select expression against the model and the redaction
990
+ # configuration.
991
+ #
992
+ # Exactly four shapes are refused (redaction stays positional by
993
+ # output header — {Woods::Console::Redactor} masks by column name, so
994
+ # any construct that renames or shadows an output header would carry
995
+ # plaintext past it):
996
+ # 1. an `AS` alias over a `console_redacted_columns` column
997
+ # (`password_digest AS note` returns plaintext under the `note`
998
+ # header, which no redaction rule matches);
999
+ # 2. an aggregate over a `console_redacted_columns` column, aliased
1000
+ # or bare (`SUM(salary)` leaks the aggregate of a column whose
1001
+ # individual values are masked — {#handle_aggregate} refuses the
1002
+ # same column via {#refuse_redacted_column!});
1003
+ # 3. an `AS` alias over either column of a
1004
+ # `console_redacted_key_values` pair (the positional EAV rule
1005
+ # resolves key/value columns by header name, so renaming either
1006
+ # header silently disarms it);
1007
+ # 4. any `AS` alias whose NAME collides with a protected header
1008
+ # (`id AS value` duplicates the EAV value header; positional
1009
+ # index resolution could then mask the shadow instead of the
1010
+ # secret — the aliased source being harmless is exactly what
1011
+ # makes the shape dangerous).
1012
+ #
1013
+ # Direct, unaliased selection of a redacted column REMAINS ALLOWED:
1014
+ # the output header keeps the column's real name, and the positional
1015
+ # redactor masks the value. Aliasing a non-redacted column stays
1016
+ # allowed.
1017
+ #
1018
+ # @param expr [String] a single select expression
1019
+ # @param model_name [String]
1020
+ # @return [String] the expression, unchanged, once validated
1021
+ # @raise [ValidationError] on an unsafe expression or a refused shape
697
1022
  def validate_select_expression!(expr, model_name)
698
- match = SAFE_SELECT_EXPR.match(expr)
1023
+ match = Server::SELECT_EXPRESSION_REGEXP.match(expr)
699
1024
  raise ValidationError, "Rejected select expression: #{expr.inspect}" unless match
700
1025
 
701
- _fn, fn_arg, bare_col, _alias = match.captures
1026
+ refuse_redacted_select_shapes!(match.captures, model_name)
1027
+ expr
1028
+ end
1029
+
1030
+ # Column-existence check plus the three redaction-refusal shapes; see
1031
+ # {#validate_select_expression!} for the shape list.
1032
+ #
1033
+ # @param captures [Array] {Server::SELECT_EXPRESSION_REGEXP} captures
1034
+ # @param model_name [String]
1035
+ # @raise [ValidationError] on an unknown column or a refused shape
1036
+ def refuse_redacted_select_shapes!(captures, model_name)
1037
+ fn, fn_arg, bare_col, alias_name = captures
702
1038
  column = bare_col || fn_arg
703
1039
  validate_column_reference!(column, model_name) unless column == '*'
704
- expr
1040
+
1041
+ refuse_protected_alias_target!(alias_name) if alias_name
1042
+ refuse_redacted_select_alias!(bare_col, alias_name) if alias_name
1043
+ refuse_redacted_aggregate_expression!(fn_arg) if fn
1044
+ end
1045
+
1046
+ # Refuse an `AS` alias whose NAME collides with a protected output
1047
+ # header (shape 4 in {#validate_select_expression!}). The positional
1048
+ # redactor resolves masks by header name; an alias naming a redacted
1049
+ # or EAV column duplicates that header, and the duplicate can steal
1050
+ # the mask from the real column's cell. Case-insensitive: unquoted
1051
+ # SQL identifiers fold, so a case-variant alias lands on the same
1052
+ # output header.
1053
+ #
1054
+ # @param alias_name [String] the `AS` alias
1055
+ # @raise [ValidationError] when the alias names a protected header
1056
+ def refuse_protected_alias_target!(alias_name)
1057
+ return unless protected_column_name?(alias_name)
1058
+
1059
+ raise ValidationError,
1060
+ "Rejected: alias '#{alias_name}' names a protected output header; an alias must not " \
1061
+ 'collide with a redacted or EAV column name.'
1062
+ end
1063
+
1064
+ # @param name [String]
1065
+ # @return [Boolean] whether either redaction layer protects the name
1066
+ def protected_column_name?(name)
1067
+ casecmp_member?(@safe_context.redacted_columns, name) ||
1068
+ casecmp_member?(redacted_kv_columns, name)
1069
+ end
1070
+
1071
+ # Case-insensitive membership, matching unquoted SQL identifier
1072
+ # semantics (the predicate refusals already compare this way).
1073
+ #
1074
+ # @param list [Array<String>]
1075
+ # @param name [String]
1076
+ # @return [Boolean]
1077
+ def casecmp_member?(list, name)
1078
+ list.any? { |entry| entry.to_s.casecmp?(name.to_s) }
1079
+ end
1080
+
1081
+ # Refuse an `AS` alias over a column protected by either redaction
1082
+ # layer. A no-op when +alias_name+ is nil. See
1083
+ # {#validate_select_expression!} for why the alias is refused while
1084
+ # direct selection is not.
1085
+ #
1086
+ # @param column [String, nil] bare or qualified column reference
1087
+ # @param alias_name [String, nil] the `AS` alias
1088
+ # @raise [ValidationError] when the aliased column is protected
1089
+ def refuse_redacted_select_alias!(column, alias_name)
1090
+ return unless column
1091
+
1092
+ base = base_column_name(column)
1093
+ if casecmp_member?(@safe_context.redacted_columns, base)
1094
+ raise ValidationError,
1095
+ "Rejected: aliasing redacted column '#{base}' as '#{alias_name}' bypasses output " \
1096
+ 'redaction. Select it unaliased; the value is masked.'
1097
+ end
1098
+
1099
+ return unless casecmp_member?(redacted_kv_columns, base)
1100
+
1101
+ raise ValidationError,
1102
+ "Rejected: aliasing redacted key/value column '#{base}' as '#{alias_name}' bypasses " \
1103
+ 'EAV output redaction. Select it unaliased.'
1104
+ end
1105
+
1106
+ # Refuse an aggregate call over a column protected by either redaction
1107
+ # layer, mirroring {#handle_aggregate}'s {#refuse_redacted_column!} for
1108
+ # the aggregate expressions accepted inside `select`. The EAV check
1109
+ # covers both columns of every `console_redacted_key_values` pair: an
1110
+ # aggregate over the value column (e.g. `MAX(amount)` over the rows a
1111
+ # sensitive key selects) reads the redacted value itself, and the key
1112
+ # column is refused for symmetry with the alias rule.
1113
+ #
1114
+ # @param column [String, nil] aggregate argument (`*` is never redacted)
1115
+ # @raise [ValidationError] when the aggregated column is protected
1116
+ def refuse_redacted_aggregate_expression!(column)
1117
+ return if column.nil? || column == '*'
1118
+
1119
+ base = base_column_name(column)
1120
+ if casecmp_member?(@safe_context.redacted_columns, base)
1121
+ raise ValidationError,
1122
+ "Rejected: aggregating redacted column '#{base}' reads its value; it cannot be used " \
1123
+ 'as an aggregate input.'
1124
+ end
1125
+
1126
+ return unless casecmp_member?(redacted_kv_columns, base)
1127
+
1128
+ raise ValidationError,
1129
+ "Rejected: aggregating redacted key/value column '#{base}' reads its value; it cannot " \
1130
+ 'be used as an aggregate input.'
1131
+ end
1132
+
1133
+ # The key and value columns of every configured EAV redaction pair.
1134
+ #
1135
+ # @return [Array<String>]
1136
+ def redacted_kv_columns
1137
+ @safe_context.redacted_key_values
1138
+ .flat_map { |pattern| [pattern['key_column'], pattern['value_column']] }
1139
+ end
1140
+
1141
+ # Strip a `table.` qualifier, returning the bare column name that
1142
+ # redaction configuration is keyed on.
1143
+ #
1144
+ # @param column [String]
1145
+ # @return [String]
1146
+ def base_column_name(column)
1147
+ column.split('.').last
705
1148
  end
706
1149
 
707
1150
  # Validate group_by entries — bare columns only (no functions, no SQL).
@@ -713,6 +1156,7 @@ module Woods
713
1156
  Array(columns).flat_map { |c| c.to_s.split(',') }.map do |col|
714
1157
  col = col.strip
715
1158
  validate_column_reference!(col, model_name)
1159
+ refuse_protected_predicate_column!(col)
716
1160
  col
717
1161
  end
718
1162
  end
@@ -726,58 +1170,152 @@ module Woods
726
1170
  # Anything else is rejected — raw strings (e.g. `"1=1 UNION SELECT
727
1171
  # password_digest FROM users"`) used to flow straight through and
728
1172
  # enable SELECT-based exfiltration despite the SafeContext rollback.
729
- HAVING_AGG_TEMPLATE = /
730
- \A\s*
731
- (?:
732
- (?<col>\w+(?:\.\w+)?)
733
- |
734
- (?<agg>SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(?<arg>\*|\w+(?:\.\w+)?)\s*\)
735
- )
736
- \s*(?<op>=|!=|<>|<=|>=|<|>)\s*\?\s*\z
737
- /ix
738
- private_constant :HAVING_AGG_TEMPLATE
739
-
740
- def validated_having(having, model_name) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
1173
+ def validated_having(having, model_name)
741
1174
  case having
742
1175
  when Hash
743
1176
  raise ValidationError, 'having: empty hash' if having.empty?
744
1177
 
745
- having.each_key { |k| validate_column_reference!(k.to_s, model_name) }
1178
+ having.each_key do |k|
1179
+ validate_column_reference!(k.to_s, model_name)
1180
+ refuse_protected_predicate_column!(k)
1181
+ end
746
1182
  [having]
747
1183
  when Array
748
- raise ValidationError, 'having: array must be [sql_with_placeholders, *binds]' if having.empty?
1184
+ validated_having_array!(having, model_name)
1185
+ else
1186
+ raise ValidationError, "having: unsupported type #{having.class}"
1187
+ end
1188
+ end
1189
+
1190
+ # Validate the `[template, bind]` array form of `having:`.
1191
+ #
1192
+ # @param having [Array] `[template, bind]`
1193
+ # @param model_name [String]
1194
+ # @return [Array] `having`, unchanged, once validated
1195
+ def validated_having_array!(having, model_name)
1196
+ unless having.length == 2 && having.first.is_a?(String)
1197
+ raise ValidationError, 'having must contain exactly one template and one bind value'
1198
+ end
1199
+
1200
+ template = having.first
1201
+ match = Server::HAVING_TEMPLATE_REGEXP.match(template)
1202
+ raise ValidationError, "having: unsupported SQL template #{template.inspect}" unless match
1203
+
1204
+ # Validate any referenced columns through ModelValidator so
1205
+ # aggregate args can't reach the db without a column check.
1206
+ col = match[1] || match[3]
1207
+ validate_column_reference!(col, model_name) if col && col != '*'
1208
+ refuse_protected_having_reference!(match)
1209
+ validate_having_bind!(having.last)
1210
+
1211
+ having
1212
+ end
1213
+
1214
+ # Redaction oracle refusal for a HAVING template. A predicate over a
1215
+ # protected column leaks its value through repeated guesses — the
1216
+ # response reveals whether any row satisfied the comparison.
1217
+ # Aggregates are refused over both redaction layers (the EAV pair
1218
+ # columns, matching {#refuse_redacted_aggregate_expression!} for
1219
+ # select); bare-column predicates are refused over protected
1220
+ # predicate columns: console_redacted_columns and EAV value columns.
1221
+ #
1222
+ # @param match [MatchData] {Server::HAVING_TEMPLATE_REGEXP} match
1223
+ # @raise [ValidationError] when the referenced column is protected
1224
+ def refuse_protected_having_reference!(match)
1225
+ return refuse_protected_predicate_column!(match[1]) if match[1]
1226
+ return if match[3].nil? || match[3] == '*'
1227
+
1228
+ refuse_redacted_aggregate_expression!(match[3])
1229
+ end
749
1230
 
750
- template = having.first.to_s
751
- match = HAVING_AGG_TEMPLATE.match(template)
752
- raise ValidationError, "having: unsupported SQL template #{template.inspect}" unless match
1231
+ # Defense-in-depth: the public schema already restricts the bind to a
1232
+ # scalar JSON type (see tool_specs.rb), but a container (Hash/Array)
1233
+ # bind that reaches AR's `?` placeholder fails as a generic adapter
1234
+ # error, not a typed one: reject it here too, mirroring
1235
+ # apply_query_scope's bind check.
1236
+ def validate_having_bind!(bind)
1237
+ case bind
1238
+ when String, Numeric, true, false, nil
1239
+ nil
1240
+ else
1241
+ raise ValidationError, 'having bind must be a string, number, boolean, or null'
1242
+ end
1243
+ end
753
1244
 
754
- # Validate any referenced columns through ModelValidator so
755
- # aggregate args can't reach the db without a column check.
756
- col = match[:col] || match[:arg]
757
- validate_column_reference!(col, model_name) if col && col != '*'
1245
+ # Apply the public console_query scope contract. Query arrays are
1246
+ # intentionally narrower than the legacy Tier 1 executor form: exactly
1247
+ # one safe column comparison template and one bind value.
1248
+ def apply_query_scope(relation, scope, model_name)
1249
+ apply_scope(relation, validated_query_scope(scope, model_name, nil), model_name: model_name)
1250
+ end
758
1251
 
759
- having
1252
+ # Validate a console_query scope. +model+ is the already-resolved
1253
+ # ActiveRecord class: its own table name lets a `table.column`
1254
+ # placeholder scope validate the column against the model's own
1255
+ # columns (public/executor parity) even when the ModelValidator
1256
+ # carries no table_names mapping. A nil +model+ falls back to the
1257
+ # strict qualified-reference resolution.
1258
+ def validated_query_scope(scope, model_name, model)
1259
+ if scope.is_a?(Hash)
1260
+ validate_scope_columns!(scope, model_name)
1261
+ return scope
1262
+ end
1263
+
1264
+ unless scope.is_a?(Array) && scope.length == 2 && scope.first.is_a?(String)
1265
+ raise ValidationError, 'scope must be an object or exact ["column OP ?", bind] array'
1266
+ end
1267
+
1268
+ case scope.last
1269
+ when String, Numeric, true, false, nil
1270
+ nil
760
1271
  else
761
- raise ValidationError, "having: unsupported type #{having.class}"
1272
+ raise ValidationError, 'scope bind must be a string, number, boolean, or null'
762
1273
  end
1274
+
1275
+ match = Server::QUERY_SCOPE_TEMPLATE_REGEXP.match(scope.first)
1276
+ raise ValidationError, "scope: unsupported SQL template #{scope.first.inspect}" unless match
1277
+
1278
+ # Redaction refusal runs BEFORE column resolution: a redacted column
1279
+ # referenced through any case variant (`Users.Password_Digest = ?`)
1280
+ # must get the typed redaction refusal, never a pass-through to the
1281
+ # existence check (whose case-sensitive column lookup would report a
1282
+ # generic "Unknown column" instead).
1283
+ refuse_protected_predicate_column!(match[1])
1284
+ validate_column_reference!(match[1], model_name, own_table: own_table_name(model))
1285
+ scope
1286
+ end
1287
+
1288
+ # The queried model's own table name, when resolvable. Test doubles
1289
+ # may not implement `table_name`; a nil result keeps the strict
1290
+ # qualified-reference resolution instead of the own-table shortcut.
1291
+ #
1292
+ # @param model [Class, nil]
1293
+ # @return [String, nil]
1294
+ def own_table_name(model)
1295
+ return nil unless model.respond_to?(:table_name)
1296
+
1297
+ model.table_name.to_s
763
1298
  end
764
1299
 
765
1300
  # Validate `order:` — only Hash `{col => :asc|:desc}` or bare column name.
766
1301
  def validated_order(order, model_name)
767
1302
  case order
768
1303
  when Hash
769
- order.each_key { |k| validate_column_reference!(k.to_s, model_name) }
1304
+ order.each_key do |key|
1305
+ validate_column_reference!(key.to_s, model_name)
1306
+ refuse_protected_predicate_column!(key)
1307
+ end
770
1308
  order.transform_values do |dir|
771
- dir_sym = dir.to_s.downcase.to_sym
772
- unless %i[asc desc].include?(dir_sym)
1309
+ unless dir.to_s.match?(Server::ORDER_DIRECTION_REGEXP)
773
1310
  raise ValidationError, "order direction must be :asc or :desc (got #{dir.inspect})"
774
1311
  end
775
1312
 
776
- dir_sym
1313
+ dir.to_s.downcase.to_sym
777
1314
  end
778
1315
  when String, Symbol
779
1316
  col = order.to_s.strip
780
1317
  validate_column_reference!(col, model_name)
1318
+ refuse_protected_predicate_column!(col)
781
1319
  col
782
1320
  else
783
1321
  raise ValidationError, "order: unsupported type #{order.class}"
@@ -791,19 +1329,23 @@ module Woods
791
1329
  # blocked-table reference into `select`/`order`/`having` via a
792
1330
  # qualified column like `users.password_digest`. Bare columns validate
793
1331
  # against the active model through ModelValidator.
794
- def validate_column_reference!(column, model_name)
1332
+ #
1333
+ # +own_table+ is the queried model's own table name, supplied by the
1334
+ # console_query scope path: a `table.column` reference whose table is
1335
+ # the model's own table validates the column against that same model,
1336
+ # exactly like the bare form. This cannot smuggle a foreign table
1337
+ # (the qualifier is checked for equality, not looked up), so redaction
1338
+ # and TableGate still apply to the reference as a whole. Any other
1339
+ # qualified reference keeps the strict cross-table resolution through
1340
+ # {ModelValidator#validate_table_column!}, which fails closed when the
1341
+ # table side cannot be proven.
1342
+ #
1343
+ # @param column [String] bare or `table.column` reference
1344
+ # @param model_name [String]
1345
+ # @param own_table [String, nil] the queried model's own table name
1346
+ def validate_column_reference!(column, model_name, own_table: nil)
795
1347
  if column.include?('.')
796
- table, col = column.split('.', 2)
797
- unless safe_identifier?(table) && safe_identifier?(col)
798
- raise ValidationError, "Rejected column reference: #{column.inspect}"
799
- end
800
-
801
- # Gate the table side through TableGate if one is configured.
802
- begin
803
- @table_gate&.check_table!(table)
804
- rescue TableGateError => e
805
- raise ValidationError, e.message
806
- end
1348
+ validate_qualified_column_reference!(column, model_name, own_table)
807
1349
  else
808
1350
  raise ValidationError, "Rejected column reference: #{column.inspect}" unless safe_identifier?(column)
809
1351
 
@@ -811,8 +1353,55 @@ module Woods
811
1353
  end
812
1354
  end
813
1355
 
1356
+ # The qualified `table.column` half of {#validate_column_reference!}.
1357
+ # The table half must be a safe identifier and unblocked (TableGate)
1358
+ # before column ownership is resolved: +own_table+ (the queried
1359
+ # model's own table name) short-circuits resolution to that model's
1360
+ # own columns, exactly like the bare form; the qualifier is compared
1361
+ # case-insensitively (unquoted SQL identifiers are case-insensitive)
1362
+ # against that one table name, never looked up, so this cannot
1363
+ # smuggle a foreign table and redaction still applies to the
1364
+ # reference as a whole. The column half keeps ModelValidator's exact
1365
+ # existence check, identical to the bare-column form. Anything else
1366
+ # resolves through {ModelValidator#validate_table_column!}, which
1367
+ # fails closed when the table side cannot be proven.
1368
+ #
1369
+ # @param column [String] a `table.column` reference
1370
+ # @param model_name [String]
1371
+ # @param own_table [String, nil] the queried model's own table name
1372
+ def validate_qualified_column_reference!(column, model_name, own_table)
1373
+ table, col = column.split('.', 2)
1374
+ unless safe_identifier?(table) && safe_identifier?(col)
1375
+ raise ValidationError, "Rejected column reference: #{column.inspect}"
1376
+ end
1377
+
1378
+ # Gate the table side through TableGate if one is configured.
1379
+ begin
1380
+ @table_gate&.check_table!(table)
1381
+ rescue TableGateError => e
1382
+ raise ValidationError, e.message
1383
+ end
1384
+
1385
+ if own_table && table.casecmp?(own_table)
1386
+ # TableGate only proves `table` isn't *blocked* — it says nothing
1387
+ # about whether `col` actually exists there. Validate ownership
1388
+ # against the real schema before this reference can reach SQL.
1389
+ @model_validator.validate_column!(model_name, col)
1390
+ else
1391
+ @model_validator.validate_table_column!(table, col)
1392
+ end
1393
+ end
1394
+
814
1395
  def safe_identifier?(name)
815
- name.is_a?(String) && name.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
1396
+ name.is_a?(String) && name.match?(Server::SAFE_IDENTIFIER_REGEXP)
1397
+ end
1398
+
1399
+ def validate_joins!(model, joins)
1400
+ joins.each do |association|
1401
+ next if model.reflect_on_association(association.to_sym)
1402
+
1403
+ raise ValidationError, "Unknown association '#{association}' on #{model.name}"
1404
+ end
816
1405
  end
817
1406
 
818
1407
  # ── Helpers ──────────────────────────────────────────────────────────
@@ -820,24 +1409,24 @@ module Woods
820
1409
  # Apply scope conditions (WHERE clauses) to a relation.
821
1410
  #
822
1411
  # Accepts Hash form for equality or Ransack-style predicate suffixes
823
- # (e.g., `{total_refund_gt: 0, status_in: ['paid','refunded']}`), or
824
- # Array form for parameterized SQL (e.g., JSON column queries like
825
- # ["preferences->>'theme' = ?", "dark"]).
1412
+ # (e.g., `{total_refund_gt: 0, status_in: ['paid','refunded']}`). The
1413
+ # array branch is used only after console_query's narrower contract has
1414
+ # validated an exact `["column OP ?", bind]` pair.
826
1415
  #
827
- # When `model_name` is supplied and the Hash contains at least one key
828
- # with a recognised predicate suffix, the ScopePredicateParser builds
829
- # safe Arel nodes. Plain equality hashes skip the parser entirely.
1416
+ # When `model_name` is supplied, ScopePredicateParser validates every
1417
+ # equality and predicate column before applying the scope.
830
1418
  #
831
1419
  # @param relation [ActiveRecord::Relation, Class] Model or relation
832
1420
  # @param scope [Hash, Array, nil] Filter conditions
833
- # @param model_name [String, nil] Model name for column validation (predicate path only)
1421
+ # @param model_name [String, nil] Model name for Hash key validation
834
1422
  # @return [ActiveRecord::Relation]
835
1423
  def apply_scope(relation, scope, model_name: nil)
836
1424
  case scope
837
1425
  when Hash
838
1426
  return relation unless scope.any?
839
1427
 
840
- if model_name && predicate_suffix?(scope)
1428
+ refuse_redacted_scope_keys!(scope)
1429
+ if model_name
841
1430
  parser = ScopePredicateParser.new(model_name: model_name, model_validator: @model_validator)
842
1431
  parser.parse(relation, scope)
843
1432
  else
@@ -846,14 +1435,8 @@ module Woods
846
1435
  when Array
847
1436
  return relation unless scope.any?
848
1437
 
849
- # Array form is `[template, *binds]`. The previous implementation
850
- # splatted directly into `where(*scope)`, which is the
851
- # `where(raw_sql_string)` arity — unbounded SQL injection. A
852
- # caller could pass `["EXISTS (SELECT 1 FROM users WHERE
853
- # password_digest LIKE 'a%')"]` and turn `console_count` /
854
- # `console_pluck` into a boolean exfiltration oracle against
855
- # any table the DB user can read (TableGate doesn't fire on
856
- # the rendered Tier-1 SQL). Validate the template now.
1438
+ # Keep defense-in-depth validation here even though the registered
1439
+ # query schema and apply_query_scope enforce a narrower array form.
857
1440
  validate_scope_array!(scope)
858
1441
  relation.where(*scope)
859
1442
  else
@@ -918,18 +1501,12 @@ module Woods
918
1501
 
919
1502
  placeholder_count = template.scan('?').size
920
1503
  bind_count = scope.length - 1
921
- return if placeholder_count == bind_count
922
-
923
- raise ValidationError,
924
- "scope template expects #{placeholder_count} bind(s), got #{bind_count}"
925
- end
1504
+ unless placeholder_count == bind_count
1505
+ raise ValidationError,
1506
+ "scope template expects #{placeholder_count} bind(s), got #{bind_count}"
1507
+ end
926
1508
 
927
- # Returns true if any key in the hash has a recognised predicate suffix.
928
- #
929
- # @param scope [Hash]
930
- # @return [Boolean]
931
- def predicate_suffix?(scope)
932
- scope.any? { |k, _| ScopePredicateParser::SUFFIX_PATTERN.match?(k.to_s) }
1509
+ refuse_protected_predicate_references!(template)
933
1510
  end
934
1511
 
935
1512
  # Validate that any requested +columns+ are real model columns before
@@ -944,6 +1521,86 @@ module Woods
944
1521
  return unless params['columns']
945
1522
 
946
1523
  @model_validator.validate_columns!(params['model'], params['columns'])
1524
+ refuse_orphan_eav_value_selection!(params['columns'])
1525
+ end
1526
+
1527
+ # Raw SQL preserves redaction identity only for direct, unaliased
1528
+ # protected columns in the outer SELECT list. Aliases, aggregates,
1529
+ # predicates, CTEs, and other result shapes can rename a protected
1530
+ # value or turn it into an oracle, so they fail closed before execution.
1531
+ def validate_protected_sql_usage!(sql)
1532
+ protected = (@safe_context.redacted_columns + redacted_kv_columns).uniq
1533
+ referenced = protected.select { |column| sql_identifier_referenced?(sql, column) }
1534
+ return if referenced.empty?
1535
+
1536
+ stripped = SqlNoiseStripper.strip_noise(sql, dialect: sql_dialect || :postgres, **mysql_quote_modes)
1537
+ expressions, tail = protected_sql_projection(stripped)
1538
+ selected = expressions.filter_map { |expression| direct_sql_column_name(expression) }
1539
+ unsafe = unsafe_protected_sql_column(referenced, expressions, selected, tail)
1540
+ return unless unsafe
1541
+
1542
+ raise ValidationError,
1543
+ "Rejected: console_sql uses protected column '#{unsafe}' in an alias, aggregate, predicate, or " \
1544
+ 'unpaired EAV shape that cannot preserve redaction identity. Select protected columns directly ' \
1545
+ 'and unaliased, or use a structured Console tool.'
1546
+ end
1547
+
1548
+ def unsafe_protected_sql_column(referenced, expressions, selected, tail)
1549
+ referenced.find do |column|
1550
+ unsafe_protected_sql_reference?(column, expressions, selected, tail)
1551
+ end || orphan_eav_sql_value(selected)
1552
+ end
1553
+
1554
+ def protected_sql_projection(stripped)
1555
+ match = /\ASELECT\s+(.*?)\s+FROM\b/im.match(stripped)
1556
+ return [[], stripped] unless match
1557
+
1558
+ [sql_projection_expressions(match[1]), stripped[match.end(1)..]]
1559
+ end
1560
+
1561
+ def unsafe_protected_sql_reference?(column, expressions, selected, tail)
1562
+ unsafe_projection = expressions.any? do |expression|
1563
+ sql_identifier_referenced?(expression, column) && direct_sql_column_name(expression) != column
1564
+ end
1565
+ unsafe_tail = protected_sql_predicate_column?(column) && sql_identifier_referenced?(tail, column)
1566
+ !selected.include?(column) || unsafe_projection || unsafe_tail
1567
+ end
1568
+
1569
+ def protected_sql_predicate_column?(column)
1570
+ @safe_context.redacted_columns.include?(column) || redacted_eav_value_columns.include?(column)
1571
+ end
1572
+
1573
+ def orphan_eav_sql_value(selected)
1574
+ pattern = @safe_context.redacted_key_values.find do |candidate|
1575
+ selected.include?(candidate['value_column']) && !selected.include?(candidate['key_column'])
1576
+ end
1577
+ pattern&.fetch('value_column')
1578
+ end
1579
+
1580
+ def sql_projection_expressions(projection)
1581
+ projection.split(',').map(&:strip)
1582
+ end
1583
+
1584
+ def direct_sql_column_name(expression)
1585
+ identifier = /(?:[A-Za-z_]\w*|"(?:""|[^"])+"|`(?:``|[^`])+`)/
1586
+ match = /\A(?:#{identifier}\.)?(#{identifier})\z/.match(expression)
1587
+ return unless match
1588
+
1589
+ match[1].sub(/\A["`]/, '').sub(/["`]\z/, '').gsub('""', '"').gsub('``', '`')
1590
+ end
1591
+
1592
+ # Defense-in-depth for legacy Tier 1 scope arrays with more than one
1593
+ # bind. The public schema currently admits only Hash scopes, but direct
1594
+ # bridge callers still reach this validator.
1595
+ def refuse_protected_predicate_references!(template)
1596
+ protected = (@safe_context.redacted_columns + redacted_eav_value_columns).uniq
1597
+ referenced = protected.find { |column| sql_identifier_referenced?(template, column) }
1598
+ refuse_protected_predicate_column!(referenced) if referenced
1599
+ end
1600
+
1601
+ def sql_identifier_referenced?(sql, column)
1602
+ stripped = SqlNoiseStripper.strip_noise(sql, dialect: sql_dialect || :postgres, **mysql_quote_modes)
1603
+ stripped.match?(/(?<![A-Za-z0-9_$])#{Regexp.escape(column)}(?![A-Za-z0-9_$])/i)
947
1604
  end
948
1605
 
949
1606
  # Apply column selection to a relation.