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 +4 -4
- data/CHANGELOG.md +36 -0
- data/CONTRIBUTING.md +19 -11
- 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/credential_scanner.rb +2 -1
- data/lib/woods/console/dispatch_pipeline.rb +22 -4
- data/lib/woods/console/embedded_executor.rb +139 -31
- 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 +165 -25
- data/lib/woods/console/sql_output_policy.rb +535 -0
- data/lib/woods/console/sql_table_scanner.rb +163 -52
- data/lib/woods/console/sql_validator.rb +70 -10
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/table_gate.rb +2 -2
- 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 +9 -5
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
|
-
require_relative '
|
|
5
|
+
require_relative 'origin_policy'
|
|
6
6
|
|
|
7
7
|
module Woods
|
|
8
8
|
module MCP
|
|
@@ -21,17 +21,12 @@ 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
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# requires an exact port match. Specify explicit ports when port isolation
|
|
27
|
-
# matters.
|
|
24
|
+
# Cross-origin entries match exact origins, with equivalent default ports.
|
|
25
|
+
# Portless entries also permit same-authority requests on other ports.
|
|
28
26
|
#
|
|
29
27
|
# Also answers CORS preflight (OPTIONS) with the matching allow-list.
|
|
30
28
|
class OriginGuard
|
|
31
|
-
DEFAULT_ALLOWED =
|
|
32
|
-
http://localhost http://127.0.0.1 http://[::1]
|
|
33
|
-
https://localhost https://127.0.0.1 https://[::1]
|
|
34
|
-
].freeze
|
|
29
|
+
DEFAULT_ALLOWED = OriginPolicy::DEFAULT_ORIGINS
|
|
35
30
|
|
|
36
31
|
# Hosts that always pass the Host-header check even without an explicit
|
|
37
32
|
# allow-list entry — they resolve to loopback by definition and cannot
|
|
@@ -39,7 +34,7 @@ module Woods
|
|
|
39
34
|
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1]].freeze
|
|
40
35
|
|
|
41
36
|
ALLOWED_METHODS = 'GET, POST, DELETE, OPTIONS'
|
|
42
|
-
ALLOWED_HEADERS = 'Authorization, Content-Type, Mcp-Session-Id'
|
|
37
|
+
ALLOWED_HEADERS = 'Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id'
|
|
43
38
|
|
|
44
39
|
# Response bodies are emitted as constants so the rejected Origin /
|
|
45
40
|
# Host value is NEVER echoed back to the caller — preventing a
|
|
@@ -48,65 +43,44 @@ module Woods
|
|
|
48
43
|
FORBIDDEN_BODY = { jsonrpc: '2.0', error: { code: -32_002, message: 'Origin not allowed' }, id: nil }.to_json.freeze
|
|
49
44
|
FORBIDDEN_HOST_BODY = { jsonrpc: '2.0', error: { code: -32_002, message: 'Host not allowed' }, id: nil }.to_json.freeze
|
|
50
45
|
|
|
51
|
-
|
|
46
|
+
attr_reader :policy
|
|
47
|
+
|
|
48
|
+
# Rails 6 passes middleware keyword options as a positional Hash on Ruby 3.
|
|
49
|
+
def initialize(app, options = {}, **keywords)
|
|
50
|
+
raise TypeError, 'middleware options must be a Hash' unless options.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
initialize_options(app, **options, **keywords)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize_options(app, allowed_origins: nil, policy: nil, path: nil)
|
|
52
56
|
@app = app
|
|
53
|
-
@
|
|
54
|
-
|
|
55
|
-
@
|
|
57
|
+
@path = path
|
|
58
|
+
origins = allowed_origins.respond_to?(:call) ? allowed_origins.call : allowed_origins
|
|
59
|
+
@policy = policy || OriginPolicy.new(allowed_origins: origins)
|
|
56
60
|
end
|
|
61
|
+
private :initialize_options
|
|
57
62
|
|
|
58
63
|
def call(env)
|
|
64
|
+
return @app.call(env) if @path && !env['PATH_INFO'].to_s.start_with?(@path)
|
|
65
|
+
|
|
59
66
|
origin = env['HTTP_ORIGIN']
|
|
60
67
|
method = env['REQUEST_METHOD']
|
|
61
68
|
host = env['HTTP_HOST']
|
|
62
69
|
|
|
63
|
-
return forbidden
|
|
64
|
-
return forbidden_host
|
|
70
|
+
return forbidden unless policy.origin_allowed?(origin, host: host)
|
|
71
|
+
return forbidden_host unless policy.host_allowed?(host)
|
|
65
72
|
|
|
66
73
|
return preflight(origin) if method == 'OPTIONS'
|
|
67
74
|
|
|
68
75
|
status, headers, body = @app.call(env)
|
|
69
|
-
headers = cors_headers(origin).merge(headers) if origin
|
|
76
|
+
headers = cors_headers(origin).merge(headers) if origin
|
|
70
77
|
[status, headers, body]
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
private
|
|
74
81
|
|
|
75
|
-
def normalize(origin)
|
|
76
|
-
origin.to_s.sub(%r{/\z}, '').downcase
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def extract_host(origin)
|
|
80
|
-
host = origin.to_s.sub(%r{\Ahttps?://}, '').sub(%r{/.*\z}, '').downcase
|
|
81
|
-
host.empty? ? nil : host
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def host_allowed?(host)
|
|
85
|
-
# Canonicalize (strip port, trailing dot, IPv6 brackets) via the
|
|
86
|
-
# shared helper so Qdrant and OriginGuard stay in sync on bypass
|
|
87
|
-
# notations. `normalized` keeps the port for literal allow-list
|
|
88
|
-
# lookups; `bare` drops it for loopback matching.
|
|
89
|
-
normalized = host.to_s.downcase.sub(/\.\z/, '')
|
|
90
|
-
bare = Util::HostGuard.canonicalize(host)
|
|
91
|
-
|
|
92
|
-
# Reject non-canonical numeric hosts. Net::HTTP / getaddrinfo
|
|
93
|
-
# would happily resolve `0x7f000001` or `2130706433` to 127.0.0.1,
|
|
94
|
-
# bypassing the loopback allow-list.
|
|
95
|
-
return false if Util::HostGuard.suspicious_numeric_host?(bare)
|
|
96
|
-
|
|
97
|
-
return true if LOOPBACK_HOSTS.include?(bare)
|
|
98
|
-
|
|
99
|
-
@allowed_hosts.include?(normalized) || @allowed_hosts.include?(bare)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def origin_allowed?(origin)
|
|
103
|
-
return false if origin.match?(/[[:cntrl:]]/)
|
|
104
|
-
|
|
105
|
-
@allowed.include?(normalize(origin)) || @allowed.include?(normalize(origin).sub(/:\d+\z/, ''))
|
|
106
|
-
end
|
|
107
|
-
|
|
108
82
|
def preflight(origin)
|
|
109
|
-
headers = origin
|
|
83
|
+
headers = origin ? cors_headers(origin) : {}
|
|
110
84
|
[204, headers, []]
|
|
111
85
|
end
|
|
112
86
|
|
|
@@ -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,8 +179,10 @@ 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
|
|
185
|
+
- lib/woods/console/sqlite_read_guard.rb
|
|
183
186
|
- lib/woods/console/table_gate.rb
|
|
184
187
|
- lib/woods/console/tool_specs.rb
|
|
185
188
|
- lib/woods/console/tools/tier1.rb
|
|
@@ -280,6 +283,7 @@ files:
|
|
|
280
283
|
- lib/woods/mcp/http_transport_options.rb
|
|
281
284
|
- lib/woods/mcp/index_reader.rb
|
|
282
285
|
- lib/woods/mcp/origin_guard.rb
|
|
286
|
+
- lib/woods/mcp/origin_policy.rb
|
|
283
287
|
- lib/woods/mcp/provider_probe.rb
|
|
284
288
|
- lib/woods/mcp/renderers/claude_renderer.rb
|
|
285
289
|
- lib/woods/mcp/renderers/json_renderer.rb
|
|
@@ -359,10 +363,10 @@ licenses:
|
|
|
359
363
|
- MIT
|
|
360
364
|
metadata:
|
|
361
365
|
homepage_uri: https://github.com/lost-in-the/woods
|
|
362
|
-
source_code_uri: https://github.com/lost-in-the/woods/tree/v1.6.
|
|
363
|
-
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
|
|
364
368
|
bug_tracker_uri: https://github.com/lost-in-the/woods/issues
|
|
365
|
-
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
|
|
366
370
|
rubygems_mfa_required: 'true'
|
|
367
371
|
post_install_message:
|
|
368
372
|
rdoc_options: []
|