karst 0.2.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 +6 -1
- data/CHANGELOG.md +82 -2
- data/CONTRIBUTING.md +9 -1
- data/README.md +118 -2
- data/docs/advanced-configuration.md +106 -0
- data/docs/rails8-authentication.md +24 -4
- 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_discovery.rb +25 -1
- 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 +52 -6
- 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/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/middleware.rb +105 -16
- data/lib/karst/web/panel.rb +302 -47
- data/lib/karst.rb +2 -0
- data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
- data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
- data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
- metadata +33 -6
data/lib/karst/identity.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "value"
|
|
4
|
+
require_relative "execution_context"
|
|
4
5
|
require_relative "identity/devise_support"
|
|
5
6
|
require_relative "identity/warden_adapter"
|
|
7
|
+
require_relative "identity/observation"
|
|
8
|
+
require_relative "identity/evidence"
|
|
6
9
|
require_relative "access/selected_principal_sources"
|
|
7
10
|
|
|
8
11
|
module Karst
|
|
@@ -23,12 +26,39 @@ module Karst
|
|
|
23
26
|
class Unavailable < Error; end
|
|
24
27
|
class ConfigurationError < Error; end
|
|
25
28
|
|
|
29
|
+
# Karst's own sign-in raised while being applied inside a probed request
|
|
30
|
+
# (see WardenAdapter.install_hook!). Raised only there, and caught by
|
|
31
|
+
# Access::Probe, which reports it as failed identity setup -- never as
|
|
32
|
+
# the probed route raising, because the route never ran.
|
|
33
|
+
class EstablishmentError < Error; end
|
|
34
|
+
|
|
35
|
+
ESTABLISHING_KEY = :karst_identity_establishing
|
|
36
|
+
private_constant :ESTABLISHING_KEY
|
|
37
|
+
|
|
26
38
|
# authentication_* is presentation-only evidence. Machine serializers
|
|
27
39
|
# deliberately ignore it; it exists so local human interfaces can be
|
|
28
40
|
# useful without broadening JSON/MCP disclosure.
|
|
29
41
|
PrincipalDescriptor = Value.define(:model_name, :id, :display_label, :authentication_key,
|
|
30
42
|
:authentication_identifier)
|
|
31
43
|
|
|
44
|
+
# A deliberate request to execute with no authenticated principal at all,
|
|
45
|
+
# passed wherever a principal is passed (see Access::Sweep). It is not a
|
|
46
|
+
# principal and is never described, sampled, resolved, or assumed: it
|
|
47
|
+
# means "establish no identity, and prove at runtime that the application
|
|
48
|
+
# saw none", which is a probe in its own right rather than the absence of
|
|
49
|
+
# one.
|
|
50
|
+
class AnonymousIdentity
|
|
51
|
+
def to_s
|
|
52
|
+
"anonymous"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def inspect
|
|
56
|
+
"Karst::Identity::ANONYMOUS"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
ANONYMOUS = AnonymousIdentity.new.freeze
|
|
61
|
+
|
|
32
62
|
# Compact, inspectable report of why Karst's zero-config Devise/Warden
|
|
33
63
|
# path is or isn't active. `status` is one of:
|
|
34
64
|
#
|
|
@@ -58,6 +88,38 @@ module Karst
|
|
|
58
88
|
# probe/browser identity still couldn't be wired up automatically.
|
|
59
89
|
SetupState = Value.define(:status, :message)
|
|
60
90
|
|
|
91
|
+
# One high-level operation's view of the effective identity
|
|
92
|
+
# configuration -- a `bin/rails karst:verify` run, one MCP verify_access
|
|
93
|
+
# call, one /karst request -- taken once, when the operation starts, from
|
|
94
|
+
# exactly one Configuration#principal_sources resolution (approval file
|
|
95
|
+
# read, model-source parse, principal source callables evaluated once
|
|
96
|
+
# each). Everything the operation asks about identity setup reads this
|
|
97
|
+
# value instead of re-resolving configuration: readiness, browser Test As
|
|
98
|
+
# support, and which principal sources to sample or resolve from.
|
|
99
|
+
#
|
|
100
|
+
# Deliberately operation-scoped, never cached beyond that: the next
|
|
101
|
+
# operation takes a fresh snapshot, so a revoked approval, a deleted
|
|
102
|
+
# scope, or a changed initializer takes effect without a restart. A
|
|
103
|
+
# caller that changes local state (saving a selection, approving a
|
|
104
|
+
# population) takes its snapshot after that write, not before.
|
|
105
|
+
#
|
|
106
|
+
# `principal_sources` is nil when no principal source is configured.
|
|
107
|
+
Snapshot = Value.define(:principal_sources, :setup_state, :browser_supported) do
|
|
108
|
+
# Identity.principal_sources' contract, over this snapshot.
|
|
109
|
+
def principal_sources!
|
|
110
|
+
principal_sources || raise(Unavailable, "no principal source is configured")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def browser_supported?
|
|
114
|
+
browser_supported
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Marks "the caller did not resolve principal sources; resolve them
|
|
119
|
+
# here" -- distinct from a caller-resolved nil, which means none exist.
|
|
120
|
+
UNRESOLVED = Object.new.freeze
|
|
121
|
+
private_constant :UNRESOLVED
|
|
122
|
+
|
|
61
123
|
# Delegates identity operations to the application's configured hooks.
|
|
62
124
|
class ConfiguredAdapter
|
|
63
125
|
def initialize(assume_hook, clear_hook)
|
|
@@ -95,13 +157,27 @@ module Karst
|
|
|
95
157
|
# Every multi-source-aware caller (Identity.resolve,
|
|
96
158
|
# Access::PrincipalSelection, the panel) reads this instead of
|
|
97
159
|
# config.principals directly.
|
|
98
|
-
|
|
99
|
-
|
|
160
|
+
#
|
|
161
|
+
# `record:`/`discovery:`, when given, are threaded straight to
|
|
162
|
+
# Configuration#principal_sources -- a caller's already-loaded
|
|
163
|
+
# approval record and population discovery instance, reused instead of
|
|
164
|
+
# loaded and parsed again (see Snapshot).
|
|
165
|
+
def principal_sources(record: nil, discovery: nil)
|
|
166
|
+
sources = Karst.config.principal_sources(record: record, discovery: discovery)
|
|
100
167
|
raise Unavailable, "no principal source is configured" unless sources
|
|
101
168
|
|
|
102
169
|
sources
|
|
103
170
|
end
|
|
104
171
|
|
|
172
|
+
# See Snapshot. Resolves principal sources exactly once and derives
|
|
173
|
+
# every other answer from that one resolution. `record:`/`discovery:`
|
|
174
|
+
# as for #principal_sources.
|
|
175
|
+
def snapshot(record: nil, discovery: nil)
|
|
176
|
+
sources = safe_principal_sources(record: record, discovery: discovery)
|
|
177
|
+
Snapshot.new(principal_sources: sources, setup_state: setup_state(sources: sources),
|
|
178
|
+
browser_supported: browser_supported?(sources: sources))
|
|
179
|
+
end
|
|
180
|
+
|
|
105
181
|
def with(session, principal)
|
|
106
182
|
active_adapter = adapter(principal)
|
|
107
183
|
# The hook may establish identity and then raise, so cleanup becomes
|
|
@@ -113,8 +189,44 @@ module Karst
|
|
|
113
189
|
active_adapter.clear(session) if active_adapter && assumed
|
|
114
190
|
end
|
|
115
191
|
|
|
116
|
-
|
|
117
|
-
|
|
192
|
+
# The establishment half of #with on its own, for callers that must
|
|
193
|
+
# keep running -- and observing -- a request whose identity setup
|
|
194
|
+
# failed (see Access::IdentityProbe). Every such caller owes #clear in
|
|
195
|
+
# its own ensure.
|
|
196
|
+
def assume(session, principal)
|
|
197
|
+
adapter(principal).assume(session, principal)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# `principal:` is the identity being cleared, when the caller still has
|
|
201
|
+
# it: the automatic Devise/Warden path resolves a scope straight from
|
|
202
|
+
# the principal's own class, and only has to fall back to guessing one
|
|
203
|
+
# from the effective principal source (which several selected Devise
|
|
204
|
+
# models make impossible) when nothing is passed. Callers that assumed
|
|
205
|
+
# an identity should always pass the same principal back here.
|
|
206
|
+
def clear(session, principal: nil)
|
|
207
|
+
adapter(principal).clear(session)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def anonymous?(principal)
|
|
211
|
+
principal.equal?(ANONYMOUS)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Marks identity setup Karst has to perform *inside* a probed request:
|
|
215
|
+
# the automatic Warden path can only sign a probe in once that request
|
|
216
|
+
# reaches Warden (see WardenAdapter), so the sign-in -- and anything the
|
|
217
|
+
# application's own after_set_user hooks do in response, such as a
|
|
218
|
+
# Devise :trackable UPDATE -- happens mid-request. Access::Probe records
|
|
219
|
+
# nothing as target evidence while this is in progress on the current
|
|
220
|
+
# thread: it is Karst's setup, not the route's behavior.
|
|
221
|
+
def establishing
|
|
222
|
+
ExecutionContext[ESTABLISHING_KEY] = true
|
|
223
|
+
yield
|
|
224
|
+
ensure
|
|
225
|
+
ExecutionContext.delete(ESTABLISHING_KEY)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def establishing?
|
|
229
|
+
ExecutionContext[ESTABLISHING_KEY] ? true : false
|
|
118
230
|
end
|
|
119
231
|
|
|
120
232
|
def describe(principal)
|
|
@@ -146,16 +258,20 @@ module Karst
|
|
|
146
258
|
# principal outside a configured relation is never resolved. A generic
|
|
147
259
|
# Enumerable source (no scoped-query capability) keeps the original
|
|
148
260
|
# enumerate-and-compare behavior, bounded to that one source.
|
|
149
|
-
|
|
150
|
-
|
|
261
|
+
#
|
|
262
|
+
# `sources:` is an already-resolved principal source Hash (see
|
|
263
|
+
# Snapshot); omitted, the effective configuration is resolved here.
|
|
264
|
+
def resolve(model_name:, id:, sources: nil)
|
|
265
|
+
(sources || principal_sources).each_value do |source|
|
|
151
266
|
resolved = resolve_within_source(source, model_name: model_name, id: id)
|
|
152
267
|
return resolved if resolved
|
|
153
268
|
end
|
|
154
269
|
nil
|
|
155
270
|
end
|
|
156
271
|
|
|
157
|
-
|
|
158
|
-
|
|
272
|
+
# `sources:` as for #setup_state.
|
|
273
|
+
def browser_supported?(sources: UNRESOLVED)
|
|
274
|
+
explicit_browser_hooks? || automatic_browser_identity_available?(lazy_sources(sources))
|
|
159
275
|
end
|
|
160
276
|
|
|
161
277
|
# Returns the Devise/Warden scope this browser identity was actually
|
|
@@ -199,10 +315,16 @@ module Karst
|
|
|
199
315
|
# panel already does on every render to type-check its result (see
|
|
200
316
|
# Access::PrincipalSampler.representative_capable?) -- never to
|
|
201
317
|
# enumerate or query it.
|
|
202
|
-
|
|
318
|
+
#
|
|
319
|
+
# `sources:` is an already-resolved principal source Hash (possibly
|
|
320
|
+
# nil -- none configured), as a Snapshot holds; omitted, principal
|
|
321
|
+
# sources are resolved here, at most once per call.
|
|
322
|
+
def setup_state(sources: UNRESOLVED)
|
|
203
323
|
return SetupState.new(status: :ambiguous, message: ambiguous_message) if ambiguous_principal_source?
|
|
204
324
|
return SetupState.new(status: :unavailable, message: nil) unless principal_source_ready?
|
|
205
|
-
|
|
325
|
+
unless identity_channels_ready?(lazy_sources(sources))
|
|
326
|
+
return SetupState.new(status: :unavailable, message: unavailable_message)
|
|
327
|
+
end
|
|
206
328
|
|
|
207
329
|
SetupState.new(status: ready_status, message: nil)
|
|
208
330
|
end
|
|
@@ -267,9 +389,23 @@ module Karst
|
|
|
267
389
|
nil
|
|
268
390
|
end
|
|
269
391
|
|
|
270
|
-
def identity_channels_ready?
|
|
271
|
-
(explicit_probe_hooks? || automatic_identity_available?) &&
|
|
272
|
-
(explicit_browser_hooks? || automatic_browser_identity_available?)
|
|
392
|
+
def identity_channels_ready?(sources)
|
|
393
|
+
(explicit_probe_hooks? || automatic_identity_available?(sources)) &&
|
|
394
|
+
(explicit_browser_hooks? || automatic_browser_identity_available?(sources))
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Resolves principal sources only if a check actually needs them, and
|
|
398
|
+
# then only once, however many checks ask. `sources` is either a
|
|
399
|
+
# caller's resolution (used as-is) or UNRESOLVED.
|
|
400
|
+
def lazy_sources(sources)
|
|
401
|
+
resolved = false
|
|
402
|
+
lambda do
|
|
403
|
+
unless resolved
|
|
404
|
+
sources = safe_principal_sources if sources.equal?(UNRESOLVED)
|
|
405
|
+
resolved = true
|
|
406
|
+
end
|
|
407
|
+
sources
|
|
408
|
+
end
|
|
273
409
|
end
|
|
274
410
|
|
|
275
411
|
# Only called once every channel is already known to be ready (see
|
|
@@ -335,11 +471,11 @@ module Karst
|
|
|
335
471
|
# Probe-identity eligibility. Preserves the pre-existing bare-Warden
|
|
336
472
|
# fallback (no Devise, no scope) for a non-Devise application already
|
|
337
473
|
# relying on Karst's isolated integration session -- see WardenAdapter.
|
|
338
|
-
def automatic_identity_available?
|
|
474
|
+
def automatic_identity_available?(sources)
|
|
339
475
|
return false unless warden_available?
|
|
340
476
|
return true unless DeviseSupport.available?
|
|
341
477
|
|
|
342
|
-
every_effective_source_scoped?
|
|
478
|
+
every_effective_source_scoped?(sources.call)
|
|
343
479
|
end
|
|
344
480
|
|
|
345
481
|
# Browser-identity eligibility. Deliberately stricter than probe
|
|
@@ -347,10 +483,10 @@ module Karst
|
|
|
347
483
|
# browser session is only safe once Karst can prove the Devise
|
|
348
484
|
# scope -- a bare bootstrapped Warden proxy with no scope is never
|
|
349
485
|
# enough here, unlike the isolated probe session.
|
|
350
|
-
def automatic_browser_identity_available?
|
|
486
|
+
def automatic_browser_identity_available?(sources)
|
|
351
487
|
return false unless warden_available? && DeviseSupport.available?
|
|
352
488
|
|
|
353
|
-
every_effective_source_scoped?
|
|
489
|
+
every_effective_source_scoped?(sources.call)
|
|
354
490
|
end
|
|
355
491
|
|
|
356
492
|
# True once every currently effective principal source resolves to its
|
|
@@ -363,8 +499,7 @@ module Karst
|
|
|
363
499
|
# sources; this governs only "no principal yet" eligibility and the
|
|
364
500
|
# bare-clear fallback, so it still refuses to guess when a source is
|
|
365
501
|
# not provably Devise-scoped.
|
|
366
|
-
def every_effective_source_scoped?
|
|
367
|
-
sources = safe_principal_sources
|
|
502
|
+
def every_effective_source_scoped?(sources)
|
|
368
503
|
return false unless sources&.any?
|
|
369
504
|
|
|
370
505
|
sources.values.all? { |source| devise_scope_for_source(source) }
|
|
@@ -416,8 +551,8 @@ module Karst
|
|
|
416
551
|
nil
|
|
417
552
|
end
|
|
418
553
|
|
|
419
|
-
def safe_principal_sources
|
|
420
|
-
principal_sources
|
|
554
|
+
def safe_principal_sources(record: nil, discovery: nil)
|
|
555
|
+
principal_sources(record: record, discovery: discovery)
|
|
421
556
|
rescue Error
|
|
422
557
|
nil
|
|
423
558
|
end
|
|
@@ -464,7 +599,7 @@ module Karst
|
|
|
464
599
|
end
|
|
465
600
|
|
|
466
601
|
def model_name_for_klass(klass)
|
|
467
|
-
|
|
602
|
+
Naming.model_name_for(klass)
|
|
468
603
|
end
|
|
469
604
|
|
|
470
605
|
def id_for(principal)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Karst
|
|
4
|
+
module Mcp
|
|
5
|
+
# Karst's MCP adapter is optional, but when requested it uses the MCP
|
|
6
|
+
# server, tool, schema, response, and stdio transport APIs, which are
|
|
7
|
+
# unchanged across the 1.5 and 1.6 release series. Keep the development
|
|
8
|
+
# dependency and the command's runtime activation guard on the same
|
|
9
|
+
# bounded, tested range: CI exercises its oldest release and the newest
|
|
10
|
+
# 1.6.x, and a later minor series is admitted only after it has been
|
|
11
|
+
# tested the same way.
|
|
12
|
+
module Compatibility
|
|
13
|
+
REQUIREMENTS = [">= 1.5.0", "< 1.7"].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# The requirement as it is written in a Gemfile, e.g.
|
|
17
|
+
# `">= 1.5.0", "< 1.7"`.
|
|
18
|
+
def gemfile_requirement
|
|
19
|
+
REQUIREMENTS.map(&:inspect).join(", ")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "mcp"
|
|
5
|
+
require_relative "reproduction"
|
|
6
|
+
|
|
7
|
+
module Karst
|
|
8
|
+
module Mcp
|
|
9
|
+
# Karst's second, and deliberately last, MCP tool: a thin transport
|
|
10
|
+
# adapter over Karst::CLI::Reproduction#evidence.
|
|
11
|
+
#
|
|
12
|
+
# It exists separately from verify_access rather than as a mode of it
|
|
13
|
+
# because the two answer different questions with different blast
|
|
14
|
+
# radii. verify_access answers "which existing user can reach this
|
|
15
|
+
# page", by issuing up to access_sweep_limit bounded GET requests
|
|
16
|
+
# automatically; it is documented as GET-only and must stay that way,
|
|
17
|
+
# since running a mutating method as 25 users would mean 25 real
|
|
18
|
+
# creates, 25 enqueued jobs, and 25 delivered mails. reproduce_request
|
|
19
|
+
# answers "what request exercises this behavior", by issuing exactly
|
|
20
|
+
# one request the caller fully specified. Folding a mutating,
|
|
21
|
+
# caller-supplied-body mode into a tool whose contract promises bounded
|
|
22
|
+
# read-only verification would make that contract false.
|
|
23
|
+
class ReproduceRequestTool < MCP::Tool
|
|
24
|
+
tool_name "reproduce_request"
|
|
25
|
+
description <<~DESCRIPTION.strip
|
|
26
|
+
Issue exactly one request against the running local Rails application and
|
|
27
|
+
return a redacted, reproducible recipe for it.
|
|
28
|
+
|
|
29
|
+
Use this to answer "something calls this Rails endpoint -- what request
|
|
30
|
+
do I send to exercise the same behavior?". Karst executes the real
|
|
31
|
+
application inside a rolled-back database transaction and reports what
|
|
32
|
+
actually happened: the controller and action that dispatched, whether
|
|
33
|
+
the controller lifecycle completed ("execution.controller_completed" --
|
|
34
|
+
distinct from dispatch: a controller can dispatch and still raise), any
|
|
35
|
+
halted callback (this is the observed authentication or authorization
|
|
36
|
+
gate -- Karst never infers one from code), any raised exception together
|
|
37
|
+
with the most specific phase instrumentation actually proves it happened
|
|
38
|
+
in ("execution.exception_phase": "controller", "render", or "unknown"),
|
|
39
|
+
which templates/partials/layouts were observed rendering or raising
|
|
40
|
+
("execution.rendered", by structural virtual_path only -- never
|
|
41
|
+
content), observed database writes, response status and content type,
|
|
42
|
+
plus a cURL command for the exact request Karst sent.
|
|
43
|
+
|
|
44
|
+
Response facts (status/content_type/redirect) are strictly scoped to
|
|
45
|
+
this one request: if the target raised before producing a response,
|
|
46
|
+
they are null and named in "unobserved" rather than reflecting an
|
|
47
|
+
earlier request Karst ran on the same session (identity establishment,
|
|
48
|
+
most often).
|
|
49
|
+
|
|
50
|
+
This is observed runtime evidence. A field Karst could not observe is
|
|
51
|
+
null and named in "unobserved"; nothing is inferred from routes,
|
|
52
|
+
controller source, exception class/message, or strong parameters.
|
|
53
|
+
|
|
54
|
+
Identity in the result follows the same requested/observed/confirmation
|
|
55
|
+
contract as verify_access: "identity.confirmation" says whether the
|
|
56
|
+
application actually ran as the identity Karst assumed (or ran with no
|
|
57
|
+
principal, for an anonymous probe) -- "confirmed" or
|
|
58
|
+
"confirmed_anonymous" only. Never report this request as having run as
|
|
59
|
+
a user on the strength of "identity.requested" alone.
|
|
60
|
+
|
|
61
|
+
Secrets are never returned. Request parameters pass through the
|
|
62
|
+
application's own Rails config.filter_parameters plus a conservative
|
|
63
|
+
credential-name filter, and credential-bearing headers are replaced by
|
|
64
|
+
placeholders such as <API_KEY> or <AUTH_TOKEN> without their values ever
|
|
65
|
+
being read. Referer and Origin -- headers whose *value* is a URL rather
|
|
66
|
+
than a credential -- have their query string, fragment, and userinfo
|
|
67
|
+
stripped structurally instead, so a token or access_token riding in
|
|
68
|
+
either one never comes back either. A redirect Karst observes is
|
|
69
|
+
stripped the same way. A returned command that needs a credential
|
|
70
|
+
filled in is working as intended.
|
|
71
|
+
|
|
72
|
+
Side effects: GET and HEAD are enabled by default. POST, PUT, PATCH, and
|
|
73
|
+
DELETE are refused unless the host application explicitly opts in with
|
|
74
|
+
config.mcp_mutating_requests = true. When opted in, exactly one request
|
|
75
|
+
is issued. Database rollback is attempted on the request's own
|
|
76
|
+
connection; that does not make the request side-effect-free. Background
|
|
77
|
+
jobs, mail, outbound HTTP, files, Redis/external stores, and other
|
|
78
|
+
database connections are not isolated.
|
|
79
|
+
DESCRIPTION
|
|
80
|
+
|
|
81
|
+
input_schema(
|
|
82
|
+
properties: {
|
|
83
|
+
path: {
|
|
84
|
+
type: "string",
|
|
85
|
+
description: "Local application path, e.g. \"/api/v1/inspections\". Must be local " \
|
|
86
|
+
"(no scheme/host). May include a query string."
|
|
87
|
+
},
|
|
88
|
+
method: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description: "HTTP method to issue. GET and HEAD are enabled by default; mutating methods require " \
|
|
91
|
+
"the host's config.mcp_mutating_requests opt-in.",
|
|
92
|
+
enum: %w[GET HEAD POST PUT PATCH DELETE],
|
|
93
|
+
default: "GET"
|
|
94
|
+
},
|
|
95
|
+
body: { type: "string", description: "Raw request body. Requires content_type." },
|
|
96
|
+
content_type: {
|
|
97
|
+
type: "string",
|
|
98
|
+
description: "Content type of body, e.g. \"application/json\". Required whenever body is given."
|
|
99
|
+
},
|
|
100
|
+
headers: {
|
|
101
|
+
type: "object",
|
|
102
|
+
description: "Request headers to send, as name => value. Credential-bearing headers are " \
|
|
103
|
+
"sent as given but never echoed back.",
|
|
104
|
+
additionalProperties: { type: "string" }
|
|
105
|
+
},
|
|
106
|
+
anonymous: {
|
|
107
|
+
type: "boolean",
|
|
108
|
+
description: "Send without assuming any application identity. Defaults to false, in which " \
|
|
109
|
+
"case Karst assumes one existing identity from the application's own " \
|
|
110
|
+
"configured principal source.",
|
|
111
|
+
default: false
|
|
112
|
+
},
|
|
113
|
+
base_url: {
|
|
114
|
+
type: "string",
|
|
115
|
+
description: "Base URL for the generated cURL command. Defaults to http://localhost:3000."
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
required: ["path"]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
class << self
|
|
122
|
+
# server_context is part of MCP::Tool's call signature; this tool
|
|
123
|
+
# needs no per-request context beyond its own arguments.
|
|
124
|
+
# rubocop:disable Lint/UnusedMethodArgument, Metrics/ParameterLists
|
|
125
|
+
def call(path:, method: "GET", body: nil, content_type: nil, headers: nil,
|
|
126
|
+
anonymous: false, base_url: nil, server_context: nil)
|
|
127
|
+
document = ::Karst::Mcp::Reproduction.new(
|
|
128
|
+
path: path, method: method, body: body, content_type: content_type,
|
|
129
|
+
headers: headers || {}, anonymous: anonymous, base_url: base_url
|
|
130
|
+
).evidence
|
|
131
|
+
MCP::Tool::Response.new([{ type: "text", text: JSON.generate(document) }], error: document.key?(:error))
|
|
132
|
+
end
|
|
133
|
+
# rubocop:enable Lint/UnusedMethodArgument, Metrics/ParameterLists
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cli/reproduction"
|
|
4
|
+
|
|
5
|
+
module Karst
|
|
6
|
+
module Mcp
|
|
7
|
+
# MCP-specific boundary in front of the human-capable reproduction
|
|
8
|
+
# adapter. CLI and /karst callers intentionally use CLI::Reproduction
|
|
9
|
+
# directly and retain its full method support; an MCP caller reaches it
|
|
10
|
+
# only after this allowlist has accepted the method.
|
|
11
|
+
class Reproduction
|
|
12
|
+
SAFE_METHODS = %w[GET HEAD].freeze
|
|
13
|
+
|
|
14
|
+
# rubocop:disable Metrics/ParameterLists
|
|
15
|
+
def initialize(path:, method: "GET", body: nil, content_type: nil, headers: nil,
|
|
16
|
+
anonymous: false, base_url: nil)
|
|
17
|
+
@path = path
|
|
18
|
+
@method = method.to_s.strip.upcase
|
|
19
|
+
@body = body
|
|
20
|
+
@content_type = content_type
|
|
21
|
+
@headers = headers
|
|
22
|
+
@anonymous = anonymous
|
|
23
|
+
@base_url = base_url
|
|
24
|
+
end
|
|
25
|
+
# rubocop:enable Metrics/ParameterLists
|
|
26
|
+
|
|
27
|
+
def evidence
|
|
28
|
+
return disabled_document unless permitted?
|
|
29
|
+
|
|
30
|
+
CLI::Reproduction.new(
|
|
31
|
+
path: @path, http_method: @method, body: @body, content_type: @content_type,
|
|
32
|
+
headers: @headers || {}, anonymous: @anonymous, base_url: @base_url
|
|
33
|
+
).evidence
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def permitted?
|
|
39
|
+
SAFE_METHODS.include?(@method) || Karst.config.mcp_mutating_requests == true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def disabled_document
|
|
43
|
+
{
|
|
44
|
+
schema_version: CLI::Reproduction::SCHEMA_VERSION,
|
|
45
|
+
error: {
|
|
46
|
+
type: "method_disabled",
|
|
47
|
+
message: "#{displayed_method} is disabled for MCP reproduction; the host application must " \
|
|
48
|
+
"explicitly enable mutating MCP requests with config.mcp_mutating_requests = true"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def displayed_method
|
|
54
|
+
@method.empty? ? "the requested method" : @method
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/karst/mcp/server.rb
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require "mcp"
|
|
4
4
|
require_relative "verify_access_tool"
|
|
5
|
+
require_relative "reproduce_request_tool"
|
|
5
6
|
require_relative "../version"
|
|
6
7
|
|
|
7
8
|
module Karst
|
|
8
9
|
module Mcp
|
|
9
|
-
# Builds and runs Karst's stdio MCP server:
|
|
10
|
-
# no prompts, no resources, no
|
|
10
|
+
# Builds and runs Karst's stdio MCP server: two tools
|
|
11
|
+
# (VerifyAccessTool, ReproduceRequestTool), no prompts, no resources, no
|
|
12
|
+
# other transport. The host Rails
|
|
11
13
|
# application must already be booted (see
|
|
12
14
|
# Rails::Command::Karst::Boot#boot_karst_application!, used by
|
|
13
15
|
# `bin/rails karst:mcp`) before this is built -- the server itself never
|
|
@@ -22,11 +24,28 @@ module Karst
|
|
|
22
24
|
identities, and reports observed evidence -- HTTP status, redirect,
|
|
23
25
|
halted callback, exception, and whether a usable outcome was found.
|
|
24
26
|
|
|
25
|
-
Call verify_access(path:, method:) to check one path
|
|
26
|
-
|
|
27
|
-
principal
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
Call verify_access(path:, method:) to check one path, or
|
|
28
|
+
verify_access(path:, identity: "anonymous") to check it with no
|
|
29
|
+
principal established at all. Karst does not infer authorization
|
|
30
|
+
rules, explain application code, or choose which principal to try on
|
|
31
|
+
your behalf -- it only reports what actually happened when a real
|
|
32
|
+
request was made under the application's own configured identities.
|
|
33
|
+
|
|
34
|
+
Identity is evidence, not intent. Each outcome separates the identity
|
|
35
|
+
Karst was asked to run as from the one the application itself
|
|
36
|
+
resolved while running the request; only identity.confirmation of
|
|
37
|
+
"confirmed" or "confirmed_anonymous" means the request provably ran
|
|
38
|
+
that way. Never report a principal as having reached a route on the
|
|
39
|
+
strength of the requested identity alone.
|
|
40
|
+
|
|
41
|
+
Call reproduce_request(path:, method:, body:, content_type:) to answer a
|
|
42
|
+
different question: "something calls this endpoint -- what request do I
|
|
43
|
+
send to exercise the same behavior?". It issues exactly one request and
|
|
44
|
+
returns the observed controller/action, halted callback, response, and a
|
|
45
|
+
redacted cURL command for what Karst actually sent. Secrets are replaced
|
|
46
|
+
by placeholders; a command that needs a credential filled in is working
|
|
47
|
+
as intended. Identity in its evidence document follows the same
|
|
48
|
+
requested/observed/confirmation contract as verify_access.
|
|
30
49
|
TEXT
|
|
31
50
|
|
|
32
51
|
class << self
|
|
@@ -36,7 +55,7 @@ module Karst
|
|
|
36
55
|
title: "Karst",
|
|
37
56
|
version: Karst::VERSION,
|
|
38
57
|
instructions: INSTRUCTIONS,
|
|
39
|
-
tools: [VerifyAccessTool],
|
|
58
|
+
tools: [VerifyAccessTool, ReproduceRequestTool],
|
|
40
59
|
configuration: MCP::Configuration.new(exception_reporter: method(:report_exception))
|
|
41
60
|
)
|
|
42
61
|
end
|
|
@@ -34,6 +34,19 @@ module Karst
|
|
|
34
34
|
runtime evidence, not an inference about authorization rules -- Karst
|
|
35
35
|
never explains *why* an outcome occurred, and this tool never
|
|
36
36
|
impersonates the developer's browser or exposes Test As.
|
|
37
|
+
|
|
38
|
+
Identity is reported as evidence, not intent. Every outcome carries an
|
|
39
|
+
`identity` document separating the identity Karst was asked to run as
|
|
40
|
+
(`requested`) from the one the application itself resolved while
|
|
41
|
+
running the request (`observed`). Only `identity.confirmation` of
|
|
42
|
+
"confirmed" or "confirmed_anonymous" means the request provably ran as
|
|
43
|
+
that principal; "absent", "mismatch", "contaminated" and
|
|
44
|
+
"unobservable" all mean it did not, and must never be reported as a
|
|
45
|
+
principal having reached the route.
|
|
46
|
+
|
|
47
|
+
Pass identity: "anonymous" to probe the route with no principal at
|
|
48
|
+
all -- a request Karst also verifies the application actually saw as
|
|
49
|
+
anonymous.
|
|
37
50
|
DESCRIPTION
|
|
38
51
|
|
|
39
52
|
input_schema(
|
|
@@ -47,6 +60,15 @@ module Karst
|
|
|
47
60
|
type: "string",
|
|
48
61
|
description: "HTTP method to verify. Only GET is currently supported.",
|
|
49
62
|
default: "GET"
|
|
63
|
+
},
|
|
64
|
+
identity: {
|
|
65
|
+
type: "string",
|
|
66
|
+
enum: %w[application_identities anonymous],
|
|
67
|
+
description: "Which identity to probe as. \"application_identities\" (the default) runs the " \
|
|
68
|
+
"route as real existing identities from the application's own principal " \
|
|
69
|
+
"source(s); \"anonymous\" runs it with no principal established at all and " \
|
|
70
|
+
"verifies the application observed none.",
|
|
71
|
+
default: "application_identities"
|
|
50
72
|
}
|
|
51
73
|
},
|
|
52
74
|
required: ["path"]
|
|
@@ -57,11 +79,23 @@ module Karst
|
|
|
57
79
|
# passes it by keyword whenever a tool's #call accepts it) but this
|
|
58
80
|
# tool needs no per-request context beyond its own arguments.
|
|
59
81
|
# rubocop:disable Lint/UnusedMethodArgument
|
|
60
|
-
def call(path:, method: "GET", server_context: nil)
|
|
61
|
-
document = ::Karst::CLI::Verification.new(path: path, http_method: method
|
|
82
|
+
def call(path:, method: "GET", identity: nil, server_context: nil)
|
|
83
|
+
document = ::Karst::CLI::Verification.new(path: path, http_method: method,
|
|
84
|
+
identity: probe_identity(identity)).evidence
|
|
62
85
|
MCP::Tool::Response.new([{ type: "text", text: JSON.generate(document) }], error: document.key?(:error))
|
|
63
86
|
end
|
|
64
87
|
# rubocop:enable Lint/UnusedMethodArgument
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# "application_identities" is the documented default rather than a
|
|
92
|
+
# second spelling of nil, so an agent that passes it explicitly gets
|
|
93
|
+
# the ordinary search instead of an argument error.
|
|
94
|
+
def probe_identity(value)
|
|
95
|
+
return nil if value.nil? || value.to_s == "application_identities"
|
|
96
|
+
|
|
97
|
+
value
|
|
98
|
+
end
|
|
65
99
|
end
|
|
66
100
|
end
|
|
67
101
|
end
|