rails-active-mcp 0.1.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 +7 -0
- data/.idea/.gitignore +8 -0
- data/.idea/discord.xml +7 -0
- data/.idea/modules.xml +8 -0
- data/.idea/rails-active-mcp-gem.iml +111 -0
- data/.idea/vcs.xml +6 -0
- data/README.md +369 -0
- data/changelog.md +59 -0
- data/docs/README.md +185 -0
- data/exe/rails-active-mcp-server +24 -0
- data/lib/generators/rails_active_mcp/install/install_generator.rb +37 -0
- data/lib/generators/rails_active_mcp/install/templates/README.md +60 -0
- data/lib/generators/rails_active_mcp/install/templates/initializer.rb +39 -0
- data/lib/generators/rails_active_mcp/install/templates/mcp.ru +7 -0
- data/lib/rails_active_mcp/configuration.rb +95 -0
- data/lib/rails_active_mcp/console_executor.rb +378 -0
- data/lib/rails_active_mcp/engine.rb +32 -0
- data/lib/rails_active_mcp/mcp_server.rb +374 -0
- data/lib/rails_active_mcp/railtie.rb +48 -0
- data/lib/rails_active_mcp/safety_checker.rb +149 -0
- data/lib/rails_active_mcp/tasks.rake +154 -0
- data/lib/rails_active_mcp/tools/console_execute_tool.rb +61 -0
- data/lib/rails_active_mcp/tools/dry_run_tool.rb +41 -0
- data/lib/rails_active_mcp/tools/model_info_tool.rb +70 -0
- data/lib/rails_active_mcp/tools/safe_query_tool.rb +41 -0
- data/lib/rails_active_mcp/version.rb +5 -0
- data/lib/rails_active_mcp.rb +59 -0
- data/mcp.ru +5 -0
- data/rails_active_mcp.gemspec +49 -0
- metadata +241 -0
@@ -0,0 +1,374 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'rack'
|
5
|
+
|
6
|
+
module RailsActiveMcp
|
7
|
+
class McpServer
|
8
|
+
JSONRPC_VERSION = '2.0'
|
9
|
+
MCP_VERSION = '2025-06-18'
|
10
|
+
|
11
|
+
def initialize(app = nil)
|
12
|
+
@app = app
|
13
|
+
@tools = {}
|
14
|
+
@resources = {}
|
15
|
+
register_default_tools
|
16
|
+
end
|
17
|
+
|
18
|
+
def call(env)
|
19
|
+
request = Rack::Request.new(env)
|
20
|
+
|
21
|
+
return [405, {}, ['Method Not Allowed']] unless request.post?
|
22
|
+
return [400, {}, ['Invalid Content-Type']] unless json_request?(request)
|
23
|
+
|
24
|
+
begin
|
25
|
+
body = request.body.read
|
26
|
+
data = JSON.parse(body)
|
27
|
+
response = handle_jsonrpc_request(data)
|
28
|
+
|
29
|
+
[200, {'Content-Type' => 'application/json'}, [response.to_json]]
|
30
|
+
rescue JSON::ParserError
|
31
|
+
error_response(400, 'Invalid JSON')
|
32
|
+
rescue => e
|
33
|
+
Rails.logger.error "MCP Server Error: #{e.message}" if defined?(Rails)
|
34
|
+
error_response(500, 'Internal Server Error')
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def json_request?(request)
|
41
|
+
request.content_type&.include?('application/json')
|
42
|
+
end
|
43
|
+
|
44
|
+
def handle_jsonrpc_request(data)
|
45
|
+
case data['method']
|
46
|
+
when 'initialize'
|
47
|
+
handle_initialize(data)
|
48
|
+
when 'tools/list'
|
49
|
+
handle_tools_list(data)
|
50
|
+
when 'tools/call'
|
51
|
+
handle_tools_call(data)
|
52
|
+
when 'resources/list'
|
53
|
+
handle_resources_list(data)
|
54
|
+
when 'resources/read'
|
55
|
+
handle_resources_read(data)
|
56
|
+
else
|
57
|
+
jsonrpc_error(data['id'], -32601, 'Method not found')
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def handle_initialize(data)
|
62
|
+
{
|
63
|
+
jsonrpc: JSONRPC_VERSION,
|
64
|
+
id: data['id'],
|
65
|
+
result: {
|
66
|
+
protocolVersion: MCP_VERSION,
|
67
|
+
capabilities: {
|
68
|
+
tools: {
|
69
|
+
list: true,
|
70
|
+
call: true
|
71
|
+
},
|
72
|
+
resources: {
|
73
|
+
read: true,
|
74
|
+
list: true
|
75
|
+
}
|
76
|
+
},
|
77
|
+
serverInfo: {
|
78
|
+
name: 'rails-active-mcp',
|
79
|
+
version: RailsActiveMcp::VERSION
|
80
|
+
}
|
81
|
+
}
|
82
|
+
}
|
83
|
+
end
|
84
|
+
|
85
|
+
def handle_tools_list(data)
|
86
|
+
tools_array = @tools.values.map do |tool|
|
87
|
+
tool_def = {
|
88
|
+
name: tool[:name],
|
89
|
+
description: tool[:description],
|
90
|
+
inputSchema: tool[:input_schema]
|
91
|
+
}
|
92
|
+
|
93
|
+
# Add annotations if present
|
94
|
+
if tool[:annotations] && !tool[:annotations].empty?
|
95
|
+
tool_def[:annotations] = tool[:annotations]
|
96
|
+
end
|
97
|
+
|
98
|
+
tool_def
|
99
|
+
end
|
100
|
+
|
101
|
+
{
|
102
|
+
jsonrpc: JSONRPC_VERSION,
|
103
|
+
id: data['id'],
|
104
|
+
result: { tools: tools_array }
|
105
|
+
}
|
106
|
+
end
|
107
|
+
|
108
|
+
def handle_tools_call(data)
|
109
|
+
tool_name = data.dig('params', 'name')
|
110
|
+
arguments = data.dig('params', 'arguments') || {}
|
111
|
+
|
112
|
+
tool = @tools[tool_name]
|
113
|
+
return jsonrpc_error(data['id'], -32602, "Tool '#{tool_name}' not found") unless tool
|
114
|
+
|
115
|
+
begin
|
116
|
+
result = tool[:handler].call(arguments)
|
117
|
+
{
|
118
|
+
jsonrpc: JSONRPC_VERSION,
|
119
|
+
id: data['id'],
|
120
|
+
result: { content: [{ type: 'text', text: result.to_s }] }
|
121
|
+
}
|
122
|
+
rescue => e
|
123
|
+
jsonrpc_error(data['id'], -32603, "Tool execution failed: #{e.message}")
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def handle_resources_list(data)
|
128
|
+
{
|
129
|
+
jsonrpc: JSONRPC_VERSION,
|
130
|
+
id: data['id'],
|
131
|
+
result: { resources: [] }
|
132
|
+
}
|
133
|
+
end
|
134
|
+
|
135
|
+
def handle_resources_read(data)
|
136
|
+
{
|
137
|
+
jsonrpc: JSONRPC_VERSION,
|
138
|
+
id: data['id'],
|
139
|
+
result: { contents: [] }
|
140
|
+
}
|
141
|
+
end
|
142
|
+
|
143
|
+
def register_tool(name, description, input_schema, annotations = {}, &handler)
|
144
|
+
@tools[name] = {
|
145
|
+
name: name,
|
146
|
+
description: description,
|
147
|
+
input_schema: input_schema,
|
148
|
+
annotations: annotations,
|
149
|
+
handler: handler
|
150
|
+
}
|
151
|
+
end
|
152
|
+
|
153
|
+
def register_default_tools
|
154
|
+
register_tool(
|
155
|
+
'rails_console_execute',
|
156
|
+
'Execute Ruby code in Rails console context',
|
157
|
+
{
|
158
|
+
type: 'object',
|
159
|
+
properties: {
|
160
|
+
code: { type: 'string', description: 'Ruby code to execute' },
|
161
|
+
timeout: { type: 'number', description: 'Timeout in seconds', default: 30 },
|
162
|
+
safe_mode: { type: 'boolean', description: 'Enable safety checks', default: true },
|
163
|
+
capture_output: { type: 'boolean', description: 'Capture console output', default: true }
|
164
|
+
},
|
165
|
+
required: ['code']
|
166
|
+
},
|
167
|
+
# Add MCP tool annotations
|
168
|
+
{
|
169
|
+
title: 'Rails Console Executor',
|
170
|
+
readOnlyHint: false,
|
171
|
+
destructiveHint: true,
|
172
|
+
idempotentHint: false,
|
173
|
+
openWorldHint: false
|
174
|
+
}
|
175
|
+
) do |args|
|
176
|
+
execute_console_code(args)
|
177
|
+
end
|
178
|
+
|
179
|
+
register_tool(
|
180
|
+
'rails_model_info',
|
181
|
+
'Get information about Rails models',
|
182
|
+
{
|
183
|
+
type: 'object',
|
184
|
+
properties: {
|
185
|
+
model_name: { type: 'string', description: 'Name of the model to inspect' }
|
186
|
+
},
|
187
|
+
required: ['model_name']
|
188
|
+
},
|
189
|
+
# Safe read-only tool
|
190
|
+
{
|
191
|
+
title: 'Rails Model Inspector',
|
192
|
+
readOnlyHint: true,
|
193
|
+
destructiveHint: false,
|
194
|
+
idempotentHint: true,
|
195
|
+
openWorldHint: false
|
196
|
+
}
|
197
|
+
) do |args|
|
198
|
+
get_model_info(args['model_name'])
|
199
|
+
end
|
200
|
+
|
201
|
+
register_tool(
|
202
|
+
'rails_safe_query',
|
203
|
+
'Execute safe read-only database queries',
|
204
|
+
{
|
205
|
+
type: 'object',
|
206
|
+
properties: {
|
207
|
+
query: { type: 'string', description: 'Safe query to execute' },
|
208
|
+
model: { type: 'string', description: 'Model class name' }
|
209
|
+
},
|
210
|
+
required: ['query', 'model']
|
211
|
+
},
|
212
|
+
# Safe read-only query tool
|
213
|
+
{
|
214
|
+
title: 'Rails Safe Query Executor',
|
215
|
+
readOnlyHint: true,
|
216
|
+
destructiveHint: false,
|
217
|
+
idempotentHint: true,
|
218
|
+
openWorldHint: false
|
219
|
+
}
|
220
|
+
) do |args|
|
221
|
+
execute_safe_query(args)
|
222
|
+
end
|
223
|
+
|
224
|
+
register_tool(
|
225
|
+
'rails_dry_run',
|
226
|
+
'Analyze Ruby code safety without execution',
|
227
|
+
{
|
228
|
+
type: 'object',
|
229
|
+
properties: {
|
230
|
+
code: { type: 'string', description: 'Ruby code to analyze' }
|
231
|
+
},
|
232
|
+
required: ['code']
|
233
|
+
},
|
234
|
+
{
|
235
|
+
title: 'Rails Code Safety Analyzer',
|
236
|
+
readOnlyHint: true,
|
237
|
+
destructiveHint: false,
|
238
|
+
idempotentHint: true,
|
239
|
+
openWorldHint: false
|
240
|
+
}
|
241
|
+
) do |args|
|
242
|
+
dry_run_analysis(args['code'])
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def execute_console_code(args)
|
247
|
+
return "Rails Active MCP is disabled" unless RailsActiveMcp.config.enabled
|
248
|
+
|
249
|
+
executor = RailsActiveMcp::ConsoleExecutor.new(RailsActiveMcp.config)
|
250
|
+
|
251
|
+
begin
|
252
|
+
result = executor.execute(
|
253
|
+
args['code'],
|
254
|
+
timeout: args['timeout'] || 30,
|
255
|
+
safe_mode: args['safe_mode'] != false,
|
256
|
+
capture_output: args['capture_output'] != false
|
257
|
+
)
|
258
|
+
|
259
|
+
if result[:success]
|
260
|
+
format_success_result(result)
|
261
|
+
else
|
262
|
+
"Error: #{result[:error]} (#{result[:error_class]})"
|
263
|
+
end
|
264
|
+
rescue RailsActiveMcp::SafetyError => e
|
265
|
+
"Safety check failed: #{e.message}"
|
266
|
+
rescue RailsActiveMcp::TimeoutError => e
|
267
|
+
"Execution timed out: #{e.message}"
|
268
|
+
rescue => e
|
269
|
+
"Execution failed: #{e.message}"
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
def get_model_info(model_name)
|
274
|
+
return "Rails Active MCP is disabled" unless RailsActiveMcp.config.enabled
|
275
|
+
|
276
|
+
begin
|
277
|
+
model_class = model_name.constantize
|
278
|
+
return "#{model_name} is not an ActiveRecord model" unless model_class < ActiveRecord::Base
|
279
|
+
|
280
|
+
info = []
|
281
|
+
info << "Model: #{model_class.name}"
|
282
|
+
info << "Table: #{model_class.table_name}"
|
283
|
+
info << "Columns: #{model_class.column_names.join(', ')}"
|
284
|
+
info << "Associations: #{model_class.reflect_on_all_associations.map(&:name).join(', ')}"
|
285
|
+
info.join("\n")
|
286
|
+
rescue NameError
|
287
|
+
"Model '#{model_name}' not found"
|
288
|
+
rescue => e
|
289
|
+
"Error getting model info: #{e.message}"
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
def execute_safe_query(args)
|
294
|
+
return "Rails Active MCP is disabled" unless RailsActiveMcp.config.enabled
|
295
|
+
|
296
|
+
begin
|
297
|
+
model_class = args['model'].constantize
|
298
|
+
return "#{args['model']} is not an ActiveRecord model" unless model_class < ActiveRecord::Base
|
299
|
+
|
300
|
+
# Only allow safe read-only methods
|
301
|
+
safe_methods = %w[find find_by where select count sum average maximum minimum first last pluck ids exists? empty? any? many? include?]
|
302
|
+
query_method = args['query'].split('.').first
|
303
|
+
|
304
|
+
return "Unsafe query method: #{query_method}" unless safe_methods.include?(query_method)
|
305
|
+
|
306
|
+
result = model_class.instance_eval(args['query'])
|
307
|
+
result.to_s
|
308
|
+
rescue NameError
|
309
|
+
"Model '#{args['model']}' not found"
|
310
|
+
rescue => e
|
311
|
+
"Error executing query: #{e.message}"
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
def dry_run_analysis(code)
|
316
|
+
return "Rails Active MCP is disabled" unless RailsActiveMcp.config.enabled
|
317
|
+
|
318
|
+
executor = RailsActiveMcp::ConsoleExecutor.new(RailsActiveMcp.config)
|
319
|
+
|
320
|
+
begin
|
321
|
+
analysis = executor.dry_run(code)
|
322
|
+
|
323
|
+
output = []
|
324
|
+
output << "Code: #{analysis[:code]}"
|
325
|
+
output << "Safe: #{analysis[:safety_analysis][:safe] ? 'Yes' : 'No'}"
|
326
|
+
output << "Read-only: #{analysis[:safety_analysis][:read_only] ? 'Yes' : 'No'}"
|
327
|
+
output << "Risk level: #{analysis[:estimated_risk]}"
|
328
|
+
output << "Would execute: #{analysis[:would_execute] ? 'Yes' : 'No'}"
|
329
|
+
output << "Summary: #{analysis[:safety_analysis][:summary]}"
|
330
|
+
|
331
|
+
if analysis[:safety_analysis][:violations].any?
|
332
|
+
output << "\nViolations:"
|
333
|
+
analysis[:safety_analysis][:violations].each do |violation|
|
334
|
+
output << " - #{violation[:description]} (#{violation[:severity]})"
|
335
|
+
end
|
336
|
+
end
|
337
|
+
|
338
|
+
if analysis[:recommendations].any?
|
339
|
+
output << "\nRecommendations:"
|
340
|
+
analysis[:recommendations].each do |rec|
|
341
|
+
output << " - #{rec}"
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
output.join("\n")
|
346
|
+
rescue => e
|
347
|
+
"Analysis failed: #{e.message}"
|
348
|
+
end
|
349
|
+
end
|
350
|
+
|
351
|
+
def format_success_result(result)
|
352
|
+
output = []
|
353
|
+
output << "Code: #{result[:code]}"
|
354
|
+
output << "Result: #{result[:return_value_string] || result[:return_value]}"
|
355
|
+
output << "Output: #{result[:output]}" if result[:output].present?
|
356
|
+
output << "Execution time: #{result[:execution_time]}s" if result[:execution_time]
|
357
|
+
output << "Note: #{result[:note]}" if result[:note]
|
358
|
+
output.join("\n")
|
359
|
+
end
|
360
|
+
|
361
|
+
def jsonrpc_error(id, code, message)
|
362
|
+
{
|
363
|
+
jsonrpc: JSONRPC_VERSION,
|
364
|
+
id: id,
|
365
|
+
error: { code: code, message: message }
|
366
|
+
}
|
367
|
+
end
|
368
|
+
|
369
|
+
def error_response(status, message)
|
370
|
+
[status, {'Content-Type' => 'application/json'},
|
371
|
+
[{ error: message }.to_json]]
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsActiveMcp
|
4
|
+
class Railtie < ::Rails::Railtie
|
5
|
+
railtie_name :rails_active_mcp
|
6
|
+
|
7
|
+
# Ensure configuration is available very early
|
8
|
+
config.before_initialize do
|
9
|
+
RailsActiveMcp.configure unless RailsActiveMcp.configuration
|
10
|
+
end
|
11
|
+
|
12
|
+
# Add rake tasks
|
13
|
+
rake_tasks do
|
14
|
+
load 'rails_active_mcp/tasks.rake'
|
15
|
+
end
|
16
|
+
|
17
|
+
# Add generators
|
18
|
+
generators do
|
19
|
+
# Generators are auto-discovered from lib/generators following Rails conventions
|
20
|
+
end
|
21
|
+
|
22
|
+
# Console hook for easier access
|
23
|
+
console do
|
24
|
+
# Add convenience methods to console
|
25
|
+
Rails::ConsoleMethods.include(RailsActiveMcp::ConsoleMethods) if defined?(Rails::ConsoleMethods)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Configure logging
|
29
|
+
initializer 'rails_active_mcp.logger' do
|
30
|
+
RailsActiveMcp.logger = Rails.logger if defined?(Rails.logger)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Console convenience methods
|
35
|
+
module ConsoleMethods
|
36
|
+
def mcp_execute(code, **options)
|
37
|
+
RailsActiveMcp.execute(code, **options)
|
38
|
+
end
|
39
|
+
|
40
|
+
def mcp_safe?(code)
|
41
|
+
RailsActiveMcp.safe?(code)
|
42
|
+
end
|
43
|
+
|
44
|
+
def mcp_config
|
45
|
+
RailsActiveMcp.config
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsActiveMcp
|
4
|
+
class SafetyChecker
|
5
|
+
DANGEROUS_PATTERNS = [
|
6
|
+
{ pattern: /\.delete_all\b/, description: 'Mass deletion of records', severity: :high },
|
7
|
+
{ pattern: /\.destroy_all\b/, description: 'Mass destruction of records', severity: :high },
|
8
|
+
{ pattern: /\.drop\b/, description: 'Database table dropping', severity: :critical },
|
9
|
+
{ pattern: /system\s*\(/, description: 'System command execution', severity: :critical },
|
10
|
+
{ pattern: /Kernel\.system\s*\(/, description: 'Kernel system command execution', severity: :critical },
|
11
|
+
{ pattern: /exec\s*\(/, description: 'Process execution', severity: :critical },
|
12
|
+
{ pattern: /`[^`]*`/, description: 'Shell command execution', severity: :critical },
|
13
|
+
{ pattern: /File\.delete/, description: 'File deletion', severity: :high },
|
14
|
+
{ pattern: /FileUtils\./, description: 'File system operations', severity: :high },
|
15
|
+
{ pattern: /Dir\.delete/, description: 'Directory deletion', severity: :high },
|
16
|
+
{ pattern: /ActiveRecord::Base\.connection\.execute/, description: 'Raw SQL execution', severity: :medium },
|
17
|
+
{ pattern: /\.update_all\(/, description: 'Mass update without callbacks', severity: :medium },
|
18
|
+
{ pattern: /eval\s*\(/, description: 'Dynamic code evaluation', severity: :high },
|
19
|
+
{ pattern: /send\s*\(/, description: 'Dynamic method calling', severity: :medium },
|
20
|
+
{ pattern: /const_set/, description: 'Dynamic constant definition', severity: :medium },
|
21
|
+
{ pattern: /remove_const/, description: 'Constant removal', severity: :high },
|
22
|
+
{ pattern: /undef_method/, description: 'Method removal', severity: :high },
|
23
|
+
{ pattern: /alias_method/, description: 'Method aliasing', severity: :medium },
|
24
|
+
{ pattern: /load\s*\(/, description: 'Code loading', severity: :medium },
|
25
|
+
{ pattern: /require\s*\(/, description: 'Library requiring', severity: :low },
|
26
|
+
{ pattern: /exit/, description: 'Process termination', severity: :high },
|
27
|
+
{ pattern: /abort/, description: 'Process abortion', severity: :high },
|
28
|
+
{ pattern: /fork/, description: 'Process forking', severity: :high },
|
29
|
+
{ pattern: /Thread\.new/, description: 'Thread creation', severity: :medium },
|
30
|
+
{ pattern: /\$LOAD_PATH/, description: 'Load path manipulation', severity: :medium },
|
31
|
+
{ pattern: /ENV\[/, description: 'Environment variable access', severity: :low },
|
32
|
+
{ pattern: /Rails\.env\s*=/, description: 'Environment changing', severity: :high },
|
33
|
+
{ pattern: /Rails\.application\.secrets/, description: 'Secrets access', severity: :medium }
|
34
|
+
].freeze
|
35
|
+
|
36
|
+
READ_ONLY_PATTERNS = [
|
37
|
+
/\.(find|find_by|find_each|find_in_batches)\b/,
|
38
|
+
/\.(where|all|first|last|take)\b/,
|
39
|
+
/\.(count|sum|average|maximum|minimum|size|length)\b/,
|
40
|
+
/\.(pluck|ids|exists\?|empty\?|any\?|many\?)\b/,
|
41
|
+
/\.(select|distinct|group|order|limit|offset)\b/,
|
42
|
+
/\.(includes|joins|left_joins|preload|eager_load)\b/,
|
43
|
+
/\.(to_a|to_sql|explain|inspect|as_json|to_json)\b/,
|
44
|
+
/\.(attributes|attribute_names|column_names)\b/,
|
45
|
+
/\.model_name\b/,
|
46
|
+
/\.table_name\b/,
|
47
|
+
/\.primary_key\b/,
|
48
|
+
/\.connection\.schema_cache/,
|
49
|
+
/Rails\.(env|root|application\.class|version)/
|
50
|
+
].freeze
|
51
|
+
|
52
|
+
def initialize(config)
|
53
|
+
@config = config
|
54
|
+
end
|
55
|
+
|
56
|
+
def safe?(code)
|
57
|
+
|
58
|
+
analysis = analyze(code)
|
59
|
+
analysis[:safe]
|
60
|
+
end
|
61
|
+
|
62
|
+
def analyze(code)
|
63
|
+
violations = []
|
64
|
+
|
65
|
+
# Check against dangerous patterns
|
66
|
+
dangerous_patterns.each do |pattern_info|
|
67
|
+
violations << pattern_info if code.match?(pattern_info[:pattern])
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check custom patterns
|
71
|
+
@config.custom_safety_patterns.each do |custom_pattern|
|
72
|
+
next unless code.match?(custom_pattern[:pattern])
|
73
|
+
|
74
|
+
violations << {
|
75
|
+
pattern: custom_pattern[:pattern],
|
76
|
+
description: custom_pattern[:description] || 'Custom safety rule',
|
77
|
+
severity: :custom
|
78
|
+
}
|
79
|
+
end
|
80
|
+
|
81
|
+
# Determine if code is read-only
|
82
|
+
read_only = read_only?(code)
|
83
|
+
|
84
|
+
# Calculate safety
|
85
|
+
critical_violations = violations.select { |v| v[:severity] == :critical }
|
86
|
+
high_violations = violations.select { |v| v[:severity] == :high }
|
87
|
+
|
88
|
+
safe = (@config.safe_mode && read_only && critical_violations.empty? && high_violations.empty?) ||
|
89
|
+
(!@config.safe_mode && critical_violations.empty?)
|
90
|
+
|
91
|
+
{
|
92
|
+
safe: safe,
|
93
|
+
read_only: read_only,
|
94
|
+
violations: violations,
|
95
|
+
summary: generate_summary(violations, read_only)
|
96
|
+
}
|
97
|
+
end
|
98
|
+
|
99
|
+
def read_only?(code)
|
100
|
+
# Must contain at least one read-only pattern
|
101
|
+
has_read_only = READ_ONLY_PATTERNS.any? { |pattern| code.match?(pattern) }
|
102
|
+
|
103
|
+
# Must not contain any obvious mutation patterns
|
104
|
+
mutation_patterns = [
|
105
|
+
/\.(save|create|update|delete|destroy)\b/,
|
106
|
+
/\.(save!|create!|update!|delete!|destroy!)\b/,
|
107
|
+
/\.reload\b/,
|
108
|
+
/\.transaction\b/,
|
109
|
+
/=\s*[^=]/ # Assignment (basic check)
|
110
|
+
]
|
111
|
+
|
112
|
+
has_mutations = mutation_patterns.any? { |pattern| code.match?(pattern) }
|
113
|
+
|
114
|
+
has_read_only && !has_mutations
|
115
|
+
end
|
116
|
+
|
117
|
+
private
|
118
|
+
|
119
|
+
def dangerous_patterns
|
120
|
+
base_patterns = DANGEROUS_PATTERNS.dup
|
121
|
+
|
122
|
+
# Add custom patterns from config
|
123
|
+
@config.custom_safety_patterns.each do |custom|
|
124
|
+
base_patterns << {
|
125
|
+
pattern: custom[:pattern],
|
126
|
+
description: custom[:description] || 'Custom rule',
|
127
|
+
severity: :custom
|
128
|
+
}
|
129
|
+
end
|
130
|
+
|
131
|
+
base_patterns
|
132
|
+
end
|
133
|
+
|
134
|
+
def generate_summary(violations, read_only)
|
135
|
+
if violations.empty?
|
136
|
+
read_only ? 'Code appears safe and read-only' : 'Code appears safe'
|
137
|
+
else
|
138
|
+
severity_counts = violations.group_by { |v| v[:severity] }.transform_values(&:count)
|
139
|
+
parts = []
|
140
|
+
|
141
|
+
severity_counts.each do |severity, count|
|
142
|
+
parts << "#{count} #{severity} violation#{'s' if count > 1}"
|
143
|
+
end
|
144
|
+
|
145
|
+
"Found #{parts.join(', ')}"
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|