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 +4 -4
- data/CHANGELOG.md +25 -0
- data/CONTRIBUTING.md +13 -10
- data/README.md +1 -1
- data/exe/woods-console +1 -1
- data/exe/woods-mcp-http +17 -4
- data/lib/woods/cache/cache_middleware.rb +11 -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 +112 -27
- data/lib/woods/console/model_validator.rb +11 -1
- data/lib/woods/console/rack_middleware.rb +27 -6
- data/lib/woods/console/redactor.rb +46 -14
- data/lib/woods/console/safe_context.rb +56 -8
- data/lib/woods/console/server.rb +2 -1
- data/lib/woods/console/sql_noise_stripper.rb +147 -35
- data/lib/woods/console/sql_output_policy.rb +535 -0
- data/lib/woods/console/sql_table_scanner.rb +117 -46
- data/lib/woods/console/sql_validator.rb +60 -5
- data/lib/woods/mcp/bearer_auth.rb +29 -10
- data/lib/woods/mcp/http_transport_options.rb +2 -11
- data/lib/woods/mcp/origin_guard.rb +25 -51
- data/lib/woods/mcp/origin_policy.rb +113 -0
- data/lib/woods/railtie.rb +11 -4
- data/lib/woods/version.rb +1 -1
- metadata +8 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec94754e7068662b57f3a9d9b760248189467408bfc98acd9e2485c86547d0d9
|
|
4
|
+
data.tar.gz: 4675c8abe609f22e8829611ede3be68d43e19bb25a72e791203fe6526dfa9a8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
132
|
-
security patch while `main` carries the newer 2.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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, **
|
|
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,
|
|
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
|
-
#
|
|
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
|
-
#
|
|
423
|
-
#
|
|
424
|
-
#
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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 { |
|
|
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
|
-
|
|
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 { |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>]
|