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
@@ -23,7 +23,99 @@ module Woods
23
23
  # validator.validate!('DELETE FROM users') # raises SqlValidationError
24
24
  # validator.valid?('SELECT 1') # => true
25
25
  #
26
- class SqlValidator
26
+ # Metrics/ClassLength is disabled here for the same reason as tool_specs.rb:
27
+ # most of the length is declarative allowlist/denylist data (documented
28
+ # named constants), not imperative logic.
29
+ class SqlValidator # rubocop:disable Metrics/ClassLength
30
+ # SQL dialects whose normalization the lock-clause check can run over.
31
+ KNOWN_DIALECTS = %i[postgres mysql].freeze
32
+
33
+ # The dialect the statement will execute under, when the caller knows
34
+ # it. `nil` (the default) keeps the conservative union: every check
35
+ # runs against both dialect normalizations, which can reject a
36
+ # statement that is valid under one dialect's quote grammar (a MySQL
37
+ # `\'` escape hides prose that the PostgreSQL view reads as SQL). When
38
+ # the execution boundary knows the adapter, passing the matching
39
+ # dialect validates the statement the way the server will actually
40
+ # parse it. Every known bypass form stays rejected under every
41
+ # dialect: the executable-comment views in {#lock_clause_views} cover
42
+ # `#` comment splits and `/*!...*/` splits under both semantics.
43
+ #
44
+ # @return [Symbol, nil]
45
+ attr_reader :dialect
46
+
47
+ # mysql_modes contains the executing session's ANSI_QUOTES and
48
+ # NO_BACKSLASH_ESCAPES flags. nil conservatively validates all combinations.
49
+ def initialize(dialect: nil, mysql_modes: nil)
50
+ unless dialect.nil? || KNOWN_DIALECTS.include?(dialect)
51
+ raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{KNOWN_DIALECTS.inspect}"
52
+ end
53
+
54
+ @dialect = dialect
55
+ @mysql_modes = mysql_modes
56
+ end
57
+ # Row-lock clauses that would take live row locks even under the
58
+ # rolled-back SafeContext transaction (M5).
59
+ #
60
+ # `SELECT ... FOR UPDATE` and friends hold row locks for the duration
61
+ # of the wrapping transaction — on both PostgreSQL (`FOR UPDATE`,
62
+ # `FOR NO KEY UPDATE`, `FOR SHARE`, `FOR KEY SHARE`, with optional
63
+ # `NOWAIT`/`SKIP LOCKED`) and MySQL (`FOR UPDATE` with the same
64
+ # modifiers, plus `LOCK IN SHARE MODE`). While SafeContext rolls the
65
+ # transaction back, the locks themselves are held live against other
66
+ # writers for the whole request — a denial-of-service lever, not a
67
+ # read. A dedicated check (rather than growing BODY_FORBIDDEN_KEYWORDS)
68
+ # because these are multi-word trailing clauses: the body-keyword scan
69
+ # is single-token and leader-anchored, neither of which can express
70
+ # "FOR immediately followed by an update/share keyword".
71
+ #
72
+ # Scanned against the noise-stripped SQL (comments and string literals
73
+ # removed), so prose like `WHERE note = 'waiting for update'` cannot
74
+ # fire it. Over-detection on a pathologically quoted identifier such
75
+ # as `"for update"` is accepted: conservative rejection is the
76
+ # validator's documented posture.
77
+ LOCK_CLAUSE_PATTERN = /
78
+ \bFOR\s+(?:NO\s+KEY\s+UPDATE|UPDATE|KEY\s+SHARE|SHARE)\b
79
+ |\bLOCK\s+IN\s+SHARE\s+MODE\b
80
+ /xi
81
+
82
+ # Matches an `AS (` opening whose balanced body {#check_writable_ctes!}
83
+ # inspects. Kept loose on purpose: deciding which `AS (` shapes are
84
+ # CTEs needs a real parser, and every non-CTE match (a WINDOW
85
+ # definition, a nested WITH) is only rejected when its body starts
86
+ # with DML, which is never valid in an allowed statement.
87
+ AS_BODY_PATTERN = /\bAS\s*\(/i
88
+
89
+ # A CTE (or any `AS (...)`) body that opens with a DML keyword —
90
+ # the writable-CTE shape, in any WITH position. A single leading
91
+ # `(` is tolerated: PostgreSQL rejects `WITH a AS ((DELETE ...))`
92
+ # outright, so this is over-detection on a syntax that never runs,
93
+ # not under-detection.
94
+ WRITABLE_CTE_BODY_PATTERN = /\A\s*\(?\s*(?:DELETE|UPDATE|INSERT)\b/i
95
+
96
+ # Matches a CTE list attached to a top-level data-modifying statement:
97
+ # `WITH a AS (SELECT 1) DELETE FROM users RETURNING *`. Legal grammar
98
+ # on PostgreSQL; the statement prefix is `WITH`, so the allowed-prefix
99
+ # check passes it, and DELETE/UPDATE have no body keyword for the
100
+ # body scans to catch (INSERT only tripped INTO, MERGE nothing).
101
+ #
102
+ # The lazy `[\s\S]*?` reaches the first closing paren that is
103
+ # immediately followed by a DML keyword. In an allowed statement
104
+ # that position is grammatically impossible: after a CTE list's
105
+ # final `)` only SELECT (or another WITH) is valid, and a DML word
106
+ # anywhere else is separated from the paren by other tokens or sits
107
+ # inside a string literal, which {SqlNoiseStripper.strip_noise}
108
+ # removed. Column names like `updated_at`/`deleted_at` cannot match:
109
+ # `\b` requires the keyword to start right after the paren, and a
110
+ # bare `update` column follows a comma or SELECT, not a paren.
111
+ WITH_ATTACHED_DML_PATTERN = /\bWITH\b[\s\S]*?\)\s*(?:DELETE|UPDATE|INSERT|MERGE)\b/i
112
+
113
+ # Matches a MySQL executable comment (`/*!...*/` or the version-guarded
114
+ # `/*!NNNNN...*/`), capturing the body. Only scanned by
115
+ # {#lock_clause_views} — {SqlNoiseStripper} deliberately leaves these
116
+ # markers in place because their meaning is version-dependent.
117
+ EXECUTABLE_COMMENT_PATTERN = %r{/\*!(?:\d{5})?(.*?)\*/}m
118
+
27
119
  # Forbidden statement prefixes (case-insensitive).
28
120
  #
29
121
  # Expanded beyond DML/DDL to cover:
@@ -40,8 +132,12 @@ module Woods
40
132
  # `RELEASE`, `START`) — SafeContext already owns the surrounding
41
133
  # transaction; inner tx control would corrupt it.
42
134
  # - File I/O vectors (`LOAD`, `HANDLER`, `COPY`).
135
+ # - Write vector (`MERGE`) — an upsert statement that can INSERT/UPDATE/
136
+ # DELETE depending on match, on databases that support it. Already
137
+ # caught by the allowed-prefix check (it isn't SELECT/WITH/EXPLAIN),
138
+ # but listed here too for defense in depth against a body-scan bypass.
43
139
  FORBIDDEN_KEYWORDS = %w[
44
- INSERT UPDATE DELETE DROP ALTER TRUNCATE CREATE GRANT REVOKE
140
+ INSERT UPDATE DELETE MERGE DROP ALTER TRUNCATE CREATE GRANT REVOKE
45
141
  DO CALL SET RESET LISTEN NOTIFY
46
142
  VACUUM ANALYZE CLUSTER REINDEX REFRESH LOCK
47
143
  PREPARE EXECUTE DEALLOCATE
@@ -64,18 +160,83 @@ module Woods
64
160
  load_file sleep benchmark
65
161
  ].freeze
66
162
 
163
+ # Enforceable read-only function policy for `console_sql`.
164
+ #
165
+ # The previous control was a denylist (DANGEROUS_FUNCTIONS above), which
166
+ # is provably incomplete: PostgreSQL ships side-effecting functions that
167
+ # are technically legal inside a SELECT list, e.g. `pg_terminate_backend`
168
+ # (kills another backend), `pg_advisory_lock`/`pg_advisory_unlock`
169
+ # (session-held locks that outlive the rolled-back transaction),
170
+ # `nextval`/`setval` (permanently mutate a sequence, DDL rollback does
171
+ # not undo it). Enumerating every such function is a losing race.
172
+ #
173
+ # Instead, `console_sql` allowlists a conservative set of pure/read
174
+ # functions and rejects everything else with a typed error naming the
175
+ # function. This is the authoritative, documented policy: extend it
176
+ # deliberately, not by discovering a false positive and reaching for
177
+ # DANGEROUS_FUNCTIONS instead.
178
+ # Read-only functions supported across all three backends Woods claims
179
+ # (MySQL, PostgreSQL, SQLite). Aggregates, window functions, and pure
180
+ # scalar string/number/date/JSON readers only — nothing that mutates
181
+ # state, sleeps, or reaches outside the query. Kept backend-agnostic on
182
+ # purpose: a PG-only list rejected ordinary MySQL/SQLite analytics.
183
+ ALLOWED_FUNCTIONS = %w[
184
+ count sum avg min max coalesce nullif
185
+ group_concat string_agg array_agg
186
+ row_number rank dense_rank percent_rank cume_dist ntile
187
+ lag lead first_value last_value nth_value
188
+ lower upper length char_length character_length octet_length
189
+ substr substring trim ltrim rtrim btrim concat concat_ws
190
+ left right replace reverse repeat lpad rpad
191
+ position strpos split_part
192
+ abs round ceil ceiling floor mod power pow sqrt sign greatest least
193
+ cast convert
194
+ extract date_part date_trunc to_char to_date to_timestamp
195
+ strftime datetime date time julianday unixepoch
196
+ now current_date current_time current_timestamp
197
+ json_extract json_value json_type json_array_length
198
+ json_extract_path json_extract_path_text
199
+ jsonb_extract_path jsonb_extract_path_text
200
+ ].freeze
201
+
202
+ # SQL keywords that are legitimately followed by `(` but are not
203
+ # function calls: a subquery predicate (`IN (SELECT ...)`), a
204
+ # parenthesized boolean group (`WHERE (a AND b)`), etc. Excluded from
205
+ # the function-allowlist scan so they are not misclassified as unknown
206
+ # function calls.
207
+ FUNCTION_SCAN_EXCLUDED_KEYWORDS = %w[
208
+ IN EXISTS NOT AND OR VALUES WHERE HAVING ON IS BETWEEN CASE WHEN
209
+ THEN ELSE END FROM JOIN USING WITH SELECT DISTINCT ALL ANY SOME
210
+ UNION INTERSECT EXCEPT ORDER GROUP BY ASC DESC LIMIT OFFSET AS INTO
211
+ OVER PARTITION FILTER WITHIN RETURNING EXPLAIN
212
+ ].freeze
213
+
214
+ # EXPLAIN is a statement leader, so `EXPLAIN (FORMAT JSON) SELECT` is an
215
+ # option list, not a call; ALLOWED_PREFIXES already refuses ANALYZE inside
216
+ # that list (B-125).
217
+ # Matches a function-call shape: an identifier immediately followed by
218
+ # `(`, where the identifier may be bare, double-quoted (ANSI/PostgreSQL),
219
+ # or backtick-quoted (MySQL). Quoting the name was the bypass — a bare
220
+ # `\bword\(` pattern never fired on `"pg_terminate_backend"(`, so a
221
+ # side-effecting function wrapped in quotes defeated both the allowlist
222
+ # and the legacy denylist. Runs against the noise-stripped SQL (comments
223
+ # and string literals removed) so literal content can't be misread.
224
+ FUNCTION_CALL_PATTERN = /(?:"([^"]+)"|`([^`]+)`|\b([A-Za-z_][A-Za-z0-9_]*))\s*\(/
225
+
67
226
  # Allowed statement prefixes (case-insensitive).
68
227
  #
69
228
  # `EXPLAIN ANALYZE` actually executes the planned query on PostgreSQL
70
229
  # (and the MySQL 8.0+ `EXPLAIN ANALYZE` does the same) — explicitly
71
- # reject the `ANALYZE` variant. PostgreSQL also accepts an option-list
230
+ # reject the `ANALYZE` variant, and PostgreSQL's `ANALYSE` spelling
231
+ # alias for it (both are accepted by the server; rejecting only one
232
+ # left the other as a bypass). PostgreSQL also accepts an option-list
72
233
  # form `EXPLAIN (ANALYZE, FORMAT JSON) SELECT …` where `ANALYZE` follows
73
- # `(` rather than whitespace; the `(?!\s*\(?\s*ANALYZE)` lookahead
74
- # rejects both spellings so SafeContext doesn't silently trust
75
- # "we're just planning, not running" for what is a side-effectful
76
- # execution. `EXPLAIN (…)` without `ANALYZE` is still permitted
77
- # (e.g. `EXPLAIN (FORMAT JSON) SELECT 1`).
78
- ALLOWED_PREFIXES = /\A\s*(SELECT|WITH|EXPLAIN(?!\s+ANALYZE)(?!\s*\([^)]*\bANALYZE\b))\b/i
234
+ # `(` rather than whitespace; the `(?!\s*\(?\s*ANALY[SZ]E)` lookahead
235
+ # rejects both spellings in both positions so SafeContext doesn't
236
+ # silently trust "we're just planning, not running" for what is a
237
+ # side-effectful execution. `EXPLAIN (…)` without `ANALYZE`/`ANALYSE`
238
+ # is still permitted (e.g. `EXPLAIN (FORMAT JSON) SELECT 1`).
239
+ ALLOWED_PREFIXES = /\A\s*(SELECT|WITH|EXPLAIN(?!\s+ANALY[SZ]E)(?!\s*\([^)]*\bANALY[SZ]E\b))\b/i
79
240
 
80
241
  # Frozen map of forbidden keyword => regex matching the keyword at statement start.
81
242
  # Used by {#check_forbidden_keywords!} and {#check_forbidden_keywords_in_body!}.
@@ -89,9 +250,59 @@ module Woods
89
250
  [kw, /\b#{kw}\b/i]
90
251
  end.freeze
91
252
 
92
- # Frozen map of forbidden keyword => regex matching the keyword anywhere in the body.
93
- # Used by {#check_forbidden_keywords_in_body!} for the whole-body scan.
253
+ # Frozen map of forbidden keyword => regex matching the keyword as a
254
+ # statement leader: at the very start of the SQL, or immediately after
255
+ # a `;` or a newline. Used by {#check_forbidden_keywords_in_body!}.
256
+ #
257
+ # A live `;` never reaches this check unescaped — #validate! rejects
258
+ # multiple statements earlier — so a `;` match here only ever comes
259
+ # from inside a comment that {SqlNoiseStripper} stripped. The same is
260
+ # true of the newline: every comment flavor (`--`, `#`, `/* ... */`)
261
+ # leaves one behind in place of its own content specifically so a
262
+ # comment-hidden statement (`SELECT 1 --;\nDELETE FROM users`,
263
+ # `SELECT 1 /*;*/ DELETE FROM users`) still reads as following a
264
+ # boundary once the comment is gone.
265
+ #
266
+ # Matching anywhere (the previous behavior) rejected a column
267
+ # legitimately named after a forbidden keyword — `WHERE do = 1`,
268
+ # `release`, `lock`, `handler` are all plausible column names, none of
269
+ # them a statement. Anchoring to leader positions is what tells the
270
+ # two apart.
94
271
  FORBIDDEN_BODY_REGEXES = FORBIDDEN_KEYWORDS.to_h do |kw|
272
+ [kw, /(?:\A|[;\n])\s*#{kw}\b/i]
273
+ end.freeze
274
+
275
+ # DML keywords that are additionally forbidden as bare tokens ANYWHERE
276
+ # in the statement body, not just at statement-leader positions.
277
+ #
278
+ # {FORBIDDEN_BODY_REGEXES} stays leader-anchored for the full
279
+ # {FORBIDDEN_KEYWORDS} set because words like `do`, `lock`, `release`,
280
+ # `handler` are plausible bare column names (`WHERE do = 1` is a read,
281
+ # not a write). INSERT, UPDATE, and DELETE cannot hide behind that
282
+ # argument: they are reserved words on every supported backend, so a
283
+ # bare occurrence inside an allowed statement is never an identifier —
284
+ # it is a mid-body write (e.g. `SELECT 1 UPDATE posts SET status = 10`)
285
+ # that used to pass validation and fail as an adapter-level syntax
286
+ # error instead of a typed refusal at this boundary.
287
+ #
288
+ # MERGE is deliberately NOT in this set: SQLite permits an unquoted
289
+ # `merge` column, so body-level MERGE scanning would false-positive on
290
+ # ordinary selects like `SELECT merge FROM posts`. MERGE statements
291
+ # stay covered where they are actually statements: the allowed-prefix
292
+ # rule (MERGE is in {FORBIDDEN_KEYWORDS}, checked at statement start)
293
+ # and {WITH_ATTACHED_DML_PATTERN} for the WITH-attached shape.
294
+ #
295
+ # Scanned against the noise-stripped SQL ({SqlNoiseStripper}), so
296
+ # literal content never triggers (`SELECT 'update' AS word` is a
297
+ # value, not a write), and `\b` keeps identifier-shaped column names
298
+ # (`updated_at`, `last_update`) accepted. Multi-word lock clauses
299
+ # (`FOR UPDATE`) keep their dedicated earlier check, so its message
300
+ # still wins over this one.
301
+ DML_BODY_KEYWORDS = %w[INSERT UPDATE DELETE].freeze
302
+
303
+ # Frozen map of DML body keyword => regex matching the keyword as a
304
+ # bare token anywhere. Used by {#check_forbidden_keywords_in_body!}.
305
+ DML_BODY_REGEXES = DML_BODY_KEYWORDS.to_h do |kw|
95
306
  [kw, /\b#{kw}\b/i]
96
307
  end.freeze
97
308
 
@@ -105,6 +316,8 @@ module Woods
105
316
  def validate!(sql)
106
317
  raise SqlValidationError, 'SQL is empty' if sql.nil? || sql.strip.empty?
107
318
 
319
+ return validate_dialect_variants!(sql) if unknown_grammar?
320
+
108
321
  normalized = sql.strip
109
322
 
110
323
  # Reject multiple statements (semicolons not inside string literals)
@@ -118,6 +331,12 @@ module Woods
118
331
  # Check for writable CTEs (before body keywords to give better error messages)
119
332
  check_writable_ctes!(normalized)
120
333
 
334
+ # Check for a CTE list attached to top-level DML
335
+ check_with_attached_dml!(normalized)
336
+
337
+ # Check for row-lock clauses (FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE)
338
+ check_lock_clauses!(normalized)
339
+
121
340
  # Check for forbidden keywords anywhere in the SQL body
122
341
  check_body_forbidden_keywords!(normalized)
123
342
 
@@ -127,6 +346,11 @@ module Woods
127
346
  # After stripping comments, check again for forbidden keywords that might have been hidden
128
347
  check_forbidden_keywords_in_body!(normalized)
129
348
 
349
+ # Enforce the read-only function allowlist (replaces relying solely
350
+ # on DANGEROUS_FUNCTIONS, which cannot enumerate every side-effecting
351
+ # function).
352
+ check_function_allowlist!(normalized)
353
+
130
354
  # Must start with an allowed prefix
131
355
  return if normalized.match?(ALLOWED_PREFIXES)
132
356
 
@@ -146,13 +370,36 @@ module Woods
146
370
 
147
371
  private
148
372
 
373
+ def unknown_grammar?
374
+ dialect.nil? || (dialect == :mysql && @mysql_modes.nil?)
375
+ end
376
+
377
+ def validate_dialect_variants!(sql)
378
+ if dialect.nil?
379
+ KNOWN_DIALECTS.each { |name| self.class.new(dialect: name).validate!(sql) }
380
+ else
381
+ SqlNoiseStripper::MYSQL_QUOTE_MODES.each do |mode|
382
+ self.class.new(dialect: :mysql, mysql_modes: mode).validate!(sql)
383
+ end
384
+ end
385
+ nil
386
+ end
387
+
388
+ def strip_validation_noise(sql)
389
+ SqlNoiseStripper.strip_noise(sql, dialect: validation_dialect, **(@mysql_modes || {}))
390
+ end
391
+
392
+ def validation_dialect
393
+ dialect || :postgres
394
+ end
395
+
149
396
  # Check if the SQL contains multiple statements separated by semicolons.
150
397
  # Strips SQL comments and string literals before checking.
151
398
  #
152
399
  # @param sql [String]
153
400
  # @return [Boolean]
154
401
  def contains_multiple_statements?(sql)
155
- stripped = SqlNoiseStripper.strip_noise(sql)
402
+ stripped = strip_validation_noise(sql)
156
403
  stripped.include?(';')
157
404
  end
158
405
 
@@ -168,54 +415,237 @@ module Woods
168
415
 
169
416
  # Check if the SQL contains forbidden keywords anywhere in the body.
170
417
  #
418
+ # Scans the noise-stripped SQL (comments and string literals removed),
419
+ # not the raw input. A raw scan misreads English words that happen to
420
+ # match a keyword inside a data literal — `WHERE body = 'please update
421
+ # the record'` contains the substring UPDATE but is not a write.
422
+ #
171
423
  # @param sql [String]
172
424
  # @raise [SqlValidationError] if a forbidden keyword is found
173
425
  def check_body_forbidden_keywords!(sql)
426
+ stripped = strip_validation_noise(sql)
427
+
174
428
  BODY_FORBIDDEN_REGEXES.each do |keyword, pattern|
175
- raise SqlValidationError, "Rejected: #{keyword} is not allowed" if sql.match?(pattern)
429
+ raise SqlValidationError, "Rejected: #{keyword} is not allowed" if stripped.match?(pattern)
176
430
  end
177
431
  end
178
432
 
179
433
  # Check if the SQL contains writable CTEs (WITH...DELETE/UPDATE/INSERT).
180
434
  #
435
+ # Walks EVERY `AS (...)` body in the statement, not just the first one.
436
+ # The previous implementation anchored a single regex to the WITH
437
+ # leader, so a writable CTE in second-or-later position validated
438
+ # cleanly and PostgreSQL executed it:
439
+ # `WITH a AS (SELECT 1), b AS (DELETE FROM users RETURNING *) SELECT * FROM b`.
440
+ # There is no full SQL parser here, so the walker matches every
441
+ # `AS (` shape and extracts the balanced paren body after it. In an
442
+ # allowed statement the extra matches are harmless: a WINDOW definition
443
+ # (`w AS (PARTITION BY x)`) or a CTE nested inside another CTE body is
444
+ # only rejected when its body *starts* with a DML keyword, which is
445
+ # never legitimate grammar. Scans noise-stripped input for the same
446
+ # reason as {#check_body_forbidden_keywords!}.
447
+ #
181
448
  # @param sql [String]
182
- # @raise [SqlValidationError] if a writable CTE is found
449
+ # @raise [SqlValidationError] if a writable CTE body is found
183
450
  def check_writable_ctes!(sql)
184
- return unless sql.match?(/WITH\s+\w+\s+AS\s*\(\s*(DELETE|UPDATE|INSERT)\b/i)
451
+ stripped = strip_validation_noise(sql)
452
+ each_as_body(stripped) do |body|
453
+ next unless body.match?(WRITABLE_CTE_BODY_PATTERN)
185
454
 
186
- raise SqlValidationError, 'Rejected: writable CTEs are not allowed'
455
+ raise SqlValidationError, 'Rejected: writable CTEs are not allowed'
456
+ end
457
+ end
458
+
459
+ # Check if the SQL carries a row-lock clause ({LOCK_CLAUSE_PATTERN})
460
+ # in any dialect-normalized view of the statement.
461
+ #
462
+ # {#lock_clause_views} produces, for each SQL dialect, the
463
+ # noise-stripped text with every MySQL executable comment
464
+ # (`/*!...*/`) interpreted under BOTH of its possible semantics:
465
+ # replaced by whitespace (version guard unsatisfied — MySQL treats
466
+ # the comment as a plain comment and `LOCK /*!99999 */ IN SHARE MODE`
467
+ # runs as a live lock clause), and replaced by its body (guard
468
+ # satisfied — the body executes in place, so `LOCK /*! IN SHARE */
469
+ # MODE` also runs as one). Running the pattern over every view and
470
+ # rejecting on any match means a lock clause cannot be split or
471
+ # hidden by a comment form without at least one view seeing it,
472
+ # while no comment body is ever hidden from the check.
473
+ #
474
+ # Scans the noise-stripped SQL; see {LOCK_CLAUSE_PATTERN} for why this
475
+ # is a dedicated check instead of a body-keyword entry.
476
+ #
477
+ # @param sql [String]
478
+ # @raise [SqlValidationError] if a lock clause is found
479
+ def check_lock_clauses!(sql)
480
+ return unless lock_clause_views(sql).any? { |view| view.match?(LOCK_CLAUSE_PATTERN) }
481
+
482
+ raise SqlValidationError,
483
+ 'Rejected: row-lock clauses (FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE) are not allowed'
484
+ end
485
+
486
+ # Every dialect-normalized view of the SQL the lock-clause check must
487
+ # consider. With {#dialect} known, only that dialect's view is
488
+ # produced (the server will parse the statement one way); without it,
489
+ # both views run as a conservative union. Each view is scanned under
490
+ # both executable-comment semantics; see {#check_lock_clauses!}.
491
+ #
492
+ # @param sql [String]
493
+ # @return [Array<String>]
494
+ def lock_clause_views(sql)
495
+ dialects = dialect ? [dialect] : KNOWN_DIALECTS
496
+ dialects.flat_map do |dialect_name|
497
+ stripped = SqlNoiseStripper.strip_noise(sql, dialect: dialect_name, **(@mysql_modes || {}))
498
+ [stripped.gsub(EXECUTABLE_COMMENT_PATTERN, ' '),
499
+ stripped.gsub(EXECUTABLE_COMMENT_PATTERN) { Regexp.last_match[1] }]
500
+ end
501
+ end
502
+
503
+ # Check if a CTE list is attached to a top-level data-modifying
504
+ # statement ({WITH_ATTACHED_DML_PATTERN}).
505
+ #
506
+ # PostgreSQL allows `WITH a AS (SELECT 1) DELETE FROM users RETURNING *`;
507
+ # the statement starts with WITH so it passes the allowed-prefix check,
508
+ # and only INSERT ever tripped a body keyword (INTO), incidentally.
509
+ # Runs before {#check_body_forbidden_keywords!} so the WITH-attached
510
+ # shapes get this specific message rather than the generic INTO one.
511
+ #
512
+ # @param sql [String]
513
+ # @raise [SqlValidationError] if DML follows the CTE list
514
+ def check_with_attached_dml!(sql)
515
+ stripped = strip_validation_noise(sql)
516
+ return unless stripped.match?(WITH_ATTACHED_DML_PATTERN)
517
+
518
+ raise SqlValidationError,
519
+ 'Rejected: a WITH clause cannot be attached to a data-modifying ' \
520
+ 'statement (DELETE/UPDATE/INSERT/MERGE)'
521
+ end
522
+
523
+ # Yields the balanced paren body following every `AS (` occurrence.
524
+ #
525
+ # Continues scanning from just inside each match so bodies nested
526
+ # inside an already-yielded body (a WITH inside a CTE) are yielded
527
+ # too. String literals are already stripped, so no paren inside a
528
+ # literal can skew the balance count.
529
+ #
530
+ # @param stripped [String] noise-stripped SQL
531
+ # @yieldparam body [String] text between the parens
532
+ def each_as_body(stripped, &block)
533
+ pos = 0
534
+ while (match = stripped.match(AS_BODY_PATTERN, pos))
535
+ yield balanced_paren_body(stripped, match.end(0) - 1)
536
+ pos = match.end(0)
537
+ end
538
+ end
539
+
540
+ # Return the text between the `(` at +open_index+ and its matching
541
+ # `)`. An unterminated body returns the remainder of the string
542
+ # (over-detection only: the body still gets checked).
543
+ #
544
+ # @param text [String]
545
+ # @param open_index [Integer] index of the opening `(` in +text+
546
+ # @return [String]
547
+ def balanced_paren_body(text, open_index)
548
+ depth = 0
549
+ open_index.upto(text.length - 1) do |i|
550
+ depth += 1 if text[i] == '('
551
+ if text[i] == ')'
552
+ depth -= 1
553
+ return text[(open_index + 1)...i] if depth.zero?
554
+ end
555
+ end
556
+ text[(open_index + 1)..]
187
557
  end
188
558
 
189
559
  # Check if the SQL calls dangerous functions.
190
560
  #
561
+ # Scans the noise-stripped SQL so a function name that only appears
562
+ # inside a comment or string literal isn't misread as a call.
563
+ #
191
564
  # @param sql [String]
192
565
  # @raise [SqlValidationError] if a dangerous function is found
193
566
  def check_dangerous_functions!(sql)
567
+ stripped = strip_validation_noise(sql)
568
+
194
569
  DANGEROUS_FUNCTION_REGEXES.each do |func, pattern|
195
- raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed" if sql.match?(pattern)
570
+ raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed" if stripped.match?(pattern)
196
571
  end
197
572
  end
198
573
 
199
- # Check if the SQL contains forbidden keywords anywhere in the body after stripping comments.
200
- # This catches comment-hidden injections like "SELECT 1 --;\nDELETE FROM users".
574
+ # Check every function-call-shaped identifier against ALLOWED_FUNCTIONS.
201
575
  #
202
576
  # @param sql [String]
203
- # @raise [SqlValidationError] if a forbidden keyword is found
577
+ # @raise [SqlValidationError] if a non-allowlisted function is called
578
+ def check_function_allowlist!(sql)
579
+ stripped = strip_validation_noise(sql)
580
+
581
+ stripped.scan(FUNCTION_CALL_PATTERN) do
582
+ match = Regexp.last_match
583
+ quoted = !(match[1] || match[2]).nil?
584
+ identifier = match[1] || match[2] || match[3]
585
+ # A bare keyword before `(` is grammar (IN/EXISTS/…), not a call; a
586
+ # quoted name before `(` is always a call, so keyword exclusion
587
+ # applies only to the bare form.
588
+ next if !quoted && FUNCTION_SCAN_EXCLUDED_KEYWORDS.include?(identifier.upcase)
589
+ next if ALLOWED_FUNCTIONS.include?(identifier.downcase)
590
+
591
+ raise SqlValidationError,
592
+ "Rejected: function '#{identifier}' is not on the read-only function allowlist. " \
593
+ "Allowed: #{ALLOWED_FUNCTIONS.join(', ')}."
594
+ end
595
+ end
596
+
597
+ # Check if the SQL contains a forbidden keyword at a statement-leader
598
+ # position after stripping comments and string literals. This catches
599
+ # comment-hidden injections like "SELECT 1 --;\nDELETE FROM users",
600
+ # and — by also stripping literals, not just comments — avoids
601
+ # rejecting a legitimate value like `body = 'please update the
602
+ # record'`, where UPDATE is English prose inside data, not SQL.
603
+ # {FORBIDDEN_BODY_REGEXES} only matches leader positions (start of the
604
+ # SQL, or right after a `;`/newline), so a column legitimately named
605
+ # after a forbidden keyword (`WHERE do = 1`) is not misread as one.
606
+ #
607
+ # The DML keywords ({DML_BODY_REGEXES}) are then matched as bare tokens
608
+ # across every dialect-normalized view ({#dml_body_views}): they are
609
+ # reserved words, so they can never be bare identifiers the way `do`
610
+ # or `lock` can. Runs after {#check_lock_clauses!} in {#validate!}, so
611
+ # a lock-clause statement (`SELECT 1 FOR UPDATE`) keeps its dedicated
612
+ # message.
613
+ #
614
+ # @param sql [String]
615
+ # @raise [SqlValidationError] if a forbidden keyword is found as a
616
+ # statement leader, or a DML keyword anywhere in the body
204
617
  def check_forbidden_keywords_in_body!(sql)
205
- stripped = SqlNoiseStripper.strip_comments(sql)
618
+ stripped = strip_validation_noise(sql)
619
+
620
+ FORBIDDEN_BODY_REGEXES.each do |keyword, leader_pattern|
621
+ next unless stripped.match?(leader_pattern)
622
+
623
+ raise SqlValidationError, "Rejected: #{keyword} statements are not allowed (found in SQL body)"
624
+ end
206
625
 
207
- # Check if any forbidden keyword appears anywhere (not just at start)
208
- FORBIDDEN_BODY_REGEXES.each do |keyword, body_pattern|
209
- # Look for keyword as a whole word anywhere in the stripped SQL
210
- next unless stripped.match?(body_pattern)
626
+ dml_body_views(sql).each do |view|
627
+ DML_BODY_REGEXES.each do |keyword, pattern|
628
+ next unless view.match?(pattern)
211
629
 
212
- # Make sure it's not at the very start (already checked)
213
- unless stripped.match?(FORBIDDEN_PREFIX_REGEXES[keyword])
214
- raise SqlValidationError,
215
- "Rejected: #{keyword} statements are not allowed (found in SQL body)"
630
+ raise SqlValidationError, "Rejected: #{keyword} statements are not allowed (found in SQL body)"
216
631
  end
217
632
  end
218
633
  end
634
+
635
+ # Every dialect-normalized view of the SQL the DML body-token scan
636
+ # must consider, mirroring {#lock_clause_views}: with {#dialect} known,
637
+ # only that dialect's view (the server parses the statement one way, so
638
+ # a MySQL `\'` escape must hide prose from this scan); without it, both
639
+ # normalizations run as the conservative union. {SqlNoiseStripper}
640
+ # leaves MySQL executable comments (`/*!...*/`) visible in every view,
641
+ # so DML inside them stays detected.
642
+ #
643
+ # @param sql [String]
644
+ # @return [Array<String>]
645
+ def dml_body_views(sql)
646
+ dialects = dialect ? [dialect] : KNOWN_DIALECTS
647
+ dialects.map { |dialect_name| SqlNoiseStripper.strip_noise(sql, dialect: dialect_name, **(@mysql_modes || {})) }
648
+ end
219
649
  end
220
650
  end
221
651
  end
@@ -32,10 +32,10 @@ module Woods
32
32
 
33
33
  def active? = !(@blocked_bare.empty? && @blocked_qualified.empty?)
34
34
 
35
- def check_sql!(sql)
35
+ def check_sql!(sql, dialect: nil, mysql_modes: nil)
36
36
  return unless active? && sql&.length&.positive?
37
37
 
38
- SqlTableScanner.identifiers_in(sql).each do |raw|
38
+ SqlTableScanner.identifiers_in(sql, dialect: dialect, mysql_modes: mysql_modes).each do |raw|
39
39
  raise TableGateError, reject_message(raw) if blocked?(raw)
40
40
  end
41
41
  end