karst 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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +59 -0
  3. data/CHANGELOG.md +67 -0
  4. data/CODE_OF_CONDUCT.md +29 -0
  5. data/CONTRIBUTING.md +45 -0
  6. data/LICENSE +21 -0
  7. data/README.md +140 -0
  8. data/SECURITY.md +11 -0
  9. data/docs/advanced-configuration.md +188 -0
  10. data/lib/generators/karst/install/install_generator.rb +88 -0
  11. data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
  12. data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
  13. data/lib/karst/access/approved_populations.rb +128 -0
  14. data/lib/karst/access/candidate_population.rb +86 -0
  15. data/lib/karst/access/database_isolation.rb +62 -0
  16. data/lib/karst/access/population_approvals.rb +195 -0
  17. data/lib/karst/access/population_config_snippet.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +271 -0
  19. data/lib/karst/access/population_preview.rb +83 -0
  20. data/lib/karst/access/principal_sampler.rb +241 -0
  21. data/lib/karst/access/principal_selection.rb +90 -0
  22. data/lib/karst/access/principal_source.rb +143 -0
  23. data/lib/karst/access/principal_source_selection.rb +161 -0
  24. data/lib/karst/access/probe_application.rb +164 -0
  25. data/lib/karst/access/resource_evidence.rb +233 -0
  26. data/lib/karst/access/search.rb +265 -0
  27. data/lib/karst/access/selected_principal_sources.rb +65 -0
  28. data/lib/karst/access/sensitive_attribute_names.rb +26 -0
  29. data/lib/karst/access/sweep.rb +198 -0
  30. data/lib/karst/cli/verification.rb +182 -0
  31. data/lib/karst/configuration.rb +223 -0
  32. data/lib/karst/execution_context.rb +83 -0
  33. data/lib/karst/identity/devise_support.rb +90 -0
  34. data/lib/karst/identity/warden_adapter.rb +130 -0
  35. data/lib/karst/identity.rb +479 -0
  36. data/lib/karst/mcp/server.rb +63 -0
  37. data/lib/karst/mcp/verify_access_tool.rb +68 -0
  38. data/lib/karst/railtie.rb +30 -0
  39. data/lib/karst/spec/catalog.rb +199 -0
  40. data/lib/karst/spec/example_observation.rb +31 -0
  41. data/lib/karst/spec/observer.rb +300 -0
  42. data/lib/karst/spec/principal.rb +12 -0
  43. data/lib/karst/spec/reporter.rb +83 -0
  44. data/lib/karst/spec/request_observation.rb +38 -0
  45. data/lib/karst/spec/scenario.rb +65 -0
  46. data/lib/karst/value.rb +35 -0
  47. data/lib/karst/version.rb +5 -0
  48. data/lib/karst/web/badge.rb +183 -0
  49. data/lib/karst/web/browser_identity.rb +103 -0
  50. data/lib/karst/web/locality.rb +64 -0
  51. data/lib/karst/web/middleware.rb +377 -0
  52. data/lib/karst/web/panel.rb +699 -0
  53. data/lib/karst/web/populations_panel.rb +391 -0
  54. data/lib/karst/web/route_lookup.rb +65 -0
  55. data/lib/karst.rb +56 -0
  56. data/lib/rails/commands/karst/boot.rb +24 -0
  57. data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
  58. data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
  59. data/lib/tasks/karst.rake +34 -0
  60. metadata +138 -0
@@ -0,0 +1,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "securerandom"
5
+ require_relative "../access/population_preview"
6
+
7
+ module Karst
8
+ module Web
9
+ # The local approval surface for Karst::Access::PopulationDiscovery:
10
+ # browse the application-defined groups Karst found, approve the ones
11
+ # Karst may try, optionally preview one, and see which approvals are no
12
+ # longer doing anything. Approving is the whole workflow -- generating
13
+ # Ruby configuration is kept as an advanced export path, not the way a
14
+ # developer is expected to finish.
15
+ #
16
+ # Kept as a pure renderer, exactly like Karst::Web::Panel: every value
17
+ # this module needs (the discovery result, the approved entries, stale
18
+ # approvals, an optional storage error, an optional generated snippet, an
19
+ # optional single preview) is computed by Karst::Web::Middleware and
20
+ # passed in, so this file never touches Active Record, the filesystem, or
21
+ # Karst.config directly.
22
+ # rubocop:disable Metrics/ModuleLength
23
+ module PopulationsPanel
24
+ CANDIDATE_SEPARATOR = "::"
25
+
26
+ # Why an approved entry currently does nothing (see
27
+ # Karst::Access::ApprovedPopulations.stale). Reported rather than
28
+ # silently ignored -- and never repaired automatically, since the fix
29
+ # is always a decision about the application, not about Karst.
30
+ STALE_REASONS = {
31
+ not_discovered: "no longer a discovered scope on this model — not used",
32
+ no_principal_source: "not part of a configured user source — not used"
33
+ }.freeze
34
+ private_constant :STALE_REASONS
35
+
36
+ STYLE = <<~CSS
37
+ :root{color-scheme:light dark}
38
+ *{box-sizing:border-box}
39
+ body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1.25rem;color:#1a1a1a;background:#fff}
40
+ h1{font-size:1.4rem;margin:0 0 1rem}
41
+ h2{font-size:1.1rem;margin:1.5rem 0 .5rem}
42
+ h3{font-size:.85rem;text-transform:uppercase;letter-spacing:.05em;color:#555;margin:1.5rem 0 .6rem;border-bottom:1px solid #e2e2e2;padding-bottom:.35rem}
43
+ a{color:#2563eb}
44
+ .lead{margin:.4rem 0 1rem}
45
+ .hint{color:#8a5b00;font-size:.85rem;margin:.4rem 0}
46
+ .warning{color:#8a5b00;font-size:.85rem;margin:.4rem 0;border:1px solid #eacb6b;background:#fff7e0;border-radius:.4rem;padding:.6rem .8rem}
47
+ .saved{color:#0f5132;font-size:.9rem;margin:.4rem 0;border:1px solid #a6d9bb;background:#e6f4ea;border-radius:.4rem;padding:.6rem .8rem}
48
+ .search-box{margin:1rem 0}
49
+ .search-box label{display:flex;flex-direction:column;font-size:.78rem;font-weight:600;gap:.25rem;color:#444}
50
+ .search-box input{font:inherit;padding:.5rem .6rem;border:1px solid #ccc;border-radius:.3rem;max-width:24rem}
51
+ button{font:inherit;padding:.35rem .65rem;border:1px solid #ccc;border-radius:.35rem;background:#f4f4f4;cursor:pointer}
52
+ button:hover{background:#eaeaea}
53
+ button:focus-visible,input:focus-visible,summary:focus-visible,a:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
54
+ button.primary{background:#202124;border-color:#202124;color:#fff;font-weight:600;padding:.65rem 1.15rem;font-size:.95rem;margin-top:1rem}
55
+ button.primary:hover{background:#3a3b3e}
56
+ .approved-summary{border:1px solid #ddd;border-radius:.5rem;padding:.75rem 1rem}
57
+ .approved-model{margin:.4rem 0}
58
+ .approved-model strong{display:block}
59
+ .approved-model ul{margin:.2rem 0 0;padding-left:1.2rem}
60
+ .approved-model .stale{color:#8a5b00;font-size:.85rem}
61
+ details.model-group{border:1px solid #e2e2e2;border-radius:.4rem;padding:.5rem .8rem;margin:.5rem 0}
62
+ details.model-group summary{cursor:pointer;font-weight:600;display:flex;gap:.6rem;align-items:baseline;flex-wrap:wrap}
63
+ details.model-group summary .count{font-weight:400;color:#666;font-size:.85rem}
64
+ details.model-group summary .badge{font-weight:400;font-size:.72rem;color:#0f5132;background:#e6f4ea;border-radius:.6rem;padding:.05rem .5rem}
65
+ .candidate-list{list-style:none;margin:.6rem 0 0;padding:0}
66
+ .candidate-row{display:flex;align-items:center;gap:.6rem;padding:.3rem 0;flex-wrap:wrap}
67
+ .candidate-row label{display:flex;align-items:center;gap:.4rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92rem}
68
+ .preview{flex-basis:100%;margin:.2rem 0 .3rem 1.6rem;font-size:.85rem;color:#444}
69
+ .preview.error{color:#8a5b00}
70
+ .snippet{margin:.6rem 0}
71
+ .snippet textarea{width:100%;min-height:6rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.85rem;padding:.5rem;border:1px solid #ccc;border-radius:.3rem}
72
+ details.advanced{border:1px solid #e2e2e2;border-radius:.4rem;padding:.5rem .8rem;margin:1.5rem 0}
73
+ details.advanced summary{cursor:pointer;font-size:.9rem}
74
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
75
+ small{color:#666}
76
+ @media (prefers-color-scheme:dark){
77
+ body{background:#16171a;color:#e4e4e6}
78
+ a{color:#7aa2f7}
79
+ h3{color:#a7a7ad;border-color:#2c2d31}
80
+ .search-box input,.snippet textarea{background:#1f2023;border-color:#3a3b3e;color:#e4e4e6}
81
+ button{background:#26272b;border-color:#3a3b3e;color:#e4e4e6}
82
+ button:hover{background:#303136}
83
+ button.primary{background:#e4e4e6;border-color:#e4e4e6;color:#16171a}
84
+ button.primary:hover{background:#c9c9cc}
85
+ .approved-summary,details.model-group,details.advanced{border-color:#33343a}
86
+ .hint,.warning,.approved-model .stale{color:#d8a63d}
87
+ .warning{background:#3a2f0d;border-color:#6b5423}
88
+ .saved{background:#123822;border-color:#1f5c37;color:#7fd8a4}
89
+ details.model-group summary .badge{background:#123822;color:#7fd8a4}
90
+ }
91
+ CSS
92
+ private_constant :STYLE
93
+
94
+ SEARCH_SCRIPT = <<~JS
95
+ (function () {
96
+ var input = document.getElementById("karst-population-search");
97
+ if (!input) return;
98
+ var groups = document.querySelectorAll(".model-group");
99
+ input.addEventListener("input", function () {
100
+ var query = input.value.trim().toLowerCase();
101
+ groups.forEach(function (details) {
102
+ var model = (details.getAttribute("data-model") || "").toLowerCase();
103
+ var modelMatches = query === "" || model.indexOf(query) !== -1;
104
+ var rows = details.querySelectorAll(".candidate-row");
105
+ var anyRowMatches = false;
106
+ rows.forEach(function (row) {
107
+ var name = (row.getAttribute("data-name") || "").toLowerCase();
108
+ var rowMatches = query === "" || modelMatches || name.indexOf(query) !== -1;
109
+ row.style.display = rowMatches ? "" : "none";
110
+ if (rowMatches) anyRowMatches = true;
111
+ });
112
+ var show = query === "" || modelMatches || anyRowMatches;
113
+ details.style.display = show ? "" : "none";
114
+ if (query !== "" && show) details.open = true;
115
+ });
116
+ });
117
+ })();
118
+ JS
119
+ private_constant :SEARCH_SCRIPT
120
+
121
+ COPY_SCRIPT = <<~JS
122
+ (function () {
123
+ var button = document.getElementById("karst-copy-snippet");
124
+ var field = document.getElementById("karst-snippet-code");
125
+ if (!button || !field || !navigator.clipboard) return;
126
+ button.addEventListener("click", function () {
127
+ navigator.clipboard.writeText(field.value).then(function () {
128
+ button.textContent = "Copied";
129
+ setTimeout(function () { button.textContent = "Copy"; }, 1500);
130
+ }, function () {});
131
+ });
132
+ })();
133
+ JS
134
+ private_constant :COPY_SCRIPT
135
+
136
+ # rubocop:disable Metrics/ClassLength
137
+ class << self
138
+ # `approved` and each entry of `stale` are anything exposing
139
+ # #model_name/#method_name -- in practice
140
+ # Karst::Access::PopulationApprovals::Entry.
141
+ # rubocop:disable Metrics/ParameterLists
142
+ def render(discovery:, approved: [], stale: [], snippet: nil, preview: nil, storage_path: nil,
143
+ storage_error: nil, saved: false)
144
+ nonce = SecureRandom.hex(16)
145
+ state = { approved: approved, stale: stale, snippet: snippet, preview: preview,
146
+ storage_path: storage_path, storage_error: storage_error, saved: saved }
147
+ [200, headers(nonce), [document(discovery, state, nonce)]]
148
+ end
149
+ # rubocop:enable Metrics/ParameterLists
150
+
151
+ private
152
+
153
+ def headers(nonce)
154
+ csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-#{nonce}'; frame-ancestors 'none'"
155
+ {
156
+ "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
157
+ "x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY", "content-security-policy" => csp
158
+ }
159
+ end
160
+
161
+ # The per-candidate Preview and the advanced snippet export submit
162
+ # through their own form, referenced by `form=` rather than nested
163
+ # inside the approval form (HTML forbids nesting): pressing either
164
+ # therefore cannot smuggle checkbox state into a save, so the only
165
+ # way to change what Karst may execute is the explicit approve
166
+ # button.
167
+ def document(discovery, state, nonce)
168
+ <<~HTML
169
+ <!DOCTYPE html>
170
+ <html lang="en"><head><meta charset="utf-8"><title>Karst — Candidate groups</title>
171
+ <style>#{STYLE}</style>
172
+ </head><body>
173
+ <h1>Karst</h1>
174
+ <p><a href="/karst">&larr; Back to route evidence</a></p>
175
+ <h2>Candidate groups</h2>
176
+ <p class="lead">Approving a group lets Karst try a few existing users from it when the ordinary sample
177
+ fails. Approving is not a claim that the group grants access — only running an analysis against a route
178
+ shows what actually happens.</p>
179
+ #{saved_notice(state[:saved])}
180
+ #{storage_error(state[:storage_error])}
181
+ #{load_warning(discovery)}
182
+ <form id="karst-secondary" method="post" action="/karst/populations"></form>
183
+ <form method="post" action="/karst/populations">
184
+ #{search_box}
185
+ #{approved_section(state[:approved], state[:stale])}
186
+ #{model_groups_section(discovery, state[:approved], state[:preview])}
187
+ <button type="submit" name="save_approvals" value="1" class="primary">Approve selected groups</button>
188
+ </form>
189
+ #{advanced_section(state[:snippet])}
190
+ #{storage_note(state[:storage_path])}
191
+ <script nonce="#{nonce}">#{SEARCH_SCRIPT}#{COPY_SCRIPT}</script>
192
+ </body></html>
193
+ HTML
194
+ end
195
+
196
+ def saved_notice(saved)
197
+ return "" unless saved
198
+
199
+ "<p class=\"saved\" role=\"status\">Approvals saved.</p>"
200
+ end
201
+
202
+ def storage_error(error)
203
+ return "" unless error
204
+
205
+ "<p class=\"warning\" role=\"alert\">#{escape(error)}</p>"
206
+ end
207
+
208
+ def storage_note(path)
209
+ return "" unless path
210
+
211
+ "<p><small>Approvals are stored locally in <code>#{escape(path)}</code> as plain model and " \
212
+ "scope names — no user data, no Ruby. Delete that file to reset every approval.</small></p>"
213
+ end
214
+
215
+ def load_warning(discovery)
216
+ return "" unless discovery.load_warning
217
+
218
+ "<p class=\"warning\" role=\"alert\">#{escape(discovery.load_warning)}</p>"
219
+ end
220
+
221
+ def search_box
222
+ <<~HTML
223
+ <div class="search-box">
224
+ <label>Search groups or models
225
+ <input type="search" id="karst-population-search" placeholder="e.g. admin, User, subscription">
226
+ </label>
227
+ </div>
228
+ HTML
229
+ end
230
+
231
+ # -- Approved summary --------------------------------------------------
232
+
233
+ def approved_section(approved, stale)
234
+ body = if approved.empty?
235
+ "<p>No groups approved yet.</p>"
236
+ else
237
+ approved.group_by(&:model_name).sort.map do |model_name, group|
238
+ approved_model(model_name, group, stale)
239
+ end.join
240
+ end
241
+ "<section class=\"approved-summary\"><h3>Approved (#{approved.size})</h3>#{body}</section>"
242
+ end
243
+
244
+ def approved_model(model_name, entries, stale)
245
+ items = entries.sort_by { |entry| entry.method_name.to_s }.map do |entry|
246
+ "<li>#{escape(entry.method_name)}#{stale_note(entry, stale)}</li>"
247
+ end.join
248
+ "<div class=\"approved-model\"><strong>#{escape(model_name)}</strong><ul>#{items}</ul></div>"
249
+ end
250
+
251
+ def stale_note(entry, stale)
252
+ match = stale.find { |item, _reason| same_candidate?(item, entry) }
253
+ return "" unless match
254
+
255
+ " <span class=\"stale\">— #{escape(STALE_REASONS.fetch(match.last, 'not used'))}</span>"
256
+ end
257
+
258
+ def same_candidate?(left, right)
259
+ left.model_name.to_s == right.model_name.to_s && left.method_name.to_s == right.method_name.to_s
260
+ end
261
+
262
+ # -- Model groups ------------------------------------------------------
263
+
264
+ def model_groups_section(discovery, approved, preview)
265
+ groups = discovery.model_groups.reject { |group| group.candidate_names.empty? }
266
+ body = if groups.empty?
267
+ "<p>No candidate groups were discovered.</p>"
268
+ else
269
+ groups.map { |group| model_group(group, approved, preview) }.join
270
+ end
271
+ "<section class=\"model-groups\"><h3>Available models</h3>#{body}</section>"
272
+ end
273
+
274
+ def model_group(group, approved, preview)
275
+ approved_names = approved.select { |entry| entry.model_name == group.model_name }
276
+ .map { |entry| entry.method_name.to_s }
277
+ open = approved_names.any? ? " open" : ""
278
+ rows = group.candidate_names.map { |name| candidate_row(group, name, approved_names, preview) }.join
279
+ <<~HTML
280
+ <details class="model-group" data-model="#{escape(group.model_name)}"#{open}>
281
+ <summary>#{model_summary(group)}</summary>
282
+ <ul class="candidate-list">#{rows}</ul>
283
+ </details>
284
+ HTML
285
+ end
286
+
287
+ def model_summary(group)
288
+ count = group.candidate_names.size
289
+ "#{escape(group.model_name)} <span class=\"count\">#{count} group#{'s' unless count == 1}</span>" \
290
+ "#{principal_badge(group)}"
291
+ end
292
+
293
+ def principal_badge(group)
294
+ return "" unless group.principal_source
295
+
296
+ "<span class=\"badge\">user source: #{escape(group.principal_source)}</span>"
297
+ end
298
+
299
+ def candidate_row(group, method_name, approved_names, preview)
300
+ key = candidate_key(group.model_name, method_name)
301
+ checked = approved_names.include?(method_name.to_s)
302
+ box = "<input type=\"checkbox\" name=\"population[]\" value=\"#{escape(key)}\"#{' checked' if checked}>"
303
+ preview_button = "<button type=\"submit\" form=\"karst-secondary\" name=\"preview\" " \
304
+ "value=\"#{escape(key)}\">Preview</button>"
305
+ label = "<label>#{box} #{escape(method_name)}</label>"
306
+ "<li class=\"candidate-row\" data-name=\"#{escape(method_name)}\">#{label}" \
307
+ "#{preview_button}#{preview_result(group, method_name, preview)}</li>"
308
+ end
309
+
310
+ def preview_result(group, method_name, preview)
311
+ unless preview && preview.model_name == group.model_name && preview.method_name.to_s == method_name.to_s
312
+ return ""
313
+ end
314
+
315
+ preview.resolved ? preview_success(preview) : preview_failure(preview)
316
+ end
317
+
318
+ def preview_success(preview)
319
+ return "<p class=\"preview\">Preview: no matching records currently.</p>" if preview.records.empty?
320
+
321
+ labels = preview.records.map { |record| record_label(record) }.join(", ")
322
+ "<p class=\"preview\">Preview (up to #{Access::PopulationPreview::PREVIEW_LIMIT}): #{escape(labels)}</p>"
323
+ end
324
+
325
+ def preview_failure(preview)
326
+ "<p class=\"preview error\">Preview: #{escape(preview.error)}.</p>"
327
+ end
328
+
329
+ # Deliberately generic -- "ModelName #id" only, never an arbitrary
330
+ # attribute dump, regardless of whether the previewed model happens
331
+ # to be a configured principal source. A discovered candidate may
332
+ # belong to any application model, not only ones a developer has
333
+ # already reviewed for what is safe to display.
334
+ def record_label(record)
335
+ primary_key = record.class.respond_to?(:primary_key) ? record.class.primary_key : "id"
336
+ "#{record.class.name} ##{record.public_send(primary_key)}"
337
+ end
338
+
339
+ # -- Advanced: Ruby export ---------------------------------------------
340
+
341
+ # Approving is the workflow; this stays available for an application
342
+ # that would rather commit its populations as reviewable Ruby (a
343
+ # shared or CI environment, where a machine-local approval file is
344
+ # deliberately not consulted).
345
+ def advanced_section(snippet)
346
+ <<~HTML
347
+ <details class="advanced"#{' open' if snippet}><summary>Advanced: export approvals as Ruby
348
+ configuration</summary>
349
+ <p><small>Explicit <code>config.principal_populations</code> keeps working exactly as before and always
350
+ takes precedence over an approval of the same name. Karst never writes to your application's files.
351
+ </small></p>
352
+ <button type="submit" form="karst-secondary" name="generate_snippet" value="1">Generate snippet from
353
+ approvals</button>
354
+ #{snippet_section(snippet)}
355
+ </details>
356
+ HTML
357
+ end
358
+
359
+ def snippet_section(snippet)
360
+ return "" unless snippet
361
+
362
+ <<~HTML
363
+ <div class="snippet">
364
+ <textarea id="karst-snippet-code" readonly>#{escape(snippet.code)}</textarea>
365
+ <p><button type="button" id="karst-copy-snippet">Copy</button></p>
366
+ #{unwired_note(snippet)}
367
+ </div>
368
+ HTML
369
+ end
370
+
371
+ def unwired_note(snippet)
372
+ return "" if snippet.unwired.empty?
373
+
374
+ names = snippet.unwired.map { |c| "#{c.model_name}.#{c.method_name}" }.join(", ")
375
+ "<p class=\"hint\">#{snippet.unwired.size} approved group(s) are not part of a configured " \
376
+ "user source yet and were left out of the snippet above: #{escape(names)}.</p>"
377
+ end
378
+
379
+ def candidate_key(model_name, method_name)
380
+ "#{model_name}#{CANDIDATE_SEPARATOR}#{method_name}"
381
+ end
382
+
383
+ def escape(value)
384
+ CGI.escapeHTML(value.to_s)
385
+ end
386
+ end
387
+ # rubocop:enable Metrics/ClassLength
388
+ end
389
+ # rubocop:enable Metrics/ModuleLength
390
+ end
391
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "active_support/core_ext/string/inflections"
5
+
6
+ module Karst
7
+ module Web
8
+ # Establishes route context for a manually entered application path. Rails'
9
+ # router is the authority: Karst does not infer a controller or action from
10
+ # the shape of the URL.
11
+ class RouteLookup
12
+ Result = Struct.new(:params, :limitation, keyword_init: true)
13
+
14
+ def initialize(path:, http_method:, application: Rails.application)
15
+ @path = path.to_s.strip
16
+ @http_method = http_method.to_s.strip.upcase
17
+ @application = application
18
+ end
19
+
20
+ def call
21
+ path = local_path
22
+ recognized = @application.routes.recognize_path(path, method: method)
23
+ return limitation("the recognized route did not identify a controller and action") unless complete?(recognized)
24
+
25
+ Result.new(params: recognized_params(recognized, path))
26
+ rescue URI::InvalidURIError
27
+ limitation("Path must be a valid local application path.")
28
+ rescue ActionController::RoutingError
29
+ limitation("Rails could not recognize that path and method. Karst will not guess the route.")
30
+ rescue StandardError => e
31
+ limitation("Rails route recognition was unavailable (#{e.class}). Karst will not guess the route.")
32
+ end
33
+
34
+ private
35
+
36
+ def complete?(recognized)
37
+ !recognized[:controller].to_s.empty? && !recognized[:action].to_s.empty?
38
+ end
39
+
40
+ def recognized_params(recognized, path)
41
+ {
42
+ "controller" => "#{recognized[:controller].to_s.camelize}Controller",
43
+ "action" => recognized[:action].to_s, "method" => method, "path" => path
44
+ }
45
+ end
46
+
47
+ def local_path
48
+ raw = @path.split("?", 2).first
49
+ uri = URI.parse(raw)
50
+ valid = uri.relative? && raw.start_with?("/") && !raw.start_with?("//")
51
+ raise URI::InvalidURIError unless valid
52
+
53
+ raw
54
+ end
55
+
56
+ def method
57
+ @http_method.empty? ? "GET" : @http_method
58
+ end
59
+
60
+ def limitation(message)
61
+ Result.new(params: { "method" => method, "path" => @path }, limitation: message)
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/karst.rb ADDED
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # On Rails 6.1, active_support/logger_thread_safe_level.rb references the
4
+ # bare ::Logger constant before active_support/logger.rb gets around to
5
+ # requiring "logger" itself -- a load-order bug in that Rails series, not a
6
+ # version conflict (it reproduces with Ruby's own bundled logger release,
7
+ # not just newer ones). A full Rails boot usually papers over it by sheer
8
+ # luck of some other gem having required "logger" first; requiring it here
9
+ # up front means `require "karst"` never depends on that luck.
10
+ require "logger"
11
+
12
+ require_relative "karst/version"
13
+ require_relative "karst/configuration"
14
+ require_relative "karst/identity"
15
+ require_relative "karst/access/sweep"
16
+ require_relative "karst/access/principal_source"
17
+ require_relative "karst/access/principal_sampler"
18
+ require_relative "karst/access/principal_selection"
19
+ require_relative "karst/access/search"
20
+ require_relative "karst/access/resource_evidence"
21
+ require_relative "karst/access/candidate_population"
22
+ require_relative "karst/access/population_discovery"
23
+ require_relative "karst/access/population_approvals"
24
+ require_relative "karst/access/approved_populations"
25
+ require_relative "karst/access/population_preview"
26
+ require_relative "karst/access/population_config_snippet"
27
+ require_relative "karst/access/principal_source_selection"
28
+ require_relative "karst/access/selected_principal_sources"
29
+
30
+ # Public entry point for Karst configuration.
31
+ module Karst
32
+ @ownership_mutex = Mutex.new
33
+
34
+ private_constant :Configuration
35
+
36
+ class << self
37
+ def configure
38
+ yield config
39
+ end
40
+
41
+ def config
42
+ @ownership_mutex.synchronize { @config ||= Configuration.new }
43
+ end
44
+
45
+ # The single switch that turns Karst's whole development surface off:
46
+ # /karst, the page badge, `bin/rails karst:verify`, and the MCP
47
+ # verify_access tool all refuse to run when this is false. Defaults to
48
+ # development/test only, so an application that never configures Karst
49
+ # at all still cannot expose it in production.
50
+ def enabled?
51
+ config.enabled
52
+ end
53
+ end
54
+ end
55
+
56
+ require_relative "karst/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Command
5
+ module Karst
6
+ # Shared by every Karst Rails::Command: boots the host application the
7
+ # same way Rails' own commands do (Rails::Command::Actions#boot_application!
8
+ # on Rails >= 7.1, #require_application_and_environment! before that),
9
+ # without depending on either method name existing across Karst's whole
10
+ # supported Rails range. `require_application!` (from
11
+ # Rails::Command::Actions, already included into Rails::Command::Base)
12
+ # plus Rails.application.require_environment! is exactly what both of
13
+ # those version-specific wrappers do internally.
14
+ module Boot
15
+ private
16
+
17
+ def boot_karst_application!
18
+ require_application!
19
+ Rails.application.require_environment! if defined?(APP_PATH)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/command"
4
+ require "karst/mcp/server"
5
+ require_relative "../boot"
6
+
7
+ module Rails
8
+ module Command
9
+ module Karst
10
+ # Rails command entry point for Karst's MCP server: `bin/rails karst:mcp`.
11
+ # Boots the host application once, the same way any other Karst Rails
12
+ # command does (see Karst::Boot), then serves verify_access tool calls
13
+ # over stdio for the lifetime of the process -- never a second
14
+ # application copy per call.
15
+ class McpCommand < Base
16
+ include Karst::Boot
17
+
18
+ desc "Run Karst's MCP server over stdio, exposing the verify_access tool"
19
+ def perform(*)
20
+ boot_karst_application!
21
+ ::Karst::Mcp::Server.run!
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/command"
4
+ require "karst/cli/verification"
5
+ require_relative "../boot"
6
+
7
+ module Rails
8
+ module Command
9
+ module Karst
10
+ # Rails command entry point for the shared Access::Search adapter.
11
+ class VerifyCommand < Base
12
+ include Karst::Boot
13
+
14
+ class_option :json, type: :boolean, default: false, desc: "Emit stable JSON evidence"
15
+
16
+ desc "Verify bounded GET access to a local application path"
17
+ def perform(*arguments)
18
+ method, path = parse(arguments)
19
+ boot_karst_application!
20
+ exit(::Karst::CLI::Verification.new(path: path, http_method: method, json: options[:json]).call)
21
+ rescue ArgumentError => e
22
+ document = { schema_version: 1, error: { type: "input_error", message: e.message } }
23
+ puts(options[:json] ? JSON.generate(document) : "Karst cannot verify this route:\n#{e.message}")
24
+ exit(2)
25
+ end
26
+
27
+ private
28
+
29
+ def parse(arguments)
30
+ raise ArgumentError, "a local application path is required" if arguments.empty?
31
+ return ["GET", arguments.first] if arguments.size == 1
32
+ raise ArgumentError, "expected METHOD PATH" unless arguments.size == 2
33
+
34
+ arguments
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :karst do
4
+ desc "List zero-argument scopes declared directly in application model source. Never executes them."
5
+ task populations: :environment do
6
+ require "karst/access/population_discovery"
7
+ require "karst/access/population_approvals"
8
+
9
+ result = Karst::Access::PopulationDiscovery.new.call
10
+ approvals = Karst::Access::PopulationApprovals.load
11
+ groups = result.model_groups.reject { |group| group.candidate_names.empty? }
12
+
13
+ puts "Warning: #{result.load_warning}\n\n" if result.load_warning
14
+ puts "Warning: #{approvals.error}\n\n" if approvals.error
15
+ puts "No candidate scopes were discovered." if groups.empty?
16
+
17
+ groups.each do |group|
18
+ label = group.principal_source ? " (principal source: #{group.principal_source})" : ""
19
+ puts "#{group.model_name}#{label} -- #{group.candidate_names.size} scope(s)"
20
+ group.candidate_names.each do |name|
21
+ puts " #{name}#{' [approved]' if approvals.approved?(group.model_name, name)}"
22
+ end
23
+ end
24
+
25
+ puts "", <<~NOTES
26
+ These are discovered scopes only -- Karst has not verified any of them return a usable relation,
27
+ and none of this grants access or is wired into sampling on its own.
28
+ Only scopes declared directly on application models are included; concern-defined scopes may not appear.
29
+ Approve the ones Karst may try at /karst/populations. Approvals are stored locally in
30
+ #{Karst::Access::PopulationApprovals.display_path}; delete that file to reset them.
31
+ config.principal_populations remains supported and always takes precedence.
32
+ NOTES
33
+ end
34
+ end