rails-ai-context 5.8.0 → 5.8.1

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: 4b34bd6800329b83e18ec7ee97eceb2d4ccab18019bf0c1f3444485e70273938
4
- data.tar.gz: 548ab9e6f769651b2e06c81c01fa570e033b8a4c42a4400db7e888ff9a1ba377
3
+ metadata.gz: 8b8c8f1c7133f9f97b35975be1ecf1485e6fd663867c7fb6ae776af131f972ec
4
+ data.tar.gz: 2a4e374b3509f1eca37ddeefa730eb8fc181a7710df616c7a0ab389babce7280
5
5
  SHA512:
6
- metadata.gz: 3c55e65978c097c6d7b16c4c5a20c1d36d494e9571183569ef4faa91252aeb97dddedd4e858fc8727d169982861320b1db4b963ceee94a1d1f2a38d7708bd61e
7
- data.tar.gz: e24ececc1491709b0f07eb235e066048d0f751cd18756462cd6e76c8ee090abc30231e02a7211f23248bb9a40f476e8ce882dcdd106b6056aa3f26406c274b22
6
+ metadata.gz: a6c3bed1bd469fbee71f6395ad2f7ac0fc90347fdf2bf8066f36f8396a99a915f5954aa207207cabf9b073c435e2fbe7849692e28ba2c0ab752ef516cc8b32e0
7
+ data.tar.gz: 16791ed1aa9293f5d87d61bd1a63c3fca990cdf25da6f06639ba514912e097f69a177f9e829b14684fbe2242b8ab53f4828f5b2ad7c15f485a1e5c2493152c4b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,70 @@ 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.8.1] — 2026-04-15
9
+
10
+ ### Fixed — Security Hardening
11
+
12
+ Four exploitable vulnerabilities across `rails_query`, the VFS URI dispatcher, and the instrumentation bridge, plus six defense-in-depth hardening issues. All discovered by security and deep code-review passes conducted during v5.8.1 pre-release verification. None were known at the v5.8.0 release — **users should upgrade immediately**.
13
+
14
+ - **SQL column-aliasing redaction bypass (exploitable).** Post-execution redaction in `rails_query` operated on `result.columns` (the DB-returned column names), which the caller controls via aliases and expressions. `SELECT password_digest AS x FROM users` returned raw bcrypt hashes. Same for `SELECT substring(password_digest, 1, 60) FROM users` (column named `substring`), `SELECT md5(session_data) FROM sessions`, `SELECT CASE WHEN id > 0 THEN password_digest END FROM users`, and subqueries that re-project the sensitive column. **Fix:** moved enforcement to pre-execution in `validate_sql`. Any query that textually references a column name in `config.query_redacted_columns` OR the hard-coded `SENSITIVE_COLUMN_SUFFIXES` list (password_digest, encrypted_password, password_hash, reset_password_token, api_key, refresh_token, otp_secret, session_data, secret_key, private_key, etc.) is now rejected. Users with a legitimately non-sensitive column matching one of these names can subtract from `config.query_redacted_columns` in an initializer. **8 bypass scenarios covered by new specs.**
15
+
16
+ - **Arbitrary filesystem read via database functions (exploitable).** `rails_query` did not block PostgreSQL's `pg_read_file`, `pg_read_binary_file`, `pg_ls_dir`, `pg_stat_file`, `lo_import`/`lo_export`, `dblink`, MySQL's `LOAD_FILE`, `SELECT ... INTO OUTFILE/DUMPFILE`, or SQLite's `load_extension`. These are SELECT-callable (so they pass the `BLOCKED_KEYWORDS` scanner and `SET TRANSACTION READ ONLY`) but give the caller a filesystem and shared-library-load primitive — completely bypassing the gem's `sensitive_patterns` allowlist by pivoting through the database process. PoC: `SELECT pg_read_file('/etc/passwd')`, `SELECT pg_read_file('config/master.key')`. **Fix:** added a `BLOCKED_FUNCTIONS` regex and `BLOCKED_OUTPUT` pattern that reject any query referencing these built-ins. **10 function-specific specs added.**
17
+
18
+ - **`sensitive_patterns` default list expanded.** The v5.8.0 default list covered `.env`, `.env.*`, `config/master.key`, `config/credentials*.yml.enc`, `*.pem`, `*.key` but missed common secret locations. v5.8.1 adds `config/database.yml`, `config/secrets.yml`, `config/cable.yml`, `config/storage.yml`, `config/mongoid.yml`, `config/redis.yml`, `*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `**/id_rsa`, `**/id_ed25519`, `**/id_ecdsa`, `**/id_dsa`, `.ssh/*`, `.aws/credentials`, `.aws/config`, `.netrc`, `.pgpass`, `.my.cnf`.
19
+
20
+ - **`get_edit_context` now re-checks `sensitive_file?` after realpath resolution.** The initial check ran on the caller-supplied string; a symlink inside `app/models/` pointing at `config/master.key` previously passed the basename check and fell through to `File.read`. The post-realpath check blocks this.
21
+
22
+ - **`validate` now enforces `sensitive_file?`.** The validate tool had no sensitive-file check at all. Even though its output is limited to error messages (not raw content), it still leaked file existence/size and ran readers on secret files. Now denied with an `access denied (sensitive file)` error.
23
+
24
+ - **`BaseTool.sensitive_file?` has direct spec coverage for the first time.** The security boundary behind every file-accepting tool had zero direct tests in v5.8.0 — 36 new specs added covering the Rails secret locations, the v5.8.1 expanded pattern list, private keys and certificates, case-insensitivity, basename-only matching, and custom pattern configurations.
25
+
26
+ - **VFS `resolve_view` sibling-directory path traversal (exploitable).** The `rails-ai-context://views/{path}` URI resolver used bare `String#start_with?` on the realpath without a `File::SEPARATOR` suffix check. `/app/views_spec/secret.erb` matched `/app/views` as a prefix, so a symlink inside `app/views/` pointing at a sibling directory escaped containment and returned arbitrary file content. **Fix:** changed the containment check to `real == base || real.start_with?(base + File::SEPARATOR)`. Also added a `sensitive_file?` realpath check mirroring the v5.8.1 `get_edit_context` fix, so `.env`/`.key` symlinks inside `app/views/` are rejected. **2 new regression specs covering both PoCs.**
27
+
28
+ - **Instrumentation bridge leaks raw tool arguments to ActiveSupport::Notifications subscribers (exploitable).** `Instrumentation.callback` forwarded the MCP SDK's full data hash to `ActiveSupport::Notifications.instrument`. The SDK's `add_instrumentation_data(tool_name:, tool_arguments:)` includes raw tool inputs — so every Rails observability subscriber (Datadog, Scout, New Relic, custom loggers) received `rails_query`'s raw SQL, `rails_get_env`'s env var names, and `rails_read_logs`'s search patterns unredacted. The response-side redaction each of those tools carefully implements did nothing for the request side. **Fix:** introduced `Instrumentation::SAFE_KEYS` (`method`, `tool_name`, `duration`, `error`, `resource_uri`, `prompt_name`) — only those fields are forwarded. Users who need arguments in observability can set `config.instrumentation_include_arguments = true` in an initializer (taking on the redaction obligation). **3 new regression specs.**
29
+
30
+ - **Instrumentation subscriber failures could crash tool calls (exploitable).** The MCP SDK's `instrument_call` invokes our callback from an `ensure` block. Any exception raised inside the callback (e.g. a custom subscriber bug, a Datadog client losing connection) would propagate out of `ensure` and overwrite the tool's actual return value — effectively failing every tool call whenever any subscriber was broken. **Fix:** wrapped the `Notifications.instrument` call in a `rescue => e` block. Subscriber failures now log to stderr under `DEBUG=1` instead of corrupting tool responses. **1 new regression spec.**
31
+
32
+ - **`analyze_feature` caps per-directory file scans at 500 files.** `discover_services`, `discover_jobs`, and `discover_views` previously ran unbounded `Dir.glob` + `SafeFile.read` on every match, which on large monorepos could read thousands of files per call. Matches the existing cap used by `discover_tests`. Tool output notes when the cap was hit so the AI agent knows to narrow its feature keyword.
33
+
34
+ ### Added — Configuration
35
+
36
+ - **`config.instrumentation_include_arguments`** (default `false`) — controls whether raw tool arguments are forwarded to `ActiveSupport::Notifications` subscribers. See the Security Hardening note above for the opt-in risk.
37
+
38
+ ### Performance — Hot-Path Optimization
39
+
40
+ - **`cached_context` TTL short-circuit.** The hot path of every tool call ran `Fingerprinter.changed?` on every hit, which walks every `*.{rb,rake,js,ts,erb,haml,slim,yml}` file in `WATCHED_DIRS` plus (for path:-installed users) every file in the gem's own lib/ tree — doing an `mtime` stat per file. Measured at ~12ms per call in dev-mode path installs, ~0.5ms in production. Since LiveReload fires `reset_all_caches!` on actual file-change events, stale-cache risk during a short TTL window is already covered. **Fix:** skip the fingerprint check entirely when within the TTL window. When TTL expires, re-fingerprint; if unchanged, bump the timestamp and reuse the cached context (avoiding a 31-introspector re-run).
41
+
42
+ - **Fingerprinter gem-lib scan memoized.** For users who install the gem via `path:` (common for gem contributors, monorepos, the standalone dev workflow), the fingerprinter was walking 123 gem-lib files on every tool call. Memoized at class level with a `reset_gem_lib_fingerprint!` hook that `BaseTool.reset_cache!` and LiveReload invoke.
43
+
44
+ - **Measured result:** `cached_context` hot-path benchmark dropped from **11.77ms to 0.199ms** per call — a **~59x speedup** on dev-mode path installs. In-Gemfile / production users see a smaller but still meaningful improvement (0.77ms → 0.199ms).
45
+
46
+ ### Fixed — schema.rb empty-file wrinkle
47
+
48
+ - `SchemaIntrospector#static_schema_parse` returned `{ error: "No db/schema.rb, db/structure.sql, or migrations found" }` when `db/schema.rb` existed but contained zero `create_table` calls (common on freshly-created Rails apps between `db:create` and the first migration). Now returns `{ total_tables: 0, tables: {}, note: "Schema file exists but is empty — no migrations have been run yet..." }`.
49
+
50
+ ### Changed — CI release matrix synced to PR matrix
51
+
52
+ - `.github/workflows/release.yml` test matrix was still on the old Ruby `3.2/3.3/3.4` × Rails `7.1/7.2/8.0` grid even though `ci.yml` was expanded to cover Ruby 4.0 and Rails 8.1 in v5.8.0. Now synced — release-time testing matches PR-time testing across all 12 combos, including the #69 reporter's environment (Ruby 4.0.2 + Rails 8.1.3).
53
+
54
+ ### Fixed — Pre-release review pass (rounds 2–3)
55
+
56
+ Five additional issues found during multi-round cold-eyes security and correctness review after the initial hardening pass.
57
+
58
+ - **`search_code` sibling-directory path traversal.** `rails_search_code`'s `path` parameter used `real_search.start_with?(real_root)` without a `File::SEPARATOR` suffix — the same bypass class as the original VFS C1 bug. A Rails root of `/app/myapp` would accept a search path whose realpath is `/app/myapp_evil`. **Fix:** changed to `real_search == real_root || real_search.start_with?(real_root + File::SEPARATOR)`. Spec added.
59
+
60
+ - **Instrumentation callback: `data[:method]` extraction outside `begin/rescue`.** Two lines before the `begin` block (`method = data[:method]` and `event_name = ...`) were not covered by the rescue. A non-Hash `data` argument from the MCP SDK would raise `NoMethodError` which would propagate into the SDK's `ensure` context and overwrite the tool's return value. **Fix:** moved `begin` to wrap the full lambda body after the early-exit guard.
61
+
62
+ - **`get_partial_interface` TOCTOU gap (residual from initial hardening).** `resolve_partial_path` performed the `File.realpath` security check internally but returned the original glob `found` path to the caller. The caller then called `File.size(found)` and `safe_read(found)` — creating a sub-millisecond race window where a symlink swap could read from a path that bypassed the check. **Fix:** `resolve_partial_path` now returns `real_found`. All file operations in the caller use the pre-checked realpath.
63
+
64
+ - **`validate` tool passed pre-realpath path to validators.** `validate_ruby`, `validate_erb`, `validate_javascript`, and `check_rails_semantics` all received `full_path` (pre-realpath) after the security check resolved `real`. **Fix:** all four now receive `Pathname.new(real)`.
65
+
66
+ - **`rails_query` `LOAD DATA INFILE` not explicitly blocked.** Added `LOAD\s+DATA` to `BLOCKED_FUNCTIONS`. Belt-and-suspenders: `ALLOWED_PREFIX` already blocks it at statement level, but the explicit entry makes intent self-documenting. Two specs added (`LOAD DATA INFILE` and `LOAD DATA LOCAL INFILE`).
67
+
68
+ ### Test coverage
69
+
70
+ - **2004 examples, 0 failures** (was 1928 in v5.8.0, +76 new regression tests across the security + hardening + empty-schema + VFS + instrumentation + review-pass fixes).
71
+
8
72
  ## [5.8.0] — 2026-04-14
9
73
 
10
74
  ### Added — Modern Rails Coverage Pass
data/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
  <br>
22
22
  [![Ruby](https://img.shields.io/badge/Ruby-3.2%20%7C%203.3%20%7C%203.4-CC342D)](https://github.com/crisnahine/rails-ai-context)
23
23
  [![Rails](https://img.shields.io/badge/Rails-7.1%20%7C%207.2%20%7C%208.0-CC0000)](https://github.com/crisnahine/rails-ai-context)
24
- [![Tests](https://img.shields.io/badge/Tests-1925%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
24
+ [![Tests](https://img.shields.io/badge/Tests-1989%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
25
25
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
26
26
 
27
27
  </div>
@@ -543,7 +543,7 @@ end
543
543
  ## About
544
544
 
545
545
  Built by a Rails developer with 10+ years of production experience.<br>
546
- 1925 tests. 38 tools. 5 resource templates. 31 introspectors. Standalone or in-Gemfile.<br>
546
+ 1989 tests. 38 tools. 5 resource templates. 31 introspectors. Standalone or in-Gemfile.<br>
547
547
  MIT licensed. [Contributions welcome.](CONTRIBUTING.md)
548
548
 
549
549
  <br>
@@ -144,7 +144,7 @@
144
144
  <div class="stat-label">Introspectors</div>
145
145
  </div>
146
146
  <div class="stat">
147
- <div class="stat-number">1925</div>
147
+ <div class="stat-number">1989</div>
148
148
  <div class="stat-label">Tests</div>
149
149
  </div>
150
150
  <div class="stat">
@@ -24,6 +24,7 @@ module RailsAiContext
24
24
  query_timeout query_row_limit query_redacted_columns allow_query_in_production
25
25
  log_lines introspectors
26
26
  hydration_enabled hydration_max_hints
27
+ instrumentation_include_arguments
27
28
  ].freeze
28
29
 
29
30
  # Load configuration from a YAML file, applying values to the current config instance.
@@ -131,6 +132,19 @@ module RailsAiContext
131
132
  # Debounce interval in seconds for live reload file watching
132
133
  attr_accessor :live_reload_debounce
133
134
 
135
+ # Whether to include raw tool arguments in ActiveSupport::Notifications
136
+ # instrumentation events. Default: false (v5.8.1+). When false, only
137
+ # metadata (method, tool_name, duration, error) is forwarded to
138
+ # subscribers. When true, `tool_arguments` and `arguments` are forwarded
139
+ # verbatim — including raw SQL from `rails_query`, env var names from
140
+ # `rails_get_env`, and log search patterns from `rails_read_logs`.
141
+ #
142
+ # Setting this to true means the operator takes on the redaction
143
+ # obligation for any downstream observability pipeline (Datadog, Scout,
144
+ # custom loggers) that receives these events. See CHANGELOG.md for v5.8.1
145
+ # for the security review that changed the default.
146
+ attr_accessor :instrumentation_include_arguments
147
+
134
148
  # Whether to generate root-level context files (CLAUDE.md, AGENTS.md, etc.)
135
149
  # When false, only generates split rule files (.claude/rules/, .cursor/rules/, etc.)
136
150
  attr_accessor :generate_root_files
@@ -229,8 +243,15 @@ module RailsAiContext
229
243
  @introspectors = PRESETS[:full].dup
230
244
  @excluded_paths = %w[node_modules tmp log vendor .git doc docs]
231
245
  @sensitive_patterns = %w[
232
- .env .env.* config/master.key config/credentials.yml.enc
233
- config/credentials/*.yml.enc *.pem *.key
246
+ .env .env.*
247
+ config/master.key
248
+ config/credentials.yml.enc config/credentials/*.yml.enc
249
+ config/database.yml config/secrets.yml
250
+ config/cable.yml config/storage.yml
251
+ config/mongoid.yml config/redis.yml
252
+ *.pem *.key *.p12 *.pfx *.jks *.keystore
253
+ **/id_rsa **/id_ed25519 **/id_ecdsa **/id_dsa
254
+ .ssh/* .aws/credentials .aws/config .netrc .pgpass .my.cnf
234
255
  ]
235
256
  @auto_mount = false
236
257
  @http_path = "/mcp"
@@ -249,6 +270,7 @@ module RailsAiContext
249
270
  @max_tool_response_chars = 200_000
250
271
  @live_reload = :auto
251
272
  @live_reload_debounce = 1.5
273
+ @instrumentation_include_arguments = false
252
274
  @generate_root_files = true
253
275
  @anti_hallucination_rules = true
254
276
  @max_file_size = 5_000_000
@@ -39,13 +39,13 @@ module RailsAiContext
39
39
  # Include the gem's own version so cache invalidates during gem development
40
40
  digest.update(RailsAiContext::VERSION)
41
41
 
42
- # Include gem lib directory mtime when using a local/path gem (development mode)
43
- gem_lib = File.expand_path("../../..", __FILE__)
44
- if gem_lib.start_with?(root) || (defined?(Bundler) && local_gem_path?)
45
- Dir.glob(File.join(gem_lib, "**/*.rb")).sort.each do |path|
46
- digest.update(File.mtime(path).to_f.to_s)
47
- end
48
- end
42
+ # Include gem lib directory fingerprint when using a local/path gem.
43
+ # MEMOIZED the gem lib contents don't change within a single process
44
+ # lifetime unless a developer is actively editing the gem source (rare
45
+ # audience, they should restart the server to see changes). Previously
46
+ # this walked 123 gem files on every tool call, adding ~12ms to the
47
+ # cached_context hot path for path:-installed users.
48
+ digest.update(gem_lib_fingerprint(root))
49
49
 
50
50
  WATCHED_FILES.each do |file|
51
51
  path = File.join(root, file)
@@ -68,8 +68,35 @@ module RailsAiContext
68
68
  digest.hexdigest
69
69
  end
70
70
 
71
+ # Clear the memoized gem-lib fingerprint. Called by BaseTool.reset_cache!
72
+ # and LiveReload so active gem development gets a fresh scan on next call
73
+ # without requiring a process restart.
74
+ def reset_gem_lib_fingerprint!
75
+ @gem_lib_fingerprint = nil
76
+ end
77
+
71
78
  private
72
79
 
80
+ # Memoized gem-lib fingerprint. Computed ONCE per process lifetime
81
+ # (or per reset_gem_lib_fingerprint! call) instead of on every
82
+ # tool invocation.
83
+ def gem_lib_fingerprint(root)
84
+ @gem_lib_fingerprint ||= compute_gem_lib_fingerprint(root)
85
+ end
86
+
87
+ def compute_gem_lib_fingerprint(root)
88
+ gem_lib = File.expand_path("../../..", __FILE__)
89
+ return "" unless gem_lib.start_with?(root) || (defined?(Bundler) && local_gem_path?)
90
+
91
+ sub = Digest::SHA256.new
92
+ Dir.glob(File.join(gem_lib, "**/*.rb")).sort.each do |path|
93
+ sub.update(File.mtime(path).to_f.to_s)
94
+ rescue Errno::ENOENT
95
+ # File deleted between glob and mtime read — skip
96
+ end
97
+ sub.hexdigest
98
+ end
99
+
73
100
  # Detect if this gem is loaded via a local path (path: in Gemfile)
74
101
  def local_gem_path?
75
102
  spec = Bundler.rubygems.find_name("rails-ai-context").first
@@ -6,17 +6,60 @@ module RailsAiContext
6
6
  module Instrumentation
7
7
  EVENT_PREFIX = "rails_ai_context"
8
8
 
9
+ # Metadata-only fields forwarded to ActiveSupport::Notifications. We
10
+ # deliberately exclude `tool_arguments`, `params`, `arguments`, and
11
+ # `request` because the MCP SDK includes raw tool inputs in those keys —
12
+ # e.g. `rails_query(sql: "SELECT password_digest...")`, `rails_get_env(name: "SECRET_KEY_BASE")`,
13
+ # `rails_read_logs(search: "api_key=xyz")`. Forwarding them unredacted
14
+ # would leak the request-side data that each tool's response-side
15
+ # redaction was specifically designed to protect. Fixed in v5.8.1.
16
+ #
17
+ # Users who legitimately need tool arguments in observability can set
18
+ # config.instrumentation_include_arguments = true in an initializer
19
+ # (see CONFIGURATION.md for the redaction obligation that comes with it).
20
+ SAFE_KEYS = %i[method tool_name duration error resource_uri prompt_name].freeze
21
+
9
22
  # Returns a lambda for MCP::Configuration#instrumentation_callback.
10
23
  # Instruments each MCP method call as an ActiveSupport::Notifications event.
11
24
  def self.callback
12
25
  ->(data) {
13
26
  return unless defined?(ActiveSupport::Notifications)
14
27
 
15
- method = data[:method] || "unknown"
16
- event_name = "#{EVENT_PREFIX}.#{method.tr("/", ".")}"
28
+ begin
29
+ method = data[:method] || "unknown"
30
+ event_name = "#{EVENT_PREFIX}.#{method.to_s.tr("/", ".")}"
17
31
 
18
- ActiveSupport::Notifications.instrument(event_name, data)
32
+ # build_payload reads configuration — wrap it in the rescue so a
33
+ # broken/nil configuration doesn't propagate out of the lambda and
34
+ # crash the MCP SDK's ensure block. v5.8.1-r3 hardening.
35
+ payload = build_payload(data)
36
+ ActiveSupport::Notifications.instrument(event_name, payload)
37
+ rescue => e
38
+ # The MCP SDK's instrument_call invokes this callback from an `ensure`
39
+ # block, which means any exception raised here would overwrite the
40
+ # tool's actual return value with the subscriber's error — effectively
41
+ # crashing every tool call whenever a single subscriber is broken.
42
+ # Swallow the error and log to stderr instead. Fixed in v5.8.1.
43
+ $stderr.puts "[rails-ai-context] instrumentation subscriber failed: #{e.message}" if ENV["DEBUG"]
44
+ end
19
45
  }
20
46
  end
47
+
48
+ # Build a safe payload from the raw MCP SDK data hash. Strips tool
49
+ # arguments unless the user has explicitly opted in.
50
+ def self.build_payload(data)
51
+ payload = SAFE_KEYS.each_with_object({}) do |key, acc|
52
+ acc[key] = data[key] if data.key?(key)
53
+ end
54
+
55
+ if RailsAiContext.configuration.instrumentation_include_arguments
56
+ # User opted in: include arguments verbatim. They take on the
57
+ # redaction obligation for anything downstream consumers log.
58
+ payload[:tool_arguments] = data[:tool_arguments] if data.key?(:tool_arguments)
59
+ payload[:arguments] = data[:arguments] if data.key?(:arguments)
60
+ end
61
+
62
+ payload
63
+ end
21
64
  end
22
65
  end
@@ -174,7 +174,9 @@ module RailsAiContext
174
174
  # Tries db/schema.rb first, then db/structure.sql, then migrations.
175
175
  # This enables introspection in CI, Claude Code, etc.
176
176
  def static_schema_parse
177
- if File.exist?(schema_file_path)
177
+ schema_rb_exists = File.exist?(schema_file_path)
178
+
179
+ if schema_rb_exists
178
180
  result = parse_schema_rb(schema_file_path)
179
181
  return result if result[:total_tables].to_i > 0
180
182
  end
@@ -188,6 +190,18 @@ module RailsAiContext
188
190
  return parse_migrations
189
191
  end
190
192
 
193
+ # schema.rb exists but has no tables — happens on fresh Rails apps right
194
+ # after `db:create` where no migrations have been run yet. Return a
195
+ # legitimate empty-schema state instead of a misleading "not found" error.
196
+ if schema_rb_exists
197
+ return {
198
+ total_tables: 0,
199
+ tables: {},
200
+ note: "Schema file exists but is empty — no migrations have been run yet. " \
201
+ "Run `bin/rails db:migrate` after generating migrations to populate schema.rb."
202
+ }
203
+ end
204
+
191
205
  { error: "No db/schema.rb, db/structure.sql, or migrations found" }
192
206
  end
193
207
 
@@ -23,6 +23,14 @@ module RailsAiContext
23
23
 
24
24
  # Map well-known feature keywords to gem-based patterns
25
25
  AUTH_KEYWORDS = %w[auth authentication login signup signin session devise omniauth].freeze
26
+
27
+ # Cap per-directory file scans to avoid unbounded work on large monorepos.
28
+ # Note: Dir.glob materialises the full path array before .first() truncates —
29
+ # the directory walk itself is unbounded; only per-file reading and processing
30
+ # is capped at MAX_SCAN_FILES. Applied to every discover_* method that walks
31
+ # the filesystem. Tool output notes when the cap is hit so the AI agent knows
32
+ # to narrow its feature keyword. v5.8.1 hardening; all glob sites in r2.
33
+ MAX_SCAN_FILES = 500
26
34
  AUTH_GEM_NAMES = %w[devise omniauth rodauth sorcery clearance authlogic warden jwt].freeze
27
35
 
28
36
  def self.call(feature:, server_context: nil) # rubocop:disable Metrics
@@ -192,13 +200,17 @@ module RailsAiContext
192
200
  dir = File.join(root, "app", "services")
193
201
  return unless Dir.exist?(dir)
194
202
 
195
- found = Dir.glob(File.join(dir, "**", "*.rb")).select do |path|
196
- File.basename(path, ".rb").include?(pattern) ||
197
- (File.size(path) < 50_000 && (RailsAiContext::SafeFile.read(path) || "").downcase.include?(pattern))
203
+ candidates = Dir.glob(File.join(dir, "**", "*.rb")).first(MAX_SCAN_FILES)
204
+ # Prefer basename match (fast, no file read) and only fall back to
205
+ # a content scan for files whose basename doesn't match. This
206
+ # avoids reading every service file's full contents on large apps.
207
+ found = candidates.select do |path|
208
+ next true if File.basename(path, ".rb").include?(pattern)
209
+ File.size(path) < 50_000 && (RailsAiContext::SafeFile.read(path) || "").downcase.include?(pattern)
198
210
  end
199
211
  return if found.empty?
200
212
 
201
- lines << "## Services (#{found.size})"
213
+ lines << "## Services (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " — first #{MAX_SCAN_FILES} scanned" : ""})"
202
214
  found.each do |path|
203
215
  relative = path.sub("#{root}/", "")
204
216
  source = RailsAiContext::SafeFile.read(path) or next
@@ -218,12 +230,13 @@ module RailsAiContext
218
230
  dir = File.join(root, "app", "jobs")
219
231
  return unless Dir.exist?(dir)
220
232
 
221
- found = Dir.glob(File.join(dir, "**", "*.rb")).select do |path|
233
+ candidates = Dir.glob(File.join(dir, "**", "*.rb")).first(MAX_SCAN_FILES)
234
+ found = candidates.select do |path|
222
235
  File.basename(path, ".rb").include?(pattern)
223
236
  end
224
237
  return if found.empty?
225
238
 
226
- lines << "## Jobs (#{found.size})"
239
+ lines << "## Jobs (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " — first #{MAX_SCAN_FILES} scanned" : ""})"
227
240
  found.each do |path|
228
241
  relative = path.sub("#{root}/", "")
229
242
  source = RailsAiContext::SafeFile.read(path) or next
@@ -242,12 +255,13 @@ module RailsAiContext
242
255
  views_dir = File.join(root, "app", "views")
243
256
  return unless Dir.exist?(views_dir)
244
257
 
245
- found = Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).select do |path|
258
+ candidates = Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).first(MAX_SCAN_FILES)
259
+ found = candidates.select do |path|
246
260
  path.sub("#{views_dir}/", "").downcase.include?(pattern)
247
261
  end
248
262
  return if found.empty?
249
263
 
250
- lines << "## Views (#{found.size})"
264
+ lines << "## Views (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " — first #{MAX_SCAN_FILES} scanned" : ""})"
251
265
  found.each do |path|
252
266
  relative = path.sub("#{views_dir}/", "")
253
267
  source = RailsAiContext::SafeFile.read(path) or next
@@ -301,20 +315,26 @@ module RailsAiContext
301
315
  def discover_tests(root, pattern, lines)
302
316
  test_dirs = [ File.join(root, "spec"), File.join(root, "test") ]
303
317
  found = []
318
+ truncated = false
304
319
 
305
320
  test_dirs.each do |dir|
306
321
  next unless Dir.exist?(dir)
307
- Dir.glob(File.join(dir, "**", "*_{test,spec}.rb")).each do |path|
322
+ suffix_glob = Dir.glob(File.join(dir, "**", "*_{test,spec}.rb")).first(MAX_SCAN_FILES)
323
+ truncated = true if suffix_glob.size == MAX_SCAN_FILES
324
+ suffix_glob.each do |path|
308
325
  found << path if File.basename(path, ".rb").include?(pattern)
309
326
  end
310
- Dir.glob(File.join(dir, "**", "{test,spec}_*.rb")).each do |path|
327
+ prefix_glob = Dir.glob(File.join(dir, "**", "{test,spec}_*.rb")).first(MAX_SCAN_FILES)
328
+ truncated = true if prefix_glob.size == MAX_SCAN_FILES
329
+ prefix_glob.each do |path|
311
330
  found << path if File.basename(path, ".rb").include?(pattern)
312
331
  end
313
332
  end
314
333
  found.uniq!
315
334
  return found if found.empty?
316
335
 
317
- lines << "## Tests (#{found.size})"
336
+ header = "## Tests (#{found.size}#{truncated ? " — first #{MAX_SCAN_FILES} per glob scanned" : ""})"
337
+ lines << header
318
338
  found.each do |path|
319
339
  relative = path.sub("#{root}/", "")
320
340
  source = RailsAiContext::SafeFile.read(path) or next
@@ -354,7 +374,7 @@ module RailsAiContext
354
374
  # Check jobs
355
375
  job_dir = File.join(root, "app", "jobs")
356
376
  if Dir.exist?(job_dir)
357
- Dir.glob(File.join(job_dir, "**", "*.rb")).each do |path|
377
+ Dir.glob(File.join(job_dir, "**", "*.rb")).first(MAX_SCAN_FILES).each do |path|
358
378
  next unless File.basename(path, ".rb").include?(pattern)
359
379
  snake = File.basename(path, ".rb")
360
380
  unless test_basenames.any? { |t| t.include?(snake) }
@@ -366,7 +386,7 @@ module RailsAiContext
366
386
  # Check services
367
387
  service_dir = File.join(root, "app", "services")
368
388
  if Dir.exist?(service_dir)
369
- Dir.glob(File.join(service_dir, "**", "*.rb")).each do |path|
389
+ Dir.glob(File.join(service_dir, "**", "*.rb")).first(MAX_SCAN_FILES).each do |path|
370
390
  next unless File.basename(path, ".rb").include?(pattern)
371
391
  snake = File.basename(path, ".rb")
372
392
  unless test_basenames.any? { |t| t.include?(snake) }
@@ -478,10 +498,11 @@ module RailsAiContext
478
498
  dir = File.join(root, "app", "channels")
479
499
  return unless Dir.exist?(dir)
480
500
 
481
- found = Dir.glob(File.join(dir, "**", "*.rb")).select { |p| File.basename(p, ".rb").include?(pattern) }
501
+ candidates = Dir.glob(File.join(dir, "**", "*.rb")).first(MAX_SCAN_FILES)
502
+ found = candidates.select { |p| File.basename(p, ".rb").include?(pattern) }
482
503
  return if found.empty?
483
504
 
484
- lines << "## Channels (#{found.size})"
505
+ lines << "## Channels (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " — first #{MAX_SCAN_FILES} scanned" : ""})"
485
506
  found.each do |path|
486
507
  relative = path.sub("#{root}/", "")
487
508
  lines << "- `#{relative}`"
@@ -497,10 +518,11 @@ module RailsAiContext
497
518
  dir = File.join(root, "app", "mailers")
498
519
  return unless Dir.exist?(dir)
499
520
 
500
- found = Dir.glob(File.join(dir, "**", "*.rb")).select { |p| File.basename(p, ".rb").include?(pattern) }
521
+ candidates = Dir.glob(File.join(dir, "**", "*.rb")).first(MAX_SCAN_FILES)
522
+ found = candidates.select { |p| File.basename(p, ".rb").include?(pattern) }
501
523
  return if found.empty?
502
524
 
503
- lines << "## Mailers (#{found.size})"
525
+ lines << "## Mailers (#{found.size}#{candidates.size == MAX_SCAN_FILES ? " — first #{MAX_SCAN_FILES} scanned" : ""})"
504
526
  found.each do |path|
505
527
  relative = path.sub("#{root}/", "")
506
528
  source = RailsAiContext::SafeFile.read(path) or next
@@ -543,9 +565,12 @@ module RailsAiContext
543
565
  # Scan services, jobs, and model files for ENV references
544
566
  dirs = %w[app/services app/jobs].map { |d| File.join(root, d) }.select { |d| Dir.exist?(d) }
545
567
  env_vars = Set.new
568
+ truncated = false
546
569
 
547
570
  dirs.each do |dir|
548
- Dir.glob(File.join(dir, "**", "*.rb")).each do |path|
571
+ candidates = Dir.glob(File.join(dir, "**", "*.rb")).first(MAX_SCAN_FILES)
572
+ truncated = true if candidates.size == MAX_SCAN_FILES
573
+ candidates.each do |path|
549
574
  next unless File.basename(path, ".rb").include?(pattern) || path.downcase.include?(pattern)
550
575
  source = RailsAiContext::SafeFile.read(path) or next
551
576
  source.scan(/ENV\[["']([^"']+)["']\]|ENV\.fetch\(["']([^"']+)["']\)/).each do |m|
@@ -555,7 +580,8 @@ module RailsAiContext
555
580
  end
556
581
  return if env_vars.empty?
557
582
 
558
- lines << "## Environment Dependencies"
583
+ header = "## Environment Dependencies#{truncated ? " (first #{MAX_SCAN_FILES} per dir scanned)" : ""}"
584
+ lines << header
559
585
  env_vars.sort.each { |v| lines << "- `#{v}`" }
560
586
  lines << ""
561
587
  rescue => e
@@ -108,7 +108,22 @@ module RailsAiContext
108
108
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
109
  ttl = RailsAiContext.configuration.cache_ttl
110
110
 
111
- if SHARED_CACHE[:context] && (now - SHARED_CACHE[:timestamp]) < ttl && !Fingerprinter.changed?(rails_app, SHARED_CACHE[:fingerprint])
111
+ # Fast path: within TTL window, trust the cache and skip the
112
+ # fingerprint walk entirely. Fingerprinter stats every *.rb file
113
+ # in WATCHED_DIRS (plus, in dev-mode path: installs, every file
114
+ # in the gem's own lib/ tree) — measured at ~12ms per call in
115
+ # dev mode, ~0.5ms in production. Since LiveReload fires
116
+ # reset_all_caches! on actual file-change events, stale-cache
117
+ # risk during a short TTL window is already covered.
118
+ if SHARED_CACHE[:context] && (now - SHARED_CACHE[:timestamp]) < ttl
119
+ return SHARED_CACHE[:context].deep_dup
120
+ end
121
+
122
+ # TTL expired: re-validate via fingerprint before re-introspecting.
123
+ # If fingerprint is unchanged, bump the timestamp and reuse the
124
+ # cached context — saves re-running all 31 introspectors.
125
+ if SHARED_CACHE[:context] && !Fingerprinter.changed?(rails_app, SHARED_CACHE[:fingerprint])
126
+ SHARED_CACHE[:timestamp] = now
112
127
  return SHARED_CACHE[:context].deep_dup
113
128
  end
114
129
 
@@ -125,6 +140,10 @@ module RailsAiContext
125
140
  SHARED_CACHE.delete(:timestamp)
126
141
  SHARED_CACHE.delete(:fingerprint)
127
142
  end
143
+ # Also invalidate the memoized gem-lib fingerprint so active gem
144
+ # development sees a fresh scan on next call without a process
145
+ # restart. No-op for production installs.
146
+ Fingerprinter.reset_gem_lib_fingerprint!
128
147
  end
129
148
 
130
149
  # Reset the shared cache. Used by LiveReload to invalidate on file change.
@@ -59,17 +59,32 @@ module RailsAiContext
59
59
  return text_response("File not found: #{file}.#{hint}")
60
60
  end
61
61
  begin
62
- unless File.realpath(full_path).start_with?(File.realpath(Rails.root))
62
+ real = File.realpath(full_path).to_s
63
+ rails_root_real = File.realpath(Rails.root).to_s
64
+ # Separator-aware containment — matches the v5.8.1-r2 hardening in
65
+ # get_view.rb / vfs.rb. Without `+ File::SEPARATOR`, a sibling-dir
66
+ # like `/app/rails_evil/...` would prefix-match a Rails root at
67
+ # `/app/rails`. Same bug class as the original C1.
68
+ unless real == rails_root_real || real.start_with?(rails_root_real + File::SEPARATOR)
63
69
  return text_response("Path not allowed: #{file}")
64
70
  end
71
+ # Re-run the sensitive_file? check on the realpath. Defense against
72
+ # symlinks that point at sensitive files from a non-sensitive path
73
+ # (e.g. app/models/notes.rb -> ../../config/master.key). The initial
74
+ # check above runs on the caller-supplied string, not the resolved
75
+ # target. See v5.8.1 security review.
76
+ relative_real = real.sub("#{rails_root_real}/", "")
77
+ if sensitive_file?(relative_real)
78
+ return text_response("Access denied: #{file} resolves to a sensitive file (secrets/keys/credentials).")
79
+ end
65
80
  rescue Errno::ENOENT
66
81
  return text_response("File not found: #{file}")
67
82
  end
68
- if File.size(full_path) > max_file_size
83
+ if File.size(real) > max_file_size
69
84
  return text_response("File too large: #{file}")
70
85
  end
71
86
 
72
- source_lines = (RailsAiContext::SafeFile.read(full_path) || "").lines
87
+ source_lines = (RailsAiContext::SafeFile.read(real) || "").lines
73
88
  context_lines = [ context_lines.to_i, 0 ].max
74
89
 
75
90
  # Find all matching lines
@@ -52,6 +52,11 @@ module RailsAiContext
52
52
  recovery_tool: "Call rails_get_view(detail:\"summary\") to see all views and partials")
53
53
  end
54
54
 
55
+ # Derive display-string bases from the realpath that resolve_partial_path
56
+ # already computed internally — keeps all path operations on realpaths.
57
+ real_root = File.realpath(root)
58
+ real_views_dir = File.realpath(views_dir)
59
+
55
60
  if File.size(file_path) > max_file_size
56
61
  return text_response("Partial file too large: #{file_path} (#{File.size(file_path)} bytes, max: #{max_file_size})")
57
62
  end
@@ -59,8 +64,8 @@ module RailsAiContext
59
64
  source = safe_read(file_path)
60
65
  return text_response("Could not read partial file.") unless source
61
66
 
62
- relative_path = file_path.sub("#{root}/", "")
63
- partial_name = file_path.sub("#{views_dir}/", "")
67
+ relative_path = file_path.sub("#{real_root}/", "")
68
+ partial_name = file_path.sub("#{real_views_dir}/", "")
64
69
 
65
70
  # Parse the partial's interface
66
71
  magic_locals = extract_magic_comment_locals(source)
@@ -245,16 +250,22 @@ module RailsAiContext
245
250
 
246
251
  return nil unless found
247
252
 
248
- # Path traversal protection
253
+ # Path traversal protection: separator-aware containment + post-realpath
254
+ # sensitive recheck. Returns real_found (the resolved realpath) so the
255
+ # caller reads from the same path that was security-checked — TOCTOU closed.
249
256
  begin
250
- unless File.realpath(found).start_with?(File.realpath(views_dir))
257
+ real_found = File.realpath(found).to_s
258
+ real_base = File.realpath(views_dir).to_s
259
+ unless real_found == real_base || real_found.start_with?(real_base + File::SEPARATOR)
251
260
  return nil
252
261
  end
262
+ relative_real = real_found.sub("#{real_base}/", "")
263
+ return nil if sensitive_file?(relative_real) || sensitive_file?(partial)
253
264
  rescue Errno::ENOENT
254
265
  return nil
255
266
  end
256
267
 
257
- found
268
+ real_found
258
269
  end
259
270
 
260
271
  # Extract locals declared via Rails 7.1+ magic comment: <%# locals: (name:, title: "default") %>
@@ -229,28 +229,47 @@ module RailsAiContext
229
229
  return text_response("Path not allowed: #{path}")
230
230
  end
231
231
 
232
+ # Block sensitive files on the caller-supplied string before any
233
+ # filesystem stat — closes the existence-oracle side channel.
234
+ if sensitive_file?(path)
235
+ return text_response("Access denied: #{path} is a sensitive file (secrets/keys/credentials).")
236
+ end
237
+
232
238
  views_dir = Rails.root.join("app", "views")
233
239
  full_path = views_dir.join(path)
234
240
 
235
- # Path traversal protection (resolves symlinks)
236
241
  unless File.exist?(full_path)
237
242
  dir = File.dirname(path)
238
243
  siblings = Dir.glob(File.join(views_dir, dir, "*")).map { |f| "#{dir}/#{File.basename(f)}" }.sort.first(10)
239
244
  hint = siblings.any? ? " Files in #{dir}/: #{siblings.join(', ')}" : ""
240
245
  return text_response("View not found: #{path}.#{hint}")
241
246
  end
247
+ # Containment check with separator + post-realpath sensitive recheck.
248
+ # Mirrors the v5.8.1 fix in vfs.rb / get_edit_context.rb. Without
249
+ # `File::SEPARATOR`, `start_with?` matches sibling directories like
250
+ # `app/views_backup/secret` against `app/views`. Without the
251
+ # post-realpath sensitive recheck, a symlink at
252
+ # `app/views/leak.key → ../../config/master.key` would slip through
253
+ # and read the secret.
254
+ real = nil
242
255
  begin
243
- unless File.realpath(full_path).start_with?(File.realpath(views_dir))
256
+ real = File.realpath(full_path).to_s
257
+ real_base = File.realpath(views_dir).to_s
258
+ unless real == real_base || real.start_with?(real_base + File::SEPARATOR)
244
259
  return text_response("Path not allowed: #{path}")
245
260
  end
261
+ relative_real = real.sub("#{real_base}/", "")
262
+ if sensitive_file?(relative_real)
263
+ return text_response("Access denied: #{path} resolves to a sensitive file (secrets/keys/credentials).")
264
+ end
246
265
  rescue Errno::ENOENT
247
266
  return text_response("View not found: #{path}")
248
267
  end
249
- if File.size(full_path) > max_file_size
250
- return text_response("File too large: #{path} (#{File.size(full_path)} bytes, max: #{max_file_size})")
268
+ if File.size(real) > max_file_size
269
+ return text_response("File too large: #{path} (#{File.size(real)} bytes, max: #{max_file_size})")
251
270
  end
252
271
 
253
- content = RailsAiContext::SafeFile.read(full_path)
272
+ content = RailsAiContext::SafeFile.read(real)
254
273
  return text_response("Could not read file: #{path}") unless content
255
274
  content = compress_tailwind(strip_svg(content))
256
275
  text_response("# #{path}\n\n```erb\n#{content}\n```")
@@ -48,6 +48,54 @@ module RailsAiContext
48
48
  MULTI_STATEMENT = /;\s*\S/
49
49
  ALLOWED_PREFIX = /\A\s*(SELECT|WITH|SHOW|EXPLAIN|DESCRIBE|DESC)\b/i
50
50
 
51
+ # SELECT-callable functions that give the caller a filesystem / network
52
+ # exfiltration primitive even though the query is technically a SELECT.
53
+ # These pass `SET TRANSACTION READ ONLY` because they're reads from the
54
+ # DB engine's perspective, but they bypass the gem's `sensitive_patterns`
55
+ # file allowlist entirely by pivoting through the database process.
56
+ #
57
+ # Postgres: pg_read_file / pg_read_binary_file / pg_ls_dir / pg_stat_file
58
+ # (file read), lo_import / lo_export (large-object I/O),
59
+ # dblink* (cross-db exfiltration), COPY ... FROM/TO PROGRAM
60
+ # (arbitrary command execution when COPY permissions permit).
61
+ # MySQL: LOAD_FILE (scalar file read outside SELECT INTO contexts).
62
+ # SQLite: load_extension (shared-library load — disabled by default
63
+ # but harden in defense).
64
+ BLOCKED_FUNCTIONS = /\b(
65
+ pg_read_binary_file | pg_read_file |
66
+ pg_ls_dir | pg_ls_logdir | pg_ls_tmpdir | pg_ls_waldir | pg_ls_archive_statusdir |
67
+ pg_stat_file | pg_file_settings | pg_current_logfile |
68
+ lo_import | lo_export |
69
+ dblink[a-z_]* |
70
+ LOAD\s+DATA |
71
+ load_file | load_extension
72
+ )\b/ix
73
+
74
+ # MySQL `SELECT ... INTO OUTFILE 'path'` / `INTO DUMPFILE 'path'`
75
+ # are caught by SELECT_INTO already, but make an explicit pattern so
76
+ # the error message is accurate.
77
+ BLOCKED_OUTPUT = /\bINTO\s+(OUTFILE|DUMPFILE)\b/i
78
+
79
+ # Defense against the column-aliasing redaction bypass:
80
+ #
81
+ # SELECT password_digest AS x FROM users -- bypasses result.columns redaction
82
+ # SELECT substring(password_digest, 1, 60) ... -- column name becomes "substring"
83
+ # SELECT md5(session_data) FROM sessions -- column name becomes "md5"
84
+ #
85
+ # Post-execution redaction operates on the column names the DB returns,
86
+ # which the caller controls via aliases and expressions. The only
87
+ # defense that works is to reject queries that TEXTUALLY reference
88
+ # any sensitive column, before execution. Users who need to query
89
+ # non-sensitive columns with a similar name can subtract from
90
+ # `config.query_redacted_columns` in an initializer.
91
+ SENSITIVE_COLUMN_SUFFIXES = %w[
92
+ password_digest password_hash encrypted_password
93
+ password_reset_token confirmation_token unlock_token
94
+ remember_token reset_password_token api_key api_secret
95
+ access_token refresh_token jti otp_secret session_data
96
+ secret_key secret private_key
97
+ ].freeze
98
+
51
99
  # SQL injection tautology patterns: OR 1=1, OR true, OR ''='', UNION SELECT, etc.
52
100
  TAUTOLOGY_PATTERNS = [
53
101
  /\bOR\s+1\s*=\s*1\b/i,
@@ -148,6 +196,14 @@ module RailsAiContext
148
196
  return [ false, "Blocked: FOR UPDATE/SHARE clause" ] if cleaned.match?(BLOCKED_CLAUSES)
149
197
  return [ false, "Blocked: sensitive SHOW command" ] if cleaned.match?(BLOCKED_SHOWS)
150
198
  return [ false, "Blocked: SELECT INTO creates a table" ] if cleaned.match?(SELECT_INTO)
199
+ return [ false, "Blocked: SELECT INTO OUTFILE / DUMPFILE writes to disk" ] if cleaned.match?(BLOCKED_OUTPUT)
200
+
201
+ # Block database functions that give a filesystem/network primitive —
202
+ # pg_read_file, lo_import, dblink, LOAD_FILE, load_extension, etc.
203
+ # These pass SET TRANSACTION READ ONLY but bypass sensitive_patterns.
204
+ if (m = cleaned.match(BLOCKED_FUNCTIONS))
205
+ return [ false, "Blocked: dangerous function #{m[0]} (filesystem/network primitive)" ]
206
+ end
151
207
 
152
208
  # Check for SQL injection tautology patterns (OR 1=1, UNION SELECT, etc.)
153
209
  tautology = TAUTOLOGY_PATTERNS.find { |p| cleaned.match?(p) }
@@ -162,9 +218,39 @@ module RailsAiContext
162
218
 
163
219
  return [ false, "Only SELECT, WITH, SHOW, EXPLAIN, DESCRIBE allowed" ] unless cleaned.match?(ALLOWED_PREFIX)
164
220
 
221
+ # Column-aliasing redaction bypass defense: reject any query that
222
+ # textually references a sensitive column name. See the comment on
223
+ # SENSITIVE_COLUMN_SUFFIXES above — post-execution redaction cannot
224
+ # survive `SELECT password_digest AS x`.
225
+ if (offending = references_sensitive_column?(cleaned))
226
+ return [ false,
227
+ "Blocked: query references sensitive column `#{offending}`. " \
228
+ "Post-execution redaction cannot survive aliases / expressions, so " \
229
+ "the entire query is rejected. Remove the reference or subtract " \
230
+ "from config.query_redacted_columns in an initializer if this " \
231
+ "column is not actually sensitive in your app." ]
232
+ end
233
+
165
234
  [ true, nil ]
166
235
  end
167
236
 
237
+ # Returns the first sensitive column name referenced by the SQL, or nil.
238
+ # Checks both the user's configured redacted columns (config.query_redacted_columns)
239
+ # AND the hard-coded suffix list (SENSITIVE_COLUMN_SUFFIXES). The match is
240
+ # case-insensitive and word-bounded so unrelated identifiers containing
241
+ # a sensitive substring are not false-positives.
242
+ def self.references_sensitive_column?(cleaned_sql)
243
+ down = cleaned_sql.downcase
244
+ # Build the combined list ONCE per call and dedupe.
245
+ configured = Array(config.query_redacted_columns).map { |c| c.to_s.downcase }
246
+ suffixed = SENSITIVE_COLUMN_SUFFIXES.map(&:downcase)
247
+ (configured + suffixed).uniq.each do |col|
248
+ next if col.empty?
249
+ return col if down.match?(/\b#{Regexp.escape(col)}\b/)
250
+ end
251
+ nil
252
+ end
253
+
168
254
  # ── Database-level execution (Layer 2) ──────────────────────────
169
255
  private_class_method def self.execute_safely(sql, row_limit, timeout_seconds)
170
256
  conn = ActiveRecord::Base.connection
@@ -126,7 +126,7 @@ module RailsAiContext
126
126
  begin
127
127
  real_search = File.realpath(search_path)
128
128
  real_root = File.realpath(root)
129
- unless real_search.start_with?(real_root)
129
+ unless real_search == real_root || real_search.start_with?(real_root + File::SEPARATOR)
130
130
  return text_response("Path not allowed: #{path}")
131
131
  end
132
132
  rescue Errno::ENOENT
@@ -54,6 +54,17 @@ module RailsAiContext
54
54
  next
55
55
  end
56
56
 
57
+ # Block sensitive files on the caller-supplied string BEFORE any
58
+ # filesystem stat. Closes the existence-oracle side channel where
59
+ # an attacker could distinguish "file not found" from "access denied"
60
+ # for a path like config/master.key. Mirrors get_edit_context.rb
61
+ # ordering. v5.8.1 round 2 hardening.
62
+ if sensitive_file?(file)
63
+ results << "\u2717 #{file} \u2014 access denied (sensitive file)"
64
+ total += 1
65
+ next
66
+ end
67
+
57
68
  full_path = Rails.root.join(file)
58
69
 
59
70
  unless File.exist?(full_path)
@@ -65,12 +76,27 @@ module RailsAiContext
65
76
  end
66
77
 
67
78
  begin
68
- real = File.realpath(full_path)
69
- unless real.start_with?(File.realpath(Rails.root))
79
+ real = File.realpath(full_path).to_s
80
+ rails_root_real = File.realpath(Rails.root).to_s
81
+ # Separator-aware containment — matches the v5.8.1-r2 hardening in
82
+ # get_view.rb / vfs.rb. Without `+ File::SEPARATOR`, a sibling-dir
83
+ # like `/app/rails_evil/...` would prefix-match a Rails root at
84
+ # `/app/rails`. Same bug class as the original C1.
85
+ unless real == rails_root_real || real.start_with?(rails_root_real + File::SEPARATOR)
70
86
  results << "\u2717 #{file} \u2014 path not allowed (outside Rails root)"
71
87
  total += 1
72
88
  next
73
89
  end
90
+ # Defense-in-depth: re-run sensitive_file? on the resolved path.
91
+ # Catches symlinks pointing into sensitive territory from a
92
+ # non-sensitive caller string (e.g. app/views/leak.html.erb →
93
+ # ../../config/master.key).
94
+ relative_real = real.sub("#{rails_root_real}/", "")
95
+ if sensitive_file?(relative_real)
96
+ results << "\u2717 #{file} \u2014 access denied (resolves to sensitive file)"
97
+ total += 1
98
+ next
99
+ end
74
100
  rescue Errno::ENOENT
75
101
  results << "\u2717 #{file} \u2014 file not found"
76
102
  total += 1
@@ -79,12 +105,13 @@ module RailsAiContext
79
105
 
80
106
  total += 1
81
107
 
108
+ real_path = Pathname.new(real)
82
109
  ok, msg, warnings = if file.end_with?(".rb")
83
- validate_ruby(full_path)
110
+ validate_ruby(real_path)
84
111
  elsif file.end_with?(".html.erb") || file.end_with?(".erb")
85
- validate_erb(full_path)
112
+ validate_erb(real_path)
86
113
  elsif file.end_with?(".js")
87
- validate_javascript(full_path)
114
+ validate_javascript(real_path)
88
115
  else
89
116
  results << "- #{file} \u2014 skipped (unsupported file type)"
90
117
  total -= 1
@@ -101,7 +128,7 @@ module RailsAiContext
101
128
  (warnings || []).each { |w| results << " \u26A0 #{w}" }
102
129
 
103
130
  if level == "rails" && ok
104
- rails_warnings = check_rails_semantics(file, full_path)
131
+ rails_warnings = check_rails_semantics(file, real_path)
105
132
  rails_warnings.each { |w| results << " \u26A0 #{w}" }
106
133
  end
107
134
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.8.0"
4
+ VERSION = "5.8.1"
5
5
  end
@@ -130,18 +130,37 @@ module RailsAiContext
130
130
  return [ { uri: uri, mime_type: "application/json", text: content } ]
131
131
  end
132
132
 
133
- # Verify resolved path is still under views_dir
134
- unless File.realpath(full_path).start_with?(File.realpath(views_dir))
133
+ # Verify resolved path is still under views_dir. `start_with?` alone
134
+ # matches `/app/views_spec/x` against `/app/views` — so we append
135
+ # File::SEPARATOR (or accept exact equality for the dir itself).
136
+ # A symlink at `app/views/leak → ../views_spec/secret.html.erb`
137
+ # would otherwise escape the views tree. Fixed in v5.8.1.
138
+ real_view = File.realpath(full_path)
139
+ real_base = File.realpath(views_dir)
140
+ unless real_view == real_base || real_view.start_with?(real_base + File::SEPARATOR)
135
141
  raise RailsAiContext::Error, "Path not allowed: #{path}"
136
142
  end
137
143
 
144
+ # Defense-in-depth: re-run sensitive_file? on the realpath. If someone
145
+ # places a `.env` or `config/master.key` symlink inside `app/views/`,
146
+ # reject it even though the containment check passed. Mirrors the
147
+ # v5.8.1 fix in `get_edit_context.rb`.
148
+ relative_real = real_view.sub("#{real_base}/", "")
149
+ if RailsAiContext::Tools::BaseTool.send(:sensitive_file?, relative_real) ||
150
+ RailsAiContext::Tools::BaseTool.send(:sensitive_file?, path)
151
+ raise RailsAiContext::Error, "Path not allowed: #{path} (sensitive file)"
152
+ end
153
+
154
+ # Use the canonicalized realpath for size + read to eliminate the TOCTOU
155
+ # window between the containment/sensitive check and the actual file
156
+ # access. Mirrors the pattern in get_view.rb and get_edit_context.rb.
138
157
  max_size = RailsAiContext.configuration.max_file_size
139
- if File.size(full_path) > max_size
140
- content = JSON.pretty_generate(error: "File too large: #{path} (#{File.size(full_path)} bytes)")
158
+ if File.size(real_view) > max_size
159
+ content = JSON.pretty_generate(error: "File too large: #{path} (#{File.size(real_view)} bytes)")
141
160
  return [ { uri: uri, mime_type: "application/json", text: content } ]
142
161
  end
143
162
 
144
- view_content = RailsAiContext::SafeFile.read(full_path) || ""
163
+ view_content = RailsAiContext::SafeFile.read(real_view) || ""
145
164
  mime = path.end_with?(".rb") ? "text/x-ruby" : "text/html"
146
165
  [ { uri: uri, mime_type: mime, text: view_content } ]
147
166
  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.8.0
4
+ version: 5.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine