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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +2 -2
- data/lib/rails_ai_bridge/introspectors/convention_detector.rb +1 -0
- data/lib/rails_ai_bridge/introspectors/non_ar_models_introspector.rb +6 -5
- data/lib/rails_ai_bridge/services/file_management_service.rb +57 -24
- data/lib/rails_ai_bridge/version.rb +1 -1
- 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: 181f3d197682eead1f7d4bcdbaf9ee507ee4604ec3bfd21b46a5255dce77d43c
|
|
4
|
+
data.tar.gz: 177d42895020fe221a3d44523392ae21500928d47b7a400691975ff55cc24e34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
547
|
+
| `rails ai:watch` | Auto-regenerate bridge files on code changes |
|
|
548
548
|
| `rails ai:inspect` | Print introspection summary to stdout |
|
|
549
549
|
|
|
550
|
-
> **
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
when :
|
|
48
|
-
|
|
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.
|
|
81
|
+
base = File.realpath(base_dir.to_s)
|
|
84
82
|
expanded = File.expand_path(path_string, base)
|
|
85
83
|
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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
|