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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +6 -1
  3. data/CHANGELOG.md +82 -2
  4. data/CONTRIBUTING.md +9 -1
  5. data/README.md +118 -2
  6. data/docs/advanced-configuration.md +106 -0
  7. data/docs/rails8-authentication.md +24 -4
  8. data/docs/request-reproduction.md +242 -0
  9. data/lib/generators/karst/install/templates/karst_initializer.rb +6 -0
  10. data/lib/karst/access/approved_populations.rb +15 -6
  11. data/lib/karst/access/errors.rb +16 -0
  12. data/lib/karst/access/identity_probe.rb +151 -0
  13. data/lib/karst/access/local_path.rb +42 -0
  14. data/lib/karst/access/observed_endpoint.rb +46 -0
  15. data/lib/karst/access/outcome_groups.rb +93 -0
  16. data/lib/karst/access/population_discovery.rb +25 -1
  17. data/lib/karst/access/principal_source.rb +24 -3
  18. data/lib/karst/access/probe.rb +397 -0
  19. data/lib/karst/access/search.rb +14 -6
  20. data/lib/karst/access/sweep.rb +42 -112
  21. data/lib/karst/cli/identity_serialization.rb +82 -0
  22. data/lib/karst/cli/outcome_summary.rb +131 -0
  23. data/lib/karst/cli/principal_reference.rb +51 -0
  24. data/lib/karst/cli/reproduction.rb +267 -0
  25. data/lib/karst/cli/verification.rb +219 -55
  26. data/lib/karst/configuration.rb +52 -6
  27. data/lib/karst/identity/evidence.rb +110 -0
  28. data/lib/karst/identity/observation.rb +167 -0
  29. data/lib/karst/identity/warden_adapter.rb +26 -1
  30. data/lib/karst/identity.rb +157 -22
  31. data/lib/karst/mcp/compatibility.rb +24 -0
  32. data/lib/karst/mcp/reproduce_request_tool.rb +137 -0
  33. data/lib/karst/mcp/reproduction.rb +58 -0
  34. data/lib/karst/mcp/server.rb +27 -8
  35. data/lib/karst/mcp/verify_access_tool.rb +36 -2
  36. data/lib/karst/reproduction/curl.rb +73 -0
  37. data/lib/karst/reproduction/exercise.rb +246 -0
  38. data/lib/karst/reproduction/observation.rb +87 -0
  39. data/lib/karst/reproduction/sanitizer.rb +210 -0
  40. data/lib/karst/version.rb +1 -1
  41. data/lib/karst/web/middleware.rb +105 -16
  42. data/lib/karst/web/panel.rb +302 -47
  43. data/lib/karst.rb +2 -0
  44. data/lib/rails/commands/karst/mcp/mcp_command.rb +30 -3
  45. data/lib/rails/commands/karst/reproduce/reproduce_command.rb +67 -0
  46. data/lib/rails/commands/karst/verify/verify_command.rb +14 -2
  47. metadata +33 -6
@@ -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
- def principal_sources
99
- sources = Karst.config.principal_sources
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
- def clear(session)
117
- adapter.clear(session)
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
- def resolve(model_name:, id:)
150
- principal_sources.each_value do |source|
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
- def browser_supported?
158
- explicit_browser_hooks? || automatic_browser_identity_available?
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
- def setup_state
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
- return SetupState.new(status: :unavailable, message: unavailable_message) unless identity_channels_ready?
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
- klass.respond_to?(:model_name) ? klass.model_name.name.to_s : klass.name.to_s
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
@@ -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: one tool (VerifyAccessTool),
10
- # no prompts, no resources, no other transport. The host Rails
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. Karst does not
26
- infer authorization rules, explain application code, or choose which
27
- principal to try on your behalf -- it only reports what actually
28
- happened when a real request was made under the application's own
29
- configured identities.
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).evidence
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