reeve 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -0
- data/README.md +74 -2
- data/lib/generators/reeve/install/install_generator.rb +4 -0
- 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/isolated_recorder.rb +131 -0
- data/lib/reeve/audit/recorder.rb +11 -6
- data/lib/reeve/audit.rb +1 -0
- data/lib/reeve/authorization/guard.rb +53 -0
- data/lib/reeve/authorization/registry.rb +43 -0
- data/lib/reeve/configuration.rb +23 -1
- data/lib/reeve/integrations/fast_mcp/tool_extension.rb +5 -1
- data/lib/reeve/invocation.rb +46 -0
- data/lib/reeve/testing/checks/contract_version.rb +5 -1
- data/lib/reeve/testing/checks.rb +1 -1
- data/lib/reeve/testing.rb +13 -0
- data/lib/reeve/version.rb +1 -1
- 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: 329fc869bdb15c7e60eb1923221aeccb1725821535c25186dafe77bda001cc44
|
|
4
|
+
data.tar.gz: 0f0781268bc7e90b1fad27a74bef611aa6aa65df3da37fb1351eb3f28524da15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b5d6286339f5f034e76f74052e3d19f9560c55b45e9432e6583b50931ddf4425a1aa5bc5c0ea1200646a9085f972521a3d1e2c38d8cf654e8aa8004fa2be0e3f
|
|
7
|
+
data.tar.gz: 32c28f2c34294f7d2454b772a1fd7bd52af690c3bc6d46793e2ec23021c6c3bd5d0d4bc6ee5f04b990a36a5f8ada40e03677bd9efa5c2a099b08c6bcf4cac147
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,155 @@ 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.4.0] - 2026-09-03
|
|
8
|
+
|
|
9
|
+
Everything here came out of a second application integrating the gem — one that had put a
|
|
10
|
+
handful of its MCP tools behind reeve and was about to guard its write tools next. Two
|
|
11
|
+
defects surfaced from reviewing that work, and both were failures of the same kind: the
|
|
12
|
+
gem reporting a guarantee it had not actually established.
|
|
13
|
+
|
|
14
|
+
**The audit-entry contract is unchanged at `2`, so no migration is required.**
|
|
15
|
+
|
|
16
|
+
Behaviour changes worth reading before upgrading:
|
|
17
|
+
|
|
18
|
+
- A denied invocation now rolls the tool's work back. A guarded tool that writes and is
|
|
19
|
+
then refused used to leave the write behind.
|
|
20
|
+
- A guarded tool that raises after writing now has that write rolled back too.
|
|
21
|
+
- The compliance suite now reports tools that never declared a guard, so a suite that
|
|
22
|
+
passed before may legitimately fail now. That is the fix, not a regression — set
|
|
23
|
+
`config.compliance_tools` to what you have certified so far.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **The compliance suite can see a tool that has no guard.** It walked the guard registry,
|
|
28
|
+
which holds one entry per `guard_with` — so every subject it checked had a guard by
|
|
29
|
+
construction, and `GuardDeclared`, the check whose whole job is naming tools that lack
|
|
30
|
+
one, could not fail. An application with three guarded tools and one that was forgotten
|
|
31
|
+
reported `4 checks, 4 passed`. The gem's own quickstart demonstrated this: it ships a
|
|
32
|
+
deliberately unguarded tool and then asserted that the compliance run passed.
|
|
33
|
+
|
|
34
|
+
Tools are now recorded when they include `Reeve::Guard`, declared or not — through
|
|
35
|
+
inheritance too, so an adapter that includes the DSL into a base class once (fast-mcp)
|
|
36
|
+
no longer hides every tool built on it. `Reeve.registry.unguarded_tool_classes` is the
|
|
37
|
+
worklist. A base class that carries the DSL for its subclasses declares itself with
|
|
38
|
+
`reeve_abstract!` rather than being guessed at: inferring "anything with a subclass is a
|
|
39
|
+
base" would silently drop a real tool from the run the moment someone subclassed it.
|
|
40
|
+
|
|
41
|
+
**Reeve can only see tools that reached it.** An MCP server dispatching tools reeve was
|
|
42
|
+
never told about is still invisible to it — pass `tools:` to certify the real inventory.
|
|
43
|
+
|
|
44
|
+
- **A denied invocation no longer leaves the host's data changed.** The envelope
|
|
45
|
+
authorizes before the tool runs, with the model class as the subject because no record
|
|
46
|
+
exists yet, and scopes the return value afterwards. A write tool lives between those two
|
|
47
|
+
points: it fetched a record, changed it, and only then was refused. The caller saw
|
|
48
|
+
`out_of_scope_record`, the ledger recorded a denial — and the row in the database had
|
|
49
|
+
already been rewritten by a principal who could not see it. A `deny` that follows a
|
|
50
|
+
committed write is not a partial guarantee, it is a false statement in the artifact the
|
|
51
|
+
ledger exists to be.
|
|
52
|
+
|
|
53
|
+
The tool body now runs in a transaction and a scope denial rolls it back. `requires_new`
|
|
54
|
+
makes it a savepoint, so a transaction the host opened around the invocation is left
|
|
55
|
+
alone — the rollback reaches the tool's work and stops there. Measured overhead is at or
|
|
56
|
+
below noise on a read-only call.
|
|
57
|
+
|
|
58
|
+
Two consequences worth knowing:
|
|
59
|
+
|
|
60
|
+
- A tool that **writes and then raises** now has that write rolled back too. It was
|
|
61
|
+
already recorded as `tool_error`; the data now matches what the ledger said.
|
|
62
|
+
- A host with no ActiveRecord, or with the library loaded but no connection, keeps the
|
|
63
|
+
previous behaviour — there is nothing to roll back, and neither should be made to fail.
|
|
64
|
+
|
|
65
|
+
### Added
|
|
66
|
+
|
|
67
|
+
- **`config.compliance_tools`** — which tools the suite certifies, as an array or a
|
|
68
|
+
callable, defaulting to every tool reeve knows about. A retrofit is the normal state of
|
|
69
|
+
an application adopting this, and now that unguarded tools are reported, one that has
|
|
70
|
+
guarded three of thirty has a red build it cannot honestly turn green. A build expected
|
|
71
|
+
to be red is a build nobody reads. Narrowing the list states what has been certified —
|
|
72
|
+
a claim that can go green and stay green as the list grows.
|
|
73
|
+
|
|
74
|
+
- **`authorize!(record)`** inside a tool body — asks the declared policy about one record
|
|
75
|
+
and returns it, raising `DeniedError` if the policy says no. For the part of a tool a
|
|
76
|
+
rollback cannot reach: a sent email, a webhook, a written file. A denial raised this way
|
|
77
|
+
carries the policy's own rule into the ledger rather than being filed as `tool_error`,
|
|
78
|
+
and names no record, so a refusal and a record that does not exist still read the same
|
|
79
|
+
(FR-006).
|
|
80
|
+
|
|
81
|
+
## [0.3.0] - 2026-08-17
|
|
82
|
+
|
|
83
|
+
Three of the limitations 0.1.0 shipped knowingly are now closed, and the audit-entry
|
|
84
|
+
contract is unchanged — **no migration is required to take this release**.
|
|
85
|
+
|
|
86
|
+
Two of the "Known limitations" under 0.1.0 below no longer hold, and are left in place
|
|
87
|
+
because that section records what was true of that release:
|
|
88
|
+
|
|
89
|
+
- *"A transaction the host wraps around an invocation takes the ledger row with it"* —
|
|
90
|
+
`IsolatedRecorder` closes it where the database has concurrent writers.
|
|
91
|
+
- *"CI runs SQLite only"* — the suite now runs on PostgreSQL and MySQL too.
|
|
92
|
+
|
|
93
|
+
Still open from that list: an unscoped fetch by id disclosing existence, a scope-less type
|
|
94
|
+
needing to be tied to its policy, `AuditCoverage` proving one invocation rather than every
|
|
95
|
+
one, and the fast-mcp adapter needing Ruby 3.1+.
|
|
96
|
+
|
|
97
|
+
### Added
|
|
98
|
+
|
|
99
|
+
- **`Reeve::Audit::IsolatedRecorder`** — the ledger row on a connection of its own, which
|
|
100
|
+
closes the R5 limitation the first release documented rather than fixed. The default
|
|
101
|
+
recorder writes in a savepoint: the trace survives a rollback by the *tool*, but a
|
|
102
|
+
transaction the *host* wrapped around the whole invocation takes the row with it, and
|
|
103
|
+
the calls most worth auditing are exactly the ones that rolled back. A transaction on
|
|
104
|
+
one connection cannot roll back an INSERT committed on another.
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
config.audit_recorder = Reeve::Audit::IsolatedRecorder
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Opt-in rather than the default, because it cannot work everywhere the default does: on
|
|
111
|
+
SQLite an open transaction holds the database write lock, so a second connection would
|
|
112
|
+
block until it timed out and fail every guarded call. It raises `ConfigurationError`
|
|
113
|
+
there rather than degrading quietly, and `IsolatedRecorder.available?` answers the
|
|
114
|
+
question in an initializer for a host that develops on SQLite and deploys on PostgreSQL.
|
|
115
|
+
The default recorder's warning now names it.
|
|
116
|
+
|
|
117
|
+
Worth deciding knowingly: this also keeps the row when the host rolls back an
|
|
118
|
+
invocation it meant to undo entirely. A ledger of what was *attempted* is the intended
|
|
119
|
+
reading of Constitution II, but it is a choice.
|
|
120
|
+
|
|
121
|
+
- **CI runs the suite on PostgreSQL and MySQL as well as SQLite.** The gem claims to work
|
|
122
|
+
on every database ActiveRecord supports — the ledger stores `invocation_id` as a string
|
|
123
|
+
rather than a native `uuid` precisely so that holds — and until now that claim had only
|
|
124
|
+
ever been executed against SQLite. The three engines disagree about exactly what this
|
|
125
|
+
gem leans on: what `t.json` becomes, what a boolean literal is, what
|
|
126
|
+
`change_column_default` does to a NOT NULL column, and how a savepoint behaves inside a
|
|
127
|
+
transaction the caller opened. Select an engine with `DB=postgresql` or `DB=mysql2`
|
|
128
|
+
(`DB=postgresql bundle install` first — the drivers are `install_if`-gated so the
|
|
129
|
+
default install needs no native client libraries).
|
|
130
|
+
|
|
131
|
+
All 545 examples pass on all three. The one portability defect it found was in the
|
|
132
|
+
`reeve:upgrade` spec rather than the library: a fixture row inserted `0` into boolean
|
|
133
|
+
columns, which is a boolean on SQLite and MySQL and a type error on PostgreSQL. The
|
|
134
|
+
`change_column_default` the upgrade migration depends on behaves identically on all
|
|
135
|
+
three, which was the specific thing worth knowing after shipping it against SQLite only.
|
|
136
|
+
|
|
137
|
+
- **`bin/rails generate reeve:upgrade`** — brings an existing ledger up to the current
|
|
138
|
+
audit-entry contract. It asks the table which columns it has and emits only the steps
|
|
139
|
+
that are missing, so it is a no-op on a current ledger and safe to run twice before
|
|
140
|
+
migrating. Replaces the hand-written `add_column` the 0.2.0 notes had to give.
|
|
141
|
+
([#11](https://github.com/vicmaster/reeve/issues/11))
|
|
142
|
+
- `contracts/audit-entry.md` states the rule the upgrade path depends on: **changes to the
|
|
143
|
+
table must be additive**, and a new column must be nullable or carry a default that is
|
|
144
|
+
true of the rows already written. An append-only ledger has no honest value to backfill
|
|
145
|
+
into a historical row. A change that cannot be expressed additively is a new table, not
|
|
146
|
+
a new version of this one.
|
|
147
|
+
|
|
148
|
+
### Fixed
|
|
149
|
+
|
|
150
|
+
- `Checks::ContractVersion` names `reeve:upgrade` when the table is missing a column. It
|
|
151
|
+
said `reeve:install`, which is wrong for exactly the case it fires in: the table exists,
|
|
152
|
+
so Rails resolves that migration by name and either emits nothing or offers to overwrite
|
|
153
|
+
one that has already run — which does not touch the database and destroys the record of
|
|
154
|
+
what was applied. The install generator now says the same thing on its way out.
|
|
155
|
+
|
|
7
156
|
## [0.2.0] - 2026-08-12
|
|
8
157
|
|
|
9
158
|
Everything here came out of running 0.1.0 against a real Rails 8.1 application rather than
|
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.4.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
|
|
|
@@ -119,6 +119,42 @@ end
|
|
|
119
119
|
Reaching for `Invoice.sum(:cents)` there is denied with `unscoped_derived_result`. The
|
|
120
120
|
guarantee is structural, not a matter of remembering.
|
|
121
121
|
|
|
122
|
+
## Tools that write
|
|
123
|
+
|
|
124
|
+
A denial means nothing happened. Authorization runs before the tool, but with the model
|
|
125
|
+
class as its subject — for an index-style check there is no record yet — so the per-record
|
|
126
|
+
answer only arrives once the tool has returned something to scope. A write tool sits
|
|
127
|
+
between those two points:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
def call(id:, number:)
|
|
131
|
+
invoice = Invoice.find(id) # unscoped fetch
|
|
132
|
+
invoice.update!(number: number) # ...then a write
|
|
133
|
+
invoice
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The tool body runs in a transaction, and a scope denial rolls it back. Someone else's
|
|
138
|
+
record is refused *and* unchanged, and the ledger's `deny` is a true statement about the
|
|
139
|
+
database. A transaction the host opened around the invocation is untouched — the rollback
|
|
140
|
+
reaches the tool's work and stops there.
|
|
141
|
+
|
|
142
|
+
What a rollback cannot reach is anything that was never in the transaction. A tool that
|
|
143
|
+
sends an email, calls a webhook or writes a file before it knows whether it is allowed to
|
|
144
|
+
must ask first:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
def call(id:, to:)
|
|
148
|
+
invoice = authorize!(Invoice.find(id)) # raises DeniedError if the policy says no
|
|
149
|
+
InvoiceMailer.reminder(invoice, to).deliver_now
|
|
150
|
+
invoice
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`authorize!` asks the declared policy about one record and returns it, so it reads inline.
|
|
155
|
+
The denial carries the policy's own rule into the ledger, and names no record — a refusal
|
|
156
|
+
and a record that does not exist have to read the same.
|
|
157
|
+
|
|
122
158
|
## Every call leaves a trace
|
|
123
159
|
|
|
124
160
|
One append-only row per invocation, allowed or denied, naming the agent, the principal, the
|
|
@@ -136,6 +172,20 @@ A call whose tool raised is still recorded — that trace is the one most worth
|
|
|
136
172
|
call that cannot be recorded fails, unless the host has explicitly opted into
|
|
137
173
|
`audit_failure_mode = :warn`.
|
|
138
174
|
|
|
175
|
+
If your application wraps invocations in a transaction — a controller that opens one per
|
|
176
|
+
request, a job runner — a rollback takes the ledger row with it, and exactly the calls
|
|
177
|
+
worth auditing are the ones that rolled back. Write on a connection the rollback cannot
|
|
178
|
+
reach:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
config.audit_recorder = Reeve::Audit::IsolatedRecorder
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
It needs a database with concurrent writers and refuses on SQLite, where an open
|
|
185
|
+
transaction holds the write lock; `IsolatedRecorder.available?` lets you branch if you
|
|
186
|
+
develop on one and deploy on the other. The default recorder warns when it detects the
|
|
187
|
+
case rather than failing silently.
|
|
188
|
+
|
|
139
189
|
## Provable in CI, in whichever framework you already use
|
|
140
190
|
|
|
141
191
|
All the logic lives in framework-neutral checks. RSpec and Minitest are thin front-ends
|
|
@@ -177,7 +227,29 @@ abort report.to_s unless report.passed?
|
|
|
177
227
|
```
|
|
178
228
|
|
|
179
229
|
`alice` and `bob` are two fixture principals with disjoint records — that disjointness is
|
|
180
|
-
what makes a shared record identifier proof of a leak.
|
|
230
|
+
what makes a shared record identifier proof of a leak.
|
|
231
|
+
|
|
232
|
+
**The suite walks every tool that included `Reeve::Guard`, not only the ones that declared
|
|
233
|
+
a guard.** A tool that forgot `guard_with` is the one worth finding, and it is absent from
|
|
234
|
+
the guard registry by definition — so a run that only inspected guarded tools reported
|
|
235
|
+
all-green on precisely the application that had a problem.
|
|
236
|
+
|
|
237
|
+
Mid-retrofit that means a red build, which is honest but unreadable if it stays red for
|
|
238
|
+
weeks. Say what you have certified so far, and grow the list:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
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
|
+
```
|
|
251
|
+
|
|
252
|
+
As a Rails rake task:
|
|
181
253
|
|
|
182
254
|
```ruby
|
|
183
255
|
# lib/tasks/reeve.rake
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -161,8 +165,9 @@ module Reeve
|
|
|
161
165
|
|
|
162
166
|
message = "reeve: invocation #{invocation_id} was recorded inside a transaction " \
|
|
163
167
|
"the host opened around it, so the ledger row will be rolled back with " \
|
|
164
|
-
"it.
|
|
165
|
-
"
|
|
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)."
|
|
166
171
|
logger = config.logger
|
|
167
172
|
logger ? logger.warn(message) : Kernel.warn(message)
|
|
168
173
|
end
|
data/lib/reeve/audit.rb
CHANGED
|
@@ -16,10 +16,22 @@ module Reeve
|
|
|
16
16
|
module Guard
|
|
17
17
|
def self.included(base)
|
|
18
18
|
base.extend(ClassMethods)
|
|
19
|
+
Reeve.registry.note(base)
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
# Class-level DSL. See contracts/tool-dsl.md.
|
|
22
23
|
module ClassMethods
|
|
24
|
+
# Declares this class a base rather than a tool: it carries the DSL so its
|
|
25
|
+
# subclasses have it, and the compliance suite should not ask whether it is guarded.
|
|
26
|
+
#
|
|
27
|
+
# class ApplicationTool
|
|
28
|
+
# include Reeve::Guard
|
|
29
|
+
# reeve_abstract!
|
|
30
|
+
# end
|
|
31
|
+
def reeve_abstract!
|
|
32
|
+
Reeve.registry.mark_abstract(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
23
35
|
# Declares which policy governs this tool. Absence is not neutral: a tool with no
|
|
24
36
|
# declaration is denied by the envelope (FR-002, FR-004).
|
|
25
37
|
def guard_with(policy, action: nil)
|
|
@@ -59,8 +71,16 @@ module Reeve
|
|
|
59
71
|
|
|
60
72
|
# A subclass of a guarded tool is itself guarded, and is registered under its own
|
|
61
73
|
# name so the envelope — which only ever has a name — can find it.
|
|
74
|
+
#
|
|
75
|
+
# The note comes first and unconditionally, because a subclass of an *unguarded*
|
|
76
|
+
# base is the case worth catching: subclasses acquire the DSL without ever running
|
|
77
|
+
# `included`, so without this every fast-mcp tool would be invisible to the
|
|
78
|
+
# compliance suite — that adapter includes Guard into `FastMcp::Tool` once and lets
|
|
79
|
+
# inheritance do the rest.
|
|
62
80
|
def inherited(subclass)
|
|
63
81
|
super
|
|
82
|
+
Reeve.registry.note(subclass)
|
|
83
|
+
|
|
64
84
|
declaration = reeve_guard
|
|
65
85
|
# An anonymous subclass has no name to be looked up by; it still inherits the
|
|
66
86
|
# declaration through the ancestry walk in Registry#for_class.
|
|
@@ -93,5 +113,38 @@ module Reeve
|
|
|
93
113
|
|
|
94
114
|
Authorization::Scoper.scoped_relation(state, model_or_relation)
|
|
95
115
|
end
|
|
116
|
+
|
|
117
|
+
# Asks the declared policy about one record, before the tool acts on it. Returns the
|
|
118
|
+
# record so it reads inline; raises +DeniedError+ if the policy says no.
|
|
119
|
+
#
|
|
120
|
+
# def call(id:, to:)
|
|
121
|
+
# invoice = authorize!(Invoice.find(id))
|
|
122
|
+
# InvoiceMailer.reminder(invoice, to).deliver_now
|
|
123
|
+
# invoice
|
|
124
|
+
# end
|
|
125
|
+
#
|
|
126
|
+
# The envelope authorizes before the tool runs and scopes what it returns, and between
|
|
127
|
+
# those two points a tool can do things neither of them can reach. A rolled-back write
|
|
128
|
+
# leaves no trace; a sent email does. `scoped(...)` is the answer when a tool can work
|
|
129
|
+
# from a relation; this is the answer when it cannot — when the tool holds one record
|
|
130
|
+
# and is about to do something to the world with it.
|
|
131
|
+
#
|
|
132
|
+
# The denial carries the policy's own rule, so the ledger names what refused rather
|
|
133
|
+
# than reporting the tool as broken. It names no record: an out-of-scope record and a
|
|
134
|
+
# record that does not exist have to be indistinguishable (FR-006), and a tool that
|
|
135
|
+
# reached this line has already fetched the record it must not talk about.
|
|
136
|
+
def authorize!(record)
|
|
137
|
+
state = Authorization::Current.state
|
|
138
|
+
raise Error, "authorize!(...) may only be called inside a guarded invocation" if state.nil?
|
|
139
|
+
|
|
140
|
+
decision = state.adapter.authorize(
|
|
141
|
+
principal: state.context.principal, policy: state.declaration.policy,
|
|
142
|
+
action: state.declaration.action, record: record
|
|
143
|
+
)
|
|
144
|
+
return record if decision.allowed?
|
|
145
|
+
|
|
146
|
+
raise DeniedError.from(decision, tool_name: state.context.tool_name,
|
|
147
|
+
principal_id: state.context.principal_id)
|
|
148
|
+
end
|
|
96
149
|
end
|
|
97
150
|
end
|
|
@@ -13,10 +13,49 @@ module Reeve
|
|
|
13
13
|
|
|
14
14
|
def initialize
|
|
15
15
|
@declarations = {}
|
|
16
|
+
@known = {} # every class that included Guard, declared or not
|
|
17
|
+
@abstract = {} # bases others inherit from; not tools themselves
|
|
16
18
|
@name_index = nil # invalidated on every add; see #name_index
|
|
17
19
|
@mutex = Mutex.new
|
|
18
20
|
end
|
|
19
21
|
|
|
22
|
+
# A class that included the DSL. Recorded whether or not it goes on to declare
|
|
23
|
+
# anything, because a tool that forgot `guard_with` is precisely the one worth
|
|
24
|
+
# finding, and it is absent from `@declarations` by definition.
|
|
25
|
+
def note(tool_class)
|
|
26
|
+
@mutex.synchronize { @known[tool_class] = true }
|
|
27
|
+
tool_class
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A base others inherit from — `FastMcp::Tool`, or a host's own `ApplicationTool`.
|
|
31
|
+
# It acquires the DSL so its subclasses have it, and it is not itself a tool, so
|
|
32
|
+
# asking whether it declared a guard is a question with no useful answer.
|
|
33
|
+
#
|
|
34
|
+
# Explicit rather than inferred. The obvious heuristic — "a class something else
|
|
35
|
+
# inherits from is a base" — silently drops a real tool from the compliance run the
|
|
36
|
+
# moment someone subclasses it, and a check that quietly stops checking is the
|
|
37
|
+
# failure mode this whole file exists to prevent.
|
|
38
|
+
def mark_abstract(tool_class)
|
|
39
|
+
@mutex.synchronize { @abstract[tool_class] = true }
|
|
40
|
+
tool_class
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def abstract?(tool_class)
|
|
44
|
+
@abstract.key?(tool_class)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Every tool reeve knows about: the ones that declared a guard and the ones that
|
|
48
|
+
# only included the DSL. This is what the compliance suite walks, so that "all
|
|
49
|
+
# checks passed" cannot mean "we only looked at the tools that were already safe".
|
|
50
|
+
def tool_classes
|
|
51
|
+
(@known.keys | @declarations.keys).reject { |klass| abstract?(klass) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The worklist.
|
|
55
|
+
def unguarded_tool_classes
|
|
56
|
+
tool_classes.reject { |klass| @declarations.key?(klass) }
|
|
57
|
+
end
|
|
58
|
+
|
|
20
59
|
# The DSL's `guard_with`. Declaring twice on one class is a mistake worth naming;
|
|
21
60
|
# `add` is the quiet path used by `redact` and by inheritance, which refine an
|
|
22
61
|
# existing declaration rather than compete with it.
|
|
@@ -74,6 +113,8 @@ module Reeve
|
|
|
74
113
|
def remove(tool_class)
|
|
75
114
|
@mutex.synchronize do
|
|
76
115
|
@declarations.delete(tool_class)
|
|
116
|
+
@known.delete(tool_class)
|
|
117
|
+
@abstract.delete(tool_class)
|
|
77
118
|
@name_index = nil
|
|
78
119
|
end
|
|
79
120
|
end
|
|
@@ -81,6 +122,8 @@ module Reeve
|
|
|
81
122
|
def reset!
|
|
82
123
|
@mutex.synchronize do
|
|
83
124
|
@declarations = {}
|
|
125
|
+
@known = {}
|
|
126
|
+
@abstract = {}
|
|
84
127
|
@name_index = nil
|
|
85
128
|
end
|
|
86
129
|
end
|
data/lib/reeve/configuration.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Reeve
|
|
|
24
24
|
SETTINGS = %i[
|
|
25
25
|
principal_resolver unguarded_tools audit_failure_mode redact_arguments
|
|
26
26
|
max_recorded_ids policy_adapter default_action audit_recorder logger
|
|
27
|
-
compliance_principals
|
|
27
|
+
compliance_principals compliance_tools
|
|
28
28
|
].freeze
|
|
29
29
|
|
|
30
30
|
attr_reader(*SETTINGS)
|
|
@@ -40,11 +40,33 @@ module Reeve
|
|
|
40
40
|
@audit_recorder = nil
|
|
41
41
|
@logger = nil
|
|
42
42
|
@compliance_principals = nil
|
|
43
|
+
@compliance_tools = nil
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
# Two fixture principals with disjoint records — the only host setup the compliance
|
|
46
47
|
# suite needs (contracts/testing-kit.md). A callable rather than a value, because in a
|
|
47
48
|
# Rails test suite the fixtures do not exist yet when the helper is loaded.
|
|
49
|
+
# Which tools the compliance suite certifies. Defaults to every tool reeve knows
|
|
50
|
+
# about, which is the answer a finished application wants.
|
|
51
|
+
#
|
|
52
|
+
# A host part-way through a retrofit needs the other answer. Once the suite reports
|
|
53
|
+
# unguarded tools — which is the whole point of it — an application with thirty tools
|
|
54
|
+
# and three guarded ones has a red build it cannot honestly turn green, and a red
|
|
55
|
+
# build that is expected to be red stops being read. Narrowing the list says "these
|
|
56
|
+
# are the ones I have certified", which is a claim that can go green and then stay
|
|
57
|
+
# green as the list grows.
|
|
58
|
+
#
|
|
59
|
+
# config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }
|
|
60
|
+
def compliance_tools=(tools)
|
|
61
|
+
unless tools.nil? || tools.respond_to?(:call) || tools.is_a?(Array)
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"compliance_tools must be an Array or a callable returning one, " \
|
|
64
|
+
"got #{tools.inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@compliance_tools = tools
|
|
68
|
+
end
|
|
69
|
+
|
|
48
70
|
def compliance_principals=(principals)
|
|
49
71
|
unless principals.nil? || principals.respond_to?(:call) || principals.is_a?(Array)
|
|
50
72
|
raise ArgumentError,
|
|
@@ -37,7 +37,11 @@ module Reeve
|
|
|
37
37
|
# Idempotent: requiring "reeve/fast_mcp" twice must not stack two envelopes around
|
|
38
38
|
# the same call.
|
|
39
39
|
def install!(tool_base = ::FastMcp::Tool)
|
|
40
|
-
|
|
40
|
+
unless tool_base.include?(Reeve::Guard)
|
|
41
|
+
tool_base.include(Reeve::Guard)
|
|
42
|
+
# FastMcp::Tool carries the DSL for its subclasses; it is not itself a tool.
|
|
43
|
+
tool_base.reeve_abstract!
|
|
44
|
+
end
|
|
41
45
|
|
|
42
46
|
unless tool_base.singleton_class.include?(Inheritance)
|
|
43
47
|
tool_base.singleton_class.prepend(Inheritance)
|
data/lib/reeve/invocation.rb
CHANGED
|
@@ -152,13 +152,51 @@ module Reeve
|
|
|
152
152
|
Decision.deny(rule: Decision::POLICY_ERROR, detail: "policy raised #{e.class}: #{e.message}")
|
|
153
153
|
end
|
|
154
154
|
|
|
155
|
+
# A denial has to mean nothing happened.
|
|
156
|
+
#
|
|
157
|
+
# Authorization runs before the tool, but with the *class* as its subject — for an
|
|
158
|
+
# index-style check there is no record yet. The per-record answer only exists once the
|
|
159
|
+
# tool has returned something to scope. So a write tool used to mutate the record and
|
|
160
|
+
# then be refused its response: the caller saw `out_of_scope_record`, the ledger
|
|
161
|
+
# recorded a denial, and the row in the host's database had already changed. A denial
|
|
162
|
+
# in a compliance artifact that follows a committed write is not a partial guarantee,
|
|
163
|
+
# it is a false statement.
|
|
164
|
+
#
|
|
165
|
+
# Running the tool inside a transaction closes that. `requires_new` matters: under a
|
|
166
|
+
# transaction the host opened around the invocation this is a savepoint, so rolling
|
|
167
|
+
# back takes the tool's work and leaves the host's alone.
|
|
168
|
+
#
|
|
169
|
+
# What it cannot undo is anything that was never in the transaction — a sent email, a
|
|
170
|
+
# webhook, a file. A tool that reaches outside the database before it knows whether it
|
|
171
|
+
# is allowed to must ask first; `authorize!` in the tool body is for exactly that.
|
|
155
172
|
def run_guarded(guard, decision, &tool)
|
|
173
|
+
return run_and_narrow(guard, decision, &tool) unless rollback_available?
|
|
174
|
+
|
|
175
|
+
::ActiveRecord::Base.transaction(requires_new: true) do
|
|
176
|
+
outcome = run_and_narrow(guard, decision, &tool)
|
|
177
|
+
raise ::ActiveRecord::Rollback if @scope_result.denied?
|
|
178
|
+
|
|
179
|
+
outcome
|
|
180
|
+
end || @scope_result.decision
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def run_and_narrow(guard, decision, &tool)
|
|
156
184
|
result = execute(&tool)
|
|
157
185
|
|
|
158
186
|
@scope_result = narrow(guard, result)
|
|
159
187
|
@scope_result.denied? ? @scope_result.decision : decision
|
|
160
188
|
end
|
|
161
189
|
|
|
190
|
+
# The core runs with no ActiveRecord at all (SC-008), and a host may have the library
|
|
191
|
+
# loaded without a connection. Neither can roll anything back, and neither should be
|
|
192
|
+
# made to fail here — they get the behaviour they had before, which the ledger still
|
|
193
|
+
# records truthfully because it records what the scoper decided either way.
|
|
194
|
+
def rollback_available?
|
|
195
|
+
defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.connected?
|
|
196
|
+
rescue StandardError
|
|
197
|
+
false
|
|
198
|
+
end
|
|
199
|
+
|
|
162
200
|
# The degraded mode a host opts into while retrofitting guards onto existing tools
|
|
163
201
|
# (FR-023). The call is unscoped — that is the whole point of the warning.
|
|
164
202
|
def run_unguarded(&tool)
|
|
@@ -171,6 +209,14 @@ module Reeve
|
|
|
171
209
|
|
|
172
210
|
def execute(&tool)
|
|
173
211
|
tool.call
|
|
212
|
+
rescue DeniedError => e
|
|
213
|
+
# A denial the tool raised on itself through `authorize!`. It is an authorization
|
|
214
|
+
# outcome, not a crash, and recording it as `tool_error` would file a policy saying
|
|
215
|
+
# no under the same rule as a NoMethodError. The rule the policy gave is carried
|
|
216
|
+
# through to the ledger unchanged.
|
|
217
|
+
@decision = Decision.deny(rule: e.rule, detail: e.detail)
|
|
218
|
+
@scope_result = ScopeResult.deny(rule: e.rule, detail: e.detail)
|
|
219
|
+
raise
|
|
174
220
|
rescue StandardError => e
|
|
175
221
|
# The tool's own failure propagates untouched, but not before the ensure block
|
|
176
222
|
# records it: the invocations most worth having a trace of are the ones that broke.
|
|
@@ -79,10 +79,14 @@ module Reeve
|
|
|
79
79
|
)
|
|
80
80
|
end
|
|
81
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.
|
|
82
86
|
def missing_columns(missing)
|
|
83
87
|
failed(
|
|
84
88
|
"expected the ledger to implement audit-entry contract version #{@expected}, " \
|
|
85
|
-
"but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:
|
|
89
|
+
"but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:upgrade` " \
|
|
86
90
|
"and migrate",
|
|
87
91
|
version: @expected, missing: missing
|
|
88
92
|
)
|
data/lib/reeve/testing/checks.rb
CHANGED
|
@@ -55,7 +55,7 @@ module Reeve
|
|
|
55
55
|
def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
|
|
56
56
|
return Report.new([check.new(ledger: ledger).call]) if GLOBAL.include?(check)
|
|
57
57
|
|
|
58
|
-
subjects = tools ||
|
|
58
|
+
subjects = tools || Testing.compliance_tools
|
|
59
59
|
Report.new(
|
|
60
60
|
subjects.map do |tool|
|
|
61
61
|
build(check, tool: tool, principals: principals, arguments: arguments,
|
data/lib/reeve/testing.rb
CHANGED
|
@@ -40,12 +40,25 @@ module Reeve
|
|
|
40
40
|
principals
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# The tools the suite walks. Unset means every tool reeve knows about — the classes
|
|
44
|
+
# that included the DSL, guarded or not. That default is what makes an unguarded
|
|
45
|
+
# tool visible rather than merely absent.
|
|
46
|
+
attr_writer :compliance_tools
|
|
47
|
+
|
|
48
|
+
def compliance_tools
|
|
49
|
+
source = @compliance_tools || Reeve.config.compliance_tools
|
|
50
|
+
return Reeve.registry.tool_classes if source.nil?
|
|
51
|
+
|
|
52
|
+
Array(source.respond_to?(:call) ? source.call : source)
|
|
53
|
+
end
|
|
54
|
+
|
|
43
55
|
def compliance_principals?
|
|
44
56
|
!(@compliance_principals || Reeve.config.compliance_principals).nil?
|
|
45
57
|
end
|
|
46
58
|
|
|
47
59
|
def reset!
|
|
48
60
|
@compliance_principals = nil
|
|
61
|
+
@compliance_tools = nil
|
|
49
62
|
end
|
|
50
63
|
|
|
51
64
|
private
|
data/lib/reeve/version.rb
CHANGED
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.4.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-
|
|
11
|
+
date: 2026-09-03 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
|