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 +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +2 -2
- data/docs/social-preview.html +1 -1
- data/lib/rails_ai_context/configuration.rb +24 -2
- data/lib/rails_ai_context/fingerprinter.rb +34 -7
- data/lib/rails_ai_context/instrumentation.rb +46 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +15 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +45 -19
- data/lib/rails_ai_context/tools/base_tool.rb +20 -1
- data/lib/rails_ai_context/tools/get_edit_context.rb +18 -3
- data/lib/rails_ai_context/tools/get_partial_interface.rb +16 -5
- data/lib/rails_ai_context/tools/get_view.rb +24 -5
- data/lib/rails_ai_context/tools/query.rb +86 -0
- data/lib/rails_ai_context/tools/search_code.rb +1 -1
- data/lib/rails_ai_context/tools/validate.rb +33 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +24 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b8c8f1c7133f9f97b35975be1ecf1485e6fd663867c7fb6ae776af131f972ec
|
|
4
|
+
data.tar.gz: 2a4e374b3509f1eca37ddeefa730eb8fc181a7710df616c7a0ab389babce7280
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
23
23
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
24
|
-
[](https://github.com/crisnahine/rails-ai-context/actions)
|
|
25
25
|
[](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
|
-
|
|
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>
|
data/docs/social-preview.html
CHANGED
|
@@ -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.*
|
|
233
|
-
config/
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
16
|
-
|
|
28
|
+
begin
|
|
29
|
+
method = data[:method] || "unknown"
|
|
30
|
+
event_name = "#{EVENT_PREFIX}.#{method.to_s.tr("/", ".")}"
|
|
17
31
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")).
|
|
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")).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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("#{
|
|
63
|
-
partial_name = file_path.sub("#{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
250
|
-
return text_response("File too large: #{path} (#{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(
|
|
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
|
-
|
|
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(
|
|
110
|
+
validate_ruby(real_path)
|
|
84
111
|
elsif file.end_with?(".html.erb") || file.end_with?(".erb")
|
|
85
|
-
validate_erb(
|
|
112
|
+
validate_erb(real_path)
|
|
86
113
|
elsif file.end_with?(".js")
|
|
87
|
-
validate_javascript(
|
|
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,
|
|
131
|
+
rails_warnings = check_rails_semantics(file, real_path)
|
|
105
132
|
rails_warnings.each { |w| results << " \u26A0 #{w}" }
|
|
106
133
|
end
|
|
107
134
|
end
|
data/lib/rails_ai_context/vfs.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
140
|
-
content = JSON.pretty_generate(error: "File too large: #{path} (#{File.size(
|
|
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(
|
|
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
|