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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 225a220bbfd6168a26fdf67a816c8a580ca57ba996f97c7d1c9317755252d497
4
- data.tar.gz: a5633fe99dedbc03796c82abf560eca68187fa783e5a9ceef1fb764b9a47d351
3
+ metadata.gz: 67bc82ca855e0385d021e54477cbcfcef697a1134d3c93e4fc073fea2ff3b748
4
+ data.tar.gz: 0c11093e604b6a056a23bec7ebd419620d4dd1780f93ff7e30fc8a08bdbd92d3
5
5
  SHA512:
6
- metadata.gz: c9bccface22cb54d935720a927dd5aff7c61582403f6d0aeada860be4f178d110d4ac94801a84d6a15993c0abad8a6b14ee0c1058311809ddf5575ddb57d1741
7
- data.tar.gz: e43b1e0a1fb8900a45cb998997b45152ab726b21961c2b86ea5dea0cc0adb3e0f9e015cf7aed8cdcd6e39aa28d7e9c827ff85d11653b8aa5fab769844b1cdecb
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 `1`.
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.3.0.** Read the known limitations in
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. As a Rails rake task:
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
@@ -42,7 +42,7 @@ module Reeve
42
42
  registry: registry,
43
43
  authorizer: Authorization::Authorizer.new,
44
44
  scoper: Authorization::Scoper.new,
45
- config: configuration_for(principal)
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
@@ -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,
@@ -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
- tool_base.include(Reeve::Guard) unless tool_base.include?(Reeve::Guard)
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)