reeve 0.1.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +154 -0
- data/README.md +104 -1
- data/lib/generators/reeve/install/install_generator.rb +4 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +9 -1
- data/lib/generators/reeve/install/templates/initializer.rb.tt +19 -0
- data/lib/generators/reeve/upgrade/templates/add_contract_version_to_reeve_audit_entries.rb.tt +19 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +130 -0
- data/lib/reeve/audit/entry.rb +9 -3
- data/lib/reeve/audit/isolated_recorder.rb +131 -0
- data/lib/reeve/audit/recorder.rb +30 -7
- data/lib/reeve/audit.rb +13 -3
- data/lib/reeve/authorization/adapters/pundit.rb +17 -4
- data/lib/reeve/context.rb +8 -1
- data/lib/reeve/testing/checks/contract_version.rb +34 -6
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +3 -2
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 225a220bbfd6168a26fdf67a816c8a580ca57ba996f97c7d1c9317755252d497
|
|
4
|
+
data.tar.gz: a5633fe99dedbc03796c82abf560eca68187fa783e5a9ceef1fb764b9a47d351
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c9bccface22cb54d935720a927dd5aff7c61582403f6d0aeada860be4f178d110d4ac94801a84d6a15993c0abad8a6b14ee0c1058311809ddf5575ddb57d1741
|
|
7
|
+
data.tar.gz: e43b1e0a1fb8900a45cb998997b45152ab726b21961c2b86ea5dea0cc0adb3e0f9e015cf7aed8cdcd6e39aa28d7e9c827ff85d11653b8aa5fab769844b1cdecb
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,160 @@ 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.3.0] - 2026-08-17
|
|
8
|
+
|
|
9
|
+
Three of the limitations 0.1.0 shipped knowingly are now closed, and the audit-entry
|
|
10
|
+
contract is unchanged — **no migration is required to take this release**.
|
|
11
|
+
|
|
12
|
+
Two of the "Known limitations" under 0.1.0 below no longer hold, and are left in place
|
|
13
|
+
because that section records what was true of that release:
|
|
14
|
+
|
|
15
|
+
- *"A transaction the host wraps around an invocation takes the ledger row with it"* —
|
|
16
|
+
`IsolatedRecorder` closes it where the database has concurrent writers.
|
|
17
|
+
- *"CI runs SQLite only"* — the suite now runs on PostgreSQL and MySQL too.
|
|
18
|
+
|
|
19
|
+
Still open from that list: an unscoped fetch by id disclosing existence, a scope-less type
|
|
20
|
+
needing to be tied to its policy, `AuditCoverage` proving one invocation rather than every
|
|
21
|
+
one, and the fast-mcp adapter needing Ruby 3.1+.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`Reeve::Audit::IsolatedRecorder`** — the ledger row on a connection of its own, which
|
|
26
|
+
closes the R5 limitation the first release documented rather than fixed. The default
|
|
27
|
+
recorder writes in a savepoint: the trace survives a rollback by the *tool*, but a
|
|
28
|
+
transaction the *host* wrapped around the whole invocation takes the row with it, and
|
|
29
|
+
the calls most worth auditing are exactly the ones that rolled back. A transaction on
|
|
30
|
+
one connection cannot roll back an INSERT committed on another.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
config.audit_recorder = Reeve::Audit::IsolatedRecorder
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Opt-in rather than the default, because it cannot work everywhere the default does: on
|
|
37
|
+
SQLite an open transaction holds the database write lock, so a second connection would
|
|
38
|
+
block until it timed out and fail every guarded call. It raises `ConfigurationError`
|
|
39
|
+
there rather than degrading quietly, and `IsolatedRecorder.available?` answers the
|
|
40
|
+
question in an initializer for a host that develops on SQLite and deploys on PostgreSQL.
|
|
41
|
+
The default recorder's warning now names it.
|
|
42
|
+
|
|
43
|
+
Worth deciding knowingly: this also keeps the row when the host rolls back an
|
|
44
|
+
invocation it meant to undo entirely. A ledger of what was *attempted* is the intended
|
|
45
|
+
reading of Constitution II, but it is a choice.
|
|
46
|
+
|
|
47
|
+
- **CI runs the suite on PostgreSQL and MySQL as well as SQLite.** The gem claims to work
|
|
48
|
+
on every database ActiveRecord supports — the ledger stores `invocation_id` as a string
|
|
49
|
+
rather than a native `uuid` precisely so that holds — and until now that claim had only
|
|
50
|
+
ever been executed against SQLite. The three engines disagree about exactly what this
|
|
51
|
+
gem leans on: what `t.json` becomes, what a boolean literal is, what
|
|
52
|
+
`change_column_default` does to a NOT NULL column, and how a savepoint behaves inside a
|
|
53
|
+
transaction the caller opened. Select an engine with `DB=postgresql` or `DB=mysql2`
|
|
54
|
+
(`DB=postgresql bundle install` first — the drivers are `install_if`-gated so the
|
|
55
|
+
default install needs no native client libraries).
|
|
56
|
+
|
|
57
|
+
All 545 examples pass on all three. The one portability defect it found was in the
|
|
58
|
+
`reeve:upgrade` spec rather than the library: a fixture row inserted `0` into boolean
|
|
59
|
+
columns, which is a boolean on SQLite and MySQL and a type error on PostgreSQL. The
|
|
60
|
+
`change_column_default` the upgrade migration depends on behaves identically on all
|
|
61
|
+
three, which was the specific thing worth knowing after shipping it against SQLite only.
|
|
62
|
+
|
|
63
|
+
- **`bin/rails generate reeve:upgrade`** — brings an existing ledger up to the current
|
|
64
|
+
audit-entry contract. It asks the table which columns it has and emits only the steps
|
|
65
|
+
that are missing, so it is a no-op on a current ledger and safe to run twice before
|
|
66
|
+
migrating. Replaces the hand-written `add_column` the 0.2.0 notes had to give.
|
|
67
|
+
([#11](https://github.com/vicmaster/reeve/issues/11))
|
|
68
|
+
- `contracts/audit-entry.md` states the rule the upgrade path depends on: **changes to the
|
|
69
|
+
table must be additive**, and a new column must be nullable or carry a default that is
|
|
70
|
+
true of the rows already written. An append-only ledger has no honest value to backfill
|
|
71
|
+
into a historical row. A change that cannot be expressed additively is a new table, not
|
|
72
|
+
a new version of this one.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
|
|
76
|
+
- `Checks::ContractVersion` names `reeve:upgrade` when the table is missing a column. It
|
|
77
|
+
said `reeve:install`, which is wrong for exactly the case it fires in: the table exists,
|
|
78
|
+
so Rails resolves that migration by name and either emits nothing or offers to overwrite
|
|
79
|
+
one that has already run — which does not touch the database and destroys the record of
|
|
80
|
+
what was applied. The install generator now says the same thing on its way out.
|
|
81
|
+
|
|
82
|
+
## [0.2.0] - 2026-08-12
|
|
83
|
+
|
|
84
|
+
Everything here came out of running 0.1.0 against a real Rails 8.1 application rather than
|
|
85
|
+
against its own test suite. The Pundit fix is the one that blocks adoption.
|
|
86
|
+
|
|
87
|
+
**Audit-entry contract version: `1` → `2`.** Two changes, one bump.
|
|
88
|
+
|
|
89
|
+
`metadata` was written NULL on every row through version 1 and now carries what the caller
|
|
90
|
+
passed. Anything mapping version 1 rows could reasonably have read that column as always
|
|
91
|
+
empty, and this project's own versioning rule calls a change in what a value means MAJOR —
|
|
92
|
+
hence 0.2.0 rather than a patch.
|
|
93
|
+
|
|
94
|
+
New non-null `contract_version` column, stamped on every row. Version 1 rows could not
|
|
95
|
+
name their own shape, which is exactly what made the `metadata` change ambiguous to a
|
|
96
|
+
reader: on a version 1 row a NULL `metadata` means "never recorded", on a version 2 row it
|
|
97
|
+
means "the caller passed none". Stamping the row settles that at read time instead of
|
|
98
|
+
requiring a reader to know when the writing gem was deployed, and it makes every future
|
|
99
|
+
bump legible on the row. It also gives `Checks::ContractVersion` real teeth — a stale
|
|
100
|
+
table now fails on the missing column even when a bump was purely semantic.
|
|
101
|
+
|
|
102
|
+
**Upgrading from 0.1.0:**
|
|
103
|
+
|
|
104
|
+
- Re-run `bin/rails generate reeve:install` and migrate, or add the column by hand:
|
|
105
|
+
`add_column :reeve_audit_entries, :contract_version, :integer, null: false, default: 1`
|
|
106
|
+
— `default: 1` is correct for existing rows, since that is the contract they were
|
|
107
|
+
written under. Drop the default afterwards if you prefer; the recorder always sets it.
|
|
108
|
+
- A host pinning `Reeve::Checks::ContractVersion.new(expected: 1)` moves it to `2`.
|
|
109
|
+
- **A custom `audit_recorder` that writes to `Reeve::Audit::Entry` must now set
|
|
110
|
+
`contract_version`.** The model rejects a row without it.
|
|
111
|
+
|
|
112
|
+
### Added
|
|
113
|
+
|
|
114
|
+
- README: **Wrapping your own JSON-RPC server** — the integration path for a Rails app
|
|
115
|
+
that already exposes `/mcp` from its own controller and tool registry. Covers mapping
|
|
116
|
+
JSON-RPC tool names to `Reeve.invoke`, passing request headers as `metadata`, resolving
|
|
117
|
+
the principal from `Current.user` or a bearer token while keeping the host's existing
|
|
118
|
+
authentication, adopting one tool at a time through `:allow_with_warning`, and pointing
|
|
119
|
+
the compliance checks at the host's own dispatcher. The recipe is executed by
|
|
120
|
+
`spec/reeve/integrations/custom_dispatcher_spec.rb` rather than only written down.
|
|
121
|
+
([#5](https://github.com/vicmaster/reeve/issues/5))
|
|
122
|
+
|
|
123
|
+
### Fixed
|
|
124
|
+
|
|
125
|
+
- `metadata` passed to `Reeve.invoke` is written to the ledger. The column, the recorder
|
|
126
|
+
mapping and the contract check all existed, but `Context#to_h` did not carry the value,
|
|
127
|
+
so `metadata` was NULL on every call ever recorded — including the header hash the
|
|
128
|
+
fast-mcp adapter collects. It is redacted on the way in, by the same redactor and the
|
|
129
|
+
same declared names as the arguments, so `Authorization` does not land in the ledger.
|
|
130
|
+
This is the change behind the contract-version bump above.
|
|
131
|
+
- `contracts/audit-entry.md` no longer claims the contract version is "recorded in the
|
|
132
|
+
initializer". It never was — which is what prompted the `contract_version` column above,
|
|
133
|
+
so that from contract 2 the claim is simply true.
|
|
134
|
+
- The envelope-overhead spec compares the fastest call on each side rather than the mean
|
|
135
|
+
of two separately-timed windows. A single GC pause or scheduler preemption landing in
|
|
136
|
+
one window and not the other lands whole in the difference — 300ms over fifty runs is
|
|
137
|
+
6ms per call against a 5ms budget, from a busy machine rather than a regression. It
|
|
138
|
+
loses no sensitivity: a regression is paid on every call, so it slows the fastest trial
|
|
139
|
+
too. Verified both ways — an injected one-off stall moves the measurement from 6.65ms to
|
|
140
|
+
0.23ms, and 6ms added to every call still fails at 7.13ms.
|
|
141
|
+
- The spec suite declares UTF-8 (`spec/spec_helper.rb`). The README-spec encoding bug was
|
|
142
|
+
one instance of five: eleven examples across `readme_spec`, `contract_version_spec`,
|
|
143
|
+
`migration_spec`, `install_generator_spec`, `framework_neutrality_spec`,
|
|
144
|
+
`isolation_spec` and `verification_spec` died on `invalid byte sequence` whenever a file
|
|
145
|
+
was run on its own, and passed in a full run only because some other file happened to
|
|
146
|
+
set the encoding first. Every spec file now passes in isolation, and the suite passes
|
|
147
|
+
under `LC_ALL=C`.
|
|
148
|
+
- A Pundit policy that inherits its `Scope` from a base policy (`class LeadPolicy <
|
|
149
|
+
LeadBasePolicy`) is now recognised. Policy detection looked only at the policy's own
|
|
150
|
+
namespace, so the ordinary Pundit inheritance pattern was refused at declaration time
|
|
151
|
+
with `ConfigurationError`. Detection now walks the policy's ancestry, stopping before
|
|
152
|
+
`Object` so a top-level `Scope` constant still cannot make an unrelated class look
|
|
153
|
+
Pundit-shaped. ([#3](https://github.com/vicmaster/reeve/issues/3))
|
|
154
|
+
- `lib/reeve.rb` no longer describes the kernel as unreleased work in progress. The note
|
|
155
|
+
belonged to the 0.0.1 name-claim release and contradicted 0.1.0 for anyone reading the
|
|
156
|
+
installed gem. ([#4](https://github.com/vicmaster/reeve/issues/4))
|
|
157
|
+
- The README compliance specs read the file as UTF-8. Under a POSIX locale they read it
|
|
158
|
+
as US-ASCII and every example failed on the first em dash, so the guarantees they
|
|
159
|
+
exist to enforce were never actually checked.
|
|
160
|
+
|
|
7
161
|
## [0.1.0] - 2026-08-11
|
|
8
162
|
|
|
9
163
|
First working release. The published 0.0.1 was a placeholder holding the gem name.
|
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.
|
|
10
|
+
> **Status: 0.3.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
|
|
|
@@ -136,6 +136,20 @@ A call whose tool raised is still recorded — that trace is the one most worth
|
|
|
136
136
|
call that cannot be recorded fails, unless the host has explicitly opted into
|
|
137
137
|
`audit_failure_mode = :warn`.
|
|
138
138
|
|
|
139
|
+
If your application wraps invocations in a transaction — a controller that opens one per
|
|
140
|
+
request, a job runner — a rollback takes the ledger row with it, and exactly the calls
|
|
141
|
+
worth auditing are the ones that rolled back. Write on a connection the rollback cannot
|
|
142
|
+
reach:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
config.audit_recorder = Reeve::Audit::IsolatedRecorder
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
It needs a database with concurrent writers and refuses on SQLite, where an open
|
|
149
|
+
transaction holds the write lock; `IsolatedRecorder.available?` lets you branch if you
|
|
150
|
+
develop on one and deploy on the other. The default recorder warns when it detects the
|
|
151
|
+
case rather than failing silently.
|
|
152
|
+
|
|
139
153
|
## Provable in CI, in whichever framework you already use
|
|
140
154
|
|
|
141
155
|
All the logic lives in framework-neutral checks. RSpec and Minitest are thin front-ends
|
|
@@ -219,6 +233,95 @@ Reeve.invoke(
|
|
|
219
233
|
)
|
|
220
234
|
```
|
|
221
235
|
|
|
236
|
+
## Wrapping your own JSON-RPC server
|
|
237
|
+
|
|
238
|
+
Plenty of Rails apps expose `/mcp` from a controller they wrote themselves, with their own
|
|
239
|
+
tool registry and their own bearer-token authentication. There is no adapter to install
|
|
240
|
+
for that, and none is needed: `Reeve.invoke` is the adapter interface. An MCP integration
|
|
241
|
+
is a function from a JSON-RPC request to one `Reeve.invoke` call.
|
|
242
|
+
|
|
243
|
+
Keep the authentication you have. Reeve does not do connection auth (see [What you have
|
|
244
|
+
not gained](#what-you-have-not-gained)) — the controller still decides whether the caller
|
|
245
|
+
gets in the door, and Reeve decides what they may touch once inside.
|
|
246
|
+
|
|
247
|
+
**Dispatch through the envelope.** Map the JSON-RPC tool name to the class, then call:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
# app/controllers/mcp_controller.rb
|
|
251
|
+
def call_tool
|
|
252
|
+
tool = McpServer.registry.fetch(params.dig(:params, :name))
|
|
253
|
+
|
|
254
|
+
records = Reeve.invoke(
|
|
255
|
+
tool: tool,
|
|
256
|
+
arguments: params.dig(:params, :arguments).to_h.symbolize_keys,
|
|
257
|
+
agent: { id: request.headers["X-MCP-Client"] || "unknown" },
|
|
258
|
+
metadata: { headers: request.headers.to_h.slice(*AUDITED_HEADERS) }
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
render json: { jsonrpc: "2.0", id: params[:id], result: serialize(records) }
|
|
262
|
+
rescue Reeve::DeniedError => e
|
|
263
|
+
render json: { jsonrpc: "2.0", id: params[:id],
|
|
264
|
+
error: { code: -32_003, message: e.message } }
|
|
265
|
+
end
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Note what is *not* passed: `principal:`. Omit it and the resolver in your initializer runs,
|
|
269
|
+
which is what you want when the controller has already set `Current.user` — one place
|
|
270
|
+
decides who the principal is, and the ledger records the same answer the guard used.
|
|
271
|
+
Passing `principal:` explicitly overrides the resolver for that call, which is useful in
|
|
272
|
+
tests and in scripts.
|
|
273
|
+
|
|
274
|
+
**`metadata:` is transport detail, and it is recorded.** It reaches the resolver as
|
|
275
|
+
`context.metadata` and is written to the ledger's `metadata` column, so it is what a
|
|
276
|
+
reviewer has to reconstruct *which request* a row came from. It goes through the same
|
|
277
|
+
redactor as the arguments, so `Authorization` and friends are replaced by name — but pass
|
|
278
|
+
the headers you would want in an audit rather than all of them.
|
|
279
|
+
|
|
280
|
+
**Resolve the principal from whichever the controller established:**
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
Reeve.configure do |config|
|
|
284
|
+
config.principal_resolver = lambda do |context|
|
|
285
|
+
Current.user || ApiToken.find_by(
|
|
286
|
+
token: context.metadata.dig(:headers, "Authorization").to_s.delete_prefix("Bearer ")
|
|
287
|
+
)&.user
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
A resolver that returns nil — or raises — denies with `no_principal` and still writes a
|
|
293
|
+
row. There is no configuration in which an unidentified caller reaches a tool.
|
|
294
|
+
|
|
295
|
+
**Adopt one tool at a time.** A registry of thirty tools does not need thirty policies
|
|
296
|
+
before any of this is worth turning on:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
config.unguarded_tools = :allow_with_warning # migrating
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Tools with `guard_with` are authorized and scoped normally. Tools without one still run —
|
|
303
|
+
unscoped, which is the entire point of the warning — and are recorded with `guard: "none"`
|
|
304
|
+
and rule `unguarded_tool`, so the ledger itself is your worklist:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
Reeve::Audit::Entry.where(guard: "none").distinct.pluck(:tool_name)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Flip to `:deny` when that comes back empty, and the mode stops being reachable by accident.
|
|
311
|
+
|
|
312
|
+
The compliance checks work here too, and they take an `invoke:` argument precisely so they
|
|
313
|
+
run against your dispatcher rather than a synthetic call:
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
Reeve::Checks.run_all(
|
|
317
|
+
principals: [alice, bob],
|
|
318
|
+
invoke: ->(tool:, principal:, arguments:) { McpServer.dispatch(tool, principal, arguments) }
|
|
319
|
+
)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
That is the whole integration: one call site, your auth untouched, and the same three
|
|
323
|
+
guarantees the fast-mcp adapter gets.
|
|
324
|
+
|
|
222
325
|
Policies are plain objects unless you want Pundit (`authorize` and `scope`, two methods).
|
|
223
326
|
The ledger is an ActiveRecord table unless you supply your own recorder. Records are
|
|
224
327
|
ActiveRecord unless they are not — a plain object with an `id` works.
|
|
@@ -39,6 +39,10 @@ module Reeve
|
|
|
39
39
|
2. Run `bin/rails db:migrate` to create the audit ledger.
|
|
40
40
|
|
|
41
41
|
Then add `guard_with SomePolicy` to a tool.
|
|
42
|
+
|
|
43
|
+
Upgrading an existing ledger later? Use `rails g reeve:upgrade`, not this
|
|
44
|
+
generator — Rails resolves a migration by name, so re-running install cannot
|
|
45
|
+
deliver a shape change to a table that already exists.
|
|
42
46
|
NEXT
|
|
43
47
|
end
|
|
44
48
|
end
|
|
@@ -14,7 +14,7 @@
|
|
|
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
16
|
#
|
|
17
|
-
# Also explicitly out of scope for
|
|
17
|
+
# Also explicitly out of scope for now, so you can plan around it:
|
|
18
18
|
# * no retention, rotation or archival — this table grows until you prune it, and
|
|
19
19
|
# pruning is your policy, run by a role that is allowed to delete;
|
|
20
20
|
# * no cryptographic chaining or tamper-evidence. If that lands later it arrives as a
|
|
@@ -53,6 +53,14 @@ class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
|
|
|
53
53
|
t.string :guard, null: false
|
|
54
54
|
t.integer :duration_ms
|
|
55
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
|
|
56
64
|
end
|
|
57
65
|
|
|
58
66
|
add_index :reeve_audit_entries, :invocation_id, unique: true
|
|
@@ -53,6 +53,25 @@ Reeve.configure do |config|
|
|
|
53
53
|
# guarantee for availability.
|
|
54
54
|
config.audit_failure_mode = :fail
|
|
55
55
|
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Does your application wrap the invocation in a transaction?
|
|
58
|
+
#
|
|
59
|
+
# A controller that opens one per request, a job runner, a test suite with
|
|
60
|
+
# transactional fixtures — if that transaction rolls back, it takes the ledger
|
|
61
|
+
# row with it, and the default recorder can only warn about it.
|
|
62
|
+
#
|
|
63
|
+
# config.audit_recorder = Reeve::Audit::IsolatedRecorder
|
|
64
|
+
#
|
|
65
|
+
# writes on a connection of its own, which a rollback on yours cannot reach.
|
|
66
|
+
# It needs a database with concurrent writers and refuses to run on SQLite, so
|
|
67
|
+
# a host that develops on SQLite and deploys on PostgreSQL branches:
|
|
68
|
+
#
|
|
69
|
+
# config.audit_recorder =
|
|
70
|
+
# Reeve::Audit::IsolatedRecorder.available? ? Reeve::Audit::IsolatedRecorder
|
|
71
|
+
# : Reeve::Audit::Recorder
|
|
72
|
+
#
|
|
73
|
+
# Left unset, which resolves to Reeve::Audit::Recorder.
|
|
74
|
+
|
|
56
75
|
# Cap on how many record identifiers one entry stores. The true count is
|
|
57
76
|
# always recorded, and the entry says when the list was truncated.
|
|
58
77
|
config.max_recorded_ids = 1000
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Audit-entry contract 1 → 2. Generated by `bin/rails generate reeve:upgrade`.
|
|
4
|
+
#
|
|
5
|
+
# Adds the column that lets a row name the shape it was written under. Contract 1 wrote
|
|
6
|
+
# `metadata` NULL whatever the caller passed, and contract 2 writes what was passed, so
|
|
7
|
+
# without this column a NULL `metadata` is ambiguous: "never recorded" and "the caller
|
|
8
|
+
# passed none" look identical, separable only by knowing when the gem was deployed.
|
|
9
|
+
#
|
|
10
|
+
# `default: 1` is not a fallback, it is the truth about the rows already in the table:
|
|
11
|
+
# every one of them was written under contract 1. The default is then dropped, so a row
|
|
12
|
+
# inserted from here on has to say what it is — which matches the shape a fresh
|
|
13
|
+
# `reeve:install` creates, and keeps the two paths converged.
|
|
14
|
+
class AddContractVersionToReeveAuditEntries < ActiveRecord::Migration[7.0]
|
|
15
|
+
def change
|
|
16
|
+
add_column :reeve_audit_entries, :contract_version, :integer, null: false, default: 1
|
|
17
|
+
change_column_default :reeve_audit_entries, :contract_version, from: 1, to: nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
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:upgrade` — brings an existing ledger up to the audit-entry
|
|
9
|
+
# contract this version of the gem writes.
|
|
10
|
+
#
|
|
11
|
+
# Why this exists as a second generator rather than as a re-run of `reeve:install`:
|
|
12
|
+
# Rails resolves a migration by name, so `install` on a host that already has
|
|
13
|
+
# `create_reeve_audit_entries` reports `identical` and emits nothing, or — once the
|
|
14
|
+
# template has changed, which is exactly the upgrade case — offers to overwrite a
|
|
15
|
+
# migration that has already run. Overwriting an applied migration does not touch the
|
|
16
|
+
# database and destroys the record of what was applied, so the one thing a host must
|
|
17
|
+
# never be told to do about an out-of-date ledger is run the install generator again.
|
|
18
|
+
#
|
|
19
|
+
# What it emits is decided by the table, not by a version number the host might have
|
|
20
|
+
# recorded wrongly or not at all: each step declares the columns it adds, and a step
|
|
21
|
+
# whose columns are all present has already been applied. That makes running this on a
|
|
22
|
+
# current ledger a no-op it can report rather than a duplicate migration.
|
|
23
|
+
class UpgradeGenerator < Rails::Generators::Base
|
|
24
|
+
include ActiveRecord::Generators::Migration
|
|
25
|
+
|
|
26
|
+
TABLE = "reeve_audit_entries"
|
|
27
|
+
|
|
28
|
+
# The ladder, oldest first. One entry per contract bump that touches the table.
|
|
29
|
+
#
|
|
30
|
+
# `adds` is what makes a step detectable, so it must name every column the step
|
|
31
|
+
# creates. A step that changes a column without adding one cannot be detected this
|
|
32
|
+
# way and needs its own predicate — see the additive-only rule in
|
|
33
|
+
# contracts/audit-entry.md, which exists so that stays hypothetical: an append-only
|
|
34
|
+
# ledger cannot be backfilled, so a column that is not additive has no honest value
|
|
35
|
+
# to give the rows already written.
|
|
36
|
+
STEPS = [
|
|
37
|
+
{
|
|
38
|
+
contract: 2,
|
|
39
|
+
adds: %w[contract_version],
|
|
40
|
+
template: "add_contract_version_to_reeve_audit_entries.rb.tt",
|
|
41
|
+
destination: "add_contract_version_to_reeve_audit_entries.rb"
|
|
42
|
+
}
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
source_root File.expand_path("templates", __dir__)
|
|
46
|
+
|
|
47
|
+
desc "Brings an existing reeve audit ledger up to the current audit-entry contract."
|
|
48
|
+
|
|
49
|
+
def verify_ledger_exists
|
|
50
|
+
return if table_exists?
|
|
51
|
+
|
|
52
|
+
say <<~MISSING
|
|
53
|
+
|
|
54
|
+
There is no #{TABLE} table to upgrade.
|
|
55
|
+
|
|
56
|
+
This generator is for a ledger that already exists. For a new one:
|
|
57
|
+
|
|
58
|
+
bin/rails generate reeve:install
|
|
59
|
+
bin/rails db:migrate
|
|
60
|
+
MISSING
|
|
61
|
+
|
|
62
|
+
# Not an exception: "you wanted the other generator" is a normal thing to get
|
|
63
|
+
# wrong, and a backtrace would suggest reeve broke.
|
|
64
|
+
@halted = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def create_upgrade_migrations
|
|
68
|
+
return if @halted
|
|
69
|
+
|
|
70
|
+
pending = STEPS.reject { |step| applied?(step) }
|
|
71
|
+
return @nothing_to_do = true if pending.empty?
|
|
72
|
+
|
|
73
|
+
pending.each do |step|
|
|
74
|
+
migration_template(step[:template], "db/migrate/#{step[:destination]}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
@emitted = pending
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def report_next_step
|
|
81
|
+
return if @halted
|
|
82
|
+
|
|
83
|
+
say(@nothing_to_do ? up_to_date_message : pending_message)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def up_to_date_message
|
|
89
|
+
<<~CURRENT
|
|
90
|
+
|
|
91
|
+
#{TABLE} is already at audit-entry contract #{STEPS.last[:contract]}. Nothing to do.
|
|
92
|
+
CURRENT
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def pending_message
|
|
96
|
+
<<~NEXT
|
|
97
|
+
|
|
98
|
+
#{@emitted.size} migration#{'s' if @emitted.size != 1} written, taking the ledger
|
|
99
|
+
to audit-entry contract #{@emitted.last[:contract]}. Run:
|
|
100
|
+
|
|
101
|
+
bin/rails db:migrate
|
|
102
|
+
|
|
103
|
+
Existing rows are stamped with the contract they were actually written under,
|
|
104
|
+
not with the current one.
|
|
105
|
+
NEXT
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Asked of the table rather than of a version the host recorded: the table is the
|
|
109
|
+
# thing being changed, and it is the only party that cannot be out of date about
|
|
110
|
+
# itself.
|
|
111
|
+
def applied?(step)
|
|
112
|
+
(step[:adds] - existing_columns).empty?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def existing_columns
|
|
116
|
+
@existing_columns ||= connection.columns(TABLE).map(&:name)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def table_exists?
|
|
120
|
+
connection.table_exists?(TABLE)
|
|
121
|
+
rescue StandardError
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def connection
|
|
126
|
+
ActiveRecord::Base.connection
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/reeve/audit/entry.rb
CHANGED
|
@@ -16,7 +16,9 @@ module Reeve
|
|
|
16
16
|
DENY = "deny"
|
|
17
17
|
OUTCOMES = [ALLOW, DENY].freeze
|
|
18
18
|
|
|
19
|
-
REQUIRED = %i[
|
|
19
|
+
REQUIRED = %i[
|
|
20
|
+
invocation_id occurred_at agent_id tool_name outcome rule guard contract_version
|
|
21
|
+
].freeze
|
|
20
22
|
|
|
21
23
|
validates(*REQUIRED, presence: true)
|
|
22
24
|
validates :invocation_id, uniqueness: true
|
|
@@ -24,8 +26,12 @@ module Reeve
|
|
|
24
26
|
|
|
25
27
|
before_destroy { throw :abort }
|
|
26
28
|
|
|
27
|
-
# The contract version this
|
|
28
|
-
#
|
|
29
|
+
# The contract version this build of the gem writes (FR-015).
|
|
30
|
+
#
|
|
31
|
+
# Class-level, and deliberately not the same question as `entry.contract_version`:
|
|
32
|
+
# this is what the gem implements *now*, while the column on each row is the shape
|
|
33
|
+
# that row was actually written under. They differ for every row written before an
|
|
34
|
+
# upgrade, which is the whole reason the column exists.
|
|
29
35
|
def self.contract_version
|
|
30
36
|
CONTRACT_VERSION
|
|
31
37
|
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
# The ledger row, written on a connection of its own.
|
|
6
|
+
#
|
|
7
|
+
# `Recorder` writes in a savepoint, which protects the trace from a transaction the
|
|
8
|
+
# *tool* opens and rolls back. It cannot protect the trace from a transaction the
|
|
9
|
+
# *host* opened around the whole invocation — a controller that wraps each request in
|
|
10
|
+
# one, a job runner, a test suite with transactional fixtures. A savepoint released
|
|
11
|
+
# into that transaction goes down with it, and the envelope has no way to learn
|
|
12
|
+
# otherwise: the write reported success, the invocation returned records, and no row
|
|
13
|
+
# survives to say so. Constitution II says every call leaves a trace; on one
|
|
14
|
+
# connection that is not achievable, only detectable, which is why `Recorder` warns.
|
|
15
|
+
#
|
|
16
|
+
# A second connection is the only thing that actually closes it. A transaction on
|
|
17
|
+
# connection A cannot roll back an INSERT committed on connection B, so the row lands
|
|
18
|
+
# whatever the host's transaction does next.
|
|
19
|
+
#
|
|
20
|
+
# config.audit_recorder = Reeve::Audit::IsolatedRecorder
|
|
21
|
+
#
|
|
22
|
+
# == Why this is not the default
|
|
23
|
+
#
|
|
24
|
+
# It cannot be. On SQLite the host's open transaction holds the database write lock,
|
|
25
|
+
# so the second connection blocks until it times out and every guarded call fails —
|
|
26
|
+
# the isolation is not merely unavailable there, it is actively worse than the
|
|
27
|
+
# savepoint. Rather than degrade quietly on the database most hosts develop against,
|
|
28
|
+
# this refuses to run on SQLite and says why. `Recorder` remains the default, and a
|
|
29
|
+
# host that needs durability under a wrapping transaction opts in on a database where
|
|
30
|
+
# opting in means something.
|
|
31
|
+
#
|
|
32
|
+
# It also costs a connection per pool, and moves the ledger write outside the host's
|
|
33
|
+
# transaction in both directions: the row survives a rollback, which is the point,
|
|
34
|
+
# and it also survives a rollback of an invocation the host *meant* to undo entirely.
|
|
35
|
+
# A ledger of what was attempted is the intended reading of Constitution II, but it is
|
|
36
|
+
# a choice, and it should be made rather than inherited.
|
|
37
|
+
class IsolatedRecorder < Recorder
|
|
38
|
+
UNSUPPORTED_ADAPTERS = %w[sqlite sqlite3].freeze
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def record(attributes)
|
|
42
|
+
new.record(attributes)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# True when a second connection can actually be used here. Lets a host branch in
|
|
46
|
+
# an initializer — SQLite in development, PostgreSQL in production — rather than
|
|
47
|
+
# discovering the answer on the first guarded call.
|
|
48
|
+
def available?
|
|
49
|
+
verify_supported!
|
|
50
|
+
true
|
|
51
|
+
rescue Reeve::Error
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def verify_supported!
|
|
56
|
+
adapter = IsolatedEntry.adapter_name
|
|
57
|
+
return unless UNSUPPORTED_ADAPTERS.include?(adapter.to_s.downcase)
|
|
58
|
+
|
|
59
|
+
raise ConfigurationError,
|
|
60
|
+
"Reeve::Audit::IsolatedRecorder cannot be used on #{adapter}: an open " \
|
|
61
|
+
"transaction holds the database write lock, so a second connection " \
|
|
62
|
+
"blocks until it times out and every guarded call fails. Use " \
|
|
63
|
+
"Reeve::Audit::Recorder here — it warns when a host transaction is " \
|
|
64
|
+
"wrapped around an invocation — and configure this recorder only where " \
|
|
65
|
+
"the database supports concurrent writers."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def initialize(config: nil)
|
|
70
|
+
super(entry_class: IsolatedEntry, config: config)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Checked out and returned around each write rather than held: the pool exists to
|
|
74
|
+
# keep the ledger off the host's connection, not to keep a connection per thread
|
|
75
|
+
# alive for the life of the process.
|
|
76
|
+
def record(attributes)
|
|
77
|
+
self.class.verify_supported!
|
|
78
|
+
IsolatedEntry.connection_pool.with_connection { super }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# The savepoint `Recorder` uses is what makes the trace survive the *tool's* own
|
|
84
|
+
# rollback, and that is still worth having here: this connection is not in a
|
|
85
|
+
# transaction the host controls, but the tool body may have opened one on the main
|
|
86
|
+
# connection, and a single INSERT wants the same all-or-nothing treatment either
|
|
87
|
+
# way. Nothing about it is inherited from the host's transaction.
|
|
88
|
+
#
|
|
89
|
+
# The warning the parent emits is correctly silent here: it asks whether *this*
|
|
90
|
+
# connection has a transaction open, and this one never does.
|
|
91
|
+
def enclosing_transaction?
|
|
92
|
+
false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The same table and the same rules, on a pool of its own.
|
|
97
|
+
#
|
|
98
|
+
# A subclass rather than a second model so that validations, readonly-once-persisted
|
|
99
|
+
# and the destroy guard are defined in exactly one place. The table has no
|
|
100
|
+
# inheritance column, so ActiveRecord adds no type condition and this reads and writes
|
|
101
|
+
# the same rows `Entry` does.
|
|
102
|
+
class IsolatedEntry < Entry
|
|
103
|
+
class << self
|
|
104
|
+
def adapter_name
|
|
105
|
+
establish_own_connection!
|
|
106
|
+
connection_pool.db_config.adapter
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
raise Error, "could not reach the audit ledger on its own connection: " \
|
|
109
|
+
"#{e.class}: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def connection_pool
|
|
113
|
+
establish_own_connection!
|
|
114
|
+
super
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Idempotent, and deliberately lazy: a Rails initializer naming this recorder runs
|
|
120
|
+
# before the database is necessarily reachable, so the pool is built on first use
|
|
121
|
+
# rather than at configure time.
|
|
122
|
+
def establish_own_connection!
|
|
123
|
+
return if @own_connection
|
|
124
|
+
|
|
125
|
+
establish_connection(::ActiveRecord::Base.connection_db_config)
|
|
126
|
+
@own_connection = true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
data/lib/reeve/audit/recorder.rb
CHANGED
|
@@ -26,10 +26,14 @@ module Reeve
|
|
|
26
26
|
# A genuinely independent write needs a second connection, and that is not portable:
|
|
27
27
|
# on SQLite the enclosing transaction holds the write lock, so a second connection
|
|
28
28
|
# blocks until it times out. Rather than fail every call on the databases where
|
|
29
|
-
# isolation is impossible,
|
|
30
|
-
# that the guarantee is suspended for that call.
|
|
31
|
-
#
|
|
32
|
-
#
|
|
29
|
+
# isolation is impossible, this recorder detects the enclosing transaction and warns
|
|
30
|
+
# that the guarantee is suspended for that call.
|
|
31
|
+
#
|
|
32
|
+
# `Reeve::Audit::IsolatedRecorder` is the answer where the database has concurrent
|
|
33
|
+
# writers: same rows, same redaction, on a pool of its own, so a rollback on the
|
|
34
|
+
# host's connection cannot reach it. It is opt-in rather than the default precisely
|
|
35
|
+
# because it cannot work everywhere this one does. A host with a different answer
|
|
36
|
+
# again — a queue, an append-only log — still supplies its own `audit_recorder`.
|
|
33
37
|
#
|
|
34
38
|
# The other known limit, unchanged: on a single connection there is a narrow window
|
|
35
39
|
# where the tool's data commits and the ledger write then fails. The caller learns by
|
|
@@ -89,7 +93,11 @@ module Reeve
|
|
|
89
93
|
derived: attributes[:derived] ? true : false,
|
|
90
94
|
guard: blank?(attributes[:guard]) ? "policy" : attributes[:guard].to_s,
|
|
91
95
|
duration_ms: attributes[:duration_ms],
|
|
92
|
-
metadata: attributes
|
|
96
|
+
metadata: redact_metadata(attributes),
|
|
97
|
+
# Stamped from the constant rather than from the caller: the row records the
|
|
98
|
+
# shape the gem that wrote it implements, which is not something an adapter or
|
|
99
|
+
# a host is in a position to assert.
|
|
100
|
+
contract_version: CONTRACT_VERSION
|
|
93
101
|
)
|
|
94
102
|
end
|
|
95
103
|
|
|
@@ -115,6 +123,20 @@ module Reeve
|
|
|
115
123
|
Redactor.for(attributes[:tool_name], config: config).call(attributes[:arguments])
|
|
116
124
|
end
|
|
117
125
|
|
|
126
|
+
# Metadata is transport detail, and the transport is where the credentials are: the
|
|
127
|
+
# fast-mcp bridge puts the whole header hash in here, `Authorization` included. It
|
|
128
|
+
# goes through the same redactor as the arguments — which recurses into nested
|
|
129
|
+
# hashes and already knows `authorization`, `token` and friends by name — so
|
|
130
|
+
# recording metadata does not turn the ledger into a place bearer tokens accumulate.
|
|
131
|
+
# nil stays nil rather than becoming `{}`: a call that carried no metadata should
|
|
132
|
+
# not be indistinguishable from one whose metadata was emptied.
|
|
133
|
+
def redact_metadata(attributes)
|
|
134
|
+
metadata = attributes[:metadata]
|
|
135
|
+
return nil if metadata.nil? || metadata.empty?
|
|
136
|
+
|
|
137
|
+
Redactor.for(attributes[:tool_name], config: config).call(metadata)
|
|
138
|
+
end
|
|
139
|
+
|
|
118
140
|
# FR-014: identifiers are capped, never silently dropped — the count stays true and
|
|
119
141
|
# the row says it was truncated. The scoper caps first; this is the backstop that
|
|
120
142
|
# makes the guarantee hold at the ledger regardless of who produced the list.
|
|
@@ -143,8 +165,9 @@ module Reeve
|
|
|
143
165
|
|
|
144
166
|
message = "reeve: invocation #{invocation_id} was recorded inside a transaction " \
|
|
145
167
|
"the host opened around it, so the ledger row will be rolled back with " \
|
|
146
|
-
"it.
|
|
147
|
-
"
|
|
168
|
+
"it. Set Reeve.config.audit_recorder = Reeve::Audit::IsolatedRecorder " \
|
|
169
|
+
"to write on a connection the transaction cannot reach (needs a " \
|
|
170
|
+
"database with concurrent writers; not SQLite)."
|
|
148
171
|
logger = config.logger
|
|
149
172
|
logger ? logger.warn(message) : Kernel.warn(message)
|
|
150
173
|
end
|
data/lib/reeve/audit.rb
CHANGED
|
@@ -43,16 +43,25 @@ module Reeve
|
|
|
43
43
|
# table. Immutability is enforced at the library level; the generated migration
|
|
44
44
|
# documents the `GRANT INSERT, SELECT` that enforces the rest where it can actually be
|
|
45
45
|
# enforced, and the gem makes no stronger claim than that.
|
|
46
|
-
# * No retention, rotation or archival
|
|
46
|
+
# * No retention, rotation or archival yet — the table is host-owned and the host's
|
|
47
47
|
# existing policies apply.
|
|
48
|
-
# * No cryptographic chaining or tamper-evidence
|
|
48
|
+
# * No cryptographic chaining or tamper-evidence yet. If that lands it arrives as a
|
|
49
49
|
# nullable column, which the audit-entry contract's versioning already permits.
|
|
50
|
+
#
|
|
51
|
+
# ("yet" rather than "in v1": the gem version and the audit-entry contract version are
|
|
52
|
+
# different numbers that move independently, and writing v1 for one of them read as the
|
|
53
|
+
# other.)
|
|
50
54
|
module Audit
|
|
51
55
|
# The version of the audit-entry shape, as documented in
|
|
52
56
|
# specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
|
|
53
57
|
# column is a MINOR change and leaves this alone; removing or renaming a column, or
|
|
54
58
|
# changing what a value means, is MAJOR and bumps it.
|
|
55
|
-
|
|
59
|
+
#
|
|
60
|
+
# 2 — `metadata` carries the transport detail the caller passed. Through version 1 it
|
|
61
|
+
# was written NULL on every row regardless of what was passed, so anything mapping
|
|
62
|
+
# version 1 rows could reasonably have read the column as "always empty". The shape
|
|
63
|
+
# did not change; what a value means did.
|
|
64
|
+
CONTRACT_VERSION = 2
|
|
56
65
|
|
|
57
66
|
TABLE_NAME = "reeve_audit_entries"
|
|
58
67
|
|
|
@@ -83,6 +92,7 @@ end
|
|
|
83
92
|
require_relative "audit/redactor"
|
|
84
93
|
require_relative "audit/entry"
|
|
85
94
|
require_relative "audit/recorder"
|
|
95
|
+
require_relative "audit/isolated_recorder"
|
|
86
96
|
require_relative "audit/query"
|
|
87
97
|
|
|
88
98
|
Reeve::Audit.install!
|
|
@@ -22,15 +22,28 @@ module Reeve
|
|
|
22
22
|
return false unless pundit_loaded?
|
|
23
23
|
return false unless policy.is_a?(Class)
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
# top-level Scope constant would make an unrelated policy look Pundit-shaped.
|
|
27
|
-
policy.const_defined?(:Scope, false) &&
|
|
25
|
+
scope_defined?(policy) &&
|
|
28
26
|
policy.public_instance_methods.any? { |method| method.to_s.end_with?("?") }
|
|
29
27
|
end
|
|
30
28
|
|
|
29
|
+
# Pundit policies ordinarily inherit their Scope from a base policy
|
|
30
|
+
# (`class LeadPolicy < LeadBasePolicy`), so asking only the policy's own namespace
|
|
31
|
+
# rejected the common case. This walks the ancestry instead, stopping before
|
|
32
|
+
# Object — which is the whole reason the check was narrow to begin with: a
|
|
33
|
+
# top-level `Scope` constant must never make an unrelated class look
|
|
34
|
+
# Pundit-shaped. `policy::Scope` resolves along the same ancestry, so what
|
|
35
|
+
# `supports?` accepts is exactly what `scope` can later reach.
|
|
36
|
+
def self.scope_defined?(policy)
|
|
37
|
+
policy_ancestry(policy).any? { |ancestor| ancestor.const_defined?(:Scope, false) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.policy_ancestry(policy)
|
|
41
|
+
policy.ancestors.take_while { |ancestor| ancestor != Object }
|
|
42
|
+
end
|
|
43
|
+
|
|
31
44
|
def self.missing_methods(policy)
|
|
32
45
|
return [] if supports?(policy)
|
|
33
|
-
return [:Scope] if policy.is_a?(Class) && !
|
|
46
|
+
return [:Scope] if policy.is_a?(Class) && !scope_defined?(policy)
|
|
34
47
|
|
|
35
48
|
%i[query_method Scope]
|
|
36
49
|
end
|
data/lib/reeve/context.rb
CHANGED
|
@@ -56,6 +56,12 @@ module Reeve
|
|
|
56
56
|
|
|
57
57
|
# The audit-facing projection. The recorder adds the outcome, rule and records;
|
|
58
58
|
# everything here is known before the tool runs.
|
|
59
|
+
#
|
|
60
|
+
# `metadata` belongs here even though nothing in the envelope reads it: the ledger has
|
|
61
|
+
# a column for it, the recorder maps it, and the contract check requires it — but this
|
|
62
|
+
# method used to omit it, so the column was written NULL on every call ever made. The
|
|
63
|
+
# transport detail a reviewer most wants after an incident (which request, which
|
|
64
|
+
# headers, which client) was accepted at the front door and dropped before the write.
|
|
59
65
|
def to_h
|
|
60
66
|
{
|
|
61
67
|
invocation_id: invocation_id,
|
|
@@ -65,7 +71,8 @@ module Reeve
|
|
|
65
71
|
agent_name: agent_name,
|
|
66
72
|
principal_type: principal_type,
|
|
67
73
|
principal_id: principal_id,
|
|
68
|
-
arguments: arguments
|
|
74
|
+
arguments: arguments,
|
|
75
|
+
metadata: metadata
|
|
69
76
|
}
|
|
70
77
|
end
|
|
71
78
|
|
|
@@ -11,20 +11,44 @@ module Reeve
|
|
|
11
11
|
# upgraded the gem and skipped the migration has a ledger one shape behind, and every
|
|
12
12
|
# other check in this kit would go on passing while columns quietly went unwritten.
|
|
13
13
|
#
|
|
14
|
+
# What each half of this check is worth, stated plainly because it is easy to read
|
|
15
|
+
# more into the version number than it carries:
|
|
16
|
+
#
|
|
17
|
+
# * The **column list** is the real test. It is compared against the columns the
|
|
18
|
+
# host's table actually has, and it is written out by hand rather than read back
|
|
19
|
+
# off the model — a check that derives its expectation from the thing it is
|
|
20
|
+
# checking checks nothing.
|
|
21
|
+
# * The **version number** is compared against what the ledger model reports, which
|
|
22
|
+
# is this gem's own +Audit::CONTRACT_VERSION+ — so by default it compares the gem
|
|
23
|
+
# to itself and can only pass. It earns its keep when a host passes `expected:` to
|
|
24
|
+
# pin the version it built its exports against.
|
|
25
|
+
#
|
|
26
|
+
# Those two are not as separate as they look, and that is by design. A contract bump
|
|
27
|
+
# is defined as a change to the shape or to what a value means; every such bump also
|
|
28
|
+
# moves the `contract_version` column, which means the column list catches a stale
|
|
29
|
+
# table even when the bump was purely semantic. Contract 2 is the worked example: the
|
|
30
|
+
# change was `metadata`'s meaning, no existing column moved, and a host still on the
|
|
31
|
+
# contract 1 table fails here on a missing `contract_version` column rather than
|
|
32
|
+
# passing while writing rows nothing can interpret.
|
|
33
|
+
#
|
|
34
|
+
# The constant below is written out rather than read from +Audit+ for the same
|
|
35
|
+
# reason the column list is, and one more: the testing kit loads with no ledger and
|
|
36
|
+
# no ActiveRecord at all (spec/reeve/testing/isolation_spec.rb), so it cannot
|
|
37
|
+
# reference the audit module. Bumping the contract means editing both by hand, and
|
|
38
|
+
# spec/reeve/audit/contract_version_spec.rb fails if they drift.
|
|
39
|
+
#
|
|
14
40
|
# Reeve::Checks::ContractVersion.new.call
|
|
41
|
+
# Reeve::Checks::ContractVersion.new(expected: 2).call # pinned by the host
|
|
15
42
|
class ContractVersion < Base
|
|
16
43
|
TABLE = "reeve_audit_entries"
|
|
17
44
|
|
|
18
|
-
# Contract version 1, as documented in contracts/audit-entry.md. This list is
|
|
19
|
-
# deliberately written out rather than read back off the model: a check that
|
|
20
|
-
# derives its expectation from the thing it is checking checks nothing.
|
|
21
45
|
COLUMNS = %w[
|
|
22
46
|
invocation_id occurred_at agent_id agent_name principal_type principal_id
|
|
23
47
|
tool_name arguments outcome rule detail record_type record_ids record_count
|
|
24
|
-
truncated derived guard duration_ms metadata
|
|
48
|
+
truncated derived guard duration_ms metadata contract_version
|
|
25
49
|
].freeze
|
|
26
50
|
|
|
27
|
-
EXPECTED_VERSION =
|
|
51
|
+
EXPECTED_VERSION = 2
|
|
28
52
|
|
|
29
53
|
def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
|
|
30
54
|
super(tool: tool, ledger: ledger)
|
|
@@ -55,10 +79,14 @@ module Reeve
|
|
|
55
79
|
)
|
|
56
80
|
end
|
|
57
81
|
|
|
82
|
+
# Names the generator that actually fixes this. It used to say `reeve:install`,
|
|
83
|
+
# which is wrong for the case this failure describes: the table exists, so Rails
|
|
84
|
+
# resolves that migration by name and either emits nothing or offers to overwrite
|
|
85
|
+
# one that has already run.
|
|
58
86
|
def missing_columns(missing)
|
|
59
87
|
failed(
|
|
60
88
|
"expected the ledger to implement audit-entry contract version #{@expected}, " \
|
|
61
|
-
"but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:
|
|
89
|
+
"but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:upgrade` " \
|
|
62
90
|
"and migrate",
|
|
63
91
|
version: @expected, missing: missing
|
|
64
92
|
)
|
data/lib/reeve/version.rb
CHANGED
data/lib/reeve.rb
CHANGED
|
@@ -12,7 +12,8 @@ require_relative "reeve/authorization"
|
|
|
12
12
|
# Reeve — per-record authorization and an append-only audit ledger for the MCP tools
|
|
13
13
|
# a Rails application exposes to AI agents.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
15
|
+
# A tool declares the policy that governs it with `guard_with`; every invocation is
|
|
16
|
+
# authorized, scoped to what the principal may see, and recorded. See
|
|
17
|
+
# https://github.com/vicmaster/reeve.
|
|
17
18
|
module Reeve
|
|
18
19
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: reeve
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Victor Velazquez
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Reeve makes it safe for a Rails application to expose MCP (Model Context Protocol)
|
|
@@ -30,9 +30,12 @@ files:
|
|
|
30
30
|
- lib/generators/reeve/install/install_generator.rb
|
|
31
31
|
- lib/generators/reeve/install/templates/create_audit_entries.rb.tt
|
|
32
32
|
- lib/generators/reeve/install/templates/initializer.rb.tt
|
|
33
|
+
- lib/generators/reeve/upgrade/templates/add_contract_version_to_reeve_audit_entries.rb.tt
|
|
34
|
+
- lib/generators/reeve/upgrade/upgrade_generator.rb
|
|
33
35
|
- lib/reeve.rb
|
|
34
36
|
- lib/reeve/audit.rb
|
|
35
37
|
- lib/reeve/audit/entry.rb
|
|
38
|
+
- lib/reeve/audit/isolated_recorder.rb
|
|
36
39
|
- lib/reeve/audit/query.rb
|
|
37
40
|
- lib/reeve/audit/recorder.rb
|
|
38
41
|
- lib/reeve/audit/redactor.rb
|