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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +495 -471
- data/CONTRIBUTING.md +12 -2
- data/README.md +11 -26
- data/docs/AGENT_GUIDE.md +31 -12
- data/docs/AGENT_SETUP.md +17 -10
- data/docs/AUTOMATIC_MAINTENANCE.md +222 -0
- data/docs/BACKEND_MATRIX.md +13 -7
- data/docs/CLIENT_HOOKS.md +1 -1
- data/docs/CONFIGURATION_REFERENCE.md +44 -27
- data/docs/CONSOLE_MCP_SETUP.md +95 -16
- data/docs/DOCKER_SETUP.md +15 -0
- data/docs/EVALUATION.md +10 -4
- data/docs/EXTRACTOR_REFERENCE.md +14 -2
- data/docs/FAQ.md +14 -3
- data/docs/GETTING_STARTED.md +18 -17
- data/docs/INCREMENTAL_EXTRACTION.md +8 -3
- data/docs/INDEX_LAYOUT.md +2 -2
- data/docs/MCP_HTTP_TRANSPORT.md +54 -2
- data/docs/MCP_SERVERS.md +28 -11
- data/docs/MCP_TOOL_COOKBOOK.md +1 -1
- data/docs/MCP_WORKTREE_SETUP.md +13 -1
- data/docs/PUBLISHED_INDEX.md +1 -1
- data/docs/README.md +2 -1
- data/docs/RETRIEVAL_GUIDE.md +57 -8
- data/docs/SOURCE_FRESHNESS.md +1 -1
- data/docs/TOKEN_BENCHMARK.md +16 -10
- data/docs/TROUBLESHOOTING.md +133 -37
- data/docs/UPGRADING_TO_2.md +69 -7
- data/docs/WATCH_DAEMON.md +172 -17
- data/docs/WHY_WOODS.md +9 -5
- data/exe/woods-console +13 -11
- data/exe/woods-mcp-http +16 -9
- data/exe/woods-watch +5 -0
- data/lib/generators/woods/watch_generator.rb +53 -0
- data/lib/puma/plugin/woods.rb +10 -0
- data/lib/tasks/woods.rake +14 -0
- data/lib/woods/cache/cache_middleware.rb +18 -11
- data/lib/woods/console/adapter_family.rb +39 -0
- data/lib/woods/console/credential_index.rb +33 -3
- data/lib/woods/console/embedded_executor.rb +401 -43
- data/lib/woods/console/model_validator.rb +8 -0
- data/lib/woods/console/rack_middleware.rb +39 -10
- data/lib/woods/console/redactor.rb +24 -10
- data/lib/woods/console/safe_context.rb +44 -7
- data/lib/woods/console/sql_noise_stripper.rb +41 -12
- data/lib/woods/console/sql_table_scanner.rb +45 -34
- data/lib/woods/console/sql_validator.rb +37 -2
- data/lib/woods/console/stdio_transport.rb +27 -0
- data/lib/woods/extractor.rb +25 -7
- data/lib/woods/git_command.rb +6 -7
- data/lib/woods/git_provenance.rb +4 -6
- data/lib/woods/mcp/bearer_auth.rb +1 -1
- data/lib/woods/mcp/bootstrapper.rb +3 -1
- data/lib/woods/mcp/initialization_guidance.rb +1 -1
- data/lib/woods/mcp/origin_guard.rb +24 -77
- data/lib/woods/mcp/origin_policy.rb +124 -0
- data/lib/woods/mcp/server.rb +41 -9
- data/lib/woods/railtie_support.rb +8 -0
- data/lib/woods/retrieval/corpus_status.rb +46 -0
- data/lib/woods/retriever.rb +19 -7
- data/lib/woods/storage/local_corpus_stats.rb +32 -0
- data/lib/woods/storage/metadata_store.rb +20 -0
- data/lib/woods/storage/vector_store.rb +10 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/child_environment.rb +30 -0
- data/lib/woods/watch/cli.rb +91 -0
- data/lib/woods/watch/daemon.rb +55 -7
- data/lib/woods/watch/event_stream.rb +70 -0
- data/lib/woods/watch/guardian.rb +142 -0
- data/lib/woods/watch/installation/layout.rb +70 -0
- data/lib/woods/watch/installation/options.rb +128 -0
- data/lib/woods/watch/installation/planner.rb +128 -0
- data/lib/woods/watch/installation/probe.rb +101 -0
- data/lib/woods/watch/installation/receipt.rb +77 -0
- data/lib/woods/watch/installation/recovery.rb +64 -0
- data/lib/woods/watch/installation/templates.rb +58 -0
- data/lib/woods/watch/installation.rb +56 -0
- data/lib/woods/watch/lifecycle.rb +182 -0
- data/lib/woods/watch/managed_child.rb +113 -0
- data/lib/woods/watch/managed_cleanup.rb +48 -0
- data/lib/woods/watch/managed_process.rb +144 -0
- data/lib/woods/watch/puma_adapter.rb +87 -0
- data/lib/woods/watch/puma_child.rb +66 -0
- data/lib/woods/watch/supervision_records.rb +95 -0
- data/lib/woods/watch/supervision_status.rb +104 -0
- data/lib/woods/watch/supervisor.rb +161 -0
- data/lib/woods/watch/supervisor_reporting.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/skills/woods-agent-enable/SKILL.md +1 -1
- data/plugin/skills/woods-diagnose/SKILL.md +88 -8
- data/plugin/skills/woods-investigate/SKILL.md +6 -6
- data/plugin/skills/woods-mcp-config/SKILL.md +43 -1
- data/plugin/skills/woods-setup/SKILL.md +66 -4
- 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
|
-
#
|
|
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.
|
|
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
|
|
46
|
+
# Set console_embedded_read_tools to unlock the sql and query tools:
|
|
43
47
|
#
|
|
44
48
|
# # config/initializers/woods_console.rb
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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'
|
|
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
|
-
{
|
|
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,
|
|
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
|
|
143
|
-
#
|
|
144
|
-
|
|
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)
|
|
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[:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
#
|
|
34
|
-
#
|
|
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
|
-
#
|
|
105
|
-
#
|
|
106
|
-
# scan
|
|
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
|
|
188
|
-
close = sql
|
|
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
|
|
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
|
|
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
|
-
# (
|
|
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?(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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{
|
|
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
|
|
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
|
|
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
|
|
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+\
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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{
|
|
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
|