woods 1.6.2 → 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: 135a4869f2fdd230990dc723a96d62f19447e51295768392d25bd0125dd4e25a
4
- data.tar.gz: bdce75a6b653bd5da5f3a7f4e7c9180020235f5a232daa5e58a4d439824e9a34
3
+ metadata.gz: ec94754e7068662b57f3a9d9b760248189467408bfc98acd9e2485c86547d0d9
4
+ data.tar.gz: 4675c8abe609f22e8829611ede3be68d43e19bb25a72e791203fe6526dfa9a8f
5
5
  SHA512:
6
- metadata.gz: 7dbe8d5460b1775c4f785954c88539fb29a76ad2878ee58e5e8ca11616787c87a058b9e9a774867f3cff4afe0c1b8ade137fd657f5249995d594e2ce2d7dfd8e
7
- data.tar.gz: 99db15fe718972172398fcf1574b33e26e94869beeb0d90c95e7adcb6b41b4a53ad3b3c4fe34067ba01df7e485dec61e73cda5b739dda89dab665f9c6bfb3282
6
+ metadata.gz: '098bf6d0109762c9499908689201d3e4f3b7040621754161696ebc7582d66d2a50fee597102c5248d32d8b1091fbad255d0f4b87dad7f0d72060f56d11c0bf39'
7
+ data.tar.gz: acdd128027e2b0e0f882cd4af56b431151071fb947e3ba9b4f646452e650c20639d21b6b3062a66eaeb81ab1674ee8d975b8de683da36496ff17e5417f0a8813
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ 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
+
35
+ ## [1.6.3] - 2026-09-22
36
+
37
+ ### Build
38
+
39
+ - Add the explicit, no-publish preparation cycle for the 1.6.3 security maintenance release; keep version writes task-owned and publication dependent on a reviewed trusted-main SHA pin.
40
+
41
+ ### Security
42
+
43
+ - Apply blocked-table policy to resolved model relations and SQLite table syntax before reads, retain table boundaries across comments, and reject quoted spellings of existing denied SQL functions.
44
+ - Scan the serialized Console response after protected-field redaction so Symbol values and custom JSON output cannot bypass credential scanning.
45
+
10
46
  ## [1.6.2] - 2026-09-18
11
47
 
12
48
  ### Fixed
data/CONTRIBUTING.md CHANGED
@@ -128,31 +128,39 @@ 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.2
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.2`, based on immutable `v1.6.1`. 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.2.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.2]"
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
146
- README maintenance `release-state` banner manually. The first reopen creates that
147
- banner; prepare updates it and folds classified Unreleased notes and optional
148
- `changelog/<type>_<slug>.md` entries into a dated release heading. Entry files are
146
+ README maintenance `release-state` banner manually. The existing maintenance
147
+ banner must be present; both tasks update it. Prepare also folds classified
148
+ Unreleased notes and optional `changelog/<type>_<slug>.md` entries into a dated
149
+ release heading. Entry files are
149
150
  nonempty Markdown without headings; supported types include `fixed`, `security`,
150
151
  `build`, and `documentation`. Invalid transitions, dirty trees, malformed notes,
151
152
  and missing/duplicate/unknown fences refuse before writes. This legacy profile
152
153
  has no v2 migration guide or v2 surface inventory requirement.
153
154
 
155
+ CI also runs on pushes to the exact `release/1.6.4` branch, so a private-advisory
156
+ merge receives upstream validation before the trusted-main SHA pin. Publication
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.
161
+
154
162
  Validate the full suite, lint, booted extraction, real Console credential rotation,
155
- 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
156
164
  sidecar into `woods-release-<commit SHA>` and tests that artifact on Ruby 3.0/Rails
157
165
  6.0 with exactly MCP 0.23.0 and Ruby 4.0/Rails 8.1 with the latest compatible 0.x SDK.
158
166
  MCP >=0.23.0 is required for upstream transport security fixes; update Woods and
@@ -165,12 +173,12 @@ ruby -rrubygems -e 'load Gem.bin_path("rspec-core", "rspec")' -- \
165
173
  --options /dev/null spec/integration/maintenance_packaged_gem_spec.rb
166
174
  ```
167
175
 
168
- Preparation is not publication; check RubyGems before describing 1.6.2 as released.
176
+ Preparation is not publication; check RubyGems before describing 1.6.4 as released.
169
177
  The legacy automatic tag publisher is disabled, and Bundler's `release`,
170
178
  `release:rubygem_push`, and `release:source_control_push` tasks abort. Only a
171
179
  maintainer may later tag the reviewed merge commit and dispatch the trusted
172
180
  **main** workflow. That workflow must explicitly allow the exact tag, protected
173
- maintenance branch, immutable 1.6.1 base, reviewed final candidate SHA, required
181
+ maintenance branch, immutable 1.6.3 base, reviewed final candidate SHA, required
174
182
  CI jobs, and immutable artifact. An unpinned candidate remains blocked. First merge a reviewed trusted-main profile update pinning `approved_sha` to that
175
183
  exact maintenance merge SHA; only then may the maintainer tag it. The
176
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.2: 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
@@ -145,7 +145,7 @@ module Woods
145
145
 
146
146
  # Scan a value (String, Hash, Array, or any other object) for credentials.
147
147
  #
148
- # Strings are gsub'd against every active pattern. Hash values and Array
148
+ # Strings and Symbols are scanned against every active pattern. Hash values and Array
149
149
  # elements are walked recursively; keys and non-string scalars
150
150
  # (Integer, Float, true/false, nil) pass through untouched.
151
151
  #
@@ -164,6 +164,7 @@ module Woods
164
164
  def walk(value, counts, index)
165
165
  case value
166
166
  when String then scan_string(value, counts, index)
167
+ when Symbol then scan_string(value.to_s, counts, index).to_sym
167
168
  when Hash then walk_hash(value, counts, index)
168
169
  when Array then value.map { |item| walk(item, counts, index) }
169
170
  else value
@@ -77,15 +77,33 @@ module Woods
77
77
  response = @conn_mgr.send_request(request)
78
78
  return error_from_response(response, request) unless response['ok']
79
79
 
80
- result = @ctx.redact(response['result'])
81
- result = scan_for_credentials(result, request)
82
- text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
83
- success_response(text)
80
+ render_response(response['result'], request)
84
81
  rescue ConnectionError => e
85
82
  scanned = scan_for_credentials("Connection error: #{e.message}", request)
86
83
  error_response(scanned)
87
84
  end
88
85
 
86
+ def render_response(result, request)
87
+ # Remove protected values before custom serializers run, then scan
88
+ # the materialized representation consumed by both response renderers.
89
+ result = @ctx.redact(result)
90
+ result = JSON.parse(JSON.generate(result))
91
+ result = @ctx.redact(result)
92
+ result = scan_for_credentials(result, request)
93
+ text = @renderer ? @renderer.render_default(result) : JSON.pretty_generate(result)
94
+ success_response(text)
95
+ rescue StandardError
96
+ # SDK 0.x includes uncaught exception messages in its tool errors.
97
+ # Serializer/parser/renderer messages may contain unscanned values.
98
+ rendering_error_response(request)
99
+ end
100
+
101
+ def rendering_error_response(request)
102
+ error_response(scan_for_credentials('Console response could not be serialized safely.', request))
103
+ rescue StandardError
104
+ error_response('[REDACTION_FAILED]')
105
+ end
106
+
89
107
  def error_from_response(response, request)
90
108
  error_text = "#{response['error_type']}: #{response['error']}"
91
109
  error_text = scan_for_credentials(error_text, request)