rails-ai-bridge 3.1.1 → 3.2.0

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: e32a72aabc513ec5937d82b7a35740260d32c3d73019c8ee5589cb683c4a289a
4
- data.tar.gz: 146876eea0ce3ea9abbc8738c832f85f1bc27eef9f2d01b13661f13ebe96afc4
3
+ metadata.gz: 181f3d197682eead1f7d4bcdbaf9ee507ee4604ec3bfd21b46a5255dce77d43c
4
+ data.tar.gz: 177d42895020fe221a3d44523392ae21500928d47b7a400691975ff55cc24e34
5
5
  SHA512:
6
- metadata.gz: f7959cb34370acd3710b4206d58673d769044727e5943cce76f25d5e263191254af278da436d044d2a422b26ca76ba14767fa44a72fbb76f1ee8f38da881757d
7
- data.tar.gz: '09d83832a6768e01474a9af411a2003bf2d77ddda937cd23fdb33c2535916ab5f8b31adda37906e3e191b6991834c69ef87ae862ec63138ecaea4cf9ff2730a8'
6
+ metadata.gz: af082eeef430d215b7e5f96725d66466bf6a9bff353e0faaf9079d404ac24d676910131f37e9322b46e33f2d18ea2ebe8f3d6313e53c0b933882542c21e0ba9b
7
+ data.tar.gz: 8cdba5c2b236a69d564d2af1cc6537bb8ed7e31ba74cd50ad66f4e16ae15727a3fcd2b477ebf378781e7eef8d05f35de21da8c86c8007317caea6313db34b502
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ 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
+ ## [3.2.0] - 2026-05-04
9
+
10
+ ### Added
11
+
12
+ - **Recursive symlink protection** — `FileManagementService` now recursively resolves and validates every directory component of a path. This prevents directory-traversal escapes via symlinks in non-existent nested paths (e.g., writing to `unsafe_link/new_dir/file.txt`).
13
+ - **ActiveRecord-free resilience** — `NonArModelsIntrospector` now safely handles Rails stacks without ActiveRecord (e.g., pure API or alternative ORMs) by guarding `ActiveRecord::Base` inheritance checks.
14
+ - **Robust Rails logger guards** — all diagnostic and error logging now uses `defined?(Rails.logger)` to prevent `NoMethodError` in environments where `Rails` is defined but lacks a logger.
15
+
16
+ ### Changed
17
+
18
+ - **Terminology alignment** — Updated generated documentation and command descriptions from "context" to "bridge" (e.g., `rails ai:watch` now describes "Auto-regenerate bridge files").
19
+ - **ConventionDetector stability** —restored standard error-hash return `{ error: msg }` for `ConventionDetector#call` to comply with introspector standards, while maintaining explicit `Rails.logger.warn` for observability.
20
+
21
+ ### Fixed
22
+
23
+ - **Rake task spec cleanup** — removed unused `let(:task_path)` and fixed duplication in rake task loading.
24
+ - **Install generator optimization** — removed redundant double-introspection call during the install process.
25
+ ## [3.1.1] - 2026-05-03
26
+
27
+ ### Changed
28
+
29
+ - **Small Security Improvement** — There was an update from rubygems security, so this made
30
+ a new release needed, no new functionality added
31
+
8
32
  ## [3.1.0] - 2026-05-01
9
33
 
10
34
  ### Added
data/README.md CHANGED
@@ -544,10 +544,10 @@ Frontend introspectors (views, Turbo, Stimulus, assets) degrade gracefully — t
544
544
  | `rails ai:serve` | Start MCP server (stdio) |
545
545
  | `rails ai:serve_http` | Start MCP server (HTTP) |
546
546
  | `rails ai:doctor` | Run diagnostics and AI readiness score (0-100) |
547
- | `rails ai:watch` | Auto-regenerate context files on code changes |
547
+ | `rails ai:watch` | Auto-regenerate bridge files on code changes |
548
548
  | `rails ai:inspect` | Print introspection summary to stdout |
549
549
 
550
- > **Context modes:**
550
+ > **Bridge modes:**
551
551
  > ```bash
552
552
  > rails ai:bridge # compact (default) — all formats
553
553
  > rails ai:bridge:full # full dump — all formats
@@ -22,6 +22,7 @@ module RailsAiBridge
22
22
  config_files: detect_config_files
23
23
  }
24
24
  rescue StandardError => error
25
+ Rails.logger.warn "[rails-ai-bridge] ConventionDetector failed: #{error.class} [#{error.message}]" if defined?(Rails) && Rails.logger
25
26
  { error: error.message }
26
27
  end
27
28
 
@@ -18,9 +18,8 @@ module RailsAiBridge
18
18
  private_constant :CollectionContext
19
19
  # Logs best-effort class processing failures without interrupting discovery.
20
20
  ErrorLogger = Struct.new(:error, keyword_init: true) do
21
- # @return [void]
22
21
  def call
23
- logger = Object.const_get(:Rails).logger if defined?(Rails.logger)
22
+ logger = Rails.logger if defined?(Rails.logger) && Rails.logger
24
23
  logger&.warn "NonArModelsIntrospector: Error processing class: #{error.class.name}"
25
24
  end
26
25
  end
@@ -120,11 +119,13 @@ module RailsAiBridge
120
119
  return false if name.blank?
121
120
  return false if name.include?('.')
122
121
  return false unless name.match?(/\A[A-Z][A-Za-z0-9_:]*\z/)
123
- return false if klass < ActiveRecord::Base
122
+ return false if defined?(ActiveRecord::Base) && klass < ActiveRecord::Base
124
123
 
125
124
  true
126
125
  rescue StandardError => error
127
- Rails.logger.warn "NonArModelsIntrospector: Error validating class: #{error.class.name}" if defined?(Rails.logger)
126
+ if defined?(Rails.logger) && Rails.logger
127
+ Rails.logger.warn "NonArModelsIntrospector: Error validating class: #{error.class.name}"
128
+ end
128
129
  false
129
130
  end
130
131
 
@@ -141,7 +142,7 @@ module RailsAiBridge
141
142
  return if name.blank?
142
143
  return if name.include?('.')
143
144
  return unless name.match?(/\A[A-Z][A-Za-z0-9_:]*\z/)
144
- return if klass < ActiveRecord::Base
145
+ return if defined?(ActiveRecord::Base) && klass < ActiveRecord::Base
145
146
 
146
147
  loc = safe_const_source_location(name)
147
148
  return unless loc&.first
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'pathname'
4
5
 
5
6
  module RailsAiBridge
6
7
  module Services
@@ -42,14 +43,10 @@ module RailsAiBridge
42
43
  return Service::Result.new(false, errors: ['Operation cannot be nil']) if operation.nil?
43
44
 
44
45
  case operation.to_sym
45
- when :write
46
- write_file(**)
47
- when :read
48
- read_file(**)
49
- when :delete
50
- delete_file(**)
51
- when :exist?
52
- file_exists?(**)
46
+ when :write then write_file(**)
47
+ when :read then read_file(**)
48
+ when :delete then delete_file(**)
49
+ when :exist? then file_exists?(**)
53
50
  else
54
51
  Service::Result.new(false, errors: ["Unsupported operation: #{operation}"])
55
52
  end
@@ -66,40 +63,73 @@ module RailsAiBridge
66
63
  end
67
64
  end
68
65
 
69
- # Expands +path+ and ensures it stays within +base_dir+ (or equals it).
66
+ # Expands +path+ and ensures it stays within +base_dir+ (or equals it), resolving symlinks.
70
67
  #
71
68
  # Relative paths are resolved from +base_dir+. Absolute paths are normalized and must still fall
72
- # under +base_dir+.
69
+ # under +base_dir+. Symlinks are resolved to their real paths to prevent escapes.
73
70
  #
74
71
  # @param path [String, #to_s] filesystem path
75
72
  # @param base_dir [String, #to_s] allowed root directory
73
+ # @param must_exist [Boolean] whether the target path must already exist (uses realpath on target vs parent)
76
74
  # @return [String] expanded absolute path within +base_dir+
77
75
  # @raise [ArgumentError] if +path+ is empty
78
76
  # @raise [SecurityError] if the expanded path escapes +base_dir+
79
- def validate_path!(path, base_dir = default_allowed_base_dir)
77
+ def validate_path!(path, base_dir = default_allowed_base_dir, must_exist: false)
80
78
  path_string = path.to_s
81
79
  raise ArgumentError, 'path must be non-empty' if path_string.empty?
82
80
 
83
- base = File.expand_path(base_dir.to_s)
81
+ base = File.realpath(base_dir.to_s)
84
82
  expanded = File.expand_path(path_string, base)
85
83
 
86
- prefix =
87
- if base.end_with?(File::SEPARATOR)
88
- base
89
- else
90
- "#{base}#{File::SEPARATOR}"
91
- end
84
+ resolved_expanded = resolve_symlink_aware_path(expanded)
92
85
 
93
- return expanded if expanded == base || expanded.start_with?(prefix)
86
+ prefix = base.end_with?(File::SEPARATOR) ? base : "#{base}#{File::SEPARATOR}"
87
+ raise SecurityError, "Path not allowed: #{path}" unless resolved_expanded == base || resolved_expanded.start_with?(prefix)
94
88
 
95
- raise SecurityError, "Path not allowed: #{path}"
89
+ raise Errno::ENOENT, "No such file or directory - #{expanded}" if must_exist && !File.exist?(expanded)
90
+
91
+ resolved_expanded
92
+ end
93
+
94
+ # Resolves a path while being sensitive to symlinks in any directory component.
95
+ # If the file doesn't exist, resolves its nearest existing ancestor.
96
+ #
97
+ # @param expanded [String] absolute expanded path
98
+ # @return [String] fully resolved realpath (or realpath-based expansion)
99
+ def resolve_symlink_aware_path(expanded)
100
+ existing_ancestor = find_existing_ancestor(expanded)
101
+ resolved_ancestor = File.realpath(existing_ancestor)
102
+
103
+ if File.exist?(expanded)
104
+ File.realpath(expanded)
105
+ else
106
+ # Append the relative difference from the existing ancestor to the target
107
+ relative_part = Pathname.new(expanded).relative_path_from(Pathname.new(existing_ancestor)).to_s
108
+ File.expand_path(relative_part, resolved_ancestor)
109
+ end
110
+ end
111
+
112
+ # Walks upward to find the nearest directory that actually exists on disk.
113
+ #
114
+ # @param path [String] starting path
115
+ # @return [String] nearest existing ancestor directory
116
+ def find_existing_ancestor(path)
117
+ ancestor = path
118
+ ancestor = File.dirname(ancestor) until File.exist?(ancestor) || root_directory?(ancestor)
119
+ ancestor
120
+ end
121
+
122
+ # @param path [String] path to check
123
+ # @return [Boolean] true if the path is a root directory
124
+ def root_directory?(path)
125
+ path == File::SEPARATOR || path.match?(/\A[A-Z]:\\?\z/i)
96
126
  end
97
127
 
98
128
  # @param path [String] file path (validated)
99
129
  # @param content [String] content to write
100
130
  # @return [Service::Result]
101
131
  def write_file(path:, content:)
102
- safe_path = validate_path!(path)
132
+ safe_path = validate_path!(path, must_exist: false)
103
133
  FileUtils.mkdir_p(File.dirname(safe_path))
104
134
  File.write(safe_path, content)
105
135
  Service::Result.new(true, data: { path: safe_path, bytes_written: content.bytesize })
@@ -110,7 +140,7 @@ module RailsAiBridge
110
140
  # @param path [String] file path (validated)
111
141
  # @return [Service::Result]
112
142
  def read_file(path:)
113
- safe_path = validate_path!(path)
143
+ safe_path = validate_path!(path, must_exist: true)
114
144
  content = File.read(safe_path)
115
145
  Service::Result.new(true, data: content)
116
146
  rescue SecurityError, StandardError => error
@@ -120,7 +150,7 @@ module RailsAiBridge
120
150
  # @param path [String] file path (validated)
121
151
  # @return [Service::Result]
122
152
  def delete_file(path:)
123
- safe_path = validate_path!(path)
153
+ safe_path = validate_path!(path, must_exist: true)
124
154
  File.delete(safe_path)
125
155
  Service::Result.new(true, data: { path: safe_path, deleted: true })
126
156
  rescue SecurityError, StandardError => error
@@ -130,10 +160,13 @@ module RailsAiBridge
130
160
  # @param path [String] file path (validated)
131
161
  # @return [Service::Result]
132
162
  def file_exists?(path:)
133
- safe_path = validate_path!(path)
163
+ safe_path = validate_path!(path, must_exist: true)
134
164
  exists = File.exist?(safe_path)
135
165
  Service::Result.new(true, data: exists)
136
166
  rescue SecurityError, StandardError => error
167
+ # Re-map ENOENT/SecurityError from validate_path! to a false result for exist?
168
+ return Service::Result.new(true, data: false) if error.is_a?(SecurityError) || error.is_a?(Errno::ENOENT)
169
+
137
170
  Service::Result.new(false, errors: [error.message])
138
171
  end
139
172
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiBridge
4
- VERSION = '3.1.1'
4
+ VERSION = '3.2.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-bridge
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Marin