woods 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
 
5
- require_relative '../util/host_guard'
5
+ require_relative 'origin_policy'
6
6
 
7
7
  module Woods
8
8
  module MCP
@@ -21,17 +21,13 @@ module Woods
21
21
  # also requiring Host to appear in the allow-list (or to be a loopback
22
22
  # address), we close that gap even when Rails is bound to 0.0.0.0.
23
23
  #
24
- # Port-matching: an allow-list entry WITHOUT a port (`http://localhost`)
25
- # matches that host on any port. An entry WITH a port (`http://localhost:3000`)
26
- # requires an exact port match. Specify explicit ports when port isolation
27
- # matters.
24
+ # Cross-origin entries match exactly. A portless entry also permits
25
+ # same-authority requests on other ports; cross-port browser clients need
26
+ # their actual origin explicitly configured, as required by the SDK.
28
27
  #
29
28
  # Also answers CORS preflight (OPTIONS) with the matching allow-list.
30
29
  class OriginGuard
31
- DEFAULT_ALLOWED = %w[
32
- http://localhost http://127.0.0.1 http://[::1]
33
- https://localhost https://127.0.0.1 https://[::1]
34
- ].freeze
30
+ DEFAULT_ALLOWED = OriginPolicy::DEFAULT_ORIGINS
35
31
 
36
32
  # Hosts that always pass the Host-header check even without an explicit
37
33
  # allow-list entry — they resolve to loopback by definition and cannot
@@ -56,6 +52,7 @@ module Woods
56
52
  # which runs after Rails railtie initializers captured the middleware
57
53
  # arguments — still takes effect (#183). Empty/nil falls back to
58
54
  # {DEFAULT_ALLOWED}.
55
+ # @param policy [OriginPolicy, nil] Captured policy also passed to the SDK
59
56
  # @param path [String, nil] When set, only requests whose PATH_INFO
60
57
  # starts with this prefix are guarded — everything else passes
61
58
  # straight through to the app. Nil (the default) guards every request.
@@ -82,27 +79,35 @@ module Woods
82
79
  method = env['REQUEST_METHOD']
83
80
  host = env['HTTP_HOST']
84
81
 
85
- return forbidden if origin && !origin_allowed?(origin)
86
- return forbidden_host if host && !host_allowed?(host)
82
+ return forbidden unless policy.origin_allowed?(origin, host: host)
83
+ return forbidden_host unless policy.host_allowed?(host)
87
84
 
88
85
  return preflight(origin) if method == 'OPTIONS'
89
86
 
90
87
  status, headers, body = @app.call(env)
91
- headers = cors_headers(origin).merge(headers) if origin && origin_allowed?(origin)
88
+ headers = cors_headers(origin).merge(headers) if origin
92
89
  [status, headers, body]
93
90
  end
94
91
 
92
+ # The same lazily captured immutable policy is passed to the transport.
93
+ # @return [OriginPolicy]
94
+ def policy
95
+ return @policy if @policy
96
+
97
+ @policy_mutex.synchronize do
98
+ @policy ||= OriginPolicy.new(allowed_origins: @allowed_source.call)
99
+ end
100
+ end
101
+
95
102
  private
96
103
 
97
- def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil)
104
+ def initialize_options(app, allowed_origins: nil, path: nil, enabled: nil, policy: nil)
98
105
  @app = app
99
106
  @path = path
100
107
  @enabled = enabled
101
- if allowed_origins.respond_to?(:call)
102
- @allowed_source = allowed_origins
103
- else
104
- build_allow_list(allowed_origins)
105
- end
108
+ @policy = policy
109
+ @policy_mutex = Mutex.new
110
+ @allowed_source = allowed_origins.respond_to?(:call) ? allowed_origins : -> { allowed_origins }
106
111
  end
107
112
 
108
113
  # @param env [Hash] Rack environment
@@ -114,66 +119,8 @@ module Woods
114
119
  true
115
120
  end
116
121
 
117
- # Normalize a raw allow-list into `@allowed` + `@allowed_hosts`.
118
- #
119
- # @param origins [Array<String>, nil]
120
- # @return [Array<String>] the normalized allow-list
121
- def build_allow_list(origins)
122
- allowed = Array(origins).compact.reject { |o| o.to_s.strip.empty? }.map { |o| normalize(o) }
123
- allowed = DEFAULT_ALLOWED.dup if allowed.empty?
124
- @allowed_hosts = allowed.map { |o| extract_host(o) }.compact.uniq
125
- @allowed = allowed
126
- end
127
-
128
- # The allow-list, resolving a deferred source on first use. Memoized —
129
- # configuration is settled by the time the first request arrives.
130
- #
131
- # @return [Array<String>]
132
- def allowed
133
- @allowed || build_allow_list(@allowed_source.call)
134
- end
135
-
136
- # @return [Array<String>] hosts extracted from the allow-list
137
- def allowed_hosts
138
- allowed
139
- @allowed_hosts
140
- end
141
-
142
- def normalize(origin)
143
- origin.to_s.sub(%r{/\z}, '').downcase
144
- end
145
-
146
- def extract_host(origin)
147
- host = origin.to_s.sub(%r{\Ahttps?://}, '').sub(%r{/.*\z}, '').downcase
148
- host.empty? ? nil : host
149
- end
150
-
151
- def host_allowed?(host)
152
- # Canonicalize (strip port, trailing dot, IPv6 brackets) via the
153
- # shared helper so Qdrant and OriginGuard stay in sync on bypass
154
- # notations. `normalized` keeps the port for literal allow-list
155
- # lookups; `bare` drops it for loopback matching.
156
- normalized = host.to_s.downcase.sub(/\.\z/, '')
157
- bare = Util::HostGuard.canonicalize(host)
158
-
159
- # Reject non-canonical numeric hosts. Net::HTTP / getaddrinfo
160
- # would happily resolve `0x7f000001` or `2130706433` to 127.0.0.1,
161
- # bypassing the loopback allow-list.
162
- return false if Util::HostGuard.suspicious_numeric_host?(bare)
163
-
164
- return true if LOOPBACK_HOSTS.include?(bare)
165
-
166
- allowed_hosts.include?(normalized) || allowed_hosts.include?(bare)
167
- end
168
-
169
- def origin_allowed?(origin)
170
- return false if origin.match?(/[[:cntrl:]]/)
171
-
172
- allowed.include?(normalize(origin)) || allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
173
- end
174
-
175
122
  def preflight(origin)
176
- headers = origin && origin_allowed?(origin) ? cors_headers(origin) : {}
123
+ headers = origin ? cors_headers(origin) : {}
177
124
  [204, headers, []]
178
125
  end
179
126
 
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../util/host_guard'
5
+
6
+ module Woods
7
+ module MCP
8
+ # Immutable HTTP policy shared by Woods preflight and the MCP transport.
9
+ # Explicit cross-origin entries normalize HTTP(S) default ports. A portless entry also permits
10
+ # same-authority requests on another port, which the SDK accepts natively.
11
+ # No request header is rewritten and SDK rebinding protection stays enabled.
12
+ class OriginPolicy
13
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
14
+ DEFAULT_ORIGINS = %w[
15
+ http://localhost http://127.0.0.1 http://[::1]
16
+ https://localhost https://127.0.0.1 https://[::1]
17
+ ].freeze
18
+
19
+ # @param allowed_origins [Array<String>, nil] Explicit browser origins
20
+ def initialize(allowed_origins: nil)
21
+ entries = Array(allowed_origins).compact.reject do |entry|
22
+ entry.to_s.valid_encoding? && entry.to_s.strip.empty?
23
+ end
24
+ @explicit_origins = entries.map { |entry| configured_origin(entry) }.uniq.freeze
25
+ @allowed = (@explicit_origins.empty? ? DEFAULT_ORIGINS : @explicit_origins).freeze
26
+ @allowed_hosts = @explicit_origins.map do |origin|
27
+ host = authority(origin)
28
+ # SDK bare IPv6 host entries omit brackets; host:port entries retain
29
+ # them. URI#host alone retains brackets and loses explicit ports.
30
+ (host.end_with?(']') ? host[1...-1] : host).freeze
31
+ end.uniq.freeze
32
+ @transport_options = { allowed_origins: transport_origins, allowed_hosts: @allowed_hosts }.freeze
33
+ freeze
34
+ end
35
+
36
+ # @return [Hash] Constructor options supported by the MCP SDK
37
+ attr_reader :transport_options
38
+
39
+ # @param host [String, nil] Unmodified HTTP Host header
40
+ # @return [Boolean] Whether the request authority is allowed
41
+ def host_allowed?(host)
42
+ return true if host.nil?
43
+ return false unless host.is_a?(String) && host.valid_encoding?
44
+
45
+ parsed = parsed_origin("http://#{host}")
46
+ return false unless parsed
47
+
48
+ hostname = parsed.hostname.downcase
49
+ return false if Util::HostGuard.suspicious_numeric_host?(hostname)
50
+ return true if LOOPBACK_HOSTS.include?(hostname)
51
+
52
+ @allowed_hosts.include?(host.downcase) || @allowed_hosts.include?(hostname)
53
+ end
54
+
55
+ # @param origin [String, nil] Unmodified HTTP Origin header
56
+ # @param host [String, nil] Unmodified HTTP Host header
57
+ # @return [Boolean] Whether preflight and SDK dispatch can both accept it
58
+ def origin_allowed?(origin, host:)
59
+ return true if origin.nil?
60
+ return false unless parsed_origin(origin)
61
+
62
+ normalized = normalized_origin(origin)
63
+ return true if @explicit_origins.include?(normalized)
64
+ return false unless @allowed.include?(normalized) || @allowed.include?(normalized.sub(/:\d+\z/, ''))
65
+ return false unless host.is_a?(String) && host.valid_encoding?
66
+
67
+ # Matches the SDK's same-authority rule. The origin's scheme selects
68
+ # its default port; request.scheme is unreliable behind reverse proxies.
69
+ default_port = normalized.start_with?('https://') ? ':443' : ':80'
70
+ authority(normalized).delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
71
+ end
72
+
73
+ private
74
+
75
+ def configured_origin(entry)
76
+ raw = entry.to_s
77
+ normalized = raw.downcase.sub(%r{/\z}, '') if raw.valid_encoding?
78
+ unless parsed_origin(normalized)
79
+ label = raw.b.byteslice(0, 160).inspect
80
+ raise ArgumentError, "Invalid MCP allowed origin #{label}: expected http(s)://host[:port] without a path"
81
+ end
82
+
83
+ normalized_origin(normalized).freeze
84
+ end
85
+
86
+ def normalized_origin(origin)
87
+ value = origin.downcase
88
+ value.delete_suffix(value.start_with?('https://') ? ':443' : ':80')
89
+ end
90
+
91
+ # The SDK compares configured origins literally. Include equivalent
92
+ # default-port spellings without rewriting the incoming request headers.
93
+ def transport_origins
94
+ @explicit_origins.flat_map do |origin|
95
+ parsed = parsed_origin(origin)
96
+ default = parsed.scheme == 'https' ? 443 : 80
97
+ parsed.port == default ? [origin, "#{origin}:#{default}"] : [origin]
98
+ end.uniq.map(&:freeze).freeze
99
+ end
100
+
101
+ def authority(origin)
102
+ origin.sub(%r{\Ahttps?://}, '')
103
+ end
104
+
105
+ # Parse only serialized HTTP origins, never URLs with paths, userinfo,
106
+ # query strings, fragments or whitespace. The comparison layer handles
107
+ # default-port equivalence separately from parsing.
108
+ def parsed_origin(origin)
109
+ return unless origin.is_a?(String) && origin.valid_encoding? && origin.ascii_only?
110
+ return if origin.match?(/[[:space:][:cntrl:]]/)
111
+
112
+ parsed = URI.parse(origin)
113
+ return unless %w[http https].include?(parsed.scheme&.downcase)
114
+ return unless parsed.host && !parsed.host.empty?
115
+ return unless parsed.path.to_s.empty? && !parsed.userinfo && !parsed.query && !parsed.fragment
116
+ return unless parsed.port&.between?(1, 65_535)
117
+
118
+ parsed
119
+ rescue URI::InvalidURIError, ArgumentError
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -114,6 +114,7 @@ module Woods
114
114
  warn_late_console_path(config)
115
115
  return unless config.console_mcp_enabled && config.console_mcp_http_enabled
116
116
 
117
+ verify_console_origins!
117
118
  require 'woods/mcp/bearer_auth'
118
119
  token = config.console_mcp_token.to_s
119
120
  if token.empty?
@@ -147,6 +148,13 @@ module Woods
147
148
 
148
149
  private
149
150
 
151
+ def verify_console_origins!
152
+ require 'woods/mcp/origin_policy'
153
+ Woods::MCP::OriginPolicy.new(allowed_origins: config.console_mcp_allowed_origins)
154
+ rescue ArgumentError => e
155
+ raise Woods::ConfigurationError, "[Woods Console] #{e.message}"
156
+ end
157
+
150
158
  # Warn when the configured console path no longer matches the path the
151
159
  # stack was mounted at — the path was set after railtie initializers
152
160
  # captured the middleware arguments.
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '2.0.0'
4
+ VERSION = '2.0.1'
5
5
  end
@@ -460,3 +460,14 @@ the named file before moving it aside, or choose a new export directory. Older v
460
460
  byte-identical generated assets into `_woods/ownership.json`; changed legacy sidecars may need this
461
461
  manual recovery. Never fabricate ownership receipts or remove personal files to silence the error.
462
462
  See the installed version's `docs/OBSIDIAN_INTEGRATION.md` for the exact safety contract.
463
+
464
+ ### Console read compatibility on security-patch candidates
465
+
466
+ Verify the loaded revision for corrections after 2.0.0. Active redaction refuses
467
+ SQL relation/CTE column alias lists; typed EAV matching can intentionally mask
468
+ extra values where tables share a final name or differ only by case. Keep the
469
+ policy enabled and use explicit scalar columns or structured tools. Check exact
470
+ sensitive-key spelling and configure binary secret columns for column redaction.
471
+ Read the [Console compatibility guide](https://github.com/lost-in-the/woods/blob/main/docs/CONSOLE_MCP_SETUP.md#read-policy-compatibility)
472
+ at the installed revision for adapter, timeout and projection limits; a plugin
473
+ update does not patch Woods.
@@ -214,3 +214,18 @@ returned typed, SHA-guarded `full_evidence` lookup for verification. Published-u
214
214
  coordinates are not physical file offsets; unknown generation remains unknown.
215
215
  Keep full-source access available. See the canonical
216
216
  [evidence contract](https://github.com/lost-in-the/woods/blob/main/docs/RETRIEVAL_GUIDE.md#compact-published-evidence-and-api-outlines).
217
+
218
+ ### HTTP configuration diagnostics on security-patch candidates
219
+
220
+ Check the installed revision before expecting this unreleased correction. Invalid
221
+ origin settings, including invalidly encoded entries, refuse at boot. The HTTP
222
+ executable reports one configuration diagnostic and exits 2. Correct the named
223
+ entry and restart; keep authentication and origin checks enabled. Follow the
224
+ installed revision's canonical `docs/MCP_HTTP_TRANSPORT.md` for accepted origins.
225
+
226
+ Use whitespace-free literal origin entries; explicit lists replace browser
227
+ defaults, and wildcard patterns are unsupported. Preserve the real Host behind
228
+ proxies. Verify both preflight and bearer-authenticated dispatch against the
229
+ [HTTP compatibility guide](https://github.com/lost-in-the/woods/blob/main/docs/MCP_HTTP_TRANSPORT.md#origin-configuration-compatibility)
230
+ at the installed revision; configured non-loopback Hosts are passed to the SDK
231
+ in supporting patches.
@@ -255,3 +255,11 @@ dependents and suggested tests manually; silence is not no impact. Do not clear
255
255
  refresh queues when optional hints time out. See the canonical
256
256
  [context guide](https://github.com/lost-in-the/woods/blob/main/docs/WATCH_DAEMON.md#optional-bounded-context-hints)
257
257
  for output/time limits, container root mapping and emitted-hint suppression.
258
+
259
+ ### Security-patch compatibility preflight
260
+
261
+ For security-patch candidates after 2.0.0, record the loaded revision and gem
262
+ path as well as the version. Review the [Console compatibility guide](https://github.com/lost-in-the/woods/blob/main/docs/CONSOLE_MCP_SETUP.md#read-policy-compatibility)
263
+ and HTTP origin compatibility at that revision before upgrading an authorized
264
+ Console installation. The plugin does not install these fixes or establish that
265
+ a release is published.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: woods
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Armstrong
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-23 00:00:00.000000000 Z
11
+ date: 2026-09-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mcp
@@ -216,6 +216,7 @@ files:
216
216
  - lib/woods/checks/moved_messages.rb
217
217
  - lib/woods/chunking/chunk.rb
218
218
  - lib/woods/chunking/semantic_chunker.rb
219
+ - lib/woods/console/adapter_family.rb
219
220
  - lib/woods/console/audit_logger.rb
220
221
  - lib/woods/console/bridge_protocol.rb
221
222
  - lib/woods/console/confirmation.rb
@@ -376,6 +377,7 @@ files:
376
377
  - lib/woods/mcp/index_reader_pinning.rb
377
378
  - lib/woods/mcp/initialization_guidance.rb
378
379
  - lib/woods/mcp/origin_guard.rb
380
+ - lib/woods/mcp/origin_policy.rb
379
381
  - lib/woods/mcp/protocol_policy.rb
380
382
  - lib/woods/mcp/provider_probe.rb
381
383
  - lib/woods/mcp/published_lexical_retriever.rb
@@ -547,10 +549,10 @@ licenses:
547
549
  - MIT
548
550
  metadata:
549
551
  homepage_uri: https://github.com/lost-in-the/woods
550
- source_code_uri: https://github.com/lost-in-the/woods/tree/v2.0.0
551
- changelog_uri: https://github.com/lost-in-the/woods/blob/v2.0.0/CHANGELOG.md
552
+ source_code_uri: https://github.com/lost-in-the/woods/tree/v2.0.1
553
+ changelog_uri: https://github.com/lost-in-the/woods/blob/v2.0.1/CHANGELOG.md
552
554
  bug_tracker_uri: https://github.com/lost-in-the/woods/issues
553
- documentation_uri: https://github.com/lost-in-the/woods/tree/v2.0.0/docs
555
+ documentation_uri: https://github.com/lost-in-the/woods/tree/v2.0.1/docs
554
556
  rubygems_mfa_required: 'true'
555
557
  post_install_message:
556
558
  rdoc_options: []