ask-ruby-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 +19 -0
- data/LICENSE +21 -0
- data/README.md +67 -0
- data/lib/ask/ruby/harness/audit_log.rb +187 -0
- data/lib/ask/ruby/harness/configuration.rb +77 -0
- data/lib/ask/ruby/harness/environment_permissions.rb +41 -0
- data/lib/ask/ruby/harness/minitest_json_reporter.rb +84 -0
- data/lib/ask/ruby/harness/tool.rb +46 -0
- data/lib/ask/ruby/harness/tools/query_database.rb +87 -0
- data/lib/ask/ruby/harness/tools/read_log.rb +103 -0
- data/lib/ask/ruby/harness/tools/read_model.rb +77 -0
- data/lib/ask/ruby/harness/tools/run_command.rb +60 -0
- data/lib/ask/ruby/harness/tools/run_tests.rb +262 -0
- data/lib/ask/ruby/harness/tools/schema_graph.rb +179 -0
- data/lib/ask/ruby/harness/version.rb +9 -0
- data/lib/ask/ruby/harness.rb +216 -0
- data/lib/minitest/ask_ruby_harness_plugin.rb +39 -0
- metadata +177 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bd5a2b1d1916502e5eb5cc92220a4d06149817a05f2bf8ed00e8327630272881
|
|
4
|
+
data.tar.gz: 847841b9d7fd758f8fb2c3da5d19e8b51414f3e3f2ee94c8e398e3231abe3b6a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 604554da7aa7e84bd2d779cd68123645757d185000d62575694e51201ef1086e5419e48d24c70679546098a4352bf9c686184ff22725fec22d0995aafa6e02f2
|
|
7
|
+
data.tar.gz: 9f4bdf2110e1a2dd490cf59c0d0af545726bec839dc64f59e7cdfe8e6438170750ebe6e2dcc1530cedf51586001b8d7a686897b2c46368eefd60c2a3a25a010f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
## [0.1.0] — 2026-08-10
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **Generic harness for any Ruby project** — extracted from
|
|
6
|
+
`ask-rails-harness`: `RunCommand`, `ReadLog`, `QueryDatabase`, `ReadModel`,
|
|
7
|
+
`SchemaGraph`, and `RunTests` with a language-agnostic tool contract.
|
|
8
|
+
- **No Rails dependency** — the gem loads in plain Ruby projects; database
|
|
9
|
+
access connects standalone via `ASK_DATABASE_URL` or `config/database.yml`.
|
|
10
|
+
- **Runner detection for `run_tests`** — `bin/rails test` (Rails apps),
|
|
11
|
+
`bundle exec rspec` (rspec projects), or `bundle exec rake test` (plain
|
|
12
|
+
Ruby projects), all with the same structured JSON results.
|
|
13
|
+
- **Minitest JSON reporter + plugin** — `minitest/ask_ruby_harness_plugin.rb`
|
|
14
|
+
(minitest 5 auto-discovery, minitest 6 RUBYOPT injection), inert unless
|
|
15
|
+
`ASK_TEST_JSON_PATH` is set.
|
|
16
|
+
- **Environment permissions** — per-environment modes and command
|
|
17
|
+
allow/deny lists.
|
|
18
|
+
- **Audit logging** — `ask_audit_logs` table (when available) +
|
|
19
|
+
`audit_log.ask_ruby_harness` notification, with sensitive-param redaction.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Kaka Ruto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Ask Ruby Harness
|
|
2
|
+
|
|
3
|
+
Admin AI copilot for any Ruby project — structured, safe, permission-gated
|
|
4
|
+
access for coding agents. The language edition of the ask-rb harness family
|
|
5
|
+
(`ask-rails-harness` builds on this gem for Rails apps).
|
|
6
|
+
|
|
7
|
+
## What it gives agents
|
|
8
|
+
|
|
9
|
+
| Tool | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `QueryDatabase` | Read-only SQL (non-SELECT rejected everywhere; SELECT-only in production) |
|
|
12
|
+
| `ReadModel` | Inspect an ActiveRecord model's columns, associations, validations |
|
|
13
|
+
| `ReadLog` | Read log files with level/search filtering and rotation support |
|
|
14
|
+
| `RunCommand` | Run shell commands in the project root, gated by permission rules |
|
|
15
|
+
| `SchemaGraph` | Full schema introspection: models, tables, columns, associations |
|
|
16
|
+
| `RunTests` | Structured test results with failure reruns (rails test / rspec / rake test) |
|
|
17
|
+
|
|
18
|
+
Generic file and search capabilities (read, grep, edit) are provided by the
|
|
19
|
+
agent's native tools; the harness focuses on what only an app-aware layer
|
|
20
|
+
can give an agent: database access, model introspection, logs, commands, and
|
|
21
|
+
tests — all structured, permission-gated, and audited.
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
- **No Rails required.** The harness loads in any Ruby project. Database
|
|
26
|
+
access connects standalone via `ASK_DATABASE_URL` or `config/database.yml`
|
|
27
|
+
when the host hasn't already connected.
|
|
28
|
+
- **Structured returns, never terminal dumps.** Tools return data an agent
|
|
29
|
+
can act on directly — `run_tests` reports counts plus per-test
|
|
30
|
+
file/line/message.
|
|
31
|
+
- **Minitest JSON reporter.** `lib/minitest/ask_ruby_harness_plugin.rb` is
|
|
32
|
+
auto-discovered by minitest 5 and injected via `RUBYOPT` on minitest 6
|
|
33
|
+
(which dropped plugin auto-discovery). Only active when the harness starts
|
|
34
|
+
the run (`ASK_TEST_JSON_PATH` set); ordinary test runs are untouched.
|
|
35
|
+
- **Audit logging.** Every tool call is recorded in `ask_audit_logs` (when
|
|
36
|
+
the table exists) and broadcast as the `audit_log.ask_ruby_harness`
|
|
37
|
+
ActiveSupport notification. Sensitive params are redacted.
|
|
38
|
+
- **Environment permissions.** Per-environment `mode` (full access, read
|
|
39
|
+
only, ask before changes) and command allow/deny lists.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
Ask::Ruby::Harness.configure do |config|
|
|
45
|
+
config.environment :production do |env|
|
|
46
|
+
env.mode = :read_only
|
|
47
|
+
env.denied_commands = [/rm/, /dropdb/]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Ask::Ruby::Harness.discover_tools!
|
|
52
|
+
session = Ask::Ruby::Harness.agent_session(model: "gpt-4o")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The Rails edition (`ask-rails-harness`) mounts an agent at `/ask` and adds
|
|
56
|
+
framework-native tools (routes, engine) on top of this gem.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
bundle install
|
|
62
|
+
bundle exec rake test
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT — see LICENSE.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "active_support/core_ext/string/filters"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Ruby
|
|
8
|
+
module Harness
|
|
9
|
+
# Append-only audit log for tool executions.
|
|
10
|
+
#
|
|
11
|
+
# Every tool call made by an agent is recorded in the +ask_audit_logs+
|
|
12
|
+
# table (when one is available) with the intent (sanitized params) and
|
|
13
|
+
# outcome (status, timing), but not the data returned. A
|
|
14
|
+
# +audit_log.ask_ruby_harness+ ActiveSupport notification fires either
|
|
15
|
+
# way, so hosts can subscribe without the table.
|
|
16
|
+
#
|
|
17
|
+
# Sensitive param values (keys matching +password+, +secret+, +token+,
|
|
18
|
+
# +api_key+, +key+) are automatically redacted before logging.
|
|
19
|
+
module AuditLog
|
|
20
|
+
SENSITIVE_KEYS = /\A(password|secret|token|api_key|key|auth_token|access_token)\z/i
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Log a tool execution event.
|
|
24
|
+
#
|
|
25
|
+
# @param session_id [String] The agent session that triggered this call
|
|
26
|
+
# @param tool_name [String] Name of the tool that ran
|
|
27
|
+
# @param params [Hash] The parameters passed to the tool (sanitized automatically)
|
|
28
|
+
# @param result [Ask::Result, Hash, nil] The result returned by the tool
|
|
29
|
+
# @param error [StandardError, nil] The exception if the tool raised
|
|
30
|
+
# @param duration_ms [Integer] Wall-clock time for the tool execution
|
|
31
|
+
# @param user_context [Hash, nil] Who initiated the session (from config)
|
|
32
|
+
def log(session_id:, tool_name:, params:, result: nil, error: nil, duration_ms:)
|
|
33
|
+
now = Time.now.utc
|
|
34
|
+
entry = {
|
|
35
|
+
session_id: session_id,
|
|
36
|
+
tool_name: tool_name,
|
|
37
|
+
params: sanitize_params(params),
|
|
38
|
+
result_summary: build_summary(tool_name, result, error),
|
|
39
|
+
status: determine_status(result, error),
|
|
40
|
+
error_message: determine_error(result, error),
|
|
41
|
+
duration_ms: duration_ms,
|
|
42
|
+
user_context: resolve_user_context,
|
|
43
|
+
environment: environment_name,
|
|
44
|
+
recorded_at: now,
|
|
45
|
+
created_at: now,
|
|
46
|
+
updated_at: now
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if table_exists?
|
|
50
|
+
write_entry(entry)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Fire an ActiveSupport notification so host apps can subscribe
|
|
54
|
+
ActiveSupport::Notifications.instrument("audit_log.ask_ruby_harness", entry)
|
|
55
|
+
|
|
56
|
+
entry
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def sanitize_params(params)
|
|
62
|
+
return {} unless params.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
params.each_with_object({}) do |(key, value), sanitized|
|
|
65
|
+
if SENSITIVE_KEYS.match?(key.to_s)
|
|
66
|
+
sanitized[key] = "[REDACTED]"
|
|
67
|
+
else
|
|
68
|
+
sanitized[key] = value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def determine_status(result, error)
|
|
74
|
+
return "error" if error
|
|
75
|
+
return "rejected" if result.is_a?(Ask::Result) && (result.error? || result.blocked?)
|
|
76
|
+
"success"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def determine_error(result, error)
|
|
80
|
+
return error.message if error
|
|
81
|
+
if result.is_a?(Ask::Result)
|
|
82
|
+
return result.error.to_s if result.error?
|
|
83
|
+
return result.content.to_s if result.blocked?
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def extract_data(result)
|
|
89
|
+
return nil unless result
|
|
90
|
+
|
|
91
|
+
if result.is_a?(Ask::Result)
|
|
92
|
+
result.content.is_a?(Hash) ? result.content : nil
|
|
93
|
+
elsif result.is_a?(Hash)
|
|
94
|
+
result
|
|
95
|
+
else
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_summary(tool_name, result, error)
|
|
101
|
+
if error
|
|
102
|
+
return { error: error.class.name }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if result.is_a?(Ask::Result)
|
|
106
|
+
if result.error?
|
|
107
|
+
return { error: "rejected: #{result.error.to_s.truncate(200)}" }
|
|
108
|
+
end
|
|
109
|
+
if result.blocked?
|
|
110
|
+
return { error: "blocked: #{result.content.to_s.truncate(200)}" }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
data = extract_data(result)
|
|
115
|
+
return {} unless data
|
|
116
|
+
|
|
117
|
+
summary = {}
|
|
118
|
+
summary[:rows] = data[:rows]&.length if data.key?(:rows)
|
|
119
|
+
summary[:columns] = data[:columns]&.length if data.key?(:columns)
|
|
120
|
+
summary[:exit_status] = data[:exit_status] if data.key?(:exit_status)
|
|
121
|
+
summary[:size] = data[:size] if data.key?(:size)
|
|
122
|
+
summary[:matched_lines] = data[:matched_lines] if data.key?(:matched_lines)
|
|
123
|
+
summary[:results] = data[:results]&.length if data.key?(:results)
|
|
124
|
+
summary[:model] = data[:name] if data.key?(:name)
|
|
125
|
+
summary
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resolve_user_context
|
|
129
|
+
proc = Ask::Ruby::Harness.configuration.current_user
|
|
130
|
+
return nil unless proc.respond_to?(:call)
|
|
131
|
+
|
|
132
|
+
result = proc.call
|
|
133
|
+
result.is_a?(Hash) ? result : nil
|
|
134
|
+
rescue StandardError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def environment_name
|
|
139
|
+
Ask::Ruby::Harness.env
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def table_exists?
|
|
143
|
+
return false unless defined?(ActiveRecord::Base)
|
|
144
|
+
|
|
145
|
+
# Only cache the true result — recheck if it was false
|
|
146
|
+
return @table_exists if @table_exists
|
|
147
|
+
|
|
148
|
+
@table_exists = begin
|
|
149
|
+
conn = ActiveRecord::Base.connection
|
|
150
|
+
conn.data_source_exists?("ask_audit_logs")
|
|
151
|
+
rescue StandardError
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def write_entry(entry)
|
|
157
|
+
# Serialize JSON fields for database storage
|
|
158
|
+
serialized = entry.dup
|
|
159
|
+
%i[params result_summary user_context].each do |key|
|
|
160
|
+
serialized[key] = ::JSON.generate(serialized[key]) if serialized[key].is_a?(Hash)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Use raw SQL to avoid requiring a model class
|
|
164
|
+
columns = serialized.keys
|
|
165
|
+
values = columns.map { |col| ActiveRecord::Base.connection.quote(serialized[col]) }
|
|
166
|
+
ActiveRecord::Base.connection.execute(
|
|
167
|
+
"INSERT INTO ask_audit_logs (#{columns.join(', ')})
|
|
168
|
+
VALUES (#{values.join(', ')})"
|
|
169
|
+
)
|
|
170
|
+
rescue StandardError => e
|
|
171
|
+
# Silently fail — audit log should never crash the caller.
|
|
172
|
+
# ::Rails avoids the bare-`Rails` constant resolving to Ask::Rails;
|
|
173
|
+
# `&.` guards against Rails.logger returning nil (no app booted).
|
|
174
|
+
::Rails.logger&.warn("[ask-ruby-harness] Audit log write failed: #{e.message}") if defined?(::Rails.logger)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Reset cached table check (useful in tests)
|
|
178
|
+
public
|
|
179
|
+
|
|
180
|
+
def reset_table_check!
|
|
181
|
+
@table_exists = nil
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Ruby
|
|
5
|
+
module Harness
|
|
6
|
+
class Configuration
|
|
7
|
+
# Keys accepted when building a standalone connection from
|
|
8
|
+
# config/database.yml (unknown keys would fail AR's config validation).
|
|
9
|
+
DATABASE_CONFIG_KEYS = %w[adapter database host port username password url encoding pool].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor :default_model, :max_turns, :system_prompt,
|
|
12
|
+
:tool_concurrency, :persistence_adapter, :tools,
|
|
13
|
+
:current_user, :allowed_commands, :denied_commands,
|
|
14
|
+
:max_session_age, :max_sessions
|
|
15
|
+
|
|
16
|
+
# @return [Hash{Symbol => EnvironmentPermissions}] per-environment permission rules
|
|
17
|
+
attr_reader :environments
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@default_model = "gpt-4o"
|
|
21
|
+
@max_turns = 25
|
|
22
|
+
@system_prompt = nil
|
|
23
|
+
@tool_concurrency = 5
|
|
24
|
+
@persistence_adapter = nil
|
|
25
|
+
@tools = []
|
|
26
|
+
@current_user = nil
|
|
27
|
+
@allowed_commands = nil
|
|
28
|
+
@denied_commands = nil
|
|
29
|
+
@max_session_age = nil
|
|
30
|
+
@max_sessions = nil
|
|
31
|
+
@environments = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Configure permissions for a specific environment.
|
|
35
|
+
#
|
|
36
|
+
# config.environment :production do |env|
|
|
37
|
+
# env.mode = :read_only
|
|
38
|
+
# env.allowed_commands = [/^rails routes/]
|
|
39
|
+
# env.denied_commands = [/rm/, /dropdb/]
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# @param name [Symbol, String] environment name (:production, :development, :staging, etc.)
|
|
43
|
+
def environment(name)
|
|
44
|
+
env = EnvironmentPermissions.new
|
|
45
|
+
yield env
|
|
46
|
+
@environments[name.to_sym] = env
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Resolved allowed commands for the current environment.
|
|
50
|
+
# Falls back to the global +allowed_commands+ if no per-env config.
|
|
51
|
+
#
|
|
52
|
+
# @return [Array<Regexp>, nil]
|
|
53
|
+
def effective_allowed_commands
|
|
54
|
+
env = @environments[Ask::Ruby::Harness.env.to_sym]
|
|
55
|
+
env&.allowed_commands || @allowed_commands
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Resolved denied commands for the current environment.
|
|
59
|
+
# Falls back to the global +denied_commands+ if no per-env config.
|
|
60
|
+
#
|
|
61
|
+
# @return [Array<Regexp>, nil]
|
|
62
|
+
def effective_denied_commands
|
|
63
|
+
env = @environments[Ask::Ruby::Harness.env.to_sym]
|
|
64
|
+
env&.denied_commands || @denied_commands
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolved access mode for the current environment.
|
|
68
|
+
#
|
|
69
|
+
# @return [Symbol, nil] +:full_access+, +:read_only+, +:ask_before_changes+, or nil
|
|
70
|
+
def effective_mode
|
|
71
|
+
env = @environments[Ask::Ruby::Harness.env.to_sym]
|
|
72
|
+
env&.mode || nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Ruby
|
|
5
|
+
module Harness
|
|
6
|
+
# Per-environment permission rules for agent tool access.
|
|
7
|
+
#
|
|
8
|
+
# Configure within Ask::Ruby::Harness.configure block:
|
|
9
|
+
#
|
|
10
|
+
# Ask::Ruby::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,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Ruby
|
|
8
|
+
module Harness
|
|
9
|
+
# Writes a machine-readable JSON summary of a minitest run.
|
|
10
|
+
#
|
|
11
|
+
# Registered by the ask_ruby_harness minitest plugin when the
|
|
12
|
+
# ASK_TEST_JSON_PATH env var is set (see the run_tests tool). Produces
|
|
13
|
+
# stable structured results — test name, klass, source file/line, status,
|
|
14
|
+
# and a message head — so the harness never has to parse terminal output.
|
|
15
|
+
#
|
|
16
|
+
# The reporter is inert unless ASK_TEST_JSON_PATH is set, so ordinary
|
|
17
|
+
# `rake test` / `bin/rails test` runs in projects that ship the harness
|
|
18
|
+
# are unaffected.
|
|
19
|
+
class MinitestJsonReporter < Minitest::AbstractReporter
|
|
20
|
+
def initialize(path = ENV["ASK_TEST_JSON_PATH"])
|
|
21
|
+
super()
|
|
22
|
+
@path = path
|
|
23
|
+
@results = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record(result)
|
|
27
|
+
@results << result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def report
|
|
31
|
+
return if @path.nil? || @path.to_s.empty?
|
|
32
|
+
File.write(@path, JSON.pretty_generate(build_report))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Never influences the process exit status — pass/fail is decided by
|
|
36
|
+
# minitest's own summary reporter.
|
|
37
|
+
def passed?
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def build_report
|
|
44
|
+
tests = @results.map { |result| test_entry(result) }
|
|
45
|
+
{
|
|
46
|
+
"framework" => "minitest",
|
|
47
|
+
"run" => tests.size,
|
|
48
|
+
"failures" => tests.count { |t| t["status"] == "failed" },
|
|
49
|
+
"errors" => tests.count { |t| t["status"] == "error" },
|
|
50
|
+
"skips" => tests.count { |t| t["status"] == "skipped" },
|
|
51
|
+
"tests" => tests
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_entry(result)
|
|
56
|
+
file, line = result.source_location
|
|
57
|
+
failure = result.failure
|
|
58
|
+
{
|
|
59
|
+
"name" => result.name,
|
|
60
|
+
"klass" => result.klass,
|
|
61
|
+
"file" => file,
|
|
62
|
+
"line" => line,
|
|
63
|
+
"time" => result.time,
|
|
64
|
+
"status" => status_of(result),
|
|
65
|
+
"message" => failure ? message_of(failure) : nil
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def status_of(result)
|
|
70
|
+
return "skipped" if result.skipped?
|
|
71
|
+
return "error" if result.error?
|
|
72
|
+
return "passed" if result.passed?
|
|
73
|
+
"failed"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# First lines of the failure message — enough for an agent to act on
|
|
77
|
+
# without dumping full backtraces into the report.
|
|
78
|
+
def message_of(failure)
|
|
79
|
+
failure.message.to_s.lines.first(3).map(&:strip).reject(&:empty?).join("\n")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Ruby
|
|
5
|
+
module Harness
|
|
6
|
+
# Base class for harness tools. Provides the app root and audit-logged
|
|
7
|
+
# execution with session correlation. Framework editions subclass this
|
|
8
|
+
# to pin the app root (ask-rails-harness overrides app_root to
|
|
9
|
+
# ::Rails.root via the railtie).
|
|
10
|
+
class Tool < Ask::Tool
|
|
11
|
+
def app_root
|
|
12
|
+
Ask::Ruby::Harness.app_root
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Override call to add audit logging around every tool execution.
|
|
16
|
+
# Logs the intent (sanitized params) and outcome (status, timing),
|
|
17
|
+
# but not the returned data.
|
|
18
|
+
def call(args = {}, abort_controller = nil)
|
|
19
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
20
|
+
result = super
|
|
21
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
22
|
+
|
|
23
|
+
AuditLog.log(
|
|
24
|
+
session_id: Thread.current[:ask_session_id],
|
|
25
|
+
tool_name: name,
|
|
26
|
+
params: args,
|
|
27
|
+
result: result,
|
|
28
|
+
duration_ms: duration_ms
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Allow the session to set its ID for audit log correlation.
|
|
35
|
+
# Called by the agent loop before executing a tool.
|
|
36
|
+
def self.session_id=(id)
|
|
37
|
+
Thread.current[:ask_session_id] = id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.session_id
|
|
41
|
+
Thread.current[:ask_session_id]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Ruby
|
|
7
|
+
module Harness
|
|
8
|
+
module Tools
|
|
9
|
+
class QueryDatabase < Ask::Ruby::Harness::Tool
|
|
10
|
+
description "Run a read-only SQL query against the application database. " \
|
|
11
|
+
"Returns columns and rows. Only SELECT queries are allowed in production."
|
|
12
|
+
|
|
13
|
+
param :sql, type: :string, desc: "SQL query (SELECT only in production)", required: true
|
|
14
|
+
param :limit, type: :integer, desc: "Max rows to return (default 50)", required: false
|
|
15
|
+
|
|
16
|
+
WRITE_STATEMENTS = /\A\s*(INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|GRANT|REVOKE)\b/i
|
|
17
|
+
|
|
18
|
+
def execute(sql:, limit: 50)
|
|
19
|
+
sql = sql.strip
|
|
20
|
+
|
|
21
|
+
if WRITE_STATEMENTS.match?(sql)
|
|
22
|
+
return Ask::Result.failure(
|
|
23
|
+
"Only SELECT queries are allowed. Write statements (#{sql.match(WRITE_STATEMENTS)[1]}) are rejected in all environments."
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if Ask::Ruby::Harness.env == "production" && !sql.match?(/\A\s*SELECT\b/i)
|
|
28
|
+
return Ask::Result.failure(
|
|
29
|
+
"Only SELECT queries are allowed in the production environment."
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
unless Ask::Ruby::Harness.database_connected?
|
|
34
|
+
Ask::Ruby::Harness.connect_database!
|
|
35
|
+
end
|
|
36
|
+
unless Ask::Ruby::Harness.database_connected?
|
|
37
|
+
return Ask::Result.failure(
|
|
38
|
+
"Database not connected. Set ASK_DATABASE_URL or provide a config/database.yml."
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
pool = ActiveRecord::Base.connection_pool
|
|
43
|
+
pool.with_connection do |conn|
|
|
44
|
+
limited_sql = sql.match?(/\bLIMIT\b/i) ? sql : "#{sql.chomp(';')} LIMIT #{limit.to_i}"
|
|
45
|
+
result = conn.exec_query(limited_sql)
|
|
46
|
+
columns = result.columns
|
|
47
|
+
rows = result.rows.first(limit.to_i).map { |row| build_row(row, columns) }
|
|
48
|
+
{
|
|
49
|
+
columns: columns,
|
|
50
|
+
rows: rows,
|
|
51
|
+
count: rows.size,
|
|
52
|
+
truncated: result.rows.size > limit.to_i
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
56
|
+
Ask::Result.failure("SQL error: #{e.message}")
|
|
57
|
+
rescue ActiveRecord::ConnectionNotEstablished => e
|
|
58
|
+
Ask::Result.failure(
|
|
59
|
+
"Database not connected: #{e.message}. Set ASK_DATABASE_URL or config/database.yml."
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def build_row(row, columns)
|
|
66
|
+
columns.each_with_index.each_with_object({}) do |(col, i), hash|
|
|
67
|
+
value = row[i]
|
|
68
|
+
hash[col] = sanitize_value(value)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sanitize_value(value)
|
|
73
|
+
return "[BINARY DATA]" if binary_value?(value)
|
|
74
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def binary_value?(value)
|
|
79
|
+
value.is_a?(String) && value.encoding == Encoding::ASCII_8BIT && value.bytesize > 0
|
|
80
|
+
rescue
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|