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 +4 -4
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +54 -0
- data/README.md +6 -0
- data/exe/woods-mcp-http +5 -3
- data/lib/woods/console/credential_index.rb +20 -2
- data/lib/woods/console/credential_scanner.rb +16 -15
- data/lib/woods/console/credential_scanner_registry.rb +36 -0
- data/lib/woods/console/dispatch_pipeline.rb +22 -4
- data/lib/woods/console/embedded_executor.rb +34 -11
- data/lib/woods/console/encrypted_credential_snapshot.rb +16 -0
- data/lib/woods/console/rack_middleware.rb +4 -1
- data/lib/woods/console/server.rb +18 -16
- data/lib/woods/console/sql_noise_stripper.rb +35 -7
- data/lib/woods/console/sql_table_scanner.rb +49 -9
- data/lib/woods/console/sql_validator.rb +13 -8
- data/lib/woods/console/sqlite_read_guard.rb +46 -0
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +1 -2
- data/lib/woods/console/tools/tier1.rb +1 -2
- data/lib/woods/console/tools/tier4.rb +1 -2
- data/lib/woods/mcp/http_transport_options.rb +24 -0
- data/lib/woods/mcp/server.rb +2 -9
- data/lib/woods/mcp/version_aware_tool_dispatch.rb +9 -3
- data/lib/woods/retrieval/query_classifier.rb +1 -2
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/session_flow_assembler.rb +3 -6
- data/lib/woods/session_tracer/session_flow_document.rb +4 -8
- data/lib/woods/version.rb +1 -1
- metadata +43 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b77f03a9d89a92c666a6ef9b8b20ed8583270fa0cfbb923acea9d6fbc973f9d2
|
|
4
|
+
data.tar.gz: 8336dccba436603daadaccd125888bc40702f16c4f514002bff298c9d8905f9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
|
168
|
-
when
|
|
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
|
|
294
|
+
def redact_indexed_secrets(str, counts, index)
|
|
295
|
+
return str unless index&.match?(str)
|
|
295
296
|
|
|
296
|
-
redacted =
|
|
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
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
476
|
+
scope.find_by(id: params['id'])
|
|
474
477
|
elsif params['by']
|
|
475
|
-
|
|
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(
|
|
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
|
data/lib/woods/console/server.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|