ask-rails-harness 0.1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +122 -0
- data/app/controllers/ask/rails/harness/chat_controller.rb +310 -0
- data/app/models/ask/rails/harness/session.rb +25 -0
- data/app/views/ask/rails/harness/chat/index.html.erb +6 -0
- data/app/views/layouts/ask/rails/harness/application.html.erb +521 -0
- data/config/routes.rb +17 -0
- data/lib/ask/rails/harness/audit_log.rb +183 -0
- data/lib/ask/rails/harness/auth.rb +24 -0
- data/lib/ask/rails/harness/configuration.rb +73 -0
- data/lib/ask/rails/harness/engine.rb +13 -0
- data/lib/ask/rails/harness/environment_permissions.rb +41 -0
- data/lib/ask/rails/harness/persistence.rb +54 -0
- data/lib/ask/rails/harness/railtie.rb +34 -0
- data/lib/ask/rails/harness/service_discovery.rb +50 -0
- data/lib/ask/rails/harness/tool.rb +42 -0
- data/lib/ask/rails/harness/tools/query_database.rb +74 -0
- data/lib/ask/rails/harness/tools/read_file.rb +23 -0
- data/lib/ask/rails/harness/tools/read_log.rb +120 -0
- data/lib/ask/rails/harness/tools/read_model.rb +75 -0
- data/lib/ask/rails/harness/tools/read_routes.rb +20 -0
- data/lib/ask/rails/harness/tools/route_inspector.rb +71 -0
- data/lib/ask/rails/harness/tools/run_command.rb +60 -0
- data/lib/ask/rails/harness/tools/schema_graph.rb +176 -0
- data/lib/ask/rails/harness/tools/search_codebase.rb +23 -0
- data/lib/ask/rails/harness/version.rb +9 -0
- data/lib/ask/rails/harness.rb +189 -0
- data/lib/ask/skills/rails.db_debug/SKILL.md +118 -0
- data/lib/ask/skills/rails.deploy_pipeline/SKILL.md +125 -0
- data/lib/ask/skills/rails.route_trouble/SKILL.md +127 -0
- data/lib/ask-rails-harness.rb +3 -0
- data/lib/generators/ask/rails/harness/install/install_generator.rb +36 -0
- data/lib/generators/ask/rails/harness/install/templates/audit_log_migration.rb +22 -0
- data/lib/generators/ask/rails/harness/install/templates/initializer.rb +5 -0
- data/lib/generators/ask/rails/harness/install/templates/migration.rb +12 -0
- metadata +208 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Rails
|
|
7
|
+
module Harness
|
|
8
|
+
# Append-only audit log for tool executions.
|
|
9
|
+
#
|
|
10
|
+
# Every tool call made by an agent is recorded in the +ask_audit_logs+
|
|
11
|
+
# table with the intent (sanitized params) and outcome (status, timing),
|
|
12
|
+
# but not the data returned. This gives a trustworthy, queryable record
|
|
13
|
+
# of what the agent did without becoming a PII liability.
|
|
14
|
+
#
|
|
15
|
+
# Sensitive param values (keys matching +password+, +secret+, +token+,
|
|
16
|
+
# +api_key+, +key+) are automatically redacted before logging.
|
|
17
|
+
module AuditLog
|
|
18
|
+
SENSITIVE_KEYS = /\A(password|secret|token|api_key|key|auth_token|access_token)\z/i
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Log a tool execution event.
|
|
22
|
+
#
|
|
23
|
+
# @param session_id [String] The agent session that triggered this call
|
|
24
|
+
# @param tool_name [String] Name of the tool that ran
|
|
25
|
+
# @param params [Hash] The parameters passed to the tool (sanitized automatically)
|
|
26
|
+
# @param result [Ask::Result, Hash, nil] The result returned by the tool
|
|
27
|
+
# @param error [StandardError, nil] The exception if the tool raised
|
|
28
|
+
# @param duration_ms [Integer] Wall-clock time for the tool execution
|
|
29
|
+
# @param user_context [Hash, nil] Who initiated the session (from config)
|
|
30
|
+
def log(session_id:, tool_name:, params:, result: nil, error: nil, duration_ms:)
|
|
31
|
+
now = Time.now.utc
|
|
32
|
+
entry = {
|
|
33
|
+
session_id: session_id,
|
|
34
|
+
tool_name: tool_name,
|
|
35
|
+
params: sanitize_params(params),
|
|
36
|
+
result_summary: build_summary(tool_name, result, error),
|
|
37
|
+
status: determine_status(result, error),
|
|
38
|
+
error_message: determine_error(result, error),
|
|
39
|
+
duration_ms: duration_ms,
|
|
40
|
+
user_context: resolve_user_context,
|
|
41
|
+
environment: environment_name,
|
|
42
|
+
recorded_at: now,
|
|
43
|
+
created_at: now,
|
|
44
|
+
updated_at: now
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if table_exists?
|
|
48
|
+
write_entry(entry)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fire an ActiveSupport notification so host apps can subscribe
|
|
52
|
+
ActiveSupport::Notifications.instrument("audit_log.ask_rails_harness", entry)
|
|
53
|
+
|
|
54
|
+
entry
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def sanitize_params(params)
|
|
60
|
+
return {} unless params.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
params.each_with_object({}) do |(key, value), sanitized|
|
|
63
|
+
if SENSITIVE_KEYS.match?(key.to_s)
|
|
64
|
+
sanitized[key] = "[REDACTED]"
|
|
65
|
+
else
|
|
66
|
+
sanitized[key] = value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def determine_status(result, error)
|
|
72
|
+
return "error" if error
|
|
73
|
+
return "rejected" if result.is_a?(Ask::Result) && (result.error? || result.blocked?)
|
|
74
|
+
"success"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def determine_error(result, error)
|
|
78
|
+
return error.message if error
|
|
79
|
+
if result.is_a?(Ask::Result)
|
|
80
|
+
return result.error.to_s if result.error?
|
|
81
|
+
return result.content.to_s if result.blocked?
|
|
82
|
+
end
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def extract_data(result)
|
|
87
|
+
return nil unless result
|
|
88
|
+
|
|
89
|
+
if result.is_a?(Ask::Result)
|
|
90
|
+
result.content.is_a?(Hash) ? result.content : nil
|
|
91
|
+
elsif result.is_a?(Hash)
|
|
92
|
+
result
|
|
93
|
+
else
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def build_summary(tool_name, result, error)
|
|
99
|
+
if error
|
|
100
|
+
return { error: error.class.name }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if result.is_a?(Ask::Result)
|
|
104
|
+
if result.error?
|
|
105
|
+
return { error: "rejected: #{result.error.to_s.truncate(200)}" }
|
|
106
|
+
end
|
|
107
|
+
if result.blocked?
|
|
108
|
+
return { error: "blocked: #{result.content.to_s.truncate(200)}" }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
data = extract_data(result)
|
|
113
|
+
return {} unless data
|
|
114
|
+
|
|
115
|
+
summary = {}
|
|
116
|
+
summary[:rows] = data[:rows]&.length if data.key?(:rows)
|
|
117
|
+
summary[:columns] = data[:columns]&.length if data.key?(:columns)
|
|
118
|
+
summary[:exit_status] = data[:exit_status] if data.key?(:exit_status)
|
|
119
|
+
summary[:size] = data[:size] if data.key?(:size)
|
|
120
|
+
summary[:matched_lines] = data[:matched_lines] if data.key?(:matched_lines)
|
|
121
|
+
summary[:results] = data[:results]&.length if data.key?(:results)
|
|
122
|
+
summary[:model] = data[:name] if data.key?(:name)
|
|
123
|
+
summary
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def resolve_user_context
|
|
127
|
+
proc = Ask::Rails::Harness.configuration.current_user
|
|
128
|
+
return nil unless proc.respond_to?(:call)
|
|
129
|
+
|
|
130
|
+
result = proc.call
|
|
131
|
+
result.is_a?(Hash) ? result : nil
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def environment_name
|
|
137
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : "unknown"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def table_exists?
|
|
141
|
+
return false unless defined?(ActiveRecord::Base)
|
|
142
|
+
|
|
143
|
+
# Only cache the true result — recheck if it was false
|
|
144
|
+
return @table_exists if @table_exists
|
|
145
|
+
|
|
146
|
+
@table_exists = begin
|
|
147
|
+
conn = ActiveRecord::Base.connection
|
|
148
|
+
conn.data_source_exists?("ask_audit_logs")
|
|
149
|
+
rescue StandardError
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def write_entry(entry)
|
|
155
|
+
# Serialize JSON fields for database storage
|
|
156
|
+
serialized = entry.dup
|
|
157
|
+
%i[params result_summary user_context].each do |key|
|
|
158
|
+
serialized[key] = ::JSON.generate(serialized[key]) if serialized[key].is_a?(Hash)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Use raw SQL to avoid requiring a model class
|
|
162
|
+
columns = serialized.keys
|
|
163
|
+
values = columns.map { |col| ActiveRecord::Base.connection.quote(serialized[col]) }
|
|
164
|
+
ActiveRecord::Base.connection.execute(
|
|
165
|
+
"INSERT INTO ask_audit_logs (#{columns.join(', ')})
|
|
166
|
+
VALUES (#{values.join(', ')})"
|
|
167
|
+
)
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
# Silently fail — audit log should never crash the caller
|
|
170
|
+
Rails.logger.warn("[ask-rails-harness] Audit log write failed: #{e.message}") if defined?(Rails.logger)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Reset cached table check (useful in tests)
|
|
174
|
+
public
|
|
175
|
+
|
|
176
|
+
def reset_table_check!
|
|
177
|
+
@table_exists = nil
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
# Inclusion module for authenticating the admin chat.
|
|
7
|
+
#
|
|
8
|
+
# By default, all ask-rails-harness engine routes are accessible without auth.
|
|
9
|
+
# To protect them, define a +current_user+ method and set the auth
|
|
10
|
+
# check in an initializer:
|
|
11
|
+
#
|
|
12
|
+
# Ask::Rails::Harness::Auth.check = -> {
|
|
13
|
+
# redirect_to main_app.login_path unless current_user&.admin?
|
|
14
|
+
# }
|
|
15
|
+
#
|
|
16
|
+
# The proc is evaluated in the controller context, so +redirect_to+,
|
|
17
|
+
# +current_user+, +session+, etc. are all available.
|
|
18
|
+
module Auth
|
|
19
|
+
mattr_accessor :check
|
|
20
|
+
self.check = nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :default_model, :max_turns, :system_prompt,
|
|
8
|
+
:tool_concurrency, :persistence_adapter, :tools,
|
|
9
|
+
:current_user, :allowed_commands, :denied_commands,
|
|
10
|
+
:max_session_age, :max_sessions
|
|
11
|
+
|
|
12
|
+
# @return [Hash{Symbol => EnvironmentPermissions}] per-environment permission rules
|
|
13
|
+
attr_reader :environments
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@default_model = "gpt-4o"
|
|
17
|
+
@max_turns = 25
|
|
18
|
+
@system_prompt = nil
|
|
19
|
+
@tool_concurrency = 5
|
|
20
|
+
@persistence_adapter = nil
|
|
21
|
+
@tools = []
|
|
22
|
+
@current_user = nil
|
|
23
|
+
@allowed_commands = nil
|
|
24
|
+
@denied_commands = nil
|
|
25
|
+
@max_session_age = nil
|
|
26
|
+
@max_sessions = nil
|
|
27
|
+
@environments = {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Configure permissions for a specific Rails environment.
|
|
31
|
+
#
|
|
32
|
+
# config.environment :production do |env|
|
|
33
|
+
# env.mode = :read_only
|
|
34
|
+
# env.allowed_commands = [/^rails routes/]
|
|
35
|
+
# env.denied_commands = [/rm/, /dropdb/]
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# @param name [Symbol, String] environment name (:production, :development, :staging, etc.)
|
|
39
|
+
def environment(name)
|
|
40
|
+
env = EnvironmentPermissions.new
|
|
41
|
+
yield env
|
|
42
|
+
@environments[name.to_sym] = env
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Resolved allowed commands for the current Rails environment.
|
|
46
|
+
# Falls back to the global +allowed_commands+ if no per-env config.
|
|
47
|
+
#
|
|
48
|
+
# @return [Array<Regexp>, nil]
|
|
49
|
+
def effective_allowed_commands
|
|
50
|
+
env = @environments[::Rails.env.to_sym]
|
|
51
|
+
env&.allowed_commands || @allowed_commands
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolved denied commands for the current Rails environment.
|
|
55
|
+
# Falls back to the global +denied_commands+ if no per-env config.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Regexp>, nil]
|
|
58
|
+
def effective_denied_commands
|
|
59
|
+
env = @environments[::Rails.env.to_sym]
|
|
60
|
+
env&.denied_commands || @denied_commands
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Resolved access mode for the current Rails environment.
|
|
64
|
+
#
|
|
65
|
+
# @return [Symbol, nil] +:full_access+, +:read_only+, +:ask_before_changes+, or nil
|
|
66
|
+
def effective_mode
|
|
67
|
+
env = @environments[::Rails.env.to_sym]
|
|
68
|
+
env&.mode || nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
# Per-environment permission rules for agent tool access.
|
|
7
|
+
#
|
|
8
|
+
# Configure within Ask::Rails::Harness.configure block:
|
|
9
|
+
#
|
|
10
|
+
# Ask::Rails::Harness.configure do |config|
|
|
11
|
+
# config.environment :production do |env|
|
|
12
|
+
# env.mode = :read_only
|
|
13
|
+
# env.allowed_commands = [/^rails routes/, /^rails log/]
|
|
14
|
+
# env.denied_commands = [/rm/, /dropdb/]
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# config.environment :development do |env|
|
|
18
|
+
# env.mode = :full_access
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
class EnvironmentPermissions
|
|
23
|
+
# @return [Symbol, nil] Access mode for ask-agent's Permissions extension
|
|
24
|
+
# (:full_access, :read_only, :ask_before_changes)
|
|
25
|
+
attr_accessor :mode
|
|
26
|
+
|
|
27
|
+
# @return [Array<Regexp>, nil] Allowed command patterns for RunCommand
|
|
28
|
+
attr_accessor :allowed_commands
|
|
29
|
+
|
|
30
|
+
# @return [Array<Regexp>, nil] Denied command patterns for RunCommand (takes precedence)
|
|
31
|
+
attr_accessor :denied_commands
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@mode = nil
|
|
35
|
+
@allowed_commands = nil
|
|
36
|
+
@denied_commands = nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
class Persistence < ::Ask::State::Adapter
|
|
7
|
+
def initialize(model_class: nil)
|
|
8
|
+
@model_class = model_class
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# State::Adapter contract — set/get/delete
|
|
12
|
+
# (aliased through save/load for backward compatibility)
|
|
13
|
+
|
|
14
|
+
def set(key, value, ttl: nil)
|
|
15
|
+
save(key, value)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get(key)
|
|
19
|
+
load(key)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def delete(session_id)
|
|
23
|
+
model_class.where(session_id: session_id).delete_all
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Backward-compatible interface (used by old code and list queries)
|
|
27
|
+
|
|
28
|
+
def save(session_id, data)
|
|
29
|
+
record = model_class.find_or_initialize_by(session_id: session_id)
|
|
30
|
+
record.update!(data: data)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def load(session_id)
|
|
34
|
+
record = model_class.find_by(session_id: session_id)
|
|
35
|
+
record&.data
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear
|
|
39
|
+
model_class.delete_all
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list
|
|
43
|
+
model_class.pluck(:session_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def model_class
|
|
49
|
+
@model_class || Ask::Rails::Harness::Session
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
rake_tasks do
|
|
8
|
+
desc "Prune old sessions and audit logs based on configuration limits"
|
|
9
|
+
task ask_rails_harness: :cleanup do
|
|
10
|
+
count = Ask::Rails::Harness.cleanup!
|
|
11
|
+
puts "Cleaned up #{count || 0} sessions."
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
generators do
|
|
16
|
+
require_relative "../../../generators/ask/rails/harness/install/install_generator"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
initializer "ask_rails_harness.configure" do |app|
|
|
20
|
+
Ask::Rails::Harness.configuration.default_model ||= ENV["ASK_DEFAULT_MODEL"] || "gpt-4o"
|
|
21
|
+
Ask::Rails::Harness.configuration.max_turns ||= (ENV["ASK_MAX_TURNS"] || 25).to_i
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
initializer "ask_rails_harness.discover_tools", after: :eager_load_most do
|
|
25
|
+
Ask::Rails::Harness.discover_tools!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
initializer "ask_rails_harness.discover_services", after: :eager_load_most do
|
|
29
|
+
Ask::Rails::Harness::ServiceDiscovery.discover!
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module ServiceDiscovery
|
|
7
|
+
SERVICE_GEMS_PATTERN = /\Aask-(?!tools|tools-shell|agent|rails|core|auth|schema|llm)/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def discover!
|
|
12
|
+
service_gems = Gem.loaded_specs.keys.select { |name| name.match?(SERVICE_GEMS_PATTERN) }
|
|
13
|
+
contexts = []
|
|
14
|
+
|
|
15
|
+
service_gems.each do |name|
|
|
16
|
+
begin
|
|
17
|
+
require "#{name.tr("-", "/")}/context"
|
|
18
|
+
mod = name.split("-").map(&:capitalize).join.constantize
|
|
19
|
+
contexts << mod if mod.respond_to?(:const_defined?) && mod.const_defined?(:DESCRIPTION)
|
|
20
|
+
rescue LoadError, NameError
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
unless contexts.empty?
|
|
25
|
+
prompt = build_system_prompt(contexts)
|
|
26
|
+
existing = Ask::Rails::Harness.configuration.system_prompt
|
|
27
|
+
Ask::Rails::Harness.configuration.system_prompt = [existing, prompt].compact.join("\n\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
contexts
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def build_system_prompt(contexts)
|
|
34
|
+
sections = ["## Available Services"]
|
|
35
|
+
|
|
36
|
+
contexts.each do |mod|
|
|
37
|
+
name = mod.respond_to?(:name) ? mod.name.to_s.split("::").last || "Unknown" : "Unknown"
|
|
38
|
+
sections << "### #{name}"
|
|
39
|
+
sections << mod::DESCRIPTION if mod.const_defined?(:DESCRIPTION)
|
|
40
|
+
sections << "Documentation: #{mod::DOCS_URL}" if mod.const_defined?(:DOCS_URL)
|
|
41
|
+
sections << "Authentication: #{mod::AUTH_HOW}" if mod.const_defined?(:AUTH_HOW)
|
|
42
|
+
sections << ""
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sections.join("\n")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
class Tool < Ask::Tool
|
|
7
|
+
def rails_root
|
|
8
|
+
::Rails.root
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Override call to add audit logging around every tool execution.
|
|
12
|
+
# Logs the intent (sanitized params) and outcome (status, timing),
|
|
13
|
+
# but not the returned data.
|
|
14
|
+
def call(args = {}, abort_controller = nil)
|
|
15
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
16
|
+
result = super
|
|
17
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
18
|
+
|
|
19
|
+
AuditLog.log(
|
|
20
|
+
session_id: Thread.current[:ask_session_id],
|
|
21
|
+
tool_name: name,
|
|
22
|
+
params: args,
|
|
23
|
+
result: result,
|
|
24
|
+
duration_ms: duration_ms
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Allow the session to set its ID for audit log correlation.
|
|
31
|
+
# Called by the agent loop before executing a tool.
|
|
32
|
+
def self.session_id=(id)
|
|
33
|
+
Thread.current[:ask_session_id] = id
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.session_id
|
|
37
|
+
Thread.current[:ask_session_id]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class QueryDatabase < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Run a read-only SQL query against the application database. " \
|
|
9
|
+
"Returns columns and rows. Only SELECT queries are allowed in production."
|
|
10
|
+
|
|
11
|
+
param :sql, type: :string, desc: "SQL query (SELECT only in production)", required: true
|
|
12
|
+
param :limit, type: :integer, desc: "Max rows to return (default 50)", required: false
|
|
13
|
+
|
|
14
|
+
WRITE_STATEMENTS = /\A\s*(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i
|
|
15
|
+
|
|
16
|
+
def execute(sql:, limit: 50)
|
|
17
|
+
sql = sql.strip
|
|
18
|
+
|
|
19
|
+
if WRITE_STATEMENTS.match?(sql)
|
|
20
|
+
return Ask::Result.failure(
|
|
21
|
+
"Only SELECT queries are allowed. Write statements (#{sql.match(WRITE_STATEMENTS)[1]}) are rejected in all environments."
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if ::Rails.env.production? && !sql.match?(/\A\s*SELECT\b/i)
|
|
26
|
+
return Ask::Result.failure(
|
|
27
|
+
"Only SELECT queries are allowed in the production environment."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
pool = ActiveRecord::Base.connection_pool
|
|
32
|
+
pool.with_connection do |conn|
|
|
33
|
+
limited_sql = sql.match?(/\bLIMIT\b/i) ? sql : "#{sql.chomp(';')} LIMIT #{limit.to_i}"
|
|
34
|
+
result = conn.exec_query(limited_sql)
|
|
35
|
+
columns = result.columns
|
|
36
|
+
rows = result.rows.first(limit.to_i).map { |row| build_row(row, columns) }
|
|
37
|
+
{
|
|
38
|
+
columns: columns,
|
|
39
|
+
rows: rows,
|
|
40
|
+
count: rows.size,
|
|
41
|
+
truncated: result.rows.size > limit.to_i
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
45
|
+
Ask::Result.failure("SQL error: #{e.message}")
|
|
46
|
+
rescue ActiveRecord::ConnectionNotEstablished => e
|
|
47
|
+
Ask::Result.failure("Database not connected: #{e.message}. Verify the database is running and Rails is connected.")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def build_row(row, columns)
|
|
53
|
+
columns.each_with_index.each_with_object({}) do |(col, i), hash|
|
|
54
|
+
value = row[i]
|
|
55
|
+
hash[col] = sanitize_value(value)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def sanitize_value(value)
|
|
60
|
+
return "[BINARY DATA]" if binary_value?(value)
|
|
61
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
62
|
+
value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def binary_value?(value)
|
|
66
|
+
value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT && value.bytesize > 0
|
|
67
|
+
rescue
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Harness
|
|
6
|
+
module Tools
|
|
7
|
+
class ReadFile < Ask::Rails::Harness::Tool
|
|
8
|
+
description "Read a file from the Rails app. Paths are relative to Rails.root."
|
|
9
|
+
|
|
10
|
+
param :path, type: :string, desc: "Relative path from Rails.root", required: true
|
|
11
|
+
|
|
12
|
+
def execute(path:)
|
|
13
|
+
full_path = rails_root.join(path)
|
|
14
|
+
return Ask::Result.error(message: "File not found: #{path}") unless full_path.exist?
|
|
15
|
+
|
|
16
|
+
content = full_path.read
|
|
17
|
+
{ path: path, content: content, size: content.length }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|