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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +14 -12
- data/CHANGELOG.md +114 -47
- data/CONTRIBUTING.md +9 -1
- data/README.md +137 -9
- data/SECURITY.md +1 -1
- data/docs/advanced-configuration.md +112 -7
- data/docs/rails8-authentication.md +191 -0
- data/docs/request-reproduction.md +242 -0
- data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
- data/lib/karst/access/approved_populations.rb +15 -6
- data/lib/karst/access/errors.rb +16 -0
- data/lib/karst/access/identity_probe.rb +151 -0
- data/lib/karst/access/local_path.rb +42 -0
- data/lib/karst/access/observed_endpoint.rb +46 -0
- data/lib/karst/access/outcome_groups.rb +93 -0
- data/lib/karst/access/population_approval.rb +67 -0
- data/lib/karst/access/population_discovery.rb +25 -1
- data/lib/karst/access/population_revocation.rb +34 -0
- data/lib/karst/access/principal_source.rb +24 -3
- data/lib/karst/access/probe.rb +397 -0
- data/lib/karst/access/search.rb +14 -6
- data/lib/karst/access/sweep.rb +42 -112
- data/lib/karst/cli/identity_serialization.rb +82 -0
- data/lib/karst/cli/outcome_summary.rb +131 -0
- data/lib/karst/cli/principal_reference.rb +51 -0
- data/lib/karst/cli/reproduction.rb +267 -0
- data/lib/karst/cli/verification.rb +219 -55
- data/lib/karst/configuration.rb +57 -11
- data/lib/karst/execution_context.rb +6 -7
- data/lib/karst/identity/evidence.rb +110 -0
- data/lib/karst/identity/observation.rb +167 -0
- data/lib/karst/identity/warden_adapter.rb +26 -1
- data/lib/karst/identity.rb +157 -22
- data/lib/karst/mcp/compatibility.rb +24 -0
- data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
- data/lib/karst/mcp/reproduction.rb +58 -0
- data/lib/karst/mcp/server.rb +27 -8
- data/lib/karst/mcp/verify_access_tool.rb +36 -2
- data/lib/karst/railtie.rb +4 -7
- data/lib/karst/reproduction/curl.rb +73 -0
- data/lib/karst/reproduction/exercise.rb +246 -0
- data/lib/karst/reproduction/observation.rb +87 -0
- data/lib/karst/reproduction/sanitizer.rb +210 -0
- data/lib/karst/version.rb +1 -1
- data/lib/karst/web/badge.rb +5 -31
- data/lib/karst/web/browser_identity.rb +10 -13
- data/lib/karst/web/csrf.rb +43 -0
- data/lib/karst/web/middleware.rb +145 -84
- data/lib/karst/web/panel.rb +343 -125
- data/lib/karst/web/populations_panel.rb +46 -336
- data/lib/karst.rb +3 -3
- data/lib/rails/commands/karst/mcp/mcp_command.rb +41 -1
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +38 -18
- data/lib/karst/access/population_config_snippet.rb +0 -67
- data/lib/karst/access/population_preview.rb +0 -83
- data/lib/karst/access/resource_evidence.rb +0 -233
- data/lib/karst/spec/catalog.rb +0 -199
- data/lib/karst/spec/example_observation.rb +0 -31
- data/lib/karst/spec/observer.rb +0 -300
- data/lib/karst/spec/principal.rb +0 -12
- data/lib/karst/spec/reporter.rb +0 -83
- data/lib/karst/spec/request_observation.rb +0 -38
- data/lib/karst/spec/scenario.rb +0 -65
- 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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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:
|
|
40
|
-
h1{font-size:1.4rem;margin:0 0 1rem}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
-
<
|
|
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">← Back to route evidence</a></p>
|
|
175
|
-
<h2>
|
|
176
|
-
<p class="lead">
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
197
|
-
return ""
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
<
|
|
281
|
-
<
|
|
282
|
-
<
|
|
283
|
-
</
|
|
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
|
|
288
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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]
|
|
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:
|
|
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
|
|