karst 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +59 -0
  3. data/CHANGELOG.md +67 -0
  4. data/CODE_OF_CONDUCT.md +29 -0
  5. data/CONTRIBUTING.md +45 -0
  6. data/LICENSE +21 -0
  7. data/README.md +140 -0
  8. data/SECURITY.md +11 -0
  9. data/docs/advanced-configuration.md +188 -0
  10. data/lib/generators/karst/install/install_generator.rb +88 -0
  11. data/lib/generators/karst/install/templates/karst_identity_controller.rb +19 -0
  12. data/lib/generators/karst/install/templates/karst_initializer.rb +18 -0
  13. data/lib/karst/access/approved_populations.rb +128 -0
  14. data/lib/karst/access/candidate_population.rb +86 -0
  15. data/lib/karst/access/database_isolation.rb +62 -0
  16. data/lib/karst/access/population_approvals.rb +195 -0
  17. data/lib/karst/access/population_config_snippet.rb +67 -0
  18. data/lib/karst/access/population_discovery.rb +271 -0
  19. data/lib/karst/access/population_preview.rb +83 -0
  20. data/lib/karst/access/principal_sampler.rb +241 -0
  21. data/lib/karst/access/principal_selection.rb +90 -0
  22. data/lib/karst/access/principal_source.rb +143 -0
  23. data/lib/karst/access/principal_source_selection.rb +161 -0
  24. data/lib/karst/access/probe_application.rb +164 -0
  25. data/lib/karst/access/resource_evidence.rb +233 -0
  26. data/lib/karst/access/search.rb +265 -0
  27. data/lib/karst/access/selected_principal_sources.rb +65 -0
  28. data/lib/karst/access/sensitive_attribute_names.rb +26 -0
  29. data/lib/karst/access/sweep.rb +198 -0
  30. data/lib/karst/cli/verification.rb +182 -0
  31. data/lib/karst/configuration.rb +223 -0
  32. data/lib/karst/execution_context.rb +83 -0
  33. data/lib/karst/identity/devise_support.rb +90 -0
  34. data/lib/karst/identity/warden_adapter.rb +130 -0
  35. data/lib/karst/identity.rb +479 -0
  36. data/lib/karst/mcp/server.rb +63 -0
  37. data/lib/karst/mcp/verify_access_tool.rb +68 -0
  38. data/lib/karst/railtie.rb +30 -0
  39. data/lib/karst/spec/catalog.rb +199 -0
  40. data/lib/karst/spec/example_observation.rb +31 -0
  41. data/lib/karst/spec/observer.rb +300 -0
  42. data/lib/karst/spec/principal.rb +12 -0
  43. data/lib/karst/spec/reporter.rb +83 -0
  44. data/lib/karst/spec/request_observation.rb +38 -0
  45. data/lib/karst/spec/scenario.rb +65 -0
  46. data/lib/karst/value.rb +35 -0
  47. data/lib/karst/version.rb +5 -0
  48. data/lib/karst/web/badge.rb +183 -0
  49. data/lib/karst/web/browser_identity.rb +103 -0
  50. data/lib/karst/web/locality.rb +64 -0
  51. data/lib/karst/web/middleware.rb +377 -0
  52. data/lib/karst/web/panel.rb +699 -0
  53. data/lib/karst/web/populations_panel.rb +391 -0
  54. data/lib/karst/web/route_lookup.rb +65 -0
  55. data/lib/karst.rb +56 -0
  56. data/lib/rails/commands/karst/boot.rb +24 -0
  57. data/lib/rails/commands/karst/mcp/mcp_command.rb +26 -0
  58. data/lib/rails/commands/karst/verify/verify_command.rb +39 -0
  59. data/lib/tasks/karst.rake +34 -0
  60. metadata +138 -0
@@ -0,0 +1,479 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "value"
4
+ require_relative "identity/devise_support"
5
+ require_relative "identity/warden_adapter"
6
+ require_relative "access/selected_principal_sources"
7
+
8
+ module Karst
9
+ # Framework-neutral identity seam for controlled probes. It deliberately
10
+ # does not discover or enumerate principals; callers own that policy.
11
+ #
12
+ # For a conventional single-model Devise application, Karst can also infer
13
+ # everything below automatically from Devise's own routing metadata and
14
+ # Warden's public runtime API -- see DeviseSupport and WardenAdapter.
15
+ # Explicit configuration (config.principals/config.principal_sources,
16
+ # config.assume_identity/config.clear_identity,
17
+ # config.assume_browser_identity/config.clear_browser_identity) always
18
+ # overrides inference; inference never partially combines with explicit
19
+ # configuration for the same seam.
20
+ # rubocop:disable Metrics/ModuleLength
21
+ module Identity
22
+ class Error < StandardError; end
23
+ class Unavailable < Error; end
24
+ class ConfigurationError < Error; end
25
+
26
+ # authentication_* is presentation-only evidence. Machine serializers
27
+ # deliberately ignore it; it exists so local human interfaces can be
28
+ # useful without broadening JSON/MCP disclosure.
29
+ PrincipalDescriptor = Value.define(:model_name, :id, :display_label, :authentication_key,
30
+ :authentication_identifier)
31
+
32
+ # Compact, inspectable report of why Karst's zero-config Devise/Warden
33
+ # path is or isn't active. `status` is one of:
34
+ #
35
+ # :ready_automatic -- principal source, probe identity, and browser
36
+ # identity are all inferred; no configuration
37
+ # required.
38
+ # :ready_mixed -- at least one of principal source / probe
39
+ # identity / browser identity is explicitly
40
+ # configured and the rest are safely inferred
41
+ # (e.g. an explicit config.principals selecting
42
+ # one of several Devise models).
43
+ # :ready_explicit -- principal source, probe identity, and browser
44
+ # identity are all explicitly configured.
45
+ # :ambiguous -- more than one Devise model was detected and no
46
+ # explicit config.principals/principal_sources
47
+ # selects one.
48
+ # :unavailable -- Karst could not identify enough of an
49
+ # authentication integration to run the primary
50
+ # workflow without explicit configuration.
51
+ #
52
+ # `message` is nil whenever the caller's own local hint text already
53
+ # says everything Karst can usefully add (the two ready states, and
54
+ # :unavailable with no principal source at all -- every hint call site
55
+ # already has its own "nothing is configured" wording for that). It is
56
+ # populated only when Karst has something more specific to say: which
57
+ # Devise models are ambiguous, or that a principal source exists but
58
+ # probe/browser identity still couldn't be wired up automatically.
59
+ SetupState = Value.define(:status, :message)
60
+
61
+ # Delegates identity operations to the application's configured hooks.
62
+ class ConfiguredAdapter
63
+ def initialize(assume_hook, clear_hook)
64
+ @assume_hook = assume_hook
65
+ @clear_hook = clear_hook
66
+ end
67
+
68
+ def assume(session, principal)
69
+ @assume_hook.call(session, principal)
70
+ end
71
+
72
+ def clear(session)
73
+ @clear_hook.call(session)
74
+ end
75
+ end
76
+ private_constant :ConfiguredAdapter
77
+
78
+ # rubocop:disable Metrics/ClassLength
79
+ class << self
80
+ def principals
81
+ source = Karst.config.principals
82
+ return called_principal_source(source) if source
83
+
84
+ inferred = DeviseSupport.unambiguous_mapping
85
+ return inferred.model.all if inferred
86
+
87
+ raise Unavailable, "no principal source is configured"
88
+ end
89
+
90
+ # The effective, normalized principal population(s): a Hash of Symbol
91
+ # => Karst::Access::PrincipalSource, covering an explicit
92
+ # config.principal_sources, a bare config.principals (wrapped as one
93
+ # implicit :default source), and -- when neither is configured -- one
94
+ # inferred Devise model (see Karst::Configuration#principal_sources).
95
+ # Every multi-source-aware caller (Identity.resolve,
96
+ # Access::PrincipalSelection, the panel) reads this instead of
97
+ # config.principals directly.
98
+ def principal_sources
99
+ sources = Karst.config.principal_sources
100
+ raise Unavailable, "no principal source is configured" unless sources
101
+
102
+ sources
103
+ end
104
+
105
+ def with(session, principal)
106
+ active_adapter = adapter(principal)
107
+ # The hook may establish identity and then raise, so cleanup becomes
108
+ # mandatory before invoking it rather than only after it returns.
109
+ assumed = true
110
+ active_adapter.assume(session, principal)
111
+ yield
112
+ ensure
113
+ active_adapter.clear(session) if active_adapter && assumed
114
+ end
115
+
116
+ def clear(session)
117
+ adapter.clear(session)
118
+ end
119
+
120
+ def describe(principal)
121
+ model_name = model_name_for(principal)
122
+ id = id_for(principal)
123
+ label_hook = Karst.config.principal_label
124
+ if label_hook && !label_hook.respond_to?(:call)
125
+ raise ConfigurationError, "config.principal_label must be callable"
126
+ end
127
+
128
+ label, key, identifier = label_attributes(principal, model_name, id, label_hook)
129
+ PrincipalDescriptor.new(model_name: model_name, id: id, display_label: label,
130
+ authentication_key: key, authentication_identifier: identifier)
131
+ end
132
+
133
+ # Resolves only principals exposed by a configured source. In
134
+ # particular, this never constantizes a submitted model name or
135
+ # performs an unrestricted model lookup.
136
+ #
137
+ # Tries each configured Karst::Access::PrincipalSource in order and
138
+ # returns the first match; a model name that does not belong to any
139
+ # configured source resolves nothing, without ever touching that
140
+ # source's records. For an Active Record relation/class source, a
141
+ # matching model name resolves through a scoped primary-key query
142
+ # against that exact relation instead of enumerating it -- a source
143
+ # may cover hundreds of thousands of rows, and this must stay a single
144
+ # bounded query regardless of table size. The relation's own WHERE
145
+ # clauses (tenant scoping, soft deletes, and so on) still apply, so a
146
+ # principal outside a configured relation is never resolved. A generic
147
+ # Enumerable source (no scoped-query capability) keeps the original
148
+ # enumerate-and-compare behavior, bounded to that one source.
149
+ def resolve(model_name:, id:)
150
+ principal_sources.each_value do |source|
151
+ resolved = resolve_within_source(source, model_name: model_name, id: id)
152
+ return resolved if resolved
153
+ end
154
+ nil
155
+ end
156
+
157
+ def browser_supported?
158
+ explicit_browser_hooks? || automatic_browser_identity_available?
159
+ end
160
+
161
+ # Returns the Devise/Warden scope this browser identity was actually
162
+ # assumed under (nil for explicit hooks, or for a non-Devise bare
163
+ # Warden proxy) -- see Karst::Web::BrowserIdentity, which retains it
164
+ # for the lifetime of the browser session so #clear_browser below never
165
+ # has to guess which of several selected sources produced the
166
+ # principal being cleared.
167
+ def assume_browser(request, principal)
168
+ raise Unavailable, "browser identity hooks are not configured" unless browser_supported?
169
+
170
+ if explicit_browser_hooks?
171
+ Karst.config.assume_browser_identity.call(request, principal)
172
+ nil
173
+ else
174
+ warden_adapter = inferred_adapter(principal)
175
+ warden_adapter.assume(request, principal)
176
+ warden_adapter.scope
177
+ end
178
+ end
179
+
180
+ # `scope`, when given, is the exact scope #assume_browser returned for
181
+ # the identity actually being cleared (see
182
+ # Karst::Web::BrowserIdentity) -- used in preference to
183
+ # #scope_for_effective_source, which cannot always determine one on
184
+ # its own with no principal in hand and several selected sources. Karst
185
+ # still refuses to guess when neither is available.
186
+ def clear_browser(request, scope: nil)
187
+ raise Unavailable, "browser identity hooks are not configured" unless browser_supported?
188
+
189
+ if explicit_browser_hooks?
190
+ Karst.config.clear_browser_identity.call(request)
191
+ else
192
+ inferred_adapter(nil, scope: scope).clear(request)
193
+ end
194
+ end
195
+
196
+ # See SetupState above. Cheap and side-effect free: touches only
197
+ # already-established configuration/metadata plus, at most, calling a
198
+ # configured principals/principal_sources callable the same way the
199
+ # panel already does on every render to type-check its result (see
200
+ # Access::PrincipalSampler.representative_capable?) -- never to
201
+ # enumerate or query it.
202
+ def setup_state
203
+ return SetupState.new(status: :ambiguous, message: ambiguous_message) if ambiguous_principal_source?
204
+ return SetupState.new(status: :unavailable, message: nil) unless principal_source_ready?
205
+ return SetupState.new(status: :unavailable, message: unavailable_message) unless identity_channels_ready?
206
+
207
+ SetupState.new(status: ready_status, message: nil)
208
+ end
209
+
210
+ private
211
+
212
+ def label_attributes(principal, model_name, id, label_hook)
213
+ return [label_hook.call(principal), nil, nil] if label_hook
214
+
215
+ key, identifier = authentication_identifier(principal)
216
+ default = "#{model_name} ##{id}"
217
+ [identifier ? "#{identifier} · #{default}" : default, key, identifier]
218
+ end
219
+
220
+ def authentication_identifier(principal)
221
+ key = DeviseSupport.authentication_key_for(principal.class)
222
+ return [nil, nil] unless key && principal.respond_to?(key)
223
+
224
+ value = principal.public_send(key)
225
+ return [nil, nil] if value.nil? || value.to_s.empty?
226
+
227
+ [key, value.to_s]
228
+ rescue StandardError
229
+ [nil, nil]
230
+ end
231
+
232
+ def resolve_within_source(source, model_name:, id:)
233
+ records = source.evaluate
234
+ relation = active_record_relation(records)
235
+ return resolve_scoped(relation, model_name: model_name, id: id) if relation
236
+
237
+ resolve_enumerated(records, model_name: model_name, id: id)
238
+ end
239
+
240
+ def active_record_relation(source)
241
+ return source if defined?(ActiveRecord::Relation) && source.is_a?(ActiveRecord::Relation)
242
+ return source.all if defined?(ActiveRecord::Base) && source.is_a?(Class) && source < ActiveRecord::Base
243
+
244
+ nil
245
+ end
246
+
247
+ # A model-name mismatch is checked before ever touching the database:
248
+ # each source is trusted to name one authoritative model, so a request
249
+ # for a different model name is rejected without issuing a query
250
+ # rather than attempting (and failing) a primary-key lookup against
251
+ # the wrong table.
252
+ def resolve_scoped(relation, model_name:, id:)
253
+ klass = relation.klass
254
+ return nil unless model_name_for_klass(klass) == model_name.to_s
255
+
256
+ primary_key = klass.primary_key
257
+ return nil unless primary_key.is_a?(String)
258
+
259
+ relation.find_by(primary_key => id)
260
+ end
261
+
262
+ def resolve_enumerated(source, model_name:, id:)
263
+ source.each do |principal|
264
+ descriptor = describe(principal)
265
+ return principal if descriptor.model_name == model_name.to_s && descriptor.id.to_s == id.to_s
266
+ end
267
+ nil
268
+ end
269
+
270
+ def identity_channels_ready?
271
+ (explicit_probe_hooks? || automatic_identity_available?) &&
272
+ (explicit_browser_hooks? || automatic_browser_identity_available?)
273
+ end
274
+
275
+ # Only called once every channel is already known to be ready (see
276
+ # #identity_channels_ready? above), so this purely classifies *how*
277
+ # each one got there -- inferred, explicit, or a mix of both (see
278
+ # SetupState).
279
+ def ready_status
280
+ explicit = [!Karst.config.principals.nil?, explicit_probe_hooks?, explicit_browser_hooks?]
281
+ return :ready_automatic if explicit.none?
282
+ return :ready_explicit if explicit.all?
283
+
284
+ :ready_mixed
285
+ end
286
+
287
+ def called_principal_source(source)
288
+ raise ConfigurationError, "config.principals must be callable" unless source.respond_to?(:call)
289
+
290
+ source.call
291
+ end
292
+
293
+ def adapter(principal = nil)
294
+ assume_hook = Karst.config.assume_identity
295
+ clear_hook = Karst.config.clear_identity
296
+ return configured_adapter(assume_hook, clear_hook) if assume_hook || clear_hook
297
+
298
+ inferred_adapter(principal)
299
+ end
300
+
301
+ def configured_adapter(assume_hook, clear_hook)
302
+ unless assume_hook.respond_to?(:call) && clear_hook.respond_to?(:call)
303
+ raise ConfigurationError,
304
+ "config.assume_identity and config.clear_identity must both be callable"
305
+ end
306
+ ConfiguredAdapter.new(assume_hook, clear_hook)
307
+ end
308
+
309
+ # Builds a Warden-backed adapter for the automatic Devise/Warden path.
310
+ # With a specific `principal`, scope comes straight from that
311
+ # principal's own class -- the most direct evidence available, and
312
+ # correct even when a source mixes multiple Devise-mapped subclasses,
313
+ # regardless of how many principal_sources are configured. Without one,
314
+ # `scope:` (when the caller already knows exactly which identity is
315
+ # being cleared -- see #clear_browser) wins; otherwise scope falls back
316
+ # to the single effective principal source's model -- see
317
+ # #scope_for_effective_source. Devise loaded with no resolvable scope
318
+ # refuses to guess rather than risk operating under the wrong Warden
319
+ # scope.
320
+ def inferred_adapter(principal, scope: nil)
321
+ raise Unavailable, "no identity hooks are configured and Warden is unavailable" unless warden_available?
322
+ return WardenAdapter.new unless DeviseSupport.available?
323
+
324
+ resolved = scope || (principal ? DeviseSupport.mapping_for(principal.class)&.scope : scope_for_effective_source)
325
+ unless resolved
326
+ raise Unavailable,
327
+ "Karst could not determine this principal's Devise/Warden scope automatically; " \
328
+ "configure config.assume_identity/config.clear_identity " \
329
+ "(or config.assume_browser_identity/config.clear_browser_identity)"
330
+ end
331
+
332
+ WardenAdapter.new(scope: resolved)
333
+ end
334
+
335
+ # Probe-identity eligibility. Preserves the pre-existing bare-Warden
336
+ # fallback (no Devise, no scope) for a non-Devise application already
337
+ # relying on Karst's isolated integration session -- see WardenAdapter.
338
+ def automatic_identity_available?
339
+ return false unless warden_available?
340
+ return true unless DeviseSupport.available?
341
+
342
+ every_effective_source_scoped?
343
+ end
344
+
345
+ # Browser-identity eligibility. Deliberately stricter than probe
346
+ # eligibility above: automatically mutating the developer's *real*
347
+ # browser session is only safe once Karst can prove the Devise
348
+ # scope -- a bare bootstrapped Warden proxy with no scope is never
349
+ # enough here, unlike the isolated probe session.
350
+ def automatic_browser_identity_available?
351
+ return false unless warden_available? && DeviseSupport.available?
352
+
353
+ every_effective_source_scoped?
354
+ end
355
+
356
+ # True once every currently effective principal source resolves to its
357
+ # own known Devise/Warden scope -- the single-source case this always
358
+ # covered (see #scope_for_effective_source), plus several sources when
359
+ # each is independently Devise-mapped, exactly what
360
+ # Access::SelectedPrincipalSources builds from a local multi-model
361
+ # selection. Per-principal scope resolution (#inferred_adapter with a
362
+ # principal already in hand) is already correct for any number of
363
+ # sources; this governs only "no principal yet" eligibility and the
364
+ # bare-clear fallback, so it still refuses to guess when a source is
365
+ # not provably Devise-scoped.
366
+ def every_effective_source_scoped?
367
+ sources = safe_principal_sources
368
+ return false unless sources&.any?
369
+
370
+ sources.values.all? { |source| devise_scope_for_source(source) }
371
+ end
372
+
373
+ def devise_scope_for_source(source)
374
+ model = model_from_source(source)
375
+ model && DeviseSupport.mapping_for(model)&.scope
376
+ end
377
+
378
+ def scope_for_effective_source
379
+ model = effective_principal_model
380
+ model && DeviseSupport.mapping_for(model)&.scope
381
+ end
382
+
383
+ # The model backing the *single* effective principal source, when one
384
+ # can be determined without risk. Per-principal scope resolution (the
385
+ # common case, with an actual principal instance already in hand)
386
+ # never goes through this path at all, and is unaffected by how many
387
+ # sources are configured -- see #every_effective_source_scoped? above
388
+ # for the multi-source "no principal in hand" case.
389
+ def effective_principal_model
390
+ sources = safe_principal_sources
391
+ return nil unless sources && sources.size == 1
392
+
393
+ model_from_source(sources.values.first)
394
+ end
395
+
396
+ # A relation/class, exactly the same type-check the panel already
397
+ # relies on for representative sampling, or the class of the first
398
+ # element of an already materialized Array (safe to inspect without
399
+ # issuing a query; evaluating the source callable itself is the same
400
+ # already-accepted pattern the panel uses every render, see
401
+ # Access::PrincipalSampler.representative_capable?). An unmaterialized
402
+ # Enumerable/lazy source yields nil rather than guessing further --
403
+ # automatic Warden scope resolution is unavailable there, by design
404
+ # (see Identity::DeviseSupport and README).
405
+ def model_from_source(source)
406
+ records = source.evaluate
407
+ active_record_model_from(records) || (records.first.class if records.is_a?(Array) && !records.empty?)
408
+ rescue StandardError
409
+ nil
410
+ end
411
+
412
+ def active_record_model_from(records)
413
+ return records.klass if defined?(ActiveRecord::Relation) && records.is_a?(ActiveRecord::Relation)
414
+ return records if defined?(ActiveRecord::Base) && records.is_a?(Class) && records < ActiveRecord::Base
415
+
416
+ nil
417
+ end
418
+
419
+ def safe_principal_sources
420
+ principal_sources
421
+ rescue Error
422
+ nil
423
+ end
424
+
425
+ def warden_available?
426
+ defined?(Warden::Manager)
427
+ end
428
+
429
+ def explicit_probe_hooks?
430
+ !!(Karst.config.assume_identity || Karst.config.clear_identity)
431
+ end
432
+
433
+ def explicit_browser_hooks?
434
+ Karst.config.assume_browser_identity.respond_to?(:call) &&
435
+ Karst.config.clear_browser_identity.respond_to?(:call)
436
+ end
437
+
438
+ def ambiguous_principal_source?
439
+ return false if Karst.config.principals || Karst.config.configured_principal_sources
440
+ return false if Access::SelectedPrincipalSources.mappings.any?
441
+
442
+ DeviseSupport.mappings.size > 1
443
+ end
444
+
445
+ def principal_source_ready?
446
+ !Karst.config.principals.nil? || !Karst.config.configured_principal_sources.nil? ||
447
+ !DeviseSupport.unambiguous_mapping.nil? || Access::SelectedPrincipalSources.mappings.any?
448
+ end
449
+
450
+ def ambiguous_message
451
+ names = DeviseSupport.mappings.map { |mapping| mapping.model.name }.sort.join(", ")
452
+ "Karst detected multiple Devise models (#{names}). Select which one(s) to test at /karst, " \
453
+ "or configure config.principals/config.principal_sources explicitly."
454
+ end
455
+
456
+ def unavailable_message
457
+ "Karst found a principal source but could not automatically wire up probe/browser identity for it. " \
458
+ "Configure config.assume_identity/config.clear_identity and " \
459
+ "config.assume_browser_identity/config.clear_browser_identity."
460
+ end
461
+
462
+ def model_name_for(principal)
463
+ model_name_for_klass(principal.class)
464
+ end
465
+
466
+ def model_name_for_klass(klass)
467
+ klass.respond_to?(:model_name) ? klass.model_name.name.to_s : klass.name.to_s
468
+ end
469
+
470
+ def id_for(principal)
471
+ raise ConfigurationError, "principal must expose an id" unless principal.respond_to?(:id)
472
+
473
+ principal.id
474
+ end
475
+ end
476
+ # rubocop:enable Metrics/ClassLength
477
+ end
478
+ # rubocop:enable Metrics/ModuleLength
479
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require_relative "verify_access_tool"
5
+ require_relative "../version"
6
+
7
+ module Karst
8
+ 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
11
+ # application must already be booted (see
12
+ # Rails::Command::Karst::Boot#boot_karst_application!, used by
13
+ # `bin/rails karst:mcp`) before this is built -- the server itself never
14
+ # boots or reboots the application, so one process serves every tool call
15
+ # for its lifetime against the one already-running application.
16
+ module Server
17
+ INSTRUCTIONS = <<~TEXT.strip
18
+ Karst verifies bounded runtime access to local paths in this Rails application.
19
+
20
+ Agent proposes: a hypothesis about who can reach a path.
21
+ Karst proves: it executes the real application, under real existing
22
+ identities, and reports observed evidence -- HTTP status, redirect,
23
+ halted callback, exception, and whether a usable outcome was found.
24
+
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.
30
+ TEXT
31
+
32
+ class << self
33
+ def build
34
+ MCP::Server.new(
35
+ name: "karst",
36
+ title: "Karst",
37
+ version: Karst::VERSION,
38
+ instructions: INSTRUCTIONS,
39
+ tools: [VerifyAccessTool],
40
+ configuration: MCP::Configuration.new(exception_reporter: method(:report_exception))
41
+ )
42
+ end
43
+
44
+ # Runs until the client closes stdin (or the process receives
45
+ # SIGINT). stdout is reserved for MCP protocol frames; anything Karst
46
+ # or the host application needs to say for local debugging goes to
47
+ # stderr instead, matching how `rails server`/`rails console` behave.
48
+ def run!
49
+ server = build
50
+ transport = MCP::Server::Transports::StdioTransport.new(server)
51
+ server.transport = transport
52
+ transport.open
53
+ end
54
+
55
+ private
56
+
57
+ def report_exception(exception, _context)
58
+ warn("karst-mcp: #{exception.class}: #{exception.message}")
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+ require_relative "../cli/verification"
6
+
7
+ module Karst
8
+ module Mcp
9
+ # The one MCP tool Karst exposes: a thin transport adapter over
10
+ # Karst::CLI::Verification#evidence, itself a thin adapter over
11
+ # Access::Search. This class owns no verification behavior of its own --
12
+ # it only shapes one MCP request into a Verification call and returns
13
+ # exactly the evidence document `bin/rails karst:verify --json` would
14
+ # print, so an agent calling this tool sees the same bounded runtime
15
+ # evidence a developer sees at the terminal, never a separate MCP result
16
+ # model.
17
+ #
18
+ # Every safety boundary an agent might reach for -- which principal to
19
+ # run as, which population to try, whether to skip rollback, how many
20
+ # requests to issue -- is owned by the host application's Karst
21
+ # configuration and Access::Search itself; nothing about that is
22
+ # settable here. The only inputs are the request an agent is allowed to
23
+ # make: which path, and which HTTP method (GET only, currently).
24
+ class VerifyAccessTool < MCP::Tool
25
+ tool_name "verify_access"
26
+ description <<~DESCRIPTION.strip
27
+ Verify bounded GET access to a local path in the running Rails application.
28
+
29
+ Karst executes the real application, under real existing identities
30
+ drawn from the application's own configured principal source(s), inside
31
+ a rolled-back database transaction, and reports what actually happened:
32
+ HTTP status, redirect target, any halted callback, any raised
33
+ exception, and whether a usable outcome was found. This is observed
34
+ runtime evidence, not an inference about authorization rules -- Karst
35
+ never explains *why* an outcome occurred, and this tool never
36
+ impersonates the developer's browser or exposes Test As.
37
+ DESCRIPTION
38
+
39
+ input_schema(
40
+ properties: {
41
+ path: {
42
+ type: "string",
43
+ description: "Local application path to verify, e.g. \"/admin/imports/123\". " \
44
+ "Must be a local path (no scheme/host)."
45
+ },
46
+ method: {
47
+ type: "string",
48
+ description: "HTTP method to verify. Only GET is currently supported.",
49
+ default: "GET"
50
+ }
51
+ },
52
+ required: ["path"]
53
+ )
54
+
55
+ class << self
56
+ # server_context is part of MCP::Tool's call signature (the server
57
+ # passes it by keyword whenever a tool's #call accepts it) but this
58
+ # tool needs no per-request context beyond its own arguments.
59
+ # 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
62
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate(document) }], error: document.key?(:error))
63
+ end
64
+ # rubocop:enable Lint/UnusedMethodArgument
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Karst
6
+ # Inserts Karst's development-only web surface and rake tasks. Karst
7
+ # installs no notification subscription, no eager-loaded state, and nothing
8
+ # that runs on an ordinary application request outside development.
9
+ class Railtie < Rails::Railtie
10
+ # Must run before the middleware stack is built (a later Finisher
11
+ # initializer), not in config.after_initialize, which runs after the stack
12
+ # already exists and would have no effect. Lazily requires the web surface
13
+ # so a plain `require "karst"` never loads Rack rendering concerns outside
14
+ # development.
15
+ initializer "karst.web_middleware", before: :build_middleware_stack do |app|
16
+ next unless Rails.env.development?
17
+
18
+ require_relative "web/middleware"
19
+ app.middleware.use(Web::Middleware)
20
+
21
+ Rails.logger&.info("Karst: evidence at /karst")
22
+ end
23
+
24
+ rake_tasks do
25
+ load File.expand_path("../tasks/karst.rake", __dir__)
26
+ end
27
+ end
28
+
29
+ private_constant :Railtie
30
+ end