woods 2.0.0.beta4 → 2.0.1

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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +495 -471
  3. data/CONTRIBUTING.md +12 -2
  4. data/README.md +11 -26
  5. data/docs/AGENT_GUIDE.md +31 -12
  6. data/docs/AGENT_SETUP.md +17 -10
  7. data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
  8. data/docs/BACKEND_MATRIX.md +13 -7
  9. data/docs/CLIENT_HOOKS.md +1 -1
  10. data/docs/CONFIGURATION_REFERENCE.md +44 -27
  11. data/docs/CONSOLE_MCP_SETUP.md +95 -16
  12. data/docs/DOCKER_SETUP.md +15 -0
  13. data/docs/EVALUATION.md +10 -4
  14. data/docs/EXTRACTOR_REFERENCE.md +14 -2
  15. data/docs/FAQ.md +14 -3
  16. data/docs/GETTING_STARTED.md +18 -17
  17. data/docs/INCREMENTAL_EXTRACTION.md +8 -3
  18. data/docs/INDEX_LAYOUT.md +2 -2
  19. data/docs/MCP_HTTP_TRANSPORT.md +54 -2
  20. data/docs/MCP_SERVERS.md +28 -11
  21. data/docs/MCP_TOOL_COOKBOOK.md +1 -1
  22. data/docs/MCP_WORKTREE_SETUP.md +13 -1
  23. data/docs/PUBLISHED_INDEX.md +1 -1
  24. data/docs/README.md +2 -1
  25. data/docs/RETRIEVAL_GUIDE.md +57 -8
  26. data/docs/SOURCE_FRESHNESS.md +1 -1
  27. data/docs/TOKEN_BENCHMARK.md +16 -10
  28. data/docs/TROUBLESHOOTING.md +133 -37
  29. data/docs/UPGRADING_TO_2.md +69 -7
  30. data/docs/WATCH_DAEMON.md +172 -17
  31. data/docs/WHY_WOODS.md +9 -5
  32. data/exe/woods-console +13 -11
  33. data/exe/woods-mcp-http +16 -9
  34. data/exe/woods-watch +5 -0
  35. data/lib/generators/woods/watch_generator.rb +53 -0
  36. data/lib/puma/plugin/woods.rb +10 -0
  37. data/lib/tasks/woods.rake +14 -0
  38. data/lib/woods/cache/cache_middleware.rb +18 -11
  39. data/lib/woods/console/adapter_family.rb +39 -0
  40. data/lib/woods/console/credential_index.rb +33 -3
  41. data/lib/woods/console/embedded_executor.rb +401 -43
  42. data/lib/woods/console/model_validator.rb +8 -0
  43. data/lib/woods/console/rack_middleware.rb +39 -10
  44. data/lib/woods/console/redactor.rb +24 -10
  45. data/lib/woods/console/safe_context.rb +44 -7
  46. data/lib/woods/console/sql_noise_stripper.rb +41 -12
  47. data/lib/woods/console/sql_table_scanner.rb +45 -34
  48. data/lib/woods/console/sql_validator.rb +37 -2
  49. data/lib/woods/console/stdio_transport.rb +27 -0
  50. data/lib/woods/extractor.rb +25 -7
  51. data/lib/woods/git_command.rb +6 -7
  52. data/lib/woods/git_provenance.rb +4 -6
  53. data/lib/woods/mcp/bearer_auth.rb +1 -1
  54. data/lib/woods/mcp/bootstrapper.rb +3 -1
  55. data/lib/woods/mcp/initialization_guidance.rb +1 -1
  56. data/lib/woods/mcp/origin_guard.rb +24 -77
  57. data/lib/woods/mcp/origin_policy.rb +124 -0
  58. data/lib/woods/mcp/server.rb +41 -9
  59. data/lib/woods/railtie_support.rb +8 -0
  60. data/lib/woods/retrieval/corpus_status.rb +46 -0
  61. data/lib/woods/retriever.rb +19 -7
  62. data/lib/woods/storage/local_corpus_stats.rb +32 -0
  63. data/lib/woods/storage/metadata_store.rb +20 -0
  64. data/lib/woods/storage/vector_store.rb +10 -0
  65. data/lib/woods/version.rb +1 -1
  66. data/lib/woods/watch/child_environment.rb +30 -0
  67. data/lib/woods/watch/cli.rb +91 -0
  68. data/lib/woods/watch/daemon.rb +55 -7
  69. data/lib/woods/watch/event_stream.rb +70 -0
  70. data/lib/woods/watch/guardian.rb +142 -0
  71. data/lib/woods/watch/installation/layout.rb +70 -0
  72. data/lib/woods/watch/installation/options.rb +128 -0
  73. data/lib/woods/watch/installation/planner.rb +128 -0
  74. data/lib/woods/watch/installation/probe.rb +101 -0
  75. data/lib/woods/watch/installation/receipt.rb +77 -0
  76. data/lib/woods/watch/installation/recovery.rb +64 -0
  77. data/lib/woods/watch/installation/templates.rb +58 -0
  78. data/lib/woods/watch/installation.rb +56 -0
  79. data/lib/woods/watch/lifecycle.rb +182 -0
  80. data/lib/woods/watch/managed_child.rb +113 -0
  81. data/lib/woods/watch/managed_cleanup.rb +48 -0
  82. data/lib/woods/watch/managed_process.rb +144 -0
  83. data/lib/woods/watch/puma_adapter.rb +87 -0
  84. data/lib/woods/watch/puma_child.rb +66 -0
  85. data/lib/woods/watch/supervision_records.rb +95 -0
  86. data/lib/woods/watch/supervision_status.rb +104 -0
  87. data/lib/woods/watch/supervisor.rb +161 -0
  88. data/lib/woods/watch/supervisor_reporting.rb +46 -0
  89. data/plugin/.claude-plugin/plugin.json +1 -1
  90. data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
  91. data/plugin/skills/woods-diagnose/SKILL.md +88 -8
  92. data/plugin/skills/woods-investigate/SKILL.md +6 -6
  93. data/plugin/skills/woods-mcp-config/SKILL.md +43 -1
  94. data/plugin/skills/woods-setup/SKILL.md +66 -4
  95. metadata +37 -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
  #
@@ -106,14 +111,18 @@ module Woods
106
111
  return @app.call(env) unless env['PATH_INFO'].to_s.start_with?(@path)
107
112
  return @app.call(env) unless enabled?
108
113
 
114
+ @guarded_request.call(env)
115
+ end
116
+
117
+ private
118
+
119
+ def handle_request(env)
109
120
  transport = ensure_transport
110
121
  request_env = env.dup
111
122
  request_env.delete('HTTP_MCP_SESSION_ID') if @stateless_mode
112
123
  transport.handle_request(Rack::Request.new(request_env))
113
124
  end
114
125
 
115
- private
116
-
117
126
  def initialize_options(app, path: '/mcp/console', embedded_read_tools: false, # rubocop:disable Metrics/ParameterLists
118
127
  unsafe_eval_confirmation: nil, unsafe_eval_audit_log_path: nil,
119
128
  stateless: true)
@@ -125,6 +134,26 @@ module Woods
125
134
  @stateless = stateless
126
135
  @mutex = Mutex.new
127
136
  @transport = nil
137
+ @guarded_request = guarded_request
138
+ validate_origin_configuration! if enabled?
139
+ end
140
+
141
+ def validate_origin_configuration!
142
+ @origin_guard.policy
143
+ rescue ArgumentError => e
144
+ raise Woods::ConfigurationError, "[Woods Console] #{e.message}"
145
+ end
146
+
147
+ # Every entry point owns its guards; an earlier manual mount cannot rely
148
+ # on middleware later in the Rails stack. Resolve configuration lazily
149
+ # so settings from application initializers and token rotation apply.
150
+ def guarded_request
151
+ authenticated = Woods::MCP::BearerAuth.new(
152
+ method(:handle_request), token: -> { Woods.configuration&.console_mcp_token }
153
+ )
154
+ @origin_guard = Woods::MCP::OriginGuard.new(
155
+ authenticated, allowed_origins: -> { Array(Woods.configuration&.console_mcp_allowed_origins) }
156
+ )
128
157
  end
129
158
 
130
159
  # Whether the console is enabled, read from the live configuration on
@@ -151,7 +180,7 @@ module Woods
151
180
  server = build_embedded_server
152
181
  @stateless_mode = resolve_deferred(@stateless)
153
182
  @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
154
- server, stateless: @stateless_mode
183
+ server, stateless: @stateless_mode, **@origin_guard.policy.transport_options
155
184
  )
156
185
  server.transport = @transport
157
186
  @transport
@@ -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
@@ -115,7 +115,7 @@ module Woods
115
115
  # `/*!NNNNN...*/`), capturing the body. Only scanned by
116
116
  # {#lock_clause_views} — {SqlNoiseStripper} deliberately leaves these
117
117
  # markers in place because their meaning is version-dependent.
118
- EXECUTABLE_COMMENT_PATTERN = %r{/\*!(?:\d{5})?(.*?)\*/}m
118
+ EXECUTABLE_COMMENT_PATTERN = %r{/\*(?:M)?!(?:\d{5,6})?(.*?)\*/}m
119
119
 
120
120
  # Forbidden statement prefixes (case-insensitive).
121
121
  #
@@ -321,6 +321,8 @@ module Woods
321
321
 
322
322
  SqliteReadGuard.validate!(sql) if dialect == :sqlite
323
323
  normalized = sql.strip
324
+ check_balanced_delimiters!(normalized)
325
+ check_supported_identifier_syntax!(normalized)
324
326
 
325
327
  # Reject multiple statements (semicolons not inside string literals)
326
328
  if contains_multiple_statements?(normalized)
@@ -372,6 +374,35 @@ module Woods
372
374
 
373
375
  private
374
376
 
377
+ # The policy scanners retain ordinary quoted identifiers but do not
378
+ # decode PostgreSQL Unicode escapes. Refuse that grammar before the
379
+ # adapter can resolve an identifier differently from the policy gates.
380
+ def check_supported_identifier_syntax!(sql)
381
+ return if @dialect && @dialect != :postgres
382
+
383
+ stripped = SqlNoiseStripper.strip_noise(sql, dialect: :postgres)
384
+ tokens = stripped.scan(/"(?:[^"]|"")*"|(?<![A-Za-z0-9_$\u0080-\u{10ffff}])[uU]&"/)
385
+ return unless tokens.any? { |token| token.start_with?('U&"', 'u&"') }
386
+
387
+ raise SqlValidationError,
388
+ 'Rejected: PostgreSQL escaped identifiers are unsupported; use ordinary quoted identifiers ' \
389
+ 'or a structured Console tool.'
390
+ end
391
+
392
+ # A fragment must not escape a later row-limit wrapper. Noise and
393
+ # quoted identifiers shield their punctuation from the balance check.
394
+ # This is a structural boundary check, not a complete SQL parser.
395
+ def check_balanced_delimiters!(sql)
396
+ stripped = strip_validation_noise(sql)
397
+ depth = 0
398
+ stripped.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]/).each do |token|
399
+ depth += 1 if token == '('
400
+ depth -= 1 if token == ')'
401
+ raise SqlValidationError, 'Rejected: unbalanced SQL parentheses' if depth.negative?
402
+ end
403
+ raise SqlValidationError, 'Rejected: unbalanced SQL parentheses' unless depth.zero?
404
+ end
405
+
375
406
  def unknown_grammar?
376
407
  dialect.nil? || (dialect == :mysql && @mysql_modes.nil?)
377
408
  end
@@ -388,7 +419,11 @@ module Woods
388
419
  end
389
420
 
390
421
  def strip_validation_noise(sql)
391
- SqlNoiseStripper.strip_noise(sql, dialect: validation_dialect, **(@mysql_modes || {}))
422
+ SqlNoiseStripper.strip_noise(sql, dialect: validation_dialect, **(@mysql_modes || {})) do |comment|
423
+ next unless comment.match?(/['"`$\\]/)
424
+
425
+ raise SqlValidationError, 'Rejected: quoted executable comments have ambiguous SQL grammar; use ordinary SQL.'
426
+ end
392
427
  end
393
428
 
394
429
  def validation_dialect
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp'
4
+
5
+ module Woods
6
+ module Console
7
+ # Keeps protocol writes separate from the host application's stdout.
8
+ # The SDK still owns framing, negotiation, notifications and shutdown.
9
+ class StdioTransport < ::MCP::Server::Transports::StdioTransport
10
+ # @param server [::MCP::Server] Console server
11
+ # @param output [IO] Original stdout saved before redirecting Rails output
12
+ def initialize(server, output:)
13
+ super(server)
14
+ @output = output
15
+ @output.set_encoding(Encoding::UTF_8)
16
+ end
17
+
18
+ # SDK responses, notifications and server requests share this writer.
19
+ # @param message [String, Hash] Encoded JSON or a JSON-compatible message
20
+ # @return [IO] Flushed protocol output
21
+ def send_response(message)
22
+ @output.puts(message.is_a?(String) ? message : JSON.generate(message))
23
+ @output.flush
24
+ end
25
+ end
26
+ end
27
+ end