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
|
@@ -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
|
-
|
|
69
|
-
app.middleware.use(Woods::MCP::
|
|
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
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.
|
|
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-
|
|
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.
|
|
364
|
-
changelog_uri: https://github.com/lost-in-the/woods/blob/v1.6.
|
|
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.
|
|
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: []
|