ask-rails 0.3.0 → 0.11.1

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.
@@ -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
@@ -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
@@ -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
@@ -2,11 +2,28 @@
2
2
 
3
3
  module Ask
4
4
  module Rails
5
- class Persistence
5
+ class Persistence < ::Ask::State::Adapter
6
6
  def initialize(model_class: nil)
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,8 +34,8 @@ 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
37
+ def clear
38
+ model_class.delete_all
22
39
  end
23
40
 
24
41
  def list
@@ -28,7 +45,7 @@ module Ask
28
45
  private
29
46
 
30
47
  def model_class
31
- @model_class || (raise "No model class configured. Use Persistence.new(model_class: MyModel)")
48
+ @model_class || Ask::Rails::Session
32
49
  end
33
50
  end
34
51
  end
@@ -4,7 +4,11 @@ module Ask
4
4
  module Rails
5
5
  class Railtie < ::Rails::Railtie
6
6
  rake_tasks do
7
- # Load rake tasks if any
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
@@ -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
- Ask::Result.success(
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
- Ask::Result.success(
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.success(
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