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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +101 -1
- data/app/helpers/console_agent/sessions_helper.rb +14 -0
- data/app/models/console_agent/session.rb +1 -1
- data/app/views/console_agent/sessions/index.html.erb +4 -4
- data/app/views/console_agent/sessions/show.html.erb +16 -6
- data/app/views/layouts/console_agent/application.html.erb +1 -0
- data/lib/console_agent/channel/base.rb +23 -0
- data/lib/console_agent/channel/console.rb +457 -0
- data/lib/console_agent/channel/slack.rb +182 -0
- data/lib/console_agent/configuration.rb +74 -5
- data/lib/console_agent/conversation_engine.rb +1122 -0
- data/lib/console_agent/executor.rb +239 -47
- data/lib/console_agent/providers/base.rb +7 -2
- data/lib/console_agent/providers/local.rb +112 -0
- data/lib/console_agent/railtie.rb +4 -0
- data/lib/console_agent/repl.rb +26 -1291
- data/lib/console_agent/safety_guards.rb +207 -0
- data/lib/console_agent/session_logger.rb +14 -3
- data/lib/console_agent/slack_bot.rb +473 -0
- data/lib/console_agent/tools/registry.rb +48 -16
- data/lib/console_agent/version.rb +1 -1
- data/lib/console_agent.rb +17 -3
- data/lib/generators/console_agent/templates/initializer.rb +34 -1
- data/lib/tasks/console_agent.rake +7 -0
- metadata +9 -1
|
@@ -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
|
-
|
|
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
|