reeve 0.0.1 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +191 -0
  3. data/README.md +280 -17
  4. data/lib/generators/reeve/install/install_generator.rb +46 -0
  5. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +77 -0
  6. data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
  7. data/lib/reeve/audit/entry.rb +54 -0
  8. data/lib/reeve/audit/query.rb +84 -0
  9. data/lib/reeve/audit/recorder.rb +181 -0
  10. data/lib/reeve/audit/redactor.rb +67 -0
  11. data/lib/reeve/audit.rb +97 -0
  12. data/lib/reeve/authorization/adapter.rb +71 -0
  13. data/lib/reeve/authorization/adapters/plain.rb +65 -0
  14. data/lib/reeve/authorization/adapters/pundit.rb +97 -0
  15. data/lib/reeve/authorization/authorizer.rb +23 -0
  16. data/lib/reeve/authorization/current.rb +66 -0
  17. data/lib/reeve/authorization/declaration.rb +73 -0
  18. data/lib/reeve/authorization/guard.rb +97 -0
  19. data/lib/reeve/authorization/registry.rb +118 -0
  20. data/lib/reeve/authorization/scoper.rb +399 -0
  21. data/lib/reeve/authorization.rb +76 -0
  22. data/lib/reeve/configuration.rb +158 -0
  23. data/lib/reeve/context.rb +111 -0
  24. data/lib/reeve/decision.rb +111 -0
  25. data/lib/reeve/errors.rb +89 -0
  26. data/lib/reeve/fast_mcp.rb +24 -0
  27. data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
  28. data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
  29. data/lib/reeve/invocation.rb +260 -0
  30. data/lib/reeve/minitest.rb +19 -0
  31. data/lib/reeve/rspec.rb +18 -0
  32. data/lib/reeve/scope_result.rb +104 -0
  33. data/lib/reeve/testing/assertions.rb +76 -0
  34. data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
  35. data/lib/reeve/testing/checks/base.rb +155 -0
  36. data/lib/reeve/testing/checks/contract_version.rb +97 -0
  37. data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
  38. data/lib/reeve/testing/checks/guard_declared.rb +33 -0
  39. data/lib/reeve/testing/checks/principal_required.rb +60 -0
  40. data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
  41. data/lib/reeve/testing/checks/rule_present.rb +55 -0
  42. data/lib/reeve/testing/checks.rb +95 -0
  43. data/lib/reeve/testing/compliance_assertions.rb +34 -0
  44. data/lib/reeve/testing/compliance_suite.rb +23 -0
  45. data/lib/reeve/testing/ledger.rb +82 -0
  46. data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
  47. data/lib/reeve/testing/matchers/base.rb +84 -0
  48. data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
  49. data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
  50. data/lib/reeve/testing/matchers.rb +33 -0
  51. data/lib/reeve/testing/report.rb +58 -0
  52. data/lib/reeve/testing/result.rb +49 -0
  53. data/lib/reeve/testing.rb +60 -0
  54. data/lib/reeve/version.rb +1 -1
  55. data/lib/reeve.rb +10 -3
  56. metadata +53 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c59f8e6c82441798415dbe8c2a0f53820615dfe8fcce4d96294ccc46d355208
4
- data.tar.gz: 670b19c4de2bc2fab5c16b4fbb7c958b3ea64cede00615f219032ef27161626f
3
+ metadata.gz: aaf976c70abfbb4d56236acface7e1ef95a4e8c177452e4ef918ca22f45933d0
4
+ data.tar.gz: 9076bbad0582db6c4cda3c38dc89f97840dc3a7e2dbb99ca98a16c5b642ece57
5
5
  SHA512:
6
- metadata.gz: 148721a9d10024026ff713a59ddf0fdf6cb1c32691f4c78c79961337a654f08c8f08b3445ba8a4630c00ce563553352c59194cc7355f00eae50942cf0f61cae0
7
- data.tar.gz: 6b2b69206f97d2ca3278a3ccb37ba63a4ab74447a39de01f6e0c5ebe1c53aff5958a4c2389fafa0d55ae192bdd4035ed1df9d224e8fa49c7c4e52064bcdbe887
6
+ metadata.gz: 7957d35a7e9df67acaa1c6babcd18b1d2340ffd63595fc88a11e16bd5c81cfa78ddb6b6cf17742f74b1f300d5e35058442d124fe77d6cc7051005e3ed4d5b837
7
+ data.tar.gz: 543bb727d1abf718053d21a72fe3d3eb62b9e82ebe5a3658046b7d80eb8c897c91e76da7d6c55842f1ee0545b76af94a2d1127ffa2fc814a88bbadab3c827a01
data/CHANGELOG.md CHANGED
@@ -1,5 +1,196 @@
1
1
  # Changelog
2
2
 
3
+ All notable changes are recorded here. This project follows [Semantic
4
+ Versioning](https://semver.org), with one rule specific to what it does — see
5
+ [Versioning policy](#versioning-policy).
6
+
7
+ ## [0.2.0] - 2026-08-12
8
+
9
+ Everything here came out of running 0.1.0 against a real Rails 8.1 application rather than
10
+ against its own test suite. The Pundit fix is the one that blocks adoption.
11
+
12
+ **Audit-entry contract version: `1` → `2`.** Two changes, one bump.
13
+
14
+ `metadata` was written NULL on every row through version 1 and now carries what the caller
15
+ passed. Anything mapping version 1 rows could reasonably have read that column as always
16
+ empty, and this project's own versioning rule calls a change in what a value means MAJOR —
17
+ hence 0.2.0 rather than a patch.
18
+
19
+ New non-null `contract_version` column, stamped on every row. Version 1 rows could not
20
+ name their own shape, which is exactly what made the `metadata` change ambiguous to a
21
+ reader: on a version 1 row a NULL `metadata` means "never recorded", on a version 2 row it
22
+ means "the caller passed none". Stamping the row settles that at read time instead of
23
+ requiring a reader to know when the writing gem was deployed, and it makes every future
24
+ bump legible on the row. It also gives `Checks::ContractVersion` real teeth — a stale
25
+ table now fails on the missing column even when a bump was purely semantic.
26
+
27
+ **Upgrading from 0.1.0:**
28
+
29
+ - Re-run `bin/rails generate reeve:install` and migrate, or add the column by hand:
30
+ `add_column :reeve_audit_entries, :contract_version, :integer, null: false, default: 1`
31
+ — `default: 1` is correct for existing rows, since that is the contract they were
32
+ written under. Drop the default afterwards if you prefer; the recorder always sets it.
33
+ - A host pinning `Reeve::Checks::ContractVersion.new(expected: 1)` moves it to `2`.
34
+ - **A custom `audit_recorder` that writes to `Reeve::Audit::Entry` must now set
35
+ `contract_version`.** The model rejects a row without it.
36
+
37
+ ### Added
38
+
39
+ - README: **Wrapping your own JSON-RPC server** — the integration path for a Rails app
40
+ that already exposes `/mcp` from its own controller and tool registry. Covers mapping
41
+ JSON-RPC tool names to `Reeve.invoke`, passing request headers as `metadata`, resolving
42
+ the principal from `Current.user` or a bearer token while keeping the host's existing
43
+ authentication, adopting one tool at a time through `:allow_with_warning`, and pointing
44
+ the compliance checks at the host's own dispatcher. The recipe is executed by
45
+ `spec/reeve/integrations/custom_dispatcher_spec.rb` rather than only written down.
46
+ ([#5](https://github.com/vicmaster/reeve/issues/5))
47
+
48
+ ### Fixed
49
+
50
+ - `metadata` passed to `Reeve.invoke` is written to the ledger. The column, the recorder
51
+ mapping and the contract check all existed, but `Context#to_h` did not carry the value,
52
+ so `metadata` was NULL on every call ever recorded — including the header hash the
53
+ fast-mcp adapter collects. It is redacted on the way in, by the same redactor and the
54
+ same declared names as the arguments, so `Authorization` does not land in the ledger.
55
+ This is the change behind the contract-version bump above.
56
+ - `contracts/audit-entry.md` no longer claims the contract version is "recorded in the
57
+ initializer". It never was — which is what prompted the `contract_version` column above,
58
+ so that from contract 2 the claim is simply true.
59
+ - The envelope-overhead spec compares the fastest call on each side rather than the mean
60
+ of two separately-timed windows. A single GC pause or scheduler preemption landing in
61
+ one window and not the other lands whole in the difference — 300ms over fifty runs is
62
+ 6ms per call against a 5ms budget, from a busy machine rather than a regression. It
63
+ loses no sensitivity: a regression is paid on every call, so it slows the fastest trial
64
+ too. Verified both ways — an injected one-off stall moves the measurement from 6.65ms to
65
+ 0.23ms, and 6ms added to every call still fails at 7.13ms.
66
+ - The spec suite declares UTF-8 (`spec/spec_helper.rb`). The README-spec encoding bug was
67
+ one instance of five: eleven examples across `readme_spec`, `contract_version_spec`,
68
+ `migration_spec`, `install_generator_spec`, `framework_neutrality_spec`,
69
+ `isolation_spec` and `verification_spec` died on `invalid byte sequence` whenever a file
70
+ was run on its own, and passed in a full run only because some other file happened to
71
+ set the encoding first. Every spec file now passes in isolation, and the suite passes
72
+ under `LC_ALL=C`.
73
+ - A Pundit policy that inherits its `Scope` from a base policy (`class LeadPolicy <
74
+ LeadBasePolicy`) is now recognised. Policy detection looked only at the policy's own
75
+ namespace, so the ordinary Pundit inheritance pattern was refused at declaration time
76
+ with `ConfigurationError`. Detection now walks the policy's ancestry, stopping before
77
+ `Object` so a top-level `Scope` constant still cannot make an unrelated class look
78
+ Pundit-shaped. ([#3](https://github.com/vicmaster/reeve/issues/3))
79
+ - `lib/reeve.rb` no longer describes the kernel as unreleased work in progress. The note
80
+ belonged to the 0.0.1 name-claim release and contradicted 0.1.0 for anyone reading the
81
+ installed gem. ([#4](https://github.com/vicmaster/reeve/issues/4))
82
+ - The README compliance specs read the file as UTF-8. Under a POSIX locale they read it
83
+ as US-ASCII and every example failed on the first em dash, so the guarantees they
84
+ exist to enforce were never actually checked.
85
+
86
+ ## [0.1.0] - 2026-08-11
87
+
88
+ First working release. The published 0.0.1 was a placeholder holding the gem name.
89
+
90
+ ### Added
91
+
92
+ **Authorization**
93
+
94
+ - `guard_with SomePolicy` declares the policy governing a tool. A tool without one is
95
+ denied (`no_guard_declared`) unless the host opts into `unguarded_tools =
96
+ :allow_with_warning`, which runs the tool unscoped and records `guard: "none"`.
97
+ - `redact :argument_name` keeps an argument's value out of the ledger while keeping its
98
+ name.
99
+ - `scoped(Model)` returns the policy-scoped relation inside a tool body. It is how a
100
+ guarded tool returns anything that is not a record: a count or a summary computed from
101
+ `scoped(...)` is safe because the tool never held unscoped data. A derived value
102
+ returned without it is denied (`unscoped_derived_result`).
103
+ - Policy adapters for plain objects and for Pundit, behind one two-method protocol
104
+ (`authorize`, `scope`). `:auto` picks one and reports which through
105
+ `Reeve.config.resolved_policy_adapter`. Neither Pundit nor any authorization library is
106
+ a runtime dependency.
107
+ - Per-record scoping for relations, arrays, single records, mixed types and derived
108
+ values. A single record outside the principal's scope is refused without naming it.
109
+
110
+ **Audit**
111
+
112
+ - One append-only `reeve_audit_entries` row per invocation, allowed or denied, recording
113
+ the agent, the principal, the tool, post-redaction arguments, returned identifiers, the
114
+ outcome, the rule that decided, and why it decided (`detail`).
115
+ - The write happens in its own transaction, so a tool that raises and rolls back its own
116
+ work still leaves a trace — the invocations most worth recording are the ones that
117
+ failed.
118
+ - A failed ledger write fails the invocation. `audit_failure_mode = :warn` degrades that
119
+ deliberately and must be opted into.
120
+ - `Reeve::Audit::Query` queries the ledger by principal, agent, tool, outcome and time
121
+ range.
122
+ - Entries are read-only after insert and refuse to be destroyed; the generated migration
123
+ documents the `GRANT INSERT, SELECT` that enforces what a library cannot.
124
+
125
+ **Testing kit**
126
+
127
+ - Seven framework-neutral checks (`CrossPrincipalLeak`, `AuditCoverage`, `GuardDeclared`,
128
+ `RulePresent`, `RedactionHolds`, `PrincipalRequired`, `ContractVersion`) plus
129
+ `Checks.run_all`, all plain Ruby that loads no test framework.
130
+ - RSpec front-end (`require "reeve/rspec"`): `deny_access_for`, `audit_every_call`,
131
+ `pass_reeve_check`, and the `"a reeve-compliant server"` shared example group.
132
+ - Minitest front-end (`require "reeve/minitest"`): the same guarantees from a stock
133
+ `rails new` application, with no RSpec installed.
134
+ - Failure messages are built by the checks, so all three front-ends emit identical text.
135
+
136
+ **Adoption**
137
+
138
+ - `rails generate reeve:install` writes the initializer and the ledger migration.
139
+ `principal_resolver` ships as a TODO, because it is the one thing only the host can
140
+ answer, and reeve denies every call until it is filled in.
141
+ - fast-mcp adapter (`require "reeve/fast_mcp"`): the DSL on every tool and the envelope
142
+ around every call, with the principal resolved from the request's headers. Needs Ruby
143
+ 3.1+, because fast-mcp does.
144
+ - `Reeve.invoke` gives the same guarantees with no Rails, no ActiveRecord and no MCP
145
+ server library.
146
+
147
+ ### Known limitations
148
+
149
+ Stated here because finding them yourself later is worse than reading them now.
150
+
151
+ - **A transaction the host wraps around an invocation takes the ledger row with it.** The
152
+ recorder writes in a savepoint, which protects the trace from a transaction the *tool*
153
+ opens and rolls back, but not from one already open around the whole call — a controller
154
+ that wraps each request, or a test suite using transactional fixtures. Reeve detects this
155
+ and warns, naming the invocation. Real isolation needs a second connection and is not
156
+ portable: on SQLite the enclosing transaction holds the write lock and a second
157
+ connection times out. Hosts needing durability there supply their own `audit_recorder`.
158
+ - **An unscoped fetch by id still discloses existence.** `Invoice.find_by(id:)` denies for
159
+ a record that exists but belongs to someone else, and returns `nil` for one that does not
160
+ exist. Fetching through `scoped(...)` makes both answers `nil`.
161
+ - **A scope-less type must be tied to its policy.** A plain object with an `id` is a
162
+ record, but a type with no relation cannot be checked against a policy scope — only
163
+ against `authorize`, which is often permissive. Such a type is trusted when a policy is
164
+ named for it or a `scoped(...)` call establishes provenance, and denied otherwise.
165
+ - **A call that cannot be recorded fails, even when it was already failing.** An
166
+ unrecordable denial raises `AuditWriteError` carrying the denial as `#during`, rather
167
+ than the denial itself. With no recorder configured at all, that is the first thing you
168
+ will see, and it names the missing ledger.
169
+ - **`AuditCoverage` proves one invocation is recorded**, not every invocation.
170
+ - **The fast-mcp adapter needs Ruby 3.1+**, because fast-mcp depends on dry-schema. The
171
+ core supports 3.0.
172
+ - CI runs SQLite only; MySQL and PostgreSQL rest on ActiveRecord's portability.
173
+
174
+ ### Notes
175
+
176
+ - Ruby 3.0+, Rails 7.0+, zero runtime dependencies.
177
+ - Publishing requires MFA on the owner's account (`rubygems_mfa_required`).
178
+
3
179
  ## [0.0.1] - 2026-08-11
4
180
 
5
181
  - Placeholder release reserving the gem name. No functionality.
182
+
183
+ ## Versioning policy
184
+
185
+ Semantic versioning, with one project-specific rule that follows from what this library
186
+ is for:
187
+
188
+ - **A change that narrows what a tool returns is a security fix and may ship in a patch.**
189
+ If reeve was letting a record through that a policy did not permit, closing that is not
190
+ a breaking change, however much it changes behaviour for someone relying on it.
191
+ - **A change that widens access is breaking by definition** — major version, plus a
192
+ migration note saying exactly what became visible that was not visible before.
193
+ - Adding a nullable ledger column is minor and leaves the audit-entry contract version
194
+ alone. Removing or renaming a column, or changing what a value means, is major and bumps
195
+ it.
196
+ - The audit-entry contract version is `1`.
data/README.md CHANGED
@@ -7,8 +7,9 @@ A Ruby gem that makes it safe for a Rails application to expose MCP tools to AI
7
7
  declarative per-record authorization, an append-only audit ledger, and a testing kit that
8
8
  proves both hold.
9
9
 
10
- > **Status: 0.0.1 is a placeholder release reserving the gem name. There is no working
11
- > code yet.** Development is in progress don't install this expecting it to do anything.
10
+ > **Status: 0.2.0.** Read the known limitations in
11
+ > [CHANGELOG.md](CHANGELOG.md) before adopting itparticularly the one about a
12
+ > transaction wrapped around an invocation, if your application wraps requests in one.
12
13
 
13
14
  *A reeve is an official who acts with delegated authority on behalf of someone else — the
14
15
  root of "sheriff" (shire-reeve). That delegation is exactly what this gem governs: an agent
@@ -27,11 +28,38 @@ in CI.
27
28
 
28
29
  That's the gap Reeve fills. It's an extension layer, not a competitor — it rides all three.
29
30
 
30
- ## The shape of it
31
+ ## Three steps
32
+
33
+ ```bash
34
+ bundle add reeve
35
+ bin/rails generate reeve:install
36
+ bin/rails db:migrate
37
+ ```
38
+
39
+ **1. Say who the agent is acting for.** This is the only thing Reeve cannot work out for
40
+ itself, and the generated initializer leaves it as a TODO:
41
+
42
+ ```ruby
43
+ # config/initializers/reeve.rb
44
+ Reeve.configure do |config|
45
+ config.principal_resolver = lambda do |context|
46
+ ApiToken.find_by(token: context.metadata.dig(:headers, "Authorization"))&.user
47
+ end
48
+
49
+ config.unguarded_tools = :deny # :deny | :allow_with_warning
50
+ config.redact_arguments = %i[password token ssn]
51
+ end
52
+ ```
53
+
54
+ Until that resolver is set, every guarded call denies with `no_principal`. That is the
55
+ intended behaviour: the library is safe before it is configured.
56
+
57
+ **2. Guard a tool.**
31
58
 
32
59
  ```ruby
33
60
  class InvoiceSearchTool < FastMcp::Tool
34
61
  guard_with InvoicePolicy # the only line you add
62
+ redact :customer_ssn
35
63
 
36
64
  def call(query:)
37
65
  Invoice.where("number LIKE ?", "%#{query}%")
@@ -39,24 +67,259 @@ class InvoiceSearchTool < FastMcp::Tool
39
67
  end
40
68
  ```
41
69
 
42
- Three things then hold:
70
+ **3. Prove it in CI.**
71
+
72
+ ```ruby
73
+ RSpec.describe InvoiceSearchTool do
74
+ it { is_expected.to deny_access_for(stranger).with(query: "AC") }
75
+ it { is_expected.to audit_every_call }
76
+ end
77
+ ```
78
+
79
+ Three things now hold, and each is a test you can run rather than a claim you have to
80
+ trust.
81
+
82
+ ## Deny by default
83
+
84
+ A tool returns only the records its principal may see. No principal, no `guard_with`, or a
85
+ policy that raises means no records — never a silent pass. A single record outside the
86
+ principal's scope is refused in a way that does not reveal whether it exists:
87
+
88
+ ```ruby
89
+ Reeve.invoke(tool: InvoiceShowTool, arguments: { id: 41 }, principal: alice)
90
+ # => Reeve::DeniedError: reeve denied invoice_show_tool for principal 1:
91
+ # out_of_scope_record (the requested record is not within this principal's scope)
92
+ ```
93
+
94
+ The error names the rule and never names the record. One caveat worth knowing: fetching
95
+ from the unscoped model still lets a caller tell "someone else's" (a denial) from "no such
96
+ record" (`nil`). If that distinction matters, fetch through `scoped`, where both answers
97
+ are `nil`:
98
+
99
+ ```ruby
100
+ def call(id:)
101
+ scoped(Invoice).find_by(id: id)
102
+ end
103
+ ```
104
+
105
+ Anything that is not a record — a count, a sum, a summary — is safe only when it was
106
+ computed from `scoped`, because then the tool never held unscoped data:
107
+
108
+ ```ruby
109
+ class OverdueTotalTool
110
+ include Reeve::Guard
111
+ guard_with InvoicePolicy
112
+
113
+ def call
114
+ scoped(Invoice).where(overdue: true).sum(:cents) # safe by construction
115
+ end
116
+ end
117
+ ```
118
+
119
+ Reaching for `Invoice.sum(:cents)` there is denied with `unscoped_derived_result`. The
120
+ guarantee is structural, not a matter of remembering.
121
+
122
+ ## Every call leaves a trace
123
+
124
+ One append-only row per invocation, allowed or denied, naming the agent, the principal, the
125
+ arguments (post-redaction), what came back, and the rule that decided:
126
+
127
+ ```ruby
128
+ Reeve::Audit::Query
129
+ .for_principal(user)
130
+ .for_agent("claude-desktop")
131
+ .between(1.week.ago, Time.current)
132
+ .pluck(:occurred_at, :tool_name, :outcome, :rule, :record_type, :record_ids)
133
+ ```
134
+
135
+ A call whose tool raised is still recorded — that trace is the one most worth having. A
136
+ call that cannot be recorded fails, unless the host has explicitly opted into
137
+ `audit_failure_mode = :warn`.
138
+
139
+ ## Provable in CI, in whichever framework you already use
140
+
141
+ All the logic lives in framework-neutral checks. RSpec and Minitest are thin front-ends
142
+ over the same objects, emitting the same messages, so no guarantee is provable in one
143
+ framework only.
144
+
145
+ ```ruby
146
+ # RSpec — require "reeve/rspec"
147
+ it { is_expected.to deny_access_for(stranger) }
148
+ it_behaves_like "a reeve-compliant server"
149
+ ```
150
+
151
+ ```ruby
152
+ # Minitest — require "reeve/minitest"
153
+ class ComplianceTest < ActiveSupport::TestCase
154
+ include Reeve::Testing::Assertions
155
+ include Reeve::Testing::ComplianceAssertions
156
+
157
+ def test_search_denies_a_stranger
158
+ assert_denies_access_for InvoiceSearchTool, stranger, query: "AC"
159
+ end
160
+ end
161
+ ```
162
+
163
+ A stock `rails new` application — Minitest, no RSpec — proves every guarantee without
164
+ adding a test framework. That is a spec in this repo, not an aspiration.
165
+
166
+ ### Without a test framework at all
167
+
168
+ The checks are plain objects, so the same guarantees are assertable from a rake task, a CI
169
+ script, or a boot-time assertion in staging:
170
+
171
+ <!-- reeve:compliance-gate -->
172
+ ```ruby
173
+ require "reeve/testing"
174
+
175
+ report = Reeve::Checks.run_all(principals: [alice, bob])
176
+ abort report.to_s unless report.passed?
177
+ ```
178
+
179
+ `alice` and `bob` are two fixture principals with disjoint records — that disjointness is
180
+ what makes a shared record identifier proof of a leak. As a Rails rake task:
181
+
182
+ ```ruby
183
+ # lib/tasks/reeve.rake
184
+ namespace :reeve do
185
+ desc "Fail the build if any guarded tool leaks or goes unaudited"
186
+ task compliance: :environment do
187
+ require "reeve/testing"
188
+
189
+ report = Reeve::Checks.run_all(principals: Reeve::Testing.compliance_principals)
190
+ abort report.to_s unless report.passed?
191
+ puts report
192
+ end
193
+ end
194
+ ```
195
+
196
+ A failing run names the check, the tool, and the records that leaked:
197
+
198
+ ```text
199
+ reeve compliance: 13 checks, 12 passed, 1 failed
200
+
201
+ FAIL CrossPrincipalLeak
202
+ expected InvoiceSearchTool to return no records belonging to another principal, but it
203
+ returned 3 records to User#1 that also belong to User#2: Invoice#7, Invoice#8,
204
+ Invoice#9 (guard: InvoicePolicy, decision: allow via InvoicePolicy#index)
205
+ ```
206
+
207
+ ## Without Rails, or without fast-mcp
208
+
209
+ The core needs neither. `Reeve.invoke` is the same envelope with the same guarantees:
210
+
211
+ ```ruby
212
+ require "reeve"
213
+
214
+ Reeve.invoke(
215
+ tool: InvoiceSearchTool,
216
+ arguments: { query: "AC" },
217
+ principal: current_user,
218
+ agent: { id: "claude-desktop" }
219
+ )
220
+ ```
221
+
222
+ ## Wrapping your own JSON-RPC server
223
+
224
+ Plenty of Rails apps expose `/mcp` from a controller they wrote themselves, with their own
225
+ tool registry and their own bearer-token authentication. There is no adapter to install
226
+ for that, and none is needed: `Reeve.invoke` is the adapter interface. An MCP integration
227
+ is a function from a JSON-RPC request to one `Reeve.invoke` call.
228
+
229
+ Keep the authentication you have. Reeve does not do connection auth (see [What you have
230
+ not gained](#what-you-have-not-gained)) — the controller still decides whether the caller
231
+ gets in the door, and Reeve decides what they may touch once inside.
232
+
233
+ **Dispatch through the envelope.** Map the JSON-RPC tool name to the class, then call:
234
+
235
+ ```ruby
236
+ # app/controllers/mcp_controller.rb
237
+ def call_tool
238
+ tool = McpServer.registry.fetch(params.dig(:params, :name))
239
+
240
+ records = Reeve.invoke(
241
+ tool: tool,
242
+ arguments: params.dig(:params, :arguments).to_h.symbolize_keys,
243
+ agent: { id: request.headers["X-MCP-Client"] || "unknown" },
244
+ metadata: { headers: request.headers.to_h.slice(*AUDITED_HEADERS) }
245
+ )
246
+
247
+ render json: { jsonrpc: "2.0", id: params[:id], result: serialize(records) }
248
+ rescue Reeve::DeniedError => e
249
+ render json: { jsonrpc: "2.0", id: params[:id],
250
+ error: { code: -32_003, message: e.message } }
251
+ end
252
+ ```
253
+
254
+ Note what is *not* passed: `principal:`. Omit it and the resolver in your initializer runs,
255
+ which is what you want when the controller has already set `Current.user` — one place
256
+ decides who the principal is, and the ledger records the same answer the guard used.
257
+ Passing `principal:` explicitly overrides the resolver for that call, which is useful in
258
+ tests and in scripts.
259
+
260
+ **`metadata:` is transport detail, and it is recorded.** It reaches the resolver as
261
+ `context.metadata` and is written to the ledger's `metadata` column, so it is what a
262
+ reviewer has to reconstruct *which request* a row came from. It goes through the same
263
+ redactor as the arguments, so `Authorization` and friends are replaced by name — but pass
264
+ the headers you would want in an audit rather than all of them.
265
+
266
+ **Resolve the principal from whichever the controller established:**
267
+
268
+ ```ruby
269
+ Reeve.configure do |config|
270
+ config.principal_resolver = lambda do |context|
271
+ Current.user || ApiToken.find_by(
272
+ token: context.metadata.dig(:headers, "Authorization").to_s.delete_prefix("Bearer ")
273
+ )&.user
274
+ end
275
+ end
276
+ ```
277
+
278
+ A resolver that returns nil — or raises — denies with `no_principal` and still writes a
279
+ row. There is no configuration in which an unidentified caller reaches a tool.
280
+
281
+ **Adopt one tool at a time.** A registry of thirty tools does not need thirty policies
282
+ before any of this is worth turning on:
283
+
284
+ ```ruby
285
+ config.unguarded_tools = :allow_with_warning # migrating
286
+ ```
287
+
288
+ Tools with `guard_with` are authorized and scoped normally. Tools without one still run —
289
+ unscoped, which is the entire point of the warning — and are recorded with `guard: "none"`
290
+ and rule `unguarded_tool`, so the ledger itself is your worklist:
291
+
292
+ ```ruby
293
+ Reeve::Audit::Entry.where(guard: "none").distinct.pluck(:tool_name)
294
+ ```
295
+
296
+ Flip to `:deny` when that comes back empty, and the mode stops being reachable by accident.
297
+
298
+ The compliance checks work here too, and they take an `invoke:` argument precisely so they
299
+ run against your dispatcher rather than a synthetic call:
300
+
301
+ ```ruby
302
+ Reeve::Checks.run_all(
303
+ principals: [alice, bob],
304
+ invoke: ->(tool:, principal:, arguments:) { McpServer.dispatch(tool, principal, arguments) }
305
+ )
306
+ ```
307
+
308
+ That is the whole integration: one call site, your auth untouched, and the same three
309
+ guarantees the fast-mcp adapter gets.
43
310
 
44
- 1. **Deny by default.** The tool returns only invoices the acting principal may see. No
45
- principal, no guard, or a policy error means no records never a silent pass.
46
- 2. **Every call leaves a trace.** One append-only ledger row per invocation, allowed or
47
- denied, naming the agent, the principal, the arguments, what came back, and the rule
48
- that decided.
49
- 3. **Provable in CI.** `expect(tool).to deny_access_for(stranger)` in RSpec,
50
- `assert_denies_access_for` in Minitest, or the checks called directly from a rake task.
311
+ Policies are plain objects unless you want Pundit (`authorize` and `scope`, two methods).
312
+ The ledger is an ActiveRecord table unless you supply your own recorder. Records are
313
+ ActiveRecord unless they are not a plain object with an `id` works.
51
314
 
52
- ## Planned for v1
315
+ ## What you have not gained
53
316
 
54
- - Per-record authorization bridging Pundit or plain policy objects
55
- - Append-only audit ledger with a query interface and an install generator
56
- - Testing kit: framework-neutral checks with RSpec, Minitest, and plain-Ruby front-ends
57
- - fast-mcp adapter, plus a plain interface for everyone else
317
+ Rate limiting, prompt-injection defence, cost control, and connection authentication are
318
+ out of scope. This library governs *what an authenticated agent may touch* and *what it
319
+ touched*. Keep your existing auth.
58
320
 
59
- Ruby 3.0+, Rails 7.0+. No runtime dependencies.
321
+ Ruby 3.0+, Rails 7.0+. No runtime dependencies. The fast-mcp adapter needs Ruby 3.1+,
322
+ because fast-mcp does.
60
323
 
61
324
  ## License
62
325
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Reeve
7
+ module Generators
8
+ # `bin/rails generate reeve:install` — step two of the three-step adoption path
9
+ # (Constitution VI). Writes an initializer and the ledger migration, and nothing else.
10
+ #
11
+ # The initializer deliberately ships with `principal_resolver` unset: it is the one
12
+ # thing only the host can answer, and a plausible-looking guess would be worse than a
13
+ # TODO, because the library fails closed until it is filled in.
14
+ class InstallGenerator < Rails::Generators::Base
15
+ include ActiveRecord::Generators::Migration
16
+
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ desc "Creates the reeve initializer and the audit ledger migration."
20
+
21
+ def create_initializer
22
+ template "initializer.rb.tt", "config/initializers/reeve.rb"
23
+ end
24
+
25
+ def create_migration_file
26
+ migration_template(
27
+ "create_audit_entries.rb.tt",
28
+ "db/migrate/create_reeve_audit_entries.rb"
29
+ )
30
+ end
31
+
32
+ def report_next_step
33
+ say <<~NEXT
34
+
35
+ reeve is installed. Two things left:
36
+
37
+ 1. Fill in `principal_resolver` in config/initializers/reeve.rb.
38
+ Until you do, every guarded call denies with `no_principal`.
39
+ 2. Run `bin/rails db:migrate` to create the audit ledger.
40
+
41
+ Then add `guard_with SomePolicy` to a tool.
42
+ NEXT
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The reeve audit ledger: one row per guarded tool invocation, allowed or denied.
4
+ #
5
+ # The table is yours. Reeve writes to it and reads from it, and exposes no API that
6
+ # updates or deletes a row (FR-010). What the library can enforce, it enforces:
7
+ # Reeve::Audit::Entry is readonly once persisted and aborts destroy. What it cannot
8
+ # enforce, it does not pretend to — a database superuser or a raw `UPDATE` can still
9
+ # rewrite history. If the ledger is a compliance artifact for you, close that gap in the
10
+ # database, not in Ruby:
11
+ #
12
+ # -- PostgreSQL, for the role your application connects as
13
+ # REVOKE ALL ON reeve_audit_entries FROM app_role;
14
+ # GRANT INSERT, SELECT ON reeve_audit_entries TO app_role;
15
+ # GRANT USAGE ON SEQUENCE reeve_audit_entries_id_seq TO app_role;
16
+ #
17
+ # Also explicitly out of scope for now, so you can plan around it:
18
+ # * no retention, rotation or archival — this table grows until you prune it, and
19
+ # pruning is your policy, run by a role that is allowed to delete;
20
+ # * no cryptographic chaining or tamper-evidence. If that lands later it arrives as a
21
+ # nullable column, which the audit-entry contract's versioning already permits.
22
+ #
23
+ # `invocation_id` is a UUID stored as a string rather than a native `uuid` column,
24
+ # because reeve supports every database ActiveRecord does and only PostgreSQL has the
25
+ # native type. The unique index on it is what makes "exactly one row per invocation"
26
+ # a checkable property.
27
+ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
28
+ def change
29
+ create_table :reeve_audit_entries do |t|
30
+ t.string :invocation_id, null: false
31
+ t.datetime :occurred_at, null: false
32
+
33
+ t.string :agent_id, null: false
34
+ t.string :agent_name
35
+ t.string :principal_type
36
+ t.string :principal_id
37
+
38
+ t.string :tool_name, null: false
39
+ t.json :arguments, null: false
40
+
41
+ t.string :outcome, null: false
42
+ t.string :rule, null: false
43
+ # Diagnostic text explaining the rule ("policy raised ArgumentError: boom").
44
+ # Nullable and free-form: match on `rule`, read `detail`. Never names a record.
45
+ t.text :detail
46
+
47
+ t.string :record_type
48
+ t.json :record_ids, null: false
49
+ t.integer :record_count, null: false, default: 0
50
+ t.boolean :truncated, null: false, default: false
51
+ t.boolean :derived, null: false, default: false
52
+
53
+ t.string :guard, null: false
54
+ t.integer :duration_ms
55
+ t.json :metadata
56
+
57
+ # The audit-entry contract version this row was written under. A ledger outlives
58
+ # the gem version that wrote it, and a row that cannot name its own shape has to be
59
+ # interpreted by guesswork: contract 1 wrote `metadata` NULL whatever the caller
60
+ # passed, so on a version 1 row NULL means "never recorded" and on a version 2 row
61
+ # it means "the caller passed none". Stamping each row keeps an export readable
62
+ # across an upgrade instead of collapsing that distinction.
63
+ t.integer :contract_version, null: false
64
+ end
65
+
66
+ add_index :reeve_audit_entries, :invocation_id, unique: true
67
+
68
+ # One index per query axis in FR-013: by principal, by tool, by agent, by outcome —
69
+ # each paired with occurred_at, since every one of those questions is asked over a
70
+ # time range.
71
+ add_index :reeve_audit_entries, %i[principal_type principal_id occurred_at],
72
+ name: "index_reeve_audit_entries_on_principal"
73
+ add_index :reeve_audit_entries, %i[tool_name occurred_at]
74
+ add_index :reeve_audit_entries, %i[agent_id occurred_at]
75
+ add_index :reeve_audit_entries, %i[outcome occurred_at]
76
+ end
77
+ end