rails-ai-context 5.35.0 → 5.35.2

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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. metadata +14 -3
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "strscan"
4
-
5
3
  module RailsAiContext
6
4
  module Tools
7
5
  class Query < BaseTool
@@ -30,13 +28,17 @@ module RailsAiContext
30
28
  explain: {
31
29
  type: "boolean",
32
30
  description: "Run EXPLAIN on the query. Returns execution plan analysis instead of data. SELECT only."
31
+ },
32
+ database: {
33
+ type: "string",
34
+ description: "The app database to query, by its config/database.yml name (for example queue). Default: the primary."
33
35
  }
34
36
  },
35
37
  required: [ "sql" ]
36
38
  )
37
39
 
38
40
  guide_row(
39
- order: 32,
41
+ order: 27,
40
42
  mcp: "rails_query(sql:\"X\")",
41
43
  cli_args: "sql=X",
42
44
  summary: "Safe read-only SQL queries with timeout, row limit, column redaction"
@@ -49,189 +51,10 @@ module RailsAiContext
49
51
  open_world_hint: false
50
52
  )
51
53
 
52
- # ── Layer 1: SQL validation ─────────────────────────────────────
53
- # REPLACE is the MySQL statement; `replace(` is the string function every
54
- # database has, so a REPLACE followed by an opening parenthesis is let be.
55
- BLOCKED_KEYWORDS = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|SET|COPY|MERGE|REPLACE(?!\s*\())\b/i
56
- # SHOW CREATE TABLE / VIEW print a definition; every other SHOW CREATE is
57
- # refused by the CREATE keyword - SHOW CREATE USER prints credential hashes.
58
- SHOW_CREATE_DEFINITION = /\A\s*SHOW\s+CREATE\s+(?:TABLE|VIEW)\b/i
59
- BLOCKED_CLAUSES = /\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE)\b/i
60
- BLOCKED_SHOWS = /\bSHOW\s+(GRANTS|PROCESSLIST|BINLOG|SLAVE|MASTER|REPLICAS)\b/i
61
- SELECT_INTO = /\bSELECT\b[^;]*\bINTO\b/i
62
- MULTI_STATEMENT = /;\s*\S/
63
- ALLOWED_PREFIX = /\A\s*(SELECT|WITH|SHOW|EXPLAIN|DESCRIBE|DESC)\b/i
64
-
65
- # SELECT-callable functions that give the caller a filesystem / network
66
- # exfiltration primitive even though the query is technically a SELECT.
67
- # These pass `SET TRANSACTION READ ONLY` because they're reads from the
68
- # DB engine's perspective, but they bypass the gem's `sensitive_patterns`
69
- # file allowlist entirely by pivoting through the database process.
70
- #
71
- # Postgres: pg_read_file / pg_read_binary_file / pg_ls_dir / pg_stat_file
72
- # (file read), lo_import / lo_export (large-object I/O),
73
- # dblink* (cross-db exfiltration).
74
- # MySQL: LOAD_FILE (scalar file read outside SELECT INTO contexts).
75
- # SQLite: load_extension (shared-library load - disabled by default
76
- # but harden in defense).
77
- #
78
- # COPY ... TO PROGRAM is not one of these: COPY is not a SELECT, so the
79
- # statement keyword blocker rejects it before this runs.
80
- BLOCKED_FUNCTIONS = /\b(
81
- pg_read_binary_file | pg_read_file |
82
- pg_ls_dir | pg_ls_logdir | pg_ls_tmpdir | pg_ls_waldir | pg_ls_archive_statusdir |
83
- pg_stat_file | pg_file_settings | pg_current_logfile |
84
- lo_import | lo_export |
85
- dblink[a-z_]* |
86
- LOAD\s+DATA |
87
- load_file | load_extension
88
- )\b/ix
89
-
90
- # Checked before SELECT_INTO, which also matches, so the refusal names the disk write.
91
- BLOCKED_OUTPUT = /\bINTO\s+(OUTFILE|DUMPFILE)\b/i
92
-
93
- # Session-effecting / administrative PostgreSQL functions. A query is a
94
- # read from the engine's point of view, so SET TRANSACTION READ ONLY lets
95
- # these run and keep their effect on the pooled connection: a backend is
96
- # terminated, a config rotated, a stat reset, a WAL/restore/replication
97
- # control fired, a logical message emitted, a session advisory lock left
98
- # held. This is the FIRST layer, a fast name blocklist; the PostgreSQL
99
- # plan's VOLATILE-function check (pg_proc.provolatile) is the robust layer
100
- # that closes the rest of the family without an ever-growing list.
101
- BLOCKED_PG_ADMIN = /\b(
102
- pg_terminate_backend | pg_cancel_backend |
103
- pg_reload_conf | pg_rotate_logfile[a-z_]* |
104
- pg_stat_reset[a-z_]* |
105
- pg_switch_wal | pg_switch_xlog | pg_create_restore_point |
106
- pg_create_(?:physical|logical)_replication_slot | pg_drop_replication_slot |
107
- pg_replication_origin_[a-z_]* | pg_logical_emit_message |
108
- pg_advisory_lock[a-z_]* | pg_advisory_unlock[a-z_]* |
109
- pg_advisory_xact_lock[a-z_]* | pg_try_advisory_lock[a-z_]* |
110
- pg_promote | pg_wal_replay_pause | pg_wal_replay_resume |
111
- pg_xlog_replay_pause | pg_xlog_replay_resume |
112
- pg_notify | set_config | txid_current[a-z_]* | pg_current_xact_id[a-z_]* |
113
- pg_import_system_collations
114
- )\s*\(/ix
115
-
116
- # Session-effecting MySQL/MariaDB functions. GET_LOCK leaves a named lock
117
- # held on the pooled connection after the query returns, which a later
118
- # caller inherits; RELEASE_* and the IS_* probes drive the same session
119
- # lock table. source/master_pos_wait block on replication. MySQL has no
120
- # cheap volatility catalogue like pg_proc, so these are a name blocklist.
121
- BLOCKED_MYSQL_SESSION = /\b(
122
- get_lock | release_lock | release_all_locks | is_free_lock | is_used_lock |
123
- master_pos_wait | source_pos_wait
124
- )\s*\(/ix
125
-
126
- # VOLATILE functions known to only read. pg_proc marks a function VOLATILE
127
- # when its answer may change within one statement, which is as true of a
128
- # reader of live server state as of a writer, so VOLATILE alone does not
129
- # mean "changes state". Each one here is a built-in C function that writes
130
- # nothing (every one is also proparallel 's', parallel safe):
131
- # * values: random, clock_timestamp, gen_random_uuid, timeofday,
132
- # uuid_generate_v4; pg_sleep* only waits, and the statement timeout
133
- # bounds it.
134
- # * sizes (dbsize.c): stat() the relation, database or tablespace files;
135
- # the most they take is a transaction-scoped AccessShareLock, released
136
- # when the read-only transaction rolls back.
137
- # * lock status (lockfuncs.c): a snapshot of the shared lock table -
138
- # pg_lock_status is what the pg_locks view reads.
139
- # * recovery and WAL position (xlogfuncs.c): read shared-memory
140
- # positions and flags; pg_current_wal_* only reports where WAL is.
141
- # pg_stat_activity is not here: pg_stat_get_activity is STABLE, so it was
142
- # never refused. Everything else the plan reports as VOLATILE is refused.
143
- PG_VOLATILE_ALLOWLIST = %w[
144
- random clock_timestamp gen_random_uuid timeofday uuid_generate_v4
145
- pg_sleep pg_sleep_for pg_sleep_until
146
- pg_total_relation_size pg_relation_size pg_table_size pg_indexes_size
147
- pg_database_size pg_tablespace_size
148
- pg_lock_status pg_blocking_pids pg_safe_snapshot_blocking_pids
149
- pg_is_in_recovery pg_is_wal_replay_paused pg_get_wal_replay_pause_state
150
- pg_current_wal_lsn pg_current_wal_insert_lsn pg_current_wal_flush_lsn
151
- pg_last_wal_receive_lsn pg_last_wal_replay_lsn pg_last_xact_replay_timestamp
152
- ].freeze
153
-
154
- # A column-alias list renames the columns a wildcard produced, so the
155
- # names redaction reads no longer name the sensitive columns underneath.
156
- # These refuse one on a CTE or a FROM item (derived table or table alias).
157
- # The list is BARE identifiers only: a type modifier (numeric(10,2),
158
- # varchar(255)) carries digits, and a record-returning function's list
159
- # (json_to_recordset(...) AS t(a int, b text)) carries types, so neither
160
- # matches; a single-column alias (generate_series(...) AS g(n)) is not a
161
- # rename of several columns and is left alone. A list on a VALUES body
162
- # (`(VALUES (1,'a')) AS v(id, name)`, `WITH v(id, name) AS (VALUES ...)`)
163
- # renames no table column - VALUES has none - so it is left alone too;
164
- # see renaming_alias_list?.
165
- ALIAS_IDENT = /[A-Za-z_]\w*/
166
- ALIAS_IDENT_LIST = /#{ALIAS_IDENT}(?:\s*,\s*#{ALIAS_IDENT})+/
167
- # CTE column list: name(a, b, ...) AS (
168
- CTE_ALIAS_LIST = /#{ALIAS_IDENT}\s*\(\s*#{ALIAS_IDENT_LIST}\s*\)\s*AS\s*\(/i
169
- # Derived-table / explicit alias: ... AS alias(a, b, ...)
170
- DERIVED_ALIAS_LIST = /\bAS\s+#{ALIAS_IDENT}\s*\(\s*#{ALIAS_IDENT_LIST}\s*\)/i
171
- # Table alias without AS: FROM/JOIN table alias(a, b, ...)
172
- TABLE_ALIAS_LIST = /\b(?:FROM|JOIN)\s+#{ALIAS_IDENT}(?:\.#{ALIAS_IDENT})?\s+#{ALIAS_IDENT}\s*\(\s*#{ALIAS_IDENT_LIST}\s*\)/i
173
- COLUMN_ALIAS_LIST = Regexp.union(CTE_ALIAS_LIST, DERIVED_ALIAS_LIST, TABLE_ALIAS_LIST)
174
-
175
- # Defense against the column-aliasing redaction bypass:
176
- #
177
- # SELECT password_digest AS x FROM users -- bypasses result.columns redaction
178
- # SELECT substring(password_digest, 1, 60) ... -- column name becomes "substring"
179
- # SELECT md5(session_data) FROM sessions -- column name becomes "md5"
180
- #
181
- # Post-execution redaction operates on the column names the DB returns,
182
- # which the caller controls via aliases and expressions. The only
183
- # defense that works is to reject queries that TEXTUALLY reference
184
- # any sensitive column, before execution. This list is frozen, so an app
185
- # whose own column merely looks sensitive exempts it by name through
186
- # `config.query_allowed_columns`.
187
- SENSITIVE_COLUMN_SUFFIXES = %w[
188
- password_digest password_hash encrypted_password
189
- password_reset_token confirmation_token unlock_token
190
- remember_token reset_password_token api_key api_secret
191
- access_token refresh_token jti otp_secret session_data
192
- secret_key secret private_key
193
- ].freeze
194
-
195
- # SQL injection tautology patterns: OR 1=1, OR true, OR ''='', etc.
196
- TAUTOLOGY_PATTERNS = [
197
- /\bOR\s+1\s*=\s*1\b/i,
198
- /\bOR\s+true\b/i,
199
- /\bOR\s+'[^']*'\s*=\s*'[^']*'/i,
200
- /\bOR\s+"[^"]*"\s*=\s*"[^"]*"/i,
201
- /\bOR\s+\d+\s*=\s*\d+/i
202
- ].freeze
203
-
204
- # A UNION takes its column names from its first SELECT, so rows of
205
- # another table - a wildcard's columns included - come out under those
206
- # names, past the redaction that reads them. Any UNION is refused, in
207
- # every spelling: `UNION ALL`, `UNION DISTINCT`, and the parenthesised
208
- # `UNION (SELECT ...)` MySQL 8 and MariaDB take, which the narrower
209
- # pattern this replaces let through. INTERSECT and EXCEPT return only
210
- # rows of their first SELECT, so they stay.
211
- SET_UNION = /\bUNION\b/i
212
-
213
54
  HARD_ROW_CAP = 1000
214
55
 
215
- # Trilogy is Rails 8's default MySQL adapter (`adapter_name` reports
216
- # "Trilogy", not "Mysql2") - every dispatch that branches on adapter
217
- # name to apply the MySQL safety/parsing path must match both, or
218
- # Trilogy apps silently fall through to the "unknown adapter" path
219
- # with no READ ONLY transaction and no statement timeout.
220
- MYSQL_ADAPTER = /mysql|trilogy/i
221
-
222
- # activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
223
- POSTGRES_ADAPTER = /postg/i
224
-
225
- # SHOW/DESCRIBE/EXPLAIN return schema metadata (a table's column list,
226
- # an EXPLAIN plan), never application data rows. Two consequences:
227
- # 1. They're inherently bounded - appending `LIMIT n` to them is
228
- # invalid syntax on MySQL/Postgres and the query fails outright,
229
- # even though these statements are explicitly advertised as allowed.
230
- # 2. Column-name-based redaction doesn't apply to them - MySQL's
231
- # DESCRIBE returns a literal column named "Key" (PRI/UNI/MUL/empty)
232
- # that isn't actual secret data but matches the generic
233
- # sensitive-suffix heuristic (ends_with "key") used for real result
234
- # sets, so it would otherwise be redacted into uselessness.
56
+ # SHOW/DESCRIBE/EXPLAIN return schema metadata: a LIMIT appended to them is a syntax error,
57
+ # and name redaction would filter the "Key" column of MySQL's DESCRIBE.
235
58
  SCHEMA_METADATA_PREFIX = /\A\s*(SHOW|DESCRIBE|DESC|EXPLAIN)\b/i
236
59
 
237
60
  # The session record is echoed back to the model; a full query body can
@@ -242,25 +65,20 @@ module RailsAiContext
242
65
  params.key?(:sql) ? params.merge(sql: params[:sql].to_s.truncate(60)) : params
243
66
  end
244
67
 
245
- def self.call(sql: nil, limit: nil, format: "table", explain: false, server_context: nil, **_extra)
68
+ def self.call(sql: nil, limit: nil, format: "table", explain: false, database: nil, server_context: nil, **_extra)
246
69
  if (refusal = static_tier_refusal("Running SQL queries"))
247
70
  return refusal
248
71
  end
249
72
 
250
- # ── Environment guard ───────────────────────────────────────
251
73
  unless config.allow_query_in_production || rails_env_name != "production"
252
- return text_response(
74
+ return error_response(
253
75
  "rails_query is disabled in production for data privacy. " \
254
76
  "Set config.allow_query_in_production = true to override."
255
77
  )
256
78
  end
257
79
 
258
- # ── ActiveRecord guard (api-only apps) ──────────────────────
259
- # Must come BEFORE any code that rescues ActiveRecord::* - Ruby
260
- # resolves rescue class constants at raise time, and `rescue
261
- # ActiveRecord::ConnectionNotEstablished` crashes with NameError
262
- # on apps where ActiveRecord is not loaded (e.g.
263
- # `rails new --api --skip-active-record`).
80
+ # Before anything that rescues ActiveRecord::*: a rescue clause names
81
+ # its class at raise time, a NameError on an app without ActiveRecord.
264
82
  unless defined?(ActiveRecord::Base)
265
83
  return text_response(
266
84
  "Database queries are unavailable: ActiveRecord is not loaded in this app. " \
@@ -269,1094 +87,177 @@ module RailsAiContext
269
87
  )
270
88
  end
271
89
 
272
- # ── Layer 1: SQL validation ─────────────────────────────────
273
- mysql = mysql_dialect?
274
- postgres = postgres_adapter?
275
- valid, error = validate_sql(sql, mysql: mysql, postgres: postgres)
276
- return error_response(error) unless valid
277
-
278
- # Run the text the validator read, never the raw input: whatever the
279
- # scanner and the database disagree on, nothing unread reaches the database.
280
- sql = comment_free(sql, mysql: mysql)
281
-
282
- # ── Layer 1b: PostgreSQL semantic check (planning only) ─────
283
- # EXPLAIN (VERBOSE, FORMAT JSON) in a read-only transaction plans the
284
- # query without running it, so whole-row serialisation, an expanded
285
- # sensitive column and a VOLATILE admin function are caught before any
286
- # execution - the normal path and the EXPLAIN path alike.
287
- if (refusal = postgresql_plan_refusal(sql, config.query_timeout))
90
+ # Before a connection is leased or anything reads the statement: a call with none needs no database,
91
+ # and every scan of a statement takes time that grows with its length.
92
+ if (refusal = SqlGate.blank_refusal(sql) || SqlGate.length_refusal(sql))
288
93
  return error_response(refusal)
289
94
  end
290
95
 
291
- # A sensitive name that survived validation only because it sat inside a
292
- # literal (masked on PostgreSQL) is allowed only if the plan was obtained
293
- # and passed every check above - so a masker fooled by a quote trick can
294
- # never be the only gate. No plan (planning failed) keeps the raw refusal.
295
- if (refusal = unvouched_literal_refusal(sql, postgres))
296
- return error_response(refusal)
297
- end
298
-
299
- # ── EXPLAIN mode ────────────────────────────────────────────
300
- if explain
301
- return execute_explain(sql, config.query_timeout)
302
- end
303
-
304
- # Resolve row limit
305
- row_limit = limit ? [ limit.to_i, HARD_ROW_CAP ].min : config.query_row_limit
306
- row_limit = [ row_limit, 1 ].max
307
- timeout_seconds = config.query_timeout
308
-
309
- # ── Layers 2-3: Execute with DB-level safety + row limit ────
310
- result = execute_safely(sql, row_limit, timeout_seconds)
311
-
312
- # ── Layer 4: Redact sensitive columns ───────────────────────
313
- # Skip for SHOW/DESCRIBE/EXPLAIN - see SCHEMA_METADATA_PREFIX.
314
- schema_metadata = sql.match?(SCHEMA_METADATA_PREFIX)
315
- provenance = schema_metadata ? [] : provenance_sensitive_indices(sql) | plan_output_sensitive_indices(sql, result)
316
- redacted = if sql.match?(SHOW_CREATE_DEFINITION)
317
- redact_definition(result)
318
- elsif schema_metadata
319
- result
320
- else
321
- redact_results(result, provenance)
322
- end
323
-
324
- # ── Format output ───────────────────────────────────────────
325
- # The truncation note rides every format (for CSV, after a blank line so
326
- # a parser still reads the block); the unbounded-SQLite note stays off
327
- # CSV, which is meant to be plain data.
328
- cut = !schema_metadata && result.respond_to?(:truncated) && result.truncated
329
- truncation = cut ? truncation_note([ row_limit, HARD_ROW_CAP ].min, format) : ""
330
- output = case format
331
- when "csv"
332
- format_csv(redacted) + truncation
333
- else
334
- body = sql.match?(SHOW_CREATE_DEFINITION) ? format_definition(redacted) : format_table(redacted)
335
- body + truncation + unbounded_note(result)
336
- end
337
-
338
- text_response(output)
339
- rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError => e
340
- text_response("Database unavailable: #{clean_error_message(e.message)}\n\n**Troubleshooting:**\n- Check `config/database.yml` for correct host/port/credentials\n- Try `RAILS_ENV=test` if the development DB is remote\n- Run `bin/rails db:create` if the database doesn't exist yet")
96
+ # Decided from the configuration, so an adapter with no family is refused before its server is pinged.
97
+ family = configured_family(database)
98
+ return no_family_response(family) if family && !family.known?
99
+
100
+ AppDatabases.lease(rails_app.root, database) { |connection| answer(connection, sql, limit, format, explain, database) }
101
+ rescue AppDatabases::Unreachable => e
102
+ # A lock met while the connection opens is the locked database a statement meets, so it answers the same.
103
+ error_response([ e, e.cause ].compact.any? { |error| DatabaseFamily.busy?(error) } ? DatabaseFamily::BUSY : e.message)
104
+ rescue AppDatabases::Error, SecretColumns::Unreadable, DatabaseFamily::Busy => e
105
+ error_response(e.message)
106
+ rescue ActiveRecord::ConnectionNotEstablished => e
107
+ # The lease checked the connection answers, so this one was lost while the statement ran.
108
+ stopped_answering_response(database, e)
109
+ rescue DatabaseFamily::TimedOut => e
110
+ return stopped_answering_response(database, e.cause) if e.cause && DatabaseFamily.lost_connection?(e.cause)
111
+ return error_response(e.message) if e.is_a?(ReadTimedOut)
112
+
113
+ error_response("Query exceeded #{config.query_timeout} second timeout. Simplify the query or add indexes.")
341
114
  rescue ActiveRecord::StatementInvalid => e
342
- if e.message.match?(/timeout|statement_timeout|MAX_EXECUTION_TIME|max_statement_time|maximum statement execution time exceeded/i)
343
- text_response("Query exceeded #{config.query_timeout} second timeout. Simplify the query or add indexes.")
344
- # Only a missing DATABASE. Postgres words a missing column and a
345
- # missing table the same way ("... does not exist"), and matching
346
- # that shape answered a real SQL error with `db:create` advice and
347
- # exit 0. `ActiveRecord::NoDatabaseError` is rescued above, so this
348
- # only catches adapters that raise the plain StatementInvalid.
349
- elsif e.message.match?(/database "[^"]*" does not exist|could not find (?:your )?database|Unknown database/i)
350
- text_response("Database not found: #{clean_error_message(e.message)}\n\n**Troubleshooting:**\n- Run `bin/rails db:create` to create the database\n- Check `config/database.yml` for the correct database name\n- Try `RAILS_ENV=test` if the development DB is remote")
351
- else
352
- # Genuine execution failure (unknown column, bad table, syntax
353
- # error) - flag as an error result so MCP clients and the CLI
354
- # (exit 1) treat it as failed. Guidance about a database that is
355
- # not there stays informational above: the question is answerable,
356
- # just not now.
357
- error_response("SQL error: #{clean_error_message(e.message)}")
358
- end
115
+ return stopped_answering_response(database, e) if DatabaseFamily.lost_connection?(e)
116
+
117
+ error_response("SQL error: #{clean_error_message(e.message)}#{held_elsewhere(e.message, database)}")
359
118
  rescue => e
360
119
  error_response("Query failed: #{clean_error_message(e.message)}")
361
120
  end
362
121
 
363
- # ── SQL comment stripping ───────────────────────────────────────
364
- def self.strip_sql_comments(sql, mysql: false)
365
- comment_free(sql, mysql: mysql).squeeze(" ")
366
- end
367
-
368
- QUOTED = /'[^']*'?|"[^"]*"?|`[^`]*`?/m
369
- # A backslash escapes the next character: every MySQL string, and a PostgreSQL E'...' one.
370
- ESCAPED_STRING = /'(?:\\.|[^'\\])*'?/m
371
- MYSQL_QUOTED = /#{ESCAPED_STRING}|"(?:\\.|[^"\\])*"?|`[^`]*`?/m
372
- DOLLAR_QUOTED = /(?<![\p{Word}$])\$(\p{Word}*)\$.*?\$\1\$/m
373
- UNQUOTED = /[^'"`$\/\-#*]+/
374
-
375
- # Comments out, everything else as written: this is the text that runs.
376
- # A quoted span is copied whole, so a comment marker inside one is data.
377
- # Only MySQL has a hash comment, a `--` that needs a space after it, and
378
- # `/*! ... */` content it runs. PostgreSQL has dollar quoting.
379
- def self.comment_free(sql, mysql: false)
380
- # fixed_anchor, so the dollar quote's lookbehind sees the text before the pointer.
381
- scanner = StringScanner.new(sql, fixed_anchor: true)
382
- line_comment = mysql ? /--(?=\s|\z)[^\n]*|#[^\n]*/ : /--[^\n]*/
383
- out = +""
384
- executable = false
385
-
386
- until scanner.eos?
387
- quoted = if mysql then MYSQL_QUOTED
388
- elsif out.match?(/(?<![\p{Word}$])[eE]\z/) && scanner.check(/'/) then ESCAPED_STRING
389
- else QUOTED
390
- end
391
-
392
- if (span = scanner.scan(quoted) || (!mysql && scanner.scan(DOLLAR_QUOTED)) || scanner.scan(UNQUOTED))
393
- out << span
394
- elsif mysql && scanner.scan(/\/\*!\d*/)
395
- # Its content stays in the text for the checks to read, e.g. the
396
- # `LOAD_FILE` in `SELECT /*!50000 LOAD_FILE('/etc/passwd') */`.
397
- executable = true
398
- out << " "
399
- elsif executable && scanner.scan(/\*\//)
400
- executable = false
401
- out << " "
402
- elsif scanner.scan(/\/\*.*?(?:\*\/|\z)/m) || scanner.scan(line_comment)
403
- out << " "
404
- else
405
- out << scanner.getch
406
- end
407
- end
408
-
409
- out.strip
410
- end
411
-
412
- # Comment-free text with every quoted and dollar-quoted span blanked to
413
- # spaces of the same length, so a check reads structure only. A `;` or a
414
- # keyword inside a string literal or a `$$ ... $$` body is data, not a
415
- # second statement: it is the `;` in a `DO $$ ... ; ... $$` block that
416
- # made the multi-statement check refuse one for the wrong reason.
417
- def self.mask_quoted(text, mysql: false)
418
- scanner = StringScanner.new(text, fixed_anchor: true)
419
- out = +""
420
-
421
- until scanner.eos?
422
- quoted = if mysql then MYSQL_QUOTED
423
- elsif out.match?(/(?<![\p{Word}$])[eE]\z/) && scanner.check(/'/) then ESCAPED_STRING
424
- else QUOTED
425
- end
426
-
427
- if (span = scanner.scan(quoted) || (!mysql && scanner.scan(DOLLAR_QUOTED)))
428
- out << (" " * span.length)
429
- elsif (span = scanner.scan(UNQUOTED))
430
- out << span
431
- else
432
- out << scanner.getch
433
- end
434
- end
435
-
436
- out
437
- end
438
-
439
- # Whether the SQL carries a column-alias list that renames a table's
440
- # columns. A list on a VALUES body renames nothing a table holds, so it is
441
- # left alone; a sensitive name inside the VALUES rows is still refused by
442
- # the textual check. The lists are matched on the text as written, as
443
- # before; parentheses are balanced on quote-masked text (same length, same
444
- # positions), so one inside a literal does not move a body's bounds.
445
- private_class_method def self.renaming_alias_list?(cleaned, mysql: false)
446
- return true if cleaned.match?(TABLE_ALIAS_LIST)
447
-
448
- masked = mask_quoted(cleaned, mysql: mysql)
449
- # A CTE's list: the pattern ends on the "(" that opens its body.
450
- cleaned.scan(CTE_ALIAS_LIST) do
451
- return true unless values_body_at?(masked, Regexp.last_match.end(0))
452
- end
453
- # A derived table's list: its body is the parenthesis before AS.
454
- cleaned.scan(DERIVED_ALIAS_LIST) do
455
- open = derived_table_open(masked, Regexp.last_match.begin(0))
456
- return true unless open && values_body_at?(masked, open + 1)
457
- end
458
- false
459
- end
460
-
461
- private_class_method def self.values_body_at?(masked, position)
462
- masked[position..].to_s.match?(/\A\s*VALUES\b/i)
463
- end
464
-
465
- # The index of the "(" that opens the parenthesised FROM item ending just
466
- # before `as_position`, or nil when no ")" stands there.
467
- private_class_method def self.derived_table_open(masked, as_position)
468
- close = as_position - 1
469
- close -= 1 while close >= 0 && masked[close].match?(/\s/)
470
- return nil unless close >= 0 && masked[close] == ")"
471
-
472
- depth = 0
473
- close.downto(0) do |i|
474
- depth += 1 if masked[i] == ")"
475
- depth -= 1 if masked[i] == "("
476
- return i if depth.zero?
477
- end
478
- nil
479
- end
480
-
481
- private_class_method def self.mysql_dialect?
482
- ActiveRecord::Base.connection_db_config.adapter.to_s.match?(MYSQL_ADAPTER)
483
- rescue ActiveRecord::ActiveRecordError
484
- false
485
- end
486
-
487
- # ── SQL validation (Layer 1) ────────────────────────────────────
488
- # `postgres:` relaxes the sensitive-column check so a name inside a string
489
- # literal ('%secret%', a JSON key) is read as data, not a column - safe on
490
- # PostgreSQL because its plan layer (Layer 1b) re-checks and must vouch.
491
- # MySQL, SQLite and unknown adapters keep raw-text matching, with no
492
- # masking: MySQL's NO_BACKSLASH_ESCAPES and ANSI_QUOTES modes change what a
493
- # quote means, and SQLite reads a single-quoted name after a dot (t.'col')
494
- # as an identifier, so a masker there could be fooled into hiding one.
495
- def self.validate_sql(sql, mysql: false, postgres: false)
496
- return [ false, "SQL query is required." ] if sql.nil? || sql.strip.empty?
497
-
498
- # Belt-and-suspenders: run BLOCKED_FUNCTIONS against the RAW sql before
499
- # stripping comments. If the unwrap logic in comment_free is ever
500
- # defeated by a novel MySQL comment variant, this still catches the
501
- # dangerous primitives (pg_read_file, LOAD_FILE, dblink, ...).
502
- if (m = sql.match(BLOCKED_FUNCTIONS))
503
- return [ false, "Blocked: dangerous function #{m[0]} (filesystem/network primitive)" ]
504
- end
505
-
506
- cleaned = strip_sql_comments(sql, mysql: mysql)
507
-
508
- # Check multi-statement and clause patterns first - they provide more
509
- # specific error messages than the generic keyword blocker. The
510
- # semicolon is read on quote-masked text, so one inside a string literal
511
- # or a `$$ ... $$` block body is not mistaken for a second statement.
512
- #
513
- # The statement-keyword checks (SELECT INTO, the write keywords) read the
514
- # same quote-masked text, so `LIKE '%update%'` or `'put into box'` is
515
- # data. That is safe: ALLOWED_PREFIX still requires a read statement, and
516
- # the read-only transaction (PostgreSQL, MySQL) or read-only connection
517
- # (SQLite) stops any write a mis-masked literal could hide. A disk write
518
- # (INTO OUTFILE/DUMPFILE) and the file/network functions stay on the raw
519
- # text: no read-only transaction stops a server-side file write.
520
- masked = mask_quoted(cleaned, mysql: mysql)
521
- return [ false, "Blocked: multiple statements (no semicolons)" ] if masked.match?(MULTI_STATEMENT)
522
- return [ false, "Blocked: FOR UPDATE/SHARE clause" ] if cleaned.match?(BLOCKED_CLAUSES)
523
- return [ false, "Blocked: sensitive SHOW command" ] if cleaned.match?(BLOCKED_SHOWS)
524
- return [ false, "Blocked: SELECT INTO OUTFILE / DUMPFILE writes to disk" ] if cleaned.match?(BLOCKED_OUTPUT)
525
- return [ false, "Blocked: SELECT INTO creates a table" ] if masked.match?(SELECT_INTO)
526
-
527
- # Block database functions that give a filesystem/network primitive.
528
- # pg_read_file, lo_import, dblink, LOAD_FILE, load_extension, etc.
529
- # These pass SET TRANSACTION READ ONLY but bypass sensitive_patterns.
530
- if (m = cleaned.match(BLOCKED_FUNCTIONS))
531
- return [ false, "Blocked: dangerous function #{m[0]} (filesystem/network primitive)" ]
532
- end
533
-
534
- # Session-effecting admin / lock functions run under READ ONLY and keep
535
- # their effect on the pooled connection. The name blocklist is the first
536
- # layer; PostgreSQL's plan VOLATILE-function check is the robust one.
537
- if (m = cleaned.match(BLOCKED_PG_ADMIN))
538
- fn = m[0].sub(/\s*\(\z/, "")
539
- return [ false,
540
- "Blocked: administrative function #{fn} has a session or server-wide " \
541
- "effect that a read-only transaction does not prevent. rails_query runs " \
542
- "read-only queries for inspection; it will not run server-control functions." ]
543
- end
544
- if (m = cleaned.match(BLOCKED_MYSQL_SESSION))
545
- fn = m[0].sub(/\s*\(\z/, "")
546
- return [ false,
547
- "Blocked: session function #{fn} leaves state (a named lock) on the pooled " \
548
- "connection after the query returns. rails_query will not run it." ]
549
- end
550
-
551
- # A CTE or FROM-item column-alias list renames a wildcard's columns,
552
- # which would carry the sensitive values out under harmless names. One
553
- # on a VALUES body renames no table column and is left alone.
554
- if renaming_alias_list?(cleaned, mysql: mysql)
555
- return [ false,
556
- "Blocked: a column-alias list (e.g. `t(a, b, c)`) renames the columns a " \
557
- "wildcard returns, so sensitive columns would leave under harmless names. " \
558
- "Name the columns you want in the SELECT list instead." ]
559
- end
560
-
561
- # On PostgreSQL a "union" inside a string literal is data; its plan
562
- # layer reads the query itself. Elsewhere the raw text decides.
563
- if (postgres ? mask_sql_literals(cleaned) : cleaned).match?(SET_UNION)
564
- return [ false,
565
- "Blocked: UNION is not run. A UNION takes its column names from its first SELECT, " \
566
- "so rows of another table would come out under those names, past the redaction that " \
567
- "reads them. Query each table on its own; a recursive CTE needs UNION, so it is not run either." ]
568
- end
569
-
570
- # Check for SQL injection tautology patterns (OR 1=1, OR true, etc.)
571
- tautology = TAUTOLOGY_PATTERNS.find { |p| cleaned.match?(p) }
572
- return [ false, "Blocked: SQL injection pattern detected (#{cleaned[tautology]})" ] if tautology
573
-
574
- # Check blocked keywords before the allowed-prefix fallback so that
575
- # INSERT/UPDATE/DELETE/DROP etc. get a specific "Blocked" error
576
- # rather than the generic "Only SELECT... allowed" message. Read on the
577
- # quote-masked text; SHOW CREATE TABLE / VIEW drop their CREATE first.
578
- if (m = masked.sub(SHOW_CREATE_DEFINITION, "SHOW").match(BLOCKED_KEYWORDS))
579
- return [ false, "Blocked: contains #{m[0]}" ]
580
- end
581
-
582
- return [ false, "Only SELECT, WITH, SHOW, EXPLAIN, DESCRIBE allowed" ] unless cleaned.match?(ALLOWED_PREFIX)
583
-
584
- # Column-aliasing redaction bypass defense: reject any query that
585
- # textually references a sensitive column name. Post-execution redaction
586
- # reads the output column names, which an alias or an expression renames,
587
- # so it cannot survive `SELECT password_digest AS x` or `upper(api_token)`.
588
- # On PostgreSQL the match runs on literal-masked text; `.call` then makes
589
- # the plan vouch for anything that passed only because of that masking.
590
- if (offending = references_sensitive_column?(postgres ? mask_sql_literals(cleaned) : cleaned))
591
- return [ false,
592
- "Blocked: query references sensitive column `#{offending}`. " \
593
- "Name the columns you need; `#{offending}` is sensitive and never " \
594
- "returned, and an alias or expression over it cannot be redacted after " \
595
- "the query runs, so the whole query is refused. If `#{offending}` is not " \
596
- "sensitive in your app, add \"#{offending}\" to config.query_allowed_columns " \
597
- "in an initializer." ]
598
- end
599
-
600
- [ true, nil ]
601
- end
602
-
603
- # PostgreSQL string literals masked to an empty pair, so a sensitive word
604
- # inside one (a `'%secret%'` pattern, a JSON key `->>'token'`) is read as
605
- # data, not a column name. Dollar quotes first, then single quotes with any
606
- # PG prefix (`E` escapes a backslash; `U&`, `B`, `X`). A double-quoted
607
- # identifier is never touched. This is good enough for the textual layer;
608
- # the plan layer, whose deparser output does not depend on
609
- # standard_conforming_strings, is the final authority (see .call).
610
- # A double-quoted identifier is matched so it is skipped whole: a quote
611
- # inside one (`AS "a'b"`) would otherwise open a literal that swallows
612
- # the SQL after it.
613
- PG_TEXT_LITERAL = /
614
- (?<ident>"(?:""|[^"])*")
615
- | \$(?<tag>(?:[A-Za-z_]\w*)?)\$.*?\$\k<tag>\$
616
- | [eE]'(?:\\.|''|[^'])*'
617
- | (?:[uU]&|[bBxX])?'(?:''|[^'])*'
618
- /mx
619
- def self.mask_sql_literals(text)
620
- text.gsub(PG_TEXT_LITERAL) { Regexp.last_match[:ident] || "''" }
621
- end
622
-
623
- # Returns the first sensitive column name the SQL names, or nil. Two rules,
624
- # one predicate (sensitive_column?):
625
- # * a fixed name list - config.query_redacted_columns and
626
- # SENSITIVE_COLUMN_SUFFIXES - matched on every adapter whether or not
627
- # the name is a real column, so the oracle holds with no connection;
628
- # * every REAL column of the app's schema that the predicate flags
629
- # (an `api_token` the heuristic catches, an `encrypts` column), so an
630
- # app-specific sensitive column is caught through an alias or an
631
- # expression too.
632
- # The match is case-insensitive and word-bounded, so a table or alias that
633
- # merely contains "token" does not trip it - only a real column name does.
634
- def self.references_sensitive_column?(cleaned_sql)
635
- down = cleaned_sql.downcase
636
- blocked_column_names.each do |col|
637
- next if col.empty?
638
- return col if down.match?(/\b#{Regexp.escape(col)}\b/)
639
- end
640
- nil
641
- end
122
+ # nil where the lease has its own refusal to give: a database this environment does not configure.
123
+ private_class_method def self.configured_family(database)
124
+ return DatabaseFamily.of(ActiveRecord::Base.connection_db_config) if database.to_s.empty?
642
125
 
643
- # The names the textual pre-check refuses: the fixed list, plus the real
644
- # sensitive columns of the schema, minus the app's allowed columns.
645
- private_class_method def self.blocked_column_names
646
- fixed = Array(config.query_redacted_columns).map { |c| c.to_s.downcase } +
647
- SENSITIVE_COLUMN_SUFFIXES.map(&:downcase)
648
- (fixed + schema_sensitive_columns.to_a).uniq - allowed_columns.to_a
126
+ named = AppDatabases.for(rails_app.root)[database]
127
+ named.family if named&.here
649
128
  end
650
129
 
651
- # The real columns of the live schema the sensitivity rule flags. Read from
652
- # the connection (cached per run), falling back to the cached context, so a
653
- # column caught only by the result heuristic (`api_token`, `auth_token`) is
654
- # refused through any alias or expression before the query runs.
655
- private_class_method def self.schema_sensitive_columns
656
- real_column_names.select { |name| sensitive_column?(name) }
657
- end
658
-
659
- # Rails' own bookkeeping tables, on every adapter. Their columns
660
- # (schema_migrations.version, ar_internal_metadata.key/value) are never
661
- # secrets, and ar_internal_metadata.key ends in "key", so leaving them in
662
- # the sensitive-column set refused every query whose text held the word
663
- # "key" - a column, a JSON key, a `LIKE '%key%'`. A `SELECT *` from one
664
- # still goes through output-name redaction, as before.
665
- BOOKKEEPING_TABLES = %w[ar_internal_metadata schema_migrations].freeze
666
-
667
- private_class_method def self.bookkeeping_table?(name)
668
- BOOKKEEPING_TABLES.include?(name.to_s.downcase)
669
- end
670
-
671
- # Every real column name of the app's schema, downcased. The live
672
- # connection first (its own schema cache), the cached introspection as a
673
- # fallback. Memoised for the run; nothing here raising fails the query.
674
- private_class_method def self.real_column_names
675
- RailsAiContext::RunCache.fetch([ :query_real_columns ]) do
676
- columns_from_connection || columns_from_context || Set.new
677
- end
130
+ private_class_method def self.no_family_response(family)
131
+ error_response(
132
+ "rails_query refuses the #{family.name} adapter: it runs SQL only on a database whose read-only " \
133
+ "transaction and statement timeout it knows (PostgreSQL, MySQL with MariaDB, SQLite). No statement was sent."
134
+ )
678
135
  end
679
136
 
680
- private_class_method def self.columns_from_connection
681
- conn = ActiveRecord::Base.connection
682
- names = Set.new
683
- conn.tables.each do |t|
684
- next if bookkeeping_table?(t)
685
- conn.columns(t).each { |c| names << c.name.to_s.downcase }
686
- end
687
- names
688
- rescue StandardError
689
- nil
137
+ private_class_method def self.stopped_answering_response(database, error)
138
+ name = database.to_s.empty? ? ActiveRecord::Base.connection_db_config.name : database
139
+ error_response("The #{name} database stopped answering during the query: #{clean_error_message(error.message)}")
690
140
  end
691
141
 
692
- private_class_method def self.columns_from_context
693
- tables = cached_context&.dig(:schema, :tables)
694
- return nil unless tables.is_a?(Hash)
142
+ # A read beside the statement ran out of the time those reads share. Its message is the answer.
143
+ class ReadTimedOut < DatabaseFamily::TimedOut; end
695
144
 
696
- names = Set.new
697
- tables.each do |table, data|
698
- next if bookkeeping_table?(table)
699
- Array(data.is_a?(Hash) && data[:columns]).each do |col|
700
- name = col.is_a?(Hash) ? col[:name] : col
701
- names << name.to_s.downcase if name
702
- end
703
- end
704
- names
705
- rescue StandardError
706
- nil
707
- end
145
+ # Runs reads beside the statement, and says which side of it they were on when their time runs out.
146
+ private_class_method def self.beside_statement(outcome)
147
+ yield
148
+ rescue DatabaseFamily::TimedOut => e
149
+ raise if e.cause && DatabaseFamily.lost_connection?(e.cause)
708
150
 
709
- # One sensitivity rule, read by the textual pre-check, the PostgreSQL plan
710
- # check and result redaction alike: a column is sensitive when it is a
711
- # configured or built-in redacted name, an `encrypts` column, or matches
712
- # the result heuristic - unless the app allows it by name.
713
- def self.sensitive_column?(name)
714
- down = name.to_s.downcase
715
- return false if down.empty? || allowed_columns.include?(down)
716
-
717
- redacted_name_set.include?(down) ||
718
- encrypted_column_set.include?(down) ||
719
- sensitive_by_heuristic?(down)
151
+ raise ReadTimedOut, outcome
720
152
  end
721
153
 
722
- # Ends with a secret-ish suffix, or carries one of the broad secret words.
723
- # The same heuristic result redaction has always used on output names.
724
- SENSITIVE_SUFFIXES = %w[password secret token key digest hash].freeze
725
- private_class_method def self.sensitive_by_heuristic?(down)
726
- down.end_with?(*SENSITIVE_SUFFIXES) || down.match?(/password|secret|token/)
727
- end
154
+ private_class_method def self.answer(connection, sql, limit, format, explain, database)
155
+ family = DatabaseFamily.of(connection)
156
+ return no_family_response(family) unless family.known?
728
157
 
729
- private_class_method def self.redacted_name_set
730
- (Array(config.query_redacted_columns).map { |c| c.to_s.downcase } +
731
- SENSITIVE_COLUMN_SUFFIXES.map(&:downcase)).to_set
732
- end
158
+ # Built before any clock starts: a cold context is the app's whole introspection.
159
+ context = cached_context
160
+ # The statement has query_timeout to itself. Every other read the call sends shares a second one.
161
+ timeout = config.query_timeout
162
+ reads = SqlGate::Budget.new(timeout)
163
+ secrets, verdict = beside_statement(
164
+ "The reads rails_query makes before a statement (the columns of the schema, and on PostgreSQL the statement's plan) " \
165
+ "took the whole #{timeout} second timeout, so the statement was not run."
166
+ ) do
167
+ secrets = reads.spend { |left| family.bounded(connection, timeout: left) { SecretColumns.for(connection, context: context, statement: sql) } }
168
+ verdict = SqlGate.new(family: family, secrets: secrets, connection: connection, budget: reads).check(sql)
169
+ # A read no statement limit holds can run past its share, and then nothing is left for the reads after the statement.
170
+ raise DatabaseFamily::TimedOut, "the reads before the statement used the whole timeout" if reads.spent? && !verdict.refused?
733
171
 
734
- # Columns an `encrypts` declaration covers, from the cached introspection.
735
- private_class_method def self.encrypted_column_set
736
- set = Set.new
737
- models = cached_context&.dig(:models)
738
- if models.is_a?(Hash)
739
- models.each_value do |data|
740
- next unless data.is_a?(Hash)
741
- Array(data[:encrypts]).each { |col| set << col.to_s.downcase }
742
- end
172
+ [ secrets, verdict ]
743
173
  end
744
- set
745
- end
746
-
747
- # An app whose own column merely looks sensitive (oauth_applications.secret)
748
- # exempts it by name, from the pre-query check and from result redaction alike.
749
- private_class_method def self.allowed_columns
750
- Array(config.query_allowed_columns).to_set { |c| c.to_s.downcase }
751
- end
174
+ return error_response(verdict.refusal) if verdict.refused?
752
175
 
753
- # ── Database-level execution (Layer 2) ──────────────────────────
754
- private_class_method def self.execute_safely(sql, row_limit, timeout_seconds)
755
- conn = ActiveRecord::Base.connection
756
- adapter = conn.adapter_name.downcase
176
+ sql = verdict.sql
177
+ return explain_response(family, connection, sql, timeout, database) if explain
757
178
 
758
- limited_sql = apply_row_limit(sql, row_limit)
759
-
760
- cap_rows(run_guarded(conn, adapter, limited_sql, timeout_seconds), sql, row_limit)
761
- end
179
+ row_limit = limit ? [ limit.to_i, HARD_ROW_CAP ].min : config.query_row_limit
180
+ row_limit = [ row_limit, 1 ].max
181
+ result = cap_rows(family.read_only(connection, apply_row_limit(sql, row_limit), timeout: timeout), sql, row_limit)
762
182
 
763
- # EXPLAIN goes through the adapter wrappers too (READ ONLY, timeout): EXPLAIN ANALYZE runs
764
- # the plan, so `explain: true` must not hold a connection past query_timeout.
765
- private_class_method def self.run_guarded(conn, adapter, sql, timeout)
766
- case adapter
767
- when POSTGRES_ADAPTER
768
- execute_postgresql(conn, sql, timeout)
769
- when MYSQL_ADAPTER
770
- execute_mysql(conn, sql, timeout)
771
- when /sqlite/
772
- execute_sqlite(conn, sql, timeout)
183
+ schema_metadata = sql.match?(SCHEMA_METADATA_PREFIX)
184
+ definition = sql.match?(SqlGate::SHOW_CREATE_DEFINITION)
185
+ redacted = if definition
186
+ redact_definition(result)
187
+ elsif schema_metadata
188
+ result
773
189
  else
774
- # Unknown adapter -- rely on Layer 1 regex validation only
775
- conn.select_all(sql)
190
+ sources = beside_statement(
191
+ "The statement ran, and the read that traces its result columns to their table columns took what was left of the " \
192
+ "#{timeout} second timeout the reads beside a statement share, so its rows are not shown."
193
+ ) { reads.spend { |left| family.bounded(connection, timeout: left) { family.column_sources(connection, sql, plan: verdict.plan) } } }
194
+ redact_results(result, secrets, sources)
776
195
  end
777
- end
778
196
 
779
- private_class_method def self.execute_postgresql(conn, sql, timeout)
780
- result = nil
781
- conn.transaction do
782
- conn.execute("SET TRANSACTION READ ONLY")
783
- conn.execute("SET LOCAL statement_timeout = '#{(timeout * 1000).to_i}'")
784
- result = conn.select_all(sql)
785
- raise ActiveRecord::Rollback
786
- end
787
- result
788
- end
789
-
790
- private_class_method def self.execute_mysql(conn, sql, timeout)
791
- # Per-query timeout. MySQL honours the MAX_EXECUTION_TIME optimizer hint;
792
- # MariaDB ignores it (a long query would run unbounded, holding the
793
- # connection), so there the statement is bounded with MariaDB's own
794
- # `SET STATEMENT max_statement_time = <seconds> FOR <query>`, which scopes
795
- # the limit to this one statement and needs no restore on the pool.
796
- bounded_sql = if mariadb_server?(conn)
797
- "SET STATEMENT max_statement_time=#{[ timeout.to_f, 0.001 ].max} FOR #{sql}"
798
- elsif sql.match?(/\ASELECT/i)
799
- sql.sub(/\ASELECT/i, "SELECT /*+ MAX_EXECUTION_TIME(#{(timeout * 1000).to_i}) */")
197
+ # The truncation note rides every format (for CSV after a blank line, so
198
+ # a parser still reads the block); the unbounded note stays off CSV.
199
+ cut = !schema_metadata && result.respond_to?(:truncated) && result.truncated
200
+ truncation = cut ? truncation_note([ row_limit, HARD_ROW_CAP ].min, format) : ""
201
+ output = if format == "csv"
202
+ format_csv(redacted) + truncation
800
203
  else
801
- sql
204
+ (definition ? format_definition(redacted) : format_table(redacted)) + truncation + unbounded_note(result)
802
205
  end
803
206
 
804
- # The SET must run BEFORE the transaction opens: Rails materializes
805
- # the lazy BEGIN ahead of the first in-block statement, and MySQL
806
- # rejects changing transaction characteristics mid-transaction
807
- # (error 1568). Without GLOBAL/SESSION scope the SET applies only to
808
- # the next transaction, which the block below immediately consumes,
809
- # so the read-only characteristic cannot leak onto the pooled
810
- # connection. (#89)
811
- result = nil
812
- conn.execute("SET TRANSACTION READ ONLY")
813
- conn.transaction do
814
- result = conn.select_all(bounded_sql)
815
- raise ActiveRecord::Rollback
816
- end
817
- result
818
- end
819
-
820
- # MariaDB and MySQL share the mysql2/trilogy adapters but bound a query
821
- # differently; the adapter answers `mariadb?` on Rails 7.1+, and the
822
- # server version string is the fallback.
823
- private_class_method def self.mariadb_server?(conn)
824
- return conn.mariadb? if conn.respond_to?(:mariadb?)
825
-
826
- conn.select_value("SELECT VERSION()").to_s.include?("MariaDB")
827
- rescue StandardError
828
- false
829
- end
830
-
831
- # sqlite3-ruby cannot interrupt a running statement, so a killable child runs it instead.
832
- private_class_method def self.execute_sqlite(conn, sql, timeout)
833
- if (path = sqlite_fork_path(conn))
834
- begin
835
- return execute_sqlite_in_child(conn, path, sql, timeout)
836
- rescue ActiveRecord::StatementInvalid => e
837
- raise unless e.message.match?(SQLITE_APP_CONNECTION_ONLY)
838
- end
839
- end
840
-
841
- result = nil
842
- begin
843
- conn.execute("PRAGMA query_only = ON")
844
- result = conn.select_all(sql)
845
- ensure
846
- conn.execute("PRAGMA query_only = OFF")
847
- end
848
- ResultProxy.new(result.columns, result.rows, true)
849
- end
850
-
851
- # Errors from state only the app's own connection has: registered functions,
852
- # virtual table modules, collations, an encryption key.
853
- SQLITE_APP_CONNECTION_ONLY = /no such (?:function|module|collation)|file is not a database/i
854
-
855
- # A row-capped result is far below this; a larger prefix is a corrupt one.
856
- SQLITE_MAX_RESULT_BYTES = 1 << 30
857
-
858
- private_class_method def self.read_before(reader, size, deadline)
859
- data = "".b
860
- while data.bytesize < size
861
- left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
862
- raise ActiveRecord::StatementInvalid, "SQLite query exceeded the statement timeout" unless left.positive? && IO.select([ reader ], nil, nil, left)
863
-
864
- chunk = reader.read_nonblock([ size - data.bytesize, 1 << 16 ].min, exception: false)
865
- raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result" if chunk.nil?
866
-
867
- data << chunk unless chunk == :wait_readable
868
- end
869
- data
207
+ text_response(output)
870
208
  end
871
209
 
872
- # nil for an in-memory or temporary database, or where there is no fork.
873
- private_class_method def self.sqlite_fork_path(conn)
874
- return nil unless Process.respond_to?(:fork)
210
+ MISSING_TABLE = /no such table: (?:\w+\.)?(\w+)|relation "(?:\w+\.)?(\w+)" does not exist|Table '(?:[^'.]+\.)?(\w+)' doesn't exist/i
875
211
 
876
- path = conn.raw_connection.filename("main").to_s
877
- path unless path.empty?
878
- end
212
+ # Where a table the statement did not find lives, read from the schema
213
+ # section's tables for the primary and each other database's dump.
214
+ private_class_method def self.held_elsewhere(message, database)
215
+ table = message.match(MISSING_TABLE)&.captures&.compact&.first or return ""
216
+ databases = AppDatabases.for(rails_app.root)
217
+ asked = database.to_s.empty? ? databases.primary&.name : database.to_s
218
+ schema = cached_context[:schema]
219
+ return "" unless schema.is_a?(Hash)
879
220
 
880
- private_class_method def self.execute_sqlite_in_child(conn, path, sql, timeout)
881
- parent = Process.pid
882
- extensions = Array(conn.pool.db_config.configuration_hash[:extensions]).map { |ext| (ext.is_a?(String) && ext.safe_constantize) || ext }
883
- # sqlite3 2.x warns in every child that inherits a writable handle; Rails 8 silences it the same way.
884
- SQLite3::ForkSafety.suppress_warnings! if defined?(SQLite3::ForkSafety)
885
-
886
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
887
- reader, writer = IO.pipe.each(&:binmode)
888
- pid = fork { answer_in_child(reader, writer, path, sql, timeout, extensions) }
889
- writer.close
890
- read_child_result(reader, deadline)
891
- ensure
892
- # Also reached in a child whose fork hooks raised: it must not carry on as a second server.
893
- exit!(1) if parent && Process.pid != parent
894
- reader&.close
895
- writer&.close
896
- reap_child(pid) if pid
897
- end
221
+ held = { (databases.primary&.name || "primary") => schema[:tables] }
222
+ (schema[:secondary_databases] || {}).each { |name, dump| held[name.to_s] ||= dump[:tables] if dump.is_a?(Hash) }
223
+ holder = held.find { |name, tables| name != asked && tables.is_a?(Hash) && tables.key?(table) }&.first or return ""
898
224
 
899
- # Writes the length-prefixed result and exits without the parent's at_exit hooks.
900
- private_class_method def self.answer_in_child(reader, writer, path, sql, timeout, extensions)
901
- reader.close
902
- payload = begin
903
- db = SQLite3::Database.new(path, readonly: true, extensions: extensions)
904
- db.busy_timeout = (timeout * 1000).to_i
905
- stmt = db.prepare(sql)
906
- [ :ok, stmt.columns, stmt.to_a ]
907
- rescue => e
908
- [ :error, "#{e.class}: #{e.message}" ]
909
- end
910
- bytes = Marshal.dump(payload)
911
- writer.write([ bytes.bytesize ].pack("Q>"), bytes)
912
- ensure
913
- exit!(0)
914
- end
225
+ found = databases[holder]
226
+ return ". The #{table} table is on the #{holder} database: run the query again with database: \"#{holder}\"." if found.nil? || found.here
915
227
 
916
- # Read by length, not to EOF: any other process forked meanwhile holds a copy of the writer.
917
- private_class_method def self.read_child_result(reader, deadline)
918
- size = read_before(reader, 8, deadline).unpack1("Q>")
919
- status, *rest = begin
920
- raise ArgumentError, "result length out of range" if size > SQLITE_MAX_RESULT_BYTES
921
-
922
- Marshal.load(read_before(reader, size, deadline))
923
- rescue ArgumentError, TypeError
924
- raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result"
925
- end
926
- raise ActiveRecord::StatementInvalid, rest.first if status == :error
927
-
928
- ActiveRecord::Result.new(*rest)
929
- end
930
-
931
- # KILL: TERM cannot land while `step` holds the GVL, and would run inherited at_exit hooks.
932
- # Runs in an ensure, so it rescues everything: nothing here may replace the query's own error.
933
- private_class_method def self.reap_child(pid)
934
- Process.kill(:KILL, pid) rescue nil
935
- Process.wait(pid) rescue nil
228
+ ". The #{table} table is on the #{holder} database, which #{RailsAiContext.environment_name} does not configure."
229
+ rescue StandardError => e
230
+ RailsAiContext.debug_fail(e, "", label: "held_elsewhere")
936
231
  end
937
232
 
938
233
  private_class_method def self.unbounded_note(result)
939
- result.is_a?(ResultProxy) && result.unbounded ? UNBOUNDED_SQLITE_NOTE : ""
234
+ result.respond_to?(:unbounded) && result.unbounded ? UNBOUNDED_SQLITE_NOTE : ""
940
235
  end
941
236
 
942
237
  # Says, in every format, that the row cap held rows back, so a reader (or a
943
- # parser) is not told a partial answer is the whole one. For CSV it sits
944
- # after a blank line, outside the comma block.
238
+ # parser) is not told a partial answer is the whole one.
945
239
  private_class_method def self.truncation_note(cap, format)
946
240
  detail = if cap >= HARD_ROW_CAP
947
241
  "the 1000-row hard cap"
948
242
  else
949
243
  "the row limit of #{cap}; pass limit: up to 1000 to see more"
950
244
  end
951
- note = "#{cap} rows shown; the query returned more (#{detail})."
245
+ note = "#{count_phrase(cap, "row")} shown; the query returned more (#{detail})."
952
246
  format == "csv" ? "\n\n#{note}" : "\n\n_#{note}_"
953
247
  end
954
248
 
955
- # ── Layer 1b: PostgreSQL semantic analysis ──────────────────────
956
- # Plans the query inside a read-only transaction with EXPLAIN (VERBOSE,
957
- # FORMAT JSON) - planning only, nothing runs, not even a VOLATILE admin
958
- # function (the planner does not fold one). Refuses three things the
959
- # textual layer cannot see: a whole-row reference (`alias.*`) to a relation
960
- # that has a sensitive column; a sensitive column used anywhere but as a
961
- # plain pass-through output (a ROW(tbl.*) the planner expanded, a WHERE
962
- # oracle); and any VOLATILE function outside the harmless allowlist.
963
- private_class_method def self.postgresql_plan_refusal(sql, timeout)
964
- return nil unless postgres_adapter?
965
-
966
- plan = pg_plan(sql, timeout)
967
- return nil unless plan
968
-
969
- nodes = []
970
- collect_plan_nodes(plan, nodes)
971
- aliases = plan_alias_map(nodes)
972
- exprs = plan_expressions(nodes)
973
-
974
- if (rel = whole_row_leak(exprs, aliases))
975
- return "Blocked: the query serialises a whole row of `#{rel}`, which carries every " \
976
- "column including the sensitive ones. Name the columns you need in the SELECT list."
977
- end
978
- if (col = sensitive_column_in_plan(exprs))
979
- return "Blocked: the query's plan uses the sensitive column `#{col}` (expanded from a " \
980
- "wildcard or a row reference). Name the non-sensitive columns you need; `#{col}` is " \
981
- "never returned."
982
- end
983
- # A sensitive column the plan reads as a plain pass-through but hides
984
- # behind an intermediate (a MATERIALIZED CTE, a subquery scan, a set
985
- # operation), so its value reaches a result column the top node does
986
- # not name. The top-Output redaction below cannot pin it, so refuse.
987
- if (col = laundered_sensitive_column(plan, nodes, aliases))
988
- return "Blocked: the query passes the sensitive column `#{col}` through a CTE, subquery " \
989
- "or set operation whose output the tool cannot map to a column, so it could leave " \
990
- "unredacted. Name the non-sensitive columns you need; `#{col}` is never returned."
991
- end
992
- if (fn = volatile_function_in_plan(exprs))
993
- return "Blocked: function #{fn} is VOLATILE (pg_proc.provolatile = 'v') and not on " \
994
- "rails_query's list of read-only VOLATILE functions, so it is not run. Some VOLATILE " \
995
- "functions change server or session state in ways a read-only transaction does not " \
996
- "stop; only those known to just read state (sizes, lock status, recovery and WAL " \
997
- "position, random values) are allowed."
998
- end
999
- nil
1000
- rescue StandardError
1001
- # Planning failed (syntax the executor will also reject, a permission
1002
- # error): let normal execution raise the real error. The name blocklist
1003
- # in validate_sql already stopped the known admin functions.
1004
- nil
1005
- end
1006
-
1007
- # The plan-vouch. On PostgreSQL a sensitive name that appears only inside a
1008
- # string literal passed the textual check (masked), so the query may run
1009
- # only if the plan was actually obtained - which means the plan layer above
1010
- # read the query and raised nothing. If planning did not happen, the
1011
- # literal masker is the only thing that let the name through, and it can be
1012
- # fooled by a quote trick (standard_conforming_strings off, an E-string),
1013
- # so the raw-text refusal stands.
1014
- private_class_method def self.unvouched_literal_refusal(sql, postgres)
1015
- return nil unless postgres
1016
-
1017
- offending = references_sensitive_column?(sql) or return nil
1018
- # A real identifier (still there after masking) is validate_sql's to
1019
- # refuse; reaching here with one would mean masking let it slip - refuse.
1020
- return nil if references_sensitive_column?(mask_sql_literals(sql)).nil? &&
1021
- pg_plan(sql, config.query_timeout)
1022
-
1023
- "Blocked: query references sensitive column `#{offending}`. It is read as data " \
1024
- "only when the query plan confirms it, and the plan could not be obtained, so the " \
1025
- "query is refused. Name the columns you need; `#{offending}` is never returned."
1026
- end
1027
-
1028
- # The query's JSON plan from EXPLAIN (VERBOSE) - run once per tool call,
1029
- # shared by the refusal checks and the output-provenance redaction.
1030
- private_class_method def self.pg_plan(sql, timeout)
1031
- target = strip_leading_explain(sql)
1032
- return nil unless target.match?(/\A\s*(SELECT|WITH)\b/i)
1033
-
1034
- RailsAiContext::RunCache.fetch([ :pg_plan, target ]) { explain_plan(target, timeout) }
1035
- end
1036
-
1037
- # Run EXPLAIN in its own read-only transaction and parse the JSON plan.
1038
- private_class_method def self.explain_plan(sql, timeout)
1039
- conn = ActiveRecord::Base.connection
1040
- json = nil
1041
- conn.transaction do
1042
- conn.execute("SET TRANSACTION READ ONLY")
1043
- conn.execute("SET LOCAL statement_timeout = '#{(timeout * 1000).to_i}'")
1044
- row = conn.select_all("EXPLAIN (VERBOSE, FORMAT JSON) #{sql}").rows.first
1045
- json = row&.first
1046
- raise ActiveRecord::Rollback
1047
- end
1048
- data = JSON.parse(json.to_s)
1049
- data.is_a?(Array) ? data.first&.dig("Plan") : nil
1050
- rescue ActiveRecord::StatementInvalid
1051
- nil
1052
- end
1053
-
1054
- # Result-column indices whose top-node Output expression is a plain Var of
1055
- # a sensitive base column - a view/CTE/subquery that renamed the column, so
1056
- # redaction by output name would miss it. The top Output is positional: its
1057
- # i-th expression is result column i, with any trailing sort/group key past
1058
- # the result width. PostgreSQL only; nothing here raising fails the query.
1059
- private_class_method def self.plan_output_sensitive_indices(sql, result)
1060
- return [] unless postgres_adapter?
1061
-
1062
- plan = pg_plan(sql, config.query_timeout)
1063
- output = plan && plan["Output"]
1064
- return [] unless output.is_a?(Array)
1065
-
1066
- nodes = []
1067
- collect_plan_nodes(plan, nodes)
1068
- aliases = plan_alias_map(nodes)
1069
- width = result.columns.length
1070
- (0...[ width, output.length ].min).select do |i|
1071
- rel, col = base_var(output[i], aliases)
1072
- rel && relation_has_sensitive_column?(rel, col)
1073
- end
1074
- rescue StandardError
1075
- []
1076
- end
1077
-
1078
- # A plain, qualified Var `rel.col` resolved to its base relation, or nil.
1079
- # A quoted or spaced alias (a set-operation's "*SELECT* 1") never matches,
1080
- # so only a real relation's column is read as a base Var.
1081
- BASE_VAR = /\A"?([A-Za-z_]\w*)"?\.([A-Za-z_]\w*)\z/
1082
- private_class_method def self.base_var(expr, aliases)
1083
- m = expr.to_s.strip.match(BASE_VAR) or return nil
1084
- rel = aliases[m[1].downcase]
1085
- rel ? [ rel, m[2].downcase ] : nil
1086
- end
1087
-
1088
- private_class_method def self.relation_has_sensitive_column?(relation, column)
1089
- Array(relation_sensitive_columns[relation]).include?(column)
1090
- end
1091
-
1092
- # The sensitive base column (if any) the plan reads as a plain pass-through
1093
- # Var in some node but NOT in the top node's Output, so the top-Output
1094
- # redaction cannot reach it. Returns the bare column name, or nil.
1095
- private_class_method def self.laundered_sensitive_column(plan, nodes, aliases)
1096
- top = sensitive_base_vars(Array(plan["Output"]), aliases)
1097
- nodes.each do |node|
1098
- sensitive_base_vars(Array(node["Output"]), aliases).each do |var|
1099
- return var.split(".").last unless top.include?(var)
1100
- end
1101
- end
1102
- nil
1103
- end
1104
-
1105
- # The "rel.col" of each Output entry that is a plain Var of a sensitive
1106
- # base column.
1107
- private_class_method def self.sensitive_base_vars(output, aliases)
1108
- output.filter_map do |expr|
1109
- rel, col = base_var(expr, aliases)
1110
- "#{rel}.#{col}" if rel && relation_has_sensitive_column?(rel, col)
1111
- end.to_set
1112
- end
1113
-
1114
- # Strip a leading EXPLAIN [ (opts) | ANALYZE | VERBOSE ... ] so the
1115
- # semantic check reads the statement a raw EXPLAIN would run - an
1116
- # EXPLAIN ANALYZE that would otherwise execute the body.
1117
- private_class_method def self.strip_leading_explain(sql)
1118
- sql.sub(/\A\s*EXPLAIN\s+(?:\([^)]*\)\s*|ANALYZE\s+|VERBOSE\s+)*/i, "")
1119
- end
1120
-
1121
- private_class_method def self.collect_plan_nodes(node, acc)
1122
- return unless node.is_a?(Hash)
1123
- acc << node
1124
- Array(node["Plans"]).each { |child| collect_plan_nodes(child, acc) }
1125
- end
1126
-
1127
- # alias (downcased) => relation name (downcased). The alias defaults to the
1128
- # relation name when the query gave none, which is how `users.*` resolves.
1129
- private_class_method def self.plan_alias_map(nodes)
1130
- map = {}
1131
- nodes.each do |node|
1132
- rel = node["Relation Name"]
1133
- next unless rel
1134
- name = (node["Alias"] || rel).to_s.downcase
1135
- map[name] = rel.to_s.downcase
1136
- end
1137
- map
1138
- end
1139
-
1140
- # Every expression string a node carries: the projected Output entries
1141
- # kept apart (they may be plain pass-throughs) from the conditions, keys
1142
- # and function calls, which never are.
1143
- PLAN_COND_KEYS = [
1144
- "Filter", "Index Cond", "Recheck Cond", "Hash Cond", "Merge Cond",
1145
- "Join Filter", "One-Time Filter", "TID Cond", "Function Call",
1146
- "Sort Key", "Group Key", "Presorted Key", "Order By", "Cache Key"
1147
- ].freeze
1148
- private_class_method def self.plan_expressions(nodes)
1149
- outputs = []
1150
- conds = []
1151
- nodes.each do |node|
1152
- outputs.concat(Array(node["Output"]).map { |e| mask_plan_literals(e) })
1153
- PLAN_COND_KEYS.each { |k| conds.concat(Array(node[k]).map { |e| mask_plan_literals(e) }) }
1154
- end
1155
- { outputs: outputs, conds: conds, all: outputs + conds }
1156
- end
1157
-
1158
- # A constant in a plan expression masked to an empty pair, so a sensitive
1159
- # word inside one (`'%key%'::text` in a Filter, a `'...'` argument) is not
1160
- # read as a column or a function name. PostgreSQL's deparser always doubles
1161
- # quotes and never emits an E'' string, so this pattern matches a literal
1162
- # exactly whatever standard_conforming_strings is; a double-quoted
1163
- # identifier is never touched. The scanners (whole_row_leak,
1164
- # sensitive_column_in_plan, volatile_function_in_plan) read the masked text.
1165
- PLAN_LITERAL = /'(?:''|[^'])*'/
1166
- private_class_method def self.mask_plan_literals(expr)
1167
- expr.to_s.gsub(PLAN_LITERAL, "''")
1168
- end
1169
-
1170
- # A whole-row `alias.*` reference to a relation that holds a sensitive
1171
- # column. An unresolved alias is refused only when some relation in the
1172
- # plan is sensitive, so a whole-row read of a non-sensitive table is fine.
1173
- private_class_method def self.whole_row_leak(exprs, aliases)
1174
- any_sensitive = aliases.values.any? { |rel| sensitive_relation?(rel) }
1175
- exprs[:all].each do |expr|
1176
- expr.to_s.scan(/([A-Za-z_]\w*)\.\*/) do |(al)|
1177
- rel = aliases[al.downcase]
1178
- return rel if rel && sensitive_relation?(rel)
1179
- return (rel || al) if rel.nil? && any_sensitive
1180
- end
1181
- end
1182
- nil
1183
- end
1184
-
1185
- # A sensitive column name in the plan that is not a plain pass-through
1186
- # output. The textual layer has already refused any query whose own text
1187
- # names a sensitive column, so a name here came from a wildcard, a row
1188
- # reference the planner expanded, or a view - never from a column the
1189
- # caller asked for by name.
1190
- private_class_method def self.sensitive_column_in_plan(exprs)
1191
- sensitive = schema_sensitive_columns.to_a
1192
- return nil if sensitive.empty?
1193
-
1194
- # Conditions and keys are never a pass-through: any sensitive name is a leak (or an oracle).
1195
- exprs[:conds].each do |expr|
1196
- sensitive.each { |col| return col if expr.to_s.downcase.match?(/\b#{Regexp.escape(col)}\b/) }
1197
- end
1198
- # An Output entry is allowed when it is exactly a bare column reference
1199
- # (a plain SELECT * column, redacted later by name/provenance).
1200
- exprs[:outputs].each do |expr|
1201
- next if passthrough_output?(expr)
1202
- sensitive.each { |col| return col if expr.to_s.downcase.match?(/\b#{Regexp.escape(col)}\b/) }
1203
- end
1204
- nil
1205
- end
1206
-
1207
- PASSTHROUGH_OUTPUT = /\A\(?\s*(?:[A-Za-z_]\w*\.)?[A-Za-z_]\w*\s*\)?\z/
1208
- private_class_method def self.passthrough_output?(expr)
1209
- expr.to_s.match?(PASSTHROUGH_OUTPUT)
1210
- end
1211
-
1212
- # A VOLATILE function (pg_proc.provolatile = 'v') named anywhere in the
1213
- # plan, outside the harmless allowlist. This closes the admin-function
1214
- # family - pg_terminate_backend, pg_advisory_lock, set_config and the rest -
1215
- # without an ever-growing name list.
1216
- private_class_method def self.volatile_function_in_plan(exprs)
1217
- names = Set.new
1218
- exprs[:all].each do |expr|
1219
- expr.to_s.scan(/([A-Za-z_]\w*)\s*\(/) { |(fn)| names << fn.downcase }
1220
- end
1221
- names.subtract(PG_VOLATILE_ALLOWLIST)
1222
- return nil if names.empty?
1223
-
1224
- conn = ActiveRecord::Base.connection
1225
- quoted = names.map { |n| conn.quote(n) }.join(", ")
1226
- volatile = conn.select_values(
1227
- "SELECT DISTINCT lower(proname) FROM pg_proc " \
1228
- "WHERE lower(proname) IN (#{quoted}) AND provolatile = 'v'"
1229
- )
1230
- volatile.first
1231
- end
1232
-
1233
- private_class_method def self.sensitive_relation?(relation)
1234
- relation_sensitive_columns.key?(relation.to_s.downcase)
1235
- end
1236
-
1237
- # relation name (downcased) => its sensitive column names, for relations
1238
- # that have at least one. Live connection first, cached context as a
1239
- # fallback, memoised for the run.
1240
- private_class_method def self.relation_sensitive_columns
1241
- RailsAiContext::RunCache.fetch([ :query_relation_sensitive ]) do
1242
- by_relation = relation_columns
1243
- by_relation.each_with_object({}) do |(rel, cols), acc|
1244
- flagged = cols.select { |c| sensitive_column?(c) }
1245
- acc[rel] = flagged unless flagged.empty?
1246
- end
1247
- end
1248
- end
1249
-
1250
- private_class_method def self.relation_columns
1251
- from_connection_relation_columns || from_context_relation_columns || {}
1252
- end
1253
-
1254
- private_class_method def self.from_connection_relation_columns
1255
- conn = ActiveRecord::Base.connection
1256
- conn.tables.each_with_object({}) do |t, acc|
1257
- next if bookkeeping_table?(t)
1258
- acc[t.to_s.downcase] = conn.columns(t).map { |c| c.name.to_s.downcase }
1259
- end
1260
- rescue StandardError
1261
- nil
1262
- end
1263
-
1264
- private_class_method def self.from_context_relation_columns
1265
- tables = cached_context&.dig(:schema, :tables)
1266
- return nil unless tables.is_a?(Hash)
1267
-
1268
- tables.each_with_object({}) do |(name, data), acc|
1269
- next if bookkeeping_table?(name)
1270
- cols = Array(data.is_a?(Hash) && data[:columns]).map do |col|
1271
- (col.is_a?(Hash) ? col[:name] : col).to_s.downcase
1272
- end
1273
- acc[name.to_s.downcase] = cols
1274
- end
1275
- rescue StandardError
1276
- nil
1277
- end
1278
-
1279
- private_class_method def self.postgres_adapter?
1280
- ActiveRecord::Base.connection.adapter_name.to_s.match?(POSTGRES_ADAPTER)
1281
- rescue StandardError
1282
- false
1283
- end
1284
-
1285
- # Output columns whose provenance (PG::Result#ftable / #ftablecol) traces
1286
- # to a sensitive base column, so a renamed sensitive column is redacted
1287
- # whatever its output name. Describe-only (no execution); PostgreSQL only.
1288
- private_class_method def self.provenance_sensitive_indices(sql)
1289
- return [] unless postgres_adapter?
1290
-
1291
- conn = ActiveRecord::Base.connection
1292
- raw = conn.raw_connection
1293
- stmt = "rac_prov_#{Process.pid}_#{rand(1 << 30)}"
1294
- begin
1295
- raw.prepare(stmt, sql)
1296
- desc = raw.describe_prepared(stmt)
1297
- ensure
1298
- raw.exec("DEALLOCATE #{stmt}") rescue nil
1299
- end
1300
-
1301
- oid_columns = {}
1302
- indices = []
1303
- desc.nfields.times do |i|
1304
- oid = desc.ftable(i)
1305
- col = desc.ftablecol(i)
1306
- next if oid.nil? || oid.to_i.zero? || col.to_i.zero?
1307
-
1308
- names = (oid_columns[oid] ||= attribute_names_for(conn, oid))
1309
- name = names[col.to_i]
1310
- indices << i if name && sensitive_column?(name)
1311
- end
1312
- indices
1313
- rescue StandardError
1314
- []
1315
- end
1316
-
1317
- # attnum => column name for one relation oid.
1318
- private_class_method def self.attribute_names_for(conn, oid)
1319
- rows = conn.select_rows(
1320
- "SELECT attnum, attname FROM pg_attribute " \
1321
- "WHERE attrelid = #{oid.to_i} AND attnum > 0 AND NOT attisdropped"
1322
- )
1323
- rows.each_with_object({}) { |(num, name), acc| acc[num.to_i] = name.to_s }
1324
- rescue StandardError
1325
- {}
1326
- end
1327
-
1328
- # ── EXPLAIN execution ────────────────────────────────────────────
1329
- private_class_method def self.execute_explain(sql, timeout)
249
+ private_class_method def self.explain_response(family, connection, sql, timeout, database)
1330
250
  unless sql.match?(/\A\s*(SELECT|WITH)\b/i)
1331
- return text_response("EXPLAIN only supports SELECT queries.")
1332
- end
1333
-
1334
- conn = ActiveRecord::Base.connection
1335
- adapter = conn.adapter_name.downcase
1336
-
1337
- explain_sql, parser = case adapter
1338
- when POSTGRES_ADAPTER
1339
- [ "EXPLAIN (FORMAT JSON, ANALYZE) #{sql}", :parse_pg_explain ]
1340
- when MYSQL_ADAPTER
1341
- # MySQL 8.3+ and 9.x default explain_format to TREE, which
1342
- # parse_mysql_explain can't read (it expects the classic
1343
- # table/type/key/rows/Extra columns). Force TRADITIONAL explicitly
1344
- # so parsing works regardless of the server's default.
1345
- [ "EXPLAIN FORMAT=TRADITIONAL #{sql}", :parse_mysql_explain ]
1346
- when /sqlite/
1347
- [ "EXPLAIN QUERY PLAN #{sql}", :parse_sqlite_explain ]
1348
- else
1349
- [ "EXPLAIN #{sql}", :parse_generic_explain ]
251
+ return error_response("#{"The statement is already an EXPLAIN. " if sql.match?(/\A\s*EXPLAIN\b/i)}" \
252
+ "`explain: true` takes a SELECT or WITH statement and explains it.")
1350
253
  end
1351
254
 
1352
- result = run_guarded(conn, adapter, explain_sql, timeout)
1353
- parsed = send(parser, result)
255
+ parsed = family.explain(connection, sql, timeout: timeout)
1354
256
 
1355
257
  lines = [ "# EXPLAIN Analysis", "" ]
1356
258
  lines << "**Query:** `#{sql.truncate(120)}`"
1357
259
  lines << ""
1358
260
 
1359
- # Summary
1360
261
  if parsed[:scan_types]&.any?
1361
262
  lines << "## Scan Summary"
1362
263
  parsed[:scan_types].each do |scan|
@@ -1365,130 +266,27 @@ module RailsAiContext
1365
266
  lines << ""
1366
267
  end
1367
268
 
1368
- # Warnings
1369
269
  if parsed[:warnings]&.any?
1370
270
  lines << "## Warnings"
1371
271
  parsed[:warnings].each { |w| lines << "- #{w}" }
1372
272
  lines << ""
1373
273
  end
1374
274
 
1375
- # Raw plan
1376
275
  lines << "## Raw Plan"
1377
276
  lines << "```"
1378
277
  lines << parsed[:raw]
1379
278
  lines << "```"
1380
279
 
1381
- text_response(lines.join("\n") + unbounded_note(result))
280
+ text_response(lines.join("\n") + (parsed[:unbounded] ? UNBOUNDED_SQLITE_NOTE : ""))
1382
281
  rescue ActiveRecord::StatementInvalid => e
1383
- text_response("EXPLAIN failed: #{clean_error_message(e.message)}")
1384
- end
1385
-
1386
- private_class_method def self.parse_sqlite_explain(result)
1387
- scans = []
1388
- warnings = []
1389
- raw_lines = []
1390
-
1391
- result.rows.each do |row|
1392
- # SQLite EXPLAIN QUERY PLAN columns: id, parent, notused, detail
1393
- detail = row.last.to_s
1394
- raw_lines << detail
1395
-
1396
- if detail.match?(/SCAN\b/i)
1397
- table = detail.match(/SCAN\s+(?:TABLE\s+)?(\w+)/i)&.captures&.first
1398
- scan_type = detail.match?(/USING.*INDEX/i) ? "index scan" : "full table scan"
1399
- index = detail.match(/USING.*INDEX\s+(\w+)/i)&.captures&.first
1400
- scans << { table: table, type: scan_type, index: index }
1401
- warnings << "Full table scan on #{table}" if scan_type == "full table scan" && table
1402
- elsif detail.match?(/SEARCH\b/i)
1403
- table = detail.match(/SEARCH\s+(?:TABLE\s+)?(\w+)/i)&.captures&.first
1404
- index = detail.match(/USING.*INDEX\s+(\w+)/i)&.captures&.first
1405
- scans << { table: table, type: "index search", index: index }
1406
- end
1407
- end
282
+ raise if DatabaseFamily.lost_connection?(e)
1408
283
 
1409
- { scan_types: scans, warnings: warnings, raw: raw_lines.join("\n") }
284
+ error_response("EXPLAIN failed: #{clean_error_message(e.message)}#{held_elsewhere(e.message, database)}")
1410
285
  end
1411
286
 
1412
- private_class_method def self.parse_pg_explain(result)
1413
- scans = []
1414
- warnings = []
1415
-
1416
- raw = result.rows.map { |r| r.first.to_s }.join("\n")
1417
-
1418
- # PostgreSQL JSON format returns plan as JSON
1419
- begin
1420
- plan_data = JSON.parse(raw)
1421
- if plan_data.is_a?(Array) && plan_data.first.is_a?(Hash)
1422
- plan = plan_data.first["Plan"]
1423
- extract_pg_nodes(plan, scans, warnings) if plan
1424
- end
1425
- rescue JSON::ParserError
1426
- # Non-JSON EXPLAIN format - fall through to raw output
1427
- end
1428
-
1429
- { scan_types: scans, warnings: warnings, raw: raw }
1430
- end
1431
-
1432
- private_class_method def self.extract_pg_nodes(node, scans, warnings)
1433
- return unless node.is_a?(Hash)
1434
-
1435
- node_type = node["Node Type"].to_s
1436
- table = node["Relation Name"]
1437
- index = node["Index Name"]
1438
- rows = node["Actual Rows"] || node["Plan Rows"]
1439
-
1440
- if node_type.include?("Seq Scan")
1441
- scans << { table: table, type: "sequential scan", index: nil }
1442
- warnings << "Sequential scan on #{table} (#{rows} rows)" if rows.to_i > 1000
1443
- elsif node_type.include?("Index")
1444
- scans << { table: table, type: node_type.downcase, index: index }
1445
- end
1446
-
1447
- (node["Plans"] || []).each { |child| extract_pg_nodes(child, scans, warnings) }
1448
- end
1449
-
1450
- private_class_method def self.parse_mysql_explain(result)
1451
- scans = []
1452
- warnings = []
1453
- raw_lines = []
1454
-
1455
- result.rows.each do |row|
1456
- cols = result.columns.zip(row).to_h
1457
- raw_lines << cols.map { |k, v| "#{k}: #{v}" }.join(", ")
1458
-
1459
- table = cols["table"]
1460
- scan_type = cols["type"]
1461
- key = cols["key"]
1462
- rows = cols["rows"].to_i
1463
- extra = cols["Extra"].to_s
1464
-
1465
- type_label = case scan_type
1466
- when "ALL" then "full table scan"
1467
- when "index" then "full index scan"
1468
- when "range" then "index range scan"
1469
- when "ref", "eq_ref" then "index lookup"
1470
- when "const", "system" then "constant lookup"
1471
- else scan_type.to_s
1472
- end
1473
-
1474
- scans << { table: table, type: type_label, index: key }
1475
- warnings << "Full table scan on #{table} (#{rows} rows)" if scan_type == "ALL" && rows > 1000
1476
- warnings << "Using filesort on #{table}" if extra.include?("filesort")
1477
- warnings << "Using temporary table on #{table}" if extra.include?("temporary")
1478
- end
1479
-
1480
- { scan_types: scans, warnings: warnings, raw: raw_lines.join("\n") }
1481
- end
1482
-
1483
- private_class_method def self.parse_generic_explain(result)
1484
- raw = result.rows.map { |r| r.join(" | ") }.join("\n")
1485
- { scan_types: [], warnings: [], raw: raw }
1486
- end
1487
-
1488
- # ── Row limit enforcement (Layer 3) ─────────────────────────────
1489
- # The query's own limit ends the statement. A LIMIT anywhere else belongs
1490
- # to a subquery or a string literal and is left as written.
1491
- TRAILING_LIMIT = /\bLIMIT\s+(?:(\d+)\s*,\s*)?(\d+)(\s+OFFSET\s+\d+)?\s*;?\s*\z/i
287
+ # The query's own limit ends the statement; a LIMIT anywhere else belongs to a subquery or a literal.
288
+ # LIMIT ALL, LIMIT NULL (PostgreSQL) and a negative limit (SQLite) are no limit at all.
289
+ TRAILING_LIMIT = /\bLIMIT\s+(?:(\d+)\s*,\s*)?(\d+|ALL|NULL|-\s*\d+)(\s+OFFSET\s+\d+)?\s*;?\s*\z/i
1492
290
  TRAILING_FETCH = /\bFETCH\s+(FIRST|NEXT)\s+(\d+)(\s+ROWS?\s+ONLY)\s*;?\s*\z/i
1493
291
 
1494
292
  # The text asks for one row past the cap, so the answer can tell a result
@@ -1500,7 +298,7 @@ module RailsAiContext
1500
298
  fetch = [ limit, HARD_ROW_CAP ].min + 1
1501
299
 
1502
300
  if sql.match?(TRAILING_LIMIT)
1503
- sql.sub(TRAILING_LIMIT) { "LIMIT #{"#{$1}, " if $1}#{[ $2.to_i, fetch ].min}#{$3}" }
301
+ sql.sub(TRAILING_LIMIT) { "LIMIT #{"#{$1}, " if $1}#{$2.match?(/\A\d+\z/) ? [ $2.to_i, fetch ].min : fetch}#{$3}" }
1504
302
  elsif sql.match?(TRAILING_FETCH)
1505
303
  sql.sub(TRAILING_FETCH) { "FETCH #{$1} #{[ $2.to_i, fetch ].min}#{$3}" }
1506
304
  elsif own_limit?(sql)
@@ -1538,24 +336,19 @@ module RailsAiContext
1538
336
  ResultProxy.new(result.columns, result.rows.first(cap), unbounded, true)
1539
337
  end
1540
338
 
1541
- # ── Column redaction (Layer 4) ──────────────────────────────────
1542
- # Redact a column by the one sensitivity rule applied to its OUTPUT name,
1543
- # plus any index flagged by provenance (PostgreSQL maps the output column
1544
- # to its base column, so a sensitive column reaches [FILTERED] whatever
1545
- # name it was given). The name rule keeps the apps-only exemption via
1546
- # sensitive_column?, which subtracts config.query_allowed_columns.
1547
- private_class_method def self.redact_results(result, provenance_indices = [])
339
+ # A column is filtered when its output name or the table column it reads is secret. A source
340
+ # whose column could not be read is filtered when its relation, or any relation, holds a secret.
341
+ private_class_method def self.redact_results(result, secrets, sources)
1548
342
  columns = result.columns
1549
343
  rows = result.rows
344
+ provenance = sources.filter_map do |i, column, relation|
345
+ unread = column.nil? && (relation ? secrets.relation_secret?(relation) : secrets.in_schema.any?)
346
+ i if unread || secrets.secret?(column)
347
+ end
1550
348
 
1551
- # An `encrypts` column is filtered even when the app allows its name:
1552
- # an encrypted-at-rest attribute is never meant to be read back raw.
1553
- encrypted = encrypted_column_set
1554
-
349
+ # SQLite names a second result column of the same name `name:1`.
1555
350
  redacted_indices = columns.each_index.select do |i|
1556
- provenance_indices.include?(i) ||
1557
- encrypted.include?(columns[i].to_s.downcase) ||
1558
- sensitive_column?(columns[i])
351
+ provenance.include?(i) || secrets.secret?(columns[i]) || secrets.secret?(columns[i].to_s.sub(/:\d+\z/, ""))
1559
352
  end
1560
353
 
1561
354
  return result if redacted_indices.empty?
@@ -1580,7 +373,6 @@ module RailsAiContext
1580
373
  ResultProxy.new(result.columns, rows)
1581
374
  end
1582
375
 
1583
- # ── Output formatting ───────────────────────────────────────────
1584
376
  private_class_method def self.format_table(result)
1585
377
  columns = result.columns
1586
378
  rows = result.rows
@@ -1635,14 +427,8 @@ module RailsAiContext
1635
427
  end
1636
428
 
1637
429
  private_class_method def self.format_csv(result)
1638
- columns = result.columns
1639
- rows = result.rows
1640
-
1641
- return "_Query returned 0 rows._" if rows.empty?
1642
-
1643
- lines = []
1644
- lines << columns.map { |col| csv_field(col.to_s) }.join(",")
1645
- rows.each do |row|
430
+ lines = [ result.columns.map { |col| csv_field(col.to_s) }.join(",") ]
431
+ result.rows.each do |row|
1646
432
  lines << row.map { |val| csv_field(csv_cell(val)) }.join(",")
1647
433
  end
1648
434
 
@@ -1685,8 +471,8 @@ module RailsAiContext
1685
471
  end
1686
472
 
1687
473
  private_class_method def self.clean_error_message(message)
1688
- # Remove internal Ruby traces and framework noise
1689
- message.lines.first&.strip || message.strip
474
+ # The first line only. Rails ends it with a colon where the statement follows on the next.
475
+ (message.lines.first || message).strip.delete_suffix(":")
1690
476
  end
1691
477
 
1692
478
  # Quacks like ActiveRecord::Result for redacted output, for a SQLite query