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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 767bea69448c4f36b6cd9158d4cdb7c5a9ea975e50b534c36bd0a191e2f78284
4
- data.tar.gz: 0d2e08650d551de767a8079e9b301445b24199dbe9d7d4bcecc6d24edf17f582
3
+ metadata.gz: 8498e25402e035ed53ab2c0dff9dff31da4c849e616632069891de80f38ff7f4
4
+ data.tar.gz: 92dac2339bbe11cc0486ad449f8b84fd5fe840d15c31e56414b53b9b0d7e9fbc
5
5
  SHA512:
6
- metadata.gz: 81f02598eafb2253634bc51b7d050276944f829ccde5e6d2464b3a845648c1778ce9a642ed6c7a513061f5471fee883671aa0dc6b8039e00b67dafac3c978a1a
7
- data.tar.gz: ee8235d4b5cb68ac2e553ba4417a4fe67e55de9422cb553b4827f57ad58d1c74fd4d6e2a83058440ba149c646992f90d594965d092c435970db66f1984e8017c
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
@@ -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
- # An EXPLAIN answers text, and one of a generic plan holds $1, which Rails 7.0's select_all sends as an empty bind.
13
- if sql.match?(/\A\s*EXPLAIN\b/i)
14
- result = bounded(conn, timeout) { conn.execute(sql) }
15
- return Rows.new(result.fields, result.values, false)
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
- result = bounded(conn, timeout) { conn.select_all(sql) }
19
- Rows.new(result.columns, result.rows, false)
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
- json = read_only(conn, "EXPLAIN (VERBOSE, FORMAT JSON#{", GENERIC_PLAN" if generic}) #{sql}", timeout).rows.first&.first
80
- data = JSON.parse(json.to_s)
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? ? /--(?=\s|\z)[^\n]*|#[^\n]*/ : /--[^\n]*/
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
- DOLLAR_QUOTED = /(?<![\p{Word}$])\$(\p{Word}*)\$.*?\$\1\$/m
223
- DOLLAR_TAG = /(?<![\p{Word}$])\$\p{Word}*\$/
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(/\$(?=(\p{Word}*\$))/)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.36.6"
4
+ VERSION = "5.36.7"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.36.6
4
+ version: 5.36.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine