woods 1.6.3 → 1.6.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b77f03a9d89a92c666a6ef9b8b20ed8583270fa0cfbb923acea9d6fbc973f9d2
4
- data.tar.gz: 8336dccba436603daadaccd125888bc40702f16c4f514002bff298c9d8905f9e
3
+ metadata.gz: ec94754e7068662b57f3a9d9b760248189467408bfc98acd9e2485c86547d0d9
4
+ data.tar.gz: 4675c8abe609f22e8829611ede3be68d43e19bb25a72e791203fe6526dfa9a8f
5
5
  SHA512:
6
- metadata.gz: ff12092063cd2a853c293812705b64f38c83da9a682d89bdcf243665ab810b5d5368e2d086a3a2949d885d677845637a3773b58c4322b62e56b83585f6f7e208
7
- data.tar.gz: '0329bd31a7ddd6b0d05dedb46b4942a09d5534faf69668e77d45b1b9a8d37f1c3b860cc222446ddef7e64717e716d7388ef1217f3efe80578e481b875a7edba6'
6
+ metadata.gz: '098bf6d0109762c9499908689201d3e4f3b7040621754161696ebc7582d66d2a50fee597102c5248d32d8b1091fbad255d0f4b87dad7f0d72060f56d11c0bf39'
7
+ data.tar.gz: acdd128027e2b0e0f882cd4af56b431151071fb947e3ba9b4f646452e650c20639d21b6b3062a66eaeb81ab1674ee8d975b8de683da36496ff17e5417f0a8813
data/CHANGELOG.md CHANGED
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.4] - 2026-09-26
11
+
12
+ ### Security
13
+
14
+ - Enforce configured bearer authentication and allowed origins on every enabled Console HTTP mount before constructing its transport, including manually mounted endpoints.
15
+ - Mask protected collection-valued cells completely and redact overlapping credential matches before rewriting response text.
16
+ - Preserve configured SQL redaction and blocked-table boundaries for supported read queries. Ambiguous protected expressions and unsupported result types refuse clearly. Apply MySQL timeout handling to compatible adapters while retaining the legacy tool API and SQL function policy.
17
+ - Isolate retrieval-context cache entries by retriever instance and rotate only that instance's namespace on reload, preventing shared-backend and stale in-flight cache reuse.
18
+ - Refuse unsupported PostgreSQL escaped identifiers in raw Console SQL before query execution. Ordinary quoted identifiers, literal contents, and comments retain their existing behavior.
19
+ - Share validated Console query projections between execution and typed EAV redaction context, including whitespace normalization and legacy comma-separated selections.
20
+ - Preserve configured Console data protections across database adapters, typed attributes, and SQL result shapes.
21
+ - Refuse SQL relation and CTE column alias lists while output redaction is configured, with an explicit redaction-identity error before execution. Direct unaliased projections remain supported.
22
+ - Apply typed key/value redaction consistently to case-variant and schema-qualified source table names, retaining all matching model types when source schemas are ambiguous.
23
+
24
+ ### Documentation
25
+
26
+ - Clarify supported Console redaction, SQL projection, adapter timeout and HTTP origin behavior, including maintenance-line differences.
27
+
28
+ ### Fixed
29
+
30
+ - Allow Console middleware construction before Woods configuration is initialized, and run each booted Console contract in its own process across the supported Rails CI matrix.
31
+ - Report invalidly encoded HTTP origin settings with bounded startup diagnostics; preserve existing origin defaults and access checks.
32
+ - HTTP startup diagnostics consistently name `WOODS_MCP_HTTP_ALLOWED_ORIGINS` for malformed origins, including under a POSIX (`C`) locale.
33
+ - Point development package links for `1.6.4.alpha` at the `release/1.6.4` branch, matching the maintenance preparation flow. Prepared `1.6.4` links remain unchanged.
34
+
10
35
  ## [1.6.3] - 2026-09-22
11
36
 
12
37
  ### Build
data/CONTRIBUTING.md CHANGED
@@ -128,18 +128,18 @@ By contributing, you agree that your contributions will be licensed under the MI
128
128
 
129
129
  ## Maintenance release
130
130
 
131
- This tree has a one-off, no-publish preparation adapter for the reviewed 1.6.3
132
- security patch while `main` carries the newer 2.0 prerelease line. It does not
131
+ This tree has a one-off, no-publish preparation adapter for the reviewed 1.6.4
132
+ security patch while `main` carries the newer 2.1 development line. It does not
133
133
  establish a permanent stable branch or authorize another maintenance version.
134
- The approved target is `release/1.6.3`, based on immutable `v1.6.2`. Create that
134
+ The approved target is `release/1.6.4`, based on immutable `v1.6.3`. Create that
135
135
  remote target only after the trusted main maintenance policy is reviewed.
136
136
 
137
137
  From a clean checkout, the only supported transitions are:
138
138
 
139
139
  ```sh
140
- bin/rake "release:reopen[1.6.3.alpha]"
140
+ bin/rake "release:reopen[1.6.4.alpha]"
141
141
  # Review and commit the generated development-state diff.
142
- bin/rake "release:prepare[1.6.3]"
142
+ bin/rake "release:prepare[1.6.4]"
143
143
  ```
144
144
 
145
145
  The tasks never commit, tag, push, dispatch or publish. Never edit VERSION or the
@@ -152,12 +152,15 @@ nonempty Markdown without headings; supported types include `fixed`, `security`,
152
152
  and missing/duplicate/unknown fences refuse before writes. This legacy profile
153
153
  has no v2 migration guide or v2 surface inventory requirement.
154
154
 
155
- CI also runs on pushes to the exact `release/1.6.3` branch, so a private-advisory
155
+ CI also runs on pushes to the exact `release/1.6.4` branch, so a private-advisory
156
156
  merge receives upstream validation before the trusted-main SHA pin. Publication
157
- still requires a separate green tag-push run.
157
+ still requires a separate green tag-push run. This legacy matrix intentionally
158
+ has no C-locale artifact-reader lane. Its installed maintenance-package and
159
+ backend contracts remain required; do not claim v2 artifact-reader CI coverage
160
+ for this branch.
158
161
 
159
162
  Validate the full suite, lint, booted extraction, real Console credential rotation,
160
- and installed maintenance package tests. CI builds one gem plus its SHA-256
163
+ installed maintenance package tests, and the real PostgreSQL/MySQL Console policy lane. CI builds one gem plus its SHA-256
161
164
  sidecar into `woods-release-<commit SHA>` and tests that artifact on Ruby 3.0/Rails
162
165
  6.0 with exactly MCP 0.23.0 and Ruby 4.0/Rails 8.1 with the latest compatible 0.x SDK.
163
166
  MCP >=0.23.0 is required for upstream transport security fixes; update Woods and
@@ -170,12 +173,12 @@ ruby -rrubygems -e 'load Gem.bin_path("rspec-core", "rspec")' -- \
170
173
  --options /dev/null spec/integration/maintenance_packaged_gem_spec.rb
171
174
  ```
172
175
 
173
- Preparation is not publication; check RubyGems before describing 1.6.3 as released.
176
+ Preparation is not publication; check RubyGems before describing 1.6.4 as released.
174
177
  The legacy automatic tag publisher is disabled, and Bundler's `release`,
175
178
  `release:rubygem_push`, and `release:source_control_push` tasks abort. Only a
176
179
  maintainer may later tag the reviewed merge commit and dispatch the trusted
177
180
  **main** workflow. That workflow must explicitly allow the exact tag, protected
178
- maintenance branch, immutable 1.6.2 base, reviewed final candidate SHA, required
181
+ maintenance branch, immutable 1.6.3 base, reviewed final candidate SHA, required
179
182
  CI jobs, and immutable artifact. An unpinned candidate remains blocked. First merge a reviewed trusted-main profile update pinning `approved_sha` to that
180
183
  exact maintenance merge SHA; only then may the maintainer tag it. The
181
184
  workflow publishes the already tested gem bytes; never rebuild or publish locally.
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  # Woods
6
6
 
7
7
  <!-- release-state:maintenance-banner -->
8
- > **Woods 1.6.3: prepared maintenance candidate.** This tree remains on the 1.6 maintenance line.
8
+ > **Woods 1.6.4: prepared maintenance candidate.** This tree remains on the 1.6 maintenance line.
9
9
  > Preparation does not establish publication. Check [RubyGems versions](https://rubygems.org/gems/woods/versions)
10
10
  > before selecting an install version. See [maintenance release policy](CONTRIBUTING.md#maintenance-release).
11
11
  <!-- release-state:end -->
data/exe/woods-console CHANGED
@@ -78,7 +78,7 @@ rescue StandardError
78
78
  next
79
79
  end
80
80
 
81
- validator = Woods::Console::ModelValidator.new(registry: registry)
81
+ validator = Woods::Console::ModelValidator.new(registry: registry, table_names: model_tables)
82
82
  safe_context = Woods::Console::SafeContext.new(connection: ActiveRecord::Base.connection)
83
83
 
84
84
  config = Woods.configuration
data/exe/woods-mcp-http CHANGED
@@ -33,10 +33,24 @@ require_relative '../lib/woods/embedding/text_preparer'
33
33
  require_relative '../lib/woods/embedding/indexer'
34
34
 
35
35
  begin
36
+ raw_origins = ENV.fetch('WOODS_MCP_HTTP_ALLOWED_ORIGINS', '')
37
+ unless raw_origins.valid_encoding?
38
+ invalid_entry = raw_origins.b.split(',').find do |entry|
39
+ !entry.dup.force_encoding(raw_origins.encoding).valid_encoding?
40
+ end
41
+ label = invalid_entry.to_s.b.byteslice(0, 160).inspect
42
+ raise Woods::ConfigurationError, "Invalid WOODS_MCP_HTTP_ALLOWED_ORIGINS entry #{label}: invalid encoding"
43
+ end
44
+ allowed_origins = raw_origins.split(',').map(&:strip).reject(&:empty?)
45
+ begin
46
+ origin_policy = Woods::MCP::OriginPolicy.new(allowed_origins: allowed_origins)
47
+ rescue ArgumentError => e
48
+ raise Woods::ConfigurationError, "#{e.message} (WOODS_MCP_HTTP_ALLOWED_ORIGINS)"
49
+ end
36
50
  index_dir = Woods::MCP::Bootstrapper.resolve_index_dir(ARGV)
37
51
  retriever, bootstrap_state = Woods::MCP::Bootstrapper.build_retriever(index_dir: index_dir)
38
52
  snapshot_store = Woods::MCP::Bootstrapper.build_snapshot_store(index_dir)
39
- rescue Woods::MCP::BootstrapError => e
53
+ rescue Woods::MCP::BootstrapError, Woods::ConfigurationError => e
40
54
  warn "[woods-mcp-http] #{e.class.name.split('::').last}: #{e.message}"
41
55
  warn "[woods-mcp-http] details: #{e.details.inspect}" if e.respond_to?(:details) && !e.details.empty?
42
56
  exit 2
@@ -64,15 +78,14 @@ server = Woods::MCP::Server.build(
64
78
  index_dir: index_dir, retriever: retriever, snapshot_store: snapshot_store,
65
79
  bootstrap_state: bootstrap_state, retriever_reloader: retriever_reloader
66
80
  )
67
- allowed_origins = ENV.fetch('WOODS_MCP_HTTP_ALLOWED_ORIGINS', '').split(',').map(&:strip).reject(&:empty?)
68
81
  transport = MCP::Server::Transports::StreamableHTTPTransport.new(
69
- server, **Woods::MCP::HttpTransportOptions.for(allowed_origins)
82
+ server, **origin_policy.transport_options
70
83
  )
71
84
  server.transport = transport
72
85
 
73
86
  inner = proc { |env| transport.handle_request(Rack::Request.new(env)) }
74
87
  app = token ? Woods::MCP::BearerAuth.new(inner, token: token) : inner
75
- app = Woods::MCP::OriginGuard.new(app, allowed_origins: allowed_origins)
88
+ app = Woods::MCP::OriginGuard.new(app, policy: origin_policy)
76
89
 
77
90
  origin_summary = allowed_origins.empty? ? 'loopback' : allowed_origins.join(',')
78
91
  auth_mode = token ? 'bearer' : 'none'
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'securerandom'
4
5
  require_relative 'cache_store'
5
6
 
6
7
  module Woods
@@ -403,6 +404,8 @@ module Woods
403
404
  @retriever = retriever
404
405
  @cache_store = cache_store
405
406
  @context_ttl = context_ttl
407
+ @context_namespace = SecureRandom.hex(16)
408
+ @context_mutex = Mutex.new
406
409
  end
407
410
 
408
411
  # Expose the wrapped stores so the MCP +reload+ tool and
@@ -414,20 +417,16 @@ module Woods
414
417
  def metadata_store = @retriever.metadata_store
415
418
  def graph_store = @retriever.graph_store
416
419
 
417
- # Invalidate every cached context result. Called from the MCP +reload+
418
- # tool after the retriever's stores have been re-hydrated from a fresh
419
- # embed — otherwise cached results from the old embedding run would
420
- # linger until their TTL expires and contradict the new stores.
420
+ # Retire this retriever's cached contexts after its corpus reloads.
421
421
  #
422
- # Embedding caches (query → vector) are NOT cleared: the query-vector
423
- # mapping is deterministic for a given provider+model and survives any
424
- # index reload. Only context results (query → ranked units) go stale.
422
+ # Each instance owns an unpredictable namespace. Rotation prevents an
423
+ # in-flight request from repopulating the active cache with old results;
424
+ # retired entries expire by their TTL. Other retrievers and embedding
425
+ # caches remain independent, without any global backend deletion.
425
426
  #
426
427
  # @return [void]
427
428
  def invalidate_context_cache!
428
- @cache_store.clear(namespace: :context)
429
- rescue StandardError => e
430
- warn("[Woods] CachedRetriever context-cache invalidation failed: #{e.message}")
429
+ @context_mutex.synchronize { @context_namespace = SecureRandom.hex(16) }
431
430
  end
432
431
 
433
432
  # Execute the retrieval pipeline with context-level caching.
@@ -474,7 +473,8 @@ module Woods
474
473
  # @param exclude_types [Array<String, Symbol>, nil]
475
474
  # @return [String]
476
475
  def context_key(query, budget, types: nil, exclude_types: nil)
477
- Cache.cache_key(:context, query, budget.to_s, fingerprint(types), fingerprint(exclude_types))
476
+ namespace = @context_mutex.synchronize { @context_namespace }
477
+ Cache.cache_key(:context, namespace, query, budget.to_s, fingerprint(types), fingerprint(exclude_types))
478
478
  end
479
479
 
480
480
  def fingerprint(types)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ # Adapter names differ from the SQL grammar and session settings they use.
6
+ module AdapterFamily
7
+ # Classify a live connection by adapter ancestry, database configuration,
8
+ # then its display name. Unknown families remain nil: SQL callers must
9
+ # refuse rather than silently omit dialect-specific safeguards.
10
+ # @param connection [Object] Active Record connection or compatible adapter
11
+ # @return [Symbol, nil] :postgres, :mysql, :sqlite, or unknown
12
+ def self.for(connection)
13
+ ancestry = connection.class.ancestors.filter_map(&:name)
14
+ return :postgres if ancestry.include?('ActiveRecord::ConnectionAdapters::PostgreSQLAdapter')
15
+ return :mysql if ancestry.any? { |name| name.match?(/::(?:AbstractMysql|Mysql2|Trilogy)Adapter\z/) }
16
+ return :sqlite if ancestry.include?('ActiveRecord::ConnectionAdapters::SQLite3Adapter')
17
+
18
+ from_name(configured_adapter(connection)) || from_name(connection.adapter_name)
19
+ end
20
+
21
+ def self.configured_adapter(connection)
22
+ return unless connection.respond_to?(:pool) && connection.pool.respond_to?(:db_config)
23
+
24
+ connection.pool.db_config.adapter
25
+ end
26
+ private_class_method :configured_adapter
27
+
28
+ def self.from_name(value)
29
+ name = value.to_s.downcase
30
+ return :mysql if name.include?('mysql') || %w[trilogy mariadb].include?(name)
31
+ return :postgres if name.include?('postgre') || %w[postgis cockroachdb redshift].include?(name)
32
+ return :sqlite if name.include?('sqlite')
33
+
34
+ nil
35
+ end
36
+ private_class_method :from_name
37
+ end
38
+ end
39
+ end
@@ -46,7 +46,7 @@ module Woods
46
46
  # index.redact("token: sk_live_actual_secret_value")
47
47
  # # => "token: [REDACTED:credential]"
48
48
  #
49
- class CredentialIndex
49
+ class CredentialIndex # rubocop:disable Metrics/ClassLength
50
50
  # Captured at require time so the mtime-check warning has a stable
51
51
  # reference point even if the clock skews later. Frozen immediately
52
52
  # to prevent accidental mutation.
@@ -187,7 +187,10 @@ module Woods
187
187
  def initialize(secrets:)
188
188
  filtered = Array(secrets).select { |s| s.is_a?(String) && s.length >= MIN_LENGTH }
189
189
  @secrets = filtered.to_set.freeze
190
- @pattern = @secrets.empty? ? nil : Regexp.union(@secrets.to_a)
190
+ # Regexp alternatives match in order: a shorter prefix must not consume
191
+ # only the start of a longer indexed credential and expose its suffix.
192
+ longest_first = @secrets.each_with_index.sort_by { |secret, idx| [-secret.length, idx] }.map(&:first)
193
+ @pattern = @secrets.empty? ? nil : Regexp.union(longest_first)
191
194
  end
192
195
 
193
196
  # @return [Boolean] true when no secrets were collected (missing key,
@@ -212,7 +215,34 @@ module Woods
212
215
  def redact(str)
213
216
  return str if empty? || !str.is_a?(String) || !@pattern.match?(str)
214
217
 
215
- str.gsub(@pattern, REDACTED)
218
+ offset = 0
219
+ parts = []
220
+ redaction_spans(str).each do |start, finish|
221
+ parts << str[offset...start] << REDACTED
222
+ offset = finish
223
+ end
224
+ parts << str[offset..]
225
+ parts.join
226
+ end
227
+
228
+ private
229
+
230
+ # Search from each match's start, rather than its end, so an overlapping
231
+ # credential cannot expose its suffix after an earlier replacement.
232
+ # Union the covered spans before changing the original string.
233
+ def redaction_spans(str)
234
+ spans = []
235
+ offset = 0
236
+ while (match = @pattern.match(str, offset))
237
+ start, finish = match.offset(0)
238
+ if spans.last && start < spans.last[1]
239
+ spans.last[1] = [spans.last[1], finish].max
240
+ else
241
+ spans << [start, finish]
242
+ end
243
+ offset = start + 1
244
+ end
245
+ spans
216
246
  end
217
247
  end
218
248
  end
@@ -8,10 +8,13 @@ require_relative 'confirmation'
8
8
  require_relative 'eval_guard'
9
9
  require_relative 'model_validator'
10
10
  require_relative 'safe_context'
11
+ require_relative 'redactor'
11
12
  require_relative 'scope_predicate_parser'
12
13
  require_relative 'sql_noise_stripper'
13
14
  require_relative 'sql_validator'
14
15
  require_relative 'table_gate'
16
+ require_relative 'adapter_family'
17
+ require_relative 'sql_output_policy'
15
18
 
16
19
  module Woods
17
20
  module Console
@@ -29,6 +32,8 @@ module Woods
29
32
  # # => { 'ok' => true, 'result' => { 'count' => 42 }, 'timing_ms' => 1.2 }
30
33
  #
31
34
  class EmbeddedExecutor # rubocop:disable Metrics/ClassLength
35
+ include SqlOutputPolicy
36
+
32
37
  AGGREGATE_FUNCTIONS = %w[sum average minimum maximum count].freeze
33
38
 
34
39
  TIER1_TOOLS = BridgeProtocol::TIER1_TOOLS
@@ -47,6 +52,7 @@ module Woods
47
52
 
48
53
  # @param model_validator [ModelValidator] Validates model/column names
49
54
  # @param safe_context [SafeContext] Wraps execution in rolled-back transaction
55
+ # @param redaction_context [SafeContext, nil] Output policy supplied by the server renderer
50
56
  # @param connection [Object, nil] Database connection for adapter detection
51
57
  # @param read_tools_enabled [Boolean] Enable sql/query tools in embedded mode (default: false)
52
58
  # @param table_gate [TableGate, nil] Enforces console_blocked_tables on every
@@ -69,9 +75,10 @@ module Woods
69
75
  # refusal as before.
70
76
  def initialize(model_validator:, safe_context:, connection: nil, read_tools_enabled: false, # rubocop:disable Metrics/ParameterLists
71
77
  table_gate: nil, eval_guard: nil, confirmation: nil, audit_logger: nil,
72
- unsafe_eval_enabled: false)
78
+ unsafe_eval_enabled: false, redaction_context: nil)
73
79
  @model_validator = model_validator
74
80
  @safe_context = safe_context
81
+ @redaction_context = redaction_context || safe_context
75
82
  @connection = connection
76
83
  @read_tools_enabled = read_tools_enabled
77
84
  @table_gate = table_gate
@@ -99,7 +106,7 @@ module Woods
99
106
  return refusal if refusal
100
107
 
101
108
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
- result = @safe_context.execute { dispatch(tool, params) }
109
+ result = @safe_context.execute { dispatch_with_key_redaction(tool, params) }
103
110
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
104
111
 
105
112
  { 'ok' => true, 'result' => result, 'timing_ms' => elapsed }
@@ -119,6 +126,60 @@ module Woods
119
126
 
120
127
  private
121
128
 
129
+ def dispatch_with_key_redaction(tool, params)
130
+ refuse_protected_scope!(params['scope'])
131
+ refuse_protected_scope!(params['by']) if tool == 'find'
132
+ output = dispatch(tool, params)
133
+ return output if redaction_key_values.empty?
134
+
135
+ Redactor.apply(output, typed_redaction_context(tool, params))
136
+ end
137
+
138
+ def typed_redaction_context(tool, params)
139
+ types = Hash.new { |hash, key| hash[key] = [] }
140
+ typed_redaction_models(tool, params).each do |name, model|
141
+ redaction_key_values.each do |pattern|
142
+ key = pattern['key_column']
143
+ types[key] << model.type_for_attribute(key) if @model_validator.columns_for(name).include?(key)
144
+ end
145
+ end
146
+ @redaction_context.with_key_value_types(types, raw: %w[sql query].include?(tool))
147
+ end
148
+
149
+ def typed_redaction_models(tool, params)
150
+ tables = if tool == 'sql'
151
+ SqlTableScanner.identifiers_in(params['sql'], dialect: sql_dialect, mysql_modes: mysql_quote_modes)
152
+ else
153
+ selected_source_tables(params)
154
+ end
155
+ @model_validator.model_names.filter_map do |name|
156
+ model = resolve_model(name)
157
+ next unless model.respond_to?(:type_for_attribute)
158
+ next unless typed_model_source?(model, name, params, tables)
159
+
160
+ [name, model]
161
+ rescue NameError
162
+ # Injectable registries can describe models without loading Rails.
163
+ next
164
+ end
165
+ end
166
+
167
+ def typed_model_source?(model, name, params, tables)
168
+ return true if name == params['model']
169
+ return false unless model.respond_to?(:table_name)
170
+
171
+ # Match the scanner's unquoted final segment, as the identifier gates
172
+ # do. Keep all matching models across schemas: every possible type
173
+ # contributes to masking rather than selecting one ambiguous source.
174
+ tables.any? { |table| table.split('.').last.casecmp?(model.table_name.split('.').last) }
175
+ end
176
+
177
+ def selected_source_tables(params)
178
+ Array(params['select'] || params['columns']).filter_map do |column|
179
+ column.split('.')[0...-1].join('.') if column.include?('.')
180
+ end
181
+ end
182
+
122
183
  def sanitize_execution_error(error)
123
184
  klass = error.class.name
124
185
  # Well-known AR wrappers that contain the adapter error as their cause —
@@ -427,7 +488,7 @@ module Woods
427
488
 
428
489
  begin
429
490
  SqliteReadGuard.validate!(sql) if sql_dialect == :sqlite
430
- @table_gate.check_sql!(sql, dialect: sql_dialect == :sqlite ? :sqlite : nil)
491
+ @table_gate.check_sql!(sql, dialect: sql_dialect)
431
492
  rescue TableGateError, SqlValidationError => e
432
493
  raise ValidationError, e.message
433
494
  end
@@ -470,6 +531,7 @@ module Woods
470
531
  end
471
532
 
472
533
  def handle_find(params)
534
+ validate_select_columns!(params)
473
535
  model = resolve_model(params['model'])
474
536
  scope = checked_relation(model)
475
537
  record = if params['id']
@@ -482,7 +544,7 @@ module Woods
482
544
 
483
545
  def handle_pluck(params)
484
546
  columns = params['columns']
485
- @model_validator.validate_columns!(params['model'], columns) if columns
547
+ validate_select_columns!(params)
486
548
  model = resolve_model(params['model'])
487
549
  limit = [params.fetch('limit', 100).to_i, 1000].min
488
550
  scope = apply_scope(model, params['scope'], model_name: params['model'])
@@ -495,6 +557,7 @@ module Woods
495
557
  column = params['column']
496
558
  function = params['function']
497
559
  @model_validator.validate_column!(params['model'], column) if column
560
+ refuse_redacted_aggregate_expression!(column)
498
561
 
499
562
  unless AGGREGATE_FUNCTIONS.include?(function)
500
563
  raise ValidationError, "Invalid aggregate function: #{function}. " \
@@ -546,11 +609,7 @@ module Woods
546
609
  end
547
610
 
548
611
  def sql_dialect
549
- name = active_connection.adapter_name.to_s.downcase
550
- return :sqlite if name.include?('sqlite')
551
- return :mysql if name.include?('mysql')
552
-
553
- :postgres
612
+ AdapterFamily.for(active_connection)
554
613
  end
555
614
 
556
615
  def gate_association!(model_name, association)
@@ -594,6 +653,7 @@ module Woods
594
653
  limit = [params.fetch('limit', 10).to_i, 50].min
595
654
 
596
655
  @model_validator.validate_column!(params['model'], order_by)
656
+ refuse_protected_predicate_column!(order_by)
597
657
  direction = 'desc' unless %w[asc desc].include?(direction)
598
658
 
599
659
  scope = apply_scope(model, params['scope'], model_name: params['model'])
@@ -621,21 +681,33 @@ module Woods
621
681
  sql = params['sql']
622
682
  raise ValidationError, 'Missing required parameter: sql' unless sql
623
683
 
684
+ raise ValidationError, 'Rejected: console_sql requires a recognized database adapter family.' unless sql_dialect
685
+
624
686
  require_relative 'sql_validator'
625
- SqlValidator.new(dialect: sql_dialect).validate!(sql)
626
- # Post-validation, pre-execution TableGate — blocks every configured
627
- # table even if the sql is otherwise well-formed.
628
- gate_sql!(sql)
687
+ validate_sql_policy!(sql)
629
688
 
630
689
  limit = params['limit'] ? [params['limit'].to_i, MAX_SQL_LIMIT].min : nil
631
- query_sql = limit ? "SELECT * FROM (#{sql}) AS _limited LIMIT #{limit}" : sql
690
+ query_sql = limit ? "SELECT * FROM (\n#{sql}\n) AS _limited LIMIT #{limit}" : sql
691
+ validate_sql_policy!(query_sql) if limit
632
692
  result = active_connection.select_all(query_sql)
693
+ validate_sql_result_types!(result)
633
694
 
634
695
  { 'columns' => result.columns, 'rows' => result.rows, 'count' => result.rows.size }
635
696
  rescue SqlValidationError => e
636
697
  raise ValidationError, e.message
637
698
  end
638
699
 
700
+ def validate_sql_policy!(sql)
701
+ # Alias-list identity is its own policy, even when an alias also
702
+ # looks like a function name to the SQL validator.
703
+ sql_security_views(sql).each { |stripped| refuse_sql_column_alias_lists!(stripped) }
704
+ SqlValidator.new(dialect: sql_dialect, mysql_modes: mysql_quote_modes).validate!(sql)
705
+ validate_protected_sql_usage!(sql)
706
+ # Check both submitted and wrapped SQL against blocked tables before
707
+ # the exact final statement reaches the adapter.
708
+ gate_sql!(sql)
709
+ end
710
+
639
711
  # Build and execute a structured ActiveRecord query.
640
712
  #
641
713
  # @param params [Hash] Must contain 'model' and 'select'
@@ -695,7 +767,9 @@ module Woods
695
767
  model_name = params['model']
696
768
  relation = model.all
697
769
 
698
- relation = relation.select(*validated_select(params['select'], model_name)) if params['select']
770
+ # Keep execution and typed source inference on the same validated projection.
771
+ params['select'] = validated_select(params['select'], model_name) if params['select']
772
+ relation = relation.select(*params['select']) if params['select']
699
773
  relation = relation.joins(params['joins'].map(&:to_sym)) if params['joins']&.any?
700
774
  relation = apply_scope(relation, params['scope'], model_name: model_name)
701
775
  relation = relation.group(*validated_columns(params['group_by'], model_name)) if params['group_by']&.any?
@@ -712,18 +786,18 @@ module Woods
712
786
  # @param model_name [String]
713
787
  # @return [Array<String>]
714
788
  def validated_select(select, model_name)
715
- Array(select).flat_map { |s| s.to_s.split(',') }.map do |expr|
789
+ expressions = Array(select).flat_map { |s| s.to_s.split(',') }.map do |expr|
716
790
  validate_select_expression!(expr.strip, model_name)
717
791
  end
792
+ refuse_orphan_eav_value_selection!(expressions, model_name)
793
+ expressions
718
794
  end
719
795
 
720
796
  def validate_select_expression!(expr, model_name)
721
797
  match = SAFE_SELECT_EXPR.match(expr)
722
798
  raise ValidationError, "Rejected select expression: #{expr.inspect}" unless match
723
799
 
724
- _fn, fn_arg, bare_col, _alias = match.captures
725
- column = bare_col || fn_arg
726
- validate_column_reference!(column, model_name) unless column == '*'
800
+ refuse_redacted_select_shapes!(match.captures, model_name)
727
801
  expr
728
802
  end
729
803
 
@@ -736,6 +810,7 @@ module Woods
736
810
  Array(columns).flat_map { |c| c.to_s.split(',') }.map do |col|
737
811
  col = col.strip
738
812
  validate_column_reference!(col, model_name)
813
+ refuse_protected_predicate_column!(col)
739
814
  col
740
815
  end
741
816
  end
@@ -760,12 +835,12 @@ module Woods
760
835
  /ix
761
836
  private_constant :HAVING_AGG_TEMPLATE
762
837
 
763
- def validated_having(having, model_name) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
838
+ def validated_having(having, model_name)
764
839
  case having
765
840
  when Hash
766
841
  raise ValidationError, 'having: empty hash' if having.empty?
767
842
 
768
- having.each_key { |k| validate_column_reference!(k.to_s, model_name) }
843
+ having.each_key { |key| validate_predicate_column_reference!(key.to_s, model_name) }
769
844
  [having]
770
845
  when Array
771
846
  raise ValidationError, 'having: array must be [sql_with_placeholders, *binds]' if having.empty?
@@ -776,8 +851,7 @@ module Woods
776
851
 
777
852
  # Validate any referenced columns through ModelValidator so
778
853
  # aggregate args can't reach the db without a column check.
779
- col = match[:col] || match[:arg]
780
- validate_column_reference!(col, model_name) if col && col != '*'
854
+ validate_having_input!(match, model_name)
781
855
 
782
856
  having
783
857
  else
@@ -785,11 +859,22 @@ module Woods
785
859
  end
786
860
  end
787
861
 
862
+ def validate_having_input!(match, model_name)
863
+ column = match[:col] || match[:arg]
864
+ validate_predicate_column_reference!(column, model_name) if column && column != '*'
865
+ refuse_redacted_aggregate_expression!(column) if match[:agg]
866
+ end
867
+
868
+ def validate_predicate_column_reference!(column, model_name)
869
+ validate_column_reference!(column, model_name)
870
+ refuse_protected_predicate_column!(column)
871
+ end
872
+
788
873
  # Validate `order:` — only Hash `{col => :asc|:desc}` or bare column name.
789
874
  def validated_order(order, model_name)
790
875
  case order
791
876
  when Hash
792
- order.each_key { |k| validate_column_reference!(k.to_s, model_name) }
877
+ order.each_key { |key| validate_predicate_column_reference!(key.to_s, model_name) }
793
878
  order.transform_values do |dir|
794
879
  dir_sym = dir.to_s.downcase.to_sym
795
880
  unless %i[asc desc].include?(dir_sym)
@@ -800,7 +885,7 @@ module Woods
800
885
  end
801
886
  when String, Symbol
802
887
  col = order.to_s.strip
803
- validate_column_reference!(col, model_name)
888
+ validate_predicate_column_reference!(col, model_name)
804
889
  col
805
890
  else
806
891
  raise ValidationError, "order: unsupported type #{order.class}"
@@ -967,6 +1052,7 @@ module Woods
967
1052
  return unless params['columns']
968
1053
 
969
1054
  @model_validator.validate_columns!(params['model'], params['columns'])
1055
+ refuse_orphan_eav_value_selection!(params['columns'], params['model'])
970
1056
  end
971
1057
 
972
1058
  # Apply column selection to a relation.
@@ -1006,8 +1092,7 @@ module Woods
1006
1092
  #
1007
1093
  # @return [Arel::Nodes::SqlLiteral]
1008
1094
  def random_function
1009
- adapter = active_connection.adapter_name.downcase
1010
- func = adapter.include?('mysql') ? 'RAND' : 'RANDOM'
1095
+ func = AdapterFamily.for(active_connection) == :mysql ? 'RAND' : 'RANDOM'
1011
1096
  Arel.sql("#{func}()")
1012
1097
  end
1013
1098
 
@@ -20,8 +20,10 @@ module Woods
20
20
  #
21
21
  class ModelValidator
22
22
  # @param registry [Hash<String, Array<String>>] Model name => column names mapping
23
- def initialize(registry:)
23
+ # @param table_names [Hash<String, String>] Optional table mapping for EAV provenance
24
+ def initialize(registry:, table_names: {})
24
25
  @registry = registry
26
+ @model_by_table = table_names.each_with_object({}) { |(model, table), acc| acc[table.to_s] = model }
25
27
  end
26
28
 
27
29
  # Validate that a model name is known.
@@ -59,6 +61,14 @@ module Woods
59
61
  column_names.each { |col| validate_column!(model_name, col) }
60
62
  end
61
63
 
64
+ # Return known table columns without guessing an unknown table's shape.
65
+ # @param table_name [String]
66
+ # @return [Array<String>, nil] nil when this table is not registered
67
+ def columns_for_table(table_name)
68
+ model = @model_by_table[table_name.to_s]
69
+ @registry[model] if model
70
+ end
71
+
62
72
  # List all known model names.
63
73
  #
64
74
  # @return [Array<String>]