reeve 0.3.0 → 0.5.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 +145 -1
- data/README.md +143 -2
- data/lib/reeve/authorization/guard.rb +53 -0
- data/lib/reeve/authorization/registry.rb +43 -0
- data/lib/reeve/authorization.rb +1 -11
- data/lib/reeve/configuration.rb +47 -1
- data/lib/reeve/decision.rb +6 -1
- data/lib/reeve/errors.rb +13 -0
- data/lib/reeve/integrations/fast_mcp/tool_extension.rb +5 -1
- data/lib/reeve/inventory/report.rb +146 -0
- data/lib/reeve/inventory.rb +212 -0
- data/lib/reeve/invocation.rb +49 -1
- data/lib/reeve/testing/assertions.rb +2 -2
- data/lib/reeve/testing/checks/endpoint_coverage.rb +133 -0
- data/lib/reeve/testing/checks.rb +18 -7
- data/lib/reeve/testing/compliance_assertions.rb +3 -1
- data/lib/reeve/testing/compliance_suite.rb +4 -1
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +1 -1
- data/lib/reeve/testing/report.rb +22 -6
- data/lib/reeve/testing/result.rb +28 -8
- data/lib/reeve/testing.rb +29 -0
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +1 -0
- 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: 67bc82ca855e0385d021e54477cbcfcef697a1134d3c93e4fc073fea2ff3b748
|
|
4
|
+
data.tar.gz: 0c11093e604b6a056a23bec7ebd419620d4dd1780f93ff7e30fc8a08bdbd92d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0524a6cd7376dc9cc8b7f2611d4a0a6a8b1fa81b6b5ff59ab662d3e5f5f313b4f47e7ad5b2d52d272da8bab1f1c19042ae937300beb382f57804f2fc72b77d3e
|
|
7
|
+
data.tar.gz: 4ead6c668edcde8b543c1e4ead9817e08015ecbf42adfbc34d44c5fa31426206284efb3ffdd88dad4cd983033be7708e256ac9cc2e5eb4758a6fc92f2ac14a19
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,150 @@ 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.5.0] - 2026-10-01
|
|
8
|
+
|
|
9
|
+
Reeve protects the tools routed through it, and only those. A server that registers its
|
|
10
|
+
tools as names and handler blocks can send some through `Reeve.invoke` and run the rest
|
|
11
|
+
directly — and the direct ones are not unguarded, they are invisible: no denial, no scope,
|
|
12
|
+
no ledger row, and nothing that says so. The compliance suite could not see them either,
|
|
13
|
+
because it walks tools reeve was told about. A green run certified the subset reeve
|
|
14
|
+
happened to know, and read exactly like a green run over the whole endpoint.
|
|
15
|
+
|
|
16
|
+
**The audit-entry contract is unchanged at `2`, so no migration is required.** One new
|
|
17
|
+
`rule` value, `unbound_tool`, can appear — only for a host that dispatches through an
|
|
18
|
+
inventory.
|
|
19
|
+
|
|
20
|
+
Behaviour changes worth reading before upgrading:
|
|
21
|
+
|
|
22
|
+
- Both compliance suites have one more check, `EndpointCoverage`. Until `config.inventory`
|
|
23
|
+
is set it is **skipped** — pending in RSpec, a skip in Minitest — with a message saying
|
|
24
|
+
the endpoint was not verified. Nothing that passed before fails.
|
|
25
|
+
- A `Report` now counts and prints skipped results (`8 checks, 7 passed, 0 failed, 1
|
|
26
|
+
skipped`, followed by `SKIP EndpointCoverage` and its reason). `passed?` is unchanged: a
|
|
27
|
+
skip is not a failure.
|
|
28
|
+
- A multi-line check message is now indented as one block under its `FAIL` heading.
|
|
29
|
+
|
|
30
|
+
**On Rails 7.0, keep `json` below 3.0.** json 3.0 dropped the `quirks_mode:` keyword that
|
|
31
|
+
ActiveSupport 7.0 still passes to `JSON.generate`, so the ledger cannot serialize a row.
|
|
32
|
+
Under the default `audit_failure_mode = :fail`, reeve fails closed: every invocation raises
|
|
33
|
+
`Reeve::AuditWriteError` rather than running unrecorded — nothing goes unaudited, and
|
|
34
|
+
nothing runs. Under `:warn`, every call runs and none is recorded. The defect is ActiveSupport's, not
|
|
35
|
+
reeve's, and no reeve version works around it; pin `gem "json", "< 3"` until the
|
|
36
|
+
application is on a Rails that supports json 3. Reeve's own CI pins it on the Ruby 3.0
|
|
37
|
+
job for the same reason.
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **`Reeve::Inventory`** — the host's registered tool names, which of them route through
|
|
42
|
+
reeve and to which class, and which are exempt and why. Every part takes a value or a
|
|
43
|
+
callable, so a Rails initializer can declare it without autoloading application
|
|
44
|
+
constants. No MCP library involved: a list of names is the whole protocol.
|
|
45
|
+
|
|
46
|
+
- `#report` classifies every registered name as protected and guarded, routed but
|
|
47
|
+
unguarded, bypassing reeve, or exempted — plus anything the inventory names that the
|
|
48
|
+
server does not register, which is usually a rename that left the real name unbound.
|
|
49
|
+
The output names tools, classes and exemption reasons, never an argument, a principal
|
|
50
|
+
or a record, so it is safe in a CI log.
|
|
51
|
+
- `#verify!` raises `Reeve::IncompleteInventoryError`, carrying the report, unless every
|
|
52
|
+
registered name is guarded or exempt. For boot, a deploy step, or CI.
|
|
53
|
+
- `#dispatch(name, ...) { handler }` is the dispatch boundary. A routed name runs through
|
|
54
|
+
`Reeve.invoke`, with the handler block as the body; an exempt name runs as written; any
|
|
55
|
+
other name is treated as a tool with no guard — refused with the new `unbound_tool`
|
|
56
|
+
rule under `unguarded_tools = :deny`, or run unscoped with `guard: "none"` under
|
|
57
|
+
`:allow_with_warning` — and recorded either way. A host that dispatches through it
|
|
58
|
+
cannot have a tool that bypasses reeve.
|
|
59
|
+
- An exemption needs a non-empty reason, and is listed in every report, complete or not.
|
|
60
|
+
|
|
61
|
+
- **`config.inventory`**, and **`Checks::EndpointCoverage`**, which compares a compliance
|
|
62
|
+
run against it. Passes only when the inventory is complete and the run covered all of
|
|
63
|
+
it; fails when the run claimed the whole endpoint and a registered tool bypasses reeve
|
|
64
|
+
or reaches it unguarded; skipped when no inventory is declared, or when
|
|
65
|
+
`compliance_tools` or `tools:` narrowed the run, naming what was left out. The
|
|
66
|
+
incremental path stays open, and a subset can no longer pass for the endpoint.
|
|
67
|
+
|
|
68
|
+
- **Skipped results.** `Testing::Result.skipped`, `Result#skipped?`, `Report#skips` and
|
|
69
|
+
`Report#skipped?`. Only `EndpointCoverage` produces one.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- The compliance suites' default population includes every class the inventory routes to.
|
|
74
|
+
A registry of blocks has no reason to `include Reeve::Guard` anywhere, so a routed class
|
|
75
|
+
with no guard would otherwise never reach `GuardDeclared`.
|
|
76
|
+
|
|
77
|
+
## [0.4.0] - 2026-09-03
|
|
78
|
+
|
|
79
|
+
Everything here came out of a second application integrating the gem — one that had put a
|
|
80
|
+
handful of its MCP tools behind reeve and was about to guard its write tools next. Two
|
|
81
|
+
defects surfaced from reviewing that work, and both were failures of the same kind: the
|
|
82
|
+
gem reporting a guarantee it had not actually established.
|
|
83
|
+
|
|
84
|
+
**The audit-entry contract is unchanged at `2`, so no migration is required.**
|
|
85
|
+
|
|
86
|
+
Behaviour changes worth reading before upgrading:
|
|
87
|
+
|
|
88
|
+
- A denied invocation now rolls the tool's work back. A guarded tool that writes and is
|
|
89
|
+
then refused used to leave the write behind.
|
|
90
|
+
- A guarded tool that raises after writing now has that write rolled back too.
|
|
91
|
+
- The compliance suite now reports tools that never declared a guard, so a suite that
|
|
92
|
+
passed before may legitimately fail now. That is the fix, not a regression — set
|
|
93
|
+
`config.compliance_tools` to what you have certified so far.
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
|
|
97
|
+
- **The compliance suite can see a tool that has no guard.** It walked the guard registry,
|
|
98
|
+
which holds one entry per `guard_with` — so every subject it checked had a guard by
|
|
99
|
+
construction, and `GuardDeclared`, the check whose whole job is naming tools that lack
|
|
100
|
+
one, could not fail. An application with three guarded tools and one that was forgotten
|
|
101
|
+
reported `4 checks, 4 passed`. The gem's own quickstart demonstrated this: it ships a
|
|
102
|
+
deliberately unguarded tool and then asserted that the compliance run passed.
|
|
103
|
+
|
|
104
|
+
Tools are now recorded when they include `Reeve::Guard`, declared or not — through
|
|
105
|
+
inheritance too, so an adapter that includes the DSL into a base class once (fast-mcp)
|
|
106
|
+
no longer hides every tool built on it. `Reeve.registry.unguarded_tool_classes` is the
|
|
107
|
+
worklist. A base class that carries the DSL for its subclasses declares itself with
|
|
108
|
+
`reeve_abstract!` rather than being guessed at: inferring "anything with a subclass is a
|
|
109
|
+
base" would silently drop a real tool from the run the moment someone subclassed it.
|
|
110
|
+
|
|
111
|
+
**Reeve can only see tools that reached it.** An MCP server dispatching tools reeve was
|
|
112
|
+
never told about is still invisible to it — pass `tools:` to certify the real inventory.
|
|
113
|
+
|
|
114
|
+
- **A denied invocation no longer leaves the host's data changed.** The envelope
|
|
115
|
+
authorizes before the tool runs, with the model class as the subject because no record
|
|
116
|
+
exists yet, and scopes the return value afterwards. A write tool lives between those two
|
|
117
|
+
points: it fetched a record, changed it, and only then was refused. The caller saw
|
|
118
|
+
`out_of_scope_record`, the ledger recorded a denial — and the row in the database had
|
|
119
|
+
already been rewritten by a principal who could not see it. A `deny` that follows a
|
|
120
|
+
committed write is not a partial guarantee, it is a false statement in the artifact the
|
|
121
|
+
ledger exists to be.
|
|
122
|
+
|
|
123
|
+
The tool body now runs in a transaction and a scope denial rolls it back. `requires_new`
|
|
124
|
+
makes it a savepoint, so a transaction the host opened around the invocation is left
|
|
125
|
+
alone — the rollback reaches the tool's work and stops there. Measured overhead is at or
|
|
126
|
+
below noise on a read-only call.
|
|
127
|
+
|
|
128
|
+
Two consequences worth knowing:
|
|
129
|
+
|
|
130
|
+
- A tool that **writes and then raises** now has that write rolled back too. It was
|
|
131
|
+
already recorded as `tool_error`; the data now matches what the ledger said.
|
|
132
|
+
- A host with no ActiveRecord, or with the library loaded but no connection, keeps the
|
|
133
|
+
previous behaviour — there is nothing to roll back, and neither should be made to fail.
|
|
134
|
+
|
|
135
|
+
### Added
|
|
136
|
+
|
|
137
|
+
- **`config.compliance_tools`** — which tools the suite certifies, as an array or a
|
|
138
|
+
callable, defaulting to every tool reeve knows about. A retrofit is the normal state of
|
|
139
|
+
an application adopting this, and now that unguarded tools are reported, one that has
|
|
140
|
+
guarded three of thirty has a red build it cannot honestly turn green. A build expected
|
|
141
|
+
to be red is a build nobody reads. Narrowing the list states what has been certified —
|
|
142
|
+
a claim that can go green and stay green as the list grows.
|
|
143
|
+
|
|
144
|
+
- **`authorize!(record)`** inside a tool body — asks the declared policy about one record
|
|
145
|
+
and returns it, raising `DeniedError` if the policy says no. For the part of a tool a
|
|
146
|
+
rollback cannot reach: a sent email, a webhook, a written file. A denial raised this way
|
|
147
|
+
carries the policy's own rule into the ledger rather than being filed as `tool_error`,
|
|
148
|
+
and names no record, so a refusal and a record that does not exist still read the same
|
|
149
|
+
(FR-006).
|
|
150
|
+
|
|
7
151
|
## [0.3.0] - 2026-08-17
|
|
8
152
|
|
|
9
153
|
Three of the limitations 0.1.0 shipped knowingly are now closed, and the audit-entry
|
|
@@ -268,4 +412,4 @@ is for:
|
|
|
268
412
|
- Adding a nullable ledger column is minor and leaves the audit-entry contract version
|
|
269
413
|
alone. Removing or renaming a column, or changing what a value means, is major and bumps
|
|
270
414
|
it.
|
|
271
|
-
- The audit-entry contract version is `
|
|
415
|
+
- The audit-entry contract version is `2`.
|
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.5.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
|
|
@@ -191,7 +227,27 @@ abort report.to_s unless report.passed?
|
|
|
191
227
|
```
|
|
192
228
|
|
|
193
229
|
`alice` and `bob` are two fixture principals with disjoint records — that disjointness is
|
|
194
|
-
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 — declare the server's
|
|
246
|
+
real inventory ([below](#every-tool-the-endpoint-dispatches)), or the run certifies the
|
|
247
|
+
subset Reeve happens to know. Until you do, the suite says so: `EndpointCoverage` is
|
|
248
|
+
reported as skipped, never as passed.
|
|
249
|
+
|
|
250
|
+
As a Rails rake task:
|
|
195
251
|
|
|
196
252
|
```ruby
|
|
197
253
|
# lib/tasks/reeve.rake
|
|
@@ -322,6 +378,91 @@ Reeve::Checks.run_all(
|
|
|
322
378
|
That is the whole integration: one call site, your auth untouched, and the same three
|
|
323
379
|
guarantees the fast-mcp adapter gets.
|
|
324
380
|
|
|
381
|
+
## Every tool the endpoint dispatches
|
|
382
|
+
|
|
383
|
+
Reeve protects the tools routed through it, and only those. A server that registers tools
|
|
384
|
+
as names and handler blocks can send some through `Reeve.invoke` and run the rest directly
|
|
385
|
+
— and the direct ones are not unguarded, they are invisible: no denial, no scope, no ledger
|
|
386
|
+
row, and nothing that says so. Deny-by-default cannot deny a call it never sees.
|
|
387
|
+
|
|
388
|
+
`Reeve::Inventory` makes the server's own list of names the thing that gets checked:
|
|
389
|
+
|
|
390
|
+
```ruby
|
|
391
|
+
# config/initializers/reeve.rb
|
|
392
|
+
McpInventory = Reeve::Inventory.new(
|
|
393
|
+
registered: -> { McpServer.tool_names },
|
|
394
|
+
routed: -> { { "search_invoices" => InvoiceSearchTool, "get_invoice" => InvoiceShowTool } },
|
|
395
|
+
exemptions: { "healthcheck" => { reason: "returns no application data" } }
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
Reeve.configure { |config| config.inventory = McpInventory }
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Each part takes a value or a callable. Callables are read when asked, so the initializer
|
|
402
|
+
neither autoloads application constants nor runs before the server has registered its
|
|
403
|
+
tools.
|
|
404
|
+
|
|
405
|
+
```text
|
|
406
|
+
reeve inventory: 4 registered
|
|
407
|
+
2 protected and guarded
|
|
408
|
+
0 routed through reeve but unguarded
|
|
409
|
+
1 bypasses reeve
|
|
410
|
+
1 exempted
|
|
411
|
+
coverage: incomplete
|
|
412
|
+
|
|
413
|
+
BYPASS export_invoices
|
|
414
|
+
registered with the server but neither routed through reeve nor exempted — route it to
|
|
415
|
+
a guarded tool, or exempt it with a reason
|
|
416
|
+
|
|
417
|
+
EXEMPT healthcheck
|
|
418
|
+
returns no application data
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
The report names tools, classes and exemption reasons — never an argument, a principal or
|
|
422
|
+
a record — so it is safe to print in CI. An exemption without a reason is refused, and one
|
|
423
|
+
with a reason is listed every time, complete or not.
|
|
424
|
+
|
|
425
|
+
**Fail boot or CI on a gap:**
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
McpInventory.verify! # raises Reeve::IncompleteInventoryError, carrying the report
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
**Dispatch through it, and a bypass stops being possible:**
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
def call_tool
|
|
435
|
+
name = params.dig(:params, :name)
|
|
436
|
+
arguments = params.dig(:params, :arguments).to_h.symbolize_keys
|
|
437
|
+
|
|
438
|
+
result = McpInventory.dispatch(name, arguments: arguments,
|
|
439
|
+
agent: { id: request.headers["X-MCP-Client"] }) do
|
|
440
|
+
McpServer.handlers.fetch(name).call(arguments)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
render json: { jsonrpc: "2.0", id: params[:id], result: serialize(result) }
|
|
444
|
+
rescue Reeve::DeniedError => e
|
|
445
|
+
render json: { jsonrpc: "2.0", id: params[:id],
|
|
446
|
+
error: { code: -32_003, message: e.message } }
|
|
447
|
+
end
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
- A **routed** name runs through `Reeve.invoke` under its class's guard, with the handler
|
|
451
|
+
block as the body. A registry of blocks needs one guarded class per tool to name its
|
|
452
|
+
policy, and nothing else — the block still does the work, and what it returns is scoped.
|
|
453
|
+
Handlers dispatched this way should not call `Reeve.invoke` themselves.
|
|
454
|
+
- An **exempt** name runs the block as written.
|
|
455
|
+
- **Any other name** is a tool with no guard, and `unguarded_tools` decides: refused with
|
|
456
|
+
`unbound_tool` under `:deny`, the default; run unscoped and recorded with `guard: "none"`
|
|
457
|
+
under `:allow_with_warning`. Recorded either way, so a name the inventory missed lands on
|
|
458
|
+
the same ledger worklist as an unguarded class.
|
|
459
|
+
|
|
460
|
+
**In the compliance suite,** `EndpointCoverage` compares the run against the inventory. It
|
|
461
|
+
passes only when every registered tool is guarded or exempt and the run covered all of
|
|
462
|
+
them. It fails when the run claimed the whole endpoint and something bypasses Reeve. A run
|
|
463
|
+
narrowed by `compliance_tools` is reported as a subset — skipped, naming what it left out —
|
|
464
|
+
so a retrofit stays green without passing for complete coverage.
|
|
465
|
+
|
|
325
466
|
Policies are plain objects unless you want Pundit (`authorize` and `scope`, two methods).
|
|
326
467
|
The ledger is an ActiveRecord table unless you supply your own recorder. Records are
|
|
327
468
|
ActiveRecord unless they are not — a plain object with an `id` works.
|
|
@@ -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/authorization.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Reeve
|
|
|
42
42
|
registry: registry,
|
|
43
43
|
authorizer: Authorization::Authorizer.new,
|
|
44
44
|
scoper: Authorization::Scoper.new,
|
|
45
|
-
config:
|
|
45
|
+
config: config.with_principal(principal)
|
|
46
46
|
) { body ? body.call : run(tool, arguments) }
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -62,15 +62,5 @@ module Reeve
|
|
|
62
62
|
|
|
63
63
|
Authorization::Declaration.new(tool_class: klass, policy: nil, action: :index).tool_name
|
|
64
64
|
end
|
|
65
|
-
|
|
66
|
-
# An explicitly supplied principal is just a resolver that returns it. Routing it
|
|
67
|
-
# through the same resolution step rather than around it keeps one answer to "where
|
|
68
|
-
# did this principal come from" — the envelope still resolves, records and denies
|
|
69
|
-
# identically, and a nil passed in still denies with `no_principal`.
|
|
70
|
-
def configuration_for(principal)
|
|
71
|
-
return config if principal == :unset
|
|
72
|
-
|
|
73
|
-
config.dup.tap { |scoped| scoped.principal_resolver = ->(_context) { principal } }
|
|
74
|
-
end
|
|
75
65
|
end
|
|
76
66
|
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 inventory
|
|
28
28
|
].freeze
|
|
29
29
|
|
|
30
30
|
attr_reader(*SETTINGS)
|
|
@@ -40,11 +40,57 @@ module Reeve
|
|
|
40
40
|
@audit_recorder = nil
|
|
41
41
|
@logger = nil
|
|
42
42
|
@compliance_principals = nil
|
|
43
|
+
@compliance_tools = nil
|
|
44
|
+
@inventory = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The host's whole tool inventory (Reeve::Inventory). Optional. Setting it is what lets
|
|
48
|
+
# the compliance suite say whether it covered the endpoint or only the tools reeve
|
|
49
|
+
# happened to be told about.
|
|
50
|
+
def inventory=(inventory)
|
|
51
|
+
unless inventory.nil? || inventory.is_a?(Inventory)
|
|
52
|
+
raise ArgumentError, "inventory must be a Reeve::Inventory, got #{inventory.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@inventory = inventory
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# This configuration with the principal fixed for one invocation.
|
|
59
|
+
#
|
|
60
|
+
# An explicitly supplied principal is just a resolver that returns it. Routing it
|
|
61
|
+
# through the same resolution step rather than around it keeps one answer to "where
|
|
62
|
+
# did this principal come from" — the envelope still resolves, records and denies
|
|
63
|
+
# identically, and a nil passed in still denies with `no_principal`.
|
|
64
|
+
def with_principal(principal)
|
|
65
|
+
return self if principal == :unset
|
|
66
|
+
|
|
67
|
+
dup.tap { |scoped| scoped.principal_resolver = ->(_context) { principal } }
|
|
43
68
|
end
|
|
44
69
|
|
|
45
70
|
# Two fixture principals with disjoint records — the only host setup the compliance
|
|
46
71
|
# suite needs (contracts/testing-kit.md). A callable rather than a value, because in a
|
|
47
72
|
# Rails test suite the fixtures do not exist yet when the helper is loaded.
|
|
73
|
+
# Which tools the compliance suite certifies. Defaults to every tool reeve knows
|
|
74
|
+
# about, which is the answer a finished application wants.
|
|
75
|
+
#
|
|
76
|
+
# A host part-way through a retrofit needs the other answer. Once the suite reports
|
|
77
|
+
# unguarded tools — which is the whole point of it — an application with thirty tools
|
|
78
|
+
# and three guarded ones has a red build it cannot honestly turn green, and a red
|
|
79
|
+
# build that is expected to be red stops being read. Narrowing the list says "these
|
|
80
|
+
# are the ones I have certified", which is a claim that can go green and then stay
|
|
81
|
+
# green as the list grows.
|
|
82
|
+
#
|
|
83
|
+
# config.compliance_tools = -> { [InvoiceSearchTool, InvoiceShowTool] }
|
|
84
|
+
def compliance_tools=(tools)
|
|
85
|
+
unless tools.nil? || tools.respond_to?(:call) || tools.is_a?(Array)
|
|
86
|
+
raise ArgumentError,
|
|
87
|
+
"compliance_tools must be an Array or a callable returning one, " \
|
|
88
|
+
"got #{tools.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@compliance_tools = tools
|
|
92
|
+
end
|
|
93
|
+
|
|
48
94
|
def compliance_principals=(principals)
|
|
49
95
|
unless principals.nil? || principals.respond_to?(:call) || principals.is_a?(Array)
|
|
50
96
|
raise ArgumentError,
|
data/lib/reeve/decision.rb
CHANGED
|
@@ -18,6 +18,10 @@ module Reeve
|
|
|
18
18
|
# ledger records it as a deny — the trace of a call that blew up is the one most
|
|
19
19
|
# worth having (R5).
|
|
20
20
|
TOOL_ERROR = "tool_error"
|
|
21
|
+
# A name dispatched through an Inventory that neither routes it through reeve nor
|
|
22
|
+
# exempts it. Not `no_guard_declared`: there may be no class to declare one on, and
|
|
23
|
+
# the fix is in the inventory.
|
|
24
|
+
UNBOUND_TOOL = "unbound_tool"
|
|
21
25
|
|
|
22
26
|
# The one reserved *allow* rule: a tool with no guard, permitted because the host
|
|
23
27
|
# opted into :allow_with_warning. Kept out of RESERVED_RULES, which names deny paths.
|
|
@@ -33,7 +37,8 @@ module Reeve
|
|
|
33
37
|
UNSCOPED_DERIVED_RESULT,
|
|
34
38
|
OUT_OF_SCOPE_RECORD,
|
|
35
39
|
AUDIT_WRITE_FAILED,
|
|
36
|
-
TOOL_ERROR
|
|
40
|
+
TOOL_ERROR,
|
|
41
|
+
UNBOUND_TOOL
|
|
37
42
|
].freeze
|
|
38
43
|
|
|
39
44
|
OUTCOMES = %i[allow deny].freeze
|
data/lib/reeve/errors.rb
CHANGED
|
@@ -57,6 +57,19 @@ module Reeve
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Raised by Inventory#verify! when a registered tool is not protected: routed through
|
|
61
|
+
# reeve with no guard, not routed at all, or declared in the inventory but not registered
|
|
62
|
+
# with the server. The message is the whole report — tool names, classes and exemption
|
|
63
|
+
# reasons, never an argument, a principal or a record.
|
|
64
|
+
class IncompleteInventoryError < Error
|
|
65
|
+
attr_reader :report
|
|
66
|
+
|
|
67
|
+
def initialize(report)
|
|
68
|
+
@report = report
|
|
69
|
+
super(report.to_s)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
60
73
|
# Raised when the ledger write fails and audit_failure_mode is :fail (FR-012).
|
|
61
74
|
#
|
|
62
75
|
# It carries a rule like any other denial, because the failure is not recorded anywhere
|
|
@@ -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)
|