reeve 0.0.1 → 0.2.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 +191 -0
- data/README.md +280 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +77 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +54 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +181 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +97 -0
- data/lib/reeve/authorization/adapter.rb +71 -0
- data/lib/reeve/authorization/adapters/plain.rb +65 -0
- data/lib/reeve/authorization/adapters/pundit.rb +97 -0
- data/lib/reeve/authorization/authorizer.rb +23 -0
- data/lib/reeve/authorization/current.rb +66 -0
- data/lib/reeve/authorization/declaration.rb +73 -0
- data/lib/reeve/authorization/guard.rb +97 -0
- data/lib/reeve/authorization/registry.rb +118 -0
- data/lib/reeve/authorization/scoper.rb +399 -0
- data/lib/reeve/authorization.rb +76 -0
- data/lib/reeve/configuration.rb +158 -0
- data/lib/reeve/context.rb +111 -0
- data/lib/reeve/decision.rb +111 -0
- data/lib/reeve/errors.rb +89 -0
- data/lib/reeve/fast_mcp.rb +24 -0
- data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
- data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
- data/lib/reeve/invocation.rb +260 -0
- data/lib/reeve/minitest.rb +19 -0
- data/lib/reeve/rspec.rb +18 -0
- data/lib/reeve/scope_result.rb +104 -0
- data/lib/reeve/testing/assertions.rb +76 -0
- data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
- data/lib/reeve/testing/checks/base.rb +155 -0
- data/lib/reeve/testing/checks/contract_version.rb +97 -0
- data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
- data/lib/reeve/testing/checks/guard_declared.rb +33 -0
- data/lib/reeve/testing/checks/principal_required.rb +60 -0
- data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
- data/lib/reeve/testing/checks/rule_present.rb +55 -0
- data/lib/reeve/testing/checks.rb +95 -0
- data/lib/reeve/testing/compliance_assertions.rb +34 -0
- data/lib/reeve/testing/compliance_suite.rb +23 -0
- data/lib/reeve/testing/ledger.rb +82 -0
- data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
- data/lib/reeve/testing/matchers/base.rb +84 -0
- data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
- data/lib/reeve/testing/matchers.rb +33 -0
- data/lib/reeve/testing/report.rb +58 -0
- data/lib/reeve/testing/result.rb +49 -0
- data/lib/reeve/testing.rb +60 -0
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +10 -3
- metadata +53 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaf976c70abfbb4d56236acface7e1ef95a4e8c177452e4ef918ca22f45933d0
|
|
4
|
+
data.tar.gz: 9076bbad0582db6c4cda3c38dc89f97840dc3a7e2dbb99ca98a16c5b642ece57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7957d35a7e9df67acaa1c6babcd18b1d2340ffd63595fc88a11e16bd5c81cfa78ddb6b6cf17742f74b1f300d5e35058442d124fe77d6cc7051005e3ed4d5b837
|
|
7
|
+
data.tar.gz: 543bb727d1abf718053d21a72fe3d3eb62b9e82ebe5a3658046b7d80eb8c897c91e76da7d6c55842f1ee0545b76af94a2d1127ffa2fc814a88bbadab3c827a01
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,196 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
All notable changes are recorded here. This project follows [Semantic
|
|
4
|
+
Versioning](https://semver.org), with one rule specific to what it does — see
|
|
5
|
+
[Versioning policy](#versioning-policy).
|
|
6
|
+
|
|
7
|
+
## [0.2.0] - 2026-08-12
|
|
8
|
+
|
|
9
|
+
Everything here came out of running 0.1.0 against a real Rails 8.1 application rather than
|
|
10
|
+
against its own test suite. The Pundit fix is the one that blocks adoption.
|
|
11
|
+
|
|
12
|
+
**Audit-entry contract version: `1` → `2`.** Two changes, one bump.
|
|
13
|
+
|
|
14
|
+
`metadata` was written NULL on every row through version 1 and now carries what the caller
|
|
15
|
+
passed. Anything mapping version 1 rows could reasonably have read that column as always
|
|
16
|
+
empty, and this project's own versioning rule calls a change in what a value means MAJOR —
|
|
17
|
+
hence 0.2.0 rather than a patch.
|
|
18
|
+
|
|
19
|
+
New non-null `contract_version` column, stamped on every row. Version 1 rows could not
|
|
20
|
+
name their own shape, which is exactly what made the `metadata` change ambiguous to a
|
|
21
|
+
reader: on a version 1 row a NULL `metadata` means "never recorded", on a version 2 row it
|
|
22
|
+
means "the caller passed none". Stamping the row settles that at read time instead of
|
|
23
|
+
requiring a reader to know when the writing gem was deployed, and it makes every future
|
|
24
|
+
bump legible on the row. It also gives `Checks::ContractVersion` real teeth — a stale
|
|
25
|
+
table now fails on the missing column even when a bump was purely semantic.
|
|
26
|
+
|
|
27
|
+
**Upgrading from 0.1.0:**
|
|
28
|
+
|
|
29
|
+
- Re-run `bin/rails generate reeve:install` and migrate, or add the column by hand:
|
|
30
|
+
`add_column :reeve_audit_entries, :contract_version, :integer, null: false, default: 1`
|
|
31
|
+
— `default: 1` is correct for existing rows, since that is the contract they were
|
|
32
|
+
written under. Drop the default afterwards if you prefer; the recorder always sets it.
|
|
33
|
+
- A host pinning `Reeve::Checks::ContractVersion.new(expected: 1)` moves it to `2`.
|
|
34
|
+
- **A custom `audit_recorder` that writes to `Reeve::Audit::Entry` must now set
|
|
35
|
+
`contract_version`.** The model rejects a row without it.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- README: **Wrapping your own JSON-RPC server** — the integration path for a Rails app
|
|
40
|
+
that already exposes `/mcp` from its own controller and tool registry. Covers mapping
|
|
41
|
+
JSON-RPC tool names to `Reeve.invoke`, passing request headers as `metadata`, resolving
|
|
42
|
+
the principal from `Current.user` or a bearer token while keeping the host's existing
|
|
43
|
+
authentication, adopting one tool at a time through `:allow_with_warning`, and pointing
|
|
44
|
+
the compliance checks at the host's own dispatcher. The recipe is executed by
|
|
45
|
+
`spec/reeve/integrations/custom_dispatcher_spec.rb` rather than only written down.
|
|
46
|
+
([#5](https://github.com/vicmaster/reeve/issues/5))
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- `metadata` passed to `Reeve.invoke` is written to the ledger. The column, the recorder
|
|
51
|
+
mapping and the contract check all existed, but `Context#to_h` did not carry the value,
|
|
52
|
+
so `metadata` was NULL on every call ever recorded — including the header hash the
|
|
53
|
+
fast-mcp adapter collects. It is redacted on the way in, by the same redactor and the
|
|
54
|
+
same declared names as the arguments, so `Authorization` does not land in the ledger.
|
|
55
|
+
This is the change behind the contract-version bump above.
|
|
56
|
+
- `contracts/audit-entry.md` no longer claims the contract version is "recorded in the
|
|
57
|
+
initializer". It never was — which is what prompted the `contract_version` column above,
|
|
58
|
+
so that from contract 2 the claim is simply true.
|
|
59
|
+
- The envelope-overhead spec compares the fastest call on each side rather than the mean
|
|
60
|
+
of two separately-timed windows. A single GC pause or scheduler preemption landing in
|
|
61
|
+
one window and not the other lands whole in the difference — 300ms over fifty runs is
|
|
62
|
+
6ms per call against a 5ms budget, from a busy machine rather than a regression. It
|
|
63
|
+
loses no sensitivity: a regression is paid on every call, so it slows the fastest trial
|
|
64
|
+
too. Verified both ways — an injected one-off stall moves the measurement from 6.65ms to
|
|
65
|
+
0.23ms, and 6ms added to every call still fails at 7.13ms.
|
|
66
|
+
- The spec suite declares UTF-8 (`spec/spec_helper.rb`). The README-spec encoding bug was
|
|
67
|
+
one instance of five: eleven examples across `readme_spec`, `contract_version_spec`,
|
|
68
|
+
`migration_spec`, `install_generator_spec`, `framework_neutrality_spec`,
|
|
69
|
+
`isolation_spec` and `verification_spec` died on `invalid byte sequence` whenever a file
|
|
70
|
+
was run on its own, and passed in a full run only because some other file happened to
|
|
71
|
+
set the encoding first. Every spec file now passes in isolation, and the suite passes
|
|
72
|
+
under `LC_ALL=C`.
|
|
73
|
+
- A Pundit policy that inherits its `Scope` from a base policy (`class LeadPolicy <
|
|
74
|
+
LeadBasePolicy`) is now recognised. Policy detection looked only at the policy's own
|
|
75
|
+
namespace, so the ordinary Pundit inheritance pattern was refused at declaration time
|
|
76
|
+
with `ConfigurationError`. Detection now walks the policy's ancestry, stopping before
|
|
77
|
+
`Object` so a top-level `Scope` constant still cannot make an unrelated class look
|
|
78
|
+
Pundit-shaped. ([#3](https://github.com/vicmaster/reeve/issues/3))
|
|
79
|
+
- `lib/reeve.rb` no longer describes the kernel as unreleased work in progress. The note
|
|
80
|
+
belonged to the 0.0.1 name-claim release and contradicted 0.1.0 for anyone reading the
|
|
81
|
+
installed gem. ([#4](https://github.com/vicmaster/reeve/issues/4))
|
|
82
|
+
- The README compliance specs read the file as UTF-8. Under a POSIX locale they read it
|
|
83
|
+
as US-ASCII and every example failed on the first em dash, so the guarantees they
|
|
84
|
+
exist to enforce were never actually checked.
|
|
85
|
+
|
|
86
|
+
## [0.1.0] - 2026-08-11
|
|
87
|
+
|
|
88
|
+
First working release. The published 0.0.1 was a placeholder holding the gem name.
|
|
89
|
+
|
|
90
|
+
### Added
|
|
91
|
+
|
|
92
|
+
**Authorization**
|
|
93
|
+
|
|
94
|
+
- `guard_with SomePolicy` declares the policy governing a tool. A tool without one is
|
|
95
|
+
denied (`no_guard_declared`) unless the host opts into `unguarded_tools =
|
|
96
|
+
:allow_with_warning`, which runs the tool unscoped and records `guard: "none"`.
|
|
97
|
+
- `redact :argument_name` keeps an argument's value out of the ledger while keeping its
|
|
98
|
+
name.
|
|
99
|
+
- `scoped(Model)` returns the policy-scoped relation inside a tool body. It is how a
|
|
100
|
+
guarded tool returns anything that is not a record: a count or a summary computed from
|
|
101
|
+
`scoped(...)` is safe because the tool never held unscoped data. A derived value
|
|
102
|
+
returned without it is denied (`unscoped_derived_result`).
|
|
103
|
+
- Policy adapters for plain objects and for Pundit, behind one two-method protocol
|
|
104
|
+
(`authorize`, `scope`). `:auto` picks one and reports which through
|
|
105
|
+
`Reeve.config.resolved_policy_adapter`. Neither Pundit nor any authorization library is
|
|
106
|
+
a runtime dependency.
|
|
107
|
+
- Per-record scoping for relations, arrays, single records, mixed types and derived
|
|
108
|
+
values. A single record outside the principal's scope is refused without naming it.
|
|
109
|
+
|
|
110
|
+
**Audit**
|
|
111
|
+
|
|
112
|
+
- One append-only `reeve_audit_entries` row per invocation, allowed or denied, recording
|
|
113
|
+
the agent, the principal, the tool, post-redaction arguments, returned identifiers, the
|
|
114
|
+
outcome, the rule that decided, and why it decided (`detail`).
|
|
115
|
+
- The write happens in its own transaction, so a tool that raises and rolls back its own
|
|
116
|
+
work still leaves a trace — the invocations most worth recording are the ones that
|
|
117
|
+
failed.
|
|
118
|
+
- A failed ledger write fails the invocation. `audit_failure_mode = :warn` degrades that
|
|
119
|
+
deliberately and must be opted into.
|
|
120
|
+
- `Reeve::Audit::Query` queries the ledger by principal, agent, tool, outcome and time
|
|
121
|
+
range.
|
|
122
|
+
- Entries are read-only after insert and refuse to be destroyed; the generated migration
|
|
123
|
+
documents the `GRANT INSERT, SELECT` that enforces what a library cannot.
|
|
124
|
+
|
|
125
|
+
**Testing kit**
|
|
126
|
+
|
|
127
|
+
- Seven framework-neutral checks (`CrossPrincipalLeak`, `AuditCoverage`, `GuardDeclared`,
|
|
128
|
+
`RulePresent`, `RedactionHolds`, `PrincipalRequired`, `ContractVersion`) plus
|
|
129
|
+
`Checks.run_all`, all plain Ruby that loads no test framework.
|
|
130
|
+
- RSpec front-end (`require "reeve/rspec"`): `deny_access_for`, `audit_every_call`,
|
|
131
|
+
`pass_reeve_check`, and the `"a reeve-compliant server"` shared example group.
|
|
132
|
+
- Minitest front-end (`require "reeve/minitest"`): the same guarantees from a stock
|
|
133
|
+
`rails new` application, with no RSpec installed.
|
|
134
|
+
- Failure messages are built by the checks, so all three front-ends emit identical text.
|
|
135
|
+
|
|
136
|
+
**Adoption**
|
|
137
|
+
|
|
138
|
+
- `rails generate reeve:install` writes the initializer and the ledger migration.
|
|
139
|
+
`principal_resolver` ships as a TODO, because it is the one thing only the host can
|
|
140
|
+
answer, and reeve denies every call until it is filled in.
|
|
141
|
+
- fast-mcp adapter (`require "reeve/fast_mcp"`): the DSL on every tool and the envelope
|
|
142
|
+
around every call, with the principal resolved from the request's headers. Needs Ruby
|
|
143
|
+
3.1+, because fast-mcp does.
|
|
144
|
+
- `Reeve.invoke` gives the same guarantees with no Rails, no ActiveRecord and no MCP
|
|
145
|
+
server library.
|
|
146
|
+
|
|
147
|
+
### Known limitations
|
|
148
|
+
|
|
149
|
+
Stated here because finding them yourself later is worse than reading them now.
|
|
150
|
+
|
|
151
|
+
- **A transaction the host wraps around an invocation takes the ledger row with it.** The
|
|
152
|
+
recorder writes in a savepoint, which protects the trace from a transaction the *tool*
|
|
153
|
+
opens and rolls back, but not from one already open around the whole call — a controller
|
|
154
|
+
that wraps each request, or a test suite using transactional fixtures. Reeve detects this
|
|
155
|
+
and warns, naming the invocation. Real isolation needs a second connection and is not
|
|
156
|
+
portable: on SQLite the enclosing transaction holds the write lock and a second
|
|
157
|
+
connection times out. Hosts needing durability there supply their own `audit_recorder`.
|
|
158
|
+
- **An unscoped fetch by id still discloses existence.** `Invoice.find_by(id:)` denies for
|
|
159
|
+
a record that exists but belongs to someone else, and returns `nil` for one that does not
|
|
160
|
+
exist. Fetching through `scoped(...)` makes both answers `nil`.
|
|
161
|
+
- **A scope-less type must be tied to its policy.** A plain object with an `id` is a
|
|
162
|
+
record, but a type with no relation cannot be checked against a policy scope — only
|
|
163
|
+
against `authorize`, which is often permissive. Such a type is trusted when a policy is
|
|
164
|
+
named for it or a `scoped(...)` call establishes provenance, and denied otherwise.
|
|
165
|
+
- **A call that cannot be recorded fails, even when it was already failing.** An
|
|
166
|
+
unrecordable denial raises `AuditWriteError` carrying the denial as `#during`, rather
|
|
167
|
+
than the denial itself. With no recorder configured at all, that is the first thing you
|
|
168
|
+
will see, and it names the missing ledger.
|
|
169
|
+
- **`AuditCoverage` proves one invocation is recorded**, not every invocation.
|
|
170
|
+
- **The fast-mcp adapter needs Ruby 3.1+**, because fast-mcp depends on dry-schema. The
|
|
171
|
+
core supports 3.0.
|
|
172
|
+
- CI runs SQLite only; MySQL and PostgreSQL rest on ActiveRecord's portability.
|
|
173
|
+
|
|
174
|
+
### Notes
|
|
175
|
+
|
|
176
|
+
- Ruby 3.0+, Rails 7.0+, zero runtime dependencies.
|
|
177
|
+
- Publishing requires MFA on the owner's account (`rubygems_mfa_required`).
|
|
178
|
+
|
|
3
179
|
## [0.0.1] - 2026-08-11
|
|
4
180
|
|
|
5
181
|
- Placeholder release reserving the gem name. No functionality.
|
|
182
|
+
|
|
183
|
+
## Versioning policy
|
|
184
|
+
|
|
185
|
+
Semantic versioning, with one project-specific rule that follows from what this library
|
|
186
|
+
is for:
|
|
187
|
+
|
|
188
|
+
- **A change that narrows what a tool returns is a security fix and may ship in a patch.**
|
|
189
|
+
If reeve was letting a record through that a policy did not permit, closing that is not
|
|
190
|
+
a breaking change, however much it changes behaviour for someone relying on it.
|
|
191
|
+
- **A change that widens access is breaking by definition** — major version, plus a
|
|
192
|
+
migration note saying exactly what became visible that was not visible before.
|
|
193
|
+
- Adding a nullable ledger column is minor and leaves the audit-entry contract version
|
|
194
|
+
alone. Removing or renaming a column, or changing what a value means, is major and bumps
|
|
195
|
+
it.
|
|
196
|
+
- The audit-entry contract version is `1`.
|
data/README.md
CHANGED
|
@@ -7,8 +7,9 @@ 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.0
|
|
11
|
-
>
|
|
10
|
+
> **Status: 0.2.0.** Read the known limitations in
|
|
11
|
+
> [CHANGELOG.md](CHANGELOG.md) before adopting it — particularly the one about a
|
|
12
|
+
> transaction wrapped around an invocation, if your application wraps requests in one.
|
|
12
13
|
|
|
13
14
|
*A reeve is an official who acts with delegated authority on behalf of someone else — the
|
|
14
15
|
root of "sheriff" (shire-reeve). That delegation is exactly what this gem governs: an agent
|
|
@@ -27,11 +28,38 @@ in CI.
|
|
|
27
28
|
|
|
28
29
|
That's the gap Reeve fills. It's an extension layer, not a competitor — it rides all three.
|
|
29
30
|
|
|
30
|
-
##
|
|
31
|
+
## Three steps
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bundle add reeve
|
|
35
|
+
bin/rails generate reeve:install
|
|
36
|
+
bin/rails db:migrate
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**1. Say who the agent is acting for.** This is the only thing Reeve cannot work out for
|
|
40
|
+
itself, and the generated initializer leaves it as a TODO:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# config/initializers/reeve.rb
|
|
44
|
+
Reeve.configure do |config|
|
|
45
|
+
config.principal_resolver = lambda do |context|
|
|
46
|
+
ApiToken.find_by(token: context.metadata.dig(:headers, "Authorization"))&.user
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
config.unguarded_tools = :deny # :deny | :allow_with_warning
|
|
50
|
+
config.redact_arguments = %i[password token ssn]
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Until that resolver is set, every guarded call denies with `no_principal`. That is the
|
|
55
|
+
intended behaviour: the library is safe before it is configured.
|
|
56
|
+
|
|
57
|
+
**2. Guard a tool.**
|
|
31
58
|
|
|
32
59
|
```ruby
|
|
33
60
|
class InvoiceSearchTool < FastMcp::Tool
|
|
34
61
|
guard_with InvoicePolicy # the only line you add
|
|
62
|
+
redact :customer_ssn
|
|
35
63
|
|
|
36
64
|
def call(query:)
|
|
37
65
|
Invoice.where("number LIKE ?", "%#{query}%")
|
|
@@ -39,24 +67,259 @@ class InvoiceSearchTool < FastMcp::Tool
|
|
|
39
67
|
end
|
|
40
68
|
```
|
|
41
69
|
|
|
42
|
-
|
|
70
|
+
**3. Prove it in CI.**
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
RSpec.describe InvoiceSearchTool do
|
|
74
|
+
it { is_expected.to deny_access_for(stranger).with(query: "AC") }
|
|
75
|
+
it { is_expected.to audit_every_call }
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Three things now hold, and each is a test you can run rather than a claim you have to
|
|
80
|
+
trust.
|
|
81
|
+
|
|
82
|
+
## Deny by default
|
|
83
|
+
|
|
84
|
+
A tool returns only the records its principal may see. No principal, no `guard_with`, or a
|
|
85
|
+
policy that raises means no records — never a silent pass. A single record outside the
|
|
86
|
+
principal's scope is refused in a way that does not reveal whether it exists:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
Reeve.invoke(tool: InvoiceShowTool, arguments: { id: 41 }, principal: alice)
|
|
90
|
+
# => Reeve::DeniedError: reeve denied invoice_show_tool for principal 1:
|
|
91
|
+
# out_of_scope_record (the requested record is not within this principal's scope)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The error names the rule and never names the record. One caveat worth knowing: fetching
|
|
95
|
+
from the unscoped model still lets a caller tell "someone else's" (a denial) from "no such
|
|
96
|
+
record" (`nil`). If that distinction matters, fetch through `scoped`, where both answers
|
|
97
|
+
are `nil`:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
def call(id:)
|
|
101
|
+
scoped(Invoice).find_by(id: id)
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Anything that is not a record — a count, a sum, a summary — is safe only when it was
|
|
106
|
+
computed from `scoped`, because then the tool never held unscoped data:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class OverdueTotalTool
|
|
110
|
+
include Reeve::Guard
|
|
111
|
+
guard_with InvoicePolicy
|
|
112
|
+
|
|
113
|
+
def call
|
|
114
|
+
scoped(Invoice).where(overdue: true).sum(:cents) # safe by construction
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Reaching for `Invoice.sum(:cents)` there is denied with `unscoped_derived_result`. The
|
|
120
|
+
guarantee is structural, not a matter of remembering.
|
|
121
|
+
|
|
122
|
+
## Every call leaves a trace
|
|
123
|
+
|
|
124
|
+
One append-only row per invocation, allowed or denied, naming the agent, the principal, the
|
|
125
|
+
arguments (post-redaction), what came back, and the rule that decided:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
Reeve::Audit::Query
|
|
129
|
+
.for_principal(user)
|
|
130
|
+
.for_agent("claude-desktop")
|
|
131
|
+
.between(1.week.ago, Time.current)
|
|
132
|
+
.pluck(:occurred_at, :tool_name, :outcome, :rule, :record_type, :record_ids)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A call whose tool raised is still recorded — that trace is the one most worth having. A
|
|
136
|
+
call that cannot be recorded fails, unless the host has explicitly opted into
|
|
137
|
+
`audit_failure_mode = :warn`.
|
|
138
|
+
|
|
139
|
+
## Provable in CI, in whichever framework you already use
|
|
140
|
+
|
|
141
|
+
All the logic lives in framework-neutral checks. RSpec and Minitest are thin front-ends
|
|
142
|
+
over the same objects, emitting the same messages, so no guarantee is provable in one
|
|
143
|
+
framework only.
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
# RSpec — require "reeve/rspec"
|
|
147
|
+
it { is_expected.to deny_access_for(stranger) }
|
|
148
|
+
it_behaves_like "a reeve-compliant server"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
# Minitest — require "reeve/minitest"
|
|
153
|
+
class ComplianceTest < ActiveSupport::TestCase
|
|
154
|
+
include Reeve::Testing::Assertions
|
|
155
|
+
include Reeve::Testing::ComplianceAssertions
|
|
156
|
+
|
|
157
|
+
def test_search_denies_a_stranger
|
|
158
|
+
assert_denies_access_for InvoiceSearchTool, stranger, query: "AC"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A stock `rails new` application — Minitest, no RSpec — proves every guarantee without
|
|
164
|
+
adding a test framework. That is a spec in this repo, not an aspiration.
|
|
165
|
+
|
|
166
|
+
### Without a test framework at all
|
|
167
|
+
|
|
168
|
+
The checks are plain objects, so the same guarantees are assertable from a rake task, a CI
|
|
169
|
+
script, or a boot-time assertion in staging:
|
|
170
|
+
|
|
171
|
+
<!-- reeve:compliance-gate -->
|
|
172
|
+
```ruby
|
|
173
|
+
require "reeve/testing"
|
|
174
|
+
|
|
175
|
+
report = Reeve::Checks.run_all(principals: [alice, bob])
|
|
176
|
+
abort report.to_s unless report.passed?
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`alice` and `bob` are two fixture principals with disjoint records — that disjointness is
|
|
180
|
+
what makes a shared record identifier proof of a leak. As a Rails rake task:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
# lib/tasks/reeve.rake
|
|
184
|
+
namespace :reeve do
|
|
185
|
+
desc "Fail the build if any guarded tool leaks or goes unaudited"
|
|
186
|
+
task compliance: :environment do
|
|
187
|
+
require "reeve/testing"
|
|
188
|
+
|
|
189
|
+
report = Reeve::Checks.run_all(principals: Reeve::Testing.compliance_principals)
|
|
190
|
+
abort report.to_s unless report.passed?
|
|
191
|
+
puts report
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
A failing run names the check, the tool, and the records that leaked:
|
|
197
|
+
|
|
198
|
+
```text
|
|
199
|
+
reeve compliance: 13 checks, 12 passed, 1 failed
|
|
200
|
+
|
|
201
|
+
FAIL CrossPrincipalLeak
|
|
202
|
+
expected InvoiceSearchTool to return no records belonging to another principal, but it
|
|
203
|
+
returned 3 records to User#1 that also belong to User#2: Invoice#7, Invoice#8,
|
|
204
|
+
Invoice#9 (guard: InvoicePolicy, decision: allow via InvoicePolicy#index)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Without Rails, or without fast-mcp
|
|
208
|
+
|
|
209
|
+
The core needs neither. `Reeve.invoke` is the same envelope with the same guarantees:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
require "reeve"
|
|
213
|
+
|
|
214
|
+
Reeve.invoke(
|
|
215
|
+
tool: InvoiceSearchTool,
|
|
216
|
+
arguments: { query: "AC" },
|
|
217
|
+
principal: current_user,
|
|
218
|
+
agent: { id: "claude-desktop" }
|
|
219
|
+
)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Wrapping your own JSON-RPC server
|
|
223
|
+
|
|
224
|
+
Plenty of Rails apps expose `/mcp` from a controller they wrote themselves, with their own
|
|
225
|
+
tool registry and their own bearer-token authentication. There is no adapter to install
|
|
226
|
+
for that, and none is needed: `Reeve.invoke` is the adapter interface. An MCP integration
|
|
227
|
+
is a function from a JSON-RPC request to one `Reeve.invoke` call.
|
|
228
|
+
|
|
229
|
+
Keep the authentication you have. Reeve does not do connection auth (see [What you have
|
|
230
|
+
not gained](#what-you-have-not-gained)) — the controller still decides whether the caller
|
|
231
|
+
gets in the door, and Reeve decides what they may touch once inside.
|
|
232
|
+
|
|
233
|
+
**Dispatch through the envelope.** Map the JSON-RPC tool name to the class, then call:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# app/controllers/mcp_controller.rb
|
|
237
|
+
def call_tool
|
|
238
|
+
tool = McpServer.registry.fetch(params.dig(:params, :name))
|
|
239
|
+
|
|
240
|
+
records = Reeve.invoke(
|
|
241
|
+
tool: tool,
|
|
242
|
+
arguments: params.dig(:params, :arguments).to_h.symbolize_keys,
|
|
243
|
+
agent: { id: request.headers["X-MCP-Client"] || "unknown" },
|
|
244
|
+
metadata: { headers: request.headers.to_h.slice(*AUDITED_HEADERS) }
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
render json: { jsonrpc: "2.0", id: params[:id], result: serialize(records) }
|
|
248
|
+
rescue Reeve::DeniedError => e
|
|
249
|
+
render json: { jsonrpc: "2.0", id: params[:id],
|
|
250
|
+
error: { code: -32_003, message: e.message } }
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Note what is *not* passed: `principal:`. Omit it and the resolver in your initializer runs,
|
|
255
|
+
which is what you want when the controller has already set `Current.user` — one place
|
|
256
|
+
decides who the principal is, and the ledger records the same answer the guard used.
|
|
257
|
+
Passing `principal:` explicitly overrides the resolver for that call, which is useful in
|
|
258
|
+
tests and in scripts.
|
|
259
|
+
|
|
260
|
+
**`metadata:` is transport detail, and it is recorded.** It reaches the resolver as
|
|
261
|
+
`context.metadata` and is written to the ledger's `metadata` column, so it is what a
|
|
262
|
+
reviewer has to reconstruct *which request* a row came from. It goes through the same
|
|
263
|
+
redactor as the arguments, so `Authorization` and friends are replaced by name — but pass
|
|
264
|
+
the headers you would want in an audit rather than all of them.
|
|
265
|
+
|
|
266
|
+
**Resolve the principal from whichever the controller established:**
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
Reeve.configure do |config|
|
|
270
|
+
config.principal_resolver = lambda do |context|
|
|
271
|
+
Current.user || ApiToken.find_by(
|
|
272
|
+
token: context.metadata.dig(:headers, "Authorization").to_s.delete_prefix("Bearer ")
|
|
273
|
+
)&.user
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
A resolver that returns nil — or raises — denies with `no_principal` and still writes a
|
|
279
|
+
row. There is no configuration in which an unidentified caller reaches a tool.
|
|
280
|
+
|
|
281
|
+
**Adopt one tool at a time.** A registry of thirty tools does not need thirty policies
|
|
282
|
+
before any of this is worth turning on:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
config.unguarded_tools = :allow_with_warning # migrating
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Tools with `guard_with` are authorized and scoped normally. Tools without one still run —
|
|
289
|
+
unscoped, which is the entire point of the warning — and are recorded with `guard: "none"`
|
|
290
|
+
and rule `unguarded_tool`, so the ledger itself is your worklist:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
Reeve::Audit::Entry.where(guard: "none").distinct.pluck(:tool_name)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Flip to `:deny` when that comes back empty, and the mode stops being reachable by accident.
|
|
297
|
+
|
|
298
|
+
The compliance checks work here too, and they take an `invoke:` argument precisely so they
|
|
299
|
+
run against your dispatcher rather than a synthetic call:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
Reeve::Checks.run_all(
|
|
303
|
+
principals: [alice, bob],
|
|
304
|
+
invoke: ->(tool:, principal:, arguments:) { McpServer.dispatch(tool, principal, arguments) }
|
|
305
|
+
)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
That is the whole integration: one call site, your auth untouched, and the same three
|
|
309
|
+
guarantees the fast-mcp adapter gets.
|
|
43
310
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
denied, naming the agent, the principal, the arguments, what came back, and the rule
|
|
48
|
-
that decided.
|
|
49
|
-
3. **Provable in CI.** `expect(tool).to deny_access_for(stranger)` in RSpec,
|
|
50
|
-
`assert_denies_access_for` in Minitest, or the checks called directly from a rake task.
|
|
311
|
+
Policies are plain objects unless you want Pundit (`authorize` and `scope`, two methods).
|
|
312
|
+
The ledger is an ActiveRecord table unless you supply your own recorder. Records are
|
|
313
|
+
ActiveRecord unless they are not — a plain object with an `id` works.
|
|
51
314
|
|
|
52
|
-
##
|
|
315
|
+
## What you have not gained
|
|
53
316
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- fast-mcp adapter, plus a plain interface for everyone else
|
|
317
|
+
Rate limiting, prompt-injection defence, cost control, and connection authentication are
|
|
318
|
+
out of scope. This library governs *what an authenticated agent may touch* and *what it
|
|
319
|
+
touched*. Keep your existing auth.
|
|
58
320
|
|
|
59
|
-
Ruby 3.0+, Rails 7.0+. No runtime dependencies.
|
|
321
|
+
Ruby 3.0+, Rails 7.0+. No runtime dependencies. The fast-mcp adapter needs Ruby 3.1+,
|
|
322
|
+
because fast-mcp does.
|
|
60
323
|
|
|
61
324
|
## License
|
|
62
325
|
|
|
@@ -0,0 +1,46 @@
|
|
|
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:install` — step two of the three-step adoption path
|
|
9
|
+
# (Constitution VI). Writes an initializer and the ledger migration, and nothing else.
|
|
10
|
+
#
|
|
11
|
+
# The initializer deliberately ships with `principal_resolver` unset: it is the one
|
|
12
|
+
# thing only the host can answer, and a plausible-looking guess would be worse than a
|
|
13
|
+
# TODO, because the library fails closed until it is filled in.
|
|
14
|
+
class InstallGenerator < Rails::Generators::Base
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
desc "Creates the reeve initializer and the audit ledger migration."
|
|
20
|
+
|
|
21
|
+
def create_initializer
|
|
22
|
+
template "initializer.rb.tt", "config/initializers/reeve.rb"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_migration_file
|
|
26
|
+
migration_template(
|
|
27
|
+
"create_audit_entries.rb.tt",
|
|
28
|
+
"db/migrate/create_reeve_audit_entries.rb"
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def report_next_step
|
|
33
|
+
say <<~NEXT
|
|
34
|
+
|
|
35
|
+
reeve is installed. Two things left:
|
|
36
|
+
|
|
37
|
+
1. Fill in `principal_resolver` in config/initializers/reeve.rb.
|
|
38
|
+
Until you do, every guarded call denies with `no_principal`.
|
|
39
|
+
2. Run `bin/rails db:migrate` to create the audit ledger.
|
|
40
|
+
|
|
41
|
+
Then add `guard_with SomePolicy` to a tool.
|
|
42
|
+
NEXT
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The reeve audit ledger: one row per guarded tool invocation, allowed or denied.
|
|
4
|
+
#
|
|
5
|
+
# The table is yours. Reeve writes to it and reads from it, and exposes no API that
|
|
6
|
+
# updates or deletes a row (FR-010). What the library can enforce, it enforces:
|
|
7
|
+
# Reeve::Audit::Entry is readonly once persisted and aborts destroy. What it cannot
|
|
8
|
+
# enforce, it does not pretend to — a database superuser or a raw `UPDATE` can still
|
|
9
|
+
# rewrite history. If the ledger is a compliance artifact for you, close that gap in the
|
|
10
|
+
# database, not in Ruby:
|
|
11
|
+
#
|
|
12
|
+
# -- PostgreSQL, for the role your application connects as
|
|
13
|
+
# REVOKE ALL ON reeve_audit_entries FROM app_role;
|
|
14
|
+
# GRANT INSERT, SELECT ON reeve_audit_entries TO app_role;
|
|
15
|
+
# GRANT USAGE ON SEQUENCE reeve_audit_entries_id_seq TO app_role;
|
|
16
|
+
#
|
|
17
|
+
# Also explicitly out of scope for now, so you can plan around it:
|
|
18
|
+
# * no retention, rotation or archival — this table grows until you prune it, and
|
|
19
|
+
# pruning is your policy, run by a role that is allowed to delete;
|
|
20
|
+
# * no cryptographic chaining or tamper-evidence. If that lands later it arrives as a
|
|
21
|
+
# nullable column, which the audit-entry contract's versioning already permits.
|
|
22
|
+
#
|
|
23
|
+
# `invocation_id` is a UUID stored as a string rather than a native `uuid` column,
|
|
24
|
+
# because reeve supports every database ActiveRecord does and only PostgreSQL has the
|
|
25
|
+
# native type. The unique index on it is what makes "exactly one row per invocation"
|
|
26
|
+
# a checkable property.
|
|
27
|
+
class CreateReeveAuditEntries < ActiveRecord::Migration[7.0]
|
|
28
|
+
def change
|
|
29
|
+
create_table :reeve_audit_entries do |t|
|
|
30
|
+
t.string :invocation_id, null: false
|
|
31
|
+
t.datetime :occurred_at, null: false
|
|
32
|
+
|
|
33
|
+
t.string :agent_id, null: false
|
|
34
|
+
t.string :agent_name
|
|
35
|
+
t.string :principal_type
|
|
36
|
+
t.string :principal_id
|
|
37
|
+
|
|
38
|
+
t.string :tool_name, null: false
|
|
39
|
+
t.json :arguments, null: false
|
|
40
|
+
|
|
41
|
+
t.string :outcome, null: false
|
|
42
|
+
t.string :rule, null: false
|
|
43
|
+
# Diagnostic text explaining the rule ("policy raised ArgumentError: boom").
|
|
44
|
+
# Nullable and free-form: match on `rule`, read `detail`. Never names a record.
|
|
45
|
+
t.text :detail
|
|
46
|
+
|
|
47
|
+
t.string :record_type
|
|
48
|
+
t.json :record_ids, null: false
|
|
49
|
+
t.integer :record_count, null: false, default: 0
|
|
50
|
+
t.boolean :truncated, null: false, default: false
|
|
51
|
+
t.boolean :derived, null: false, default: false
|
|
52
|
+
|
|
53
|
+
t.string :guard, null: false
|
|
54
|
+
t.integer :duration_ms
|
|
55
|
+
t.json :metadata
|
|
56
|
+
|
|
57
|
+
# The audit-entry contract version this row was written under. A ledger outlives
|
|
58
|
+
# the gem version that wrote it, and a row that cannot name its own shape has to be
|
|
59
|
+
# interpreted by guesswork: contract 1 wrote `metadata` NULL whatever the caller
|
|
60
|
+
# passed, so on a version 1 row NULL means "never recorded" and on a version 2 row
|
|
61
|
+
# it means "the caller passed none". Stamping each row keeps an export readable
|
|
62
|
+
# across an upgrade instead of collapsing that distinction.
|
|
63
|
+
t.integer :contract_version, null: false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
add_index :reeve_audit_entries, :invocation_id, unique: true
|
|
67
|
+
|
|
68
|
+
# One index per query axis in FR-013: by principal, by tool, by agent, by outcome —
|
|
69
|
+
# each paired with occurred_at, since every one of those questions is asked over a
|
|
70
|
+
# time range.
|
|
71
|
+
add_index :reeve_audit_entries, %i[principal_type principal_id occurred_at],
|
|
72
|
+
name: "index_reeve_audit_entries_on_principal"
|
|
73
|
+
add_index :reeve_audit_entries, %i[tool_name occurred_at]
|
|
74
|
+
add_index :reeve_audit_entries, %i[agent_id occurred_at]
|
|
75
|
+
add_index :reeve_audit_entries, %i[outcome occurred_at]
|
|
76
|
+
end
|
|
77
|
+
end
|