rails_console_ai 0.13.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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +95 -0
  3. data/LICENSE +21 -0
  4. data/README.md +328 -0
  5. data/app/controllers/rails_console_ai/application_controller.rb +28 -0
  6. data/app/controllers/rails_console_ai/sessions_controller.rb +16 -0
  7. data/app/helpers/rails_console_ai/sessions_helper.rb +56 -0
  8. data/app/models/rails_console_ai/session.rb +23 -0
  9. data/app/views/layouts/rails_console_ai/application.html.erb +84 -0
  10. data/app/views/rails_console_ai/sessions/index.html.erb +57 -0
  11. data/app/views/rails_console_ai/sessions/show.html.erb +66 -0
  12. data/config/routes.rb +4 -0
  13. data/lib/generators/rails_console_ai/install_generator.rb +26 -0
  14. data/lib/generators/rails_console_ai/templates/initializer.rb +79 -0
  15. data/lib/rails_console_ai/channel/base.rb +23 -0
  16. data/lib/rails_console_ai/channel/console.rb +457 -0
  17. data/lib/rails_console_ai/channel/slack.rb +182 -0
  18. data/lib/rails_console_ai/configuration.rb +185 -0
  19. data/lib/rails_console_ai/console_methods.rb +277 -0
  20. data/lib/rails_console_ai/context_builder.rb +120 -0
  21. data/lib/rails_console_ai/conversation_engine.rb +1142 -0
  22. data/lib/rails_console_ai/engine.rb +5 -0
  23. data/lib/rails_console_ai/executor.rb +461 -0
  24. data/lib/rails_console_ai/providers/anthropic.rb +122 -0
  25. data/lib/rails_console_ai/providers/base.rb +118 -0
  26. data/lib/rails_console_ai/providers/bedrock.rb +171 -0
  27. data/lib/rails_console_ai/providers/local.rb +112 -0
  28. data/lib/rails_console_ai/providers/openai.rb +114 -0
  29. data/lib/rails_console_ai/railtie.rb +34 -0
  30. data/lib/rails_console_ai/repl.rb +65 -0
  31. data/lib/rails_console_ai/safety_guards.rb +207 -0
  32. data/lib/rails_console_ai/session_logger.rb +90 -0
  33. data/lib/rails_console_ai/slack_bot.rb +473 -0
  34. data/lib/rails_console_ai/storage/base.rb +27 -0
  35. data/lib/rails_console_ai/storage/file_storage.rb +63 -0
  36. data/lib/rails_console_ai/tools/code_tools.rb +126 -0
  37. data/lib/rails_console_ai/tools/memory_tools.rb +136 -0
  38. data/lib/rails_console_ai/tools/model_tools.rb +95 -0
  39. data/lib/rails_console_ai/tools/registry.rb +478 -0
  40. data/lib/rails_console_ai/tools/schema_tools.rb +60 -0
  41. data/lib/rails_console_ai/version.rb +3 -0
  42. data/lib/rails_console_ai.rb +214 -0
  43. data/lib/tasks/rails_console_ai.rake +7 -0
  44. metadata +152 -0
@@ -0,0 +1,171 @@
1
+ module RailsConsoleAI
2
+ module Providers
3
+ class Bedrock < Base
4
+ def chat(messages, system_prompt: nil)
5
+ call_api(messages, system_prompt: system_prompt)
6
+ end
7
+
8
+ def chat_with_tools(messages, tools:, system_prompt: nil)
9
+ call_api(messages, system_prompt: system_prompt, tools: tools)
10
+ end
11
+
12
+ def format_assistant_message(result)
13
+ content = []
14
+ content << { text: result.text } if result.text && !result.text.empty?
15
+ (result.tool_calls || []).each do |tc|
16
+ content << {
17
+ tool_use: {
18
+ tool_use_id: tc[:id],
19
+ name: tc[:name],
20
+ input: tc[:arguments]
21
+ }
22
+ }
23
+ end
24
+ { role: 'assistant', content: content }
25
+ end
26
+
27
+ def format_tool_result(tool_call_id, result_string)
28
+ {
29
+ role: 'user',
30
+ content: [
31
+ {
32
+ tool_result: {
33
+ tool_use_id: tool_call_id,
34
+ content: [{ text: result_string.to_s }]
35
+ }
36
+ }
37
+ ]
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def call_api(messages, system_prompt: nil, tools: nil)
44
+ params = {
45
+ model_id: config.resolved_model,
46
+ messages: format_messages(messages),
47
+ inference_config: {
48
+ max_tokens: config.resolved_max_tokens,
49
+ temperature: config.temperature
50
+ }
51
+ }
52
+ if system_prompt
53
+ sys_blocks = [{ text: system_prompt }]
54
+ sys_blocks << { cache_point: { type: 'default' } } if cache_supported?
55
+ params[:system] = sys_blocks
56
+ end
57
+ if tools
58
+ bedrock_tools = tools.to_bedrock_format
59
+ bedrock_tools << { cache_point: { type: 'default' } } if bedrock_tools.any? && cache_supported?
60
+ params[:tool_config] = { tools: bedrock_tools }
61
+ end
62
+
63
+ debug_bedrock_request(params)
64
+ response = client.converse(params)
65
+ debug_bedrock_response(response)
66
+
67
+ tool_calls = extract_tool_calls(response)
68
+ stop = response.stop_reason == 'tool_use' ? :tool_use : :end_turn
69
+
70
+ usage = response.usage
71
+ ChatResult.new(
72
+ text: extract_text(response),
73
+ input_tokens: usage&.input_tokens,
74
+ output_tokens: usage&.output_tokens,
75
+ cache_read_input_tokens: usage.respond_to?(:cache_read_input_token_count) ? usage.cache_read_input_token_count : nil,
76
+ cache_write_input_tokens: usage.respond_to?(:cache_write_input_token_count) ? usage.cache_write_input_token_count : nil,
77
+ tool_calls: tool_calls,
78
+ stop_reason: stop
79
+ )
80
+ rescue aws_error_class => e
81
+ raise ProviderError, "AWS Bedrock error: #{e.message}"
82
+ end
83
+
84
+ def client
85
+ @client ||= begin
86
+ unless defined?(Aws::BedrockRuntime::Client)
87
+ begin
88
+ require 'aws-sdk-bedrockruntime'
89
+ rescue LoadError
90
+ raise ProviderError,
91
+ "aws-sdk-bedrockruntime gem is required for the :bedrock provider. Add it to your Gemfile."
92
+ end
93
+ end
94
+ client_opts = {}
95
+ region = config.respond_to?(:bedrock_region) && config.bedrock_region
96
+ client_opts[:region] = region if region && !region.empty?
97
+ t = config.respond_to?(:resolved_timeout) ? config.resolved_timeout : config.timeout
98
+ client_opts[:http_read_timeout] = t
99
+ Aws::BedrockRuntime::Client.new(client_opts)
100
+ end
101
+ end
102
+
103
+ def cache_supported?
104
+ model = config.resolved_model
105
+ model.include?('anthropic')
106
+ end
107
+
108
+ def aws_error_class
109
+ if defined?(Aws::BedrockRuntime::Errors::ServiceError)
110
+ Aws::BedrockRuntime::Errors::ServiceError
111
+ else
112
+ # Fallback if the gem isn't loaded yet (shouldn't happen after client init)
113
+ StandardError
114
+ end
115
+ end
116
+
117
+ def format_messages(messages)
118
+ messages.map do |msg|
119
+ content = if msg[:content].is_a?(Array)
120
+ msg[:content]
121
+ else
122
+ [{ text: msg[:content].to_s }]
123
+ end
124
+ { role: msg[:role].to_s, content: content }
125
+ end
126
+ end
127
+
128
+ def extract_text(response)
129
+ content = response.output&.message&.content
130
+ return '' unless content.is_a?(Array)
131
+
132
+ content.select { |c| c.respond_to?(:text) && c.text }
133
+ .map(&:text)
134
+ .join("\n")
135
+ end
136
+
137
+ def extract_tool_calls(response)
138
+ content = response.output&.message&.content
139
+ return [] unless content.is_a?(Array)
140
+
141
+ content.select { |c| c.respond_to?(:tool_use) && c.tool_use }
142
+ .map do |c|
143
+ tu = c.tool_use
144
+ {
145
+ id: tu.tool_use_id,
146
+ name: tu.name,
147
+ arguments: tu.input || {}
148
+ }
149
+ end
150
+ end
151
+
152
+ def debug_bedrock_request(params)
153
+ return unless config.debug
154
+
155
+ msg_count = params[:messages]&.length || 0
156
+ sys_len = params.dig(:system, 0, :text).to_s.length
157
+ tool_count = params.dig(:tool_config, :tools)&.length || 0
158
+ $stderr.puts "\e[33m[debug] Bedrock converse | model: #{params[:model_id]} | #{msg_count} msgs | system: #{sys_len} chars | #{tool_count} tools\e[0m"
159
+ end
160
+
161
+ def debug_bedrock_response(response)
162
+ return unless config.debug
163
+
164
+ usage = response.usage
165
+ if usage
166
+ $stderr.puts "\e[36m[debug] response: #{response.stop_reason} | in: #{usage.input_tokens} out: #{usage.output_tokens}\e[0m"
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,112 @@
1
+ module RailsConsoleAI
2
+ module Providers
3
+ class Local < OpenAI
4
+ private
5
+
6
+ def call_api(messages, system_prompt: nil, tools: nil)
7
+ base_url = config.local_url
8
+
9
+ headers = { 'Content-Type' => 'application/json' }
10
+ api_key = config.local_api_key
11
+ if api_key && api_key != 'no-key' && !api_key.empty?
12
+ headers['Authorization'] = "Bearer #{api_key}"
13
+ end
14
+
15
+ conn = build_connection(base_url, headers)
16
+
17
+ formatted = []
18
+ formatted << { role: 'system', content: system_prompt } if system_prompt
19
+ formatted.concat(format_messages(messages))
20
+
21
+ body = {
22
+ model: config.resolved_model,
23
+ max_tokens: config.resolved_max_tokens,
24
+ temperature: config.temperature,
25
+ messages: formatted
26
+ }
27
+ body[:tools] = tools.to_openai_format if tools
28
+
29
+ estimated_input_tokens = estimate_tokens(formatted, system_prompt, tools)
30
+
31
+ json_body = JSON.generate(body)
32
+ debug_request("#{base_url}/v1/chat/completions", body)
33
+ response = conn.post('/v1/chat/completions', json_body)
34
+ debug_response(response.body)
35
+ data = parse_response(response)
36
+ usage = data['usage'] || {}
37
+
38
+ prompt_tokens = usage['prompt_tokens']
39
+ if prompt_tokens && estimated_input_tokens > 0 && prompt_tokens < estimated_input_tokens * 0.5
40
+ raise ProviderError,
41
+ "Context truncated by local server: sent ~#{estimated_input_tokens} estimated tokens " \
42
+ "but server only used #{prompt_tokens}. Increase the model's context window " \
43
+ "(e.g. num_ctx for Ollama) or reduce conversation length."
44
+ end
45
+
46
+ choice = (data['choices'] || []).first || {}
47
+ message = choice['message'] || {}
48
+ finish_reason = choice['finish_reason']
49
+
50
+ tool_calls = extract_tool_calls(message)
51
+
52
+ # Fallback: some local models (e.g. Ollama) emit tool calls as JSON
53
+ # in the content field instead of using the structured tool_calls format.
54
+ # Only match when the JSON "name" is a known tool name to avoid false positives.
55
+ if tool_calls.empty? && tools
56
+ tool_names = tools.to_openai_format.map { |t| t.dig('function', 'name') }.compact
57
+ text_calls = extract_tool_calls_from_text(message['content'], tool_names)
58
+ if text_calls.any?
59
+ tool_calls = text_calls
60
+ finish_reason = 'tool_calls'
61
+ message['content'] = ''
62
+ end
63
+ end
64
+
65
+ stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
66
+
67
+ ChatResult.new(
68
+ text: message['content'] || '',
69
+ input_tokens: usage['prompt_tokens'],
70
+ output_tokens: usage['completion_tokens'],
71
+ tool_calls: tool_calls,
72
+ stop_reason: stop
73
+ )
74
+ end
75
+
76
+ def estimate_tokens(messages, system_prompt, tools)
77
+ chars = system_prompt.to_s.length
78
+ messages.each { |m| chars += m[:content].to_s.length + (m[:tool_calls].to_s.length) }
79
+ chars += tools.to_openai_format.to_s.length if tools
80
+ chars / 4
81
+ end
82
+
83
+ # Parse tool calls emitted as JSON text in the content field.
84
+ # Only recognizes calls whose "name" matches a known tool name.
85
+ def extract_tool_calls_from_text(content, tool_names)
86
+ return [] if content.nil? || content.strip.empty?
87
+
88
+ text = content.strip
89
+ parsed = begin
90
+ JSON.parse(text)
91
+ rescue JSON::ParserError
92
+ match = text.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
93
+ match ? (JSON.parse(match[1]) rescue nil) : nil
94
+ end
95
+
96
+ return [] unless parsed
97
+
98
+ calls = parsed.is_a?(Array) ? parsed : [parsed]
99
+ calls.filter_map do |call|
100
+ next unless call.is_a?(Hash) && tool_names.include?(call['name'])
101
+ {
102
+ id: "local_#{SecureRandom.hex(4)}",
103
+ name: call['name'],
104
+ arguments: call['arguments'] || {}
105
+ }
106
+ end
107
+ rescue
108
+ []
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,114 @@
1
+ module RailsConsoleAI
2
+ module Providers
3
+ class OpenAI < Base
4
+ API_URL = 'https://api.openai.com'.freeze
5
+
6
+ def chat(messages, system_prompt: nil)
7
+ call_api(messages, system_prompt: system_prompt)
8
+ end
9
+
10
+ def chat_with_tools(messages, tools:, system_prompt: nil)
11
+ call_api(messages, system_prompt: system_prompt, tools: tools)
12
+ end
13
+
14
+ def format_assistant_message(result)
15
+ msg = { role: 'assistant' }
16
+ msg[:content] = result.text if result.text && !result.text.empty?
17
+ if result.tool_calls && !result.tool_calls.empty?
18
+ msg[:tool_calls] = result.tool_calls.map do |tc|
19
+ {
20
+ 'id' => tc[:id],
21
+ 'type' => 'function',
22
+ 'function' => {
23
+ 'name' => tc[:name],
24
+ 'arguments' => JSON.generate(tc[:arguments] || {})
25
+ }
26
+ }
27
+ end
28
+ end
29
+ msg
30
+ end
31
+
32
+ def format_tool_result(tool_call_id, result_string)
33
+ {
34
+ role: 'tool',
35
+ tool_call_id: tool_call_id,
36
+ content: result_string.to_s
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def call_api(messages, system_prompt: nil, tools: nil)
43
+ conn = build_connection(API_URL, {
44
+ 'Authorization' => "Bearer #{config.resolved_api_key}"
45
+ })
46
+
47
+ formatted = []
48
+ formatted << { role: 'system', content: system_prompt } if system_prompt
49
+ formatted.concat(format_messages(messages))
50
+
51
+ body = {
52
+ model: config.resolved_model,
53
+ max_tokens: config.resolved_max_tokens,
54
+ temperature: config.temperature,
55
+ messages: formatted
56
+ }
57
+ body[:tools] = tools.to_openai_format if tools
58
+
59
+ json_body = JSON.generate(body)
60
+ debug_request("#{API_URL}/v1/chat/completions", body)
61
+ response = conn.post('/v1/chat/completions', json_body)
62
+ debug_response(response.body)
63
+ data = parse_response(response)
64
+ usage = data['usage'] || {}
65
+
66
+ choice = (data['choices'] || []).first || {}
67
+ message = choice['message'] || {}
68
+ finish_reason = choice['finish_reason']
69
+
70
+ tool_calls = extract_tool_calls(message)
71
+ stop = finish_reason == 'tool_calls' ? :tool_use : :end_turn
72
+
73
+ ChatResult.new(
74
+ text: message['content'] || '',
75
+ input_tokens: usage['prompt_tokens'],
76
+ output_tokens: usage['completion_tokens'],
77
+ tool_calls: tool_calls,
78
+ stop_reason: stop
79
+ )
80
+ end
81
+
82
+ def format_messages(messages)
83
+ messages.map do |msg|
84
+ base = { role: msg[:role].to_s }
85
+ if msg[:content]
86
+ base[:content] = msg[:content].is_a?(Array) ? JSON.generate(msg[:content]) : msg[:content].to_s
87
+ end
88
+ base[:tool_calls] = msg[:tool_calls] if msg[:tool_calls]
89
+ base[:tool_call_id] = msg[:tool_call_id] if msg[:tool_call_id]
90
+ base
91
+ end
92
+ end
93
+
94
+ def extract_tool_calls(message)
95
+ calls = message['tool_calls']
96
+ return [] unless calls.is_a?(Array)
97
+
98
+ calls.map do |tc|
99
+ func = tc['function'] || {}
100
+ args = begin
101
+ JSON.parse(func['arguments'] || '{}')
102
+ rescue
103
+ {}
104
+ end
105
+ {
106
+ id: tc['id'],
107
+ name: func['name'],
108
+ arguments: args
109
+ }
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,34 @@
1
+ require 'rails_console_ai'
2
+
3
+ module RailsConsoleAI
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path('../tasks/rails_console_ai.rake', __dir__)
7
+ end
8
+
9
+ console do
10
+ require 'rails_console_ai/console_methods'
11
+
12
+ # Inject into IRB if available
13
+ if defined?(IRB::ExtendCommandBundle)
14
+ IRB::ExtendCommandBundle.include(RailsConsoleAI::ConsoleMethods)
15
+ end
16
+
17
+ # Extend TOPLEVEL_BINDING's receiver as well
18
+ TOPLEVEL_BINDING.eval('self').extend(RailsConsoleAI::ConsoleMethods)
19
+
20
+ # Welcome message
21
+ if $stdout.respond_to?(:tty?) && $stdout.tty?
22
+ $stdout.puts "\e[36m[RailsConsoleAI v#{RailsConsoleAI::VERSION}] AI assistant loaded. Try: ai \"show me all tables\"\e[0m"
23
+ end
24
+
25
+ # Pre-build context in background
26
+ Thread.new do
27
+ require 'rails_console_ai/context_builder'
28
+ RailsConsoleAI::ContextBuilder.new.build
29
+ rescue => e
30
+ RailsConsoleAI.logger.debug("RailsConsoleAI: background context build failed: #{e.message}")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,65 @@
1
+ require 'rails_console_ai/channel/console'
2
+ require 'rails_console_ai/conversation_engine'
3
+
4
+ module RailsConsoleAI
5
+ class Repl
6
+ def initialize(binding_context)
7
+ @binding_context = binding_context
8
+ @channel = Channel::Console.new
9
+ @engine = ConversationEngine.new(binding_context: binding_context, channel: @channel)
10
+ end
11
+
12
+ def one_shot(query)
13
+ @engine.one_shot(query)
14
+ end
15
+
16
+ def explain(query)
17
+ @engine.explain(query)
18
+ end
19
+
20
+ def init_guide
21
+ @engine.init_guide
22
+ end
23
+
24
+ def interactive
25
+ @channel.interactive_loop(@engine)
26
+ end
27
+
28
+ def resume(session)
29
+ @channel.resume_interactive(@engine, session)
30
+ end
31
+
32
+ # Expose engine internals for specs that inspect state
33
+ def instance_variable_get(name)
34
+ case name
35
+ when :@history
36
+ @engine.history
37
+ when :@executor
38
+ @engine.instance_variable_get(:@executor)
39
+ else
40
+ super
41
+ end
42
+ end
43
+
44
+ # Allow specs to set internal state
45
+ def instance_variable_set(name, value)
46
+ case name
47
+ when :@history
48
+ @engine.instance_variable_set(:@history, value)
49
+ else
50
+ super
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ # Expose send methods for spec compatibility
57
+ def send_query(query, conversation: nil)
58
+ @engine.send(:send_query, query, conversation: conversation)
59
+ end
60
+
61
+ def trim_old_outputs(messages)
62
+ @engine.send(:trim_old_outputs, messages)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,207 @@
1
+ module RailsConsoleAI
2
+ # Raised by safety guards to block dangerous operations.
3
+ # Host apps should raise this error in their custom guards.
4
+ # RailsConsoleAI will catch it and guide the user to use 'd' or /danger.
5
+ class SafetyError < StandardError
6
+ attr_reader :guard, :blocked_key
7
+
8
+ def initialize(message, guard: nil, blocked_key: nil)
9
+ super(message)
10
+ @guard = guard
11
+ @blocked_key = blocked_key
12
+ end
13
+ end
14
+
15
+ class SafetyGuards
16
+ attr_reader :guards
17
+
18
+ def initialize
19
+ @guards = {}
20
+ @enabled = true
21
+ @allowlist = {} # { guard_name => [String or Regexp, ...] }
22
+ end
23
+
24
+ def add(name, &block)
25
+ @guards[name.to_sym] = block
26
+ end
27
+
28
+ def remove(name)
29
+ @guards.delete(name.to_sym)
30
+ end
31
+
32
+ def enabled?
33
+ @enabled
34
+ end
35
+
36
+ def enable!
37
+ @enabled = true
38
+ end
39
+
40
+ def disable!
41
+ @enabled = false
42
+ end
43
+
44
+ def empty?
45
+ @guards.empty?
46
+ end
47
+
48
+ def names
49
+ @guards.keys
50
+ end
51
+
52
+ def allow(guard_name, key)
53
+ guard_name = guard_name.to_sym
54
+ @allowlist[guard_name] ||= []
55
+ @allowlist[guard_name] << key unless @allowlist[guard_name].include?(key)
56
+ end
57
+
58
+ def allowed?(guard_name, key)
59
+ entries = @allowlist[guard_name.to_sym]
60
+ return false unless entries
61
+
62
+ entries.any? do |entry|
63
+ case entry
64
+ when Regexp then key.match?(entry)
65
+ else entry.to_s == key.to_s
66
+ end
67
+ end
68
+ end
69
+
70
+ def allowlist
71
+ @allowlist
72
+ end
73
+
74
+ # Compose all guards around a block of code.
75
+ # Each guard is an around-block: guard.call { inner }
76
+ # Result: guard_1 { guard_2 { guard_3 { yield } } }
77
+ def wrap(&block)
78
+ return yield unless @enabled && !@guards.empty?
79
+
80
+ @guards.values.reduce(block) { |inner, guard|
81
+ -> { guard.call(&inner) }
82
+ }.call
83
+ end
84
+ end
85
+
86
+ # Built-in guard: database write prevention
87
+ # Works on all Rails versions (5+) and all database adapters.
88
+ # Prepends a write-intercepting module once, controlled by a thread-local flag.
89
+ module BuiltinGuards
90
+ # Blocks INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE
91
+ module WriteBlocker
92
+ WRITE_PATTERN = /\A\s*(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE)\b/i
93
+ TABLE_PATTERN = /\b(?:INTO|FROM|UPDATE|TABLE|TRUNCATE)\s+[`"]?(\w+)[`"]?/i
94
+
95
+ private
96
+
97
+ def rails_console_ai_check_write!(sql)
98
+ return unless Thread.current[:rails_console_ai_block_writes] && sql.match?(WRITE_PATTERN)
99
+
100
+ table = sql.match(TABLE_PATTERN)&.captures&.first
101
+ guards = RailsConsoleAI.configuration.safety_guards
102
+ return if table && guards.allowed?(:database_writes, table)
103
+
104
+ raise RailsConsoleAI::SafetyError.new(
105
+ "Database write blocked: #{sql.strip.split(/\s+/).first(3).join(' ')}...",
106
+ guard: :database_writes,
107
+ blocked_key: table
108
+ )
109
+ end
110
+
111
+ public
112
+
113
+ def execute(sql, *args, **kwargs)
114
+ rails_console_ai_check_write!(sql)
115
+ super
116
+ end
117
+
118
+ def exec_delete(sql, *args, **kwargs)
119
+ rails_console_ai_check_write!(sql)
120
+ super
121
+ end
122
+
123
+ def exec_update(sql, *args, **kwargs)
124
+ rails_console_ai_check_write!(sql)
125
+ super
126
+ end
127
+ end
128
+
129
+ def self.database_writes
130
+ ->(& block) {
131
+ ensure_write_blocker_installed!
132
+ Thread.current[:rails_console_ai_block_writes] = true
133
+ begin
134
+ block.call
135
+ ensure
136
+ Thread.current[:rails_console_ai_block_writes] = false
137
+ end
138
+ }
139
+ end
140
+
141
+ def self.ensure_write_blocker_installed!
142
+ return if @write_blocker_installed
143
+
144
+ connection = ActiveRecord::Base.connection
145
+ unless connection.class.ancestors.include?(WriteBlocker)
146
+ connection.class.prepend(WriteBlocker)
147
+ end
148
+ @write_blocker_installed = true
149
+ end
150
+
151
+ # Blocks non-safe HTTP requests (POST, PUT, PATCH, DELETE, etc.) via Net::HTTP.
152
+ # Since most Ruby HTTP libraries (HTTParty, RestClient, Faraday) use Net::HTTP
153
+ # under the hood, this covers them all.
154
+ module HttpBlocker
155
+ SAFE_METHODS = %w[GET HEAD OPTIONS TRACE].freeze
156
+
157
+ def request(req, *args, &block)
158
+ if Thread.current[:rails_console_ai_block_http] && !SAFE_METHODS.include?(req.method)
159
+ host = @address.to_s
160
+ guards = RailsConsoleAI.configuration.safety_guards
161
+ unless guards.allowed?(:http_mutations, host)
162
+ raise RailsConsoleAI::SafetyError.new(
163
+ "HTTP #{req.method} blocked (#{host}#{req.path})",
164
+ guard: :http_mutations,
165
+ blocked_key: host
166
+ )
167
+ end
168
+ end
169
+ super
170
+ end
171
+ end
172
+
173
+ def self.http_mutations
174
+ ->(&block) {
175
+ ensure_http_blocker_installed!
176
+ Thread.current[:rails_console_ai_block_http] = true
177
+ begin
178
+ block.call
179
+ ensure
180
+ Thread.current[:rails_console_ai_block_http] = false
181
+ end
182
+ }
183
+ end
184
+
185
+ def self.mailers
186
+ ->(&block) {
187
+ old_value = ActionMailer::Base.perform_deliveries
188
+ ActionMailer::Base.perform_deliveries = false
189
+ begin
190
+ block.call
191
+ ensure
192
+ ActionMailer::Base.perform_deliveries = old_value
193
+ end
194
+ }
195
+ end
196
+
197
+ def self.ensure_http_blocker_installed!
198
+ return if @http_blocker_installed
199
+
200
+ require 'net/http'
201
+ unless Net::HTTP.ancestors.include?(HttpBlocker)
202
+ Net::HTTP.prepend(HttpBlocker)
203
+ end
204
+ @http_blocker_installed = true
205
+ end
206
+ end
207
+ end