rails-ai-context 5.36.6 → 5.36.7
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 +32 -0
- data/docs/COMPATIBILITY.md +7 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +48 -8
- data/lib/rails_ai_context/database_family.rb +2 -2
- data/lib/rails_ai_context/mcp_edge.rb +13 -0
- data/lib/rails_ai_context/redaction.rb +16 -0
- data/lib/rails_ai_context/server.rb +1 -1
- data/lib/rails_ai_context/sql_gate.rb +6 -3
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8498e25402e035ed53ab2c0dff9dff31da4c849e616632069891de80f38ff7f4
|
|
4
|
+
data.tar.gz: 92dac2339bbe11cc0486ad449f8b84fd5fe840d15c31e56414b53b9b0d7e9fbc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d06dccf666aa9a2ca94e8a42f5191a9d7cf98f2a8f9799f6ab0afa941c6d4e397ecefe5aa71979bb91d6f667151345b161795bd81058a4e4810d5f29828e64eb
|
|
7
|
+
data.tar.gz: 324d72633467fc133a608d848fa3dce1f3f21cc600ab0a9d637288a5ba05f1f69c356aaf3a2871a5217a5c4498787a279695554d8c08ff80af303ba1a8857984
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.36.7] - 2026-10-22
|
|
9
|
+
|
|
10
|
+
### Security
|
|
11
|
+
|
|
12
|
+
- **`rails_query` reads a dollar-quote tag the way PostgreSQL does, and runs
|
|
13
|
+
one statement at the wire.** The gate read a `$tag$` that followed a
|
|
14
|
+
non-ASCII byte, or whose tag held one, as a string boundary where the server
|
|
15
|
+
read it as part of a name, so text the gate believed was one quoted value was
|
|
16
|
+
several statements to the server, and a crafted statement could run a write.
|
|
17
|
+
The tag grammar now matches the server's: a non-ASCII byte counts as a letter
|
|
18
|
+
in a tag and before one. On top of that, the statement and its plan now run
|
|
19
|
+
over the extended protocol (one statement per message), so even a text that
|
|
20
|
+
slipped past the gate cannot carry a second statement to the server. A generic
|
|
21
|
+
plan's unbound parameter, which only that protocol refuses, falls back to a
|
|
22
|
+
plan that runs nothing.
|
|
23
|
+
- **`rails_query` reads `--` on MySQL the way the server does.** MySQL starts a
|
|
24
|
+
`--` comment before any control character, not whitespace alone; the gate
|
|
25
|
+
accepted whitespace only, so a `--` before a control byte hid code from the
|
|
26
|
+
checks that the server still ran, and a secret column could leave under a
|
|
27
|
+
harmless name. The comment rule now ends at a control byte too.
|
|
28
|
+
- **`rails_read_logs` filters a secret column's logged bind values.** Active
|
|
29
|
+
Record filters a bind only when `config.filter_parameters` names it, which
|
|
30
|
+
older apps often leave at `[:password]`, so a token or reset key logged in
|
|
31
|
+
full. A `["name", "value"]` bind pair whose name is a secret now has its
|
|
32
|
+
value filtered, alongside the existing name-and-value rules.
|
|
33
|
+
- **The standalone HTTP server refuses a non-loopback Host of its own.** It
|
|
34
|
+
binds a loopback address and hands the SDK no allowed hosts, so only a
|
|
35
|
+
loopback Host is its own. mcp 1.x refuses the rest itself; the gemspec floor
|
|
36
|
+
is 0.13, and 0.13 to 0.22 check nothing, so a web page the developer opened
|
|
37
|
+
could reach the endpoint by DNS rebinding. The server now makes that check
|
|
38
|
+
across every supported SDK, as `docs/SECURITY.md` says it does.
|
|
39
|
+
|
|
8
40
|
## [5.36.6] - 2026-10-22
|
|
9
41
|
|
|
10
42
|
### Removed
|
data/docs/COMPATIBILITY.md
CHANGED
|
@@ -359,6 +359,13 @@ Proof sources:
|
|
|
359
359
|
them on Mongoid, in the static tier, compared with v5.36.5 and every
|
|
360
360
|
changed answer checked against the app's source; and the unit suite and
|
|
361
361
|
the E2E harness on Linux, macOS and Windows.
|
|
362
|
+
In the v5.36.7 release QA: each fix's example failing on the code before
|
|
363
|
+
it; the `rails_query` gate and its reads run against live PostgreSQL 17,
|
|
364
|
+
MySQL 9.6 and MariaDB 13 for the dollar-quote and `--` comment readings,
|
|
365
|
+
with each server confirmed to read the text the way the gate does; the
|
|
366
|
+
security, gate, redaction and database specs on Rails 7.0 (Ruby 3.1), 7.1,
|
|
367
|
+
7.2 and 8.1 against live PostgreSQL; and the unit suite and the E2E harness
|
|
368
|
+
on Linux, macOS and Windows.
|
|
362
369
|
2. Non-crash coverage for every built-in tool including `get_view` in
|
|
363
370
|
`spec/e2e/in_gemfile_install_spec.rb`'s full-tool sweep; output correctness
|
|
364
371
|
(ivar cross-check, render-form detection, partial interfaces) verified
|
|
@@ -9,14 +9,50 @@ module RailsAiContext
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
def read_only(conn, sql, timeout)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
result = bounded(conn, timeout) { exec_single(conn, sql) }
|
|
13
|
+
Rows.new(result.fields, result.values, false)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# One statement over the extended protocol (exec_params with no binds). The simple
|
|
17
|
+
# protocol conn.execute and select_all use runs every statement in its text, so a text
|
|
18
|
+
# the gate passed in error would run a second one; the extended protocol refuses that at
|
|
19
|
+
# the wire ("cannot insert multiple commands"), whatever the gate saw. The adapter set the
|
|
20
|
+
# raw connection's result type map, so values cast as select_all's do. The bounded block's
|
|
21
|
+
# SET statements have materialized the transaction, so this runs inside it, under its
|
|
22
|
+
# read-only mode and statement_timeout. A driver error is raised as the ActiveRecord class
|
|
23
|
+
# the callers expect, so a timeout still reads as TimedOut.
|
|
24
|
+
#
|
|
25
|
+
# A single statement that leaves a $n unbound can only be an EXPLAIN (GENERIC_PLAN), which
|
|
26
|
+
# the extended protocol will not run but the simple one plans without a value; it runs
|
|
27
|
+
# nothing (no ANALYZE). The extended attempt aborts the transaction, so a savepoint lets
|
|
28
|
+
# the simple protocol plan it after. A text with a second statement fails with "cannot
|
|
29
|
+
# insert multiple commands" before the bind, never the unbound-parameter error, so the
|
|
30
|
+
# simple-protocol fallback can never run one. Only a $ can be an unbound parameter, so a
|
|
31
|
+
# statement without one takes the extended protocol with no savepoint round-trips.
|
|
32
|
+
def exec_single(conn, sql)
|
|
33
|
+
raw = conn.raw_connection
|
|
34
|
+
return raw.exec_params(sql, []) unless sql.include?("$")
|
|
35
|
+
|
|
36
|
+
raw.exec("SAVEPOINT rac_exec_single")
|
|
37
|
+
begin
|
|
38
|
+
result = raw.exec_params(sql, [])
|
|
39
|
+
raw.exec("RELEASE SAVEPOINT rac_exec_single")
|
|
40
|
+
result
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
raw.exec("ROLLBACK TO SAVEPOINT rac_exec_single")
|
|
43
|
+
raise e unless unbound_parameters?(e)
|
|
44
|
+
|
|
45
|
+
conn.execute(sql)
|
|
16
46
|
end
|
|
47
|
+
rescue ActiveRecord::ActiveRecordError
|
|
48
|
+
raise
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
raise conn.send(:translate_exception_class, e, sql, [])
|
|
51
|
+
end
|
|
17
52
|
|
|
18
|
-
|
|
19
|
-
|
|
53
|
+
# The driver's "bind message supplies 0 parameters, but ... requires N" for a lone $n.
|
|
54
|
+
def unbound_parameters?(error)
|
|
55
|
+
(defined?(PG::ProtocolViolation) && error.is_a?(PG::ProtocolViolation)) || error.message.include?("bind message supplies")
|
|
20
56
|
end
|
|
21
57
|
|
|
22
58
|
# statement_timeout ends each statement in the block, a wait on a lock included. Read-only, rolled back.
|
|
@@ -76,8 +112,12 @@ module RailsAiContext
|
|
|
76
112
|
# Planning only: EXPLAIN without ANALYZE runs nothing, not even a VOLATILE function.
|
|
77
113
|
# A timeout and an SQL error are raised, so the caller never runs what it could not plan.
|
|
78
114
|
def plan(conn, sql, timeout, generic: false)
|
|
79
|
-
|
|
80
|
-
|
|
115
|
+
explain = "EXPLAIN (VERBOSE, FORMAT JSON#{", GENERIC_PLAN" if generic}) #{sql}"
|
|
116
|
+
# One statement at the wire while the gate is still checking, so a text it passed in
|
|
117
|
+
# error cannot run a write here. A generic plan's unbound $n falls back to the simple
|
|
118
|
+
# protocol inside exec_single, which plans it without running anything.
|
|
119
|
+
result = bounded(conn, timeout) { exec_single(conn, explain) }
|
|
120
|
+
data = JSON.parse(result.values.first&.first.to_s)
|
|
81
121
|
data.is_a?(Array) && data.first.is_a?(Hash) ? data.first["Plan"] : nil
|
|
82
122
|
rescue JSON::ParserError
|
|
83
123
|
nil
|
|
@@ -71,9 +71,9 @@ module RailsAiContext
|
|
|
71
71
|
mysql?
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
# @return [Regexp] a comment that runs to the end of its line: `#` too, and `--` only before a space, on MySQL
|
|
74
|
+
# @return [Regexp] a comment that runs to the end of its line: `#` too, and `--` only before a space or a control character, on MySQL
|
|
75
75
|
def line_comment
|
|
76
|
-
mysql? ? /--(
|
|
76
|
+
mysql? ? /--(?=[\s\x00-\x1f\x7f]|\z)[^\n]*|#[^\n]*/ : /--[^\n]*/
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
# @return [Rows] run in a read-only transaction, bounded by timeout seconds
|
|
@@ -153,6 +153,19 @@ module RailsAiContext
|
|
|
153
153
|
message && [ 403, { "Content-Type" => "application/json" }, [ error_frame(INVALID_REQUEST, message) ] ]
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
+
# The standalone server's own Host and Origin check. It binds a loopback address and
|
|
157
|
+
# hands the SDK no allowed_hosts, so only a loopback Host is its own. mcp >= 0.23 makes
|
|
158
|
+
# the SDK refuse the rest itself; the gemspec floor is 0.13, and 0.13 to 0.22 check
|
|
159
|
+
# nothing, so a web page the developer opens could reach the endpoint by DNS rebinding.
|
|
160
|
+
# This stands in for the SDK's check across every allowed version.
|
|
161
|
+
def loopback_refusal(env)
|
|
162
|
+
host = env["HTTP_HOST"].to_s.downcase.sub(/:\d+\z/, "").delete_prefix("[").delete_suffix("]")
|
|
163
|
+
message = if !LOOPBACK_HOSTS.include?(host) then "Forbidden: Invalid Host header"
|
|
164
|
+
elsif env["HTTP_ORIGIN"] && !same_origin?(env["HTTP_ORIGIN"], env["HTTP_HOST"]) then "Forbidden: Invalid Origin header"
|
|
165
|
+
end
|
|
166
|
+
message && [ 403, { "Content-Type" => "application/json" }, [ error_frame(INVALID_REQUEST, message) ] ]
|
|
167
|
+
end
|
|
168
|
+
|
|
156
169
|
private
|
|
157
170
|
|
|
158
171
|
def host_allowed?(env, app)
|
|
@@ -175,6 +175,13 @@ module RailsAiContext
|
|
|
175
175
|
(\s*(?:=>|:|=(?!>))\s*)
|
|
176
176
|
(?!["']?\[FILTERED\])(?:"([^"]*)"|'([^']*)'|([^\s,;&)\]}]+))
|
|
177
177
|
/xi
|
|
178
|
+
# A `["name", "value"]` pair in a query's logged binds. Active Record filters a bind only
|
|
179
|
+
# when config.filter_parameters lists its name, which pre-Rails-7 apps often leave at
|
|
180
|
+
# [:password], so a token or reset key logs in full. The name and the quoted value are
|
|
181
|
+
# captured and the value filtered when the name is a secret; a bare value (a number, nil) is
|
|
182
|
+
# left, as is the `$n` placeholder in the SQL before the binds.
|
|
183
|
+
LOG_BIND = /\[\s*"((?:[^"\\]|\\.)*)"\s*,\s*("(?:[^"\\]|\\.)*")\s*\]/
|
|
184
|
+
|
|
178
185
|
# A quoted key may close an index (`ENV["API_TOKEN"] =`); the assignment may be `||=` or another op-assign.
|
|
179
186
|
SECRET_KEY = /"(?<name>#{DASHED_KEY_NAME})"\]?|'(?<name>#{DASHED_KEY_NAME})'\]?|(?<![\w"'\/\\-])(?<name>#{KEY_NAME})/
|
|
180
187
|
KEYED_LITERAL = /(?:#{SECRET_KEY})(?<assign>\s*(?::|=>|(?:\|\||&&|[-+*\/|&])?=(?![=~>]))\s*)(?<literal>#{STRING_LITERAL})/
|
|
@@ -370,6 +377,7 @@ module RailsAiContext
|
|
|
370
377
|
result.gsub!(result.encoding == Encoding::UTF_8 ? EMAIL_PATTERN : ASCII_EMAIL_PATTERN, EMAIL)
|
|
371
378
|
|
|
372
379
|
result.gsub!(LOG_SECRET_ASSIGNMENT) { filter_assignment(Regexp.last_match) }
|
|
380
|
+
result.gsub!(LOG_BIND) { filter_bind(Regexp.last_match) }
|
|
373
381
|
|
|
374
382
|
LOG_PATTERNS.each do |pattern, replacement|
|
|
375
383
|
next result.gsub!(pattern, replacement) unless replacement == :value
|
|
@@ -740,6 +748,14 @@ module RailsAiContext
|
|
|
740
748
|
"#{quote}#{key}#{quote}#{assign}#{value_quote}#{FILTERED}#{value_quote}"
|
|
741
749
|
end
|
|
742
750
|
|
|
751
|
+
# A logged bind pair `["name", "value"]` with its value gone when the name is a secret.
|
|
752
|
+
def filter_bind(match)
|
|
753
|
+
name = key_name(match[1])
|
|
754
|
+
return match[0] unless name && log_secret?(name, match[2])
|
|
755
|
+
|
|
756
|
+
%(["#{match[1]}", "#{FILTERED}"])
|
|
757
|
+
end
|
|
758
|
+
|
|
743
759
|
# The setting name at the head of a matched assignment.
|
|
744
760
|
def descriptor?(match)
|
|
745
761
|
match[/\A\w+/].to_s.match?(DESCRIPTOR_SUFFIX)
|
|
@@ -648,7 +648,7 @@ module RailsAiContext
|
|
|
648
648
|
def build_rack_app(transport)
|
|
649
649
|
lambda do |env|
|
|
650
650
|
if McpEdge.mcp_request?(env)
|
|
651
|
-
McpEdge.serve(env, transport)
|
|
651
|
+
McpEdge.loopback_refusal(env) || McpEdge.serve(env, transport)
|
|
652
652
|
else
|
|
653
653
|
[ 404, { "Content-Type" => "application/json" }, [ '{"error":"Not found"}' ] ]
|
|
654
654
|
end
|
|
@@ -219,8 +219,11 @@ module RailsAiContext
|
|
|
219
219
|
E_QUOTE = /(?<=(?<![\p{Word}$]|[^\x00-\x7F])[eE])'/
|
|
220
220
|
# SQLite reads [name] as an identifier.
|
|
221
221
|
BRACKETED = /\[[^\]\n]*\]/
|
|
222
|
-
|
|
223
|
-
|
|
222
|
+
# PostgreSQL spells a dollar quote's tag as a name, and reads any non-ASCII character as a letter in both:
|
|
223
|
+
# a dollar after one (`→$a$`) is part of that name, and `$→$` is a tag.
|
|
224
|
+
TAG_NAME = /(?:[A-Za-z_]|[^\x00-\x7F])(?:\w|[^\x00-\x7F])*/
|
|
225
|
+
DOLLAR_QUOTED = /(?<![\w$]|[^\x00-\x7F])\$((?:#{TAG_NAME})?)\$.*?\$\1\$/m
|
|
226
|
+
DOLLAR_TAG = /(?<![\w$]|[^\x00-\x7F])\$(?:#{TAG_NAME})?\$/
|
|
224
227
|
UNQUOTED = /[^'"`$\/\-#*\[]+/
|
|
225
228
|
|
|
226
229
|
# A constant in a plan expression. PostgreSQL's deparser always doubles
|
|
@@ -269,7 +272,7 @@ module RailsAiContext
|
|
|
269
272
|
def closed?(tag)
|
|
270
273
|
@last ||= {}.tap do |last|
|
|
271
274
|
tags = StringScanner.new(string)
|
|
272
|
-
last["$#{tags[1]}"] = tags.pos - 1 while tags.skip_until(/\$(?=(
|
|
275
|
+
last["$#{tags[1]}"] = tags.pos - 1 while tags.skip_until(/\$(?=((?:#{TAG_NAME})?\$))/o)
|
|
273
276
|
end
|
|
274
277
|
@last.fetch(tag, -1) >= pos + tag.bytesize
|
|
275
278
|
end
|