karst 0.1.0 → 0.4.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +14 -12
  3. data/CHANGELOG.md +114 -47
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +137 -9
  6. data/SECURITY.md +1 -1
  7. data/docs/advanced-configuration.md +112 -7
  8. data/docs/rails8-authentication.md +191 -0
  9. data/docs/request-reproduction.md +242 -0
  10. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  11. data/lib/karst/access/approved_populations.rb +15 -6
  12. data/lib/karst/access/errors.rb +16 -0
  13. data/lib/karst/access/identity_probe.rb +151 -0
  14. data/lib/karst/access/local_path.rb +42 -0
  15. data/lib/karst/access/observed_endpoint.rb +46 -0
  16. data/lib/karst/access/outcome_groups.rb +93 -0
  17. data/lib/karst/access/population_approval.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +25 -1
  19. data/lib/karst/access/population_revocation.rb +34 -0
  20. data/lib/karst/access/principal_source.rb +24 -3
  21. data/lib/karst/access/probe.rb +397 -0
  22. data/lib/karst/access/search.rb +14 -6
  23. data/lib/karst/access/sweep.rb +42 -112
  24. data/lib/karst/cli/identity_serialization.rb +82 -0
  25. data/lib/karst/cli/outcome_summary.rb +131 -0
  26. data/lib/karst/cli/principal_reference.rb +51 -0
  27. data/lib/karst/cli/reproduction.rb +267 -0
  28. data/lib/karst/cli/verification.rb +219 -55
  29. data/lib/karst/configuration.rb +57 -11
  30. data/lib/karst/execution_context.rb +6 -7
  31. data/lib/karst/identity/evidence.rb +110 -0
  32. data/lib/karst/identity/observation.rb +167 -0
  33. data/lib/karst/identity/warden_adapter.rb +26 -1
  34. data/lib/karst/identity.rb +157 -22
  35. data/lib/karst/mcp/compatibility.rb +24 -0
  36. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  37. data/lib/karst/mcp/reproduction.rb +58 -0
  38. data/lib/karst/mcp/server.rb +27 -8
  39. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  40. data/lib/karst/railtie.rb +4 -7
  41. data/lib/karst/reproduction/curl.rb +73 -0
  42. data/lib/karst/reproduction/exercise.rb +246 -0
  43. data/lib/karst/reproduction/observation.rb +87 -0
  44. data/lib/karst/reproduction/sanitizer.rb +210 -0
  45. data/lib/karst/version.rb +1 -1
  46. data/lib/karst/web/badge.rb +5 -31
  47. data/lib/karst/web/browser_identity.rb +10 -13
  48. data/lib/karst/web/csrf.rb +43 -0
  49. data/lib/karst/web/middleware.rb +145 -84
  50. data/lib/karst/web/panel.rb +343 -125
  51. data/lib/karst/web/populations_panel.rb +46 -336
  52. data/lib/karst.rb +3 -3
  53. data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
  54. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  55. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  56. metadata +38 -18
  57. data/lib/karst/access/population_config_snippet.rb +0 -67
  58. data/lib/karst/access/population_preview.rb +0 -83
  59. data/lib/karst/access/resource_evidence.rb +0 -233
  60. data/lib/karst/spec/catalog.rb +0 -199
  61. data/lib/karst/spec/example_observation.rb +0 -31
  62. data/lib/karst/spec/observer.rb +0 -300
  63. data/lib/karst/spec/principal.rb +0 -12
  64. data/lib/karst/spec/reporter.rb +0 -83
  65. data/lib/karst/spec/request_observation.rb +0 -38
  66. data/lib/karst/spec/scenario.rb +0 -65
  67. data/lib/tasks/karst.rake +0 -34
@@ -1,32 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "cgi"
4
- require "securerandom"
5
- require_relative "../access/population_preview"
6
4
 
7
5
  module Karst
8
6
  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
7
+ # Advanced, local-only management for approvals already granted from the
8
+ # contextual /karst workflow. New approvals deliberately cannot be made
9
+ # here: this surface exists only so persistent state can be inspected and
10
+ # safely revoked, including after its model or scope becomes stale.
23
11
  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
12
  STALE_REASONS = {
31
13
  not_discovered: "no longer a discovered scope on this model — not used",
32
14
  no_principal_source: "not part of a configured user source — not used"
@@ -36,356 +18,84 @@ module Karst
36
18
  STYLE = <<~CSS
37
19
  :root{color-scheme:light dark}
38
20
  *{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
- }
21
+ body{font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;max-width:42rem;margin:2rem auto;padding:0 1.25rem;color:#1a1a1a;background:#fff}
22
+ h1{font-size:1.4rem;margin:0 0 1rem} h2{font-size:1.1rem;margin:1.5rem 0 .5rem}
23
+ a{color:#2563eb} .lead,small{color:#666} ul{list-style:none;padding:0}
24
+ li{display:flex;justify-content:space-between;align-items:center;gap:1rem;border-top:1px solid #ddd;padding:.75rem 0}
25
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
26
+ .stale{color:#8a5b00;font-size:.85rem;display:block}
27
+ .warning{color:#8a5b00;font-size:.85rem;border:1px solid #eacb6b;background:#fff7e0;border-radius:.4rem;padding:.6rem .8rem}
28
+ .saved{color:#0f5132;font-size:.9rem;border:1px solid #a6d9bb;background:#e6f4ea;border-radius:.4rem;padding:.6rem .8rem}
29
+ button{font:inherit;padding:.35rem .65rem;border:1px solid #b3261e;border-radius:.35rem;background:transparent;color:#b3261e;cursor:pointer}
30
+ button:focus-visible,a:focus-visible{outline:2px solid #2563eb;outline-offset:2px}
31
+ @media (prefers-color-scheme:dark){body{background:#16171a;color:#e4e4e6}.lead,small{color:#a7a7ad}li{border-color:#33343a}.stale,.warning{color:#d8a63d}.warning{background:#3a2f0d;border-color:#6b5423}.saved{background:#123822;border-color:#1f5c37;color:#7fd8a4}button{color:#ff817a;border-color:#ff817a}}
91
32
  CSS
92
33
  private_constant :STYLE
93
34
 
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
35
  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)]]
36
+ def render(approved: [], stale: [], storage_path: nil, storage_error: nil, revoked: false)
37
+ [200, headers, [document(approved, stale, storage_path, storage_error, revoked)]]
148
38
  end
149
- # rubocop:enable Metrics/ParameterLists
150
39
 
151
40
  private
152
41
 
153
- def headers(nonce)
154
- csp = "default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-#{nonce}'; frame-ancestors 'none'"
42
+ def headers
155
43
  {
156
44
  "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
45
+ "x-robots-tag" => "noindex, nofollow", "x-frame-options" => "DENY",
46
+ "content-security-policy" => "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'"
158
47
  }
159
48
  end
160
49
 
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)
50
+ def document(approved, stale, storage_path, storage_error, revoked)
168
51
  <<~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>
52
+ <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Karst — Manage approvals</title>
53
+ <style>#{STYLE}</style></head><body><h1>Karst</h1>
174
54
  <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>
55
+ <h2>Manage population approvals</h2>
56
+ <p class="lead">Advanced local-state management. Approve new candidate populations only when a failed route analysis offers them on <code>/karst</code>.</p>
57
+ #{notice(revoked)}#{storage_error_message(storage_error)}#{approval_list(approved, stale)}
58
+ #{storage_note(storage_path)}</body></html>
193
59
  HTML
194
60
  end
195
61
 
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 ------------------------------------------------------
62
+ def approval_list(approved, stale)
63
+ return "<p>No population approvals are stored.</p>" if approved.empty?
263
64
 
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>"
65
+ items = approved.map { |entry| approval_item(entry, stale) }.join
66
+ "<ul>#{items}</ul>"
272
67
  end
273
68
 
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
69
+ def approval_item(entry, stale)
70
+ match = stale.find { |item, _reason| item.matches?(entry.model_name, entry.method_name) }
71
+ note = match ? "<span class=\"stale\">#{escape(STALE_REASONS.fetch(match.last, 'not used'))}</span>" : ""
279
72
  <<~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>
73
+ <li><span><code>#{escape(entry.display_label)}</code>#{note}</span>
74
+ <form method="post" action="/karst/populations"><input type="hidden" name="operation" value="revoke_population">
75
+ <input type="hidden" name="population" value="#{escape(entry.model_name)}::#{escape(entry.method_name)}">
76
+ <button type="submit">Revoke</button></form></li>
284
77
  HTML
285
78
  end
286
79
 
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>"
80
+ def notice(revoked)
81
+ revoked ? '<p class="saved" role="status">Approval revoked.</p>' : ""
327
82
  end
328
83
 
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)}"
84
+ def storage_error_message(error)
85
+ error ? "<p class=\"warning\" role=\"alert\">#{escape(error)}</p>" : ""
337
86
  end
338
87
 
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
88
+ def storage_note(path)
89
+ return "" unless path
378
90
 
379
- def candidate_key(model_name, method_name)
380
- "#{model_name}#{CANDIDATE_SEPARATOR}#{method_name}"
91
+ "<p><small>Approvals are stored locally in <code>#{escape(path)}</code> " \
92
+ "as model and scope names only.</small></p>"
381
93
  end
382
94
 
383
95
  def escape(value)
384
96
  CGI.escapeHTML(value.to_s)
385
97
  end
386
98
  end
387
- # rubocop:enable Metrics/ClassLength
388
99
  end
389
- # rubocop:enable Metrics/ModuleLength
390
100
  end
391
101
  end
data/lib/karst.rb CHANGED
@@ -17,15 +17,15 @@ require_relative "karst/access/principal_source"
17
17
  require_relative "karst/access/principal_sampler"
18
18
  require_relative "karst/access/principal_selection"
19
19
  require_relative "karst/access/search"
20
- require_relative "karst/access/resource_evidence"
21
20
  require_relative "karst/access/candidate_population"
22
21
  require_relative "karst/access/population_discovery"
23
22
  require_relative "karst/access/population_approvals"
23
+ require_relative "karst/access/population_approval"
24
24
  require_relative "karst/access/approved_populations"
25
- require_relative "karst/access/population_preview"
26
- require_relative "karst/access/population_config_snippet"
27
25
  require_relative "karst/access/principal_source_selection"
28
26
  require_relative "karst/access/selected_principal_sources"
27
+ require_relative "karst/reproduction/exercise"
28
+ require_relative "karst/reproduction/curl"
29
29
 
30
30
  # Public entry point for Karst configuration.
31
31
  module Karst
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails/command"
4
- require "karst/mcp/server"
5
4
  require_relative "../boot"
5
+ require "karst/mcp/compatibility"
6
+ require "karst/version"
6
7
 
7
8
  module Rails
8
9
  module Command
@@ -17,9 +18,48 @@ module Rails
17
18
 
18
19
  desc "Run Karst's MCP server over stdio, exposing the verify_access tool"
19
20
  def perform(*)
21
+ load_mcp!
20
22
  boot_karst_application!
21
23
  ::Karst::Mcp::Server.run!
22
24
  end
25
+
26
+ private
27
+
28
+ def load_mcp!
29
+ begin
30
+ gem "mcp", *::Karst::Mcp::Compatibility::REQUIREMENTS
31
+ rescue Gem::LoadError
32
+ abort mcp_unavailable_message
33
+ end
34
+
35
+ require "karst/mcp/server"
36
+ end
37
+
38
+ # "Not installed" and "installed at a version Karst does not support"
39
+ # need different fixes, so they get different messages: telling an
40
+ # application that already has mcp in its bundle to add it would
41
+ # send its developer looking for a problem they don't have.
42
+ def mcp_unavailable_message
43
+ requirement = ::Karst::Mcp::Compatibility.gemfile_requirement
44
+ installed = installed_mcp_version
45
+ if installed
46
+ "Karst MCP found mcp #{installed}, but Karst #{::Karst::VERSION} " \
47
+ "requires mcp #{requirement}. Constrain it in your Gemfile with " \
48
+ "gem \"mcp\", #{requirement} and run bundle update mcp."
49
+ else
50
+ "Karst MCP requires the optional dependency. " \
51
+ "Add gem \"mcp\", #{requirement} to your Gemfile and run bundle install."
52
+ end
53
+ end
54
+
55
+ # The mcp version this process can see but could not activate, if
56
+ # any. Bundler marks every gem in the bundle as loaded, so under
57
+ # `bin/rails` this is the version the bundle resolved; outside
58
+ # Bundler, it is the newest one installed.
59
+ def installed_mcp_version
60
+ spec = Gem.loaded_specs["mcp"] || Gem::Specification.find_all_by_name("mcp").max_by(&:version)
61
+ spec&.version
62
+ end
23
63
  end
24
64
  end
25
65
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/command"
4
+ require "karst/cli/reproduction"
5
+ require_relative "../boot"
6
+
7
+ module Rails
8
+ module Command
9
+ module Karst
10
+ # Rails command entry point for the shared Reproduction adapter.
11
+ class ReproduceCommand < Base
12
+ include Karst::Boot
13
+
14
+ class_option :json, type: :boolean, default: false, desc: "Emit stable JSON evidence"
15
+ class_option :body, type: :string, desc: "Request body to send"
16
+ class_option :content_type, type: :string, desc: "Content type of --body (required with --body)"
17
+ class_option :header, type: :array, default: [], desc: "Request header, as 'Name: value' (repeatable)"
18
+ class_option :anonymous, type: :boolean, default: false,
19
+ desc: "Send without assuming any application identity"
20
+ class_option :as, type: :string,
21
+ desc: "Send as one specific existing principal instead of the ordinary sampled " \
22
+ "one, e.g. --as User:72. Resolved only through Karst's own configured " \
23
+ "principal source(s); cannot be combined with --anonymous"
24
+ class_option :base_url, type: :string, desc: "Base URL for the generated cURL command"
25
+
26
+ desc "Issue one local request and print a reproducible, redacted recipe for it"
27
+ def perform(*arguments)
28
+ method, path = parse(arguments)
29
+ boot_karst_application!
30
+ exit(reproduction(method, path).call)
31
+ rescue ArgumentError => e
32
+ document = { schema_version: ::Karst::CLI::Reproduction::SCHEMA_VERSION,
33
+ error: { type: "input_error", message: e.message } }
34
+ puts(options[:json] ? JSON.generate(document) : "Karst cannot reproduce this request:\n#{e.message}")
35
+ exit(2)
36
+ end
37
+
38
+ private
39
+
40
+ def reproduction(method, path)
41
+ ::Karst::CLI::Reproduction.new(
42
+ path: path, http_method: method, body: options[:body], content_type: options[:content_type],
43
+ headers: headers, anonymous: options[:anonymous], as: options[:as],
44
+ base_url: options[:base_url], json: options[:json]
45
+ )
46
+ end
47
+
48
+ def headers
49
+ Array(options[:header]).each_with_object({}) do |entry, result|
50
+ name, value = entry.to_s.split(":", 2)
51
+ raise ArgumentError, "expected --header 'Name: value'" if value.nil?
52
+
53
+ result[name.strip] = value.strip
54
+ end
55
+ end
56
+
57
+ def parse(arguments)
58
+ raise ArgumentError, "a local application path is required" if arguments.empty?
59
+ return ["GET", arguments.first] if arguments.size == 1
60
+ raise ArgumentError, "expected METHOD PATH" unless arguments.size == 2
61
+
62
+ arguments
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -12,17 +12,29 @@ module Rails
12
12
  include Karst::Boot
13
13
 
14
14
  class_option :json, type: :boolean, default: false, desc: "Emit stable JSON evidence"
15
+ class_option :anonymous, type: :boolean, default: false,
16
+ desc: "Probe with no principal established, and verify the " \
17
+ "application observed none"
18
+ class_option :as, type: :string,
19
+ desc: "Run as one specific existing principal instead of sampling one, " \
20
+ "e.g. --as User:72. Resolved only through Karst's own configured " \
21
+ "principal source(s); cannot be combined with --anonymous"
15
22
 
16
23
  desc "Verify bounded GET access to a local application path"
24
+ # rubocop:disable Metrics/AbcSize
17
25
  def perform(*arguments)
18
26
  method, path = parse(arguments)
19
27
  boot_karst_application!
20
- exit(::Karst::CLI::Verification.new(path: path, http_method: method, json: options[:json]).call)
28
+ exit(::Karst::CLI::Verification.new(path: path, http_method: method, json: options[:json],
29
+ identity: (:anonymous if options[:anonymous]),
30
+ as: options[:as]).call)
21
31
  rescue ArgumentError => e
22
- document = { schema_version: 1, error: { type: "input_error", message: e.message } }
32
+ document = { schema_version: ::Karst::CLI::Verification::SCHEMA_VERSION,
33
+ error: { type: "input_error", message: e.message } }
23
34
  puts(options[:json] ? JSON.generate(document) : "Karst cannot verify this route:\n#{e.message}")
24
35
  exit(2)
25
36
  end
37
+ # rubocop:enable Metrics/AbcSize
26
38
 
27
39
  private
28
40