rails_agent_console 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 +80 -0
- data/LICENSE.txt +21 -0
- data/README.md +414 -0
- data/exe/rails-agent +7 -0
- data/lib/generators/rails_agent_console/install_generator.rb +17 -0
- data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
- data/lib/rails_agent_console/agent.rb +277 -0
- data/lib/rails_agent_console/cli.rb +114 -0
- data/lib/rails_agent_console/configuration.rb +170 -0
- data/lib/rails_agent_console/console_installer.rb +49 -0
- data/lib/rails_agent_console/console_methods.rb +54 -0
- data/lib/rails_agent_console/console_session.rb +41 -0
- data/lib/rails_agent_console/credentials.rb +66 -0
- data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
- data/lib/rails_agent_console/diagnosis.rb +149 -0
- data/lib/rails_agent_console/errors.rb +20 -0
- data/lib/rails_agent_console/executor.rb +190 -0
- data/lib/rails_agent_console/history.rb +65 -0
- data/lib/rails_agent_console/model_picker.rb +166 -0
- data/lib/rails_agent_console/prompt/dates.rb +34 -0
- data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
- data/lib/rails_agent_console/prompt.rb +122 -0
- data/lib/rails_agent_console/proposal.rb +208 -0
- data/lib/rails_agent_console/providers/anthropic.rb +48 -0
- data/lib/rails_agent_console/providers/base.rb +114 -0
- data/lib/rails_agent_console/providers/callable.rb +28 -0
- data/lib/rails_agent_console/providers/gemini.rb +45 -0
- data/lib/rails_agent_console/providers/ollama.rb +84 -0
- data/lib/rails_agent_console/providers/openai.rb +51 -0
- data/lib/rails_agent_console/providers.rb +28 -0
- data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
- data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
- data/lib/rails_agent_console/query_validator/result.rb +55 -0
- data/lib/rails_agent_console/query_validator/rules.rb +108 -0
- data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
- data/lib/rails_agent_console/query_validator.rb +305 -0
- data/lib/rails_agent_console/railtie.rb +26 -0
- data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
- data/lib/rails_agent_console/rewriter/chain.rb +126 -0
- data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
- data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
- data/lib/rails_agent_console/rewriter/joined.rb +42 -0
- data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
- data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
- data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
- data/lib/rails_agent_console/rewriter.rb +183 -0
- data/lib/rails_agent_console/schema.rb +275 -0
- data/lib/rails_agent_console/setup.rb +40 -0
- data/lib/rails_agent_console/ui.rb +125 -0
- data/lib/rails_agent_console/value_check.rb +85 -0
- data/lib/rails_agent_console/version.rb +5 -0
- data/lib/rails_agent_console/wizard.rb +235 -0
- data/lib/rails_agent_console.rb +58 -0
- metadata +145 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RailsAgentConsole
|
|
8
|
+
module Providers
|
|
9
|
+
class Base
|
|
10
|
+
attr_reader :config
|
|
11
|
+
|
|
12
|
+
def initialize(config = RailsAgentConsole.configuration)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def chat(system:, messages:)
|
|
17
|
+
response = post(endpoint, headers, payload(system: system, messages: messages))
|
|
18
|
+
extract(response)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def name
|
|
22
|
+
self.class.name.split("::").last
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def model
|
|
26
|
+
config.model
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def models
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default_base
|
|
34
|
+
self.class::DEFAULT_BASE
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def get(uri, headers = {})
|
|
40
|
+
uri = URI(uri)
|
|
41
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
42
|
+
http.use_ssl = uri.scheme == "https"
|
|
43
|
+
http.open_timeout = 5
|
|
44
|
+
http.read_timeout = 10
|
|
45
|
+
response = http.request(Net::HTTP::Get.new(uri.request_uri, headers))
|
|
46
|
+
JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def endpoint
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def headers
|
|
56
|
+
{ "Content-Type" => "application/json" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def payload(system:, messages:)
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extract(_response)
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def api_key!
|
|
68
|
+
config.api_key or raise ConfigurationError, "Missing API key for #{config.provider}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def post(uri, headers, body)
|
|
72
|
+
uri = URI(uri)
|
|
73
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
74
|
+
http.use_ssl = uri.scheme == "https"
|
|
75
|
+
http.open_timeout = 10
|
|
76
|
+
http.read_timeout = config.request_timeout
|
|
77
|
+
|
|
78
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers)
|
|
79
|
+
request.body = JSON.generate(body)
|
|
80
|
+
|
|
81
|
+
response = http.request(request)
|
|
82
|
+
handle(response)
|
|
83
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
84
|
+
raise ProviderError, "#{name} did not respond within #{config.request_timeout}s"
|
|
85
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
|
86
|
+
raise ProviderError, "Could not reach #{name}: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def handle(response)
|
|
90
|
+
body = response.body.to_s
|
|
91
|
+
return JSON.parse(body) if response.is_a?(Net::HTTPSuccess)
|
|
92
|
+
|
|
93
|
+
raise ProviderError, "#{name} returned #{response.code}: #{error_message(body)}"
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
raise ProviderError, "#{name} returned a non-JSON response: #{body[0, 200]}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def error_message(body)
|
|
99
|
+
parsed = JSON.parse(body)
|
|
100
|
+
return body[0, 200] unless parsed.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
error = parsed["error"]
|
|
103
|
+
message = error.is_a?(Hash) ? error["message"] : error
|
|
104
|
+
(message || parsed["message"] || body[0, 200]).to_s[0, 200]
|
|
105
|
+
rescue JSON::ParserError
|
|
106
|
+
body[0, 200]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def base_url(default)
|
|
110
|
+
config.api_base || default
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Providers
|
|
5
|
+
# Example:
|
|
6
|
+
# RailsAgentConsole.configure do |c|
|
|
7
|
+
# c.client = ->(system:, messages:) do
|
|
8
|
+
# RubyLLM.chat.with_instructions(system).ask(messages.last[:content]).content
|
|
9
|
+
# end
|
|
10
|
+
# end
|
|
11
|
+
class Callable < Base
|
|
12
|
+
def name
|
|
13
|
+
"custom client"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def model
|
|
17
|
+
config.model
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def chat(system:, messages:)
|
|
21
|
+
result = config.client.call(system: system, messages: messages)
|
|
22
|
+
raise InvalidResponseError, "config.client returned nothing" if result.to_s.strip.empty?
|
|
23
|
+
|
|
24
|
+
result.to_s
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Providers
|
|
5
|
+
class Gemini < Base
|
|
6
|
+
DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta"
|
|
7
|
+
|
|
8
|
+
def name
|
|
9
|
+
"Gemini"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def models
|
|
13
|
+
listed = Array(get("#{base_url(DEFAULT_BASE)}/models?pageSize=200&key=#{api_key!}")&.dig("models"))
|
|
14
|
+
listed.select { |entry| Array(entry["supportedGenerationMethods"]).include?("generateContent") }
|
|
15
|
+
.filter_map { |entry| entry["name"].to_s.delete_prefix("models/") }
|
|
16
|
+
rescue ConfigurationError
|
|
17
|
+
[]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def endpoint
|
|
23
|
+
"#{base_url(DEFAULT_BASE)}/models/#{model}:generateContent?key=#{api_key!}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def payload(system:, messages:)
|
|
27
|
+
{
|
|
28
|
+
system_instruction: { parts: [{ text: system }] },
|
|
29
|
+
generationConfig: { temperature: config.temperature },
|
|
30
|
+
contents: messages.map do |message|
|
|
31
|
+
{ role: message[:role] == "assistant" ? "model" : "user", parts: [{ text: message[:content] }] }
|
|
32
|
+
end
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def extract(response)
|
|
37
|
+
parts = response.dig("candidates", 0, "content", "parts")
|
|
38
|
+
content = Array(parts).filter_map { |part| part["text"] }.join.strip
|
|
39
|
+
raise InvalidResponseError, "Gemini returned no content" if content.empty?
|
|
40
|
+
|
|
41
|
+
content
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Providers
|
|
5
|
+
class Ollama < Base
|
|
6
|
+
DEFAULT_BASE = "http://localhost:11434"
|
|
7
|
+
CONTEXT_TOKENS = 8192
|
|
8
|
+
ANSWER_TOKENS = 1024
|
|
9
|
+
MISSING_MODEL = /model ['"]?([^'"]+?)['"]? not found/
|
|
10
|
+
RECOMMENDED = "qwen2.5-coder:7b"
|
|
11
|
+
|
|
12
|
+
def name
|
|
13
|
+
"Ollama"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def chat(system:, messages:)
|
|
17
|
+
super
|
|
18
|
+
rescue ProviderError => e
|
|
19
|
+
missing = e.message[MISSING_MODEL, 1]
|
|
20
|
+
raise if missing.nil?
|
|
21
|
+
|
|
22
|
+
switch_from(missing)
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def installed_models
|
|
27
|
+
Array(get("#{base_url(DEFAULT_BASE)}/api/tags")&.dig("models")).filter_map { |model| model["name"] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def models
|
|
31
|
+
installed_models
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def switch_from(missing)
|
|
37
|
+
installed = installed_models
|
|
38
|
+
raise ProviderError, nothing_installed(missing) if installed.empty?
|
|
39
|
+
|
|
40
|
+
config.model = pick(installed)
|
|
41
|
+
UI.new(config).warn("#{missing} is not installed in Ollama, so this uses #{config.model}. " \
|
|
42
|
+
"Keep it with ai_model \"ollama/#{config.model}\", #{way_to(missing)}.")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def way_to(missing)
|
|
46
|
+
owner = Configuration.provider_for(missing)
|
|
47
|
+
return "or install the other with `ollama pull #{missing}`" if owner.nil?
|
|
48
|
+
|
|
49
|
+
"or use #{missing} where it runs with ai_model \"#{owner}/#{missing}\""
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def pick(installed)
|
|
53
|
+
installed.find { |model| model.start_with?(RECOMMENDED) } ||
|
|
54
|
+
installed.find { |model| model.include?("coder") } ||
|
|
55
|
+
installed.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def nothing_installed(missing)
|
|
59
|
+
"Ollama has no models installed, so #{missing} cannot answer. " \
|
|
60
|
+
"Install one with `ollama pull #{RECOMMENDED}` and ask again."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def endpoint
|
|
64
|
+
"#{base_url(DEFAULT_BASE)}/api/chat"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def payload(system:, messages:)
|
|
68
|
+
{
|
|
69
|
+
model: model,
|
|
70
|
+
stream: false,
|
|
71
|
+
options: { temperature: config.temperature, num_ctx: CONTEXT_TOKENS, num_predict: ANSWER_TOKENS },
|
|
72
|
+
messages: [{ role: "system", content: system }] + messages
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract(response)
|
|
77
|
+
content = response.dig("message", "content").to_s
|
|
78
|
+
raise InvalidResponseError, "Ollama returned no content" if content.strip.empty?
|
|
79
|
+
|
|
80
|
+
content
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Providers
|
|
5
|
+
class OpenAI < Base
|
|
6
|
+
DEFAULT_BASE = "https://api.openai.com/v1"
|
|
7
|
+
|
|
8
|
+
CHAT_MODEL = /\A(gpt-|chatgpt-|o\d)/
|
|
9
|
+
NOT_CHAT = /audio|realtime|tts|transcribe|image|search|embedding|instruct|-\d{4}(-\d\d-\d\d)?\z/
|
|
10
|
+
|
|
11
|
+
def name
|
|
12
|
+
"OpenAI"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def models
|
|
16
|
+
listed = Array(get("#{base_url(DEFAULT_BASE)}/models", headers)&.dig("data"))
|
|
17
|
+
listed = listed.sort_by { |entry| -entry["created"].to_i }.filter_map { |entry| entry["id"] }
|
|
18
|
+
return listed unless base_url(DEFAULT_BASE) == DEFAULT_BASE
|
|
19
|
+
|
|
20
|
+
listed.grep(CHAT_MODEL).grep_v(NOT_CHAT)
|
|
21
|
+
rescue ConfigurationError
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def endpoint
|
|
28
|
+
"#{base_url(DEFAULT_BASE)}/chat/completions"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def headers
|
|
32
|
+
super.merge("Authorization" => "Bearer #{api_key!}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def payload(system:, messages:)
|
|
36
|
+
{
|
|
37
|
+
model: model,
|
|
38
|
+
temperature: config.temperature,
|
|
39
|
+
messages: [{ role: "system", content: system }] + messages
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def extract(response)
|
|
44
|
+
content = response.dig("choices", 0, "message", "content")
|
|
45
|
+
raise InvalidResponseError, "OpenAI returned no content" if content.nil? || content.strip.empty?
|
|
46
|
+
|
|
47
|
+
content
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "providers/base"
|
|
4
|
+
require_relative "providers/openai"
|
|
5
|
+
require_relative "providers/anthropic"
|
|
6
|
+
require_relative "providers/gemini"
|
|
7
|
+
require_relative "providers/ollama"
|
|
8
|
+
require_relative "providers/callable"
|
|
9
|
+
|
|
10
|
+
module RailsAgentConsole
|
|
11
|
+
module Providers
|
|
12
|
+
REGISTRY = {
|
|
13
|
+
openai: OpenAI,
|
|
14
|
+
anthropic: Anthropic,
|
|
15
|
+
gemini: Gemini,
|
|
16
|
+
ollama: Ollama
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def self.build(config = RailsAgentConsole.configuration)
|
|
20
|
+
return Callable.new(config) if config.client
|
|
21
|
+
|
|
22
|
+
klass = REGISTRY[config.provider]
|
|
23
|
+
raise ConfigurationError, "Unknown provider #{config.provider.inspect}" unless klass
|
|
24
|
+
|
|
25
|
+
klass.new(config)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
class QueryValidator
|
|
5
|
+
module ModelSuggestion
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def for(name)
|
|
9
|
+
close = Schema.model_names.select { |model| close?(model.split("::").last.downcase, name.downcase) }
|
|
10
|
+
close.empty? ? "" : " (did you mean #{close.first(3).join(" or ")}?)"
|
|
11
|
+
rescue StandardError
|
|
12
|
+
""
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def close?(model, wanted)
|
|
16
|
+
single = wanted.singularize
|
|
17
|
+
model.start_with?(single) || model.end_with?(single) || wanted.start_with?(model) ||
|
|
18
|
+
edit_distance(model, wanted) <= 2
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def edit_distance(left, right)
|
|
22
|
+
previous = (0..right.length).to_a
|
|
23
|
+
left.each_char.with_index(1) do |left_char, row|
|
|
24
|
+
current = [row]
|
|
25
|
+
right.each_char.with_index(1) do |right_char, column|
|
|
26
|
+
cost = left_char == right_char ? 0 : 1
|
|
27
|
+
current << [previous[column] + 1, current[column - 1] + 1, previous[column - 1] + cost].min
|
|
28
|
+
end
|
|
29
|
+
previous = current
|
|
30
|
+
end
|
|
31
|
+
previous.last
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
module RailsAgentConsole
|
|
6
|
+
class QueryValidator
|
|
7
|
+
class ParseError < Ripper
|
|
8
|
+
def self.message_for(code)
|
|
9
|
+
parser = new(code)
|
|
10
|
+
parser.parse
|
|
11
|
+
parser.message
|
|
12
|
+
rescue StandardError
|
|
13
|
+
nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :message
|
|
17
|
+
|
|
18
|
+
def on_parse_error(message)
|
|
19
|
+
@message = message if @message.nil?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
class QueryValidator
|
|
5
|
+
class Result
|
|
6
|
+
attr_reader :code, :violations, :writes, :methods, :constants, :unknown, :unknown_constants
|
|
7
|
+
|
|
8
|
+
def initialize(code:, violations:, writes:, methods:, constants:,
|
|
9
|
+
unknown: [], unknown_constants: [], blocked: false, syntax_error: false)
|
|
10
|
+
@code = code
|
|
11
|
+
@violations = violations.uniq
|
|
12
|
+
@writes = writes.uniq
|
|
13
|
+
@methods = methods.uniq
|
|
14
|
+
@constants = constants.uniq
|
|
15
|
+
@unknown = unknown.uniq
|
|
16
|
+
@unknown_constants = unknown_constants.uniq
|
|
17
|
+
@blocked = blocked
|
|
18
|
+
@syntax_error = syntax_error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def valid?
|
|
22
|
+
@violations.empty?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def blocked?
|
|
26
|
+
@blocked
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def syntax_error?
|
|
30
|
+
@syntax_error
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def repairable?
|
|
34
|
+
!valid? && !blocked? && @writes.empty? &&
|
|
35
|
+
(syntax_error? || @unknown.any? || @unknown_constants.any?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def writes?
|
|
39
|
+
@writes.any?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def destructive?
|
|
43
|
+
@writes.any? { |name| Rules::DESTRUCTIVE.include?(name) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def models
|
|
47
|
+
@constants & Schema.model_names
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_s
|
|
51
|
+
valid? ? "valid" : "invalid: #{@violations.join("; ")}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module RailsAgentConsole
|
|
6
|
+
class QueryValidator
|
|
7
|
+
module Rules
|
|
8
|
+
FORBIDDEN = %w[
|
|
9
|
+
eval instance_eval class_eval module_eval instance_exec class_exec binding
|
|
10
|
+
send __send__ public_send method define_method method_missing
|
|
11
|
+
system exec spawn fork syscall exit exit! abort at_exit trap sleep
|
|
12
|
+
require require_relative load autoload open popen
|
|
13
|
+
const_get const_set remove_const instance_variable_get instance_variable_set
|
|
14
|
+
class_variable_get class_variable_set attr_accessor alias_method undef_method
|
|
15
|
+
connection connection_pool with_connection raw_connection execute exec_query
|
|
16
|
+
exec_update exec_delete select_all select_one select_value find_by_sql
|
|
17
|
+
count_by_sql sanitize_sql create_table drop_table change_table add_column
|
|
18
|
+
remove_column rename_table truncate truncate_tables migrate rollback
|
|
19
|
+
establish_connection remove_connection instance_variables methods
|
|
20
|
+
caller caller_locations gets `
|
|
21
|
+
].to_set.freeze
|
|
22
|
+
|
|
23
|
+
WRITE = %w[
|
|
24
|
+
save save! create create! create_or_find_by create_or_find_by!
|
|
25
|
+
find_or_create_by find_or_create_by! first_or_create first_or_create!
|
|
26
|
+
first_or_initialize new build update update! update_attribute
|
|
27
|
+
update_attributes update_column update_columns update_all upsert upsert_all
|
|
28
|
+
insert insert! insert_all insert_all! import touch touch_all
|
|
29
|
+
increment! decrement! toggle! reset_counters
|
|
30
|
+
delete delete_all delete_by destroy destroy! destroy_all destroy_by
|
|
31
|
+
transaction lock lock! with_lock
|
|
32
|
+
].to_set.freeze
|
|
33
|
+
|
|
34
|
+
DESTRUCTIVE = %w[
|
|
35
|
+
delete delete_all delete_by destroy destroy! destroy_all destroy_by
|
|
36
|
+
update_all update_column update_columns
|
|
37
|
+
].to_set.freeze
|
|
38
|
+
|
|
39
|
+
ALLOWED = %w[
|
|
40
|
+
all where not or and rewhere unscope only except merge none from
|
|
41
|
+
joins left_joins left_outer_joins includes preload eager_load references
|
|
42
|
+
group having order reorder reverse_order in_order_of
|
|
43
|
+
limit offset distinct select pluck pick ids extract_associated
|
|
44
|
+
readonly strict_loading annotate optimizer_hints with
|
|
45
|
+
find find_by find_by! first first! last last! take take! sole find_sole_by
|
|
46
|
+
find_each find_in_batches in_batches each each_with_index each_with_object
|
|
47
|
+
exists? any? many? empty? none? one? present? blank? nil? include?
|
|
48
|
+
count sum average minimum maximum size length calculate tally
|
|
49
|
+
group_by index_by index_with partition
|
|
50
|
+
map collect flat_map filter_map filter reject sort sort_by
|
|
51
|
+
min min_by max max_by reduce inject zip uniq compact flatten
|
|
52
|
+
to_a to_ary to_h to_hash keys values values_at fetch dig slice
|
|
53
|
+
transform_values transform_keys invert drop take_while drop_while
|
|
54
|
+
reverse sample shuffle rotate chunk_while each_slice each_cons
|
|
55
|
+
to_s to_i to_f to_r to_d to_sym inspect round abs ceil floor
|
|
56
|
+
divmod fdiv clamp between? positive? negative? zero? nonzero? nan? infinite?
|
|
57
|
+
upcase downcase capitalize titleize humanize pluralize singularize
|
|
58
|
+
strip chomp split join gsub sub start_with? end_with? match match? scan
|
|
59
|
+
present blank squish truncate_words
|
|
60
|
+
ago from_now since until seconds second minutes minute hours hour
|
|
61
|
+
days day weeks week fortnight months month years year
|
|
62
|
+
beginning_of_day end_of_day midnight noon beginning_of_hour end_of_hour
|
|
63
|
+
beginning_of_minute end_of_minute
|
|
64
|
+
beginning_of_week end_of_week beginning_of_month end_of_month
|
|
65
|
+
beginning_of_quarter end_of_quarter beginning_of_year end_of_year
|
|
66
|
+
all_day all_week all_month all_quarter all_year
|
|
67
|
+
today yesterday tomorrow current now at parse iso8601 strftime
|
|
68
|
+
to_date to_time to_datetime in_time_zone utc localtime change advance
|
|
69
|
+
prev_day next_day prev_week next_week prev_month next_month
|
|
70
|
+
prev_year next_year monday sunday wday yday cweek on_weekend? on_weekday?
|
|
71
|
+
new_offset zone
|
|
72
|
+
sql cast lower upper
|
|
73
|
+
rand hex uuid alphanumeric random_number
|
|
74
|
+
attributes attribute_names as_json to_json column_names table_name
|
|
75
|
+
primary_key model_name human_attribute_name arel_table type_for_attribute
|
|
76
|
+
puts pp print p format sprintf tap then yield_self lambda proc
|
|
77
|
+
].to_set.freeze
|
|
78
|
+
|
|
79
|
+
CONSTRUCTORS = %w[new build].to_set.freeze
|
|
80
|
+
|
|
81
|
+
UNSAFE_LOAD_RECEIVERS = %w[
|
|
82
|
+
Kernel Object Module Class Marshal YAML Psych JSON ERB File IO Dir
|
|
83
|
+
].to_set.freeze
|
|
84
|
+
|
|
85
|
+
BLOCKED_CONSTANTS = (UNSAFE_LOAD_RECEIVERS + %w[
|
|
86
|
+
ENV Process Open3 Socket Net Shellwords Tempfile Pathname Gem Bundler
|
|
87
|
+
Sidekiq ActionMailer
|
|
88
|
+
]).to_set.freeze
|
|
89
|
+
|
|
90
|
+
VALUE_CONSTANTS = %w[
|
|
91
|
+
Time Date DateTime TimeZone Range Hash Array Set String Symbol
|
|
92
|
+
Integer Float Rational BigDecimal Struct Arel
|
|
93
|
+
].to_set.freeze
|
|
94
|
+
|
|
95
|
+
SAFE_CONSTANTS = %w[
|
|
96
|
+
Time Date DateTime ActiveSupport TimeZone Arel Float Integer Numeric String
|
|
97
|
+
Symbol Hash Array Range Set Math Rational BigDecimal Comparable Struct
|
|
98
|
+
SecureRandom ActiveRecord Base Relation Rails
|
|
99
|
+
].to_set.freeze
|
|
100
|
+
|
|
101
|
+
DANGEROUS_SQL = /
|
|
102
|
+
\b(delete\s+from|drop\s+(table|database|index|schema)|truncate\s+table|
|
|
103
|
+
insert\s+into|update\s+\w+\s+set|alter\s+table|grant\s+|create\s+table)\b |
|
|
104
|
+
;\s*(select|delete|drop|update|insert|alter|attach)\b
|
|
105
|
+
/ix
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
class QueryValidator
|
|
5
|
+
module SensitiveColumns
|
|
6
|
+
PATTERN = /\A(?:\w*password\w*|\w+_digest|(?:\w+_)?token|\w*secret\w*|(?:\w+_)?api_key|otp_\w+)\z/i
|
|
7
|
+
PRESENCE_TEST = /(?<![\w.:])\w+:\s*nil\b(?!\s*\.)/
|
|
8
|
+
TOKENS = %i[on_ident on_label on_tstring_content on_symbol].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def named_in(code)
|
|
13
|
+
Ripper.lex(code.gsub(PRESENCE_TEST, "")).flat_map { |(_, type, text)| TOKENS.include?(type) ? words(text) : [] }
|
|
14
|
+
.grep(PATTERN).uniq
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def words(text)
|
|
18
|
+
text.scan(/[A-Za-z_]\w*/)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|