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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'woods/console/sql_noise_stripper'
4
- require 'woods/console/sqlite_read_guard'
5
4
 
6
5
  # @see Woods
7
6
  module Woods
@@ -24,7 +23,99 @@ module Woods
24
23
  # validator.validate!('DELETE FROM users') # raises SqlValidationError
25
24
  # validator.valid?('SELECT 1') # => true
26
25
  #
27
- class SqlValidator # rubocop:disable Metrics/ClassLength -- dialect views and delimiter checks supplement legacy validation
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
+
28
119
  # Forbidden statement prefixes (case-insensitive).
29
120
  #
30
121
  # Expanded beyond DML/DDL to cover:
@@ -41,8 +132,12 @@ module Woods
41
132
  # `RELEASE`, `START`) — SafeContext already owns the surrounding
42
133
  # transaction; inner tx control would corrupt it.
43
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.
44
139
  FORBIDDEN_KEYWORDS = %w[
45
- INSERT UPDATE DELETE DROP ALTER TRUNCATE CREATE GRANT REVOKE
140
+ INSERT UPDATE DELETE MERGE DROP ALTER TRUNCATE CREATE GRANT REVOKE
46
141
  DO CALL SET RESET LISTEN NOTIFY
47
142
  VACUUM ANALYZE CLUSTER REINDEX REFRESH LOCK
48
143
  PREPARE EXECUTE DEALLOCATE
@@ -65,18 +160,83 @@ module Woods
65
160
  load_file sleep benchmark
66
161
  ].freeze
67
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
+
68
226
  # Allowed statement prefixes (case-insensitive).
69
227
  #
70
228
  # `EXPLAIN ANALYZE` actually executes the planned query on PostgreSQL
71
229
  # (and the MySQL 8.0+ `EXPLAIN ANALYZE` does the same) — explicitly
72
- # 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
73
233
  # form `EXPLAIN (ANALYZE, FORMAT JSON) SELECT …` where `ANALYZE` follows
74
- # `(` rather than whitespace; the `(?!\s*\(?\s*ANALYZE)` lookahead
75
- # rejects both spellings so SafeContext doesn't silently trust
76
- # "we're just planning, not running" for what is a side-effectful
77
- # execution. `EXPLAIN (…)` without `ANALYZE` is still permitted
78
- # (e.g. `EXPLAIN (FORMAT JSON) SELECT 1`).
79
- 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
80
240
 
81
241
  # Frozen map of forbidden keyword => regex matching the keyword at statement start.
82
242
  # Used by {#check_forbidden_keywords!} and {#check_forbidden_keywords_in_body!}.
@@ -90,28 +250,75 @@ module Woods
90
250
  [kw, /\b#{kw}\b/i]
91
251
  end.freeze
92
252
 
93
- # Frozen map of forbidden keyword => regex matching the keyword anywhere in the body.
94
- # 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.
95
271
  FORBIDDEN_BODY_REGEXES = FORBIDDEN_KEYWORDS.to_h do |kw|
96
- [kw, /\b#{kw}\b/i]
272
+ [kw, /(?:\A|[;\n])\s*#{kw}\b/i]
97
273
  end.freeze
98
274
 
99
- # @param dialect [Symbol, nil] Known connection dialect, when available
100
- def initialize(dialect: nil, mysql_modes: nil)
101
- @dialect = dialect
102
- @mysql_modes = mysql_modes
103
- end
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|
306
+ [kw, /\b#{kw}\b/i]
307
+ end.freeze
104
308
 
105
- KNOWN_DIALECTS = %i[postgres mysql sqlite].freeze
309
+ # Frozen map of dangerous function name => regex matching a call to that function.
310
+ # Used by {#check_dangerous_functions!}.
311
+ DANGEROUS_FUNCTION_REGEXES = DANGEROUS_FUNCTIONS.to_h do |func|
312
+ [func, /\b#{func}\s*\(/i]
313
+ end.freeze
106
314
 
107
315
  # @raise [SqlValidationError] if the SQL is not a safe read-only statement
108
316
  def validate!(sql)
109
317
  raise SqlValidationError, 'SQL is empty' if sql.nil? || sql.strip.empty?
110
318
 
319
+ return validate_dialect_variants!(sql) if unknown_grammar?
320
+
111
321
  normalized = sql.strip
112
- check_balanced_delimiters!(normalized)
113
- check_supported_identifier_syntax!(normalized)
114
- SqliteReadGuard.validate!(normalized) if @dialect == :sqlite
115
322
 
116
323
  # Reject multiple statements (semicolons not inside string literals)
117
324
  if contains_multiple_statements?(normalized)
@@ -124,6 +331,12 @@ module Woods
124
331
  # Check for writable CTEs (before body keywords to give better error messages)
125
332
  check_writable_ctes!(normalized)
126
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
+
127
340
  # Check for forbidden keywords anywhere in the SQL body
128
341
  check_body_forbidden_keywords!(normalized)
129
342
 
@@ -133,6 +346,11 @@ module Woods
133
346
  # After stripping comments, check again for forbidden keywords that might have been hidden
134
347
  check_forbidden_keywords_in_body!(normalized)
135
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
+
136
354
  # Must start with an allowed prefix
137
355
  return if normalized.match?(ALLOWED_PREFIXES)
138
356
 
@@ -152,50 +370,27 @@ module Woods
152
370
 
153
371
  private
154
372
 
155
- def validation_views(sql)
156
- dialects = @dialect ? [@dialect] : KNOWN_DIALECTS
157
- dialects.flat_map do |dialect|
158
- modes = if dialect == :mysql
159
- @mysql_modes ? [@mysql_modes] : SqlNoiseStripper::MYSQL_QUOTE_MODES
160
- else
161
- [{}]
162
- end
163
- modes.map do |mode|
164
- SqlNoiseStripper.strip_noise(sql, dialect: dialect, **mode) do |comment|
165
- next unless comment.match?(/['"`$\\]/)
166
-
167
- raise SqlValidationError,
168
- 'Rejected: quoted executable comments have ambiguous SQL grammar; use ordinary SQL.'
169
- end
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)
170
383
  end
171
384
  end
385
+ nil
172
386
  end
173
387
 
174
- # The policy scanners retain ordinary quoted identifiers but do not
175
- # decode PostgreSQL Unicode escapes. Refuse that grammar before the
176
- # adapter can resolve an identifier differently from the policy gates.
177
- def check_supported_identifier_syntax!(sql)
178
- return if @dialect && @dialect != :postgres
179
-
180
- stripped = SqlNoiseStripper.strip_noise(sql, dialect: :postgres)
181
- tokens = stripped.scan(/"(?:[^"]|"")*"|(?<![A-Za-z0-9_$\u0080-\u{10ffff}])[uU]&"/)
182
- return unless tokens.any? { |token| token.start_with?('U&"', 'u&"') }
183
-
184
- raise SqlValidationError,
185
- 'Rejected: PostgreSQL escaped identifiers are unsupported; use ordinary quoted identifiers ' \
186
- 'or a structured Console tool.'
388
+ def strip_validation_noise(sql)
389
+ SqlNoiseStripper.strip_noise(sql, dialect: validation_dialect, **(@mysql_modes || {}))
187
390
  end
188
391
 
189
- def check_balanced_delimiters!(sql)
190
- validation_views(sql).each do |stripped|
191
- depth = 0
192
- stripped.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]/).each do |token|
193
- depth += 1 if token == '('
194
- depth -= 1 if token == ')'
195
- raise SqlValidationError, 'Rejected: unbalanced SQL parentheses' if depth.negative?
196
- end
197
- raise SqlValidationError, 'Rejected: unbalanced SQL parentheses' unless depth.zero?
198
- end
392
+ def validation_dialect
393
+ dialect || :postgres
199
394
  end
200
395
 
201
396
  # Check if the SQL contains multiple statements separated by semicolons.
@@ -204,7 +399,8 @@ module Woods
204
399
  # @param sql [String]
205
400
  # @return [Boolean]
206
401
  def contains_multiple_statements?(sql)
207
- validation_views(sql).any? { |stripped| stripped.include?(';') }
402
+ stripped = strip_validation_noise(sql)
403
+ stripped.include?(';')
208
404
  end
209
405
 
210
406
  # Check if the SQL starts with a forbidden keyword.
@@ -219,63 +415,237 @@ module Woods
219
415
 
220
416
  # Check if the SQL contains forbidden keywords anywhere in the body.
221
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
+ #
222
423
  # @param sql [String]
223
424
  # @raise [SqlValidationError] if a forbidden keyword is found
224
425
  def check_body_forbidden_keywords!(sql)
426
+ stripped = strip_validation_noise(sql)
427
+
225
428
  BODY_FORBIDDEN_REGEXES.each do |keyword, pattern|
226
- raise SqlValidationError, "Rejected: #{keyword} is not allowed" if sql.match?(pattern)
429
+ raise SqlValidationError, "Rejected: #{keyword} is not allowed" if stripped.match?(pattern)
227
430
  end
228
431
  end
229
432
 
230
433
  # Check if the SQL contains writable CTEs (WITH...DELETE/UPDATE/INSERT).
231
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
+ #
232
448
  # @param sql [String]
233
- # @raise [SqlValidationError] if a writable CTE is found
449
+ # @raise [SqlValidationError] if a writable CTE body is found
234
450
  def check_writable_ctes!(sql)
235
- 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)
236
454
 
237
- 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)..]
238
557
  end
239
558
 
240
559
  # Check if the SQL calls dangerous functions.
241
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
+ #
242
564
  # @param sql [String]
243
565
  # @raise [SqlValidationError] if a dangerous function is found
244
566
  def check_dangerous_functions!(sql)
245
- validation_views(sql).each do |view|
246
- check_dangerous_functions_in_view!(view)
567
+ stripped = strip_validation_noise(sql)
568
+
569
+ DANGEROUS_FUNCTION_REGEXES.each do |func, pattern|
570
+ raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed" if stripped.match?(pattern)
247
571
  end
248
572
  end
249
573
 
250
- def check_dangerous_functions_in_view!(view)
251
- view.scan(/(?:"([^"\n]+)"|`([^`]+)`|\b([a-z_][a-z0-9_]*))\s*\(/i) do |quoted, backtick, bare|
252
- func = (quoted || backtick || bare).downcase
253
- next unless DANGEROUS_FUNCTIONS.include?(func)
254
-
255
- raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed"
574
+ # Check every function-call-shaped identifier against ALLOWED_FUNCTIONS.
575
+ #
576
+ # @param sql [String]
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(', ')}."
256
594
  end
257
595
  end
258
596
 
259
- # Check if the SQL contains forbidden keywords anywhere in the body after stripping comments.
260
- # This catches comment-hidden injections like "SELECT 1 --;\nDELETE FROM users".
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.
261
613
  #
262
614
  # @param sql [String]
263
- # @raise [SqlValidationError] if a forbidden keyword is found
615
+ # @raise [SqlValidationError] if a forbidden keyword is found as a
616
+ # statement leader, or a DML keyword anywhere in the body
264
617
  def check_forbidden_keywords_in_body!(sql)
265
- 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)
266
622
 
267
- # Check if any forbidden keyword appears anywhere (not just at start)
268
- FORBIDDEN_BODY_REGEXES.each do |keyword, body_pattern|
269
- # Look for keyword as a whole word anywhere in the stripped SQL
270
- next unless stripped.match?(body_pattern)
623
+ raise SqlValidationError, "Rejected: #{keyword} statements are not allowed (found in SQL body)"
624
+ end
271
625
 
272
- # Make sure it's not at the very start (already checked)
273
- unless stripped.match?(FORBIDDEN_PREFIX_REGEXES[keyword])
274
- raise SqlValidationError,
275
- "Rejected: #{keyword} statements are not allowed (found in SQL body)"
626
+ dml_body_views(sql).each do |view|
627
+ DML_BODY_REGEXES.each do |keyword, pattern|
628
+ next unless view.match?(pattern)
629
+
630
+ raise SqlValidationError, "Rejected: #{keyword} statements are not allowed (found in SQL body)"
276
631
  end
277
632
  end
278
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
279
649
  end
280
650
  end
281
651
  end