rails-ai-context

45 MCP tools that give AI agents live access to your Rails schema, models, routes & conventions.

View the Project on GitHub crisnahine/rails-ai-context

Security Model

Read-only by design. Defense in depth. Every tool is non-destructive.

Architecture · Configuration · Tools Reference · FAQ


[!CAUTION] This gem is designed for development environments. The query tool is disabled in production by default. Sensitive files are blocked. All 45 tools are read-only.

Design principles

  1. Read-only by design - All 45 tools are annotated as non-destructive in the MCP protocol
  2. Defense in depth - Multiple security layers, not single points of failure
  3. Sensitive data blocking - Configurable patterns prevent access to secrets
  4. Offline by default - No network calls except optional rails_search_docs with fetch: true
  5. Graceful degradation - Missing optional dependencies don’t expose errors or state

SQL query safety (4 layers)

The rails_query tool uses a 4-layer security model:

flowchart LR
    Q[SQL Query] --> L1{Layer 1\nRegex Validation}
    L1 -->|"Blocked:\nINSERT, DROP,\nUNION SELECT..."| R1[Rejected]
    L1 -->|SELECT only| L2{Layer 2\nDatabase Read-Only}
    L2 -->|"SET TRANSACTION\nREAD ONLY\n+ timeout"| L3{Layer 3\nRow Limit}
    L3 -->|"Cap: 1000 rows\nDefault: 100"| L4{Layer 4\nSensitive Columns}
    L4 -->|"names password_digest,\napi_key, ..."| R1
    L4 -->|"no sensitive column"| OK[Safe Result]

    style R1 fill:#e74c3c,stroke:#c0392b,color:#fff
    style OK fill:#27ae60,stroke:#1e8449,color:#fff
    style L1 fill:#e67e22,stroke:#d35400,color:#fff
    style L2 fill:#f39c12,stroke:#e67e22,color:#fff
    style L3 fill:#3498db,stroke:#2980b9,color:#fff
    style L4 fill:#9b59b6,stroke:#8e44ad,color:#fff

Layer 1 - SQL validation (regex-based)

Before any query reaches the database:

Layer 2 - Database-level read-only

After validation, the query runs inside a transaction:

Database Mechanism
PostgreSQL SET TRANSACTION READ ONLY + SET LOCAL statement_timeout
MySQL SET TRANSACTION READ ONLY + MAX_EXECUTION_TIME hint
SQLite A read-only connection in a child process, killed at timeout

On PostgreSQL and MySQL the query executes inside a transaction, then rolls back (even if it could write, it can’t). Any other adapter has no database-level guard: the query runs with Layer 1 validation and the row limit only.

A SQLite query runs in-process under PRAGMA query_only = ON, with no time limit, for an in-memory database, on a platform without fork, or when it needs a function, virtual-table module, collation or encryption key only the app’s own connection has. The table and EXPLAIN answers then say so; CSV output stays plain data.

On sqlite3 1.x a query fails at its timeout, with the statement-timeout error, when another connection in the same process held an exclusive lock on a rollback-journal database as it started (BEGIN EXCLUSIVE): the child inherits that lock record and it never clears. A lock held by another process is waited on as usual, and an open write transaction or any lock in WAL mode does not block it.

Layer 3 - Row limit

Layer 4 - Sensitive column rejection

A query that names a sensitive column is rejected before execution, not redacted after it:

Default redacted patterns: password_digest, encrypted_password, password_hash, reset_password_token, confirmation_token, unlock_token, otp_secret, session_data, secret_key, api_key, api_secret, access_token, refresh_token, jti

Those are the defaults of config.query_redacted_columns. A fixed built-in list is checked as well, which adds password_reset_token, remember_token, secret and private_key. Matching is by name, case-insensitive and word-bounded, against both lists. SELECT password_digest AS pd FROM users is blocked outright: post-execution redaction reads the column names the database returns, which the caller controls through aliases and expressions, so it cannot be relied on.

If one of your own columns merely looks sensitive (an oauth_applications.secret, say), exempt it by name:

config.query_allowed_columns = %w[secret]

Results are redacted as well: a returned column comes back as [FILTERED] when its name is in config.query_redacted_columns, contains password, secret or token, or ends in key, digest or hash. SHOW, DESCRIBE and EXPLAIN output is not redacted.

The exemption covers the results too: an allowed name comes back unredacted. A column declared with encrypts stays [FILTERED] either way.

Environment guard

[!WARNING] Disabled in production by default. Only enable with config.allow_query_in_production = true if you understand the implications.


Sensitive file blocking

The rails_search_code and file-reading tools block access to sensitive files:

Default patterns

.env .env.* *.env .envrc
config/master.key
config/credentials.yml.enc config/credentials/*.yml.enc
config/database.yml config/secrets*.yml config/secrets*.yml.enc
config/application.yml
config/settings.local.yml config/settings/*.local.yml
config/cable.yml config/storage.yml
config/mongoid.yml config/redis.yml
*.pem *.key *.p8 *.p12 *.pfx *.jks *.keystore
**/id_rsa **/id_ed25519 **/id_ecdsa **/id_dsa
.ssh/* .aws/credentials .aws/config .netrc .pgpass .my.cnf

A placeholder whose name ends in .example, .sample, .template or .dist (.env.example) is committed to be read, so a basename glob such as .env.* does not block it. A pattern that names it exactly, with no glob characters, still does, and so does a path pattern (one with a /, such as .ssh/*), which covers everything under it.

AI context file exclusions

Search also excludes generated AI context files to prevent circular references:

CLAUDE.md, .claude/, .mcp.json
.cursor/, .cursorrules
.github/copilot-instructions.md, .github/instructions/, .vscode/mcp.json
AGENTS.md, opencode.json
.codex/
.ai-context.json

Configuration

config.sensitive_patterns = %w[.env* *.key *.pem credentials.yml.enc]

Path traversal protection

All file-reading operations validate paths against Rails.root:

real_path = File.realpath(requested_path)
root = File.realpath(Rails.root.to_s)
raise unless real_path == root || real_path.start_with?(root + File::SEPARATOR)

The VFS (rails-ai-context://views/{path}) applies the same protection for view template reads.

Frontend roots outside the app

Two directories outside Rails.root are read, for frontend manifests only: each frontend_paths entry you configure (../web-client), and the JS workspace root above the app (the nearest ancestor holding a lockfile or declaring workspaces, never above the git root and never outside a git repository). Only package.json, lockfiles and the presence of a bundler config (vite.config.* and similar) are read there, plus a configured entry’s tsconfig.json and the configs it extends inside that same directory. Sensitive patterns and symlink containment apply relative to that directory, and frontend_stack names it in its answer.

The bundle config/boot.rb declares

An app with no lockfile of its own (an engine’s test/dummy) has its gems in the Gemfile its config/boot.rb sets as BUNDLE_GEMFILE (../../Gemfile). That Gemfile and its lockfile (gems.rb and gems.locked alike) are read with the same trust as the app’s own Gemfile.lock, only when their directory is inside the app’s git repository, never above its root and never outside a repository. Besides those two, only a file that Gemfile names with eval_gemfile inside that directory, and the lib/ of a path gem the lockfile names inside the repository (the engine’s own remote: .), are read. A file there that links out of the directory is refused.

The engine an app’s test/dummy runs in

Booted from an engine’s test/dummy, the engine whose root holds the app root (a loaded Rails::Engine, never this gem) is the project’s own source: its app/ code, its app/views layouts and templates, and, when the dummy keeps no test/ or spec/ of its own, its test suite are read. Paths into it are printed relative to the app root (../../app/models/...), and symlink containment applies relative to the engine root. Without booting, only the test suite is read this way, and only from the directory of the bundle config/boot.rb declares (read under the rule above) when it holds a *.gemspec and contains the app root.

How a refusal is reported

A path refused on policy - outside the app, a traversal, a sensitive file - comes back as an error result: isError: true over MCP, exit 1 from the CLI. A path that is simply not there is an ordinary answer and exits 0, so a script can tell the two apart.


Command injection prevention

Search tools use array-based command execution (never shell strings):

# Safe: array form
Open3.capture3("rg", "--no-heading", "--", pattern, directory)

# Pattern injection prevented by -- separator

File type parameters accept only alphanumeric characters.


Regex injection prevention

On Ruby 3.2 and newer, user-supplied regex patterns have a 1-second timeout (2 seconds in the Ruby search fallback):

Regexp.new(pattern, timeout: 1)

Complex patterns that would cause catastrophic backtracking raise RegexpError instead of hanging.

[!WARNING] Regexp.timeout does not exist on Ruby 3.1, which this gem still supports. There the timeout is skipped, and a pattern crafted to backtrack catastrophically can hang the process serving the tool. If you expose rails_search_code to input you do not control, run it on Ruby 3.2 or newer.


Safe file reading

SafeFile.read provides drop-in safety for all file reads:


Redaction

Everything that leaves your app through this gem - log lines, query rows, environment values, config source slices - passes through one redaction module before anything else can touch it. Redacting and shortening are a single operation there, so a long credential cannot be cut apart in a way that hides it from the pattern that would have caught it.

What gets redacted:

Markers

Redacted values carry one of two markers, and only these two:

Marker Means
[FILTERED] A value was removed because it is or may be a secret
[EMAIL] An email address was removed

Marker presence and this vocabulary are part of the output contract (see below) - you can pattern-match on them.


Output contract

Contract, safe to depend on:

Incidental, expected to change between releases:

Pin your assertions to the first list. When something in the second list changes in a way that could break a pinned assertion, the CHANGELOG says so.


Migration safety

The rails_migration_advisor tool validates input:


MCP HTTP transport

When using HTTP transport (Rack middleware or McpController):

The McpController uses thread-safe transport initialization with mutex synchronization.


Credential handling


Reporting vulnerabilities

Email crisjosephnahine@gmail.com. Response within 48 hours.

Supported versions: only the latest 5.x release gets security fixes. See the repo root SECURITY.md for the full policy.


← Introspectors · CLI Reference →

Back to Home