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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +122 -0
  5. data/app/controllers/ask/rails/harness/chat_controller.rb +310 -0
  6. data/app/models/ask/rails/harness/session.rb +25 -0
  7. data/app/views/ask/rails/harness/chat/index.html.erb +6 -0
  8. data/app/views/layouts/ask/rails/harness/application.html.erb +521 -0
  9. data/config/routes.rb +17 -0
  10. data/lib/ask/rails/harness/audit_log.rb +183 -0
  11. data/lib/ask/rails/harness/auth.rb +24 -0
  12. data/lib/ask/rails/harness/configuration.rb +73 -0
  13. data/lib/ask/rails/harness/engine.rb +13 -0
  14. data/lib/ask/rails/harness/environment_permissions.rb +41 -0
  15. data/lib/ask/rails/harness/persistence.rb +54 -0
  16. data/lib/ask/rails/harness/railtie.rb +34 -0
  17. data/lib/ask/rails/harness/service_discovery.rb +50 -0
  18. data/lib/ask/rails/harness/tool.rb +42 -0
  19. data/lib/ask/rails/harness/tools/query_database.rb +74 -0
  20. data/lib/ask/rails/harness/tools/read_file.rb +23 -0
  21. data/lib/ask/rails/harness/tools/read_log.rb +120 -0
  22. data/lib/ask/rails/harness/tools/read_model.rb +75 -0
  23. data/lib/ask/rails/harness/tools/read_routes.rb +20 -0
  24. data/lib/ask/rails/harness/tools/route_inspector.rb +71 -0
  25. data/lib/ask/rails/harness/tools/run_command.rb +60 -0
  26. data/lib/ask/rails/harness/tools/schema_graph.rb +176 -0
  27. data/lib/ask/rails/harness/tools/search_codebase.rb +23 -0
  28. data/lib/ask/rails/harness/version.rb +9 -0
  29. data/lib/ask/rails/harness.rb +189 -0
  30. data/lib/ask/skills/rails.db_debug/SKILL.md +118 -0
  31. data/lib/ask/skills/rails.deploy_pipeline/SKILL.md +125 -0
  32. data/lib/ask/skills/rails.route_trouble/SKILL.md +127 -0
  33. data/lib/ask-rails-harness.rb +3 -0
  34. data/lib/generators/ask/rails/harness/install/install_generator.rb +36 -0
  35. data/lib/generators/ask/rails/harness/install/templates/audit_log_migration.rb +22 -0
  36. data/lib/generators/ask/rails/harness/install/templates/initializer.rb +5 -0
  37. data/lib/generators/ask/rails/harness/install/templates/migration.rb +12 -0
  38. 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth"
4
+
5
+ module Ask
6
+ module Rails
7
+ module Harness
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace Ask::Rails::Harness
10
+ end
11
+ end
12
+ end
13
+ 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