woods 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/CONTRIBUTING.md +135 -10
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -18
  10. data/docs/CONSOLE_MCP_SETUP.md +141 -12
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +59 -2
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +184 -9
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/exe/woods-mcp-http +16 -9
  33. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  34. data/lib/tasks/woods.rake +23 -7
  35. data/lib/tasks/woods_checks.rake +2 -2
  36. data/lib/woods/agent_configuration/cli.rb +1 -1
  37. data/lib/woods/agent_configuration/layout.rb +16 -2
  38. data/lib/woods/agent_configuration/plan.rb +13 -3
  39. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  40. data/lib/woods/agent_configuration/preflight.rb +5 -3
  41. data/lib/woods/builder.rb +17 -57
  42. data/lib/woods/cache/cache_middleware.rb +68 -41
  43. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  44. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  45. data/lib/woods/console/adapter_family.rb +39 -0
  46. data/lib/woods/console/connection_manager.rb +56 -3
  47. data/lib/woods/console/credential_index.rb +33 -3
  48. data/lib/woods/console/embedded_executor.rb +431 -48
  49. data/lib/woods/console/model_validator.rb +8 -0
  50. data/lib/woods/console/rack_middleware.rb +68 -11
  51. data/lib/woods/console/redactor.rb +24 -10
  52. data/lib/woods/console/safe_context.rb +44 -7
  53. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  54. data/lib/woods/console/sql_table_scanner.rb +45 -34
  55. data/lib/woods/console/sql_validator.rb +37 -2
  56. data/lib/woods/dependency_graph.rb +34 -10
  57. data/lib/woods/embedding/fake.rb +12 -0
  58. data/lib/woods/embedding/indexer.rb +195 -98
  59. data/lib/woods/embedding/input_budget.rb +67 -0
  60. data/lib/woods/embedding/openai.rb +70 -20
  61. data/lib/woods/embedding/provider.rb +37 -25
  62. data/lib/woods/embedding/text_preparer.rb +76 -32
  63. data/lib/woods/embedding/token_counter.rb +18 -81
  64. data/lib/woods/embedding/vector_configuration.rb +48 -0
  65. data/lib/woods/extraction_identities.rb +175 -0
  66. data/lib/woods/extractor.rb +304 -107
  67. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  68. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  69. data/lib/woods/extractors/class_declarations.rb +121 -0
  70. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  71. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  72. data/lib/woods/extractors/event_extractor.rb +8 -0
  73. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  74. data/lib/woods/extractors/job_extractor.rb +5 -1
  75. data/lib/woods/extractors/lib_extractor.rb +132 -15
  76. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  77. data/lib/woods/extractors/manager_extractor.rb +7 -21
  78. data/lib/woods/extractors/migration_declaration.rb +87 -0
  79. data/lib/woods/extractors/migration_extractor.rb +5 -39
  80. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  81. data/lib/woods/extractors/policy_extractor.rb +9 -5
  82. data/lib/woods/extractors/poro_extractor.rb +112 -53
  83. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  84. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  85. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  86. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  87. data/lib/woods/extractors/source_nesting.rb +142 -106
  88. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  89. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  90. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  91. data/lib/woods/flow_assembler.rb +4 -1
  92. data/lib/woods/generation.rb +25 -0
  93. data/lib/woods/hooks/context_hint.rb +7 -2
  94. data/lib/woods/mcp/bearer_auth.rb +1 -1
  95. data/lib/woods/mcp/bootstrapper.rb +33 -7
  96. data/lib/woods/mcp/config_resolver.rb +26 -7
  97. data/lib/woods/mcp/index_reader.rb +125 -24
  98. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  99. data/lib/woods/mcp/origin_guard.rb +24 -77
  100. data/lib/woods/mcp/origin_policy.rb +124 -0
  101. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  102. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  103. data/lib/woods/mcp/search_results.rb +7 -1
  104. data/lib/woods/mcp/server.rb +24 -4
  105. data/lib/woods/module_reconciliation.rb +151 -0
  106. data/lib/woods/path_dispatcher.rb +7 -2
  107. data/lib/woods/railtie_support.rb +8 -0
  108. data/lib/woods/rake_helpers.rb +43 -11
  109. data/lib/woods/release.rb +1 -1
  110. data/lib/woods/resilience/index_validator.rb +8 -3
  111. data/lib/woods/resilience/retryable_provider.rb +18 -1
  112. data/lib/woods/resolved_config.rb +68 -8
  113. data/lib/woods/retrieval/context_assembler.rb +3 -3
  114. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  115. data/lib/woods/retrieval/scope.rb +18 -2
  116. data/lib/woods/retrieval/source_evidence.rb +14 -2
  117. data/lib/woods/source_contributor_validation.rb +78 -0
  118. data/lib/woods/source_contributors.rb +116 -0
  119. data/lib/woods/source_inputs/handoff.rb +37 -0
  120. data/lib/woods/source_inputs/launcher.rb +53 -13
  121. data/lib/woods/source_inputs/manifest.rb +84 -3
  122. data/lib/woods/source_inputs/private_key.rb +44 -12
  123. data/lib/woods/source_inputs/scanner.rb +98 -27
  124. data/lib/woods/source_inputs/scopes.rb +1 -1
  125. data/lib/woods/source_inputs/session.rb +147 -15
  126. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  127. data/lib/woods/source_inputs/status.rb +40 -8
  128. data/lib/woods/source_inputs/verifier.rb +28 -5
  129. data/lib/woods/source_path_encoding.rb +33 -0
  130. data/lib/woods/source_references/cache.rb +284 -0
  131. data/lib/woods/source_references/collector.rb +120 -0
  132. data/lib/woods/source_references/extraction.rb +185 -0
  133. data/lib/woods/source_references/inputs.rb +134 -0
  134. data/lib/woods/source_references/parser_adapter.rb +134 -0
  135. data/lib/woods/source_references/pass.rb +152 -0
  136. data/lib/woods/source_references/prism_adapter.rb +116 -0
  137. data/lib/woods/source_references/registry.rb +178 -0
  138. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  139. data/lib/woods/source_references/value_class.rb +82 -0
  140. data/lib/woods/storage/metadata_store.rb +4 -1
  141. data/lib/woods/storage/qdrant.rb +2 -2
  142. data/lib/woods/unblocked/client.rb +12 -7
  143. data/lib/woods/unblocked/document_builder.rb +4 -1
  144. data/lib/woods/unblocked/exporter.rb +127 -37
  145. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  146. data/lib/woods/unblocked/uri_migration.rb +105 -0
  147. data/lib/woods/util/host_guard.rb +3 -2
  148. data/lib/woods/version.rb +1 -1
  149. data/lib/woods/watch/catch_up.rb +138 -0
  150. data/lib/woods/watch/claim_lease.rb +150 -0
  151. data/lib/woods/watch/cli.rb +26 -2
  152. data/lib/woods/watch/daemon.rb +80 -59
  153. data/lib/woods/watch/installation/options.rb +1 -1
  154. data/lib/woods/watch/installation/receipt.rb +6 -1
  155. data/lib/woods/watch/managed_child.rb +1 -1
  156. data/lib/woods/watch/supervisor.rb +1 -1
  157. data/lib/woods/watch/tree_scan.rb +14 -2
  158. data/plugin/.claude-plugin/plugin.json +1 -1
  159. data/plugin/hooks/adapters/normalize.rb +3 -2
  160. data/plugin/hooks/woods-input-rules.sh +4 -0
  161. data/plugin/hooks/woods-refresh.sh +15 -7
  162. data/plugin/hooks/woods-session-start.sh +60 -3
  163. data/plugin/skills/woods-diagnose/SKILL.md +336 -2
  164. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  165. data/plugin/skills/woods-mcp-config/SKILL.md +86 -0
  166. data/plugin/skills/woods-setup/SKILL.md +56 -1
  167. metadata +34 -5
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'json'
4
4
  require 'woods/observability/structured_logger'
5
+ require 'woods/mcp/bearer_auth'
6
+ require 'woods/mcp/origin_guard'
5
7
 
6
8
  module Woods
7
9
  module Console
@@ -13,9 +15,10 @@ module Woods
13
15
  #
14
16
  # == Basic setup (Tier 1 tools only)
15
17
  #
16
- # Add to config/application.rb or an initializer:
18
+ # The Woods railtie mounts this middleware automatically. Configure the
19
+ # path in config/application.rb before Rails constructs its middleware:
17
20
  #
18
- # config.middleware.use Woods::Console::RackMiddleware, path: '/mcp/console'
21
+ # Woods.configure { |config| config.console_mcp_path = '/mcp/console' }
19
22
  #
20
23
  # This mounts the 9 executable Tier 1 tools at /mcp/console. Explicit
21
24
  # read-tool mode registers console_sql and console_query as well.
@@ -26,6 +29,7 @@ module Woods
26
29
  #
27
30
  # Woods.configure do |config|
28
31
  # config.console_mcp_enabled = true
32
+ # config.console_mcp_token = ENV.fetch('WOODS_CONSOLE_MCP_TOKEN')
29
33
  # config.console_blocked_tables = %w[authorizations credentials]
30
34
  # config.console_redacted_columns = %w[api_token password_digest]
31
35
  # end
@@ -39,13 +43,14 @@ module Woods
39
43
  #
40
44
  # == Enabling read tools (console_sql + console_query)
41
45
  #
42
- # Set embedded_read_tools: true to unlock the sql and query tools:
46
+ # Set console_embedded_read_tools to unlock the sql and query tools:
43
47
  #
44
48
  # # config/initializers/woods_console.rb
45
- # Rails.application.config.middleware.use \
46
- # Woods::Console::RackMiddleware,
47
- # path: '/mcp/console',
48
- # embedded_read_tools: true
49
+ # Woods.configure { |config| config.console_embedded_read_tools = true }
50
+ #
51
+ # Legacy manual mounts remain guarded: each instance enforces the configured
52
+ # bearer token and origin policy before building or dispatching the server,
53
+ # even when it appears before the railtie's outer HTTP guards.
49
54
  #
50
55
  # Security posture with embedded_read_tools: true:
51
56
  #
@@ -68,6 +73,12 @@ module Woods
68
73
  # disabled when the host requires a narrower database capability.
69
74
  #
70
75
  class RackMiddleware # rubocop:disable Metrics/ClassLength
76
+ EAGER_LOAD_UNAVAILABLE = JSON.generate(
77
+ jsonrpc: '2.0', id: nil,
78
+ error: { code: -32_000,
79
+ message: 'Console unavailable: Rails eager loading failed. ' \
80
+ 'Fix the application load error and restart the server.' }
81
+ ).freeze
71
82
  # @param app [#call] The next Rack app in the middleware stack
72
83
  # @param path [String] URL path to mount the MCP endpoint (default: '/mcp/console')
73
84
  # @param embedded_read_tools [Boolean, #call] Enable sql/query tools in
@@ -106,14 +117,23 @@ module Woods
106
117
  return @app.call(env) unless env['PATH_INFO'].to_s.start_with?(@path)
107
118
  return @app.call(env) unless enabled?
108
119
 
120
+ @guarded_request.call(env)
121
+ end
122
+
123
+ private
124
+
125
+ def handle_request(env)
109
126
  transport = ensure_transport
127
+ unless transport
128
+ return [503, { 'content-type' => 'application/json', 'cache-control' => 'no-store' },
129
+ [EAGER_LOAD_UNAVAILABLE]]
130
+ end
131
+
110
132
  request_env = env.dup
111
133
  request_env.delete('HTTP_MCP_SESSION_ID') if @stateless_mode
112
134
  transport.handle_request(Rack::Request.new(request_env))
113
135
  end
114
136
 
115
- private
116
-
117
137
  def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
118
138
  unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
119
139
  stateless: true)
@@ -125,6 +145,26 @@ module Woods
125
145
  @stateless = stateless
126
146
  @mutex = Mutex.new
127
147
  @transport = nil
148
+ @guarded_request = guarded_request
149
+ validate_origin_configuration! if enabled?
150
+ end
151
+
152
+ def validate_origin_configuration!
153
+ @origin_guard.policy
154
+ rescue ArgumentError => e
155
+ raise Woods::ConfigurationError, "[Woods Console] #{e.message}"
156
+ end
157
+
158
+ # Every entry point owns its guards; an earlier manual mount cannot rely
159
+ # on middleware later in the Rails stack. Resolve configuration lazily
160
+ # so settings from application initializers and token rotation apply.
161
+ def guarded_request
162
+ authenticated = Woods::MCP::BearerAuth.new(
163
+ method(:handle_request), token: -> { Woods.configuration&.console_mcp_token }
164
+ )
165
+ @origin_guard = Woods::MCP::OriginGuard.new(
166
+ authenticated, allowed_origins: -> { Array(Woods.configuration&.console_mcp_allowed_origins) }
167
+ )
128
168
  end
129
169
 
130
170
  # Whether the console is enabled, read from the live configuration on
@@ -139,25 +179,42 @@ module Woods
139
179
  # @return [::MCP::Server::Transports::StreamableHTTPTransport]
140
180
  def ensure_transport
141
181
  return @transport if @transport
182
+ return if @eager_load_failed
142
183
 
143
184
  @mutex.synchronize do
144
185
  return @transport if @transport
186
+ return if @eager_load_failed
145
187
 
146
188
  check_blocked_tables_config!
147
189
 
148
190
  require 'woods/console/server'
149
- Rails.application.eager_load!
191
+ return unless eager_load_application
150
192
 
151
193
  server = build_embedded_server
152
194
  @stateless_mode = resolve_deferred(@stateless)
153
195
  @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
154
- server, stateless: @stateless_mode
196
+ server, stateless: @stateless_mode, **@origin_guard.policy.transport_options
155
197
  )
156
198
  server.transport = @transport
157
199
  @transport
158
200
  end
159
201
  end
160
202
 
203
+ # A failed loader may have registered only part of the application.
204
+ # Keep this worker unavailable rather than retrying against partial state.
205
+ def eager_load_application
206
+ Rails.application.eager_load!
207
+ true
208
+ rescue NameError => e
209
+ @eager_load_failed = true
210
+ structured_logger.error(
211
+ 'console.eager_load.failed', error_class: e.class.name,
212
+ remediation: 'Run Rails.application.eager_load! in the application ' \
213
+ 'environment; fix the error and restart.'
214
+ )
215
+ false
216
+ end
217
+
161
218
  # Emit a prominent warning (or raise in production) when the Console MCP
162
219
  # is enabled but no tables are blocked. An empty block list means Layer 1
163
220
  # of the defense stack is fully inactive — every table in the database is
@@ -65,7 +65,8 @@ module Woods
65
65
  case key
66
66
  when 'record' then value.is_a?(Hash) ? ctx.redact(value) : value
67
67
  when 'records' then redact_hash_array(value, ctx)
68
- when 'rows', 'values' then redact_positional(value, plan)
68
+ when 'rows' then redact_positional(value, plan)
69
+ when 'values' then redact_positional(value, plan, single_column: plan[:column_count] == 1)
69
70
  when 'associations' then redact_association_map(value, ctx)
70
71
  else value
71
72
  end
@@ -93,7 +94,8 @@ module Woods
93
94
  # `columns` header: the column-name mask plus any EAV key-value rules
94
95
  # resolved to column indexes.
95
96
  def positional_plan(columns, ctx)
96
- { mask: positional_mask(columns, ctx),
97
+ { column_count: columns.is_a?(Array) ? columns.length : nil,
98
+ mask: positional_mask(columns, ctx),
97
99
  kv_rules: positional_kv_rules(columns, ctx) }
98
100
  end
99
101
 
@@ -123,30 +125,42 @@ module Woods
123
125
  return [] unless columns.is_a?(Array)
124
126
 
125
127
  names = columns.map(&:to_s)
126
- ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern) }
128
+ ctx.redacted_key_values.filter_map { |pattern| positional_kv_rule(names, pattern, ctx) }
127
129
  end
128
130
 
129
131
  # One resolved rule for one EAV pattern, or nil when the header lacks
130
132
  # either column. Unambiguous headers get the key/value index pair;
131
133
  # duplicated headers get the unconditional mask list.
132
- def positional_kv_rule(names, pattern)
134
+ def positional_kv_rule(names, pattern, ctx)
133
135
  key_idxs = names.each_index.select { |i| names[i] == pattern['key_column'] }
134
136
  val_idxs = names.each_index.select { |i| names[i] == pattern['value_column'] }
135
137
  return nil if key_idxs.empty? || val_idxs.empty?
136
138
  return { mask_idxs: val_idxs } unless key_idxs.one? && val_idxs.one?
137
139
 
138
- { key_idx: key_idxs.first, val_idx: val_idxs.first, sensitive: pattern['sensitive_keys'] }
140
+ { key_idx: key_idxs.first, val_idx: val_idxs.first,
141
+ key_matches: ->(value) { sensitive_key?(ctx, value, pattern) } }
142
+ end
143
+
144
+ def sensitive_key?(ctx, value, pattern)
145
+ return ctx.sensitive_key?(value, pattern) if ctx.respond_to?(:sensitive_key?)
146
+
147
+ pattern['sensitive_keys'].include?(value.to_s)
139
148
  end
140
149
 
141
150
  # Redact positional row data using a precomputed plan. Handles both
142
- # nested arrays (multi-column pluck, sql/query rows) and flat scalar
143
- # arrays (pluck with a single column — Rails collapses the result).
144
- def redact_positional(rows, plan)
151
+ # nested arrays (multi-column pluck, sql/query rows) and single-column
152
+ # pluck values. Rails collapses the row for single-column pluck, so an
153
+ # Array/Hash value is still one cell and must be redacted as a whole.
154
+ def redact_positional(rows, plan, single_column: false)
145
155
  return rows unless rows.is_a?(Array)
146
156
  return rows if plan[:mask].nil? && plan[:kv_rules].empty?
147
157
 
148
158
  rows.map do |row|
149
- row.is_a?(Array) ? redact_row(row, plan) : redact_scalar(row, plan[:mask])
159
+ if !single_column && row.is_a?(Array)
160
+ redact_row(row, plan)
161
+ else
162
+ redact_scalar(row, plan[:mask])
163
+ end
150
164
  end
151
165
  end
152
166
 
@@ -156,7 +170,7 @@ module Woods
156
170
  if rule[:mask_idxs]
157
171
  # Ambiguous (duplicated) headers: mask every value-named cell.
158
172
  rule[:mask_idxs].each { |idx| result[idx] = '[REDACTED]' }
159
- elsif rule[:sensitive].include?(row[rule[:key_idx]].to_s)
173
+ elsif rule[:key_matches].call(row[rule[:key_idx]])
160
174
  result[rule[:val_idx]] = '[REDACTED]'
161
175
  end
162
176
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'adapter_family'
4
+
3
5
  # Stub for environments that don't load ActiveRecord
4
6
  unless defined?(ActiveRecord::Rollback)
5
7
  module ActiveRecord
@@ -203,6 +205,36 @@ module Woods
203
205
  redacted_columns: policy_columns, redacted_key_values: policy_key_values)
204
206
  end
205
207
 
208
+ # Add runtime key types to a request-local redaction view. The original
209
+ # context and its execution policy are unchanged.
210
+ # @param types [Hash<String, Array<Object>>] Active Record attribute types
211
+ # @param raw [Boolean] whether keys are raw database cells
212
+ # @return [SafeContext]
213
+ def with_key_value_types(types, raw: false)
214
+ dup.tap do |context|
215
+ context.instance_variable_set(:@key_value_types, types)
216
+ context.instance_variable_set(:@raw_key_values, raw)
217
+ end
218
+ end
219
+
220
+ # Match the database spelling and its application representation. If a
221
+ # type cannot safely transform a key, protect the value rather than
222
+ # returning a value whose sensitivity could not be established.
223
+ # @param value [Object] raw or cast EAV key
224
+ # @param pattern [Hash] normalized EAV policy
225
+ # @return [Boolean]
226
+ def sensitive_key?(value, pattern)
227
+ sensitive = pattern['sensitive_keys']
228
+ return true if sensitive.include?(value.to_s)
229
+
230
+ Array(@key_value_types&.fetch(pattern['key_column'], nil)).any? do |type|
231
+ alternate = @raw_key_values ? type.deserialize(value) : type.serialize(value)
232
+ sensitive.include?(alternate.to_s)
233
+ rescue StandardError
234
+ true
235
+ end
236
+ end
237
+
206
238
  private
207
239
 
208
240
  # Wrap one connection in a rolled-back transaction with timeout, and
@@ -249,7 +281,7 @@ module Woods
249
281
  key_col = pattern['key_column']
250
282
  val_col = pattern['value_column']
251
283
  next unless hash.key?(key_col) && hash.key?(val_col)
252
- next unless pattern['sensitive_keys'].include?(hash[key_col].to_s)
284
+ next unless sensitive_key?(hash[key_col], pattern)
253
285
 
254
286
  hash[val_col] = '[REDACTED]'
255
287
  end
@@ -279,14 +311,14 @@ module Woods
279
311
  # unsupported adapter sets nothing to restore).
280
312
  def set_timeout(connection, timeout_ms = @timeout_ms)
281
313
  adapter = connection.adapter_name.downcase
282
- if adapter.include?('mysql')
314
+ if AdapterFamily.for(connection) == :mysql
283
315
  set_mysql_timeout(connection, timeout_ms)
284
316
  else
285
317
  connection.execute("SET LOCAL statement_timeout = '#{timeout_ms.to_i}ms'")
286
318
  nil
287
319
  end
288
320
  rescue StandardError => e
289
- # Unsupported adapter (SQLite, Trilogy on unsupported version, Oracle) —
321
+ # Unsupported timeout facility (for example SQLite or older servers) —
290
322
  # timeout enforcement is best-effort, but operators need to know their
291
323
  # rollback fence is narrower than advertised. Log once per adapter via
292
324
  # Rails.logger when available; otherwise swallow as before.
@@ -295,16 +327,21 @@ module Woods
295
327
  end
296
328
 
297
329
  # Read MySQL's current session-scoped `max_execution_time`, override
298
- # it, and return a Proc that restores the value read here. There is no
330
+ # it, and return a Proc that restores the value read here. MariaDB uses
331
+ # max_statement_time in seconds; MySQL uses milliseconds. There is no
299
332
  # per-statement `SET LOCAL` on MySQL, so the override otherwise
300
333
  # outlives this transaction's rollback and bleeds onto whatever the
301
334
  # pooled connection serves next.
302
335
  #
303
336
  # @return [Proc] restores the previous session value
304
337
  def set_mysql_timeout(connection, timeout_ms)
305
- previous_value = connection.select_value('SELECT @@SESSION.max_execution_time').to_i
306
- connection.execute("SET max_execution_time = #{timeout_ms.to_i}")
307
- -> { connection.execute("SET max_execution_time = #{previous_value}") }
338
+ maria = connection.respond_to?(:mariadb?) && connection.mariadb?
339
+ variable = maria ? 'max_statement_time' : 'max_execution_time'
340
+ timeout = maria ? timeout_ms.to_i / 1000.0 : timeout_ms.to_i
341
+ previous_value = Float(connection.select_value("SELECT @@SESSION.#{variable}"))
342
+ connection.execute("SET #{variable} = #{timeout}")
343
+ previous_value = previous_value.to_i unless maria
344
+ -> { connection.execute("SET #{variable} = #{previous_value}") }
308
345
  end
309
346
 
310
347
  def warn_timeout_unsupported(adapter, error)
@@ -30,8 +30,8 @@ module Woods
30
30
  # newline-separated statement structure is preserved for callers that
31
31
  # check for multiple statements.
32
32
  #
33
- # Block comments are non-nested — real SQL engines do not support nested
34
- # block comments, and neither does this stripper.
33
+ # This legacy helper is not a security scanner. Use strip_noise for
34
+ # quote-aware and PostgreSQL nested-comment handling.
35
35
  #
36
36
  # @param sql [String] the SQL string to process
37
37
  # @return [String] a new string with all SQL comments removed
@@ -101,9 +101,10 @@ module Woods
101
101
  # `:mysql` dialect — PostgreSQL does not treat `#` as a comment, and
102
102
  # collapsing it there would hide SQL that a real PostgreSQL server
103
103
  # still executes. A MySQL `/*! ... */` executable comment is left
104
- # untouched (not treated as a comment at all, under either dialect):
105
- # MySQL runs its body, so it must stay visible to every downstream
106
- # scan; leaving it visible under `:postgres` too is over-detection at
104
+ # visible as one span, including MariaDB's `/*M! ... */` spelling:
105
+ # its body can execute, so it must stay visible to every downstream
106
+ # scan without changing literal state outside the span. Leaving it
107
+ # visible under `:postgres` too is over-detection at
107
108
  # worst, never under-detection, on a server where it really is inert.
108
109
  # An ordinary `/* ... */` block comment is replaced by a single
109
110
  # newline rather than vanishing outright, mirroring how a `--`/`#`
@@ -184,8 +185,22 @@ module Woods
184
185
  elsif dash_comment?(sql, i, mysql: mysql) || (mysql && ch == '#')
185
186
  nl = sql.index("\n", i)
186
187
  i = nl || len
187
- elsif ch == '/' && sql[i + 1] == '*' && sql[i + 2] != '!'
188
- close = sql.index('*/', i + 2)
188
+ elsif sql[i, 3] == '/*!' || sql[i, 4] == '/*M!'
189
+ close = block_comment_end(sql, i, nested: dialect == :postgres)
190
+ if close
191
+ comment = sql[i...close]
192
+ yield comment if block_given?
193
+ # An executable comment is a lexical boundary even when its
194
+ # version guard makes its body inert. A quote in that body must
195
+ # never change the scanner's state outside the comment.
196
+ out << comment
197
+ i = close
198
+ else
199
+ out << ch
200
+ i += 1
201
+ end
202
+ elsif ch == '/' && sql[i + 1] == '*'
203
+ close = block_comment_end(sql, i, nested: dialect == :postgres)
189
204
  if close
190
205
  # Preserve a newline in place of the removed comment, mirroring
191
206
  # line comments (see class docs): callers that check for
@@ -193,7 +208,7 @@ module Woods
193
208
  # need a survivable marker showing a comment sat here, the
194
209
  # same way a `--`/`#` comment's own trailing newline does.
195
210
  out << "\n"
196
- i = close + 2
211
+ i = close
197
212
  else
198
213
  # Unterminated block comment: never under-detect. Leave it in
199
214
  # place (over-detection is safe; the old regex also required a
@@ -210,6 +225,20 @@ module Woods
210
225
  out
211
226
  end
212
227
 
228
+ def self.block_comment_end(sql, start, nested:)
229
+ depth = 1
230
+ position = start + 2
231
+ while (match = %r{/\*|\*/}.match(sql, position))
232
+ depth += 1 if nested && match[0] == '/*'
233
+ depth -= 1 if match[0] == '*/'
234
+ return match.end(0) if depth.zero?
235
+
236
+ position = match.end(0)
237
+ end
238
+ nil
239
+ end
240
+ private_class_method :block_comment_end
241
+
213
242
  # Session modes change MySQL quoting without changing the adapter name.
214
243
  # Security consumers must reject SQL unsafe under any supported combination.
215
244
  MYSQL_QUOTE_MODES = [false, true].product([false, true]).map do |ansi, no_backslash|
@@ -231,7 +260,7 @@ module Woods
231
260
 
232
261
  # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
233
262
  # `$tag$`) at the start of the given slice.
234
- DOLLAR_TAG = /\A\$\w*\$/
263
+ DOLLAR_TAG = /\A\$(?:[A-Za-z_\u0080-\u{10ffff}][A-Za-z0-9_\u0080-\u{10ffff}]*)?\$/u
235
264
  private_constant :DOLLAR_TAG
236
265
 
237
266
  # Return the dollar-quote tag opening at +index+, or nil.
@@ -244,7 +273,7 @@ module Woods
244
273
  private_class_method :dollar_tag_at
245
274
 
246
275
  # Whether the character immediately before +index+ is a word character
247
- # (`\w`). PostgreSQL allows `$` inside identifiers (`x$a$`), so a `$`
276
+ # (including high-bit characters). PostgreSQL allows `$` inside identifiers (`x$a$`), so a `$`
248
277
  # is only a candidate dollar-quote opener when it does NOT follow an
249
278
  # identifier character — otherwise `x$a$ FROM blocked, (SELECT 1 AS
250
279
  # z$a$)` gets misread as one dollar-quoted literal spanning the real
@@ -252,7 +281,7 @@ module Woods
252
281
  #
253
282
  # @api private
254
283
  def self.preceded_by_word_char?(sql, index)
255
- index.positive? && sql[index - 1].match?(/\w/)
284
+ index.positive? && sql[index - 1].match?(/[A-Za-z0-9_$\u0080-\u{10ffff}]/u)
256
285
  end
257
286
  private_class_method :preceded_by_word_char?
258
287
 
@@ -303,7 +332,7 @@ module Woods
303
332
  def self.postgres_escape_string?(sql, quote_index)
304
333
  return false unless quote_index.positive? && sql[quote_index - 1].match?(/[eE]/)
305
334
 
306
- quote_index < 2 || !sql[quote_index - 2].match?(/[A-Za-z0-9_$]/)
335
+ quote_index < 2 || !sql[quote_index - 2].match?(/[A-Za-z0-9_$\u0080-\u{10ffff}]/u)
307
336
  end
308
337
  private_class_method :postgres_escape_string?
309
338
  end # rubocop:enable Metrics/ModuleLength
@@ -38,20 +38,20 @@ module Woods
38
38
  # identifier so it does not hide the table name. ANSI-89 comma joins
39
39
  # are handled separately — see FROM_CLAUSE.
40
40
  JOIN_REFERENCE = /
41
- \b(?:STRAIGHT_)?JOIN\s+
42
- (?:ONLY\s+)?
41
+ \b(?:STRAIGHT_)?JOIN(?=[\s("`])\s*
42
+ (?:ONLY(?=[\s("`])\s*\(?\s*)?
43
43
  (?:
44
44
  (?:
45
- `(?<jschema_bt>[^`]+)` |
46
- "(?<jschema_dq>[^"]+)" |
47
- (?<jschema_bare>\w+)
45
+ `(?<jschema_bt>(?:``|[^`])+)` |
46
+ "(?<jschema_dq>(?:""|[^"])+)" |
47
+ (?<jschema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
48
48
  )
49
49
  \s* \. \s*
50
50
  )?
51
51
  (?:
52
- `(?<backtick>[^`]+)` |
53
- "(?<double>[^"]+)" |
54
- (?<bare>\w+(?:\.\w+)?)
52
+ `(?<backtick>(?:``|[^`])+)` |
53
+ "(?<double>(?:""|[^"])+)" |
54
+ (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
55
55
  )
56
56
  /xi
57
57
 
@@ -66,7 +66,7 @@ module Woods
66
66
  # every `FROM` as its own independent scan match is what keeps CTEs,
67
67
  # UNIONs, and nested subqueries in coverage.
68
68
  FROM_CLAUSE = /
69
- \bFROM\s+
69
+ \bFROM(?=[\s("`])\s*
70
70
  (?<clause>.+?)
71
71
  (?=
72
72
  \b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|
@@ -85,16 +85,16 @@ module Woods
85
85
  \A
86
86
  (?:
87
87
  (?:
88
- `(?<schema_bt>[^`]+)` |
89
- "(?<schema_dq>[^"]+)" |
90
- (?<schema_bare>\w+)
88
+ `(?<schema_bt>(?:``|[^`])+)` |
89
+ "(?<schema_dq>(?:""|[^"])+)" |
90
+ (?<schema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
91
91
  )
92
92
  \s* \. \s*
93
93
  )?
94
94
  (?:
95
- `(?<backtick>[^`]+)` |
96
- "(?<double>[^"]+)" |
97
- (?<bare>\w+(?:\.\w+)?)
95
+ `(?<backtick>(?:``|[^`])+)` |
96
+ "(?<double>(?:""|[^"])+)" |
97
+ (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
98
98
  )
99
99
  /xi
100
100
 
@@ -102,7 +102,7 @@ module Woods
102
102
  # identifier. Strip it so the lead-identifier regex sees the table
103
103
  # directly. Anchored with `\A` because callers strip leading whitespace
104
104
  # first via #strip.
105
- ONLY_PREFIX = /\AONLY\s+/i
105
+ ONLY_PREFIX = /\AONLY(?=[\s("`])\s*/i
106
106
 
107
107
  # Matches a MySQL executable comment (`/*!...*/` or the version-guarded
108
108
  # `/*!NNNNN...*/`), capturing the body. Mirrors
@@ -110,7 +110,7 @@ module Woods
110
110
  # deliberately leaves these markers in place because their meaning is
111
111
  # version-dependent; .executable_comment_views scans both of their
112
112
  # possible semantics.
113
- EXECUTABLE_COMMENT_PATTERN = %r{/\*!(?:\d{5})?(.*?)\*/}m
113
+ EXECUTABLE_COMMENT_PATTERN = %r{/\*(?:M)?!(?:\d{5,6})?(.*?)\*/}m
114
114
 
115
115
  # Matches the standalone SQL `TABLE name` statement (PostgreSQL, and
116
116
  # MySQL 8.0.19+) — shorthand for `SELECT * FROM name`. It appears as a
@@ -119,20 +119,20 @@ module Woods
119
119
  # scanned independently of FROM_CLAUSE/JOIN_REFERENCE rather than as
120
120
  # part of either. The identifier grammar mirrors LEAD_IDENT.
121
121
  TABLE_STATEMENT = /
122
- \bTABLE\s+
123
- (?:ONLY\s+)?
122
+ \bTABLE(?=[\s("`])\s*
123
+ (?:ONLY(?=[\s("`])\s*\(?\s*)?
124
124
  (?:
125
125
  (?:
126
- `(?<schema_bt>[^`]+)` |
127
- "(?<schema_dq>[^"]+)" |
128
- (?<schema_bare>\w+)
126
+ `(?<schema_bt>(?:``|[^`])+)` |
127
+ "(?<schema_dq>(?:""|[^"])+)" |
128
+ (?<schema_bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+)
129
129
  )
130
130
  \s* \. \s*
131
131
  )?
132
132
  (?:
133
- `(?<backtick>[^`]+)` |
134
- "(?<double>[^"]+)" |
135
- (?<bare>\w+(?:\.\w+)?)
133
+ `(?<backtick>(?:``|[^`])+)` |
134
+ "(?<double>(?:""|[^"])+)" |
135
+ (?<bare>[A-Za-z0-9_$\u0080-\u{10ffff}]+(?:\.[A-Za-z0-9_$\u0080-\u{10ffff}]+)?)
136
136
  )
137
137
  /xi
138
138
 
@@ -169,10 +169,12 @@ module Woods
169
169
 
170
170
  # Table-factor prefixes, retaining commas after balanced subqueries and
171
171
  # JOIN predicates. Each nested FROM/JOIN is also scanned independently.
172
+ # SQL punctuation separates tokens without mandatory whitespace. Keep
173
+ # compact quoted targets and parenthesized relations in the same policy.
172
174
  # @param sql [String] noise-stripped SQL
173
175
  # @return [Array<String>]
174
176
  def self.relation_factors(sql)
175
- sql.to_enum(:scan, /\b(?:FROM|JOIN)\s+/i).flat_map do
177
+ sql.to_enum(:scan, /\b(?:FROM|(?:STRAIGHT_)?JOIN)(?=[\s("`])\s*/i).flat_map do
176
178
  suffix = sql[Regexp.last_match.end(0)..]
177
179
  split_top_level_commas(relation_clause(suffix))
178
180
  end
@@ -204,7 +206,7 @@ module Woods
204
206
  return false unless token.match?(/\A[A-Za-z]/)
205
207
  return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
206
208
  return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
207
- return rest.match?(/\A\s+\w+\s+AS\b/i) if token.casecmp?('WINDOW')
209
+ return rest.match?(/\A\s+[A-Za-z0-9_$\u0080-\u{10ffff}]+\s+AS\b/i) if token.casecmp?('WINDOW')
208
210
 
209
211
  true
210
212
  end
@@ -299,8 +301,9 @@ module Woods
299
301
  # PostgreSQL `ONLY` inheritance keyword is stripped first so it does
300
302
  # not hide the table.
301
303
  def self.lead_identifier(chunk)
302
- stripped = chunk.to_s.strip.sub(ONLY_PREFIX, '')
303
- return nil if stripped.empty?
304
+ stripped = chunk.to_s.strip.sub(/\A(?:\(\s*)+/, '').sub(ONLY_PREFIX, '')
305
+ stripped = stripped.sub(/\A(?:\(\s*)+/, '')
306
+ return nil if stripped.empty? || stripped.match?(/\A(?:SELECT|WITH|TABLE)\b/i)
304
307
 
305
308
  match = LEAD_IDENT.match(stripped)
306
309
  return nil unless match
@@ -313,14 +316,22 @@ module Woods
313
316
  # Combine a schema prefix with the table identifier captured by
314
317
  # JOIN_REFERENCE / LEAD_IDENT into a single `schema.table` string.
315
318
  def self.qualified_identifier(match)
316
- table = match[:backtick] || match[:double] || match[:bare]
317
- schema = match.named_captures.values_at(
318
- 'schema_bt', 'schema_dq', 'schema_bare',
319
- 'jschema_bt', 'jschema_dq', 'jschema_bare'
320
- ).compact.first
319
+ captures = match.named_captures
320
+ table = decoded_identifier(*captures.values_at('backtick', 'double', 'bare'))
321
+ schema = decoded_identifier(captures['schema_bt'] || captures['jschema_bt'],
322
+ captures['schema_dq'] || captures['jschema_dq'],
323
+ captures['schema_bare'] || captures['jschema_bare'])
321
324
  schema ? "#{schema}.#{table}" : table
322
325
  end
323
326
  private_class_method :qualified_identifier
327
+
328
+ def self.decoded_identifier(backtick, double, bare)
329
+ return backtick.gsub('``', '`') if backtick
330
+ return double.gsub('""', '"') if double
331
+
332
+ bare
333
+ end
334
+ private_class_method :decoded_identifier
324
335
  end
325
336
  end
326
337
  end