ask-rails 0.2.5 → 0.11.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 +157 -0
- data/README.md +94 -10
- data/app/controllers/ask/rails/chat_controller.rb +308 -0
- data/app/models/ask/rails/session.rb +23 -0
- data/app/views/ask/rails/chat/index.html.erb +6 -0
- data/app/views/layouts/ask/rails/application.html.erb +521 -0
- data/config/routes.rb +17 -0
- data/lib/ask/rails/audit_log.rb +181 -0
- data/lib/ask/rails/auth.rb +22 -0
- data/lib/ask/rails/configuration.rb +53 -1
- data/lib/ask/rails/engine.rb +1 -1
- data/lib/ask/rails/environment_permissions.rb +39 -0
- data/lib/ask/rails/persistence.rb +18 -5
- data/lib/ask/rails/railtie.rb +5 -1
- data/lib/ask/rails/tool.rb +29 -0
- data/lib/ask/rails/tools/read_file.rb +1 -4
- data/lib/ask/rails/tools/read_routes.rb +1 -4
- data/lib/ask/rails/tools/route_inspector.rb +69 -0
- data/lib/ask/rails/tools/run_command.rb +39 -1
- data/lib/ask/rails/tools/schema_graph.rb +174 -0
- data/lib/ask/rails/tools/search_codebase.rb +6 -7
- data/lib/ask/rails/version.rb +1 -1
- data/lib/ask/rails.rb +111 -4
- data/lib/generators/ask/rails/install/install_generator.rb +5 -0
- data/lib/generators/ask/rails/install/templates/audit_log_migration.rb +22 -0
- data/lib/generators/ask/rails/install/templates/initializer.rb +1 -1
- metadata +12 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Rails
|
|
7
|
+
# Append-only audit log for tool executions.
|
|
8
|
+
#
|
|
9
|
+
# Every tool call made by an agent is recorded in the +ask_audit_logs+
|
|
10
|
+
# table with the intent (sanitized params) and outcome (status, timing),
|
|
11
|
+
# but not the data returned. This gives a trustworthy, queryable record
|
|
12
|
+
# of what the agent did without becoming a PII liability.
|
|
13
|
+
#
|
|
14
|
+
# Sensitive param values (keys matching +password+, +secret+, +token+,
|
|
15
|
+
# +api_key+, +key+) are automatically redacted before logging.
|
|
16
|
+
module AuditLog
|
|
17
|
+
SENSITIVE_KEYS = /\A(password|secret|token|api_key|key|auth_token|access_token)\z/i
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Log a tool execution event.
|
|
21
|
+
#
|
|
22
|
+
# @param session_id [String] The agent session that triggered this call
|
|
23
|
+
# @param tool_name [String] Name of the tool that ran
|
|
24
|
+
# @param params [Hash] The parameters passed to the tool (sanitized automatically)
|
|
25
|
+
# @param result [Ask::Result, Hash, nil] The result returned by the tool
|
|
26
|
+
# @param error [StandardError, nil] The exception if the tool raised
|
|
27
|
+
# @param duration_ms [Integer] Wall-clock time for the tool execution
|
|
28
|
+
# @param user_context [Hash, nil] Who initiated the session (from config)
|
|
29
|
+
def log(session_id:, tool_name:, params:, result: nil, error: nil, duration_ms:)
|
|
30
|
+
now = Time.now.utc
|
|
31
|
+
entry = {
|
|
32
|
+
session_id: session_id,
|
|
33
|
+
tool_name: tool_name,
|
|
34
|
+
params: sanitize_params(params),
|
|
35
|
+
result_summary: build_summary(tool_name, result, error),
|
|
36
|
+
status: determine_status(result, error),
|
|
37
|
+
error_message: determine_error(result, error),
|
|
38
|
+
duration_ms: duration_ms,
|
|
39
|
+
user_context: resolve_user_context,
|
|
40
|
+
environment: environment_name,
|
|
41
|
+
recorded_at: now,
|
|
42
|
+
created_at: now,
|
|
43
|
+
updated_at: now
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if table_exists?
|
|
47
|
+
write_entry(entry)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fire an ActiveSupport notification so host apps can subscribe
|
|
51
|
+
ActiveSupport::Notifications.instrument("audit_log.ask_rails", entry)
|
|
52
|
+
|
|
53
|
+
entry
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def sanitize_params(params)
|
|
59
|
+
return {} unless params.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
params.each_with_object({}) do |(key, value), sanitized|
|
|
62
|
+
if SENSITIVE_KEYS.match?(key.to_s)
|
|
63
|
+
sanitized[key] = "[REDACTED]"
|
|
64
|
+
else
|
|
65
|
+
sanitized[key] = value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def determine_status(result, error)
|
|
71
|
+
return "error" if error
|
|
72
|
+
return "rejected" if result.is_a?(Ask::Result) && (result.error? || result.blocked?)
|
|
73
|
+
"success"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def determine_error(result, error)
|
|
77
|
+
return error.message if error
|
|
78
|
+
if result.is_a?(Ask::Result)
|
|
79
|
+
return result.error.to_s if result.error?
|
|
80
|
+
return result.content.to_s if result.blocked?
|
|
81
|
+
end
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def extract_data(result)
|
|
86
|
+
return nil unless result
|
|
87
|
+
|
|
88
|
+
if result.is_a?(Ask::Result)
|
|
89
|
+
result.content.is_a?(Hash) ? result.content : nil
|
|
90
|
+
elsif result.is_a?(Hash)
|
|
91
|
+
result
|
|
92
|
+
else
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def build_summary(tool_name, result, error)
|
|
98
|
+
if error
|
|
99
|
+
return { error: error.class.name }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if result.is_a?(Ask::Result)
|
|
103
|
+
if result.error?
|
|
104
|
+
return { error: "rejected: #{result.error.to_s.truncate(200)}" }
|
|
105
|
+
end
|
|
106
|
+
if result.blocked?
|
|
107
|
+
return { error: "blocked: #{result.content.to_s.truncate(200)}" }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
data = extract_data(result)
|
|
112
|
+
return {} unless data
|
|
113
|
+
|
|
114
|
+
summary = {}
|
|
115
|
+
summary[:rows] = data[:rows]&.length if data.key?(:rows)
|
|
116
|
+
summary[:columns] = data[:columns]&.length if data.key?(:columns)
|
|
117
|
+
summary[:exit_status] = data[:exit_status] if data.key?(:exit_status)
|
|
118
|
+
summary[:size] = data[:size] if data.key?(:size)
|
|
119
|
+
summary[:matched_lines] = data[:matched_lines] if data.key?(:matched_lines)
|
|
120
|
+
summary[:results] = data[:results]&.length if data.key?(:results)
|
|
121
|
+
summary[:model] = data[:name] if data.key?(:name)
|
|
122
|
+
summary
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolve_user_context
|
|
126
|
+
proc = Ask::Rails.configuration.current_user
|
|
127
|
+
return nil unless proc.respond_to?(:call)
|
|
128
|
+
|
|
129
|
+
result = proc.call
|
|
130
|
+
result.is_a?(Hash) ? result : nil
|
|
131
|
+
rescue StandardError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def environment_name
|
|
136
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : "unknown"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def table_exists?
|
|
140
|
+
return false unless defined?(ActiveRecord::Base)
|
|
141
|
+
|
|
142
|
+
# Only cache the true result — recheck if it was false
|
|
143
|
+
return @table_exists if @table_exists
|
|
144
|
+
|
|
145
|
+
@table_exists = begin
|
|
146
|
+
conn = ActiveRecord::Base.connection
|
|
147
|
+
conn.data_source_exists?("ask_audit_logs")
|
|
148
|
+
rescue StandardError
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def write_entry(entry)
|
|
154
|
+
# Serialize JSON fields for database storage
|
|
155
|
+
serialized = entry.dup
|
|
156
|
+
%i[params result_summary user_context].each do |key|
|
|
157
|
+
serialized[key] = ::JSON.generate(serialized[key]) if serialized[key].is_a?(Hash)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Use raw SQL to avoid requiring a model class
|
|
161
|
+
columns = serialized.keys
|
|
162
|
+
values = columns.map { |col| ActiveRecord::Base.connection.quote(serialized[col]) }
|
|
163
|
+
ActiveRecord::Base.connection.execute(
|
|
164
|
+
"INSERT INTO ask_audit_logs (#{columns.join(', ')})
|
|
165
|
+
VALUES (#{values.join(', ')})"
|
|
166
|
+
)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
# Silently fail — audit log should never crash the caller
|
|
169
|
+
Rails.logger.warn("[ask-rails] Audit log write failed: #{e.message}") if defined?(Rails.logger)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Reset cached table check (useful in tests)
|
|
173
|
+
public
|
|
174
|
+
|
|
175
|
+
def reset_table_check!
|
|
176
|
+
@table_exists = nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
# Inclusion module for authenticating the admin chat.
|
|
6
|
+
#
|
|
7
|
+
# By default, all ask-rails engine routes are accessible without auth.
|
|
8
|
+
# To protect them, define a +current_user+ method and set the auth
|
|
9
|
+
# check in an initializer:
|
|
10
|
+
#
|
|
11
|
+
# Ask::Rails::Auth.check = -> {
|
|
12
|
+
# redirect_to main_app.login_path unless current_user&.admin?
|
|
13
|
+
# }
|
|
14
|
+
#
|
|
15
|
+
# The proc is evaluated in the controller context, so +redirect_to+,
|
|
16
|
+
# +current_user+, +session+, etc. are all available.
|
|
17
|
+
module Auth
|
|
18
|
+
mattr_accessor :check
|
|
19
|
+
self.check = nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -4,7 +4,12 @@ module Ask
|
|
|
4
4
|
module Rails
|
|
5
5
|
class Configuration
|
|
6
6
|
attr_accessor :default_model, :max_turns, :system_prompt,
|
|
7
|
-
:tool_concurrency, :persistence_adapter, :tools
|
|
7
|
+
:tool_concurrency, :persistence_adapter, :tools,
|
|
8
|
+
:current_user, :allowed_commands, :denied_commands,
|
|
9
|
+
:max_session_age, :max_sessions
|
|
10
|
+
|
|
11
|
+
# @return [Hash{Symbol => EnvironmentPermissions}] per-environment permission rules
|
|
12
|
+
attr_reader :environments
|
|
8
13
|
|
|
9
14
|
def initialize
|
|
10
15
|
@default_model = "gpt-4o"
|
|
@@ -13,6 +18,53 @@ module Ask
|
|
|
13
18
|
@tool_concurrency = 5
|
|
14
19
|
@persistence_adapter = nil
|
|
15
20
|
@tools = []
|
|
21
|
+
@current_user = nil
|
|
22
|
+
@allowed_commands = nil
|
|
23
|
+
@denied_commands = nil
|
|
24
|
+
@max_session_age = nil
|
|
25
|
+
@max_sessions = nil
|
|
26
|
+
@environments = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Configure permissions for a specific Rails environment.
|
|
30
|
+
#
|
|
31
|
+
# config.environment :production do |env|
|
|
32
|
+
# env.mode = :read_only
|
|
33
|
+
# env.allowed_commands = [/^rails routes/]
|
|
34
|
+
# env.denied_commands = [/rm/, /dropdb/]
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# @param name [Symbol, String] environment name (:production, :development, :staging, etc.)
|
|
38
|
+
def environment(name)
|
|
39
|
+
env = EnvironmentPermissions.new
|
|
40
|
+
yield env
|
|
41
|
+
@environments[name.to_sym] = env
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Resolved allowed commands for the current Rails environment.
|
|
45
|
+
# Falls back to the global +allowed_commands+ if no per-env config.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<Regexp>, nil]
|
|
48
|
+
def effective_allowed_commands
|
|
49
|
+
env = @environments[::Rails.env.to_sym]
|
|
50
|
+
env&.allowed_commands || @allowed_commands
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Resolved denied commands for the current Rails environment.
|
|
54
|
+
# Falls back to the global +denied_commands+ if no per-env config.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Regexp>, nil]
|
|
57
|
+
def effective_denied_commands
|
|
58
|
+
env = @environments[::Rails.env.to_sym]
|
|
59
|
+
env&.denied_commands || @denied_commands
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Resolved access mode for the current Rails environment.
|
|
63
|
+
#
|
|
64
|
+
# @return [Symbol, nil] +:full_access+, +:read_only+, +:ask_before_changes+, or nil
|
|
65
|
+
def effective_mode
|
|
66
|
+
env = @environments[::Rails.env.to_sym]
|
|
67
|
+
env&.mode || nil
|
|
16
68
|
end
|
|
17
69
|
end
|
|
18
70
|
end
|
data/lib/ask/rails/engine.rb
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
# Per-environment permission rules for agent tool access.
|
|
6
|
+
#
|
|
7
|
+
# Configure within Ask::Rails.configure block:
|
|
8
|
+
#
|
|
9
|
+
# Ask::Rails.configure do |config|
|
|
10
|
+
# config.environment :production do |env|
|
|
11
|
+
# env.mode = :read_only
|
|
12
|
+
# env.allowed_commands = [/^rails routes/, /^rails log/]
|
|
13
|
+
# env.denied_commands = [/rm/, /dropdb/]
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# config.environment :development do |env|
|
|
17
|
+
# env.mode = :full_access
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
class EnvironmentPermissions
|
|
22
|
+
# @return [Symbol, nil] Access mode for ask-agent's Permissions extension
|
|
23
|
+
# (:full_access, :read_only, :ask_before_changes)
|
|
24
|
+
attr_accessor :mode
|
|
25
|
+
|
|
26
|
+
# @return [Array<Regexp>, nil] Allowed command patterns for RunCommand
|
|
27
|
+
attr_accessor :allowed_commands
|
|
28
|
+
|
|
29
|
+
# @return [Array<Regexp>, nil] Denied command patterns for RunCommand (takes precedence)
|
|
30
|
+
attr_accessor :denied_commands
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@mode = nil
|
|
34
|
+
@allowed_commands = nil
|
|
35
|
+
@denied_commands = nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -7,6 +7,23 @@ module Ask
|
|
|
7
7
|
@model_class = model_class
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# State::Adapter contract — set/get/delete
|
|
11
|
+
# (aliased through save/load for backward compatibility)
|
|
12
|
+
|
|
13
|
+
def set(key, value, ttl: nil)
|
|
14
|
+
save(key, value)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def get(key)
|
|
18
|
+
load(key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def delete(session_id)
|
|
22
|
+
model_class.where(session_id: session_id).delete_all
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Backward-compatible interface (used by old code and list queries)
|
|
26
|
+
|
|
10
27
|
def save(session_id, data)
|
|
11
28
|
record = model_class.find_or_initialize_by(session_id: session_id)
|
|
12
29
|
record.update!(data: data)
|
|
@@ -17,10 +34,6 @@ module Ask
|
|
|
17
34
|
record&.data
|
|
18
35
|
end
|
|
19
36
|
|
|
20
|
-
def delete(session_id)
|
|
21
|
-
model_class.where(session_id: session_id).delete_all
|
|
22
|
-
end
|
|
23
|
-
|
|
24
37
|
def list
|
|
25
38
|
model_class.pluck(:session_id)
|
|
26
39
|
end
|
|
@@ -28,7 +41,7 @@ module Ask
|
|
|
28
41
|
private
|
|
29
42
|
|
|
30
43
|
def model_class
|
|
31
|
-
@model_class ||
|
|
44
|
+
@model_class || Ask::Rails::Session
|
|
32
45
|
end
|
|
33
46
|
end
|
|
34
47
|
end
|
data/lib/ask/rails/railtie.rb
CHANGED
|
@@ -4,7 +4,11 @@ module Ask
|
|
|
4
4
|
module Rails
|
|
5
5
|
class Railtie < ::Rails::Railtie
|
|
6
6
|
rake_tasks do
|
|
7
|
-
|
|
7
|
+
desc "Prune old sessions and audit logs based on configuration limits"
|
|
8
|
+
task ask_rails: :cleanup do
|
|
9
|
+
count = Ask::Rails.cleanup!
|
|
10
|
+
puts "Cleaned up #{count || 0} sessions."
|
|
11
|
+
end
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
generators do
|
data/lib/ask/rails/tool.rb
CHANGED
|
@@ -6,6 +6,35 @@ module Ask
|
|
|
6
6
|
def rails_root
|
|
7
7
|
::Rails.root
|
|
8
8
|
end
|
|
9
|
+
|
|
10
|
+
# Override call to add audit logging around every tool execution.
|
|
11
|
+
# Logs the intent (sanitized params) and outcome (status, timing),
|
|
12
|
+
# but not the returned data.
|
|
13
|
+
def call(args = {}, abort_controller = nil)
|
|
14
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
result = super
|
|
16
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
17
|
+
|
|
18
|
+
AuditLog.log(
|
|
19
|
+
session_id: Thread.current[:ask_session_id],
|
|
20
|
+
tool_name: name,
|
|
21
|
+
params: args,
|
|
22
|
+
result: result,
|
|
23
|
+
duration_ms: duration_ms
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Allow the session to set its ID for audit log correlation.
|
|
30
|
+
# Called by the agent loop before executing a tool.
|
|
31
|
+
def self.session_id=(id)
|
|
32
|
+
Thread.current[:ask_session_id] = id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.session_id
|
|
36
|
+
Thread.current[:ask_session_id]
|
|
37
|
+
end
|
|
9
38
|
end
|
|
10
39
|
end
|
|
11
40
|
end
|
|
@@ -13,10 +13,7 @@ module Ask
|
|
|
13
13
|
return Ask::Result.error(message: "File not found: #{path}") unless full_path.exist?
|
|
14
14
|
|
|
15
15
|
content = full_path.read
|
|
16
|
-
|
|
17
|
-
data: { path: path, content: content, size: content.length },
|
|
18
|
-
metadata: { path: path, size: content.length }
|
|
19
|
-
)
|
|
16
|
+
{ path: path, content: content, size: content.length }
|
|
20
17
|
end
|
|
21
18
|
end
|
|
22
19
|
end
|
|
@@ -10,10 +10,7 @@ module Ask
|
|
|
10
10
|
return Ask::Result.error(message: "No routes file found") unless routes_file.exist?
|
|
11
11
|
|
|
12
12
|
content = routes_file.read
|
|
13
|
-
|
|
14
|
-
data: { content: content },
|
|
15
|
-
metadata: { size: content.length }
|
|
16
|
-
)
|
|
13
|
+
{ content: content, size: content.length }
|
|
17
14
|
end
|
|
18
15
|
end
|
|
19
16
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Rails
|
|
5
|
+
module Tools
|
|
6
|
+
class RouteInspector < Ask::Rails::Tool
|
|
7
|
+
description "Return the parsed Rails route table — every route with its HTTP verb, path, " \
|
|
8
|
+
"controller, action, and name. Returns structured data the agent can filter " \
|
|
9
|
+
"and reason about, unlike reading the raw routes.rb file."
|
|
10
|
+
|
|
11
|
+
param :controller, type: :string, desc: "Filter by controller name (e.g. 'users', 'api/v1/posts')", required: false
|
|
12
|
+
param :pattern, type: :string, desc: "Filter by path pattern (e.g. 'users', 'admin')", required: false
|
|
13
|
+
param :verbose, type: :boolean, desc: "Include internal route details (default false)", required: false
|
|
14
|
+
|
|
15
|
+
def execute(controller: nil, pattern: nil, verbose: false)
|
|
16
|
+
return { routes: [], count: 0 } unless defined?(::Rails) && ::Rails.application&.routes
|
|
17
|
+
|
|
18
|
+
all_routes = ::Rails.application.routes.routes.map do |route|
|
|
19
|
+
entry = {
|
|
20
|
+
verb: verb_for(route),
|
|
21
|
+
path: route.path.spec.to_s.delete_suffix("(.:format)"),
|
|
22
|
+
controller: route.defaults[:controller]&.to_s,
|
|
23
|
+
action: route.defaults[:action]&.to_s,
|
|
24
|
+
name: route.name&.to_s
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if verbose
|
|
28
|
+
entry[:requirements] = route.required_parts.presence
|
|
29
|
+
entry[:defaults] = route.defaults.except(:controller, :action).presence
|
|
30
|
+
entry[:constraints] = route.constraints.except(:request_method).presence if route.constraints.any?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
entry
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remove internal/engine routes unless verbose
|
|
37
|
+
unless verbose
|
|
38
|
+
all_routes.reject! { |r| r[:controller].nil? || r[:controller].start_with?("rails/") }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Apply filters
|
|
42
|
+
all_routes.select! { |r| r[:controller] == controller } if controller
|
|
43
|
+
all_routes.select! { |r| r[:path].include?(pattern) } if pattern
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
routes: all_routes,
|
|
47
|
+
count: all_routes.size
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def verb_for(route)
|
|
54
|
+
if route.verb.is_a?(String)
|
|
55
|
+
route.verb
|
|
56
|
+
elsif route.verb.respond_to?(:call)
|
|
57
|
+
# Rails stores verb as a regexp — extract the source
|
|
58
|
+
source = route.verb.source
|
|
59
|
+
source.gsub(/[$^\\]/, "").split("|")
|
|
60
|
+
else
|
|
61
|
+
"ANY"
|
|
62
|
+
end
|
|
63
|
+
rescue
|
|
64
|
+
"ANY"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -8,12 +8,50 @@ module Ask
|
|
|
8
8
|
param :command, type: :string, desc: "Shell command to run", required: true
|
|
9
9
|
|
|
10
10
|
def execute(command:)
|
|
11
|
+
check_result = check_command_allowed(command)
|
|
12
|
+
return check_result if check_result
|
|
13
|
+
|
|
11
14
|
output = `cd #{rails_root} && #{command} 2>&1`
|
|
12
|
-
Ask::Result.
|
|
15
|
+
Ask::Result.ok(
|
|
13
16
|
data: { output: output, exit_status: $?.exitstatus },
|
|
14
17
|
metadata: { exit_status: $?.exitstatus }
|
|
15
18
|
)
|
|
16
19
|
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def check_command_allowed(command)
|
|
24
|
+
config = Ask::Rails.configuration
|
|
25
|
+
|
|
26
|
+
# Use per-environment rules if configured, fall back to global
|
|
27
|
+
denied = config.effective_denied_commands
|
|
28
|
+
allowed = config.effective_allowed_commands
|
|
29
|
+
|
|
30
|
+
# 1. Check denied commands first (takes precedence)
|
|
31
|
+
if denied
|
|
32
|
+
denied.each do |pattern|
|
|
33
|
+
if command.match?(pattern)
|
|
34
|
+
return Ask::Result.error(
|
|
35
|
+
message: "Command blocked by deny rule: #{pattern.inspect}"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 2. Check allowed commands (if configured)
|
|
42
|
+
if allowed
|
|
43
|
+
matches = allowed.any? { |pattern| command.match?(pattern) }
|
|
44
|
+
unless matches
|
|
45
|
+
allowed_desc = allowed.map(&:inspect).join(", ")
|
|
46
|
+
return Ask::Result.error(
|
|
47
|
+
message: "Command blocked: does not match any allowed pattern (#{allowed_desc})"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 3. No restrictions configured — allow
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
17
55
|
end
|
|
18
56
|
end
|
|
19
57
|
end
|