rails-ai-context 4.2.0 → 4.2.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: f82bdd4a192f2776214a228f36e54c62cc6e5e19ac5351d997ade9e5bbdd1667
4
- data.tar.gz: 303476c0cf9c43ed4eb4f10df5cdf89c7dba0e5b8858d86a90b984c7cd5ffd0e
3
+ metadata.gz: c4979f73cd9d2deaa6568c81bcc22a42034cc26ead1012e18ccc9afd202cd94b
4
+ data.tar.gz: 90399f07e1d97b4d3ce24a0e203db4febb297810451cea6e3c4a81c2e222b2fc
5
5
  SHA512:
6
- metadata.gz: aacc3fad8dbbced828ff47eece6d37db6baefaf92b5e2ad1fa4a55d18fa720857d284aba325742985dde0e8e83420f3bb45b8cccf6be6e37d8a9b4973f96365a
7
- data.tar.gz: 8c860fedacf47a88253f732a74298f6e7a0a878631d09d090aff3f017703b930bc4add8909bf33407c4165121e942d15c3abce6befc88ccc9e08b0cb09a9e0bb
6
+ metadata.gz: 47b89a0f367cc401a28175031f46db972f8a2756aa4757cf73864216e98a9bb6fa09b3f7e30686c24e11ca32bae0388b4d2d190be11fa953a6271c7ae67dab20
7
+ data.tar.gz: 464042799cda86f8ab622c1ac6de5201874749d24e548ba55b2f4d2781ea684c35baf59edeeb8f7c1a8788186b1b8e86784a86987b612043c01d1ff680030ca8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ 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
+ ## [4.2.1] — 2026-03-31
9
+
10
+ ### Fixed
11
+ - **Security: SQL comment stripping** — `rails_query` now strips MySQL-style `#` comments in addition to `--` and `/* */`
12
+ - **Security: Regex injection** — PerformanceIntrospector now uses `Regexp.escape` on all interpolated model/association names to prevent regex injection
13
+ - **Security: SearchDocs error memoization** — transient index load failures (JSON parse errors, missing file) are no longer cached permanently; subsequent calls retry instead of returning stale errors
14
+ - **Security: ReadLogs file parameter** — null byte sanitization + `File.basename` enforcement prevents path traversal via directory separators in file names
15
+ - **Security: ReadLogs redaction** — added `cookie`, `session_id`, and `_session` patterns to sensitive data redaction
16
+ - **Security: SearchDocs fetch size** — 2MB cap on fetched documentation content prevents memory exhaustion from oversized HTTP responses
17
+ - **Security: MigrationAdvisor input validation** — table and column names now validated as safe identifiers; special characters rejected with clear error messages
18
+ - **Cache: Fingerprinter watched paths** — added `app/components` to WATCHED_DIRS, `package.json` and `tsconfig.json` to WATCHED_FILES; component catalog and frontend stack tools now invalidate on relevant file changes
19
+ - **Schema: static parse skipped tables** — `parse_schema_rb` no longer leaves `current_table` pointing at a skipped table (`schema_migrations`, `ar_internal_metadata`), preventing potential nil access on subsequent column lines
20
+ - **Query: CSV newline escaping** — CSV format output now properly quotes cell values containing newlines and carriage returns
21
+ - **DependencyGraph: Mermaid node IDs** — model names starting with digits now get an `M` prefix to produce valid Mermaid syntax
22
+
23
+ ### Changed
24
+ - Test count: 983 → 998
25
+
8
26
  ## [4.2.0] — 2026-03-26
9
27
 
10
28
  ### Added
data/CLAUDE.md CHANGED
@@ -60,7 +60,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
60
60
  ## Testing
61
61
 
62
62
  ```bash
63
- bundle exec rspec # Run specs (983 examples)
63
+ bundle exec rspec # Run specs (998 examples)
64
64
  bundle exec rubocop # Lint
65
65
  ```
66
66
 
data/README.md CHANGED
@@ -8,12 +8,12 @@
8
8
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-green)](https://registry.modelcontextprotocol.io)
9
9
  [![Ruby](https://img.shields.io/badge/Ruby-3.2%20%7C%203.3%20%7C%203.4-red)](https://github.com/crisnahine/rails-ai-context)
10
10
  [![Rails](https://img.shields.io/badge/Rails-7.1%20%7C%207.2%20%7C%208.0-red)](https://github.com/crisnahine/rails-ai-context)
11
- [![Tests](https://img.shields.io/badge/Tests-983%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
11
+ [![Tests](https://img.shields.io/badge/Tests-998%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
13
13
 
14
14
  **Works with:** Claude Code • Cursor • GitHub Copilot • OpenCode • Any terminal
15
15
 
16
- > Built by a Rails developer with 10+ years of production experience. AI assisted — the same way it assists me shipping features at work. I designed the architecture, made every decision, reviewed every line, and wrote 983 tests. This gem exists because I understand Rails deeply enough to know exactly what AI agents get wrong and what context they need to get it right.
16
+ > Built by a Rails developer with 10+ years of production experience. AI assisted — the same way it assists me shipping features at work. I designed the architecture, made every decision, reviewed every line, and wrote 998 tests. This gem exists because I understand Rails deeply enough to know exactly what AI agents get wrong and what context they need to get it right.
17
17
 
18
18
  ```bash
19
19
  gem "rails-ai-context", group: :development
data/SECURITY.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---------|--------------------|
7
- | 4.2.x | :white_check_mark: |
7
+ | 4.2.x | :white_check_mark: (4.2.1 includes security hardening) |
8
8
  | 4.1.x | :white_check_mark: |
9
9
  | 4.0.x | :white_check_mark: |
10
10
  | 3.1.x | :white_check_mark: |
@@ -34,4 +34,10 @@ If you discover a security vulnerability in rails-ai-context, please report it r
34
34
  - **Credential safety** — `rails_get_env` only reads `.env.example` (never `.env`), shows credential key names only (never values), and redacts secrets. `rails_get_config` exposes adapter/framework names, not connection strings.
35
35
  - **Brakeman integration** — optional `rails_security_scan` tool runs static security analysis. Graceful degradation if not installed. Users can exclude it via `config.skip_tools = %w[rails_security_scan]`.
36
36
  - **File size limits** — all tools enforce configurable `max_file_size` (default 5MB) to prevent memory exhaustion on large files.
37
- - The gem does not make any outbound network requests.
37
+ - **SQL comment stripping** `rails_query` strips block (`/* */`), line (`--`), and MySQL-style (`#`) comments before validation to prevent keyword hiding.
38
+ - **Regex interpolation safety** — all introspectors use `Regexp.escape` when interpolating model/association names into patterns to prevent regex injection.
39
+ - **Log redaction** — `rails_read_logs` redacts passwords, tokens, secrets, API keys, cookies, session IDs, emails, and environment variables before output.
40
+ - **Migration input validation** — `rails_migration_advisor` validates table and column names as safe identifiers before generating migration code.
41
+ - **Cache invalidation coverage** — Fingerprinter watches `app/components`, `package.json`, and `tsconfig.json` alongside models/controllers/views to prevent stale tool responses.
42
+ - **Fetch size limits** — `rails_search_docs` caps fetched documentation content at 2MB to prevent memory exhaustion.
43
+ - The gem makes outbound HTTPS requests only when `rails_search_docs` is called with `fetch: true` (to fetch Rails documentation from GitHub raw content). All other tools are offline.
@@ -12,6 +12,8 @@ module RailsAiContext
12
12
  config/routes.rb
13
13
  config/database.yml
14
14
  Gemfile.lock
15
+ package.json
16
+ tsconfig.json
15
17
  ].freeze
16
18
 
17
19
  WATCHED_DIRS = %w[
@@ -21,6 +23,7 @@ module RailsAiContext
21
23
  app/jobs
22
24
  app/mailers
23
25
  app/channels
26
+ app/components
24
27
  app/javascript/controllers
25
28
  app/middleware
26
29
  config/initializers
@@ -108,12 +108,12 @@ module RailsAiContext
108
108
  model_data.each do |model|
109
109
  model[:has_many].each do |assoc|
110
110
  # Check if controller fetches this model's collection without includes
111
- model_ref = model[:name]
111
+ model_ref = Regexp.escape(model[:name])
112
112
  pattern = /#{model_ref}\.(all|where|order|limit|find_each)\b/
113
113
  next unless content.match?(pattern)
114
114
 
115
115
  # Check if .includes is used for this association
116
- includes_pattern = /\.includes\(.*:#{assoc[:name]}/
116
+ includes_pattern = /\.includes\(.*:#{Regexp.escape(assoc[:name])}/
117
117
  next if content.match?(includes_pattern)
118
118
 
119
119
  # Check views for accessing association
@@ -142,7 +142,7 @@ module RailsAiContext
142
142
  # Check if any view iterates over the association
143
143
  Dir.glob(File.join(views_dir, "**/*.{erb,haml,slim}")).any? do |path|
144
144
  content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
145
- content.match?(/\.#{association_name}\b/)
145
+ content.match?(/\.#{Regexp.escape(association_name)}\b/)
146
146
  rescue
147
147
  false
148
148
  end
@@ -231,7 +231,7 @@ module RailsAiContext
231
231
  relative = path.sub("#{root}/", "")
232
232
 
233
233
  model_names.each do |model_name|
234
- content.scan(/#{model_name}\.all\b/).each do
234
+ content.scan(/#{Regexp.escape(model_name)}\.all\b/).each do
235
235
  findings << {
236
236
  controller: relative,
237
237
  model: model_name,
@@ -182,7 +182,10 @@ module RailsAiContext
182
182
  content.each_line do |line|
183
183
  if (match = line.match(/create_table\s+"(\w+)"/))
184
184
  current_table = match[1]
185
- next if current_table.start_with?("ar_internal_metadata", "schema_migrations")
185
+ if current_table.start_with?("ar_internal_metadata", "schema_migrations")
186
+ current_table = nil
187
+ next
188
+ end
186
189
  tables[current_table] = { columns: [], indexes: [], foreign_keys: [] }
187
190
  elsif current_table && (match = line.match(/t\.(\w+)\s+"(\w+)"/))
188
191
  col = { name: match[2], type: match[1] }
@@ -63,11 +63,16 @@ module RailsAiContext
63
63
  }
64
64
  }.freeze
65
65
 
66
- class << self
67
- def register(server)
68
- require "json"
66
+ MODEL_TEMPLATE = MCP::ResourceTemplate.new(
67
+ uri_template: "rails://models/{name}",
68
+ name: "Model Details",
69
+ description: "Detailed information about a specific ActiveRecord model",
70
+ mime_type: "application/json"
71
+ ).freeze
69
72
 
70
- resources = STATIC_RESOURCES.map do |uri, meta|
73
+ class << self
74
+ def static_resources
75
+ STATIC_RESOURCES.map do |uri, meta|
71
76
  MCP::Resource.new(
72
77
  uri: uri,
73
78
  name: meta[:name],
@@ -75,17 +80,16 @@ module RailsAiContext
75
80
  mime_type: meta[:mime_type]
76
81
  )
77
82
  end
83
+ end
78
84
 
79
- server.resources = resources
85
+ def resource_templates
86
+ [ MODEL_TEMPLATE ]
87
+ end
80
88
 
81
- template = MCP::ResourceTemplate.new(
82
- uri_template: "rails://models/{name}",
83
- name: "Model Details",
84
- description: "Detailed information about a specific ActiveRecord model",
85
- mime_type: "application/json"
86
- )
89
+ def register(server)
90
+ require "json"
87
91
 
88
- server.resources_templates_list_handler { [ template ] }
92
+ server.resources = static_resources
89
93
 
90
94
  server.resources_read_handler do |params|
91
95
  handle_read(params)
@@ -56,7 +56,8 @@ module RailsAiContext
56
56
  server = MCP::Server.new(
57
57
  name: config.server_name,
58
58
  version: config.server_version,
59
- tools: active_tools(config) + config.custom_tools
59
+ tools: active_tools(config) + config.custom_tools,
60
+ resource_templates: Resources.resource_templates
60
61
  )
61
62
 
62
63
  Resources.register(server)
@@ -193,7 +193,10 @@ module RailsAiContext
193
193
  end
194
194
 
195
195
  def sanitize(name)
196
- name.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
196
+ sanitized = name.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
197
+ # Mermaid node IDs must start with a letter
198
+ sanitized = "M#{sanitized}" if sanitized.match?(/\A\d/)
199
+ sanitized
197
200
  end
198
201
  end
199
202
  end
@@ -56,6 +56,15 @@ module RailsAiContext
56
56
  return text_response("**Error:** `action` is required. Valid actions: #{VALID_ACTIONS.join(', ')}") if action.empty?
57
57
  return text_response("**Error:** `table` is required (e.g., 'users', 'posts').") if table.empty?
58
58
 
59
+ # Validate identifier characters to produce valid migration code
60
+ unless table.match?(/\A[a-z_][a-z0-9_]*\z/)
61
+ return text_response("**Error:** Invalid table name `#{table}`. Use lowercase letters, digits, and underscores only.")
62
+ end
63
+ # create_table uses column param as a comma-separated column:type definition string
64
+ if action != "create_table" && column && !column.empty? && !column.match?(/\A[a-z_][a-z0-9_]*\z/)
65
+ return text_response("**Error:** Invalid column name `#{column}`. Use lowercase letters, digits, and underscores only.")
66
+ end
67
+
59
68
  unless VALID_ACTIONS.include?(action)
60
69
  suggestion = VALID_ACTIONS.find { |a| a.start_with?(action) || a.include?(action) }
61
70
  hint = suggestion ? " Did you mean `#{suggestion}`?" : ""
@@ -93,7 +93,11 @@ module RailsAiContext
93
93
 
94
94
  # ── SQL comment stripping ───────────────────────────────────────
95
95
  def self.strip_sql_comments(sql)
96
- sql.gsub(/\/\*.*?\*\//m, " ").gsub(/--[^\n]*/, " ").squeeze(" ").strip
96
+ sql
97
+ .gsub(/\/\*.*?\*\//m, " ") # Block comments: /* ... */
98
+ .gsub(/--[^\n]*/, " ") # Line comments: -- ...
99
+ .gsub(/#[^\n]*/, " ") # MySQL-style comments: # ...
100
+ .squeeze(" ").strip
97
101
  end
98
102
 
99
103
  # ── SQL validation (Layer 1) ────────────────────────────────────
@@ -263,8 +267,8 @@ module RailsAiContext
263
267
  rows.each do |row|
264
268
  lines << row.map { |val|
265
269
  formatted = format_cell(val)
266
- # Quote values that contain commas or quotes
267
- if formatted.include?(",") || formatted.include?('"')
270
+ # Quote values that contain commas, quotes, or newlines
271
+ if formatted.include?(",") || formatted.include?('"') || formatted.include?("\n") || formatted.include?("\r")
268
272
  "\"#{formatted.gsub('"', '""')}\""
269
273
  else
270
274
  formatted
@@ -52,6 +52,9 @@ module RailsAiContext
52
52
  /(?<=api_key:\s)\S+/i,
53
53
  /(?<=authorization:\s)(Bearer\s)?\S+/i,
54
54
  /(SECRET|PRIVATE|SIGNING|ENCRYPTION)[_A-Z]*=\S+/i,
55
+ /(?<=cookie:\s)\S+/i,
56
+ /(?<=session_id=)\S+/i,
57
+ /(?<=_session=)\S+/i,
55
58
  /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z]{2,}\b/i
56
59
  ].freeze
57
60
 
@@ -155,8 +158,9 @@ module RailsAiContext
155
158
  root = Rails.root.to_s
156
159
 
157
160
  if file_name
158
- # Strip .log suffix if provided, then re-add
159
- name = file_name.to_s.strip.delete_suffix(".log")
161
+ # Strip .log suffix if provided, then re-add; sanitize null bytes and path separators
162
+ name = file_name.to_s.strip.delete("\0").delete_suffix(".log")
163
+ name = File.basename(name) # Prevent directory traversal via slashes
160
164
  path = File.join(root, "log", "#{name}.log")
161
165
  else
162
166
  path = File.join(root, "log", "#{Rails.env}.log")
@@ -105,7 +105,9 @@ module RailsAiContext
105
105
  private
106
106
 
107
107
  def load_index
108
- @docs_index ||= begin
108
+ return @docs_index if @docs_index&.dig(:topics)
109
+
110
+ result = begin
109
111
  unless File.exist?(INDEX_PATH)
110
112
  return { error: "Documentation index not found at #{INDEX_PATH}. The gem installation may be incomplete — reinstall rails-ai-context." }
111
113
  end
@@ -117,6 +119,10 @@ module RailsAiContext
117
119
  rescue JSON::ParserError => e
118
120
  { error: "Failed to parse documentation index: #{e.message}" }
119
121
  end
122
+
123
+ # Only memoize successful results so transient failures can be retried
124
+ @docs_index = result if result[:topics]
125
+ result
120
126
  end
121
127
 
122
128
  def detect_rails_branch
@@ -222,9 +228,12 @@ module RailsAiContext
222
228
  request = Net::HTTP::Get.new(uri)
223
229
  response = http.request(request)
224
230
 
231
+ max_fetch_bytes = 2_000_000 # 2MB safety cap
225
232
  if response.is_a?(Net::HTTPSuccess)
226
- File.write(cache_file, response.body)
227
- response.body
233
+ body = response.body
234
+ body = body.byteslice(0, max_fetch_bytes) if body.bytesize > max_fetch_bytes
235
+ File.write(cache_file, body)
236
+ body
228
237
  else
229
238
  "#{topic['summary']}\n→ #{url}\n_(fetch failed: HTTP #{response.code})_"
230
239
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "4.2.0"
4
+ VERSION = "4.2.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.2.0
4
+ version: 4.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -13,16 +13,22 @@ dependencies:
13
13
  name: mcp
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '0.8'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '2.0'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '0.8'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '2.0'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: railties
28
34
  requirement: !ruby/object:Gem::Requirement