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
@@ -23,17 +23,45 @@ module Karst
23
23
  # Karst), already true by default wherever it should be. What remains
24
24
  # below is for exceptional applications: custom (non-Devise)
25
25
  # authentication, identity spread across several models, and a handful of
26
- # deliberately unprominent bounds. See docs/advanced-configuration.md.
26
+ # deliberately unprominent bounds. Mutating MCP reproduction is a separate,
27
+ # default-off capability because same-connection rollback cannot contain
28
+ # arbitrary application side effects. See docs/advanced-configuration.md.
27
29
  class Configuration
28
30
  # None of these are normal configuration. `enabled` is an off switch for
29
31
  # the exceptional case where Karst's default (on in development/test) is
30
32
  # wrong for this environment. The rest are escape hatches for custom
31
33
  # (non-Devise) authentication -- principals, assume_identity,
32
34
  # clear_identity, principal_label, and the browser Test-as pair -- and
33
- # are documented as such.
34
- attr_accessor :enabled, :principals, :assume_identity, :clear_identity, :principal_label,
35
+ # are documented as such. `mcp_mutating_requests` is the narrowly scoped
36
+ # opt-in that lets an MCP client issue POST/PUT/PATCH/DELETE reproduction;
37
+ # it is false by default and does not affect human reproduction.
38
+ attr_accessor :enabled, :mcp_mutating_requests, :principals, :assume_identity, :clear_identity, :principal_label,
35
39
  :assume_browser_identity, :clear_browser_identity
36
40
 
41
+ # How Karst observes which principal the *application itself* resolved
42
+ # while running a probe request -- the only thing that can turn "Karst
43
+ # was asked to run as User#123" into evidence that the request actually
44
+ # did. A callable taking one Karst::Identity::ObservationContext
45
+ # (#controller, #request, #env) and returning the application's runtime
46
+ # principal, or nil when the application resolved none:
47
+ #
48
+ # config.observe_identity = ->(context) { context.controller&.current_user }
49
+ #
50
+ # `controller` is the exact ActionController instance that processed the
51
+ # request, present even when a before_action halted it, so an identity
52
+ # the application keeps in an instance variable, a Current attribute, or
53
+ # anything else Karst cannot know about is still observable. Returning
54
+ # nil/false means "the application ran this request anonymously"; raising
55
+ # (or returning something with no id) makes the observation unobservable,
56
+ # never confirmed.
57
+ #
58
+ # A Devise/Warden application needs none of this: Karst reads the
59
+ # application's own Warden proxy from the probe request instead (see
60
+ # Karst::Identity::Observer). With neither seam available, Karst reports
61
+ # identity as unobservable rather than assuming the requested principal
62
+ # is the one that ran.
63
+ attr_accessor :observe_identity
64
+
37
65
  # principal_populations is an escape hatch (committed, reviewable
38
66
  # populations for CI); the four bounds after it are advanced tuning an
39
67
  # ordinary developer should never need to see.
@@ -88,8 +116,10 @@ module Karst
88
116
  access_scenario: "artifact scenarios were removed; Karst analyzes routes, not record sweeps"
89
117
  }.freeze
90
118
 
119
+ # rubocop:disable Metrics/MethodLength
91
120
  def initialize
92
121
  @enabled = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.development? || Rails.env.test? : false
122
+ @mcp_mutating_requests = false
93
123
  @access_sweep_limit = 25
94
124
  @principal_candidate_pool_size = 1_000
95
125
  @population_retry_limit = 3
@@ -97,8 +127,10 @@ module Karst
97
127
  @principal_populations = {}
98
128
  @configured_principal_sources = nil
99
129
  %i[@principals @assume_identity @clear_identity @principal_label
100
- @assume_browser_identity @clear_browser_identity].each { |hook| instance_variable_set(hook, nil) }
130
+ @assume_browser_identity @clear_browser_identity
131
+ @observe_identity].each { |hook| instance_variable_set(hook, nil) }
101
132
  end
133
+ # rubocop:enable Metrics/MethodLength
102
134
 
103
135
  # An application-authored hint about meaningful candidate populations for
104
136
  # whatever config.principals returns -- a Hash of name => zero-argument
@@ -149,8 +181,22 @@ module Karst
149
181
  # what a "approved population" is. Resolved on every call rather than
150
182
  # memoized: an approval revoked, or a scope deleted, must stop being
151
183
  # executed on the next analysis without a server restart.
152
- def principal_sources
153
- Access::ApprovedPopulations.merge(configured_or_inferred_sources)
184
+ #
185
+ # That resolution is real work -- reading the approval (and selection)
186
+ # file, parsing model source, evaluating principal source callables -- so
187
+ # one high-level operation resolves it once and reuses the result (see
188
+ # Karst::Identity::Snapshot) rather than calling this repeatedly. Within
189
+ # one result, each source's records callable is evaluated at most once
190
+ # (see Access::PrincipalSource#evaluated_once).
191
+ #
192
+ # `record:`/`discovery:`, when given, are threaded straight to
193
+ # Access::ApprovedPopulations.merge -- a caller's already-loaded
194
+ # approval record and population discovery instance, reused instead of
195
+ # loaded and parsed again (see Karst::Identity::Snapshot).
196
+ def principal_sources(record: nil, discovery: nil)
197
+ sources = configured_or_inferred_sources
198
+ Access::ApprovedPopulations.merge(sources&.transform_values(&:evaluated_once), record: record,
199
+ discovery: discovery)
154
200
  end
155
201
 
156
202
  def access_sweep_limit=(value)
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+ require_relative "observation"
5
+
6
+ module Karst
7
+ module Identity
8
+ # The three identities of one probe request, kept deliberately apart:
9
+ #
10
+ # requested -- what the caller asked Karst to execute as (intent).
11
+ # nil means an anonymous probe was requested.
12
+ # establishment -- what Karst's own identity seam managed to set up
13
+ # (setup), never evidence on its own.
14
+ # observed -- what the Rails application itself resolved as its
15
+ # authenticated principal while running the request
16
+ # (evidence).
17
+ #
18
+ # `confirmation` is the only field a machine consumer should read to
19
+ # decide whether a principal claim is true, and it fails closed:
20
+ #
21
+ # :confirmed requested a principal, observed that same one
22
+ # :confirmed_anonymous requested anonymous, observed no principal
23
+ # :mismatch requested a principal, observed a different one
24
+ # :absent requested a principal, observed none
25
+ # :contaminated requested anonymous, observed a principal
26
+ # :unobservable Karst could not determine what identity the
27
+ # application used
28
+ #
29
+ # Only :confirmed and :confirmed_anonymous are evidence about identity.
30
+ Evidence = Value.define(:requested, :observed, :confirmation, :observed_at, :observation_source,
31
+ :observation_error, :establishment, :establishment_error, :cleanup_error,
32
+ :changed_during_request) do
33
+ def confirmed?
34
+ %i[confirmed confirmed_anonymous].include?(confirmation)
35
+ end
36
+
37
+ def anonymous_probe?
38
+ requested.nil?
39
+ end
40
+ end
41
+
42
+ # Derives one Evidence from the observations a probe actually collected.
43
+ module EvidenceBuilder
44
+ class << self
45
+ # `halt` is the observation taken at the moment an access callback
46
+ # halted the request -- the state the application had established
47
+ # when that access decision was made -- and `completion` the one
48
+ # taken after the request returned. Both may be nil (never attempted).
49
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
50
+ def build(requested:, halt: nil, completion: nil, establishment: nil,
51
+ establishment_error: nil, cleanup_error: nil)
52
+ decisive = decisive_observation(halt, completion)
53
+ Evidence.new(
54
+ requested: requested,
55
+ observed: decisive&.observable? ? decisive.principal : nil,
56
+ confirmation: confirmation(requested, decisive),
57
+ observed_at: decisive&.observable? ? phase_of(decisive, halt) : nil,
58
+ observation_source: decisive&.source,
59
+ observation_error: decisive&.error,
60
+ establishment: establishment,
61
+ establishment_error: establishment_error,
62
+ cleanup_error: cleanup_error,
63
+ changed_during_request: changed?(halt, completion)
64
+ )
65
+ end
66
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
67
+
68
+ private
69
+
70
+ # The halt moment is the one that explains an access decision, so it
71
+ # wins whenever it produced a real observation; a failed observation
72
+ # there still falls back to the completed request rather than
73
+ # discarding evidence Karst does hold.
74
+ def decisive_observation(halt, completion)
75
+ return halt if halt&.observable?
76
+ return completion if completion&.observable?
77
+
78
+ halt || completion
79
+ end
80
+
81
+ def phase_of(decisive, halt)
82
+ decisive.equal?(halt) ? :halted_callback : :request_completion
83
+ end
84
+
85
+ def confirmation(requested, decisive)
86
+ return :unobservable if decisive.nil? || !decisive.observable?
87
+ return anonymous_confirmation(decisive.principal) if requested.nil?
88
+ return :absent if decisive.principal.nil?
89
+
90
+ same?(requested, decisive.principal) ? :confirmed : :mismatch
91
+ end
92
+
93
+ def anonymous_confirmation(observed)
94
+ observed.nil? ? :confirmed_anonymous : :contaminated
95
+ end
96
+
97
+ def same?(requested, observed)
98
+ requested.model_name.to_s == observed.model_name.to_s &&
99
+ requested.id.to_s == observed.id.to_s
100
+ end
101
+
102
+ def changed?(halt, completion)
103
+ return false unless halt&.observable? && completion&.observable?
104
+
105
+ halt.principal != completion.principal
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+ require_relative "devise_support"
5
+
6
+ module Karst
7
+ module Identity
8
+ # The one place a principal object's stable model name is derived, shared
9
+ # by requested-identity description (Identity.describe) and runtime
10
+ # observation below, so the two can never disagree about what "User"
11
+ # means for the same object.
12
+ module Naming
13
+ module_function
14
+
15
+ def model_name_for(klass)
16
+ klass.respond_to?(:model_name) ? klass.model_name.name.to_s : klass.name.to_s
17
+ end
18
+ end
19
+
20
+ # What the *application* resolved as its own authenticated principal
21
+ # during a probe request -- never what Karst asked for. Deliberately
22
+ # narrower than PrincipalDescriptor: an observation carries no display
23
+ # label or authentication identifier, because it is evidence about a
24
+ # request rather than a record Karst selected and can describe.
25
+ ObservedPrincipal = Value.define(:model_name, :id)
26
+
27
+ # Handed to config.observe_identity. `controller` is the exact
28
+ # ActionController instance that processed the probe request (Rails sets
29
+ # env["action_controller.instance"] in ActionController::Metal#set_request!
30
+ # before any callback runs, so it is present even for a request halted in
31
+ # a before_action); `request` is an ActionDispatch::Request over the same
32
+ # env, and `env` the raw Rack env.
33
+ ObservationContext = Value.define(:controller, :request, :env)
34
+
35
+ # One attempt to observe the application's runtime principal.
36
+ # `principal` is an ObservedPrincipal, or nil for "the application
37
+ # resolved no principal" -- which is only meaningful when `error` is nil.
38
+ # `source` names which seam produced it (:configured, :warden), so a
39
+ # consumer can tell an application-authored observation from an inferred
40
+ # one. A non-nil `error` means Karst could not determine what identity the
41
+ # application used at all, and nothing may be concluded from `principal`.
42
+ Observation = Value.define(:principal, :source, :error) do
43
+ def observable?
44
+ error.nil?
45
+ end
46
+ end
47
+
48
+ # Observes the principal the Rails application actually used, from the
49
+ # runtime state of the probe request itself.
50
+ #
51
+ # Two seams, in order of authority:
52
+ #
53
+ # 1. config.observe_identity -- an application-authored callable given
54
+ # the controller instance that processed the request. This is the
55
+ # only seam that can observe an identity the application keeps
56
+ # somewhere Karst cannot know about (a @current_user set by a
57
+ # before_action, a Current attribute, a custom session lookup).
58
+ #
59
+ # 2. The Warden proxy on the probe request's own env, read through
60
+ # Warden's public Proxy#user. For a Devise application this is the
61
+ # same object Devise's own current_<scope> helper returns, and
62
+ # reading it neither runs authentication strategies nor mutates the
63
+ # request (Proxy#user only deserializes an existing session, unlike
64
+ # Proxy#authenticate).
65
+ #
66
+ # With neither available, Karst reports the observation as unobservable
67
+ # rather than assuming the requested identity was the one that ran.
68
+ module Observer
69
+ UNCONFIGURED = "no runtime identity observation seam is available; " \
70
+ "configure config.observe_identity (or use Karst's Devise/Warden integration)"
71
+
72
+ class << self
73
+ def observe(env, requested: nil)
74
+ return unobservable("the probe request environment was not captured") unless env.is_a?(Hash)
75
+
76
+ hook = Karst.config.observe_identity
77
+ return observe_with_hook(hook, env) if hook
78
+ return observe_with_warden(env, requested) if warden_proxy(env)
79
+
80
+ unobservable(UNCONFIGURED)
81
+ end
82
+
83
+ # Public so Identity.describe and observation agree on model naming.
84
+ def model_name_for(klass)
85
+ Naming.model_name_for(klass)
86
+ end
87
+
88
+ private
89
+
90
+ def observe_with_hook(hook, env)
91
+ raise ConfigurationError, "config.observe_identity must be callable" unless hook.respond_to?(:call)
92
+
93
+ observed(hook.call(context_for(env)), :configured)
94
+ rescue ConfigurationError
95
+ raise
96
+ rescue StandardError => e
97
+ unobservable("config.observe_identity raised #{e.class}: #{e.message}")
98
+ end
99
+
100
+ def context_for(env)
101
+ ObservationContext.new(controller: env["action_controller.instance"],
102
+ request: action_dispatch_request(env), env: env)
103
+ end
104
+
105
+ def action_dispatch_request(env)
106
+ return nil unless defined?(ActionDispatch::Request)
107
+
108
+ ActionDispatch::Request.new(env)
109
+ rescue StandardError
110
+ nil
111
+ end
112
+
113
+ # Every scope the application might have authenticated under, most
114
+ # likely first: the requested principal's own Devise scope, then every
115
+ # other registered one (so an anonymous probe contaminated under some
116
+ # *other* scope is still seen), then Warden's own default scope for a
117
+ # plain non-Devise Warden setup.
118
+ def observe_with_warden(env, requested)
119
+ proxy = warden_proxy(env)
120
+ scopes(requested).each do |scope|
121
+ user = scope ? proxy.user(scope: scope) : proxy.user
122
+ return observed(user, :warden) if user
123
+ end
124
+ Observation.new(principal: nil, source: :warden, error: nil)
125
+ rescue StandardError => e
126
+ unobservable("reading the Warden proxy raised #{e.class}: #{e.message}")
127
+ end
128
+
129
+ def scopes(requested)
130
+ registered = DeviseSupport.mappings.map(&:scope)
131
+ return [nil] if registered.empty?
132
+
133
+ preferred = requested && DeviseSupport.mapping_for(requested.class)&.scope
134
+ ([preferred] + registered).compact.uniq
135
+ end
136
+
137
+ # false and nil both mean "the application resolved no principal";
138
+ # anything that cannot state a stable identity is unobservable rather
139
+ # than silently reported as some principal.
140
+ def observed(value, source)
141
+ return Observation.new(principal: nil, source: source, error: nil) if value.nil? || value == false
142
+
143
+ id = identifier_of(value)
144
+ return unobservable("the observed principal (#{value.class}) has no usable id", source: source) if id.nil?
145
+
146
+ Observation.new(principal: ObservedPrincipal.new(model_name: model_name_for(value.class), id: id),
147
+ source: source, error: nil)
148
+ rescue StandardError => e
149
+ unobservable("describing the observed principal raised #{e.class}: #{e.message}", source: source)
150
+ end
151
+
152
+ def identifier_of(value)
153
+ value.respond_to?(:id) ? value.id : nil
154
+ end
155
+
156
+ def warden_proxy(env)
157
+ proxy = env["warden"]
158
+ proxy if proxy.respond_to?(:user)
159
+ end
160
+
161
+ def unobservable(message, source: nil)
162
+ Observation.new(principal: nil, source: source, error: message)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -49,6 +49,22 @@ module Karst
49
49
  defined?(Warden::Manager) && Warden::Manager.respond_to?(:on_request)
50
50
  end
51
51
 
52
+ # Drops any principal this thread queued but never consumed -- the
53
+ # exact state an anonymous probe must not inherit (see
54
+ # Access::IdentityProbe). A queued principal survives only until the
55
+ # next Warden request on this thread, so a probe whose request never
56
+ # reached Warden (construction failure, an exception before dispatch)
57
+ # would otherwise leave one behind for whichever probe ran next.
58
+ def discard_pending!
59
+ Karst::ExecutionContext.delete(PENDING_PRINCIPAL_KEY)
60
+ end
61
+
62
+ # The queued sign-in runs inside the probed request, so it is marked
63
+ # as identity setup (Identity.establishing): nothing it or the
64
+ # application's own after_set_user hooks do -- a Devise :trackable
65
+ # UPDATE, say -- is evidence about the probed route. If it raises,
66
+ # the route never runs, and EstablishmentError says so rather than
67
+ # letting the failure pass for the route's own exception.
52
68
  def install_hook!
53
69
  return if @installed
54
70
 
@@ -57,9 +73,18 @@ module Karst
57
73
  pending = Karst::ExecutionContext.delete(PENDING_PRINCIPAL_KEY)
58
74
  next unless pending
59
75
 
60
- principal, scope = pending
76
+ apply_pending(proxy, *pending)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def apply_pending(proxy, principal, scope)
83
+ Karst::Identity.establishing do
61
84
  scope ? proxy.set_user(principal, scope: scope) : proxy.set_user(principal)
62
85
  end
86
+ rescue StandardError => e
87
+ raise Karst::Identity::EstablishmentError, "#{e.class}: #{e.message}"
63
88
  end
64
89
  end
65
90