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.
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative '../util/host_guard'
5
+
6
+ module Woods
7
+ module MCP
8
+ # One immutable policy for the outer Rack guard and the legacy MCP SDK.
9
+ # Keep SDK rebinding protection active and pass original request headers.
10
+ class OriginPolicy
11
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
12
+ DEFAULT_ORIGINS = %w[
13
+ http://localhost http://127.0.0.1 http://[::1]
14
+ https://localhost https://127.0.0.1 https://[::1]
15
+ ].freeze
16
+
17
+ attr_reader :transport_options
18
+
19
+ def initialize(allowed_origins: nil)
20
+ entries = Array(allowed_origins).compact.reject do |entry|
21
+ entry.to_s.valid_encoding? && entry.to_s.strip.empty?
22
+ end
23
+ @explicit = entries.map { |entry| configured_origin(entry) }.uniq.freeze
24
+ @allowed = @explicit.empty? ? DEFAULT_ORIGINS : @explicit
25
+ @hosts = @explicit.map do |origin|
26
+ host = authority(origin)
27
+ (host.end_with?(']') ? host[1...-1] : host).freeze
28
+ end.uniq.freeze
29
+ @transport_options = if @explicit.empty?
30
+ {}.freeze
31
+ else
32
+ { allowed_origins: transport_origins, allowed_hosts: @hosts }.freeze
33
+ end
34
+ freeze
35
+ end
36
+
37
+ def host_allowed?(host)
38
+ return true if host.nil?
39
+ return false unless host.is_a?(String) && host.valid_encoding?
40
+
41
+ parsed = parsed_origin("http://#{host}")
42
+ return false unless parsed
43
+
44
+ hostname = parsed.hostname.downcase
45
+ return false if Util::HostGuard.suspicious_numeric_host?(hostname)
46
+ return true if LOOPBACK_HOSTS.include?(hostname)
47
+
48
+ @hosts.include?(host.downcase) || @hosts.include?(hostname)
49
+ end
50
+
51
+ def origin_allowed?(origin, host:)
52
+ return true if origin.nil?
53
+ return false unless parsed_origin(origin)
54
+
55
+ normalized = normalized_origin(origin)
56
+ return true if @explicit.include?(normalized)
57
+ return false unless @allowed.include?(normalized) || @allowed.include?(normalized.sub(/:\d+\z/, ''))
58
+ return false unless host.is_a?(String) && host.valid_encoding?
59
+
60
+ default_port = normalized.start_with?('https://') ? ':443' : ':80'
61
+ authority(normalized).delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
62
+ end
63
+
64
+ private
65
+
66
+ def configured_origin(entry)
67
+ raw = entry.to_s
68
+ value = raw.strip.downcase.delete_suffix('/') if raw.valid_encoding?
69
+ unless parsed_origin(value)
70
+ label = raw.b.byteslice(0, 160).inspect
71
+ raise ArgumentError, "Invalid MCP allowed origin #{label}: expected http(s)://host[:port] without a path"
72
+ end
73
+
74
+ normalized_origin(value).freeze
75
+ end
76
+
77
+ def normalized_origin(origin)
78
+ value = origin.downcase
79
+ value.delete_suffix(value.start_with?('https://') ? ':443' : ':80')
80
+ end
81
+
82
+ # SDK 0.23 and 0.25 compare explicit entries literally. Both default-port
83
+ # spellings are needed for cross-origin requests; same-authority matching
84
+ # is otherwise performed independently by both layers.
85
+ def transport_origins
86
+ @explicit.flat_map do |origin|
87
+ parsed = parsed_origin(origin)
88
+ port = parsed.scheme == 'https' ? 443 : 80
89
+ parsed.port == port ? [origin, "#{origin}:#{port}"] : [origin]
90
+ end.uniq.map(&:freeze).freeze
91
+ end
92
+
93
+ def authority(origin)
94
+ origin.sub(%r{\Ahttps?://}, '')
95
+ end
96
+
97
+ def parsed_origin(origin)
98
+ return unless origin.is_a?(String) && origin.valid_encoding? && origin.ascii_only?
99
+ return if origin.match?(/[[:space:][:cntrl:]]/)
100
+
101
+ parsed = URI.parse(origin)
102
+ return unless %w[http https].include?(parsed.scheme&.downcase)
103
+ return unless parsed.host && !parsed.host.empty?
104
+ return unless parsed.path.to_s.empty? && !parsed.userinfo && !parsed.query && !parsed.fragment
105
+ return unless parsed.port&.between?(1, 65_535)
106
+
107
+ parsed
108
+ rescue URI::InvalidURIError, ArgumentError
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/woods/railtie.rb CHANGED
@@ -34,7 +34,7 @@ module Woods
34
34
  )
35
35
  end
36
36
 
37
- initializer 'woods.console_mcp' do |app|
37
+ initializer 'woods.console_mcp' do |app| # rubocop:disable Metrics/BlockLength -- validates and scopes the legacy HTTP middleware stack
38
38
  config = Woods.configuration
39
39
  next unless config.console_mcp_enabled
40
40
 
@@ -63,15 +63,22 @@ module Woods
63
63
  next
64
64
  end
65
65
 
66
+ if token.to_s.length < Woods::MCP::BearerAuth::MIN_TOKEN_LENGTH
67
+ raise Woods::ConfigurationError, '[Woods Console] console_mcp_token must be at least 32 characters.'
68
+ end
69
+
66
70
  # Origin guard first — rejects cross-origin POSTs before any auth cost.
67
71
  # BearerAuth next — requires `Authorization: Bearer <token>` on every request.
68
- app.middleware.use(Woods::MCP::OriginGuard, allowed_origins: Array(config.console_mcp_allowed_origins))
69
- app.middleware.use(Woods::MCP::BearerAuth, token: token)
72
+ origin_policy = Woods::MCP::OriginPolicy.new(allowed_origins: config.console_mcp_allowed_origins)
73
+ app.middleware.use(Woods::MCP::OriginGuard, policy: origin_policy, path: config.console_mcp_path)
74
+ app.middleware.use(Woods::MCP::BearerAuth,
75
+ token: -> { Woods.configuration.console_mcp_token }, path: config.console_mcp_path)
70
76
 
71
77
  app.middleware.use(
72
78
  Woods::Console::RackMiddleware,
73
79
  path: config.console_mcp_path,
74
- embedded_read_tools: config.console_embedded_read_tools
80
+ embedded_read_tools: config.console_embedded_read_tools,
81
+ origin_policy: origin_policy
75
82
  )
76
83
  end
77
84
  end
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.6.3'
4
+ VERSION = '1.6.4'
5
5
  end
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: 1.6.3
4
+ version: 1.6.4
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-22 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
@@ -152,6 +152,7 @@ files:
152
152
  - lib/woods/cache/solid_cache_store.rb
153
153
  - lib/woods/chunking/chunk.rb
154
154
  - lib/woods/chunking/semantic_chunker.rb
155
+ - lib/woods/console/adapter_family.rb
155
156
  - lib/woods/console/adapters/cache_adapter.rb
156
157
  - lib/woods/console/adapters/good_job_adapter.rb
157
158
  - lib/woods/console/adapters/job_adapter.rb
@@ -178,6 +179,7 @@ files:
178
179
  - lib/woods/console/scope_predicate_parser.rb
179
180
  - lib/woods/console/server.rb
180
181
  - lib/woods/console/sql_noise_stripper.rb
182
+ - lib/woods/console/sql_output_policy.rb
181
183
  - lib/woods/console/sql_table_scanner.rb
182
184
  - lib/woods/console/sql_validator.rb
183
185
  - lib/woods/console/sqlite_read_guard.rb
@@ -281,6 +283,7 @@ files:
281
283
  - lib/woods/mcp/http_transport_options.rb
282
284
  - lib/woods/mcp/index_reader.rb
283
285
  - lib/woods/mcp/origin_guard.rb
286
+ - lib/woods/mcp/origin_policy.rb
284
287
  - lib/woods/mcp/provider_probe.rb
285
288
  - lib/woods/mcp/renderers/claude_renderer.rb
286
289
  - lib/woods/mcp/renderers/json_renderer.rb
@@ -360,10 +363,10 @@ licenses:
360
363
  - MIT
361
364
  metadata:
362
365
  homepage_uri: https://github.com/lost-in-the/woods
363
- source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.3
364
- changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.3/CHANGELOG.md
366
+ source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.4
367
+ changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.4/CHANGELOG.md
365
368
  bug_tracker_uri: https://github.com/lost-in-the/woods/issues
366
- documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.3/docs
369
+ documentation_uri: https://github.com/lost-in-the/woods/tree/v1.6.4/docs
367
370
  rubygems_mfa_required: 'true'
368
371
  post_install_message:
369
372
  rdoc_options: []