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
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../karst"
5
+ require_relative "identity_serialization"
6
+ require_relative "principal_reference"
7
+ require_relative "../reproduction/exercise"
8
+ require_relative "../reproduction/curl"
9
+
10
+ module Karst
11
+ module CLI
12
+ # The one adapter over Karst::Reproduction::Exercise, shared by
13
+ # `bin/rails karst:reproduce`, the MCP reproduce_request tool, and the
14
+ # /karst panel -- exactly as Karst::CLI::Verification is shared by
15
+ # `karst:verify` and verify_access. Nothing downstream re-runs an
16
+ # Exercise or re-serializes an Observation, so the recipe an agent reads
17
+ # and the recipe a developer reads cannot drift apart.
18
+ #
19
+ # This class owns two decisions Exercise deliberately does not: which
20
+ # identity to send the request under (the first candidate Karst's own
21
+ # sampler offers, or a genuine anonymous probe), and how an Observation
22
+ # becomes a stable evidence document.
23
+ # rubocop:disable Metrics/ClassLength
24
+ class Reproduction
25
+ include IdentitySerialization
26
+
27
+ # 3 (was 2): `execution` gained controller_completed, exception_phase,
28
+ # and rendered -- additive -- but `response` (status/content_type/
29
+ # redirect) also stopped trusting session.request/session.response
30
+ # once a target request raised before producing one. Those fields
31
+ # could previously read back an *earlier* request's status, content
32
+ # type, or location on the same reproduction session (identity
33
+ # establishment's own request, most often); they now report nil, named
34
+ # in `unobserved`, in exactly that situation. This is a correctness
35
+ # fix, not a new failure mode: any consumer reading `response.status`
36
+ # after `execution.exception_class` was already set was reading a
37
+ # value Karst should never have reported.
38
+ SCHEMA_VERSION = 3
39
+
40
+ # Same-connection rollback contains database writes and nothing else.
41
+ # Named in the document rather than left implicit, so an agent
42
+ # reasoning about whether to issue a mutating request sees the real
43
+ # boundary instead of assuming "isolated" means isolated.
44
+ NOT_ISOLATED = ["background jobs", "mail", "outbound HTTP", "files", "other database connections"].freeze
45
+
46
+ # `as`, when given, is a human-only "MODEL:ID" reference (e.g.
47
+ # "User:72") naming one existing principal to send this one request as,
48
+ # instead of the ordinary sampled candidate -- see
49
+ # Karst::CLI::PrincipalReference. Resolved exclusively through
50
+ # Karst::Identity.resolve, so it can never select a record outside a
51
+ # configured principal source. Mutually exclusive with `anonymous`;
52
+ # nothing about this reaches the MCP reproduce_request tool, which
53
+ # never accepts it.
54
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
55
+ def initialize(path:, http_method: "GET", body: nil, content_type: nil, headers: {},
56
+ anonymous: false, as: nil, base_url: nil, output: $stdout, json: false)
57
+ PrincipalReference.parse(as) if as
58
+ raise ArgumentError, "--anonymous and --as cannot be combined" if anonymous && as
59
+
60
+ @path = path
61
+ @http_method = http_method
62
+ @body = body
63
+ @content_type = content_type
64
+ @headers = headers || {}
65
+ @anonymous = anonymous
66
+ @as = as
67
+ @base_url = base_url.to_s.empty? ? Karst::Reproduction::Curl::DEFAULT_BASE_URL : base_url.to_s
68
+ @output = output
69
+ @json = json
70
+ end
71
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
72
+
73
+ def call
74
+ observation = run
75
+ @output.puts(@json ? JSON.generate(document(observation)) : human(observation))
76
+ observation.exception_class ? 1 : 0
77
+ rescue Access::Error, Identity::Error, ArgumentError => e
78
+ @output.puts(@json ? JSON.generate(error_document(e)) : "Karst cannot reproduce this request:\n#{e.message}")
79
+ 2
80
+ end
81
+
82
+ # The stable evidence document, with no dependency on @output. Always
83
+ # returns a Hash -- either the recipe or error_document(e) -- so no
84
+ # adapter has to decide how a Karst failure becomes a transport error.
85
+ def evidence
86
+ document(run)
87
+ rescue Access::Error, Identity::Error, ArgumentError => e
88
+ error_document(e)
89
+ end
90
+
91
+ private
92
+
93
+ # rubocop:disable Metrics/MethodLength
94
+ def run
95
+ @identity_reason = nil
96
+ principal = if @anonymous
97
+ Identity::ANONYMOUS
98
+ elsif @as
99
+ PrincipalReference.resolve(@as)
100
+ else
101
+ sampled_principal
102
+ end
103
+ Karst::Reproduction::Exercise.new(
104
+ path: @path, http_method: @http_method, body: @body, content_type: @content_type,
105
+ headers: @headers, principal: principal
106
+ ).call
107
+ end
108
+ # rubocop:enable Metrics/MethodLength
109
+
110
+ # One candidate, not a sample: reproduction issues exactly one request,
111
+ # so asking the sampler for more than one would query for records
112
+ # Karst has already decided it will never send. Falling back to
113
+ # Identity::ANONYMOUS (rather than a bare nil) when no candidate is
114
+ # available means this still runs, and is still confirmed, as a real
115
+ # anonymous probe -- never a request whose identity Karst simply never
116
+ # got around to establishing.
117
+ def sampled_principal
118
+ selection = Access::PrincipalSelection.new(sources: Identity.principal_sources, limit: 1).call
119
+ principal = selection.principals.first
120
+ return principal if principal
121
+
122
+ @identity_reason = "no principal was available from the configured principal source"
123
+ Identity::ANONYMOUS
124
+ rescue Identity::Error => e
125
+ @identity_reason = e.message
126
+ Identity::ANONYMOUS
127
+ end
128
+
129
+ # rubocop:disable Metrics/MethodLength
130
+ def document(observation)
131
+ {
132
+ schema_version: SCHEMA_VERSION,
133
+ request: request(observation),
134
+ identity: identity(observation),
135
+ execution: execution(observation),
136
+ response: response(observation),
137
+ unobserved: observation.unobserved,
138
+ isolation: { database: "same_connection_rollback_attempted", not_isolated: NOT_ISOLATED },
139
+ reproduce: { curl: curl(observation) },
140
+ summary: { elapsed_ms: observation.elapsed_ms }
141
+ }
142
+ end
143
+ # rubocop:enable Metrics/MethodLength
144
+
145
+ def request(observation)
146
+ {
147
+ method: observation.http_method, path: observation.url_path,
148
+ url: Karst::Reproduction::Curl.url(observation, @base_url),
149
+ query_params: observation.query_params, route_params: observation.route_params,
150
+ content_type: observation.content_type, body_format: observation.body_representation.to_s,
151
+ body: (observation.body? ? observation.body_params : nil), headers: observation.headers
152
+ }
153
+ end
154
+
155
+ # The same requested/observed/confirmation document verify_access
156
+ # produces (see Karst::CLI::IdentitySerialization), plus `reason`: why
157
+ # Karst ran this request under no identity at all, when that was not
158
+ # what the caller asked for (an anonymous request the caller
159
+ # deliberately chose carries no reason -- there is nothing to explain).
160
+ def identity(observation)
161
+ identity_document(observation.identity).merge(reason: @identity_reason)
162
+ end
163
+
164
+ def execution(observation)
165
+ {
166
+ controller: observation.controller, action: observation.action,
167
+ controller_completed: observation.controller_completed,
168
+ halted_callback: observation.halted_callback, exception_class: observation.exception_class,
169
+ exception_phase: observation.exception_phase, rendered: observation.rendered,
170
+ writes_observed: observation.writes_observed, write_count: observation.write_count,
171
+ database_rollback_attempted: observation.database_rollback_attempted
172
+ }
173
+ end
174
+
175
+ def response(observation)
176
+ { status: observation.status, content_type: observation.response_content_type,
177
+ redirect: observation.redirect }
178
+ end
179
+
180
+ def curl(observation)
181
+ Karst::Reproduction::Curl.render(observation, base_url: @base_url)
182
+ end
183
+
184
+ # observation_error: Karst could not observe the request it ran, so it
185
+ # reports nothing about the application rather than partial evidence.
186
+ def error_document(error)
187
+ type = if error.is_a?(Identity::Error) then "configuration_error"
188
+ elsif error.is_a?(Access::ObservationError) then "observation_error"
189
+ else "input_error"
190
+ end
191
+ { schema_version: SCHEMA_VERSION, error: { type: type, message: error.message } }
192
+ end
193
+
194
+ def human(observation)
195
+ lines = ["Karst request reproduction", "", "Request",
196
+ " #{observation.http_method} #{observation.url_path}"]
197
+ append_identity(lines, observation)
198
+ append_execution(lines, observation)
199
+ append_response(lines, observation)
200
+ append_effects(lines, observation)
201
+ append_unobserved(lines, observation)
202
+ lines.push("", "Reproduce", curl(observation).lines.map { |line| " #{line}" }.join.rstrip)
203
+ lines.join("\n")
204
+ end
205
+
206
+ def append_identity(lines, observation)
207
+ lines.push("", "Identity", " #{identity_line(observation.identity)}")
208
+ lines << " (#{@identity_reason})" if @identity_reason
209
+ lines << " Karst assumed this identity directly; it did not exercise how an external " \
210
+ "client would authenticate this endpoint."
211
+ end
212
+
213
+ def append_execution(lines, observation)
214
+ lines.push("", "Observed execution")
215
+ lines << if observation.controller
216
+ " #{observation.controller}##{observation.action}"
217
+ else
218
+ " no controller dispatched"
219
+ end
220
+ append_execution_detail(lines, observation)
221
+ append_rendered(lines, observation.rendered)
222
+ end
223
+
224
+ def append_execution_detail(lines, observation)
225
+ lines << " controller completed: #{observation.controller_completed}" \
226
+ unless observation.controller_completed.nil?
227
+ lines << " halted at #{observation.halted_callback}" if observation.halted_callback
228
+ lines << " exception #{observation.exception_class} (during #{observation.exception_phase})" \
229
+ if observation.exception_class
230
+ end
231
+
232
+ def append_rendered(lines, rendered)
233
+ return if rendered.empty?
234
+
235
+ lines << " rendered:"
236
+ rendered.each do |template|
237
+ lines << " #{template[:virtual_path]}#{' (raised)' unless template[:completed]}"
238
+ end
239
+ end
240
+
241
+ def append_response(lines, observation)
242
+ lines.push("", "Observed response")
243
+ type = observation.response_content_type
244
+ lines << if observation.status
245
+ " #{observation.status}#{" #{type}" if type}"
246
+ else
247
+ " no response observed"
248
+ end
249
+ lines << " redirect #{observation.redirect}" if observation.redirect
250
+ end
251
+
252
+ def append_effects(lines, observation)
253
+ lines.push("", "Observed effects",
254
+ " #{observation.write_count} database #{observation.write_count == 1 ? 'write' : 'writes'} " \
255
+ "(rollback attempted on the same connection)")
256
+ lines << " Not isolated: #{NOT_ISOLATED.join(', ')}."
257
+ end
258
+
259
+ def append_unobserved(lines, observation)
260
+ return if observation.unobserved.empty?
261
+
262
+ lines.push("", "Not observed", " #{observation.unobserved.join(', ')}")
263
+ end
264
+ end
265
+ # rubocop:enable Metrics/ClassLength
266
+ end
267
+ end
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "time"
4
5
  require_relative "../../karst"
6
+ require_relative "identity_serialization"
7
+ require_relative "principal_reference"
8
+ require_relative "outcome_summary"
9
+ require_relative "../access/outcome_groups"
5
10
 
6
11
  module Karst
7
12
  module CLI
@@ -12,14 +17,53 @@ module Karst
12
17
  # place, keeping the versioned contract auditable.
13
18
  # rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
14
19
  class Verification
15
- SCHEMA_VERSION = 1
20
+ include IdentitySerialization
16
21
 
17
- def initialize(path:, http_method: "GET", output: $stdout, json: false)
22
+ # 2 (was 1): every principal field is now explicitly either a
23
+ # *requested* identity (what Karst was asked to run as) or an
24
+ # *observed* one (what the application itself resolved at runtime),
25
+ # and each outcome carries an `identity` document saying whether the
26
+ # two agree. The ambiguous v1 `principal`/`verified_principal` keys are
27
+ # gone rather than renamed in place: a consumer reading "principal" and
28
+ # believing it described the request that actually ran is precisely the
29
+ # false attribution this schema exists to make impossible.
30
+ #
31
+ # 3 (was 2): `outcomes` groups are now semantic -- keyed by what the
32
+ # request observed (see Access::OutcomeGroups), never by per-probe
33
+ # timing -- so equivalent outcomes from several users form one group
34
+ # instead of one group per user. A group therefore no longer carries
35
+ # `elapsed_ms` (it had no single value once groups held more than one
36
+ # probe; `summary.elapsed_ms` and `verified_outcome.elapsed_ms` are
37
+ # unchanged), gains `verified_usable` (the configured policy's verdict,
38
+ # shared by every member), and groups are ordered largest first.
39
+ SCHEMA_VERSION = 3
40
+
41
+ # A deliberate identity-free probe: no principal is established, and
42
+ # the application is expected to resolve none. Requires no principal
43
+ # source at all, so a route can be probed anonymously in an application
44
+ # Karst could not otherwise test.
45
+ ANONYMOUS = :anonymous
46
+
47
+ # `as`, when given, is a human-only "MODEL:ID" reference (e.g.
48
+ # "User:72") naming one existing principal to run this probe as
49
+ # instead of sampling one -- see Karst::CLI::PrincipalReference. It is
50
+ # resolved exclusively through Karst::Identity.resolve, so it can never
51
+ # select a record outside a configured principal source. Mutually
52
+ # exclusive with `identity: "anonymous"`; nothing about this reaches
53
+ # the MCP verify_access tool, which never accepts it.
54
+ # rubocop:disable Metrics/ParameterLists
55
+ def initialize(path:, http_method: "GET", output: $stdout, json: false, identity: nil, as: nil)
18
56
  @path = path
19
57
  @http_method = http_method
20
58
  @output = output
21
59
  @json = json
60
+ @identity = normalize_identity(identity)
61
+ PrincipalReference.parse(as) if as
62
+ raise ArgumentError, "--anonymous and --as cannot be combined" if anonymous? && as
63
+
64
+ @as = as
22
65
  end
66
+ # rubocop:enable Metrics/ParameterLists
23
67
 
24
68
  def call
25
69
  result = run_search
@@ -44,13 +88,64 @@ module Karst
44
88
 
45
89
  private
46
90
 
91
+ def normalize_identity(value)
92
+ return nil if value.nil?
93
+ return ANONYMOUS if value.to_s == ANONYMOUS.to_s
94
+
95
+ raise ArgumentError, "identity must be \"anonymous\" when given"
96
+ end
97
+
98
+ def anonymous?
99
+ @identity == ANONYMOUS
100
+ end
101
+
102
+ # An additive third value alongside the documented "anonymous" and
103
+ # "application_identities": a --as probe is still a real application
104
+ # identity, just a human-selected one rather than one Karst sampled --
105
+ # a distinction worth reporting honestly rather than folding into
106
+ # "application_identities" and implying this result came from the
107
+ # ordinary search.
108
+ def probe_identity
109
+ return "anonymous" if anonymous?
110
+ return "specific_principal" if @as
111
+
112
+ "application_identities"
113
+ end
114
+
115
+ # One Identity::Snapshot for the whole operation: readiness, --as
116
+ # resolution, and the search itself all read the same single
117
+ # resolution of the effective principal sources.
47
118
  def run_search
48
- validate_setup!
49
- Access::Search.new(path: @path, http_method: @http_method, sources: Identity.principal_sources).call
119
+ return anonymous_search if anonymous?
120
+
121
+ snapshot = Identity.snapshot
122
+ validate_setup!(snapshot.setup_state)
123
+ return as_search(snapshot) if @as
124
+
125
+ Access::Search.new(path: @path, http_method: @http_method, sources: snapshot.principal_sources!).call
126
+ end
127
+
128
+ # One request against exactly the requested principal, never a sample
129
+ # and never a population retry: --as means "run as this one existing
130
+ # record," not "start a search that happens to prefer it." Wrapped in
131
+ # Access::Search::Result (with no attempts) purely so every downstream
132
+ # adapter method below stays the same for every probe kind.
133
+ def as_search(snapshot)
134
+ principal = PrincipalReference.resolve(@as, sources: snapshot.principal_sources!)
135
+ sweep = Access::Sweep.new(path: @path, http_method: @http_method, principals: [principal], limit: 1).call
136
+ Access::Search::Result.new(initial: sweep, attempts: [].freeze)
50
137
  end
51
138
 
52
- def validate_setup!
53
- state = Identity.setup_state
139
+ # One request, no principal source consulted and none required: the
140
+ # whole point is that nothing is authenticated. Reuses Search::Result so
141
+ # every adapter below stays identical for both probe kinds.
142
+ def anonymous_search
143
+ sweep = Access::Sweep.new(path: @path, http_method: @http_method,
144
+ principals: [Identity::ANONYMOUS], limit: 1).call
145
+ Access::Search::Result.new(initial: sweep, attempts: [].freeze)
146
+ end
147
+
148
+ def validate_setup!(state)
54
149
  return if state.status.to_s.start_with?("ready_")
55
150
 
56
151
  message = state.message || "no principal source is configured"
@@ -58,13 +153,16 @@ module Karst
58
153
  end
59
154
 
60
155
  def document(result)
156
+ @groups = {}.compare_by_identity
61
157
  winner = result.verified_outcome
62
158
  {
63
159
  schema_version: SCHEMA_VERSION,
64
160
  request: { method: result.http_method, path: result.path },
161
+ probe: { identity: probe_identity },
162
+ provenance: provenance,
65
163
  verified_usable: !winner.nil?,
66
- verified_principal: winner && principal(winner.principal),
67
- verified_outcome: winner && outcome(winner, include_principal: false),
164
+ verified_identity: winner && identity_document(winner.identity),
165
+ verified_outcome: winner && outcome(winner),
68
166
  source: result.verified_source,
69
167
  sample: sweep(result.initial),
70
168
  populations: result.attempts.map { |attempt| population(attempt) },
@@ -72,86 +170,146 @@ module Karst
72
170
  }
73
171
  end
74
172
 
173
+ # What execution produced these observations. Deliberately cheap and
174
+ # certain: versions, environment, and when the probe ran. It does not
175
+ # yet carry an application source digest/commit -- a consumer that must
176
+ # know the evidence still matches the code it is reasoning about needs
177
+ # freshness machinery this adapter does not own.
178
+ def provenance
179
+ {
180
+ karst_version: Karst::VERSION,
181
+ rails_version: defined?(Rails::VERSION::STRING) ? Rails::VERSION::STRING : nil,
182
+ rails_env: defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.to_s : nil,
183
+ ruby_version: RUBY_VERSION,
184
+ observed_at: Time.now.utc.iso8601
185
+ }
186
+ end
187
+
75
188
  def sweep(result)
189
+ groups = groups_for(result)
76
190
  {
77
191
  candidate_pool_size: result.candidate_pool_size,
78
192
  users_tested: result.outcomes.size,
79
- verified_usable: result.outcomes.any? { |item| Karst.config.usable_access_outcome.call(item) },
193
+ verified_usable: groups.any?(&:usable),
80
194
  database_isolation: result.database_isolation.to_s,
81
- outcomes: grouped_outcomes(result.outcomes)
195
+ outcomes: grouped_outcomes(groups)
82
196
  }
83
197
  end
84
198
 
199
+ # Semantic groups for one stage's sweep result, computed once per
200
+ # document so the usable policy runs once per outcome here.
201
+ def groups_for(sweep_result)
202
+ @groups ||= {}.compare_by_identity
203
+ @groups[sweep_result] ||= Access::OutcomeGroups.group(sweep_result.outcomes)
204
+ end
205
+
85
206
  def population(attempt)
86
207
  data = { name: attempt.name.to_s, source: attempt.source_name.to_s, state: attempt.state.to_s }
87
208
  data[:reason] = attempt.error if attempt.error
88
209
  return data unless attempt.result
89
210
 
90
- data.merge(users_tested: attempt.result.outcomes.size, outcomes: grouped_outcomes(attempt.result.outcomes))
211
+ data.merge(users_tested: attempt.result.outcomes.size, outcomes: grouped_outcomes(groups_for(attempt.result)))
91
212
  end
92
213
 
93
- def grouped_outcomes(outcomes)
94
- outcomes.group_by { |item| outcome(item, include_principal: false) }.map do |evidence, items|
95
- evidence.merge(count: items.size, principals: items.map { |item| principal(item.principal) })
214
+ # Outcomes that observed the same thing (see Access::OutcomeGroups) are
215
+ # reported once, with every probe's own identity evidence listed under
216
+ # it -- so "three requests halted at authorize_admin" never flattens
217
+ # into one claim about who made them. Per-probe timing belongs to the
218
+ # probe, not the outcome, so a group carries none.
219
+ def grouped_outcomes(groups)
220
+ groups.map do |group|
221
+ evidence = outcome(group.representative).tap { |fields| fields.delete(:elapsed_ms) }
222
+ evidence.merge(verified_usable: group.usable, count: group.count,
223
+ identities: group.outcomes.map { |item| identity_document(item.identity) })
96
224
  end
97
225
  end
98
226
 
99
- def outcome(item, include_principal: true)
100
- data = {
227
+ def outcome(item)
228
+ {
101
229
  status: item.status, redirect: item.redirect, exception_class: item.exception_class,
102
230
  halted_callback: item.halted_callback&.to_s, writes_observed: item.writes_observed,
103
231
  write_count: item.write_count, database_rollback_attempted: item.database_rollback_attempted,
104
- elapsed_ms: item.elapsed_ms
232
+ elapsed_ms: item.elapsed_ms, controller: item.controller, action: item.action
105
233
  }
106
- data[:principal] = principal(item.principal) if include_principal
107
- data
108
- end
109
-
110
- def principal(value)
111
- # JSON is also the MCP contract. Framework-inferred login identifiers
112
- # must never cross that machine-readable boundary. An application-
113
- # authored principal_label remains explicit configuration and keeps
114
- # its longstanding serialization behavior.
115
- label = if Karst.config.principal_label
116
- value.display_label.to_s
117
- else
118
- "#{value.model_name} ##{value.id}"
119
- end
120
- { model: value.model_name.to_s, id: primitive_id(value.id), label: label }
121
- end
122
-
123
- def primitive_id(value)
124
- value.is_a?(Integer) ? value : value.to_s
125
234
  end
126
235
 
236
+ # observation_error: Karst could not observe the request it ran, so it
237
+ # reports nothing about the application rather than partial evidence.
127
238
  def error_document(error)
128
- type = error.is_a?(Identity::Error) ? "configuration_error" : "input_error"
239
+ type = if error.is_a?(Identity::Error) then "configuration_error"
240
+ elsif error.is_a?(Access::ObservationError) then "observation_error"
241
+ else "input_error"
242
+ end
129
243
  { schema_version: SCHEMA_VERSION, error: { type: type, message: error.message } }
130
244
  end
131
245
 
132
246
  def human(result)
133
- lines = ["Karst verification", "", "#{result.http_method} #{result.path}", "", "Sample",
134
- " #{result.initial.outcomes.size} users tested",
135
- " #{sample_usable_count(result)} verified usable"]
136
- append_key_evidence(lines, result.initial.outcomes)
247
+ @groups = {}.compare_by_identity
248
+ lines = ["Karst verification", "", "#{result.http_method} #{result.path}",
249
+ "Probe identity: #{probe_identity_description}", "",
250
+ "#{probe_section_heading}: #{stage_counts(result.initial)}"]
251
+ append_outcomes(lines, result.initial, indent: " ")
137
252
  append_populations(lines, result)
138
253
  append_result(lines, result)
139
254
  lines.join("\n")
140
255
  end
141
256
 
142
- def sample_usable_count(result)
143
- result.initial.outcomes.count { |item| Karst.config.usable_access_outcome.call(item) }
257
+ def probe_identity_description
258
+ return "anonymous" if anonymous?
259
+ return "the specific principal requested via --as" if @as
260
+
261
+ "the application's own identities"
262
+ end
263
+
264
+ def probe_section_heading
265
+ return "Probe" if anonymous?
266
+ return "Requested" if @as
267
+
268
+ "Sample"
269
+ end
270
+
271
+ # How many were probed, and -- for a principal probe -- how many of
272
+ # them the configured usable policy accepted. An anonymous probe is a
273
+ # request, not a user, and has no "verified usable user" to count.
274
+ def stage_counts(sweep_result)
275
+ size = sweep_result.outcomes.size
276
+ return "#{size} #{size == 1 ? 'request' : 'requests'}" if anonymous?
277
+
278
+ usable = groups_for(sweep_result).select(&:usable).sum(&:count)
279
+ "#{size} #{size == 1 ? 'user' : 'users'} tested, #{usable} verified usable"
280
+ end
281
+
282
+ # The bounded outcome summary (see CLI::OutcomeSummary), then identity
283
+ # and write evidence for the whole stage -- never just its first probe.
284
+ def append_outcomes(lines, sweep_result, indent:)
285
+ outcomes = sweep_result.outcomes
286
+ return if outcomes.empty?
287
+
288
+ lines.concat(OutcomeSummary.new(groups_for(sweep_result), indent: indent).lines)
289
+ append_identity_evidence(lines, outcomes, indent)
290
+ append_write_evidence(lines, outcomes, indent)
144
291
  end
145
292
 
146
- def append_key_evidence(lines, outcomes)
147
- evidence = outcomes.first
148
- return unless evidence
293
+ # One probe: its full identity evidence line. Several: how many probes
294
+ # reached each identity confirmation state, so an unconfirmed identity
295
+ # is visible even when its outcome matches everyone else's.
296
+ def append_identity_evidence(lines, outcomes, indent)
297
+ if outcomes.size == 1
298
+ lines << "#{indent}#{identity_line(outcomes.first.identity)}" if outcomes.first.identity
299
+ return
300
+ end
149
301
 
150
- lines << " status #{evidence.status}" if evidence.status
151
- lines << " redirect #{evidence.redirect}" if evidence.redirect
152
- lines << " halted at #{evidence.halted_callback}" if evidence.halted_callback
153
- lines << " exception #{evidence.exception_class}" if evidence.exception_class
154
- lines << " WARNING: #{evidence.write_count} writes observed" if evidence.writes_observed
302
+ tally = outcomes.map { |item| item.identity ? item.identity.confirmation.to_s : "not recorded" }.tally
303
+ states = tally.sort_by { |state, count| [-count, state] }.map { |state, count| "#{count} #{state}" }
304
+ lines << "#{indent}identity: #{states.join(', ')}"
305
+ end
306
+
307
+ def append_write_evidence(lines, outcomes, indent)
308
+ writing = outcomes.count(&:writes_observed)
309
+ return if writing.zero?
310
+
311
+ lines << "#{indent}WARNING: database writes observed in #{writing} of #{outcomes.size} " \
312
+ "#{outcomes.size == 1 ? 'request' : 'requests'} (rollback attempted on the same connection)"
155
313
  end
156
314
 
157
315
  def append_populations(lines, result)
@@ -161,21 +319,27 @@ module Karst
161
319
  result.attempts.each do |attempt|
162
320
  count = attempt.result&.outcomes&.size || 0
163
321
  lines << " #{attempt.name}: #{attempt.state} (#{count} users tested)"
164
- append_key_evidence(lines, attempt.result.outcomes) if attempt.result
322
+ append_outcomes(lines, attempt.result, indent: " ") if attempt.result
165
323
  end
166
324
  end
167
325
 
168
326
  def append_result(lines, result)
169
327
  lines.push("", "Result")
170
- if result.verified_outcome
171
- lines << " verified usable user: #{result.verified_outcome.principal.display_label}"
328
+ winner = result.verified_outcome
329
+ if winner
330
+ lines << " verified usable: #{winner_label(winner)}"
331
+ lines << " #{identity_line(winner.identity)}" if winner.identity
172
332
  source = result.verified_source
173
333
  lines << " source: #{source[:type]}#{"=#{source[:name]}" if source[:name]}"
174
334
  else
175
- lines << " no verified usable user found"
335
+ lines << (anonymous? ? " not usable anonymously" : " no verified usable user found")
176
336
  end
177
337
  lines << " #{result.request_count} requests in #{result.elapsed_ms} ms"
178
338
  end
339
+
340
+ def winner_label(winner)
341
+ winner.principal ? winner.principal.display_label : "anonymous request"
342
+ end
179
343
  end
180
344
  # rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
181
345
  end