woods 1.6.1 → 1.6.3

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: 4a3bf50a7ad421cc52a336375446c4e08c2583dbf2ce96e4a3038bd8dca7a0ae
4
- data.tar.gz: fe9a6512787372c3f3d0cbe4038720f123349b9961c548b1584d21b8b4cbd0fb
3
+ metadata.gz: b77f03a9d89a92c666a6ef9b8b20ed8583270fa0cfbb923acea9d6fbc973f9d2
4
+ data.tar.gz: 8336dccba436603daadaccd125888bc40702f16c4f514002bff298c9d8905f9e
5
5
  SHA512:
6
- metadata.gz: 7070f985d4a9bab191593599e4f4f8c807d0378c8345a9aa8a73c2ac4de0200b7959ffd6bb6a823bb12fd67a4afe90ad6d3475eb16bbb60ac320ead1de114b77
7
- data.tar.gz: 3c0cd27167baa9cd7e0245894527760e12bf2d038e57819ffa78468e6f0e6e4122dd144129bcfd769bd89965caad7c997299dfc5de9c1c38dfddb88bab8f2f78
6
+ metadata.gz: ff12092063cd2a853c293812705b64f38c83da9a682d89bdcf243665ab810b5d5368e2d086a3a2949d885d677845637a3773b58c4322b62e56b83585f6f7e208
7
+ data.tar.gz: '0329bd31a7ddd6b0d05dedb46b4942a09d5534faf69668e77d45b1b9a8d37f1c3b860cc222446ddef7e64717e716d7388ef1217f3efe80578e481b875a7edba6'
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.6.3] - 2026-09-22
11
+
12
+ ### Build
13
+
14
+ - 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.
15
+
16
+ ### Security
17
+
18
+ - 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.
19
+ - Scan the serialized Console response after protected-field redaction so Symbol values and custom JSON output cannot bypass credential scanning.
20
+
21
+ ## [1.6.2] - 2026-09-18
22
+
23
+ ### Fixed
24
+
25
+ - Refresh encrypted Rails credentials from disk when rebuilding the Console credential index, update every live scanner, and preserve the last valid index if refresh fails. Each response uses one consistent index snapshot.
26
+ Keep credential scanner refresh snapshots limited to live weakly referenced
27
+ scanners on older Ruby versions, avoiding unsafe receiver access after garbage
28
+ collection while preserving updates to every live Console server.
29
+ Keep JSON on `>= 2.19.9, < 3` so supported Rails encoders can serialize metadata after a fresh bundle resolution; JSON 3 removes their `quirks_mode` option. Bound MessagePack below 2 and Railties below 9, retaining their existing minimum versions and the strict package-build gate.
30
+ - Preserve version-aware missing-tool guidance with MCP 0.23 by installing its dispatch override before the SDK captures request handlers.
31
+
32
+ ### Build
33
+
34
+ - Add a no-publish, one-off 1.6.2 maintenance preparation flow and installed-package CI. Disable the legacy automatic publisher; publication requires the trusted main maintenance profile and its reviewed candidate SHA.
35
+
36
+ ### Security
37
+
38
+ - Require MCP 0.23.0 or newer within the 0.x line for upstream transport security fixes; run `bundle update woods mcp` when upgrading. Ruby 3.0 remains supported. The newer SDK removes the old json-schema/addressable dependency chain. Existing Index and Console HTTP origin settings now reach the SDK Host/Origin guards; cross-origin browser access requires the actual configured port.
39
+
10
40
  ## [1.6.1] - 2026-07-22
11
41
 
12
42
  ### Fixed
data/CONTRIBUTING.md CHANGED
@@ -125,3 +125,57 @@ Woods uses runtime introspection, not static parsing. If your feature requires a
125
125
  ## License
126
126
 
127
127
  By contributing, you agree that your contributions will be licensed under the MIT License.
128
+
129
+ ## Maintenance release
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
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
135
+ remote target only after the trusted main maintenance policy is reviewed.
136
+
137
+ From a clean checkout, the only supported transitions are:
138
+
139
+ ```sh
140
+ bin/rake "release:reopen[1.6.3.alpha]"
141
+ # Review and commit the generated development-state diff.
142
+ bin/rake "release:prepare[1.6.3]"
143
+ ```
144
+
145
+ The tasks never commit, tag, push, dispatch or publish. Never edit VERSION or the
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
150
+ nonempty Markdown without headings; supported types include `fixed`, `security`,
151
+ `build`, and `documentation`. Invalid transitions, dirty trees, malformed notes,
152
+ and missing/duplicate/unknown fences refuse before writes. This legacy profile
153
+ has no v2 migration guide or v2 surface inventory requirement.
154
+
155
+ CI also runs on pushes to the exact `release/1.6.3` 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.
158
+
159
+ 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
161
+ sidecar into `woods-release-<commit SHA>` and tests that artifact on Ruby 3.0/Rails
162
+ 6.0 with exactly MCP 0.23.0 and Ruby 4.0/Rails 8.1 with the latest compatible 0.x SDK.
163
+ MCP >=0.23.0 is required for upstream transport security fixes; update Woods and
164
+ MCP together (`bundle update woods mcp`). Ruby 3.0 remains supported.
165
+ The optional package specs require `WOODS_GEM_PATH` and
166
+ run standalone, without the repository's `spec_helper` or implementation path:
167
+
168
+ ```sh
169
+ ruby -rrubygems -e 'load Gem.bin_path("rspec-core", "rspec")' -- \
170
+ --options /dev/null spec/integration/maintenance_packaged_gem_spec.rb
171
+ ```
172
+
173
+ Preparation is not publication; check RubyGems before describing 1.6.3 as released.
174
+ The legacy automatic tag publisher is disabled, and Bundler's `release`,
175
+ `release:rubygem_push`, and `release:source_control_push` tasks abort. Only a
176
+ maintainer may later tag the reviewed merge commit and dispatch the trusted
177
+ **main** workflow. That workflow must explicitly allow the exact tag, protected
178
+ maintenance branch, immutable 1.6.2 base, reviewed final candidate SHA, required
179
+ CI jobs, and immutable artifact. An unpinned candidate remains blocked. First merge a reviewed trusted-main profile update pinning `approved_sha` to that
180
+ exact maintenance merge SHA; only then may the maintainer tag it. The
181
+ workflow publishes the already tested gem bytes; never rebuild or publish locally.
data/README.md CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  # Woods
6
6
 
7
+ <!-- release-state:maintenance-banner -->
8
+ > **Woods 1.6.3: prepared maintenance candidate.** This tree remains on the 1.6 maintenance line.
9
+ > Preparation does not establish publication. Check [RubyGems versions](https://rubygems.org/gems/woods/versions)
10
+ > before selecting an install version. See [maintenance release policy](CONTRIBUTING.md#maintenance-release).
11
+ <!-- release-state:end -->
12
+
7
13
  **Your AI coding assistant is guessing about your Rails app. Woods gives it the real answers.**
8
14
 
9
15
  Rails hides enormous amounts of behavior behind conventions, concerns, and runtime magic. When you ask an AI assistant "what callbacks fire when a User saves?" or "what routes map to this controller?", it guesses from training data — and gets it wrong. Woods runs *inside* your Rails app, extracts what's actually happening at runtime, and serves that context directly to your AI tools via [MCP](https://modelcontextprotocol.io/).
data/exe/woods-mcp-http CHANGED
@@ -28,6 +28,7 @@ require_relative '../lib/woods/mcp/server'
28
28
  require_relative '../lib/woods/mcp/bootstrapper'
29
29
  require_relative '../lib/woods/mcp/bearer_auth'
30
30
  require_relative '../lib/woods/mcp/origin_guard'
31
+ require_relative '../lib/woods/mcp/http_transport_options'
31
32
  require_relative '../lib/woods/embedding/text_preparer'
32
33
  require_relative '../lib/woods/embedding/indexer'
33
34
 
@@ -63,10 +64,11 @@ server = Woods::MCP::Server.build(
63
64
  index_dir: index_dir, retriever: retriever, snapshot_store: snapshot_store,
64
65
  bootstrap_state: bootstrap_state, retriever_reloader: retriever_reloader
65
66
  )
66
- transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
67
- server.transport = transport
68
-
69
67
  allowed_origins = ENV.fetch('WOODS_MCP_HTTP_ALLOWED_ORIGINS', '').split(',').map(&:strip).reject(&:empty?)
68
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(
69
+ server, **Woods::MCP::HttpTransportOptions.for(allowed_origins)
70
+ )
71
+ server.transport = transport
70
72
 
71
73
  inner = proc { |env| transport.handle_request(Rack::Request.new(env)) }
72
74
  app = token ? Woods::MCP::BearerAuth.new(inner, token: token) : inner
@@ -68,6 +68,7 @@ module Woods
68
68
  MISSING_KEY_ERRORS = %w[
69
69
  ActiveSupport::EncryptedConfiguration::MissingKeyError
70
70
  ActiveSupport::EncryptedFile::MissingKeyError
71
+ ActiveSupport::EncryptedFile::MissingContentError
71
72
  ActiveSupport::MessageEncryptor::InvalidMessage
72
73
  ].freeze
73
74
 
@@ -90,13 +91,19 @@ module Woods
90
91
  # @return [CredentialIndex] populated index, or an empty index when
91
92
  # the credentials store can't be opened.
92
93
  def build(rails_app:)
93
- new(secrets: collect_secrets(rails_app))
94
+ refresh(rails_app: rails_app)
94
95
  rescue StandardError => e
95
96
  raise unless missing_key_error?(e)
96
97
 
97
98
  new(secrets: [])
98
99
  end
99
100
 
101
+ # Strict refresh: construct the replacement completely before a caller
102
+ # installs it. Unlike boot-time .build, errors must preserve the old index.
103
+ def refresh(rails_app:)
104
+ new(secrets: collect_secrets(rails_app))
105
+ end
106
+
100
107
  # Emit a structured log warning when any of the given credentials
101
108
  # files has a modification time newer than `process_start`. This
102
109
  # catches the common "rotated credentials but forgot to restart"
@@ -136,12 +143,23 @@ module Woods
136
143
  private
137
144
 
138
145
  def collect_secrets(rails_app)
139
- config = rails_app.credentials.config
146
+ config = fresh_credentials(rails_app.credentials).config
140
147
  collected = []
141
148
  walk(config, collected)
142
149
  collected
143
150
  end
144
151
 
152
+ def fresh_credentials(credentials)
153
+ return credentials unless defined?(ActiveSupport::EncryptedConfiguration) &&
154
+ credentials.is_a?(ActiveSupport::EncryptedConfiguration)
155
+
156
+ require_relative 'encrypted_credential_snapshot'
157
+ EncryptedCredentialSnapshot.new(
158
+ config_path: credentials.content_path, key_path: credentials.key_path,
159
+ env_key: credentials.env_key, raise_if_missing_key: true
160
+ )
161
+ end
162
+
145
163
  def walk(node, collected)
146
164
  case node
147
165
  when Hash then node.each_value { |v| walk(v, collected) }
@@ -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
  #
@@ -155,17 +155,18 @@ module Woods
155
155
  # for patterns that fired — callers should treat a missing key as zero.
156
156
  def scan(value)
157
157
  counts = {}
158
- scanned = walk(value, counts)
158
+ scanned = walk(value, counts, @secret_index)
159
159
  [scanned, counts]
160
160
  end
161
161
 
162
162
  private
163
163
 
164
- def walk(value, counts)
164
+ def walk(value, counts, index)
165
165
  case value
166
- when String then scan_string(value, counts)
167
- when Hash then walk_hash(value, counts)
168
- when Array then value.map { |item| walk(item, counts) }
166
+ when String then scan_string(value, counts, index)
167
+ when Symbol then scan_string(value.to_s, counts, index).to_sym
168
+ when Hash then walk_hash(value, counts, index)
169
+ when Array then value.map { |item| walk(item, counts, index) }
169
170
  else value
170
171
  end
171
172
  end
@@ -177,19 +178,19 @@ module Woods
177
178
  # credential-shaped string is emitted as a Symbol after redaction
178
179
  # (e.g. `:"[REDACTED]"`). String keys stay Strings. Non-String,
179
180
  # non-Symbol keys (Integer, etc.) pass through unchanged.
180
- def walk_hash(hash, counts)
181
+ def walk_hash(hash, counts, index)
181
182
  hash.each_with_object({}) do |(key, val), out|
182
183
  scanned_key = case key
183
- when String then scan_string(key, counts)
184
- when Symbol then scan_string(key.to_s, counts).to_sym
184
+ when String then scan_string(key, counts, index)
185
+ when Symbol then scan_string(key.to_s, counts, index).to_sym
185
186
  else key
186
187
  end
187
- out[scanned_key] = walk(val, counts)
188
+ out[scanned_key] = walk(val, counts, index)
188
189
  end
189
190
  end
190
191
 
191
- def scan_string(str, counts)
192
- result = redact_indexed_secrets(str, counts)
192
+ def scan_string(str, counts, index)
193
+ result = redact_indexed_secrets(str, counts, index)
193
194
  result = scan_encoded_forms(result, counts)
194
195
  @active_patterns.inject(result) do |acc, (name, pattern)|
195
196
  acc.gsub(pattern) do
@@ -290,10 +291,10 @@ module Woods
290
291
  printable.to_f / str.length >= PRINTABLE_RATIO_THRESHOLD
291
292
  end
292
293
 
293
- def redact_indexed_secrets(str, counts)
294
- return str unless @secret_index&.match?(str)
294
+ def redact_indexed_secrets(str, counts, index)
295
+ return str unless index&.match?(str)
295
296
 
296
- redacted = @secret_index.redact(str)
297
+ redacted = index.redact(str)
297
298
  counts[INDEX_HIT] = (counts[INDEX_HIT] || 0) + redacted.scan(CredentialIndex::REDACTED).size
298
299
  redacted
299
300
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Console
5
+ # Coordinates scanner construction and rotation without retaining servers
6
+ # after their transports release them. Requests keep their own index snapshot.
7
+ class CredentialScannerRegistry
8
+ def initialize
9
+ @scanners = ObjectSpace::WeakMap.new
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def register
14
+ @mutex.synchronize do
15
+ scanner = yield
16
+ # Older Ruby WeakMap implementations validate the value's liveness
17
+ # when enumerating. Keep the scanner in both weak positions so an
18
+ # immortal sentinel cannot make an abandoned scanner look live.
19
+ @scanners[scanner] = scanner
20
+ scanner
21
+ end
22
+ end
23
+
24
+ def rebuild
25
+ @mutex.synchronize do
26
+ scanners = @scanners.values
27
+ return nil if scanners.empty?
28
+
29
+ index = yield
30
+ scanners.each { |scanner| scanner.replace_index!(index) }
31
+ index
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -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)
@@ -10,6 +10,7 @@ require_relative 'model_validator'
10
10
  require_relative 'safe_context'
11
11
  require_relative 'scope_predicate_parser'
12
12
  require_relative 'sql_noise_stripper'
13
+ require_relative 'sql_validator'
13
14
  require_relative 'table_gate'
14
15
 
15
16
  module Woods
@@ -425,8 +426,9 @@ module Woods
425
426
  return unless @table_gate
426
427
 
427
428
  begin
428
- @table_gate.check_sql!(sql)
429
- rescue TableGateError => e
429
+ SqliteReadGuard.validate!(sql) if sql_dialect == :sqlite
430
+ @table_gate.check_sql!(sql, dialect: sql_dialect == :sqlite ? :sqlite : nil)
431
+ rescue TableGateError, SqlValidationError => e
430
432
  raise ValidationError, e.message
431
433
  end
432
434
  end
@@ -454,7 +456,7 @@ module Woods
454
456
  def handle_count(params)
455
457
  model = resolve_model(params['model'])
456
458
  scope = apply_scope(model, params['scope'], model_name: params['model'])
457
- { 'count' => scope.count }
459
+ { 'count' => checked_relation(scope).count }
458
460
  end
459
461
 
460
462
  def handle_sample(params)
@@ -463,16 +465,17 @@ module Woods
463
465
  limit = [params.fetch('limit', 5).to_i, 25].min
464
466
  scope = apply_scope(model, params['scope'], model_name: params['model'])
465
467
  scope = apply_columns(scope, params['columns'])
466
- records = scope.order(random_function).limit(limit)
468
+ records = checked_relation(scope.order(random_function).limit(limit))
467
469
  { 'records' => serialize_records(records, params['columns']) }
468
470
  end
469
471
 
470
472
  def handle_find(params)
471
473
  model = resolve_model(params['model'])
474
+ scope = checked_relation(model)
472
475
  record = if params['id']
473
- model.find_by(id: params['id'])
476
+ scope.find_by(id: params['id'])
474
477
  elsif params['by']
475
- model.find_by(params['by'])
478
+ scope.find_by(params['by'])
476
479
  end
477
480
  { 'record' => record ? serialize_record(record, params['columns']) : nil }
478
481
  end
@@ -484,7 +487,7 @@ module Woods
484
487
  limit = [params.fetch('limit', 100).to_i, 1000].min
485
488
  scope = apply_scope(model, params['scope'], model_name: params['model'])
486
489
  scope = scope.distinct if params['distinct']
487
- values = scope.limit(limit).pluck(*columns.map(&:to_sym))
490
+ values = checked_relation(scope.limit(limit)).pluck(*columns.map(&:to_sym))
488
491
  { 'columns' => Array(columns), 'values' => values }
489
492
  end
490
493
 
@@ -501,6 +504,7 @@ module Woods
501
504
  model = resolve_model(params['model'])
502
505
  scope = apply_scope(model, params['scope'], model_name: params['model'])
503
506
 
507
+ scope = checked_relation(scope)
504
508
  value = if function == 'count'
505
509
  column ? scope.count(column.to_sym) : scope.count
506
510
  else
@@ -511,7 +515,7 @@ module Woods
511
515
 
512
516
  def handle_association_count(params)
513
517
  model = resolve_model(params['model'])
514
- record = model.find(params['id'])
518
+ record = checked_relation(model).find(params['id'])
515
519
  association_name = params['association']
516
520
 
517
521
  unless model.reflect_on_association(association_name.to_sym)
@@ -527,7 +531,26 @@ module Woods
527
531
 
528
532
  scope = record.public_send(association_name)
529
533
  scope = apply_scope(scope, params['scope'])
530
- { 'count' => scope.count }
534
+ { 'count' => checked_relation(scope).count }
535
+ end
536
+
537
+ # Resolve the default scope once and execute the same checked relation.
538
+ # @param scope [Class, ActiveRecord::Relation] Pending model read
539
+ # @return [Class, ActiveRecord::Relation] Authorized relation
540
+ def checked_relation(scope)
541
+ return scope unless @table_gate
542
+
543
+ relation = scope.all
544
+ gate_sql!(relation.to_sql)
545
+ relation
546
+ end
547
+
548
+ 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
531
554
  end
532
555
 
533
556
  def gate_association!(model_name, association)
@@ -575,7 +598,7 @@ module Woods
575
598
 
576
599
  scope = apply_scope(model, params['scope'], model_name: params['model'])
577
600
  scope = apply_columns(scope, params['columns'])
578
- records = scope.order(order_by => direction.to_sym).limit(limit)
601
+ records = checked_relation(scope.order(order_by => direction.to_sym).limit(limit))
579
602
  { 'records' => serialize_records(records, params['columns']) }
580
603
  end
581
604
 
@@ -599,7 +622,7 @@ module Woods
599
622
  raise ValidationError, 'Missing required parameter: sql' unless sql
600
623
 
601
624
  require_relative 'sql_validator'
602
- SqlValidator.new.validate!(sql)
625
+ SqlValidator.new(dialect: sql_dialect).validate!(sql)
603
626
  # Post-validation, pre-execution TableGate — blocks every configured
604
627
  # table even if the sql is otherwise well-formed.
605
628
  gate_sql!(sql)
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/encrypted_configuration'
4
+
5
+ module Woods
6
+ module Console
7
+ # A private, uncached Rails configuration for one credential-index build.
8
+ # Unlike Rails' permissive configuration reader, a missing encrypted file
9
+ # must raise during an explicit refresh rather than erase the last index.
10
+ class EncryptedCredentialSnapshot < ActiveSupport::EncryptedConfiguration
11
+ def read
12
+ ActiveSupport::EncryptedFile.instance_method(:read).bind(self).call
13
+ end
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'woods/mcp/http_transport_options'
4
5
  require 'woods/observability/structured_logger'
5
6
 
6
7
  module Woods
@@ -131,7 +132,9 @@ module Woods
131
132
  Rails.application.eager_load!
132
133
 
133
134
  server = build_embedded_server
134
- @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server)
135
+ @transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(
136
+ server, **Woods::MCP::HttpTransportOptions.for(Woods.configuration.console_mcp_allowed_origins)
137
+ )
135
138
  server.transport = @transport
136
139
  @transport
137
140
  end
@@ -13,6 +13,7 @@ require_relative 'audit_logger'
13
13
  require_relative 'confirmation'
14
14
  require_relative 'console_response_renderer'
15
15
  require_relative 'credential_scanner'
16
+ require_relative 'credential_scanner_registry'
16
17
  require_relative 'eval_guard'
17
18
  require_relative 'table_gate'
18
19
  require_relative 'redactor'
@@ -34,10 +35,14 @@ module Woods
34
35
  # transport.open
35
36
  #
36
37
  module Server # rubocop:disable Metrics/ModuleLength
38
+ SCANNERS = CredentialScannerRegistry.new
39
+ private_constant :SCANNERS
40
+
37
41
  class << self # rubocop:disable Metrics/ClassLength
38
42
  # Rebuild the boot-time credential index from fresh Rails credentials
39
- # and hot-swap it into the active scanner without restarting the process.
43
+ # and hot-swap it into every live scanner without restarting the process.
40
44
  #
45
+ # Refresh failures propagate without replacing any live index.
41
46
  # Host rotation jobs should call this immediately after `rails credentials:edit`
42
47
  # changes are deployed. The swap is atomic on MRI (GVL) — in-flight scans see
43
48
  # either the old or the new index, never a partial one.
@@ -57,14 +62,11 @@ module Woods
57
62
  # the rebuild was skipped.
58
63
  def rebuild_credential_index(rails_app: nil)
59
64
  return nil unless credential_defense_enabled?
60
- return nil unless @active_scanner
61
65
 
62
66
  target_app = rails_app || default_rails_app
63
67
  return nil unless target_app
64
68
 
65
- new_index = CredentialIndex.build(rails_app: target_app)
66
- @active_scanner.replace_index!(new_index)
67
- new_index
69
+ SCANNERS.rebuild { CredentialIndex.refresh(rails_app: target_app) }
68
70
  end
69
71
 
70
72
  # True when Woods is configured and credential defense is on.
@@ -399,21 +401,21 @@ module Woods
399
401
  model_reflections: model_reflections)
400
402
  end
401
403
 
402
- secret_index = build_credential_index(config)
403
- disabled_patterns = Array(config&.console_disabled_scanner_patterns)
404
- scanner = if disabled_patterns.include?(:all)
405
- nil
406
- else
407
- CredentialScanner.new(
408
- disabled_patterns: disabled_patterns,
409
- secret_index: secret_index
410
- )
411
- end
412
- @active_scanner = scanner
404
+ scanner = build_scanner(config)
413
405
 
414
406
  ResponseContext.build(safe_ctx: safe_ctx, table_gate: table_gate, credential_scanner: scanner)
415
407
  end
416
408
 
409
+ def build_scanner(config)
410
+ disabled_patterns = Array(config&.console_disabled_scanner_patterns)
411
+ return nil if disabled_patterns.include?(:all)
412
+
413
+ SCANNERS.register do
414
+ CredentialScanner.new(disabled_patterns: disabled_patterns,
415
+ secret_index: build_credential_index(config))
416
+ end
417
+ end
418
+
417
419
  # Build the boot-time credential index from Rails.application's encrypted
418
420
  # credentials. Returns nil when credential defense is disabled or when no
419
421
  # Rails application is reachable (specs, non-Rails hosts) — the scanner