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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '08cd05c0ed4b829d53920c0cde0a5844fcb518f380b63616a812f7baef7a3e0d'
4
+ data.tar.gz: 43a7fde748e330ec79a586b2621f3110c0f77061594a456b8968d5beb0eba897
5
+ SHA512:
6
+ metadata.gz: 5b34b65961902ec98e9f2d26e8d26add2f9cb20ad440d0ea5d098e95817e75abb1db4aeca809ebb662a2481ed08ef466deba5ec0c07ed192034331ddc6b3a1cc
7
+ data.tar.gz: 9396ea4e987e42f208e8f01d2c6e9127817ed902577dac113de07dee7187c6eb272b2126249153caa95412bd0dbe8514cec622652150244423f6d2d4beaf1db7
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,59 @@
1
+ # Architecture
2
+
3
+ This document describes how Karst is put together and the policy that governs which Ruby/Rails combinations it supports. See [README.md](README.md) for user-facing behavior and [CONTRIBUTING.md](CONTRIBUTING.md) for the day-to-day development workflow.
4
+
5
+ ## Component map
6
+
7
+ - `Karst::Configuration` — the whole public configuration surface, deliberately small: one on/off switch, the escape hatches for authentication Karst cannot infer, and four bounds with working defaults. It also owns `#principal_sources`, the single effective-configuration method every adapter reads. Karst installs no `ActiveSupport::Notifications` subscriber at boot; the only SQL Karst observes is what a probe itself emits, through a scoped per-probe subscription in `Access::DatabaseIsolation`.
8
+ - `Karst::Spec::Observer` / `Spec::Reporter` — an opt-in RSpec integration that turns real spec execution (via `ActiveSupport::Notifications` and Warden's public hooks) into a deterministic JSON scenario artifact, with no source parsing and no database access.
9
+ - `Karst::Spec::Catalog` — a read-only index over that artifact; requires none of RSpec, Rails, or a database to read an already-written catalog.
10
+ - `Karst::Web::Middleware` / `Web::Panel` / `Web::Badge` / `Web::Locality` — Karst's development-only HTTP surface: `GET /karst` served directly at the Rack boundary (no engine, route, or controller) plus an optional page-local badge injected into eligible host HTML responses.
11
+ - `Karst::Access::PrincipalSampler` — a bounded candidate-selection step ahead of `Access::Sweep`, with nothing configurable in it. It materializes one recent-N pool with a single `LIMIT` query, then stratifies that pool in memory over states derived from the schema itself: boolean columns, `enum` columns, nullable-foreign-key presence/absence, and low-cardinality scalars, minus anything PII- or tenancy-shaped. Its total query volume is exactly one query, independent of table size -- no discovery query, `COUNT`, unbounded scan, or live subquery. It only selects candidates and never executes a route, so `Access::Sweep` remains the sole behavioral-evidence contract. Candidate populations are deliberately not here: they are `Access::Search`'s second stage, so "the ordinary sample failed, then `system_admins` reached it" stays reportable rather than being folded into an ordinary-looking sample.
12
+ - `Karst::Access::CandidatePopulation` — resolves one configured name => callable pair into a bounded, already-queried population plus a provenance label (`population=system_admins`), or `nil` when calling the callable does not yield an `ActiveRecord::Relation` scoped to the same model being sampled; never raises for a misconfigured population, and never issues more than one `LIMIT`-bounded query regardless of the underlying relation's row count. Preserves the configured relation's own ordering when it has one, adding a deterministic primary-key fallback only when it does not. Deliberately does not claim that a configured callable is a "real" Rails named scope -- Active Record exposes no reliable, public way to distinguish a method defined via the `scope` macro from an ordinary handwritten class method, so this class validates only what it can actually observe: the shape of what calling the callable returns. A population is a hint about where meaningful candidates might live -- "these records are worth trying," never "these records satisfy the behavior" -- so it carries no authorization or behavioral claim; only `Access::Sweep`'s runtime execution produces that evidence. Deliberately generic over what a candidate represents: `PrincipalSampler` is its only caller today, but nothing in this class assumes authentication, so a future artifact-population caller (`Subscription.renewable`, `Import.with_sheets`) could reuse it unchanged.
13
+ - `Karst::Access::PrincipalSource` / `PrincipalSelection` — the multi-source layer above `PrincipalSampler`. A `PrincipalSource` is "which records may Karst consider at all": a name, a lazily-evaluated records callable, and its own optional `populations`. Those two keys are the whole spec; any other key raises rather than being ignored. `Configuration#principal_sources` normalizes either an explicit `config.principal_sources` Hash or a bare `config.principals` (plus `config.principal_populations`) into one implicit `:default` source, so every downstream consumer only ever handles "one or more sources." `PrincipalSelection` runs `PrincipalSampler` independently per source -- never materializing sources together -- and allocates combined candidates fairly within one overall `limit`, tagging `source=<name>` on candidates only once more than one source is actually configured.
14
+ - `Karst::Access::PopulationDiscovery` / `PopulationApprovals` / `ApprovedPopulations` — the local approval workflow that removes hand-written `config.principal_populations` from the normal path. `PopulationDiscovery` parses application model source with Ripper and lists statically named, zero-parameter Rails `scope` declarations; it executes no scope, issues no query, and mutates nothing. `PopulationApprovals` is the machine-local record of which of those a developer explicitly approved (`tmp/karst/approved_populations.json`) -- plain model and scope names, never executable Ruby, never user data, never evaluated, and fails closed on any malformed, unreadable, or version-mismatched document. `ApprovedPopulations` folds an approval back into ordinary `PrincipalSource` populations inside `Configuration#principal_sources`, but only when Karst is in development/test, the entry's model name matches the Active Record class of an already-configured principal source (the class always comes from that source, never from the file), and current discovery still confirms that exact scope. Explicit configuration wins outright on name conflict and keeps its position first. Because the merge happens in the one effective-configuration method, the panel, `bin/rails karst:verify`, and the MCP `verify_access` tool inherit approvals identically and none of them reads approval state itself.
15
+ - `Karst::Access::ResourceEvidence` — a read-only, downstream step for one already-selected `Access::Sweep` outcome. Reports simple, directly observed foreign-key relationships (column name plus id equality, nothing else) between the exact resource a route addresses and one exact principal; never a join, a `has_many` traversal, or any other attribute. Resource resolution from a route path trusts only Rails' own route recognition plus controller-to-model naming convention, while principal resolution always goes through `Karst::Identity.resolve` and therefore cannot escape `config.principals`; when either step is ambiguous it reports a limitation string rather than guessing. Deliberately a separate class from both `Sweep` and `PrincipalSampler`: it runs no route and selects no candidates, only compares two already-identified records.
16
+
17
+ Each area is deliberately narrow and composable; none of them depends on the others' internals beyond the public objects listed above.
18
+
19
+ ## Compatibility policy
20
+
21
+ **Supported core: Ruby >= 2.7, Rails >= 6.1.** This is a CI-backed claim, not an aspiration: a legacy job runs the real integration suite against Ruby 2.7 and Rails 6.1 on every push and pull request, alongside modern targets (see [CI](#ci)). Karst does not claim support for a Ruby/Rails combination CI does not exercise.
22
+
23
+ The guiding rule: **core Karst functionality works on Rails 6.1 even where an optional UI convenience does not.** Nothing in Karst raises, at load time or at request time, because a modern-Rails-only API is unavailable. Where a capability genuinely cannot be provided safely on an older stack, that one capability degrades quietly; nothing else is weakened to compensate, and modern Rails never loses anything to accommodate the older floor.
24
+
25
+ ### Capability degradation
26
+
27
+ | Rails / Rack | `require "karst"` | Access search (`/karst`, CLI, MCP) | Spec Observer | Scenario Catalog | `/karst` panel | Page-local badge |
28
+ |---------------------|:---:|:---:|:---:|:---:|:---:|:---:|
29
+ | 6.1 / Rack 2 | yes | yes | yes | yes | yes | **unavailable** |
30
+ | 7.0 / Rack 2 | yes | yes | yes | yes | yes | **unavailable** |
31
+ | 7.1, 7.2, 8.x / Rack 3 | yes | yes | yes | yes | yes | yes |
32
+
33
+ The badge is the one capability that degrades. `Karst::Web::Badge` only ever rewrites a Rack response body that reports itself bufferable via Rack's own `to_ary` idiom (the same check `Rack::ETag` relies on for the same reason). Under Rack 2, `ActionDispatch`'s response body wrapper never exposes `to_ary`, so every response reports non-bufferable and Badge leaves it untouched — this is a real, per-response runtime check, not a hardcoded Rails-version branch, so it needs no maintenance as new Rack/Rails combinations appear. `/karst` itself does not depend on badge injection at all: it is a small, independent Rack middleware branch keyed on `PATH_INFO`, so it is unaffected. Karst never monkey-patches `ActionView`, never consumes a streaming body to work around this, and never weakens `Content-Length`, CSP, or host middleware semantics to force badge parity onto Rack 2.
34
+
35
+ ### Value objects: `Karst::Value`
36
+
37
+ Ruby 2.7 has no `Data.define` (added in Ruby 3.2). Every former `Data.define` site now goes through `Karst::Value.define`, a small internal helper built on `Struct.new(..., keyword_init: true)`: Struct already provides structural equality, keyword construction, and `#members`; the one thing it does not provide for free is immutability, so `Value.define` freezes every instance its class produces. This is a shallow freeze, matching `Data.define`'s own contract exactly — a member holding a mutable object (an `Array`, say) is not deep-frozen, and Karst does not need it to be. There is no version branching here: `Karst::Value` is used uniformly on every supported Ruby, so `Data.define` semantics never need to be reverse-engineered from two different code paths.
38
+
39
+ ### Request-local state: `Karst::ExecutionContext`
40
+
41
+ The page badge and the spec observer both need request-local (not global, not thread-shared-and-racy) correlation storage: evidence captured inside a notification callback, read back out after the call returns. Modern Rails provides exactly this via `ActiveSupport::IsolatedExecutionState`, added in Rails 7.0. `Karst::ExecutionContext` is the seam:
42
+
43
+ - When `ActiveSupport::IsolatedExecutionState` is defined, `Karst::ExecutionContext` delegates directly to it — modern Rails keeps using its own preferred primitive, with no extra indirection cost.
44
+ - Otherwise (Rails 6.1), it falls back to `ThreadLocalStore`, a plain per-thread `Hash` reached through `Thread#thread_variable_get`/`thread_variable_set` — deliberately not `Thread#[]`/`[]=`, which are fiber-local and would silently miss context under a Fiber scheduler.
45
+
46
+ The fallback mirrors `IsolatedExecutionState`'s own default `:thread` isolation level: storage is shared by every Fiber running on one OS thread, not isolated per Fiber. Karst's own usage (one badge or spec correlation captured and read back within a single synchronous request or example) never spans multiple concurrently-scheduled Fibers, so this has no observable effect on Karst's supported behavior — it is documented so a future caller does not assume Fiber isolation the fallback cannot provide.
47
+
48
+ Both backends share the same three-method contract (`[]`, `[]=`, `delete`), cleanup happens in the caller's own `ensure` block exactly as before, and neither backend introduces global mutable state: each thread only ever sees its own slot, so concurrent Puma requests cannot cross-contaminate each other's context.
49
+
50
+ ### No scattered version checks
51
+
52
+ Compatibility decisions live behind exactly two narrow seams — `Karst::Value` and `Karst::ExecutionContext` — plus one capability-detected `require` (`Karst::Subscription` requires `"logger"` before `"active_support"`; see its source comment for why Rails 6.1 needs that ordering). Feature code elsewhere does not branch on `Rails.version` or `RUBY_VERSION`; where a modern constant might not exist, the one call site checks `defined?` for the capability itself rather than comparing version numbers.
53
+
54
+ ### CI
55
+
56
+ - `unit-test` — Ruby 3.2, the root `Gemfile` (RSpec + RuboCop against everything except `spec/integration`).
57
+ - `rails-integration` matrix — `spec/integration` against a version-pinned `Gemfile` per row, each a real Rails application booted through Rack: Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, Rails 7.2 and 8.0 on Ruby 3.3. Every row is a required, blocking job.
58
+
59
+ The Rails 6.1 row is what backs the compatibility claim in this document: it boots a real `Rails::Application`, exercises `GET` against ordinary routes and `/karst`, and runs the scenario observer/catalog round trip, all against genuine Ruby 2.7 syntax and Rails 6.1 APIs — not an assumption that "this probably still works."
data/CHANGELOG.md ADDED
@@ -0,0 +1,67 @@
1
+ # Changelog
2
+
3
+ All notable changes to Karst will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project intends to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once releases begin.
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Fixed
10
+
11
+ - **The golden path was broken for every real Devise/Warden application.** `Karst::Access::ProbeApplication`'s deliberately minimal probe Rack stack never wrapped `Warden::Manager`, so `env["warden"]` never existed inside a probe and every principal failed with `Karst::Identity::Unavailable` instead of real observed evidence. Fixed by wrapping a `Warden::Manager` (reusing Devise's own already-configured scope defaults, session serializers, and failure app when Devise is present) directly around the router, in the same position a real host application's own compiled middleware stack uses. Because `Karst::Identity.with` establishes probe identity *before* the caller's own request runs, `Karst::Identity::WardenAdapter#assume` now queues the principal (via a thread-local, request-scoped slot -- never Warden's own global `Test::Helpers` queue, which would leak across concurrent unrelated requests) and applies it the moment that exact probe's own request reaches the newly-wrapped `Warden::Manager` -- the same queue-for-next-request idiom `Warden::Test::Helpers#login_as` and `Devise::Test::IntegrationHelpers#sign_in` already use for the identical problem in integration tests.
12
+ - `ProbeApplication` no longer passes Devise's live `warden_config` Hash directly into `Warden::Manager.new`'s `options` argument: that constructor destructively deletes `:default_strategies` from whatever Hash it's given, silently corrupting the one shared config object every *real* host request also authenticates through -- a single Karst probe could previously break the host application's own Devise login for the rest of the process. A duplicate is used instead, and only through `Warden::Manager`'s config *block* (`Warden::Manager.new(endpoint) { |config| config.merge!(...) }`), never as constructor `options` -- passing it as `options` also mis-splats Devise's own already-per-scope `:default_strategies` Hash into `:_all`, misfiling every scope name as if it were itself a strategy and raising Warden's own `"Invalid strategy <scope>"` the moment more than one Devise model is involved.
13
+ - `ProbeApplication::Environment#call` built a fresh, merged copy of the incoming Rack env on every call instead of mutating it in place, so any state a wrapped middleware set during the request (in particular `env["warden"]`) was invisible to the exact env object `ActionDispatch::Integration::Session` retains afterward as `#request.env` -- `WardenAdapter#clear` could never find the very proxy `#assume` had just used, raising `Karst::Identity::Unavailable` from Access::Sweep's own cleanup path even on an otherwise fully successful probe.
14
+ - **"Stop testing as" could fail and leave the assumed identity active.** `Karst::Web::BrowserIdentity#return_path` raised when the panel's hidden `path` field was blank -- which it always is on a plain `/karst` visit with no `?path=` query string, exactly what a developer sees immediately after Test As redirects them to the page they were testing. Karst now falls back to `/karst` itself for a blank return path instead of raising.
15
+ - **A multi-Devise application's ambiguity notice never appeared until *after* a route was already selected.** `Web::Panel#access_section` rendered nothing at all -- not even the "Karst found N user types, which should Karst test?" selection form, and not the custom-authentication pointer either -- until a URL was chosen, so a fresh multi-Devise (or non-Devise) installation's very first `/karst` visit looked entirely blank with no indication anything needed attention. The same setup notice now renders regardless of whether a route is selected yet.
16
+
17
+ ### Removed
18
+
19
+ - **`config.principal_dimensions`.** Declaring which coarse states Karst should try to cover during the ordinary sample is no longer part of the product. Schema-derived stratification (boolean columns, `enum` columns, nullable-foreign-key presence/absence, low-cardinality scalars, minus anything PII- or tenancy-shaped) was already the zero-configuration path and is now the only one; a configured dimension replaced it wholesale rather than improving it, so the option could only ever make sampling narrower than the default. The case it actually existed for -- a user too rare for a recent sample to reach -- is what candidate populations are for, and those report themselves as named evidence instead of quietly reshaping an ordinary-looking sample. `Karst::Access::PrincipalDimension` is removed. `Karst::Access::PrincipalSource` no longer accepts `dimensions:`, and a `dimensions:` key inside a `config.principal_sources` spec now raises `ArgumentError` rather than being silently ignored -- as does any other unrecognized key. Scalar sampling reasons render plainly (`role=local_admin`, not `role="local_admin"`), preserving exactly what the panel displayed before.
20
+ - **`config.artifact_source` / `config.access_scenario`.** Explicit artifact scenarios swept application records ("can any recent import be opened cross-account?") rather than answering "which user can reach this route," had no entry point in the primary workflow, and were already absent from the README. `Karst::Access::ArtifactSource`, `Access::Scenario`, and `Access::ScenarioSweep` are removed along with the panel's per-scenario buttons and the middleware's `artifact_sweep` operation.
21
+ - **`config.buffer_size`, and the runtime SQL evidence subsystem with it.** Karst kept a process-wide, bounded `sql.active_record` buffer from an earlier product direction; no Karst surface has reported it since `/karst` was restructured around access search, so a host application was paying a notification callback on every query for evidence nobody could see. `Karst.buffer`, `Karst.window`, `Karst.subscribe!`/`unsubscribe!`/`subscribed?`, `Karst::Buffer`, `Karst::Subscription`, and `Karst::Sql::Event`/`Canonicalizer`/`Shape`/`Window` are removed, as is the railtie's `after_initialize` subscription. **Karst now installs no `ActiveSupport::Notifications` subscriber at boot at all.** Database-write evidence during a probe is unaffected: it has always come from `Access::DatabaseIsolation`'s own scoped, per-probe subscription, not from this buffer.
22
+
23
+ Setting any removed option raises `Karst::RemovedConfiguration` (a `NoMethodError` subclass) with a message naming the removal and what replaced it. Karst never silently ignores a removed option or reinterprets it as something else. See [docs/advanced-configuration.md](docs/advanced-configuration.md#removed-configuration).
24
+
25
+ ### Changed
26
+
27
+ - **`config.enabled` now actually gates Karst.** It previously controlled only the SQL subscription removed above, which meant setting `config.enabled = false` disabled nothing a developer could observe -- `/karst`, the badge, `bin/rails karst:verify`, and the MCP `verify_access` tool all kept working, contradicting the README. It is now the single switch for Karst's whole development surface, re-read live on every request so turning Karst off never depends on initializer ordering. The default is unchanged (development/test only), and production remains off regardless.
28
+ - Configuration is restructured around "usually, you don't": the README's configuration section is now the off switch and a pointer, and every remaining option is documented in [docs/advanced-configuration.md](docs/advanced-configuration.md) as either an escape hatch for authentication Karst cannot infer or a bound with a working default. `access_sweep_limit`, `principal_candidate_pool_size`, `population_retry_limit`, and `usable_access_outcome` remain configurable but no longer appear on the normal installation path.
29
+
30
+ - Candidate populations leave `Karst::Access::PrincipalSampler` entirely and become a deliberate second search stage owned by `Karst::Access::Search`, run only when the ordinary sample finds nothing usable — so "the sample found nothing; then `system_admins` reached it" is reportable as observed evidence rather than silently folded into an ordinary-looking sample. `PrincipalSampler` and `PrincipalSelection` no longer accept or report populations, and the sampler's query budget drops to exactly one bounded recent-pool query. Configured dimensions still shape the ordinary sample, with generic schema discovery as their fallback.
31
+ - The manual `population_sweep` operation and its "Try another population" panel section are removed, along with `Karst::Access::PopulationSuggestion` (its name-ranking heuristic only existed to order that manual choice). Retries are automatic.
32
+ - Panel copy now says "user" rather than "principal" in the access workflow.
33
+ - `/karst/populations` is reorganized around approval rather than snippet generation: candidate groups are approved with a checkbox and one **Approve selected groups** button, approvals and stale approvals are surfaced first, and Ruby snippet generation moves to an advanced export path for applications that prefer to commit their populations (or for CI, where machine-local approval state is deliberately not consulted). Preview remains available, bounded and rollback-safe, and is never required in order to approve. The page's POST operations now require a same-origin request, since approving writes local state that outlives the request.
34
+
35
+ ### Added
36
+
37
+ - **Local selection of an ambiguous Devise setup, so a multi-model Devise app no longer needs an initializer to resolve it.** Karst still refuses to guess when `Devise.mappings` reports more than one model and nothing is explicitly configured — that safety boundary is unchanged — but the `/karst` panel now shows every detected model as a checkbox right where the old "configure `config.principals` explicitly" message used to sit, and one **Save** persists the choice. Selecting one or more models produces the same `Karst::Access::PrincipalSource` machinery an explicit `config.principal_sources` would: one independently queryable source per selected model, keyed by that model's own Devise/Warden scope (`User` → `:user`, `Admin` → `:admin`), never collapsed into a combined source. The selection is recorded by the new `Karst::Access::PrincipalSourceSelection` in `tmp/karst/principal_source_selection.json` — the same machine-local, git-ignored, development/test-only mechanism candidate-population approval already uses — holding only bare model names, never executable Ruby, never constantized; `Karst::Access::SelectedPrincipalSources` revalidates every entry against Devise's own current metadata on every read, silently dropping a model Devise no longer maps, and reverting to the ambiguous prompt if that empties the selection entirely. An explicit `config.principals`/`config.principal_sources` always wins outright over a saved selection. Probe identity already resolved Devise/Warden scope per principal regardless of source count; browser identity eligibility and **Stop testing as** now do the same — the browser session retains the exact scope an assumed identity was established under, so clearing never has to guess which of several selected sources produced it. `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool all pick up a saved selection automatically, and both CLI and MCP return the same actionable, structured error (pointing at `/karst` instead of at Ruby configuration) while nothing is selected yet.
38
+ - **Local approval of discovered candidate populations, so `config.principal_populations` is no longer part of the normal workflow.** A developer who installs Karst and finds no usable user at `/karst` now sees one small contextual action (`Karst found 3 application-defined user groups that could be tried — Review candidate groups`), approves the groups Karst may try at `/karst/populations`, and from then on those groups are searched automatically — with no initializer editing, no generated Ruby to paste, and identical behavior through `/karst`, `bin/rails karst:verify`, and the MCP `verify_access` tool. Discovery is unchanged and remains conservative (`Karst::Access::PopulationDiscovery`: statically named, zero-parameter Rails `scope` declarations parsed from application model source with Ripper; no execution, no query, no state change) — and **discovery is still not approval**. Approval is recorded by `Karst::Access::PopulationApprovals` in `tmp/karst/approved_populations.json`, deliberately machine-local, git-ignored development state rather than committed project configuration; it holds only model and scope names (never user data, never executable Ruby, never a lambda) and Karst never evaluates it. `Karst::Access::ApprovedPopulations` folds approvals into `Configuration#principal_sources` — the single source of truth every adapter already reads — only when Karst is in development/test, only for a model that is already a configured or inferred principal source (the class always comes from that source, never from the file), and only while current discovery still confirms that exact scope, so a removed or renamed scope stops being executed with no file edit (and is shown as unused on the review page), and a hand-written entry naming an ordinary class method is never confirmed at all. A malformed, unreadable, or version-mismatched approval file approves nothing and says so. Explicit `config.principal_populations`/`config.principal_sources[...] :populations` keeps working unchanged, wins outright on a name conflict, and keeps its configured order ahead of approved populations. `Access::Search`'s two-stage semantics, retry bounds, deduplication, rollback safety, evidence states, and early stop are untouched.
39
+ - `Karst::Access::Search`: after an ordinary bounded sample observes no usable user, Karst now automatically retries each *approved* candidate population in configuration order, stopping at the first verified success. Bounded by `config.population_retry_limit` records per population (default 3, max 10) and by `config.access_sweep_limit` total extra requests. Every approved population is reported, including the ones deliberately not run.
40
+ - `config.population_retry_limit`.
41
+ - Initial project documentation, contributor tooling, and continuous integration.
42
+ - Configuration and an idempotent, deliberately inert `sql.active_record` subscription lifecycle.
43
+ - Automatic subscription after Rails initialization when Karst is enabled.
44
+ - Immutable, minimal SQL events constructed internally from valid `sql.active_record` notifications.
45
+ - Bounded, thread-safe, process-local retention of recent events through `Karst.buffer`.
46
+ - Experimental, conservative SQL canonicalization independent of event capture, preserving structural casts and list cardinality while normalizing supported literals, whitespace, and ordinary comments.
47
+ - Internal deterministic query-shape identity: a SHA-256-based fingerprint over canonicalized SQL, with declared `IN (?+)` placeholder-list arity equivalence, feeding an immutable `Karst::Sql::Shape` that aggregates count, cache hits, duration statistics, and up to three sample events (first, slowest, latest) per shape.
48
+ - `Karst.window`, Karst's first public analysis API: one immutable `Karst::Sql::Window` snapshot per call, derived from exactly one `Karst.buffer.to_a` read and grouped into `shapes` (deterministically ordered by count, then duration, then fingerprint) and `declined` events, with `event_count`, `capacity`, and `saturated` reporting whether older events may already have been evicted from the retained window.
49
+ - `Buffer#capacity`, exposing the fixed capacity of the retained buffer so `Karst.window` can report it.
50
+ - `GET /karst`, a development-only HTTP evidence surface served by a small Rack middleware (no engine, route, or controller) that presents `Karst.enabled?`, `Karst.subscribed?`, and basic `Karst.window` counts. Loopback-only, gated by `Rails.env.development?` at both insertion and request time, and transparent to every other request.
51
+ - `Karst::Spec::Observer`, an opt-in RSpec integration (`require "karst/spec/observer"`) that turns real spec execution into a deterministic JSON scenario catalog: for every example that reaches a browser-facing (HTML) Rails request, it records the request's method, recovered route pattern, controller/action, format, status, and redirect target (with any query string stripped, since redirect targets can carry the same class of secret as request paths), alongside the Warden principal immediately before and after that request and whether it changed -- raw evidence rather than a "setup versus subject" classification, since a single request offers no reliable signal for telling a signup or checkout-completion route that happens to authenticate apart from a login route. Also records the example's stable id, file/line, nested description, and outcome. Built entirely from `ActiveSupport::Notifications` and Warden's public hooks; never parses spec source, route-helper arguments, or FactoryBot calls, and never persists into the host application's database.
52
+ - `Karst::Spec::Catalog`, a read-only index over the JSON artifact `Karst::Spec::Observer` writes, answering `catalog.scenarios_for(controller:, action:, http_method: nil)` from an immutable `Karst::Spec::Scenario` per browser-facing request -- one example that issues several such requests (a denied attempt, then an allowed retry) legitimately produces one Scenario each. Indexed by controller/action, Rails' own stable routing identity, so dynamic id segments never fragment lookup; `http_method` narrows further only when one controller/action answers more than one verb. Loads from `tmp/karst/scenarios.json` by default (or `Rails.root`-relative when Rails is loaded), reports an explicit `:missing`/`:invalid`/`:ready` status so "not yet generated" is never confused with "zero scenarios observed," and skips malformed individual entries rather than failing the whole artifact. `observed_status`/`observed_redirect` name what a spec run observed, not what it asserted; `example_outcome` (`passed`/`failed`/`pending`) keeps a failing example's evidence visible without presenting it as verified. Reuses `Karst::Spec::Principal`'s type/id/scope evidence as-is, reintroduces no setup-versus-subject classification, and requires no RSpec, Rails, or database access to read an already-written artifact. Each Scenario keeps both `principal_before` and `principal_after` rather than collapsing to one side, since a signup or checkout-completion scenario is exactly the case where the identity a request produces is the evidence that matters.
53
+ - Explicit, data-only RSpec scenario names through `karst: "Name"` or `karst: { name: "Name" }` metadata, serialized alongside the existing stable example provenance. Malformed opt-in metadata fails the example with a clear configuration error; unannotated discovery remains unchanged.
54
+ - Ruby 2.7 and Rails 6.1 support, backed by a blocking CI job: `require "karst"`, runtime SQL evidence, the spec observer, the scenario catalog, and `/karst` all work unchanged on that floor. The page-local badge is the one capability that degrades there (Rack 2 cannot safely expose a bufferable response body to rewrite); `/karst` remains directly reachable. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy).
55
+ - `Karst::Value`, a small internal `Struct`-based immutable value-object helper standing in for Ruby 3.2's `Data.define` across every Karst value object (`Sql::Event`, `Sql::Shape`, `Sql::Window`, `Spec::Principal`, `Spec::RequestObservation`, `Spec::ExampleObservation`, `Spec::Scenario`), used uniformly on every supported Ruby.
56
+ - `Karst::ExecutionContext`, a small internal request-local storage seam used by the page badge and the spec observer: delegates to `ActiveSupport::IsolatedExecutionState` where available, and falls back to a per-thread store on Rails 6.1.
57
+ - `bin/rails generate karst:install`, optional Rails scaffolding for the host-specific seams Karst cannot safely infer: a documented, entirely commented-out `config/initializers/karst.rb` placeholder for every identity hook, a small explicitly named `KarstIdentityController` whose `create` action resolves the submitted principal strictly through `Karst::Identity.resolve` (never a bare `Model.find`, so it can never reach outside the configured `config.principals` scope) before raising `NotImplementedError` with a `TODO` until a developer wires up this application's real authentication against that already-resolved principal, and development-only routes for that controller (idempotent across repeated runs, via Thor's own file-collision handling and a duplicate-safe route insertion). Implements no Devise/Warden/generic authentication mechanism and never runs automatically; existing manual `Karst.configure` setups have no need to run it.
58
+ - `Karst::Access::PrincipalSampler`, an optional candidate-selection step ahead of the experimental access sweep: over an Active Record relation or model class it replaces "first 25 rows" with up to `access_sweep_limit` deterministic principals chosen for database-state diversity rather than whatever happens to sort first -- boolean columns, `enum` columns, nullable-foreign-key presence/absence, and other low-cardinality scalar columns (this is schema-state diversity the sampler observed in the database, not behavioral diversity; it never executes a route). Column candidacy requires an observed cardinality of 10 or fewer (via one bounded `DISTINCT ... LIMIT` query per candidate column, never a full-table scan or `COUNT(*)`), a conservative PII-aware column-name filter that unconditionally excludes anything resembling email, name, phone, address, token, password, or other sensitive fields regardless of cardinality, and a separate name-based exclusion for foreign keys shaped like a tenant/account/organization boundary (`tenant_id`, `account_id`, and similar) -- checked independently of nullability and cardinality, since a *nullable* such column would otherwise reach presence/absence sampling without ever going through the cardinality check. Query volume is bounded by dimension and limit counts, not row count (verified flat between 300 and 8,000 rows in the test suite), and is enforced as a hard invariant at every query-issuing call site via `PrincipalSampler.query_budget(limit)` -- `#call` may return fewer than `limit` principals if the budget is exhausted, but never issues more queries than that budget declares. Never escapes the configured principal scope (an already tenant-scoped relation stays tenant-scoped), and returns each selected principal alongside the minimal evidence (e.g. `"premium=true"`) that earned it a slot. Raises `Karst::Access::PrincipalSampler::UnsupportedPrimaryKey` (a `Karst::Access::Error`) for an Active Record source with a composite or missing primary key, rather than failing obscurely mid-query. Falls back to the existing bounded-first strategy, unchanged, for any non-Active-Record Enumerable source. `/karst` prefers this over "first 25" whenever the configured principal source supports it, purely as a label distinction -- selection, execution, identity assumption, and rollback semantics in `Karst::Access::Sweep` itself are unchanged.
59
+ - `Karst::Access::ResourceEvidence`, a read-only, opt-in follow-up step for one specific `Access::Sweep` outcome: given the exact resource a route addresses and one specific principal, it reports simple, directly observed foreign-key relationships between those two records (e.g. `Document#user_id` pointing at that exact `User#id`) -- evidence, not an authorization claim, and never phrased causally. Only foreign-key-shaped columns (ending in `_id`) are ever inspected, so no other attribute (name, email, token, ...) is read or shown; only a direct column-value comparison between the two given records is made, never a join, a `has_many` traversal, or any multi-hop graph walk. `.for_outcome` additionally resolves the resource from a route path using only Rails' own route recognition plus its controller-to-model naming convention, and only trusts that resolution when every step succeeds unambiguously (a recognized route with an `:id` segment, a controller name that classifies to a real loaded Active Record class, and a record that actually exists for that id); anything softer -- an unrecognized route, a controller with no conventional model, a missing record -- produces a reported limitation rather than a guess. `Result#to_text` renders the plain evidence format (principal, observed status, then related state).
60
+ - Candidate population discovery and curation, an opt-in workflow on top of `config.principal_populations`/`config.principal_sources`. `Karst::Access::PopulationDiscovery` uses Ruby's standard-library Ripper AST parser to list only statically named, zero-argument Rails `scope` declarations made directly in application model source; it never enumerates or invokes arbitrary class methods, executes scope bodies, or issues SQL. Concern-contributed scopes are explicitly outside this initial discovery boundary. Run it via `bin/rails karst:populations` or the `/karst/populations` page (linked from the main panel). The page groups scopes by model, collapsed by default behind native `<details>` (small vanilla JS only powers client-side search/filter -- no frontend framework), stays usable at 150 models/500 scopes, and surfaces the current selection first. `Karst::Access::PopulationPreview` is a separate, explicit, `LIMIT 3`-bounded validation step (never a `COUNT`) for one discovered scope at a time. `Karst::Access::PopulationConfigSnippet` renders a curated selection into copy-pasteable `config.principal_populations`/`config.principal_sources` Ruby -- nesting per source when more than one is involved, so the same population name on two different models stays distinguishable -- and never writes to the host application's files. The main `/karst` panel gains a **Try another population** guided retry, shown only once an analysis found no usable outcome: one button per already-*approved* (configured, not merely discovered) population, running a fresh bounded sweep against just that population, respecting the existing `access_sweep_limit`. When the analysis observed a halted controller callback, approved populations are optionally ranked by `Karst::Access::PopulationSuggestion`, a transparent, no-AI substring-overlap name heuristic that always shows every approved population and never claims the suggested one will change the outcome -- only running it does.
61
+
62
+ ### Changed
63
+
64
+ - Restructured `/karst` around its primary workflow -- "which existing principal can I use to test what I'm looking at" -- rather than giving equal visual weight to route context, spec evidence, access analysis, and raw SQL evidence. A compact route header (method, path, controller/action) replaces the old verbose route form; **Analyze N principals** is the obvious primary action; usable principals and their **Test as** actions remain prominent (per the actionable hierarchy above); a currently-assumed browser identity is now shown as a clear banner exposing **Stop testing as**, worded so it never implies Karst can restore a previous session. Spec evidence and Runtime SQL evidence are unchanged in substance but are now demoted under a collapsed **Diagnostics** section (native `<details>`/`<summary>`, no JavaScript), each summarized by a useful count (e.g. "Spec evidence — 3 matching scenarios", "Runtime SQL — 544 observations · 66 shapes") and expanded only on request; a disabled SQL capture state is called out instead of silently collapsing. Coherent, non-dominating states were added for an unconfigured principal source, unconfigured browser Test-as, and no route selected yet.
65
+ - Reorganized `/karst` access results around a host-configurable usable-outcome presentation policy (2xx by default): usable sampled principals and their Test-as actions are prominent, exact-resource relationship evidence is shown when available, and every other raw outcome remains preserved in a subordinate expandable section. Resource-evidence principal resolution now obeys the configured principal source.
66
+ - Marked configuration, buffer, subscription, and the experimental SQL canonicalizer as private implementation constants.
67
+ - Lowered `required_ruby_version` to `>= 2.7` and the `activesupport` dependency to `>= 6.1, < 9`.
@@ -0,0 +1,29 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our pledge
4
+
5
+ We pledge to make participation in Karst a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6
+
7
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8
+
9
+ ## Our standards
10
+
11
+ Examples of behavior that contributes to a positive environment include demonstrating empathy, respecting differing opinions, giving and accepting constructive feedback, taking responsibility for mistakes, and focusing on what is best for the community.
12
+
13
+ Unacceptable behavior includes sexualized language or attention, trolling or insulting comments, harassment, publishing others' private information without permission, and other conduct that could reasonably be considered inappropriate in a professional setting.
14
+
15
+ ## Enforcement responsibilities
16
+
17
+ Project maintainers are responsible for clarifying and enforcing these standards and may remove, edit, or reject contributions or participation that they deem inappropriate, threatening, offensive, or harmful.
18
+
19
+ ## Scope
20
+
21
+ This Code of Conduct applies in all project spaces and when an individual is officially representing the project in public spaces.
22
+
23
+ ## Enforcement
24
+
25
+ Report abusive, harassing, or otherwise unacceptable behavior privately through the repository's maintainers. All complaints will be reviewed promptly and fairly. Maintainers will respect the reporter's privacy and security.
26
+
27
+ ## Attribution
28
+
29
+ This Code of Conduct is adapted from the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,45 @@
1
+ # Contributing to Karst
2
+
3
+ Thank you for helping build Karst. Contributions should preserve its central principle: observation comes before recommendation.
4
+
5
+ ## Getting started
6
+
7
+ 1. Fork and clone the repository.
8
+ 2. Run `bin/setup` to install dependencies.
9
+ 3. Run `bin/test` to execute all checks.
10
+
11
+ ## Coding style
12
+
13
+ - Follow the project RuboCop configuration and idiomatic Ruby conventions.
14
+ - Keep units small, composable, and explicit.
15
+ - Do not make claims about application behavior without captured evidence.
16
+ - Keep Rails integration boundaries narrow and avoid unnecessary coupling.
17
+ - Add documentation for public APIs and decisions that are not self-evident.
18
+
19
+ ## Commits
20
+
21
+ Write focused commits with imperative, descriptive subjects. A commit should contain one coherent change and should leave the test suite passing. Explain important motivation in the commit body rather than restating the diff.
22
+
23
+ ## Pull requests
24
+
25
+ Open a focused pull request and complete the template. Describe the runtime behavior affected, the evidence supporting the change, and any compatibility implications. Keep unrelated refactoring separate and update documentation and the changelog when appropriate.
26
+
27
+ Draft pull requests are welcome for early design feedback. Please discuss broad architectural changes in an issue before investing in an implementation.
28
+
29
+ ## Tests
30
+
31
+ Every behavior change requires tests at the narrowest useful level. Bug fixes should include a regression test. Tests must be deterministic and must not depend on network services. Run `bin/test` before submitting a pull request; CI runs RSpec and RuboCop.
32
+
33
+ ### Rails compatibility
34
+
35
+ Karst's compatibility harness currently covers Rails 6.1 on Ruby 2.7, Rails 7.0 and 7.1 on Ruby 3.2, and Rails 7.2 and 8.0 on Ruby 3.3. The Rails 6.1 / Ruby 2.7 job is blocking, the same as every other row: Karst claims that floor because CI proves it, not the other way around. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy) for how optional features (currently: the page-local badge) degrade on that floor instead of raising.
36
+
37
+ The repository uses version-specific Gemfiles under `gemfiles/`. This keeps each dependency set explicit and lets Bundler and CI use their standard `BUNDLE_GEMFILE` behavior without an additional dependency-management tool. Matrix lockfiles are intentionally not committed: compatibility CI resolves the current dependency set allowed by each Rails line, so it detects dependency-resolution regressions instead of only testing a previously locked snapshot. To run one target locally:
38
+
39
+ ```sh
40
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle install
41
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile EXPECTED_RAILS_VERSION=7.2 \
42
+ bundle exec rspec spec/integration
43
+ ```
44
+
45
+ Run every compatibility target with `bin/test-rails`. To add a Rails version, add a version-specific Gemfile, add it to `bin/test-rails`, and add the matching Rails/Ruby entry to the `rails-integration` matrix in `.github/workflows/ci.yml`. Choose a Ruby version on which that Rails release installs and runs, and keep linting out of the compatibility matrix.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chad Snow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # Karst
2
+
3
+ Karst finds a real, existing user who can reach a page in your Rails app — by actually running the route as your users and reporting what happened.
4
+
5
+ "Which user can access this page?" is normally answered by reading role checks and `before_action` filters by hand, or asking around until someone remembers a working login. Karst answers it by running the real route, through your real Rails request stack, as a bounded set of real users — and shows you the evidence: HTTP status, redirect, halted callback, exception.
6
+
7
+ ```text
8
+ GET /admin/imports/123
9
+
10
+ Ordinary sample
11
+ 25 users tested · none verified usable
12
+ halted at authorize_admin
13
+
14
+ system_admins
15
+ User #27 → 200 OK ✓
16
+
17
+ [ Test as User #27 ]
18
+ ```
19
+
20
+ That's the whole product. Everything below is how to get there.
21
+
22
+ ## Quick start
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "karst", group: :development
27
+ ```
28
+
29
+ ```bash
30
+ bundle install
31
+ bin/rails server
32
+ ```
33
+
34
+ Open `/karst` in your browser.
35
+
36
+ Using Devise with one user model? That's usually it — Karst finds it automatically through Devise's own routing metadata, with no configuration. If Karst finds more than one Devise model, it asks you to pick which one(s) to test right there on the `/karst` panel — no initializer, no restart. If it finds none at all, it says so instead of guessing.
37
+
38
+ Custom or non-Devise authentication needs a few lines of setup — see [Custom authentication](#custom-authentication) below.
39
+
40
+ ## How it works
41
+
42
+ 1. Open any page in development. Karst adds a small **Karst** badge in the corner, already scoped to the controller/action that rendered it.
43
+ 2. Click it (or visit `/karst` directly) and press **Who can use this?**. Karst runs the route through your real Rails stack as a bounded set of existing users — 25 by default — inside a database transaction it rolls back.
44
+ 3. If none of those work, Karst automatically tries a few users from any [candidate populations](#candidate-populations) you've approved, such as `system_admins`.
45
+ 4. Every result shows what actually happened: status, redirect, halted callback, exception, and observed database writes.
46
+ 5. Found a usable user? Click **Test as** to become them in your own browser and keep working.
47
+
48
+ ## Candidate populations
49
+
50
+ Sometimes the right user is rare and won't show up in a normal recent-user sample. Karst can find these groups itself — no configuration needed. When the ordinary sample comes up empty, `/karst` says so:
51
+
52
+ ```
53
+ No verified usable user found
54
+ Karst found 3 application-defined user groups that could be tried. [ Review candidate groups ]
55
+ ```
56
+
57
+ Open `/karst/populations`, check the groups Karst may try (`system_admins`, `auditors`, ...), and press **Approve**. From then on, approved groups are searched automatically — through `/karst`, the CLI, and MCP alike — until one produces a usable user. Approving is a hint, never a claim: Karst only reports that a user was *sampled from* `system_admins`, never that the group is what granted access.
58
+
59
+ Need populations committed as reviewable code, or applied outside your own machine (CI)? `config.principal_populations` does that and always takes precedence over an approval of the same name — see [docs/advanced-configuration.md](docs/advanced-configuration.md#curating-candidate-populations) for discovery, approval, and precedence details.
60
+
61
+ ## What Karst shows you
62
+
63
+ For every user it tries, Karst reports:
64
+
65
+ - HTTP status and redirect target
66
+ - the halted Rails callback, if the request was stopped by one
67
+ - any raised exception
68
+ - observed database writes
69
+ - which user was tested, and which configured population (if any) produced them
70
+
71
+ When it can, Karst also shows how the tested user relates to the resource on the page — for example, `Document #22 → user_id → User #27`.
72
+
73
+ Karst reports observations, not authorization conclusions. If Rails halted at `authorize_admin`, Karst reports that callback name; it does not claim the user lacks permission unless your application says so itself.
74
+
75
+ ## CLI
76
+
77
+ ```bash
78
+ bin/rails karst:verify GET /admin/imports/123
79
+ bin/rails karst:verify GET /admin/imports/123 --json
80
+ ```
81
+
82
+ Runs the same search as `/karst` from a shell. Exit code `0` means a usable user was found, `1` means the search completed without one, `2` means a setup error. The `--json` form is a stable, schema-versioned evidence document meant for scripts and tools.
83
+
84
+ ## Coding agents
85
+
86
+ ```bash
87
+ bin/rails karst:mcp
88
+ ```
89
+
90
+ ```json
91
+ {
92
+ "mcpServers": {
93
+ "karst": { "command": "bin/rails", "args": ["karst:mcp"] }
94
+ }
95
+ }
96
+ ```
97
+
98
+ Claude Code or another [MCP](https://modelcontextprotocol.io) client can call `verify_access` and get back exactly the evidence `karst:verify --json` prints. An agent can guess who *should* have access by reading code; only Karst can show who actually does. The agent picks the path and method — it can't choose a user, skip the rollback, or use Test As.
99
+
100
+ ## Configuration
101
+
102
+ Usually, you don't. A conventional Devise app needs no initializer at all: the user model comes from Devise's own routing metadata, sampling states come from your schema, and candidate populations are approved at `/karst/populations` rather than written down.
103
+
104
+ The one option worth knowing is the off switch:
105
+
106
+ ```ruby
107
+ Karst.configure { |config| config.enabled = false } # on by default in development and test
108
+ ```
109
+
110
+ Everything else is for exceptional applications — custom authentication, several user models, populations committed as code for CI, and a few bounds most developers never touch. All of it lives in [docs/advanced-configuration.md](docs/advanced-configuration.md).
111
+
112
+ ## Custom authentication
113
+
114
+ Not using Devise, or authenticating some other way? Tell Karst how to sign a user in and out for a probe request:
115
+
116
+ ```ruby
117
+ Karst.configure do |config|
118
+ config.principals = -> { Account.active }
119
+ config.assume_identity = lambda do |session, account|
120
+ session.post "/karst_test_login", params: { account_id: account.id }
121
+ end
122
+ config.clear_identity = ->(session) { session.delete "/karst_test_logout" }
123
+ end
124
+ ```
125
+
126
+ The compatibility-preserving `bin/rails generate karst:install` command optionally scaffolds this custom-authentication escape hatch. Replace its `TODO`s with your app's real sign-in/sign-out code. A conventional single-model Devise app needs none of its initializer, controller, or routes. Browser **Test as** needs a second, similar pair of hooks (`config.assume_browser_identity` / `config.clear_browser_identity`) — see [docs/advanced-configuration.md](docs/advanced-configuration.md).
127
+
128
+ ## Safety
129
+
130
+ Karst is for local development only — `/karst`, the badge, and Test As only work from loopback requests while `Rails.env.development?` is true. Every search is bounded (25 users by default, 100 max), and every probe runs inside a database transaction Karst rolls back.
131
+
132
+ That rollback only covers writes made through the same Active Record connection. Jobs, mail, external HTTP calls, files, Redis, and other database connections aren't covered — a route that triggers those can still cause real side effects even though its own database writes are undone.
133
+
134
+ ## Compatibility
135
+
136
+ Ruby 2.7+, Rails 6.1+. Every core capability (`/karst`, CLI, MCP, access search) works across that whole range; the page-local badge needs Rack 3 (Rails 7.1+) and is simply absent on Rails 6.1/7.0 — `/karst` itself is unaffected. See [ARCHITECTURE.md](ARCHITECTURE.md#compatibility-policy) for the CI-backed matrix.
137
+
138
+ ## Contributing
139
+
140
+ Contributions and design discussion are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow.
data/SECURITY.md ADDED
@@ -0,0 +1,11 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ Karst has not released a supported version yet. This policy will be updated when releases begin.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Please do not disclose suspected vulnerabilities in a public issue. Use GitHub's **Report a vulnerability** feature on the repository's Security tab to send a private report to the maintainers.
10
+
11
+ Include a description, reproduction steps, potential impact, and any suggested mitigation. You can expect an acknowledgement within seven days. The maintainers will coordinate validation, remediation, and disclosure with the reporter.