woods 1.6.4 → 2.0.0.beta1

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 (282) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1879 -37
  3. data/CONTRIBUTING.md +195 -137
  4. data/README.md +162 -520
  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 +620 -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 +415 -0
  19. data/docs/INTERNALS.md +415 -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 +197 -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 +39 -3
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +78 -24
  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 +40 -1
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +18 -13
  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/credential_index.rb +5 -53
  64. data/lib/woods/console/credential_scanner.rb +15 -16
  65. data/lib/woods/console/dispatch_pipeline.rb +46 -34
  66. data/lib/woods/console/embedded_executor.rb +806 -257
  67. data/lib/woods/console/eval_guard.rb +27 -20
  68. data/lib/woods/console/input_contract.rb +78 -0
  69. data/lib/woods/console/model_validator.rb +24 -6
  70. data/lib/woods/console/rack_middleware.rb +62 -63
  71. data/lib/woods/console/redactor.rb +10 -24
  72. data/lib/woods/console/safe_context.rb +45 -45
  73. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  74. data/lib/woods/console/server.rb +136 -267
  75. data/lib/woods/console/sql_noise_stripper.rb +20 -51
  76. data/lib/woods/console/sql_table_scanner.rb +39 -90
  77. data/lib/woods/console/sql_validator.rb +455 -85
  78. data/lib/woods/console/table_gate.rb +2 -2
  79. data/lib/woods/console/tool_specs.rb +462 -88
  80. data/lib/woods/console/tools/tier1.rb +0 -3
  81. data/lib/woods/console/tools/tier4.rb +17 -7
  82. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  83. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  84. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  85. data/lib/woods/db/migrator.rb +3 -9
  86. data/lib/woods/db/schema_version.rb +47 -2
  87. data/lib/woods/dependency_graph.rb +898 -64
  88. data/lib/woods/embedding/fake.rb +138 -0
  89. data/lib/woods/embedding/indexer.rb +832 -40
  90. data/lib/woods/embedding/openai.rb +77 -19
  91. data/lib/woods/embedding/provider.rb +189 -11
  92. data/lib/woods/embedding/text_preparer.rb +1 -1
  93. data/lib/woods/embedding/token_counter.rb +0 -7
  94. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  95. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  96. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  97. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  98. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  99. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  100. data/lib/woods/evaluation/ablation_task.rb +66 -0
  101. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  102. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  103. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  104. data/lib/woods/evaluation/baseline.rb +60 -0
  105. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  106. data/lib/woods/evaluation/evaluator.rb +41 -8
  107. data/lib/woods/evaluation/query_set.rb +79 -13
  108. data/lib/woods/evaluation/report_generator.rb +20 -1
  109. data/lib/woods/export/unit_facts.rb +0 -11
  110. data/lib/woods/extracted_unit.rb +22 -63
  111. data/lib/woods/extractor.rb +2503 -192
  112. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  113. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  114. data/lib/woods/extractors/caching_extractor.rb +46 -12
  115. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  116. data/lib/woods/extractors/component_discovery.rb +123 -0
  117. data/lib/woods/extractors/concern_extractor.rb +17 -3
  118. data/lib/woods/extractors/controller_extractor.rb +389 -29
  119. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  120. data/lib/woods/extractors/engine_extractor.rb +53 -8
  121. data/lib/woods/extractors/event_extractor.rb +55 -4
  122. data/lib/woods/extractors/factory_extractor.rb +49 -11
  123. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  124. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  125. data/lib/woods/extractors/job_extractor.rb +51 -21
  126. data/lib/woods/extractors/lib_extractor.rb +23 -17
  127. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  128. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  129. data/lib/woods/extractors/manager_extractor.rb +19 -2
  130. data/lib/woods/extractors/migration_extractor.rb +22 -11
  131. data/lib/woods/extractors/model_extractor.rb +292 -57
  132. data/lib/woods/extractors/package_extractor.rb +154 -0
  133. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  134. data/lib/woods/extractors/policy_extractor.rb +6 -5
  135. data/lib/woods/extractors/poro_extractor.rb +13 -14
  136. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  137. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  138. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  139. data/lib/woods/extractors/reference_patterns.rb +38 -0
  140. data/lib/woods/extractors/route_extractor.rb +58 -2
  141. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  142. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  143. data/lib/woods/extractors/service_extractor.rb +11 -1
  144. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  145. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  146. data/lib/woods/extractors/source_nesting.rb +560 -0
  147. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  148. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  149. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  150. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  151. data/lib/woods/feedback/gap_detector.rb +9 -3
  152. data/lib/woods/feedback/store.rb +7 -1
  153. data/lib/woods/filename_utils.rb +29 -1
  154. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  155. data/lib/woods/flow_assembler.rb +63 -21
  156. data/lib/woods/flow_document.rb +1 -0
  157. data/lib/woods/flow_precomputer.rb +138 -22
  158. data/lib/woods/gem_mapper.rb +285 -0
  159. data/lib/woods/generation.rb +185 -0
  160. data/lib/woods/git_command.rb +38 -0
  161. data/lib/woods/git_provenance.rb +16 -2
  162. data/lib/woods/graph_analyzer.rb +408 -34
  163. data/lib/woods/index_artifact.rb +93 -23
  164. data/lib/woods/mcp/bearer_auth.rb +92 -22
  165. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  166. data/lib/woods/mcp/bootstrapper.rb +582 -77
  167. data/lib/woods/mcp/config_resolver.rb +66 -6
  168. data/lib/woods/mcp/errors.rb +60 -0
  169. data/lib/woods/mcp/index_reader.rb +836 -117
  170. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  171. data/lib/woods/mcp/origin_guard.rb +108 -23
  172. data/lib/woods/mcp/protocol_policy.rb +98 -0
  173. data/lib/woods/mcp/provider_probe.rb +45 -6
  174. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  175. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  176. data/lib/woods/mcp/server.rb +907 -154
  177. data/lib/woods/mcp/tasks/extension.rb +196 -0
  178. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  179. data/lib/woods/mcp/tasks/store.rb +518 -0
  180. data/lib/woods/mcp/tool_contract.rb +171 -0
  181. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  182. data/lib/woods/mcp/version_aware_tool_dispatch.rb +3 -9
  183. data/lib/woods/model_name_cache.rb +19 -1
  184. data/lib/woods/notion/client.rb +132 -36
  185. data/lib/woods/notion/exporter.rb +456 -61
  186. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  187. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  188. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  189. data/lib/woods/notion/mappers/shared.rb +45 -3
  190. data/lib/woods/notion/sync_manifest.rb +258 -0
  191. data/lib/woods/obsidian/errors.rb +6 -0
  192. data/lib/woods/obsidian/name_mapper.rb +40 -24
  193. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  194. data/lib/woods/operator/pipeline_guard.rb +118 -21
  195. data/lib/woods/operator/status_reporter.rb +20 -3
  196. data/lib/woods/path_dispatcher.rb +276 -0
  197. data/lib/woods/payload_store.rb +223 -0
  198. data/lib/woods/published_index/edge_shaper.rb +61 -0
  199. data/lib/woods/published_index/generation_catalog.rb +72 -0
  200. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  201. data/lib/woods/published_index.rb +287 -0
  202. data/lib/woods/railtie.rb +70 -38
  203. data/lib/woods/railtie_support.rb +167 -0
  204. data/lib/woods/release.rb +12 -0
  205. data/lib/woods/reload_policy.rb +206 -0
  206. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  207. data/lib/woods/resilience/index_validator.rb +296 -10
  208. data/lib/woods/resilience/retryable_provider.rb +71 -6
  209. data/lib/woods/resolved_config.rb +55 -11
  210. data/lib/woods/retrieval/context_assembler.rb +132 -40
  211. data/lib/woods/retrieval/query_classifier.rb +25 -6
  212. data/lib/woods/retrieval/ranker.rb +193 -28
  213. data/lib/woods/retrieval/search_executor.rb +206 -39
  214. data/lib/woods/retriever.rb +317 -71
  215. data/lib/woods/retry_after.rb +22 -2
  216. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  217. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  218. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  219. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  220. data/lib/woods/ruby_analyzer.rb +21 -5
  221. data/lib/woods/session_tracer/file_store.rb +138 -19
  222. data/lib/woods/session_tracer/redis_store.rb +122 -12
  223. data/lib/woods/session_tracer/session_flow_assembler.rb +54 -11
  224. data/lib/woods/session_tracer/session_flow_document.rb +52 -6
  225. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  226. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  227. data/lib/woods/session_tracer/store.rb +14 -1
  228. data/lib/woods/storage/metadata_store.rb +230 -26
  229. data/lib/woods/storage/pgvector.rb +180 -22
  230. data/lib/woods/storage/qdrant.rb +367 -41
  231. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  232. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  233. data/lib/woods/storage/snapshotter.rb +23 -5
  234. data/lib/woods/storage/vector_store.rb +49 -8
  235. data/lib/woods/storage_identity.rb +28 -0
  236. data/lib/woods/tasks.rb +53 -2
  237. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  238. data/lib/woods/temporal/snapshot_store.rb +139 -42
  239. data/lib/woods/unblocked/client.rb +119 -17
  240. data/lib/woods/unblocked/document_builder.rb +34 -2
  241. data/lib/woods/unblocked/exporter.rb +63 -27
  242. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  243. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  244. data/lib/woods/update_check.rb +24 -1
  245. data/lib/woods/util/uuid5.rb +124 -0
  246. data/lib/woods/version.rb +1 -1
  247. data/lib/woods/watch/daemon.rb +1345 -0
  248. data/lib/woods/watch/listen_watcher.rb +81 -0
  249. data/lib/woods/watch/polling_watcher.rb +137 -0
  250. data/lib/woods/watch/status.rb +169 -0
  251. data/lib/woods/watch/tree_scan.rb +163 -0
  252. data/lib/woods/watch/watcher.rb +100 -0
  253. data/lib/woods.rb +53 -9
  254. data/plugin/.claude-plugin/plugin.json +18 -0
  255. data/plugin/hooks/hooks.json +29 -0
  256. data/plugin/hooks/woods-post-edit.sh +226 -0
  257. data/plugin/hooks/woods-session-start.sh +77 -0
  258. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  259. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  260. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  261. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  262. data/plugin/skills/woods-setup/SKILL.md +99 -0
  263. metadata +102 -30
  264. data/lib/woods/console/adapter_family.rb +0 -39
  265. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  266. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  268. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  269. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  270. data/lib/woods/console/bridge.rb +0 -210
  271. data/lib/woods/console/credential_scanner_registry.rb +0 -36
  272. data/lib/woods/console/encrypted_credential_snapshot.rb +0 -16
  273. data/lib/woods/console/sql_output_policy.rb +0 -535
  274. data/lib/woods/console/sqlite_read_guard.rb +0 -46
  275. data/lib/woods/formatting/claude_adapter.rb +0 -98
  276. data/lib/woods/formatting/generic_adapter.rb +0 -56
  277. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  278. data/lib/woods/mcp/http_transport_options.rb +0 -15
  279. data/lib/woods/mcp/origin_policy.rb +0 -113
  280. data/lib/woods/notion/mapper.rb +0 -40
  281. data/lib/woods/observability/health_check.rb +0 -79
  282. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -5,26 +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
- require_relative 'redactor'
12
13
  require_relative 'scope_predicate_parser'
13
- require_relative 'sql_noise_stripper'
14
14
  require_relative 'sql_validator'
15
+ require_relative 'sql_noise_stripper'
15
16
  require_relative 'table_gate'
16
- require_relative 'adapter_family'
17
- require_relative 'sql_output_policy'
17
+ require_relative 'tool_specs'
18
18
 
19
19
  module Woods
20
20
  module Console
21
- # Drop-in replacement for ConnectionManager + the bridge process that
22
- # executes queries directly via ActiveRecord instead of going over the
23
- # JSON-lines protocol (see {StubBridge} for the protocol scaffold).
24
- #
25
- # Implements the same `send_request(Hash) -> Hash` interface as
26
- # ConnectionManager, so all existing tool definitions in Server work
27
- # 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.
28
24
  #
29
25
  # @example
30
26
  # executor = EmbeddedExecutor.new(model_validator: validator, safe_context: ctx)
@@ -32,10 +28,6 @@ module Woods
32
28
  # # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
33
29
  #
34
30
  class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
35
- include SqlOutputPolicy
36
-
37
- AGGREGATE_FUNCTIONS = %w[sum average minimum maximum count].freeze
38
-
39
31
  TIER1_TOOLS = BridgeProtocol::TIER1_TOOLS
40
32
 
41
33
  # Tools gated behind the read_tools_enabled flag.
@@ -43,16 +35,10 @@ module Woods
43
35
  # but require explicit opt-in for embedded mode.
44
36
  EMBEDDED_READ_TOOLS = %w[sql query].freeze
45
37
 
46
- MAX_SQL_LIMIT = 10_000
47
- MAX_QUERY_LIMIT = 10_000
48
-
49
- MIN_EVAL_TIMEOUT = 1
50
- MAX_EVAL_TIMEOUT = 30
51
38
  DEFAULT_EVAL_TIMEOUT = 10
52
39
 
53
40
  # @param model_validator [ModelValidator] Validates model/column names
54
41
  # @param safe_context [SafeContext] Wraps execution in rolled-back transaction
55
- # @param redaction_context [SafeContext, nil] Output policy supplied by the server renderer
56
42
  # @param connection [Object, nil] Database connection for adapter detection
57
43
  # @param read_tools_enabled [Boolean] Enable sql/query tools in embedded mode (default: false)
58
44
  # @param table_gate [TableGate, nil] Enforces console_blocked_tables on every
@@ -75,10 +61,9 @@ module Woods
75
61
  # refusal as before.
76
62
  def initialize(model_validator:, safe_context:, connection: nil, read_tools_enabled: false, # rubocop:disable Metrics/ParameterLists
77
63
  table_gate: nil, eval_guard: nil, confirmation: nil, audit_logger: nil,
78
- unsafe_eval_enabled: false, redaction_context: nil)
64
+ unsafe_eval_enabled: false)
79
65
  @model_validator = model_validator
80
66
  @safe_context = safe_context
81
- @redaction_context = redaction_context || safe_context
82
67
  @connection = connection
83
68
  @read_tools_enabled = read_tools_enabled
84
69
  @table_gate = table_gate
@@ -105,8 +90,9 @@ module Woods
105
90
  refusal = refusal_for(tool)
106
91
  return refusal if refusal
107
92
 
93
+ normalize_params!(tool, params)
108
94
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
- result = @safe_context.execute { dispatch_with_key_redaction(tool, params) }
95
+ result = @safe_context.execute { with_mysql_quote_modes { dispatch(tool, params) } }
110
96
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
111
97
 
112
98
  { 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
@@ -126,60 +112,6 @@ module Woods
126
112
 
127
113
  private
128
114
 
129
- def dispatch_with_key_redaction(tool, params)
130
- refuse_protected_scope!(params['scope'])
131
- refuse_protected_scope!(params['by']) if tool == 'find'
132
- output = dispatch(tool, params)
133
- return output if redaction_key_values.empty?
134
-
135
- Redactor.apply(output, typed_redaction_context(tool, params))
136
- end
137
-
138
- def typed_redaction_context(tool, params)
139
- types = Hash.new { |hash, key| hash[key] = [] }
140
- typed_redaction_models(tool, params).each do |name, model|
141
- redaction_key_values.each do |pattern|
142
- key = pattern['key_column']
143
- types[key] << model.type_for_attribute(key) if @model_validator.columns_for(name).include?(key)
144
- end
145
- end
146
- @redaction_context.with_key_value_types(types, raw: %w[sql query].include?(tool))
147
- end
148
-
149
- def typed_redaction_models(tool, params)
150
- tables = if tool == 'sql'
151
- SqlTableScanner.identifiers_in(params['sql'], dialect: sql_dialect, mysql_modes: mysql_quote_modes)
152
- else
153
- selected_source_tables(params)
154
- end
155
- @model_validator.model_names.filter_map do |name|
156
- model = resolve_model(name)
157
- next unless model.respond_to?(:type_for_attribute)
158
- next unless typed_model_source?(model, name, params, tables)
159
-
160
- [name, model]
161
- rescue NameError
162
- # Injectable registries can describe models without loading Rails.
163
- next
164
- end
165
- end
166
-
167
- def typed_model_source?(model, name, params, tables)
168
- return true if name == params['model']
169
- return false unless model.respond_to?(:table_name)
170
-
171
- # Match the scanner's unquoted final segment, as the identifier gates
172
- # do. Keep all matching models across schemas: every possible type
173
- # contributes to masking rather than selecting one ambiguous source.
174
- tables.any? { |table| table.split('.').last.casecmp?(model.table_name.split('.').last) }
175
- end
176
-
177
- def selected_source_tables(params)
178
- Array(params['select'] || params['columns']).filter_map do |column|
179
- column.split('.')[0...-1].join('.') if column.include?('.')
180
- end
181
- end
182
-
183
115
  def sanitize_execution_error(error)
184
116
  klass = error.class.name
185
117
  # Well-known AR wrappers that contain the adapter error as their cause —
@@ -192,12 +124,30 @@ module Woods
192
124
  return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
193
125
 
194
126
  Rails.logger.warn(
195
- "[Woods::Console] execution error: #{error.class}: #{error.message}"
127
+ "[Woods::Console] execution error: #{error.class}: #{scan_log_text(error.message)}"
196
128
  )
197
129
  rescue StandardError
198
130
  # Never let logging break the request path.
199
131
  end
200
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
+
201
151
  # Return a pre-dispatch refusal hash for tools the executor cannot or
202
152
  # will not run, else nil to let dispatch proceed.
203
153
  #
@@ -220,20 +170,26 @@ module Woods
220
170
 
221
171
  # Self-describing error for tools the embedded executor cannot run.
222
172
  #
223
- # `sql`/`query` are gated behind `embedded_read_tools: true` — point the
224
- # caller at the flag. Everything else (Tier 2–4 domain/analytics tools)
225
- # 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.
226
182
  #
227
183
  # @param tool [String] Tool name that was rejected
228
184
  # @return [String] Actionable error message
229
185
  def unsupported_message(tool)
230
186
  if EMBEDDED_READ_TOOLS.include?(tool)
231
187
  "Tool '#{tool}' requires embedded_read_tools: true on " \
232
- '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). ' \
233
190
  'See docs/CONSOLE_MCP_SETUP.md.'
234
191
  else
235
- "Tool '#{tool}' is not available in embedded mode — it requires the " \
236
- 'bridge architecture (Option D in docs/CONSOLE_MCP_SETUP.md).'
192
+ "Tool '#{tool}' is not available in a supported Console MCP mode."
237
193
  end
238
194
  end
239
195
 
@@ -252,16 +208,12 @@ module Woods
252
208
  # @return [String] Multi-line actionable message.
253
209
  def eval_disabled_message
254
210
  <<~MSG.strip
255
- 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.
256
212
  Use console_query (model + select + joins/group_by/having/order) or console_sql
257
213
  for anything you were about to run. Both already support aggregates and scoping.
258
214
  If you believe eval is still necessary, SHOW your proposed Ruby snippet to the
259
215
  user first and let them run it manually — do not retry console_eval automatically.
260
- Operators: set WOODS_CONSOLE_UNSAFE_EVAL=true (or console_unsafe_eval_enabled = true)
261
- AND wire console_unsafe_eval_confirmation + console_unsafe_eval_audit_log_path.
262
- The server refuses to boot with the flag on in Rails.env.production?, and refuses
263
- to boot with the flag on but any collaborator missing (fail-closed).
264
- 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.
265
217
  MSG
266
218
  end
267
219
 
@@ -313,19 +265,8 @@ module Woods
313
265
  raise
314
266
  end
315
267
 
316
- # Validate + clamp the user-supplied timeout. Accepts a positive
317
- # Integer (or nil → default). Everything else is rejected so a
318
- # caller passing `timeout: 0` or `timeout: "forever"` hears about
319
- # it instead of silently getting MIN_EVAL_TIMEOUT.
320
268
  def eval_timeout_from(raw)
321
- return DEFAULT_EVAL_TIMEOUT if raw.nil?
322
-
323
- unless raw.is_a?(Integer) && raw.positive?
324
- raise ValidationError,
325
- "timeout must be a positive integer (#{MIN_EVAL_TIMEOUT}..#{MAX_EVAL_TIMEOUT})"
326
- end
327
-
328
- raw.clamp(MIN_EVAL_TIMEOUT, MAX_EVAL_TIMEOUT)
269
+ raw || DEFAULT_EVAL_TIMEOUT
329
270
  end
330
271
 
331
272
  def guard_check!(code, audit_params)
@@ -458,6 +399,28 @@ module Woods
458
399
  end
459
400
  end
460
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
+
461
424
  # @param params [Hash] Must contain 'model' key
462
425
  # @raise [ValidationError]
463
426
  def validate_model!(params)
@@ -487,9 +450,8 @@ module Woods
487
450
  return unless @table_gate
488
451
 
489
452
  begin
490
- SqliteReadGuard.validate!(sql) if sql_dialect == :sqlite
491
- @table_gate.check_sql!(sql, dialect: sql_dialect)
492
- rescue TableGateError, SqlValidationError => e
453
+ @table_gate.check_sql!(sql, dialect: sql_dialect, mysql_modes: mysql_quote_modes)
454
+ rescue TableGateError => e
493
455
  raise ValidationError, e.message
494
456
  end
495
457
  end
@@ -517,57 +479,82 @@ module Woods
517
479
  def handle_count(params)
518
480
  model = resolve_model(params['model'])
519
481
  scope = apply_scope(model, params['scope'], model_name: params['model'])
520
- { 'count' => checked_relation(scope).count }
482
+ { 'count' => scope.count }
521
483
  end
522
484
 
523
485
  def handle_sample(params)
524
486
  validate_select_columns!(params)
525
487
  model = resolve_model(params['model'])
526
- limit = [params.fetch('limit', 5).to_i, 25].min
488
+ limit = params.fetch('limit', 5)
527
489
  scope = apply_scope(model, params['scope'], model_name: params['model'])
528
490
  scope = apply_columns(scope, params['columns'])
529
- records = checked_relation(scope.order(random_function).limit(limit))
491
+ records = scope.order(random_function).limit(limit)
530
492
  { 'records' => serialize_records(records, params['columns']) }
531
493
  end
532
494
 
533
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
534
502
  validate_select_columns!(params)
535
503
  model = resolve_model(params['model'])
536
- scope = checked_relation(model)
537
- record = if params['id']
538
- scope.find_by(id: params['id'])
539
- elsif params['by']
540
- scope.find_by(params['by'])
541
- end
504
+ record = params['id'] ? model.find_by(id: params['id']) : model.find_by(params['by'])
542
505
  { 'record' => record ? serialize_record(record, params['columns']) : nil }
543
506
  end
544
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
+
545
527
  def handle_pluck(params)
546
528
  columns = params['columns']
547
- validate_select_columns!(params)
529
+ raise ValidationError, 'columns must contain at least one item' if columns && columns.empty?
530
+
531
+ @model_validator.validate_columns!(params['model'], columns) if columns
532
+ refuse_orphan_eav_value_selection!(Array(columns)) if columns
548
533
  model = resolve_model(params['model'])
549
- limit = [params.fetch('limit', 100).to_i, 1000].min
534
+ limit = params.fetch('limit', 100)
550
535
  scope = apply_scope(model, params['scope'], model_name: params['model'])
551
536
  scope = scope.distinct if params['distinct']
552
- values = checked_relation(scope.limit(limit)).pluck(*columns.map(&:to_sym))
537
+ values = scope.limit(limit).pluck(*columns.map(&:to_sym))
553
538
  { 'columns' => Array(columns), 'values' => values }
554
539
  end
555
540
 
556
541
  def handle_aggregate(params)
557
542
  column = params['column']
558
543
  function = params['function']
559
- @model_validator.validate_column!(params['model'], column) if column
560
- refuse_redacted_aggregate_expression!(column)
544
+ if column
545
+ @model_validator.validate_column!(params['model'], column)
546
+ refuse_redacted_aggregate_expression!(column)
547
+ end
561
548
 
562
- unless AGGREGATE_FUNCTIONS.include?(function)
549
+ unless Server::AGGREGATE_FUNCTIONS.include?(function)
563
550
  raise ValidationError, "Invalid aggregate function: #{function}. " \
564
- "Allowed: #{AGGREGATE_FUNCTIONS.join(', ')}"
551
+ "Allowed: #{Server::AGGREGATE_FUNCTIONS.join(', ')}"
565
552
  end
553
+ raise ValidationError, "column is required for #{function} aggregate" if function != 'count' && column.nil?
566
554
 
567
555
  model = resolve_model(params['model'])
568
556
  scope = apply_scope(model, params['scope'], model_name: params['model'])
569
557
 
570
- scope = checked_relation(scope)
571
558
  value = if function == 'count'
572
559
  column ? scope.count(column.to_sym) : scope.count
573
560
  else
@@ -578,12 +565,10 @@ module Woods
578
565
 
579
566
  def handle_association_count(params)
580
567
  model = resolve_model(params['model'])
581
- record = checked_relation(model).find(params['id'])
582
568
  association_name = params['association']
569
+ reflection = model.reflect_on_association(association_name.to_sym)
583
570
 
584
- unless model.reflect_on_association(association_name.to_sym)
585
- raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}"
586
- end
571
+ raise ValidationError, "Unknown association '#{association_name}' on #{params['model']}" unless reflection
587
572
 
588
573
  # Defense-in-depth: the parent model passed validate_model!'s
589
574
  # gate_model! check, but the association may target a different
@@ -592,24 +577,117 @@ module Woods
592
577
  # explicitly before reading any rows from it.
593
578
  gate_association!(params['model'], association_name)
594
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'])
595
587
  scope = record.public_send(association_name)
596
- scope = apply_scope(scope, params['scope'])
597
- { 'count' => checked_relation(scope).count }
588
+ scope = apply_scope(scope, params['scope'], model_name: reflection.klass.name) if params['scope']
589
+ gate_association_sql!(scope)
590
+ { 'count' => scope.count }
591
+ end
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
598
620
  end
599
621
 
600
- # Resolve the default scope once and execute the same checked relation.
601
- # @param scope [Class, ActiveRecord::Relation] Pending model read
602
- # @return [Class, ActiveRecord::Relation] Authorized relation
603
- def checked_relation(scope)
604
- return scope unless @table_gate
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) }
605
650
 
606
- relation = scope.all
607
- gate_sql!(relation.to_sql)
608
- relation
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.'
609
654
  end
610
655
 
611
- def sql_dialect
612
- AdapterFamily.for(active_connection)
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'] }
613
691
  end
614
692
 
615
693
  def gate_association!(model_name, association)
@@ -650,15 +728,17 @@ module Woods
650
728
  model = resolve_model(params['model'])
651
729
  order_by = params.fetch('order_by', 'created_at')
652
730
  direction = params.fetch('direction', 'desc')
653
- limit = [params.fetch('limit', 10).to_i, 50].min
731
+ limit = params.fetch('limit', 10)
654
732
 
655
733
  @model_validator.validate_column!(params['model'], order_by)
656
- refuse_protected_predicate_column!(order_by)
657
- 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
658
738
 
659
739
  scope = apply_scope(model, params['scope'], model_name: params['model'])
660
740
  scope = apply_columns(scope, params['columns'])
661
- records = checked_relation(scope.order(order_by => direction.to_sym).limit(limit))
741
+ records = scope.order(order_by => direction.to_sym).limit(limit)
662
742
  { 'records' => serialize_records(records, params['columns']) }
663
743
  end
664
744
 
@@ -681,31 +761,80 @@ module Woods
681
761
  sql = params['sql']
682
762
  raise ValidationError, 'Missing required parameter: sql' unless sql
683
763
 
684
- raise ValidationError, 'Rejected: console_sql requires a recognized database adapter family.' unless sql_dialect
685
-
686
764
  require_relative 'sql_validator'
687
- validate_sql_policy!(sql)
765
+ SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(sql)
766
+ validate_protected_sql_usage!(sql)
767
+ # Post-validation, pre-execution TableGate — blocks every configured
768
+ # table even if the sql is otherwise well-formed.
769
+ gate_sql!(sql)
770
+
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
688
781
 
689
- limit = params['limit'] ? [params['limit'].to_i, MAX_SQL_LIMIT].min : nil
690
- query_sql = limit ? "SELECT * FROM (\n#{sql}\n) AS _limited LIMIT #{limit}" : sql
691
- validate_sql_policy!(query_sql) if limit
782
+ query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
692
783
  result = active_connection.select_all(query_sql)
693
- validate_sql_result_types!(result)
694
784
 
695
785
  { 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
696
786
  rescue SqlValidationError => e
697
787
  raise ValidationError, e.message
698
788
  end
699
789
 
700
- def validate_sql_policy!(sql)
701
- # Alias-list identity is its own policy, even when an alias also
702
- # looks like a function name to the SQL validator.
703
- sql_security_views(sql).each { |stripped| refuse_sql_column_alias_lists!(stripped) }
704
- SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(sql)
705
- validate_protected_sql_usage!(sql)
706
- # Check both submitted and wrapped SQL against blocked tables before
707
- # the exact final statement reaches the adapter.
708
- gate_sql!(sql)
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
709
838
  end
710
839
 
711
840
  # Build and execute a structured ActiveRecord query.
@@ -733,23 +862,42 @@ module Woods
733
862
  # @param params [Hash] Query parameters (select, joins, scope, group_by, having, order, limit)
734
863
  # @return [ActiveRecord::Relation]
735
864
  def build_query_relation(model, params)
736
- relation = apply_query_clauses(model, params)
737
- 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)
738
868
  relation.limit(limit)
739
869
  end
740
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
+
741
897
  # Optional aggregate-expression wrappers accepted inside a `select`.
742
898
  # Anything else must be a bare column name validated against the model.
743
899
  # Matching is case-insensitive; the trailing `AS alias` is optional and
744
900
  # the alias itself must be an identifier — it can't carry SQL.
745
- SAFE_SELECT_EXPR = /
746
- \A\s*
747
- (?:(SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(\*|\w+(?:\.\w+)?)\s*\)|(\w+(?:\.\w+)?))
748
- (?:\s+AS\s+(\w+))?
749
- \s*\z
750
- /ix
751
- private_constant :SAFE_SELECT_EXPR
752
-
753
901
  # Apply select/joins/scope/group/having/order clauses to a relation.
754
902
  #
755
903
  # Validates every user-supplied column/alias through the ModelValidator
@@ -761,46 +909,244 @@ module Woods
761
909
  #
762
910
  # @param model [Class] ActiveRecord model class
763
911
  # @param params [Hash]
912
+ # @param clauses [Hash] prevalidated query clauses
764
913
  # @return [ActiveRecord::Relation]
765
914
  # @raise [ValidationError] on unsafe column/expression input
766
- def apply_query_clauses(model, params) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
915
+ def apply_query_clauses(model, params, clauses)
767
916
  model_name = params['model']
768
917
  relation = model.all
769
918
 
770
- # Keep execution and typed source inference on the same validated projection.
771
- params['select'] = validated_select(params['select'], model_name) if params['select']
772
- relation = relation.select(*params['select']) if params['select']
773
- relation = relation.joins(params['joins'].map(&:to_sym)) if params['joins']&.any?
774
- relation = apply_scope(relation, params['scope'], model_name: model_name)
775
- relation = relation.group(*validated_columns(params['group_by'], model_name)) if params['group_by']&.any?
776
- relation = relation.having(*validated_having(params['having'], model_name)) if params['having']
777
- 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]
778
925
  relation
779
926
  end
780
927
 
781
928
  # Normalize `select:` into an array of safe expressions. Each element
782
929
  # must be a column name (optionally qualified and/or aliased) or a
783
- # 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.
784
934
  #
785
935
  # @param select [String, Array<String>]
786
936
  # @param model_name [String]
787
937
  # @return [Array<String>]
788
938
  def validated_select(select, model_name)
789
- expressions = Array(select).flat_map { |s| s.to_s.split(',') }.map do |expr|
939
+ expressions = Array(select).map do |expr|
790
940
  validate_select_expression!(expr.strip, model_name)
791
941
  end
792
- refuse_orphan_eav_value_selection!(expressions, model_name)
942
+ refuse_orphan_eav_value_selection!(expressions)
793
943
  expressions
794
944
  end
795
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
969
+ end
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
796
1022
  def validate_select_expression!(expr, model_name)
797
- match = SAFE_SELECT_EXPR.match(expr)
1023
+ match = Server::SELECT_EXPRESSION_REGEXP.match(expr)
798
1024
  raise ValidationError, "Rejected select expression: #{expr.inspect}" unless match
799
1025
 
800
1026
  refuse_redacted_select_shapes!(match.captures, model_name)
801
1027
  expr
802
1028
  end
803
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
1038
+ column = bare_col || fn_arg
1039
+ validate_column_reference!(column, model_name) unless column == '*'
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
1148
+ end
1149
+
804
1150
  # Validate group_by entries — bare columns only (no functions, no SQL).
805
1151
  #
806
1152
  # @param columns [String, Array<String>]
@@ -824,68 +1170,152 @@ module Woods
824
1170
  # Anything else is rejected — raw strings (e.g. `"1=1 UNION SELECT
825
1171
  # password_digest FROM users"`) used to flow straight through and
826
1172
  # enable SELECT-based exfiltration despite the SafeContext rollback.
827
- HAVING_AGG_TEMPLATE = /
828
- \A\s*
829
- (?:
830
- (?<col>\w+(?:\.\w+)?)
831
- |
832
- (?<agg>SUM|AVG|MIN|MAX|COUNT)\s*\(\s*(?<arg>\*|\w+(?:\.\w+)?)\s*\)
833
- )
834
- \s*(?<op>=|!=|<>|<=|>=|<|>)\s*\?\s*\z
835
- /ix
836
- private_constant :HAVING_AGG_TEMPLATE
837
-
838
1173
  def validated_having(having, model_name)
839
1174
  case having
840
1175
  when Hash
841
1176
  raise ValidationError, 'having: empty hash' if having.empty?
842
1177
 
843
- having.each_key { |key| validate_predicate_column_reference!(key.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
844
1182
  [having]
845
1183
  when Array
846
- 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
847
1189
 
848
- template = having.first.to_s
849
- match = HAVING_AGG_TEMPLATE.match(template)
850
- raise ValidationError, "having: unsupported SQL template #{template.inspect}" unless match
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
851
1199
 
852
- # Validate any referenced columns through ModelValidator so
853
- # aggregate args can't reach the db without a column check.
854
- validate_having_input!(match, model_name)
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
855
1213
 
856
- having
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
1230
+
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
857
1240
  else
858
- raise ValidationError, "having: unsupported type #{having.class}"
1241
+ raise ValidationError, 'having bind must be a string, number, boolean, or null'
859
1242
  end
860
1243
  end
861
1244
 
862
- def validate_having_input!(match, model_name)
863
- column = match[:col] || match[:arg]
864
- validate_predicate_column_reference!(column, model_name) if column && column != '*'
865
- refuse_redacted_aggregate_expression!(column) if match[:agg]
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)
866
1250
  end
867
1251
 
868
- def validate_predicate_column_reference!(column, model_name)
869
- validate_column_reference!(column, model_name)
870
- refuse_protected_predicate_column!(column)
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
1271
+ else
1272
+ raise ValidationError, 'scope bind must be a string, number, boolean, or null'
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
871
1298
  end
872
1299
 
873
1300
  # Validate `order:` — only Hash `{col => :asc|:desc}` or bare column name.
874
1301
  def validated_order(order, model_name)
875
1302
  case order
876
1303
  when Hash
877
- order.each_key { |key| validate_predicate_column_reference!(key.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
878
1308
  order.transform_values do |dir|
879
- dir_sym = dir.to_s.downcase.to_sym
880
- unless %i[asc desc].include?(dir_sym)
1309
+ unless dir.to_s.match?(Server::ORDER_DIRECTION_REGEXP)
881
1310
  raise ValidationError, "order direction must be :asc or :desc (got #{dir.inspect})"
882
1311
  end
883
1312
 
884
- dir_sym
1313
+ dir.to_s.downcase.to_sym
885
1314
  end
886
1315
  when String, Symbol
887
1316
  col = order.to_s.strip
888
- validate_predicate_column_reference!(col, model_name)
1317
+ validate_column_reference!(col, model_name)
1318
+ refuse_protected_predicate_column!(col)
889
1319
  col
890
1320
  else
891
1321
  raise ValidationError, "order: unsupported type #{order.class}"
@@ -899,19 +1329,23 @@ module Woods
899
1329
  # blocked-table reference into `select`/`order`/`having` via a
900
1330
  # qualified column like `users.password_digest`. Bare columns validate
901
1331
  # against the active model through ModelValidator.
902
- 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)
903
1347
  if column.include?('.')
904
- table, col = column.split('.', 2)
905
- unless safe_identifier?(table) && safe_identifier?(col)
906
- raise ValidationError, "Rejected column reference: #{column.inspect}"
907
- end
908
-
909
- # Gate the table side through TableGate if one is configured.
910
- begin
911
- @table_gate&.check_table!(table)
912
- rescue TableGateError => e
913
- raise ValidationError, e.message
914
- end
1348
+ validate_qualified_column_reference!(column, model_name, own_table)
915
1349
  else
916
1350
  raise ValidationError, "Rejected column reference: #{column.inspect}" unless safe_identifier?(column)
917
1351
 
@@ -919,8 +1353,55 @@ module Woods
919
1353
  end
920
1354
  end
921
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
+
922
1395
  def safe_identifier?(name)
923
- 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
924
1405
  end
925
1406
 
926
1407
  # ── Helpers ──────────────────────────────────────────────────────────
@@ -928,24 +1409,24 @@ module Woods
928
1409
  # Apply scope conditions (WHERE clauses) to a relation.
929
1410
  #
930
1411
  # Accepts Hash form for equality or Ransack-style predicate suffixes
931
- # (e.g., `{total_refund_gt: 0, status_in: ['paid','refunded']}`), or
932
- # Array form for parameterized SQL (e.g., JSON column queries like
933
- # ["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.
934
1415
  #
935
- # When `model_name` is supplied and the Hash contains at least one key
936
- # with a recognised predicate suffix, the ScopePredicateParser builds
937
- # 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.
938
1418
  #
939
1419
  # @param relation [ActiveRecord::Relation, Class] Model or relation
940
1420
  # @param scope [Hash, Array, nil] Filter conditions
941
- # @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
942
1422
  # @return [ActiveRecord::Relation]
943
1423
  def apply_scope(relation, scope, model_name: nil)
944
1424
  case scope
945
1425
  when Hash
946
1426
  return relation unless scope.any?
947
1427
 
948
- if model_name && predicate_suffix?(scope)
1428
+ refuse_redacted_scope_keys!(scope)
1429
+ if model_name
949
1430
  parser = ScopePredicateParser.new(model_name: model_name, model_validator: @model_validator)
950
1431
  parser.parse(relation, scope)
951
1432
  else
@@ -954,14 +1435,8 @@ module Woods
954
1435
  when Array
955
1436
  return relation unless scope.any?
956
1437
 
957
- # Array form is `[template, *binds]`. The previous implementation
958
- # splatted directly into `where(*scope)`, which is the
959
- # `where(raw_sql_string)` arity — unbounded SQL injection. A
960
- # caller could pass `["EXISTS (SELECT 1 FROM users WHERE
961
- # password_digest LIKE 'a%')"]` and turn `console_count` /
962
- # `console_pluck` into a boolean exfiltration oracle against
963
- # any table the DB user can read (TableGate doesn't fire on
964
- # 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.
965
1440
  validate_scope_array!(scope)
966
1441
  relation.where(*scope)
967
1442
  else
@@ -1026,18 +1501,12 @@ module Woods
1026
1501
 
1027
1502
  placeholder_count = template.scan('?').size
1028
1503
  bind_count = scope.length - 1
1029
- return if placeholder_count == bind_count
1030
-
1031
- raise ValidationError,
1032
- "scope template expects #{placeholder_count} bind(s), got #{bind_count}"
1033
- end
1504
+ unless placeholder_count == bind_count
1505
+ raise ValidationError,
1506
+ "scope template expects #{placeholder_count} bind(s), got #{bind_count}"
1507
+ end
1034
1508
 
1035
- # Returns true if any key in the hash has a recognised predicate suffix.
1036
- #
1037
- # @param scope [Hash]
1038
- # @return [Boolean]
1039
- def predicate_suffix?(scope)
1040
- scope.any? { |k, _| ScopePredicateParser::SUFFIX_PATTERN.match?(k.to_s) }
1509
+ refuse_protected_predicate_references!(template)
1041
1510
  end
1042
1511
 
1043
1512
  # Validate that any requested +columns+ are real model columns before
@@ -1052,7 +1521,86 @@ module Woods
1052
1521
  return unless params['columns']
1053
1522
 
1054
1523
  @model_validator.validate_columns!(params['model'], params['columns'])
1055
- refuse_orphan_eav_value_selection!(params['columns'], params['model'])
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)
1056
1604
  end
1057
1605
 
1058
1606
  # Apply column selection to a relation.
@@ -1092,7 +1640,8 @@ module Woods
1092
1640
  #
1093
1641
  # @return [Arel::Nodes::SqlLiteral]
1094
1642
  def random_function
1095
- func = AdapterFamily.for(active_connection) == :mysql ? 'RAND' : 'RANDOM'
1643
+ adapter = active_connection.adapter_name.downcase
1644
+ func = adapter.include?('mysql') ? 'RAND' : 'RANDOM'
1096
1645
  Arel.sql("#{func}()")
1097
1646
  end
1098
1647