omamori 0.1.2 → 0.2.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Gemfile +1 -1
  4. data/Gemfile.lock +34 -14
  5. data/README.md +202 -49
  6. data/README_ja.md +204 -46
  7. data/demo/ai_analysis_vulnerability.rb +3 -3
  8. data/demo/csrf_vulnerability.rb +1 -1
  9. data/demo/eval_vulnerability.rb +1 -1
  10. data/demo/idor_vulnerability.rb +2 -2
  11. data/demo/insecure_cookie_vulnerability.rb +1 -1
  12. data/demo/open_redirect_vulnerability.rb +1 -1
  13. data/demo/static_analysis_vulnerability.rb +1 -1
  14. data/demo/xss_vulnerability.rb +1 -1
  15. data/exe/omamori +2 -2
  16. data/lib/omamori/ai_analysis_engine/checklist_scanner.rb +143 -0
  17. data/lib/omamori/ai_analysis_engine/cli_clients/base.rb +43 -0
  18. data/lib/omamori/ai_analysis_engine/cli_clients/claude_code_client.rb +35 -0
  19. data/lib/omamori/ai_analysis_engine/cli_clients/codex_client.rb +47 -0
  20. data/lib/omamori/ai_analysis_engine/diff_splitter.rb +30 -22
  21. data/lib/omamori/ai_analysis_engine/fallback_client.rb +23 -0
  22. data/lib/omamori/ai_analysis_engine/judge_client.rb +45 -0
  23. data/lib/omamori/ai_analysis_engine/llm_client.rb +71 -0
  24. data/lib/omamori/ai_analysis_engine/prompt_manager.rb +96 -19
  25. data/lib/omamori/ai_analysis_engine/ruby_llm_context.rb +23 -0
  26. data/lib/omamori/ai_analysis_engine/sub_checks/audit_log_missing.yml +12 -0
  27. data/lib/omamori/ai_analysis_engine/sub_checks/csrf.yml +22 -0
  28. data/lib/omamori/ai_analysis_engine/sub_checks/insecure_deserialization_rce.yml +9 -0
  29. data/lib/omamori/ai_analysis_engine/sub_checks/insufficient_encryption.yml +19 -0
  30. data/lib/omamori/ai_analysis_engine/sub_checks/mass_assignment.yml +19 -0
  31. data/lib/omamori/ai_analysis_engine/sub_checks/open_redirect.yml +17 -0
  32. data/lib/omamori/ai_analysis_engine/sub_checks/sql_injection.yml +20 -0
  33. data/lib/omamori/ai_analysis_engine/sub_checks/xss.yml +37 -0
  34. data/lib/omamori/ai_settings.rb +273 -0
  35. data/lib/omamori/config.rb +89 -69
  36. data/lib/omamori/core_runner.rb +485 -286
  37. data/lib/omamori/doctor.rb +95 -0
  38. data/lib/omamori/parallel.rb +24 -0
  39. data/lib/omamori/report_generator/console_formatter.rb +48 -42
  40. data/lib/omamori/report_generator/html_formatter.rb +6 -6
  41. data/lib/omamori/report_generator/json_formatter.rb +1 -1
  42. data/lib/omamori/setup_files.rb +260 -0
  43. data/lib/omamori/static_analysers/brakeman_runner.rb +7 -7
  44. data/lib/omamori/static_analysers/bundler_audit_runner.rb +8 -6
  45. data/lib/omamori/version.rb +2 -2
  46. data/lib/omamori.rb +11 -11
  47. metadata +61 -14
  48. data/lib/omamori/ai_analysis_engine/gemini_client.rb +0 -71
@@ -0,0 +1,19 @@
1
+ # Concrete patterns that are each enough to make insufficient encryption real on their own.
2
+ # Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
3
+ weak_randomness: >-
4
+ Does the code generate a token, reset code, one-time code, session id, or key with a predictable random source
5
+ (rand, Random.new, Kernel#srand, or values derived from the time or an id) instead of SecureRandom? Answer no when
6
+ SecureRandom (or a library that uses it) produces the value.
7
+ weak_cipher_use: >-
8
+ Does the code encrypt data with a fixed, reused, or derived-from-data IV or nonce, with ECB mode, with a hard-coded
9
+ key, or with an unauthenticated mode where tampering matters (for example AES-CBC without a MAC)? Answer no when it
10
+ uses ActiveSupport::MessageEncryptor or Active Record encryption with their defaults, or an authenticated mode such
11
+ as AES-GCM with a fresh random IV for every message.
12
+ encryption_removed: >-
13
+ Does the change stop encrypting sensitive data that was encrypted before, for example removing encrypts from a model
14
+ attribute, storing a secret or personal data in a plain column, or replacing encryption with encoding (Base64)?
15
+ Answer no when the data stays encrypted, or only a non-sensitive value is stored in plain form.
16
+ weak_password_hash: >-
17
+ Does the code store or compare passwords, or other low-entropy secrets such as short codes, with a fast digest (MD5,
18
+ SHA1, SHA256 without a password hashing scheme) or without a salt? Answer no when it uses has_secure_password,
19
+ bcrypt, scrypt, or Argon2, or when the hashed value is a long random token where a fast digest is fine.
@@ -0,0 +1,19 @@
1
+ # Concrete patterns that are each enough to make mass assignment real on their own.
2
+ # Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
3
+ permit_all: >-
4
+ Does the code create or update a model with request parameters that are not limited to a fixed list of
5
+ attributes, through params.permit!, to_unsafe_h, to_unsafe_hash, permit(*Model.column_names), or a permit list
6
+ chosen by the client, in an action regular users can call? Answer no when the parameters are limited with permit
7
+ or expect to a fixed list, or when a hash permit such as permit(preferences: {}) only fills a single JSON or
8
+ serialized column.
9
+ sensitive_attribute: >-
10
+ Does an action that regular users can call (sign-up, profile or account update, a member API) permit attributes
11
+ that users should not control, such as role, admin, owner_id, user_id, account_id, balance, price, plan, verified,
12
+ or confirmed_at? Answer no when such attributes are only permitted in an admin-only action that checks the user is
13
+ an administrator, or are set on the server instead of from params.
14
+ strong_params_bypass: >-
15
+ Does the code pass request data to a model write (new, create, update, assign_attributes, insert_all, upsert)
16
+ without going through strong parameters, for example JSON.parse(request.body.read), request.request_parameters,
17
+ request.POST, or the arguments of a GraphQL mutation (GraphQL input objects are not protected by strong
18
+ parameters, so every argument the input type declares can be written)? Answer no when only explicitly listed,
19
+ harmless attributes are taken from that data, or the GraphQL input type declares only harmless arguments.
@@ -0,0 +1,17 @@
1
+ # Concrete patterns that are each enough to make an open redirect real on their own.
2
+ # Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
3
+ other_host_allowed: >-
4
+ Does the code redirect to a URL that may come from users (params, a value saved in the session from params, or
5
+ the Referer header) in a way that allows other hosts: redirect_to or redirect_back with allow_other_host: true,
6
+ raise_on_open_redirects turned off, or a redirect outside Rails' redirect_to such as a Sinatra or Rack redirect
7
+ or a Location header set by hand? Answer no when the destination is checked first with url_from, or its parsed
8
+ host is compared exactly against a fixed list, or Rails' default protection (Rails 7+) is left on.
9
+ weak_url_check: >-
10
+ Does the code decide that a redirect destination is safe with a string check that can be fooled: start_with?,
11
+ include?, or end_with? on the URL or host, or only checking that the value starts with "/" (which lets
12
+ //evil.example and /\evil.example through)? Answer no when the URL is parsed and its host compared exactly, or
13
+ when a path check also rejects values starting with "//" or "/\".
14
+ client_side_redirect: >-
15
+ Does front-end JavaScript send the browser to a URL taken from the query string, the hash, or other
16
+ user-controlled input (window.location, location.href, location.assign, location.replace) without making sure it
17
+ stays on the same origin? Answer no when it only accepts URLs whose origin equals location.origin.
@@ -0,0 +1,20 @@
1
+ # Concrete patterns that are each enough to make SQL injection real on their own.
2
+ # Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
3
+ string_built_query: >-
4
+ Does the code interpolate or concatenate data that may come from users into a SQL fragment passed to an
5
+ ActiveRecord method that accepts raw SQL (where, having, joins, from, lock, find_by_sql)? Answer no when the value
6
+ is passed through a ? or named placeholder, a hash condition, sanitize_sql_array, connection.quote, or is cast to
7
+ an integer first.
8
+ raw_connection: >-
9
+ Does the code run SQL through the database connection directly (connection.execute, select_all, select_value,
10
+ select_rows, exec_query) with data that may come from users interpolated into the SQL string? Answer no when the
11
+ values are bound as parameters, quoted with connection.quote, or cast to integers.
12
+ raw_sql_method: >-
13
+ Does data that may come from users reach a method that takes raw SQL without checking it: Arel.sql(...),
14
+ select("..."), group("..."), or order / reorder / pluck with Arel.sql? Answer no when the value is mapped through
15
+ a fixed allowlist of column names or directions, or passed as a symbol or hash such as order(created_at: :desc).
16
+ Note that plain order(params[:sort]) without Arel.sql is rejected by Rails 6+ for anything but a column name.
17
+ like_and_in_clauses: >-
18
+ Does the code build a LIKE pattern or an IN (...) list by putting data that may come from users directly into
19
+ the SQL string, for example where("name LIKE '%#{q}%'") or where("id IN (#{ids.join(',')})")? Answer no when it
20
+ uses where("name LIKE ?", "%#{sanitize_sql_like(q)}%"), a placeholder, or a hash condition such as where(id: ids).
@@ -0,0 +1,37 @@
1
+ # Concrete patterns that are each enough to make XSS real on their own.
2
+ # The classifier answers each one separately, alongside the overall XSS question.
3
+ # Each question asks for the pattern AND a missing defense, so that code which uses the same construct safely is a no.
4
+ raw_output: >-
5
+ Does the code send data that may come from users to the page as HTML without escaping it? Answer yes when such
6
+ data reaches raw, html_safe, <%== %>, or safe_concat, or is interpolated into a string that is then marked
7
+ html_safe, without being escaped first. Answer no when every user value is escaped (h, ERB::Util.html_escape,
8
+ sanitize) or the HTML is built with tag helpers (content_tag, tag.*, link_to, safe_join) before being marked safe.
9
+ js_context: >-
10
+ Does the code put data that may come from users into JavaScript (an inline <script> block, an event handler
11
+ attribute such as onclick, or a JavaScript string) without JavaScript-safe escaping? This includes raw output,
12
+ and JSON produced by JSON.generate or a JSON library other than Rails' to_json, because it does not escape
13
+ </script>. Answer no when the data goes through json_escape, j / escape_javascript, or Rails' to_json, which
14
+ escape the characters that could end the script.
15
+ url_attribute: >-
16
+ Does the code put a URL that may come from users into href, src, action, or formaction without making sure it is
17
+ not a javascript: URL? Answer no when the scheme is checked to be http or https, the URL comes from a route helper
18
+ or a fixed path, or the content goes through sanitize, which removes javascript: URLs.
19
+ html_response: >-
20
+ Does the code build HTML by interpolating data that may come from users into a string and send it to the browser
21
+ outside the escaping template engine (render html:, render inline:, or a Rack or Sinatra response body), without
22
+ escaping that data? Answer no when every interpolated value is escaped (h, ERB::Util.html_escape,
23
+ Rack::Utils.escape_html).
24
+ sanitizer_config: >-
25
+ Does the code change how content that may come from users is sanitized or rendered so that dangerous HTML can now
26
+ get through? For example allowing script, iframe, or object tags or on* attributes in sanitize, or enabling raw
27
+ HTML in a Markdown renderer (Commonmarker unsafe: true, Redcarpet without filter_html). Answer no when the change
28
+ keeps raw HTML disabled and keeps the default sanitize allowlist.
29
+ dom_sink: >-
30
+ Does front-end JavaScript insert data that may come from users or from the server into the page as HTML, through
31
+ innerHTML, outerHTML, insertAdjacentHTML, document.write, or jQuery .html()? Answer no when it only uses
32
+ textContent, createTextNode, or creates elements and sets their text.
33
+ attribute_injection: >-
34
+ Does the code build an HTML attribute value by interpolating data that may come from users into a string, without
35
+ escaping the quotes, so that the value could end the attribute and add a new one such as onmouseover? Answer no
36
+ when the value is escaped with h or ERB::Util.html_escape (which escape quotes) or the attribute is set through a
37
+ tag helper.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ai_analysis_engine/ruby_llm_context'
4
+ require_relative 'ai_analysis_engine/llm_client'
5
+ require_relative 'ai_analysis_engine/judge_client'
6
+ require_relative 'ai_analysis_engine/cli_clients/claude_code_client'
7
+ require_relative 'ai_analysis_engine/cli_clients/codex_client'
8
+ require_relative 'ai_analysis_engine/fallback_client'
9
+
10
+ module Omamori
11
+ # Decides which classifier and reporter to use, from .omamorirc and command-line options.
12
+ #
13
+ # classifier: # omit to let the reporter check everything itself
14
+ # provider: typesafe # any RubyLLM provider that can judge (typesafe, openai, ollama)
15
+ # model: jev-1.13.0
16
+ # sub_checks: true # also ask each risk's concrete patterns (sub_checks/*.yml)
17
+ # reporter:
18
+ # provider: anthropic # any RubyLLM provider, or claude_code / codex (subscription CLIs)
19
+ # model: claude-sonnet-5-5
20
+ # fallback: # optional; asked only when the reporter refuses or fails
21
+ # provider: claude_code
22
+ # model: sonnet
23
+ #
24
+ # There is no default reporter: `omamori init --reporter ...` writes one. The older keys (engine, llm,
25
+ # model, provider, api_key, decision, claude, codex) are still read; an old config with only `api_key`
26
+ # keeps using Gemini, as it did before.
27
+ class AISettings
28
+ class Error < StandardError; end
29
+
30
+ CLI_REPORTERS = %w[claude_code codex].freeze
31
+ # The reporter of omamori 0.1, used only for old configs that set the top-level api_key (a Gemini key)
32
+ LEGACY_REPORTER = { 'provider' => 'gemini', 'model' => AIAnalysisEngine::LLMClient::DEFAULT_MODEL }.freeze
33
+ # Classifier providers whose model can be omitted
34
+ DEFAULT_CLASSIFIER_MODELS = { 'typesafe' => 'jev-1.13.0', 'openai' => 'gpt-6-luna' }.freeze
35
+ # Questions per request accepted by each classifier provider (no entry: no limit)
36
+ MAX_QUESTIONS = { 'openai' => 200 }.freeze
37
+ # RubyLLM protocols that answer judgment questions
38
+ JUDGE_PROTOCOLS = %i[system_one decisions].freeze
39
+ DEFAULT_THRESHOLD = 0.5
40
+ DEFAULT_CLASSIFIER_CONCURRENCY = 8
41
+ DEFAULT_API_REPORTER_CONCURRENCY = 8
42
+ # Subscription CLIs have tighter rate limits than APIs
43
+ DEFAULT_CLI_REPORTER_CONCURRENCY = 3
44
+
45
+ LEGACY_ENGINES = { 'jev' => 'typesafe', 'openai' => 'openai' }.freeze
46
+ LEGACY_NO_CLASSIFIER = %w[llm gemini].freeze
47
+ LEGACY_LLMS = { 'claude' => 'claude_code', 'codex' => 'codex' }.freeze
48
+
49
+ # The classifier settings, or nil when the reporter checks everything itself
50
+ attr_reader :classifier
51
+ # The reporter settings, or nil when none is configured (`omamori scan` stops with #reporter_missing_message)
52
+ attr_reader :reporter
53
+
54
+ # overrides: classifier:, classifier_model:, reporter:, reporter_model:, reporter_fallback:,
55
+ # reporter_fallback_model: (from the command line)
56
+ def initialize(config, overrides = {})
57
+ @config = config
58
+ @overrides = overrides.compact.transform_values(&:to_s)
59
+ @classifier = resolve_classifier
60
+ @reporter = resolve_reporter
61
+ @reporter_fallback = resolve_reporter_fallback
62
+ end
63
+
64
+ def classifier?
65
+ !@classifier.nil?
66
+ end
67
+
68
+ def reporter?
69
+ !@reporter.nil?
70
+ end
71
+
72
+ def cli_reporter?
73
+ reporter? && CLI_REPORTERS.include?(@reporter['provider'])
74
+ end
75
+
76
+ def threshold
77
+ @classifier.fetch('threshold', DEFAULT_THRESHOLD)
78
+ end
79
+
80
+ # Whether each risk is also asked as concrete sub-checks (on unless `classifier.sub_checks: false`)
81
+ def sub_checks?
82
+ @classifier.fetch('sub_checks', true)
83
+ end
84
+
85
+ def classifier_concurrency
86
+ @classifier.fetch('concurrency', DEFAULT_CLASSIFIER_CONCURRENCY)
87
+ end
88
+
89
+ def reporter_concurrency
90
+ @reporter.fetch('concurrency', cli_reporter? ? DEFAULT_CLI_REPORTER_CONCURRENCY : DEFAULT_API_REPORTER_CONCURRENCY)
91
+ end
92
+
93
+ def reporter_model
94
+ @reporter&.fetch('model', nil)
95
+ end
96
+
97
+ def self.reporter_missing_message(command: 'scan')
98
+ message = <<~MESSAGE
99
+ No reporter is configured. Choose the LLM that writes reports, for example:
100
+
101
+ omamori init --reporter claude_code # Claude Code subscription (claude -p)
102
+ omamori init --reporter codex # Codex subscription (codex exec)
103
+ omamori init --reporter anthropic --reporter-model claude-sonnet-5-5 # Any RubyLLM provider and model
104
+ MESSAGE
105
+ return message unless command == 'scan'
106
+
107
+ "#{message}\nOr pass --reporter (and --reporter-model) to `omamori scan`.\n"
108
+ end
109
+
110
+ # The environment variable that holds the API key of a RubyLLM provider, or nil when it has none
111
+ # (claude_code / codex use the CLI's login; some providers only need a server address)
112
+ def self.api_key_env(provider)
113
+ return nil if provider.nil? || CLI_REPORTERS.include?(provider)
114
+
115
+ name = "#{provider}_api_key"
116
+ RubyLLM.config.respond_to?("#{name}=") ? name.upcase : nil
117
+ end
118
+
119
+ def build_classifier_client
120
+ return nil unless classifier?
121
+
122
+ AIAnalysisEngine::JudgeClient.new(
123
+ ruby_llm_context,
124
+ provider: @classifier['provider'].to_sym, model: @classifier['model'],
125
+ max_questions: @classifier.fetch('max_questions', MAX_QUESTIONS[@classifier['provider']])
126
+ )
127
+ end
128
+
129
+ def build_reporter_client
130
+ return nil unless reporter?
131
+
132
+ client = build_llm_client(@reporter)
133
+ return client unless @reporter_fallback
134
+
135
+ AIAnalysisEngine::FallbackClient.new(client, build_llm_client(@reporter_fallback),
136
+ fallback_model: @reporter_fallback['model'])
137
+ end
138
+
139
+ attr_reader :reporter_fallback
140
+
141
+ # A client for one reporter's settings (the reporter or its fallback)
142
+ def build_llm_client(settings)
143
+ case settings['provider']
144
+ when 'claude_code' then AIAnalysisEngine::CLIClients::ClaudeCodeClient.new(command: settings['command'])
145
+ when 'codex' then AIAnalysisEngine::CLIClients::CodexClient.new(command: settings['command'])
146
+ else AIAnalysisEngine::LLMClient.new(ruby_llm_context, provider: settings['provider']&.to_sym)
147
+ end
148
+ end
149
+
150
+ # One line for the console, e.g. "classifier: typesafe/jev-1.13.0, reporter: gemini/gemini-3.8-flash"
151
+ def describe
152
+ reporter = reporter? ? [@reporter['provider'], @reporter['model']].compact.join('/') : 'none'
153
+ reporter += " (fallback: #{[@reporter_fallback['provider'], @reporter_fallback['model']].compact.join('/')})" if @reporter_fallback
154
+ return "reporter: #{reporter}" unless classifier?
155
+
156
+ "classifier: #{@classifier['provider']}/#{@classifier['model']}, reporter: #{reporter}"
157
+ end
158
+
159
+ private
160
+
161
+ def get(key)
162
+ @config.get(key, nil)
163
+ end
164
+
165
+ def resolve_classifier
166
+ settings = apply_override(configured_classifier, @overrides[:classifier], @overrides[:classifier_model])
167
+ return nil if settings.nil? || settings['provider'] == 'none'
168
+
169
+ provider = settings.fetch('provider') { raise Error, 'classifier.provider is required' }
170
+ validate_classifier_provider!(provider)
171
+ settings['model'] ||= DEFAULT_CLASSIFIER_MODELS.fetch(provider) do
172
+ raise Error, "classifier.model is required for provider #{provider}"
173
+ end
174
+ settings
175
+ end
176
+
177
+ def resolve_reporter
178
+ settings = apply_override(configured_reporter, @overrides[:reporter], @overrides[:reporter_model])
179
+ settings = LEGACY_REPORTER.merge(settings || {}) if legacy_gemini?(settings)
180
+ return nil if settings.nil? || (settings['provider'].nil? && settings['model'].nil?)
181
+
182
+ validate_reporter(settings.except('fallback'))
183
+ end
184
+
185
+ def resolve_reporter_fallback
186
+ # Read from the configured reporter, so a --reporter override keeps the configured fallback
187
+ configured = configured_reporter&.fetch('fallback', nil)&.then { |fallback| stringify(fallback) }
188
+ settings = apply_override(configured, @overrides[:reporter_fallback], @overrides[:reporter_fallback_model])
189
+ return nil if settings.nil? || settings['provider'] == 'none'
190
+
191
+ validate_reporter(settings, role: 'reporter.fallback')
192
+ end
193
+
194
+ def validate_reporter(settings, role: 'reporter')
195
+ provider = settings['provider']
196
+ return settings if provider.nil? || CLI_REPORTERS.include?(provider)
197
+
198
+ raise Error, "Unknown #{role} provider: #{provider}" unless RubyLLM::Provider.resolve(provider)
199
+ raise Error, "#{role}.model is required for provider #{provider}" unless settings['model']
200
+
201
+ settings
202
+ end
203
+
204
+ # A provider given on the command line replaces the configured one; the configured model only
205
+ # carries over when the provider stays the same.
206
+ def apply_override(settings, provider, model)
207
+ if provider
208
+ settings = settings&.fetch('provider', nil) == provider ? settings : {}
209
+ settings = settings.merge('provider' => provider)
210
+ end
211
+ settings = (settings || {}).merge('model' => model) if model
212
+ settings
213
+ end
214
+
215
+ # omamori 0.1 configs had only the Gemini `api_key` (and maybe `model`), and used Gemini by default
216
+ def legacy_gemini?(settings)
217
+ get('api_key') && (settings.nil? || (settings['provider'].nil? && settings['model'].nil?))
218
+ end
219
+
220
+ def configured_classifier
221
+ return stringify(get('classifier')) if get('classifier')
222
+
223
+ engine = get('engine')&.to_s
224
+ return nil if engine.nil? || LEGACY_NO_CLASSIFIER.include?(engine)
225
+
226
+ provider = LEGACY_ENGINES.fetch(engine) { raise Error, "Unknown engine: #{engine}" }
227
+ decision = stringify(get('decision') || {})
228
+ engine_settings = stringify(decision[engine] || {})
229
+ { 'provider' => provider, 'threshold' => decision['threshold'], 'concurrency' => decision['concurrency'] }
230
+ .merge(engine_settings.slice('model', 'base_url', 'api_key')).compact
231
+ end
232
+
233
+ def configured_reporter
234
+ return stringify(get('reporter')) if get('reporter')
235
+
236
+ llm = get('llm')&.to_s
237
+ report_concurrency = stringify(get('decision') || {})['report_concurrency']
238
+ settings = if LEGACY_LLMS.key?(llm)
239
+ { 'provider' => LEGACY_LLMS[llm] }.merge(stringify(get(llm) || {}).slice('model', 'command'))
240
+ else
241
+ { 'provider' => get('provider')&.to_s, 'model' => get('model') }
242
+ end
243
+ settings.merge('concurrency' => report_concurrency).compact.then { |s| s.empty? ? nil : s }
244
+ end
245
+
246
+ def validate_classifier_provider!(provider)
247
+ provider_class = RubyLLM::Provider.resolve(provider)
248
+ raise Error, "Unknown classifier provider: #{provider}" unless provider_class
249
+ return if provider_class.protocols.keys.intersect?(JUDGE_PROTOCOLS)
250
+
251
+ raise Error, "Provider #{provider} cannot be used as a classifier"
252
+ end
253
+
254
+ # Each role's api_key / base_url go to RubyLLM's <provider>_api_key / <provider>_api_base.
255
+ # The old top-level api_key is a Gemini key.
256
+ def ruby_llm_context
257
+ @ruby_llm_context ||= begin
258
+ overrides = { gemini_api_key: get('api_key') }
259
+ [@classifier, @reporter, @reporter_fallback].compact.each do |settings|
260
+ next unless settings['provider'] && !CLI_REPORTERS.include?(settings['provider'])
261
+
262
+ overrides[:"#{settings['provider']}_api_key"] = settings['api_key'] if settings['api_key']
263
+ overrides[:"#{settings['provider']}_api_base"] = settings['base_url'] if settings['base_url']
264
+ end
265
+ AIAnalysisEngine::RubyLLMContext.build(overrides)
266
+ end
267
+ end
268
+
269
+ def stringify(hash)
270
+ hash.to_h.transform_keys(&:to_s)
271
+ end
272
+ end
273
+ end
@@ -4,11 +4,15 @@ require 'yaml'
4
4
 
5
5
  module Omamori
6
6
  class Config
7
- DEFAULT_CONFIG_PATH = ".omamorirc"
7
+ DEFAULT_CONFIG_PATH = '.omamorirc'
8
+ DEFAULT_IGNORE_PATH = '.omamoriignore'
9
+
10
+ attr_reader :ignore_patterns
8
11
 
9
12
  def initialize(config_path = DEFAULT_CONFIG_PATH)
10
13
  @config_path = config_path
11
14
  @config = load_config
15
+ @ignore_patterns = load_ignore_patterns # Load ignore patterns
12
16
  validate_config # Add validation after loading
13
17
  end
14
18
 
@@ -31,100 +35,116 @@ module Omamori
31
35
  end
32
36
 
33
37
  def validate_api_key
34
- api_key = @config["api_key"]
35
- if api_key && !api_key.is_a?(String)
36
- puts "Warning: Config 'api_key' should be a string."
37
- end
38
+ api_key = @config['api_key']
39
+ return unless api_key && !api_key.is_a?(String)
40
+
41
+ puts "Warning: Config 'api_key' should be a string."
38
42
  end
39
43
 
40
44
  def validate_model
41
- model = @config["model"]
42
- if model && !model.is_a?(String)
43
- puts "Warning: Config 'model' should be a string."
44
- end
45
+ model = @config['model']
46
+ return unless model && !model.is_a?(String)
47
+
48
+ puts "Warning: Config 'model' should be a string."
45
49
  end
46
50
 
47
51
  def validate_checks
48
- checks = @config["checks"]
49
- if checks && !checks.is_a?(Array)
50
- puts "Warning: Config 'checks' should be an array."
51
- end
52
+ checks = @config['checks']
53
+ return unless checks && !checks.is_a?(Array)
54
+
55
+ puts "Warning: Config 'checks' should be an array."
52
56
  end
53
57
 
54
58
  def validate_prompt_templates
55
- prompt_templates = @config["prompt_templates"]
56
- if prompt_templates && !prompt_templates.is_a?(Hash)
57
- puts "Warning: Config 'prompt_templates' should be a hash."
58
- end
59
+ prompt_templates = @config['prompt_templates']
60
+ return unless prompt_templates && !prompt_templates.is_a?(Hash)
61
+
62
+ puts "Warning: Config 'prompt_templates' should be a hash."
59
63
  end
60
64
 
61
65
  def validate_report_settings
62
- report = @config["report"]
63
- if report
64
- unless report.is_a?(Hash)
65
- puts "Warning: Config 'report' should be a hash."
66
- return
67
- end
68
- if report.key?("output_path") && !report["output_path"].is_a?(String)
69
- puts "Warning: Config 'report.output_path' should be a string."
70
- end
71
- if report.key?("html_template") && !report["html_template"].is_a?(String)
72
- puts "Warning: Config 'report.html_template' should be a string."
73
- end
66
+ report = @config['report']
67
+ return unless report
68
+
69
+ unless report.is_a?(Hash)
70
+ puts "Warning: Config 'report' should be a hash."
71
+ return
72
+ end
73
+ if report.key?('output_path') && !report['output_path'].is_a?(String)
74
+ puts "Warning: Config 'report.output_path' should be a string."
74
75
  end
76
+ return unless report.key?('html_template') && !report['html_template'].is_a?(String)
77
+
78
+ puts "Warning: Config 'report.html_template' should be a string."
75
79
  end
76
80
 
77
81
  def validate_static_analyser_settings
78
- static_analysers = @config["static_analysers"]
79
- if static_analysers
80
- unless static_analysers.is_a?(Hash)
81
- puts "Warning: Config 'static_analysers' should be a hash."
82
- return
83
- end
84
- if static_analysers.key?("brakeman")
85
- brakeman_config = static_analysers["brakeman"]
86
- unless brakeman_config.is_a?(Hash)
87
- puts "Warning: Config 'static_analysers.brakeman' should be a hash."
88
- else
89
- if brakeman_config.key?("options") && !brakeman_config["options"].is_a?(String)
90
- puts "Warning: Config 'static_analysers.brakeman.options' should be a string."
91
- end
82
+ static_analysers = @config['static_analysers']
83
+ return unless static_analysers
84
+
85
+ unless static_analysers.is_a?(Hash)
86
+ puts "Warning: Config 'static_analysers' should be a hash."
87
+ return
88
+ end
89
+ if static_analysers.key?('brakeman')
90
+ brakeman_config = static_analysers['brakeman']
91
+ if brakeman_config.is_a?(Hash)
92
+ if brakeman_config.key?('options') && !brakeman_config['options'].is_a?(String)
93
+ puts "Warning: Config 'static_analysers.brakeman.options' should be a string."
92
94
  end
95
+ else
96
+ puts "Warning: Config 'static_analysers.brakeman' should be a hash."
93
97
  end
94
- if static_analysers.key?("bundler_audit")
95
- bundler_audit_config = static_analysers["bundler_audit"]
96
- unless bundler_audit_config.is_a?(Hash)
97
- puts "Warning: Config 'static_analysers.bundler_audit' should be a hash."
98
- else
99
- if bundler_audit_config.key?("options") && !bundler_audit_config["options"].is_a?(String)
100
- puts "Warning: Config 'static_analysers.bundler_audit.options' should be a string."
101
- end
102
- end
98
+ end
99
+ return unless static_analysers.key?('bundler_audit')
100
+
101
+ bundler_audit_config = static_analysers['bundler_audit']
102
+ if bundler_audit_config.is_a?(Hash)
103
+ if bundler_audit_config.key?('options') && !bundler_audit_config['options'].is_a?(String)
104
+ puts "Warning: Config 'static_analysers.bundler_audit.options' should be a string."
103
105
  end
106
+ else
107
+ puts "Warning: Config 'static_analysers.bundler_audit' should be a hash."
104
108
  end
105
109
  end
106
110
 
107
-
108
111
  def validate_ci_setup_settings
109
- ci_setup = @config["ci_setup"]
110
- if ci_setup
111
- unless ci_setup.is_a?(Hash)
112
- puts "Warning: Config 'ci_setup' should be a hash."
113
- return
114
- end
115
- if ci_setup.key?("github_actions_path") && !ci_setup["github_actions_path"].is_a?(String)
116
- puts "Warning: Config 'ci_setup.github_actions_path' should be a string."
117
- end
118
- if ci_setup.key?("gitlab_ci_path") && !ci_setup["gitlab_ci_path"].is_a?(String)
119
- puts "Warning: Config 'ci_setup.gitlab_ci_path' should be a string."
120
- end
112
+ ci_setup = @config['ci_setup']
113
+ return unless ci_setup
114
+
115
+ unless ci_setup.is_a?(Hash)
116
+ puts "Warning: Config 'ci_setup' should be a hash."
117
+ return
121
118
  end
119
+ if ci_setup.key?('github_actions_path') && !ci_setup['github_actions_path'].is_a?(String)
120
+ puts "Warning: Config 'ci_setup.github_actions_path' should be a string."
121
+ end
122
+ return unless ci_setup.key?('gitlab_ci_path') && !ci_setup['gitlab_ci_path'].is_a?(String)
123
+
124
+ puts "Warning: Config 'ci_setup.gitlab_ci_path' should be a string."
122
125
  end
123
126
 
124
127
  def validate_language
125
- language = @config["language"]
126
- if language && !language.is_a?(String)
127
- puts "Warning: Config 'language' should be a string."
128
+ language = @config['language']
129
+ return unless language && !language.is_a?(String)
130
+
131
+ puts "Warning: Config 'language' should be a string."
132
+ end
133
+
134
+ # Load .omamoriignore file and return an array of ignore patterns
135
+ def load_ignore_patterns
136
+ ignore_path = DEFAULT_IGNORE_PATH
137
+ if File.exist?(ignore_path)
138
+ begin
139
+ File.readlines(ignore_path, chomp: true).reject do |line|
140
+ line.strip.empty? || line.strip.start_with?('#')
141
+ end
142
+ rescue StandardError => e
143
+ puts "Warning: Error reading .omamoriignore file #{ignore_path}: #{e.message}"
144
+ [] # Return empty array if reading fails
145
+ end
146
+ else
147
+ [] # Return empty array if file does not exist
128
148
  end
129
149
  end
130
150
 
@@ -141,4 +161,4 @@ module Omamori
141
161
  end
142
162
  end
143
163
  end
144
- end
164
+ end