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,242 @@
1
+ # Request reproduction
2
+
3
+ > Something calls this Rails endpoint. What request do I send to exercise the same behavior?
4
+
5
+ That is the whole question this answers. A Jira story says an external system
6
+ `POST`s to `/api/v1/inspections`; the endpoint exists, someone else built it,
7
+ and reproducing it locally normally means reading routes, controllers,
8
+ `before_action` filters, and old specs until you can guess a working request.
9
+
10
+ Karst answers it the same way it answers every other question: by running the
11
+ real application and reporting what actually happened.
12
+
13
+ ## What it is
14
+
15
+ You give Karst a method, a path, and (for a body-carrying method) a body.
16
+ Karst issues **exactly one** request through the real Rails stack, inside a
17
+ rolled-back database transaction, and reports:
18
+
19
+ - the controller and action that actually dispatched
20
+ - whether the controller lifecycle *completed* (`controller_completed`) --
21
+ distinct from dispatching: a controller can dispatch and still raise
22
+ - the route parameters the router actually bound
23
+ - the halted callback, if a `before_action` stopped the request
24
+ - any raised exception, and the most specific phase Karst can prove it
25
+ happened in -- `controller`, `render`, or `unknown`
26
+ - which templates, partials, and layouts were observed rendering (or
27
+ raising), by structural virtual path only -- never their content
28
+ - the response status and content type
29
+ - how many database writes the request made
30
+ - a **cURL command for exactly what Karst sent**
31
+
32
+ Every one of these is scoped strictly to the one request you asked for. If it
33
+ raised before producing a response, the response fields are `null` and named
34
+ in `unobserved` -- never a status, content type, or redirect left over from an
35
+ earlier request Karst ran on the same session (identity establishment, most
36
+ often).
37
+
38
+ Then you iterate. A first attempt that halts at `authenticate_api_key!` has
39
+ told you the endpoint's real gate. Add the header, send again, get a `201`,
40
+ and copy the cURL — which is now a command you have watched work, not one
41
+ assembled from reading code.
42
+
43
+ ## This is observed behavior, not documentation
44
+
45
+ Karst observes the requests it issues. It does not record your application's
46
+ production traffic, and it never infers a request from routes, controller
47
+ source, or strong-parameter declarations.
48
+
49
+ The practical consequence: Karst can tell you **whether the request you
50
+ described works, and what it did**, but it cannot invent a body it has never
51
+ seen. What it removes is the two genuinely hard parts — knowing whether a
52
+ request works, and knowing what stopped it when it doesn't.
53
+
54
+ A field Karst could not observe is `null` and is named in `unobserved`, never
55
+ filled in with a plausible-looking value. If the path did not route, Karst
56
+ reports no controller rather than guessing one.
57
+
58
+ ### What Karst deliberately does not capture
59
+
60
+ - **Your application's real traffic.** Karst installs no request recorder and
61
+ keeps no buffer of past requests. There is nothing to leak later, and
62
+ nothing running on an ordinary request.
63
+ - **How an external client authenticates.** Karst can sign in as one of your
64
+ own existing users, and it reports the callback that halted a request — but
65
+ it never claims to know that an endpoint "uses an API key". The halted
66
+ callback's name is evidence; anything beyond it would be a guess.
67
+ - **Response bodies.** Karst reports the response status and content type,
68
+ never the body.
69
+
70
+ ## Secrets
71
+
72
+ A generated request that makes you fill in a credential is better than one
73
+ that leaks one, so Karst always chooses the former.
74
+
75
+ - **Parameters** pass through your application's own
76
+ `config.filter_parameters`, using Rails' own `ActiveSupport::ParameterFilter`
77
+ — the same rules, including nested hashes and Procs, that already keep those
78
+ values out of your logs. Karst then applies one additional, conservative
79
+ credential-name filter on top (`token`, `secret`, `api_key`, `password`,
80
+ `signature`, `session`, ...), because an application's `filter_parameters` is
81
+ tuned for its own logs and routinely misses credentials that only appear in a
82
+ third-party integration's payload.
83
+ - **Headers** are handled by name, never by value. `Authorization`, `Cookie`,
84
+ `X-Api-Key`, `X-CSRF-Token` and friends become `<AUTH_TOKEN>`,
85
+ `<SESSION_COOKIE>`, `<API_KEY>`, `<CSRF_TOKEN>`. Their real values are never
86
+ read, compared, or echoed. Headers that describe a request rather than
87
+ authorize it (`Content-Type`, `Accept`, ...) are shown as sent; anything else
88
+ whose name looks credential-shaped becomes `<FILTERED>`.
89
+ - **Referer and Origin are URLs, so name-based header handling isn't enough
90
+ for them.** A `Referer` routinely carries the query string of whatever page
91
+ linked here — `?token=`, `?code=`, `?api_key=` — and, for an OAuth-style
92
+ redirect, a fragment such as `#access_token=`. Karst keeps the scheme, host,
93
+ and path (useful context: "this came from the password-reset page") and
94
+ discards the query, fragment, and any userinfo — structurally, by parsing
95
+ the header as a URL, never by inspecting the value for what looks like a
96
+ credential. A value that does not parse as a URL at all is shown as
97
+ `<FILTERED>` rather than echoed.
98
+ - **Credentials in the path** — a password-reset token, a signed id — are
99
+ substituted back out of the URL too, so `/reset/s3cret` becomes
100
+ `/reset/<TOKEN>` in both the recipe and the cURL command.
101
+ - **A redirect Karst observes** has its query string and fragment stripped
102
+ the same way, for the same reason: an OAuth-style callback's `Location`
103
+ can carry `?code=` or `#access_token=` every bit as much as a `Referer`
104
+ can.
105
+ - **A body Karst cannot parse** (anything but JSON or form encoding) is sent
106
+ as given but never echoed back; the command shows `<BODY>`.
107
+ - **Nothing is persisted.** Karst writes no recipe, request, or credential to
108
+ disk, and `/karst` is served `no-store`.
109
+
110
+ One deliberate exception, for usability: the `/karst` form re-fills the body
111
+ and header boxes with what you just typed, so you can edit and resend. That is
112
+ ordinary form behavior on your own machine — but it does mean a credential you
113
+ paste there is visible on that page until you navigate away. The **recipe**
114
+ Karst generates, which is the part you copy into a ticket, never contains one.
115
+
116
+ ## Reproducing a request
117
+
118
+ ### At `/karst`
119
+
120
+ Open `/karst`, pick a URL, and expand **Reproduce request**. Fill in method,
121
+ path, content type, body, and any headers, then press **Send once and build
122
+ request**. The recipe appears below, ending in a copyable cURL command built
123
+ against your own development origin.
124
+
125
+ ### From the shell
126
+
127
+ ```bash
128
+ bin/rails karst:reproduce POST /api/v1/inspections \
129
+ --content-type application/json \
130
+ --body '{"serial_number":"ABC123","status":"passed"}' \
131
+ --header 'Authorization: Bearer real-key'
132
+ ```
133
+
134
+ Add `--json` for the same schema-versioned evidence document the MCP tool
135
+ returns. `--anonymous` sends without assuming any identity. `--as MODEL:ID`
136
+ (e.g. `--as User:72`) sends as one specific existing record instead of the
137
+ ordinary sampled one, resolved only through Karst's own configured principal
138
+ source — see [Running as a specific principal](advanced-configuration.md#running-as-a-specific-principal)
139
+ in the advanced configuration docs. `--as` is CLI-only and cannot be combined
140
+ with `--anonymous`. Exit code `0` means Karst observed a response, `1` means
141
+ the request raised, `2` means Karst could not issue it at all.
142
+
143
+ ### From a coding agent
144
+
145
+ The MCP server exposes `reproduce_request(path:, method:, body:, content_type:,
146
+ headers:, anonymous:, base_url:)`. It returns the same document as `--json`:
147
+ request, identity, execution, response, `unobserved`, isolation, and the cURL
148
+ command.
149
+
150
+ MCP reproduction permits only `GET` and `HEAD` by default. `POST`, `PUT`,
151
+ `PATCH`, and `DELETE` fail with a structured `method_disabled` error before
152
+ Karst constructs or executes an application request. A host application can
153
+ make those methods available to MCP explicitly:
154
+
155
+ ```ruby
156
+ Karst.configure { |config| config.mcp_mutating_requests = true }
157
+ ```
158
+
159
+ That opt-in applies only to the MCP surface. Human-driven reproduction through
160
+ `bin/rails karst:reproduce` and the `/karst` reproduction form continues to
161
+ support all six methods without it. Enabling the MCP option means an agent can
162
+ issue a real mutating request as an application principal; it is not a claim
163
+ that the request is safe or free of side effects.
164
+
165
+ It is a second tool rather than a mode of `verify_access` because the two have
166
+ different blast radii. `verify_access` answers "which existing user can reach
167
+ this page" by issuing up to 25 bounded GET requests automatically, and is
168
+ GET-only for a reason: running a mutating method as 25 users would mean 25
169
+ real creates, 25 enqueued jobs, and 25 delivered mails. `reproduce_request`
170
+ issues exactly one request that the caller fully specified.
171
+
172
+ ## Identity
173
+
174
+ By default Karst sends the request as one existing user drawn from your
175
+ application's own configured principal source — the same machinery `/karst`
176
+ uses to answer "who can use this?", so you do not have to know how your app
177
+ signs anyone in. Pass `--anonymous` (or `anonymous: true`) for a genuinely
178
+ identity-free probe: Karst establishes no identity at all, and then verifies
179
+ that the application actually resolved none, which is usually what you want
180
+ for an endpoint an external system calls.
181
+
182
+ Karst assumes an identity directly rather than authenticating as an external
183
+ client would, so the recipe's `identity` document reports what it *observed*
184
+ the application resolve while running the request — not just what it asked
185
+ for — through the same `requested`/`observed`/`confirmation` evidence
186
+ `verify_access` reports:
187
+
188
+ - `confirmed` — the application ran as exactly the requested user.
189
+ - `confirmed_anonymous` — the application ran with no principal, as asked.
190
+ - `mismatch` — the application resolved a *different* principal than requested.
191
+ - `absent` — Karst's identity setup did not take, and the application resolved
192
+ no principal at all.
193
+ - `contaminated` — an anonymous probe was requested, but the application
194
+ resolved a principal anyway (stale session state, most often).
195
+ - `unobservable` — Karst has no way to see what the application resolved
196
+ (configure `config.observe_identity`, or use Karst's Devise/Warden
197
+ integration).
198
+
199
+ Only `confirmed` and `confirmed_anonymous` mean the recipe ran under the
200
+ identity it claims to.
201
+
202
+ ## Limitations
203
+
204
+ - **Same-connection rollback is not side-effect isolation.** Database rollback
205
+ is attempted for writes on the request's own connection. Background jobs,
206
+ mail, outbound HTTP, files, Redis/external stores, and other database
207
+ connections are not.
208
+ One `POST` that enqueues a job really enqueues it. MCP therefore refuses
209
+ mutating methods by default; opting in does not broaden transaction isolation.
210
+ Human reproduction remains explicit and issues exactly one request, never a
211
+ sweep.
212
+ - **Development only.** Reproduction refuses to run outside
213
+ `Rails.env.development?`, refuses when `config.enabled` is false, and at
214
+ `/karst` requires a loopback peer, a same-origin POST, and Karst's own CSRF
215
+ token.
216
+ - **Local paths only.** A target with a scheme, a host, or a leading `//` is
217
+ refused rather than normalized.
218
+ - **`GET HEAD POST PUT PATCH DELETE` only.** Anything else is refused rather
219
+ than passed through.
220
+ - **Header names must be header names.** A name outside `[A-Za-z0-9-]+`, or a
221
+ CGI variable such as `REQUEST_METHOD` or `PATH_INFO`, is refused. Those
222
+ would be written into the Rack environment rather than sent as headers, so
223
+ the request Karst issued would stop matching the request Karst reports.
224
+ - **Multipart bodies are not parsed.** They are sent as given and shown as
225
+ `<BODY>`.
226
+ - **Karst cannot invent a body.** If nothing has ever told you what the
227
+ external system sends, Karst will faithfully report that your guess returned
228
+ a `422` — which is still more than reading the controller gives you, but it
229
+ is not the payload.
230
+ - **Redaction is name-based (and, for Referer/Origin/redirects, structural),
231
+ never value-based.** A custom header, or a query or body field, whose name
232
+ neither your application's `filter_parameters` nor Karst's own
233
+ credential-name net recognizes is shown exactly as sent — even if its value
234
+ happens to hold a secret (a bespoke `X-Callback-Url` header carrying a
235
+ token in its own query string, say). If your application accepts secrets
236
+ under names Karst has no way to guess, add them to
237
+ `config.filter_parameters`; Karst's net is a supplement to that, never a
238
+ replacement for it.
239
+
240
+ This is not API documentation, an OpenAPI generator, a request collection, or
241
+ an HTTP client. It makes one workflow easier: reproducing a request the
242
+ application actually handles.
@@ -12,6 +12,12 @@ Karst.configure do |config|
12
12
  end
13
13
  config.clear_identity = ->(session) { session.delete "/karst_test_logout" }
14
14
 
15
+ # How Karst observes which principal the application itself resolved while
16
+ # running a probe. Without it Karst reports identity as unobservable rather
17
+ # than assuming the requested principal is the one that ran.
18
+ # TODO: return this app's runtime principal (nil when it has none).
19
+ config.observe_identity = ->(context) { context.controller&.current_account }
20
+
15
21
  # TODO: replace :account_id with this app's browser-session identity.
16
22
  config.assume_browser_identity = ->(request, principal) { request.session[:account_id] = principal.id }
17
23
  config.clear_browser_identity = ->(request) { request.session.delete(:account_id) }
@@ -42,16 +42,19 @@ module Karst
42
42
  # populations extended by whatever its model has approved and
43
43
  # confirmed. Returns the argument untouched when nothing applies, so
44
44
  # the overwhelmingly common "no approvals" case costs one file stat.
45
- def merge(sources)
45
+ # `record:`/`discovery:`, when given, are a caller's already-loaded
46
+ # PopulationApprovals::Record and PopulationDiscovery instance (see
47
+ # Karst::Identity::Snapshot, which resolves this once per operation
48
+ # and shares both with whatever else that operation needs to discover
49
+ # or confirm populations) -- reused instead of loading and parsing
50
+ # again. Omitted, each is loaded fresh exactly as before.
51
+ def merge(sources, record: nil, discovery: nil)
46
52
  return sources unless sources && local_environment?
47
53
 
48
- record = PopulationApprovals.load
54
+ record ||= PopulationApprovals.load
49
55
  return sources if record.entries.empty?
50
56
 
51
- discovery = PopulationDiscovery.new
52
- sources.each_with_object({}) do |(name, source), merged|
53
- merged[name] = extend_source(source, record.entries, discovery)
54
- end
57
+ merge_entries(sources, record.entries, discovery || PopulationDiscovery.new)
55
58
  rescue StandardError
56
59
  # Approval is an optional convenience layered over configuration
57
60
  # Karst already had. If resolving it fails for any reason, the
@@ -88,6 +91,12 @@ module Karst
88
91
 
89
92
  private
90
93
 
94
+ def merge_entries(sources, entries, discovery)
95
+ sources.each_with_object({}) do |(name, source), merged|
96
+ merged[name] = extend_source(source, entries, discovery)
97
+ end
98
+ end
99
+
91
100
  def extend_source(source, entries, discovery)
92
101
  klass = source.record_klass
93
102
  return source unless klass
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karst
4
+ module Access
5
+ class Error < StandardError; end
6
+ class UnsafeTarget < Error; end
7
+ class UnsupportedMethod < Error; end
8
+ class Unavailable < Error; end
9
+
10
+ # Karst could not observe a request it tried to run -- one of its own
11
+ # subscribers raised, or the request never reached the application. Never
12
+ # evidence about the application: an operation that raises this reports
13
+ # nothing for the request rather than incomplete evidence.
14
+ class ObservationError < Error; end
15
+ end
16
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../identity"
4
+ require_relative "observed_endpoint"
5
+
6
+ module Karst
7
+ module Access
8
+ # One probe request's complete identity lifecycle, kept out of
9
+ # Access::Probe so the rule it exists to enforce stays readable:
10
+ #
11
+ # a requested identity is intent, an established one is setup, and only
12
+ # an observed one is evidence.
13
+ #
14
+ # Deliberately never raises out of establishment or release. A probe whose
15
+ # identity setup failed is exactly the probe whose runtime observation
16
+ # matters most ("asked for User#123, application saw nobody, halted at
17
+ # authorize_admin"), so the request still runs and is still observed --
18
+ # the failure is recorded as evidence rather than swallowing the probe or
19
+ # being mistaken for an application exception.
20
+ class IdentityProbe
21
+ # Establishment outcomes:
22
+ #
23
+ # :established the requested identity's assume seam ran
24
+ # :failed that seam raised (identity was not established)
25
+ # :cleared an anonymous probe: no identity was established
26
+ # and any queued/configured identity was dropped
27
+ # :clear_failed an anonymous probe whose configured
28
+ # clear_identity hook raised
29
+ attr_reader :requested, :request_env
30
+
31
+ def initialize(principal)
32
+ @principal = principal
33
+ @anonymous = Identity.anonymous?(principal)
34
+ @requested = @anonymous ? nil : Identity.describe(principal)
35
+ @observations = {}
36
+ @thread = Thread.current
37
+ end
38
+
39
+ def anonymous?
40
+ @anonymous
41
+ end
42
+
43
+ def endpoint(application)
44
+ ObservedEndpoint.new(application, self)
45
+ end
46
+
47
+ def capture_env(env)
48
+ @request_env = env
49
+ end
50
+
51
+ # Called as the target request starts. Identity setup may already have
52
+ # issued requests on the same session (a sign-in endpoint, a clear
53
+ # hook), and only the target's own env may ever be observed -- so
54
+ # whatever setup left behind is forgotten here, and a target that never
55
+ # reaches the application leaves nothing to observe at all.
56
+ def begin_target
57
+ @request_env = nil
58
+ end
59
+
60
+ def establish(session)
61
+ @anonymous ? establish_anonymous(session) : establish_principal(session)
62
+ end
63
+
64
+ # Runs in the caller's ensure, so it must survive anything the request
65
+ # did. An anonymous probe established nothing, but still releases: a
66
+ # configured clear hook is the application's own "make this anonymous
67
+ # again" seam, and a Warden principal queued for a request that never
68
+ # reached Warden must not survive into the next probe.
69
+ def release(session)
70
+ Identity.clear(session, principal: @principal) unless @anonymous
71
+ rescue StandardError => e
72
+ @cleanup_error = describe_error(e)
73
+ ensure
74
+ Identity::WardenAdapter.discard_pending!
75
+ end
76
+
77
+ # Karst's own sign-in raised inside the target request itself (see
78
+ # Identity::EstablishmentError): identity was never established, and
79
+ # the request stopped before the application dispatched it. Whatever
80
+ # half-applied state its env holds is not the application's, so
81
+ # nothing is observed from it.
82
+ def establishment_failed(message)
83
+ @establishment = :failed
84
+ @establishment_error = message
85
+ @request_env = nil
86
+ @observations[:request_completion] ||=
87
+ Identity::Observation.new(principal: nil, source: nil,
88
+ error: "the request stopped during Karst's own sign-in, before the application ran")
89
+ end
90
+
91
+ # Called at each moment worth observing. Ignores events raised on
92
+ # another thread: ActiveSupport::Notifications subscriptions are
93
+ # process-wide, and a concurrent request in a real development server
94
+ # must never be mistaken for this probe.
95
+ def observe(phase)
96
+ return unless Thread.current == @thread
97
+
98
+ @observations[phase] ||= Identity::Observer.observe(@request_env, requested: observable_principal)
99
+ end
100
+
101
+ def observed_this_probe?(phase)
102
+ @observations.key?(phase)
103
+ end
104
+
105
+ def evidence
106
+ Identity::EvidenceBuilder.build(requested: @requested, halt: @observations[:halted_callback],
107
+ completion: @observations[:request_completion],
108
+ establishment: @establishment,
109
+ establishment_error: @establishment_error,
110
+ cleanup_error: @cleanup_error)
111
+ end
112
+
113
+ private
114
+
115
+ def observable_principal
116
+ @anonymous ? nil : @principal
117
+ end
118
+
119
+ def establish_principal(session)
120
+ Identity.assume(session, @principal)
121
+ @establishment = :established
122
+ rescue StandardError => e
123
+ @establishment = :failed
124
+ @establishment_error = describe_error(e)
125
+ end
126
+
127
+ # Every probe already runs in its own freshly built integration session,
128
+ # which carries no cookies and no Warden proxy, so anonymity is
129
+ # established by construction rather than by logging something out.
130
+ # What can still leak across probes is Karst's own queued identity, so
131
+ # that is dropped explicitly; a configured clear_identity hook is then
132
+ # run as well, because it is the only seam that can reach application
133
+ # state Karst does not know about.
134
+ def establish_anonymous(session)
135
+ Identity::WardenAdapter.discard_pending!
136
+ @establishment = :cleared
137
+ hook = Karst.config.clear_identity
138
+ return unless hook.respond_to?(:call)
139
+
140
+ hook.call(session)
141
+ rescue StandardError => e
142
+ @establishment = :clear_failed
143
+ @establishment_error = describe_error(e)
144
+ end
145
+
146
+ def describe_error(error)
147
+ "#{error.class}: #{error.message}"
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "errors"
5
+
6
+ module Karst
7
+ module Access
8
+ # The single definition of "a local application path Karst may execute".
9
+ # Access::Sweep and Reproduction::Exercise both route their caller-supplied
10
+ # target through here, so there is exactly one place to audit rather than
11
+ # one per caller -- a security check duplicated across callers is a
12
+ # security check that eventually diverges.
13
+ module LocalPath
14
+ Parsed = Struct.new(:path, :query, keyword_init: true)
15
+
16
+ class << self
17
+ # Splits a target into its path and raw query string, refusing
18
+ # anything that is not a local application path. A scheme, an
19
+ # authority, or a protocol-relative "//host" target is rejected
20
+ # outright rather than normalized into something local-looking.
21
+ def parse(value)
22
+ raw = value.to_s
23
+ path, query = raw.split("?", 2)
24
+ path = path.to_s
25
+ uri = URI.parse(path)
26
+ local = uri.relative? && path.start_with?("/") && !path.start_with?("//")
27
+ raise UnsafeTarget, "target must be a local application path" unless local
28
+
29
+ Parsed.new(path: path, query: query)
30
+ rescue URI::InvalidURIError
31
+ raise UnsafeTarget, "target must be a valid local application path"
32
+ end
33
+
34
+ # The query-stripped path, for callers (Access::Sweep) that
35
+ # deliberately never carry a query string.
36
+ def path(value)
37
+ parse(value).path
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Karst
4
+ module Access
5
+ # Wraps the probe endpoint for exactly one probe request so Karst holds
6
+ # the request's own Rack env while it is still running.
7
+ #
8
+ # This is what makes halt-time identity observation possible: when
9
+ # "halted_callback.action_controller" fires, the request has not returned
10
+ # yet, so ActionDispatch::Integration::Session cannot hand Karst its env
11
+ # (it only records one afterwards, and not at all when the application
12
+ # raises). Rails has already put the dispatching controller instance on
13
+ # this same env object by then -- ActionController::Metal#set_request!
14
+ # sets env["action_controller.instance"] before any callback runs -- and
15
+ # every middleware in the probe stack mutates that one env in place, so
16
+ # holding it here is enough to observe the application's runtime identity
17
+ # both mid-request and after it, without instrumenting the application.
18
+ class ObservedEndpoint
19
+ def initialize(app, probe)
20
+ @app = app
21
+ @probe = probe
22
+ end
23
+
24
+ def call(env)
25
+ @probe.capture_env(env)
26
+ @app.call(env)
27
+ end
28
+
29
+ # ActionDispatch::Integration::Session asks the endpoint it was given
30
+ # for things other than #call (#routes, for url_options), so this must
31
+ # stay transparent rather than merely callable.
32
+ def respond_to_missing?(name, include_private = false)
33
+ @app.respond_to?(name, include_private) || super
34
+ end
35
+
36
+ # rubocop:disable Naming/BlockForwarding, Style/ArgumentsForwarding -- anonymous
37
+ # argument/block forwarding needs Ruby 3.x; this file runs on Ruby 2.7.
38
+ def method_missing(name, *arguments, &block)
39
+ return @app.public_send(name, *arguments, &block) if @app.respond_to?(name)
40
+
41
+ super
42
+ end
43
+ # rubocop:enable Naming/BlockForwarding, Style/ArgumentsForwarding
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../value"
4
+
5
+ module Karst
6
+ module Access
7
+ # The one definition of "these probes observed the same thing", shared by
8
+ # Access::Result#groups, the CLI/MCP evidence document, the human CLI
9
+ # summary, and the /karst panel -- so no surface can split or merge
10
+ # outcomes differently from another.
11
+ #
12
+ # A group is keyed by what Karst observed about the *request*: the
13
+ # response, where it dispatched, what it wrote, and whether the configured
14
+ # usable_access_outcome policy accepts it. Everything about the *probe*
15
+ # that made the request -- who it ran as, how that identity was observed,
16
+ # why that principal was sampled, how long it took -- is deliberately not
17
+ # part of the key: two users halted at the same callback are one outcome
18
+ # observed twice, not two outcomes. Those per-probe details stay attached
19
+ # to each member outcome, so nothing is lost by grouping.
20
+ #
21
+ # Nothing here normalizes observed values (no redirect path rewriting, no
22
+ # status bucketing): two outcomes group only when every semantic field is
23
+ # exactly equal, so grouping can merge equivalent observations but never
24
+ # reinterpret distinct ones as the same.
25
+ module OutcomeGroups
26
+ # Observed facts about the request. Two outcomes that differ in any of
27
+ # these are different outcomes.
28
+ SEMANTIC_FIELDS = %i[status redirect exception_class halted_callback controller action
29
+ writes_observed write_count database_rollback_attempted].freeze
30
+
31
+ # Facts about the probe, not the request's outcome. Volatile (elapsed
32
+ # time), per-identity (principal, identity evidence), or sampling
33
+ # provenance -- never part of group identity.
34
+ PROBE_METADATA = %i[principal identity sampling_reasons elapsed_ms].freeze
35
+
36
+ # `usable` is the configured usable_access_outcome verdict, shared by
37
+ # every member (it is part of the key), so a group can always be
38
+ # described as verified usable or not without consulting any one member.
39
+ Group = Value.define(:key, :outcomes, :usable) do
40
+ def count
41
+ outcomes.size
42
+ end
43
+
44
+ # Every member agrees on every SEMANTIC_FIELDS value, so any member
45
+ # describes the group; the first one observed is used.
46
+ def representative
47
+ outcomes.first
48
+ end
49
+ end
50
+
51
+ class << self
52
+ # Groups `outcomes` and orders the groups deterministically: largest
53
+ # first, then by the semantic key itself, so the same observations
54
+ # produce the same order regardless of the order they were probed in.
55
+ # Members keep the order they were probed in. `usable` is evaluated
56
+ # exactly once per outcome.
57
+ def group(outcomes, usable: Karst.config.usable_access_outcome)
58
+ groups = partition(outcomes, usable).map do |group_key, (verdict, members)|
59
+ Group.new(key: group_key, outcomes: members.freeze, usable: verdict)
60
+ end
61
+ groups.sort_by { |item| [-item.count, sort_key(item.key)] }
62
+ end
63
+
64
+ # halted_callback is compared by its string form: Rails reports a
65
+ # named callback as a Symbol, and the serialized evidence reports it
66
+ # as a String, so the two must never become separate groups.
67
+ def key(outcome)
68
+ SEMANTIC_FIELDS.map do |field|
69
+ value = outcome.respond_to?(field) ? outcome.public_send(field) : nil
70
+ field == :halted_callback && !value.nil? ? value.to_s : value
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ # semantic key + verdict => [verdict, members in probe order]
77
+ def partition(outcomes, usable)
78
+ outcomes.each_with_object({}) do |outcome, grouped|
79
+ verdict = usable.call(outcome) ? true : false
80
+ (grouped[key(outcome) + [verdict]] ||= [verdict, []]).last << outcome
81
+ end
82
+ end
83
+
84
+ # A total order over heterogeneous values (Integer, String, booleans,
85
+ # nil) that never raises: nil sorts first, everything else by its
86
+ # string form.
87
+ def sort_key(values)
88
+ values.map { |value| value.nil? ? [0, ""] : [1, value.to_s] }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end