console_agent 0.10.0 → 0.12.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.
@@ -0,0 +1,207 @@
1
+ module ConsoleAgent
2
+ # Raised by safety guards to block dangerous operations.
3
+ # Host apps should raise this error in their custom guards.
4
+ # ConsoleAgent 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 console_agent_check_write!(sql)
98
+ return unless Thread.current[:console_agent_block_writes] && sql.match?(WRITE_PATTERN)
99
+
100
+ table = sql.match(TABLE_PATTERN)&.captures&.first
101
+ guards = ConsoleAgent.configuration.safety_guards
102
+ return if table && guards.allowed?(:database_writes, table)
103
+
104
+ raise ConsoleAgent::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
+ console_agent_check_write!(sql)
115
+ super
116
+ end
117
+
118
+ def exec_delete(sql, *args, **kwargs)
119
+ console_agent_check_write!(sql)
120
+ super
121
+ end
122
+
123
+ def exec_update(sql, *args, **kwargs)
124
+ console_agent_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[:console_agent_block_writes] = true
133
+ begin
134
+ block.call
135
+ ensure
136
+ Thread.current[:console_agent_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[:console_agent_block_http] && !SAFE_METHODS.include?(req.method)
159
+ host = @address.to_s
160
+ guards = ConsoleAgent.configuration.safety_guards
161
+ unless guards.allowed?(:http_mutations, host)
162
+ raise ConsoleAgent::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[:console_agent_block_http] = true
177
+ begin
178
+ block.call
179
+ ensure
180
+ Thread.current[:console_agent_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
@@ -5,12 +5,12 @@ module ConsoleAgent
5
5
  return unless ConsoleAgent.configuration.session_logging
6
6
  return unless table_exists?
7
7
 
8
- record = session_class.create!(
8
+ create_attrs = {
9
9
  query: attrs[:query],
10
10
  conversation: Array(attrs[:conversation]).to_json,
11
11
  input_tokens: attrs[:input_tokens] || 0,
12
12
  output_tokens: attrs[:output_tokens] || 0,
13
- user_name: current_user_name,
13
+ user_name: attrs[:user_name] || current_user_name,
14
14
  mode: attrs[:mode].to_s,
15
15
  name: attrs[:name],
16
16
  code_executed: attrs[:code_executed],
@@ -22,7 +22,9 @@ module ConsoleAgent
22
22
  model: ConsoleAgent.configuration.resolved_model,
23
23
  duration_ms: attrs[:duration_ms],
24
24
  created_at: Time.respond_to?(:current) ? Time.current : Time.now
25
- )
25
+ }
26
+ create_attrs[:slack_thread_ts] = attrs[:slack_thread_ts] if attrs[:slack_thread_ts]
27
+ record = session_class.create!(create_attrs)
26
28
  record.id
27
29
  rescue => e
28
30
  msg = "ConsoleAgent: session logging failed: #{e.class}: #{e.message}"
@@ -31,6 +33,15 @@ module ConsoleAgent
31
33
  nil
32
34
  end
33
35
 
36
+ def find_by_slack_thread(thread_ts)
37
+ return nil unless ConsoleAgent.configuration.session_logging
38
+ return nil unless table_exists?
39
+ session_class.where(slack_thread_ts: thread_ts).order(created_at: :desc).first
40
+ rescue => e
41
+ ConsoleAgent.logger.warn("ConsoleAgent: session lookup failed: #{e.class}: #{e.message}")
42
+ nil
43
+ end
44
+
34
45
  def update(id, attrs)
35
46
  return unless id
36
47
  return unless ConsoleAgent.configuration.session_logging