reeve 0.4.0 → 0.6.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +176 -1
  3. data/README.md +202 -12
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +41 -3
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
  6. data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
  7. data/lib/generators/reeve/upgrade/upgrade_generator.rb +40 -2
  8. data/lib/reeve/audit/chain/canonical.rb +129 -0
  9. data/lib/reeve/audit/chain/head.rb +94 -0
  10. data/lib/reeve/audit/chain/keyring.rb +53 -0
  11. data/lib/reeve/audit/chain/normalizer.rb +88 -0
  12. data/lib/reeve/audit/chain/report.rb +134 -0
  13. data/lib/reeve/audit/chain/verification.rb +110 -0
  14. data/lib/reeve/audit/chain/verifier.rb +160 -0
  15. data/lib/reeve/audit/chain/walk.rb +123 -0
  16. data/lib/reeve/audit/chain.rb +168 -0
  17. data/lib/reeve/audit/chain_record.rb +61 -0
  18. data/lib/reeve/audit/isolated_recorder.rb +5 -0
  19. data/lib/reeve/audit/recorder.rb +32 -19
  20. data/lib/reeve/audit.rb +24 -4
  21. data/lib/reeve/authorization.rb +1 -11
  22. data/lib/reeve/configuration.rb +90 -2
  23. data/lib/reeve/decision.rb +6 -1
  24. data/lib/reeve/errors.rb +28 -0
  25. data/lib/reeve/inventory/report.rb +146 -0
  26. data/lib/reeve/inventory.rb +212 -0
  27. data/lib/reeve/invocation.rb +3 -1
  28. data/lib/reeve/ledger_key.rb +47 -0
  29. data/lib/reeve/railtie.rb +15 -0
  30. data/lib/reeve/tasks.rb +62 -0
  31. data/lib/reeve/testing/assertions.rb +2 -2
  32. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  33. data/lib/reeve/testing/checks/endpoint_coverage.rb +133 -0
  34. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  35. data/lib/reeve/testing/checks.rb +43 -7
  36. data/lib/reeve/testing/compliance_assertions.rb +4 -1
  37. data/lib/reeve/testing/compliance_suite.rb +4 -1
  38. data/lib/reeve/testing/ledger.rb +11 -1
  39. data/lib/reeve/testing/matchers/pass_reeve_check.rb +1 -1
  40. data/lib/reeve/testing/report.rb +30 -6
  41. data/lib/reeve/testing/result.rb +28 -8
  42. data/lib/reeve/testing.rb +20 -4
  43. data/lib/reeve/version.rb +1 -1
  44. data/lib/reeve.rb +3 -0
  45. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 329fc869bdb15c7e60eb1923221aeccb1725821535c25186dafe77bda001cc44
4
- data.tar.gz: 0f0781268bc7e90b1fad27a74bef611aa6aa65df3da37fb1351eb3f28524da15
3
+ metadata.gz: 03d0a3695140d0703272da744486caa3ab21530eddb0d48d275977e3b83047c0
4
+ data.tar.gz: aa8988dd9d7fc1dfb40b5c7a67f1f0a9f70d5ba72350b7669ba32afb2121f140
5
5
  SHA512:
6
- metadata.gz: b5d6286339f5f034e76f74052e3d19f9560c55b45e9432e6583b50931ddf4425a1aa5bc5c0ea1200646a9085f972521a3d1e2c38d8cf654e8aa8004fa2be0e3f
7
- data.tar.gz: 32c28f2c34294f7d2454b772a1fd7bd52af690c3bc6d46793e2ec23021c6c3bd5d0d4bc6ee5f04b990a36a5f8ada40e03677bd9efa5c2a099b08c6bcf4cac147
6
+ metadata.gz: 116dbf0359f8666ffa50cb70a9a85aa7a037d76390305b3315fc4ecc37233fe83a5faabb14103c0ae999ae69f5cc5fa020813cd7da3ade8ae86f837639604c98
7
+ data.tar.gz: 4ae861908807ec1bc3b3363442edda7cf6936d60d01e39b5aa03f6ae91cd7e0daad49ac8a154a53db277894c866cf9adfdb2e8776d99071b20d6d4704647f4b0
data/CHANGELOG.md CHANGED
@@ -4,6 +4,181 @@ All notable changes are recorded here. This project follows [Semantic
4
4
  Versioning](https://semver.org), with one rule specific to what it does — see
5
5
  [Versioning policy](#versioning-policy).
6
6
 
7
+ ## [0.6.0] - 2026-10-10
8
+
9
+ The ledger has been append-only at the library level since 0.1.0: no API updates or deletes
10
+ an entry, and the generated migration documents the grant that enforces the rest. Nothing
11
+ *detected* a change made outside the library. A DBA, a stolen application credential or a
12
+ well-meant data fix could edit, delete or reorder rows, and the ledger read exactly as
13
+ before. Now every row is chained to the one before it with a keyed digest, and
14
+ `bin/rails reeve:verify_ledger` reports every place the chain no longer holds.
15
+
16
+ **Audit-entry contract `2` → `3`, and a migration is required.** The new columns are
17
+ nullable, so the shape change is additive; the bump is MAJOR by the contract's own rule,
18
+ because what an absence means changed. On a contract 1–2 row a NULL digest means "written
19
+ before chaining"; on a contract-3 row it means the row was written unchained, which
20
+ verification reports. Upgrading is three steps:
21
+
22
+ ```bash
23
+ bin/rails generate reeve:upgrade
24
+ bin/rails db:migrate
25
+ ```
26
+
27
+ then add the line the generator prints to `config/initializers/reeve.rb`:
28
+
29
+ ```ruby
30
+ config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
31
+ ```
32
+
33
+ Existing rows are not touched. They are reported as "not covered" from then on, and are
34
+ exactly as trustworthy as they were before.
35
+
36
+ What this detects and what it does not are set out in the README, under "Tamper-evident,
37
+ within stated limits". In short, it does **not** detect a change made by someone holding
38
+ the key; the newest rows deleted by someone who also rewinds the chain record's head; the
39
+ whole ledger deleted (reported as *no chain*, never as a pass); or anything about rows
40
+ written before the upgrade. It detects; it does not prevent.
41
+
42
+ Behaviour changes worth reading before upgrading:
43
+
44
+ - **A ledger key is required.** Without one, every guarded call fails with
45
+ `Reeve::AuditWriteError` (cause `Reeve::LedgerKeyError`, naming the setting). Under
46
+ `audit_failure_mode = :warn` the call goes through and the row is written *unchained*, a
47
+ warning is logged on every such write, and verification reports the row.
48
+ - **Rotating `secret_key_base` rotates the ledger key**, if you keep the generated line. Put
49
+ the old derived key in `config.retired_ledger_keys`, or entries it signed become
50
+ unverifiable — reported as such, never as tampered. A dedicated key avoids the coupling.
51
+ - **Writes serialize on the chain record.** About 0.5 ms more per ledger write on SQLite and
52
+ about 1 ms on PostgreSQL. Under the default recorder inside a transaction the host opened,
53
+ the lock is held until that transaction ends; `IsolatedRecorder` holds it for one insert.
54
+ - **Rails 7.0 with SQLite:** sqlite3 1.x waits for the write lock while holding Ruby's GVL,
55
+ so concurrent guarded calls in one process can fail closed after the busy timeout. Rails
56
+ 7.1+ or a server database avoids it. The limit predates this release; the chain's longer
57
+ write makes it likelier.
58
+ - **The compliance suites run one more check, `LedgerChain`.** It writes an entry of its own
59
+ and fails without a key, on a broken or unverifiable chain, or on unchained entries. It is
60
+ skipped, saying so, when there is no compliance tool to write with or no ledger loaded.
61
+ The test environment needs the key line too; the generated one works there unchanged.
62
+ - **Breaking for custom recorders** that write to `Reeve::Audit::Entry`: rows must be written
63
+ through `Reeve::Audit::Chain#append!` (or `#append_unchained!` to keep running without a
64
+ key), or verification reports them as unchained.
65
+ - **A table still at contract 2** now fails with `Reeve::ConfigurationError` naming
66
+ `reeve:upgrade`, instead of a column error from inside the write.
67
+ - **Known limit, MySQL:** a number in a tool's arguments beyond double precision is stored
68
+ rounded by MySQL's `JSON` type, and its row then fails to verify. A false alarm, never a
69
+ false pass.
70
+ - **During a rolling deploy**, servers still on 0.5.0 keep writing contract-2 rows. They
71
+ are counted as not covered, not reported as tampering.
72
+
73
+ ### Added
74
+
75
+ - **The ledger chain** (`Reeve::Audit::Chain`). Every entry carries a sequence number, the
76
+ 16-character fingerprint of the key that signed it, and an HMAC-SHA256 digest over its
77
+ canonical form and its predecessor's digest. The digest is computed over the values the
78
+ database stores — defaults, casts and JSON normalization included — so a row verifies
79
+ after a dump and restore with every primary key renumbered.
80
+ - **`Reeve::Audit::Chain.verify`** (optionally with `keys:`, for an auditor's own copy of the
81
+ key) and **`bin/rails reeve:verify_ledger`**, registered by a Railtie that loads only when
82
+ Rails is present. Exit status `0` intact, `1` broken, `2` could not verify; `LIMIT=` caps
83
+ the breaks listed, never the totals; `FORMAT=json` writes every break. Every line that
84
+ says *intact* or *pass* states the covered range and the uncovered count beside it.
85
+ `Reeve::Tasks.verify_ledger` is the same check without Rake.
86
+ - **`config.ledger_key`** and **`config.retired_ledger_keys`**, validated at assignment (32
87
+ bytes minimum) and shown only as fingerprints in `to_h`, `inspect`, logs and reports.
88
+ - **`Checks::LedgerChain`**, in both compliance suites.
89
+ - **`reeve:upgrade`**'s contract-3 step, which prints the initializer line, the rotation
90
+ warning and how to build the new index concurrently on a large PostgreSQL ledger. A step
91
+ now counts as applied only when its columns and its tables both exist.
92
+
93
+ ### Fixed
94
+
95
+ - **A per-tool compliance check with no tools to check is skipped, not passed.** With no
96
+ tool registered, `compliance_tools` set to `[]`, or `tools: []`, `Reeve::Checks.run`
97
+ returned an empty `Report` — which reads as passed — so the RSpec shared group and
98
+ `ComplianceAssertions` showed all six per-tool checks green over zero tools. Each now
99
+ returns one skipped result, "no compliance tools to check, so GuardDeclared established
100
+ nothing — the registry is empty and no endpoint inventory routes a tool" (or `the run was
101
+ given tools: []`, or `compliance_tools is []`), shown as pending in RSpec and as a skip
102
+ in Minitest. Over an empty registry `run_all` used to report only the ledger-wide checks
103
+ (two in 0.5.0); it now also reports the six per-tool skips. Nothing that ran over at
104
+ least one tool changes.
105
+ - **The framework-free gate fails when a check had nothing to check.** The README's
106
+ `abort report.to_s unless report.passed?` stayed green over zero tools, because a skip is
107
+ not a failure. The documented gate is now
108
+ `abort report.to_s if report.failed? || report.nothing_checked?`, using the new
109
+ `Report#nothing_checked?`. A skip for a missing inventory does not trip it.
110
+
111
+
112
+ ## [0.5.0] - 2026-10-01
113
+
114
+ Reeve protects the tools routed through it, and only those. A server that registers its
115
+ tools as names and handler blocks can send some through `Reeve.invoke` and run the rest
116
+ directly — and the direct ones are not unguarded, they are invisible: no denial, no scope,
117
+ no ledger row, and nothing that says so. The compliance suite could not see them either,
118
+ because it walks tools reeve was told about. A green run certified the subset reeve
119
+ happened to know, and read exactly like a green run over the whole endpoint.
120
+
121
+ **The audit-entry contract is unchanged at `2`, so no migration is required.** One new
122
+ `rule` value, `unbound_tool`, can appear — only for a host that dispatches through an
123
+ inventory.
124
+
125
+ Behaviour changes worth reading before upgrading:
126
+
127
+ - Both compliance suites have one more check, `EndpointCoverage`. Until `config.inventory`
128
+ is set it is **skipped** — pending in RSpec, a skip in Minitest — with a message saying
129
+ the endpoint was not verified. Nothing that passed before fails.
130
+ - A `Report` now counts and prints skipped results (`8 checks, 7 passed, 0 failed, 1
131
+ skipped`, followed by `SKIP EndpointCoverage` and its reason). `passed?` is unchanged: a
132
+ skip is not a failure.
133
+ - A multi-line check message is now indented as one block under its `FAIL` heading.
134
+
135
+ **On Rails 7.0, keep `json` below 3.0.** json 3.0 dropped the `quirks_mode:` keyword that
136
+ ActiveSupport 7.0 still passes to `JSON.generate`, so the ledger cannot serialize a row.
137
+ Under the default `audit_failure_mode = :fail`, reeve fails closed: every invocation raises
138
+ `Reeve::AuditWriteError` rather than running unrecorded — nothing goes unaudited, and
139
+ nothing runs. Under `:warn`, every call runs and none is recorded. The defect is ActiveSupport's, not
140
+ reeve's, and no reeve version works around it; pin `gem "json", "< 3"` until the
141
+ application is on a Rails that supports json 3. Reeve's own CI pins it on the Ruby 3.0
142
+ job for the same reason.
143
+
144
+ ### Added
145
+
146
+ - **`Reeve::Inventory`** — the host's registered tool names, which of them route through
147
+ reeve and to which class, and which are exempt and why. Every part takes a value or a
148
+ callable, so a Rails initializer can declare it without autoloading application
149
+ constants. No MCP library involved: a list of names is the whole protocol.
150
+
151
+ - `#report` classifies every registered name as protected and guarded, routed but
152
+ unguarded, bypassing reeve, or exempted — plus anything the inventory names that the
153
+ server does not register, which is usually a rename that left the real name unbound.
154
+ The output names tools, classes and exemption reasons, never an argument, a principal
155
+ or a record, so it is safe in a CI log.
156
+ - `#verify!` raises `Reeve::IncompleteInventoryError`, carrying the report, unless every
157
+ registered name is guarded or exempt. For boot, a deploy step, or CI.
158
+ - `#dispatch(name, ...) { handler }` is the dispatch boundary. A routed name runs through
159
+ `Reeve.invoke`, with the handler block as the body; an exempt name runs as written; any
160
+ other name is treated as a tool with no guard — refused with the new `unbound_tool`
161
+ rule under `unguarded_tools = :deny`, or run unscoped with `guard: "none"` under
162
+ `:allow_with_warning` — and recorded either way. A host that dispatches through it
163
+ cannot have a tool that bypasses reeve.
164
+ - An exemption needs a non-empty reason, and is listed in every report, complete or not.
165
+
166
+ - **`config.inventory`**, and **`Checks::EndpointCoverage`**, which compares a compliance
167
+ run against it. Passes only when the inventory is complete and the run covered all of
168
+ it; fails when the run claimed the whole endpoint and a registered tool bypasses reeve
169
+ or reaches it unguarded; skipped when no inventory is declared, or when
170
+ `compliance_tools` or `tools:` narrowed the run, naming what was left out. The
171
+ incremental path stays open, and a subset can no longer pass for the endpoint.
172
+
173
+ - **Skipped results.** `Testing::Result.skipped`, `Result#skipped?`, `Report#skips` and
174
+ `Report#skipped?`. Only `EndpointCoverage` produces one.
175
+
176
+ ### Changed
177
+
178
+ - The compliance suites' default population includes every class the inventory routes to.
179
+ A registry of blocks has no reason to `include Reeve::Guard` anywhere, so a routed class
180
+ with no guard would otherwise never reach `GuardDeclared`.
181
+
7
182
  ## [0.4.0] - 2026-09-03
8
183
 
9
184
  Everything here came out of a second application integrating the gem — one that had put a
@@ -342,4 +517,4 @@ is for:
342
517
  - Adding a nullable ledger column is minor and leaves the audit-entry contract version
343
518
  alone. Removing or renaming a column, or changing what a value means, is major and bumps
344
519
  it.
345
- - The audit-entry contract version is `1`.
520
+ - The audit-entry contract version is `3`.
data/README.md CHANGED
@@ -7,7 +7,7 @@ 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.4.0.** Read the known limitations in
10
+ > **Status: 0.6.0.** Read the known limitations in
11
11
  > [CHANGELOG.md](CHANGELOG.md) before adopting it — particularly the one about a
12
12
  > transaction wrapped around an invocation, if your application wraps requests in one.
13
13
 
@@ -52,7 +52,10 @@ end
52
52
  ```
53
53
 
54
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.
55
+ intended behaviour: the library is safe before it is configured. The initializer also
56
+ derives the key that chains the audit ledger from `secret_key_base`, so that works in
57
+ every environment with nothing to set up — see [Tamper-evident, within stated
58
+ limits](#tamper-evident-within-stated-limits).
56
59
 
57
60
  **2. Guard a tool.**
58
61
 
@@ -186,6 +189,108 @@ transaction holds the write lock; `IsolatedRecorder.available?` lets you branch
186
189
  develop on one and deploy on the other. The default recorder warns when it detects the
187
190
  case rather than failing silently.
188
191
 
192
+ ## Tamper-evident, within stated limits
193
+
194
+ Every row Reeve writes is linked to the one before it by a keyed digest (HMAC-SHA256), with
195
+ a key the database does not hold. Someone who can write to the ledger table but does not
196
+ hold the key can still change it, but the change shows when the chain is checked:
197
+
198
+ ```bash
199
+ bin/rails reeve:verify_ledger
200
+ ```
201
+
202
+ ```text
203
+ reeve ledger: INTACT — sequences 1–48,211 verified (head at start: 48,211); 5,000 earlier entries not covered (5,000 when the chain started)
204
+ ```
205
+
206
+ It exits `0` when the chain is intact, `1` when it is broken, and `2` when it could not
207
+ verify (no chain, no key, or rows signed by a key it does not hold). Run it on a schedule
208
+ and alert on anything but `0`. `LIMIT=` caps the breaks it lists, never the totals;
209
+ `FORMAT=json` writes every break. The same answer from Ruby, or with an auditor's own copy
210
+ of the key:
211
+
212
+ ```ruby
213
+ result = Reeve::Audit::Chain.verify
214
+ result.status # => :intact, :broken, :unverifiable or :no_chain
215
+ result.covered # => 1..48211
216
+ puts result # the report above, with every break listed under it
217
+ ```
218
+
219
+ In CI the compliance suite you already run includes `LedgerChain`, in both frameworks:
220
+ it writes entries of its own and fails on a broken chain, a missing key, or entries that
221
+ were not chained.
222
+
223
+ **The key.** The generated initializer derives it from `secret_key_base`:
224
+
225
+ ```ruby
226
+ config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
227
+ ```
228
+
229
+ Rotating `secret_key_base` therefore rotates the ledger key. Keep the old one, or the
230
+ entries it signed become *unverifiable* — reported as such, never as tampered:
231
+
232
+ ```ruby
233
+ config.retired_ledger_keys = [old_key] # verify with these; never sign with them
234
+ ```
235
+
236
+ A dedicated key decouples the two: `config.ledger_key = Rails.application.credentials.reeve_ledger_key`.
237
+ Keys must be at least 32 bytes and are only ever printed as 16-character fingerprints.
238
+ Without a key, every guarded call fails closed; under `audit_failure_mode = :warn` it is
239
+ recorded unchained instead, logged each time, and reported by verification.
240
+
241
+ **Detected**, provided the key has not leaked:
242
+
243
+ - a chained row whose recorded content was changed;
244
+ - a chained row removed from anywhere before the newest one;
245
+ - chained rows swapped, renumbered or moved, or a row inserted that Reeve did not write —
246
+ including one numbered past the head;
247
+ - the newest rows removed while the chain record was left alone;
248
+ - the chain record removed while chained rows remain.
249
+
250
+ **Not detected** — stated plainly, because these are the limits of the guarantee:
251
+
252
+ 1. **A leaked key.** Anyone holding it can rewrite the chain from any point forward. By
253
+ default that is anyone holding `secret_key_base`.
254
+ 2. **The newest rows deleted by someone who also rewinds the chain record's head.** Only a
255
+ checkpoint held outside the database could catch that; Reeve does not ship one.
256
+ 3. **The whole ledger deleted, chain record included.** Reported as *no chain*, never as a
257
+ pass — but indistinguishable from a ledger that was never chained.
258
+ 4. **Rows written before you upgraded.** They are counted as "not covered" and are exactly
259
+ as trustworthy as they were before.
260
+
261
+ And it prevents nothing: it detects a change after the fact. The `GRANT INSERT, SELECT` in
262
+ the generated migration is what prevents one. The chain record needs `UPDATE` as well,
263
+ which is why a role allowed to rewind it is limit 2.
264
+
265
+ **What it costs.** About half a millisecond per ledger write on SQLite and about one on
266
+ PostgreSQL. Every chained write locks the chain record, so writes serialize. Under the
267
+ default recorder inside a transaction your application opened, that lock is held until the
268
+ transaction ends, and every other guarded call waits behind it — up to the database's own
269
+ lock wait: indefinitely on PostgreSQL (`lock_timeout` is 0 by default), 50 seconds on MySQL
270
+ (`innodb_lock_wait_timeout`), and ActiveRecord's `timeout` on SQLite (5 seconds in a
271
+ generated `database.yml`). `IsolatedRecorder` holds it for the length of one insert. On
272
+ **Rails 7.0 with SQLite**, a thread waiting for SQLite's write lock holds Ruby's GVL
273
+ (sqlite3 1.x), so concurrent guarded calls in one process can fail closed after the busy
274
+ timeout. Rails 7.1+ or a server database avoids it.
275
+
276
+ **Your own recorder.** A recorder that writes to Reeve's table chains its rows through the
277
+ same door the built-in ones use, or verification reports them as unchained:
278
+
279
+ ```ruby
280
+ Reeve::Audit::Chain.new(entry_class: Reeve::Audit::Entry).append!(row)
281
+ ```
282
+
283
+ **Upgrading from 0.5.0:**
284
+
285
+ ```bash
286
+ bin/rails generate reeve:upgrade
287
+ bin/rails db:migrate
288
+ ```
289
+
290
+ Then add the `config.ledger_key` line the generator prints. Existing rows are not touched;
291
+ the next guarded call starts the chain. Servers still on 0.5.0 during a rolling deploy keep
292
+ writing unchained rows, which are counted as not covered rather than reported as tampering.
293
+
189
294
  ## Provable in CI, in whichever framework you already use
190
295
 
191
296
  All the logic lives in framework-neutral checks. RSpec and Minitest are thin front-ends
@@ -223,11 +328,13 @@ script, or a boot-time assertion in staging:
223
328
  require "reeve/testing"
224
329
 
225
330
  report = Reeve::Checks.run_all(principals: [alice, bob])
226
- abort report.to_s unless report.passed?
331
+ abort report.to_s if report.failed? || report.nothing_checked?
227
332
  ```
228
333
 
229
334
  `alice` and `bob` are two fixture principals with disjoint records — that disjointness is
230
- what makes a shared record identifier proof of a leak.
335
+ what makes a shared record identifier proof of a leak. `passed?` only means nothing failed,
336
+ so a run over no tools at all passes; `nothing_checked?` is what stops this gate when the
337
+ registry is empty, since a script has no pending test to show it.
231
338
 
232
339
  **The suite walks every tool that included `Reeve::Guard`, not only the ones that declared
233
340
  a guard.** A tool that forgot `guard_with` is the one worth finding, and it is absent from
@@ -242,12 +349,10 @@ config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }
242
349
  ```
243
350
 
244
351
  Reeve can only see tools that reached it. If your MCP server dispatches tools Reeve has
245
- never been told about — a custom controller with its own registry — hand it the real
246
- inventory, or it will certify the subset it happens to know:
247
-
248
- ```ruby
249
- Reeve::Checks.run_all(principals: [alice, bob], tools: McpToolRegistry.tool_classes)
250
- ```
352
+ never been told about — a custom controller with its own registry — declare the server's
353
+ real inventory ([below](#every-tool-the-endpoint-dispatches)), or the run certifies the
354
+ subset Reeve happens to know. Until you do, the suite says so: `EndpointCoverage` is
355
+ reported as skipped, never as passed.
251
356
 
252
357
  As a Rails rake task:
253
358
 
@@ -259,7 +364,7 @@ namespace :reeve do
259
364
  require "reeve/testing"
260
365
 
261
366
  report = Reeve::Checks.run_all(principals: Reeve::Testing.compliance_principals)
262
- abort report.to_s unless report.passed?
367
+ abort report.to_s if report.failed? || report.nothing_checked?
263
368
  puts report
264
369
  end
265
370
  end
@@ -380,6 +485,91 @@ Reeve::Checks.run_all(
380
485
  That is the whole integration: one call site, your auth untouched, and the same three
381
486
  guarantees the fast-mcp adapter gets.
382
487
 
488
+ ## Every tool the endpoint dispatches
489
+
490
+ Reeve protects the tools routed through it, and only those. A server that registers tools
491
+ as names and handler blocks can send some through `Reeve.invoke` and run the rest directly
492
+ — and the direct ones are not unguarded, they are invisible: no denial, no scope, no ledger
493
+ row, and nothing that says so. Deny-by-default cannot deny a call it never sees.
494
+
495
+ `Reeve::Inventory` makes the server's own list of names the thing that gets checked:
496
+
497
+ ```ruby
498
+ # config/initializers/reeve.rb
499
+ McpInventory = Reeve::Inventory.new(
500
+ registered: -> { McpServer.tool_names },
501
+ routed: -> { { "search_invoices" => InvoiceSearchTool, "get_invoice" => InvoiceShowTool } },
502
+ exemptions: { "healthcheck" => { reason: "returns no application data" } }
503
+ )
504
+
505
+ Reeve.configure { |config| config.inventory = McpInventory }
506
+ ```
507
+
508
+ Each part takes a value or a callable. Callables are read when asked, so the initializer
509
+ neither autoloads application constants nor runs before the server has registered its
510
+ tools.
511
+
512
+ ```text
513
+ reeve inventory: 4 registered
514
+ 2 protected and guarded
515
+ 0 routed through reeve but unguarded
516
+ 1 bypasses reeve
517
+ 1 exempted
518
+ coverage: incomplete
519
+
520
+ BYPASS export_invoices
521
+ registered with the server but neither routed through reeve nor exempted — route it to
522
+ a guarded tool, or exempt it with a reason
523
+
524
+ EXEMPT healthcheck
525
+ returns no application data
526
+ ```
527
+
528
+ The report names tools, classes and exemption reasons — never an argument, a principal or
529
+ a record — so it is safe to print in CI. An exemption without a reason is refused, and one
530
+ with a reason is listed every time, complete or not.
531
+
532
+ **Fail boot or CI on a gap:**
533
+
534
+ ```ruby
535
+ McpInventory.verify! # raises Reeve::IncompleteInventoryError, carrying the report
536
+ ```
537
+
538
+ **Dispatch through it, and a bypass stops being possible:**
539
+
540
+ ```ruby
541
+ def call_tool
542
+ name = params.dig(:params, :name)
543
+ arguments = params.dig(:params, :arguments).to_h.symbolize_keys
544
+
545
+ result = McpInventory.dispatch(name, arguments: arguments,
546
+ agent: { id: request.headers["X-MCP-Client"] }) do
547
+ McpServer.handlers.fetch(name).call(arguments)
548
+ end
549
+
550
+ render json: { jsonrpc: "2.0", id: params[:id], result: serialize(result) }
551
+ rescue Reeve::DeniedError => e
552
+ render json: { jsonrpc: "2.0", id: params[:id],
553
+ error: { code: -32_003, message: e.message } }
554
+ end
555
+ ```
556
+
557
+ - A **routed** name runs through `Reeve.invoke` under its class's guard, with the handler
558
+ block as the body. A registry of blocks needs one guarded class per tool to name its
559
+ policy, and nothing else — the block still does the work, and what it returns is scoped.
560
+ Handlers dispatched this way should not call `Reeve.invoke` themselves.
561
+ - An **exempt** name runs the block as written.
562
+ - **Any other name** is a tool with no guard, and `unguarded_tools` decides: refused with
563
+ `unbound_tool` under `:deny`, the default; run unscoped and recorded with `guard: "none"`
564
+ under `:allow_with_warning`. Recorded either way, so a name the inventory missed lands on
565
+ the same ledger worklist as an unguarded class.
566
+
567
+ **In the compliance suite,** `EndpointCoverage` compares the run against the inventory. It
568
+ passes only when every registered tool is guarded or exempt and the run covered all of
569
+ them. It fails when the run claimed the whole endpoint and something bypasses Reeve. A run
570
+ narrowed by `compliance_tools` is reported as a subset — skipped, naming what it left out —
571
+ so a retrofit stays green without passing for complete coverage.
572
+
383
573
  Policies are plain objects unless you want Pundit (`authorize` and `scope`, two methods).
384
574
  The ledger is an ActiveRecord table unless you supply your own recorder. Records are
385
575
  ActiveRecord unless they are not — a plain object with an `id` works.
@@ -387,7 +577,7 @@ ActiveRecord unless they are not — a plain object with an `id` works.
387
577
  ## What you have not gained
388
578
 
389
579
  Rate limiting, prompt-injection defence, cost control, and connection authentication are
390
- out of scope. This library governs *what an authenticated agent may touch* and *what it
580
+ out of scope, and the ledger's chain detects tampering rather than preventing it. This library governs *what an authenticated agent may touch* and *what it
391
581
  touched*. Keep your existing auth.
392
582
 
393
583
  Ruby 3.0+, Rails 7.0+. No runtime dependencies. The fast-mcp adapter needs Ruby 3.1+,
@@ -13,12 +13,23 @@
13
13
  # REVOKE ALL ON reeve_audit_entries FROM app_role;
14
14
  # GRANT INSERT, SELECT ON reeve_audit_entries TO app_role;
15
15
  # GRANT USAGE ON SEQUENCE reeve_audit_entries_id_seq TO app_role;
16
+ # -- the chain record: its head moves on every write
17
+ # GRANT INSERT, SELECT, UPDATE ON reeve_audit_chains TO app_role;
18
+ # GRANT USAGE ON SEQUENCE reeve_audit_chains_id_seq TO app_role;
19
+ #
20
+ # Every row is chained to the one before it with a keyed digest (audit-entry contract 3),
21
+ # so a row changed, removed or reordered outside reeve is reported by
22
+ # `bin/rails reeve:verify_ledger`. That is tamper-evidence, not tamper-proofing: it
23
+ # detects a change after the fact and prevents nothing — the grant above is what
24
+ # prevents. It does not detect a change made by someone holding the ledger key, nor the
25
+ # newest rows being deleted by a role that can also rewind the head in
26
+ # reeve_audit_chains; that role needs UPDATE there, which is why the chain table is
27
+ # granted separately from the ledger.
16
28
  #
17
29
  # Also explicitly out of scope for now, so you can plan around it:
18
30
  # * 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.
31
+ # pruning is your policy, run by a role that is allowed to delete. Deleting rows
32
+ # breaks the chain by design.
22
33
  #
23
34
  # `invocation_id` is a UUID stored as a string rather than a native `uuid` column,
24
35
  # because reeve supports every database ActiveRecord does and only PostgreSQL has the
@@ -61,6 +72,13 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
61
72
  # it means "the caller passed none". Stamping each row keeps an export readable
62
73
  # across an upgrade instead of collapsing that distinction.
63
74
  t.integer :contract_version, null: false
75
+
76
+ # The chain (contract 3). Nullable: a row written before the chain began has no
77
+ # honest value for these, and nothing is backfilled into an append-only table.
78
+ # `chain_key_id` is the key's fingerprint, never the key.
79
+ t.bigint :chain_sequence
80
+ t.string :chain_key_id, limit: 16
81
+ t.string :chain_digest, limit: 64
64
82
  end
65
83
 
66
84
  add_index :reeve_audit_entries, :invocation_id, unique: true
@@ -73,5 +91,25 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
73
91
  add_index :reeve_audit_entries, %i[tool_name occurred_at]
74
92
  add_index :reeve_audit_entries, %i[agent_id occurred_at]
75
93
  add_index :reeve_audit_entries, %i[outcome occurred_at]
94
+
95
+ # One position, one row. NULLs are distinct, so unchained rows never collide; the
96
+ # index also orders the walk `reeve:verify_ledger` makes.
97
+ add_index :reeve_audit_entries, :chain_sequence, unique: true
98
+
99
+ # The chain record: one row. The genesis columns are written once, when the first
100
+ # chained entry is; the head moves with every chained write, in the same transaction
101
+ # as the row it points at.
102
+ create_table :reeve_audit_chains do |t|
103
+ t.string :name, null: false
104
+ t.datetime :genesis_at, null: false, precision: 6
105
+ t.bigint :pre_chain_count, null: false
106
+ t.bigint :pre_chain_unchained_count, null: false
107
+ t.string :genesis_key_id, null: false, limit: 16
108
+ t.string :genesis_digest, null: false, limit: 64
109
+ t.bigint :head_sequence, null: false
110
+ t.string :head_digest, null: false, limit: 64
111
+ end
112
+
113
+ add_index :reeve_audit_chains, :name, unique: true
76
114
  end
77
115
  end
@@ -72,6 +72,26 @@ Reeve.configure do |config|
72
72
  #
73
73
  # Left unset, which resolves to Reeve::Audit::Recorder.
74
74
 
75
+ # ---------------------------------------------------------------------------
76
+ # The key that chains the audit ledger, so a row changed, removed or reordered
77
+ # outside reeve is reported by `bin/rails reeve:verify_ledger`. It must live
78
+ # outside the database: someone who can write the table but does not hold the
79
+ # key cannot forge the chain.
80
+ #
81
+ # Derived from secret_key_base, it works in every environment — test included —
82
+ # with no new secret to provision. Without a key, every guarded call fails
83
+ # closed (or, under audit_failure_mode :warn, is recorded unchained).
84
+ config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
85
+
86
+ # Rotating secret_key_base rotates this key too. Keep the old one here, or the
87
+ # entries it signed become unverifiable (reported as such, never as tampered):
88
+ #
89
+ # config.retired_ledger_keys = [old_key]
90
+ #
91
+ # Or use a dedicated key, so secret_key_base can rotate on its own schedule:
92
+ #
93
+ # config.ledger_key = Rails.application.credentials.reeve_ledger_key
94
+
75
95
  # Cap on how many record identifiers one entry stores. The true count is
76
96
  # always recorded, and the entry says when the list was truncated.
77
97
  config.max_recorded_ids = 1000
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Audit-entry contract 2 → 3. Generated by `bin/rails generate reeve:upgrade`.
4
+ #
5
+ # Chains every row reeve writes from here on to the one before it with a keyed digest, so a
6
+ # row changed, removed or reordered outside reeve is reported by
7
+ # `bin/rails reeve:verify_ledger`.
8
+ #
9
+ # Rows already in the table are not touched: there is no honest digest to give a row
10
+ # nobody was there to sign, and nothing is backfilled into an append-only table. They are
11
+ # reported as "not covered" from now on — exactly as trustworthy as they were before this
12
+ # migration, and no more. The three columns are nullable for that reason.
13
+ #
14
+ # The chain needs a key, held outside the database. Add this to
15
+ # config/initializers/reeve.rb, or every guarded call fails closed after you migrate:
16
+ #
17
+ # config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
18
+ #
19
+ # The chain record's head moves on every write, so the role your application connects as
20
+ # needs UPDATE on reeve_audit_chains (the ledger itself stays INSERT, SELECT):
21
+ #
22
+ # GRANT INSERT, SELECT, UPDATE ON reeve_audit_chains TO app_role;
23
+ #
24
+ # A role holding that UPDATE can rewind the head, and a rewound head hides the deletion of
25
+ # the newest rows. Tamper-evidence detects a change after the fact; the grants are what
26
+ # prevent one.
27
+ #
28
+ # On a very large ledger, the unique index below scans the table once. On PostgreSQL that
29
+ # scan blocks writes for its duration; to avoid it, add `disable_ddl_transaction!` to this
30
+ # class and `algorithm: :concurrently` to the add_index call.
31
+ class AddLedgerChainToReeveAuditEntries < ActiveRecord::Migration[7.0]
32
+ def change
33
+ add_column :reeve_audit_entries, :chain_sequence, :bigint
34
+ add_column :reeve_audit_entries, :chain_key_id, :string, limit: 16
35
+ add_column :reeve_audit_entries, :chain_digest, :string, limit: 64
36
+ add_index :reeve_audit_entries, :chain_sequence, unique: true
37
+
38
+ create_table :reeve_audit_chains do |t|
39
+ t.string :name, null: false
40
+ t.datetime :genesis_at, null: false, precision: 6
41
+ t.bigint :pre_chain_count, null: false
42
+ t.bigint :pre_chain_unchained_count, null: false
43
+ t.string :genesis_key_id, null: false, limit: 16
44
+ t.string :genesis_digest, null: false, limit: 64
45
+ t.bigint :head_sequence, null: false
46
+ t.string :head_digest, null: false, limit: 64
47
+ end
48
+
49
+ add_index :reeve_audit_chains, :name, unique: true
50
+ end
51
+ end