reeve 0.0.1 → 0.1.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 +112 -0
- data/README.md +191 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +48 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +163 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +88 -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 +84 -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 +104 -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 +73 -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 +9 -3
- metadata +52 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bea7ce4962daf51d8ac269ab8fa8d95198d3dd0ab22fe534c2ddd0a0f79a8981
|
|
4
|
+
data.tar.gz: 8000630e05686b6d1a29c81e5b007f7a1b46f586aca9688870465c1b8b8d1d3a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40886bd0fa070325c4e52eb41a9aa87e4f8cda355d71d68993641b62201aadd37243ce8988f0b9831d2aa33ef80a219d663a2c8b78cd4bd317dca411bbc70c48
|
|
7
|
+
data.tar.gz: b12725979b28ffa95cbd6838c9e6f1faadc47675894bd1d824c99632f7b0c715441b8c690eabd8f9d8873139a8be97b9cfa75eba22e97b0fa0a254287003f65b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,117 @@
|
|
|
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.1.0] - 2026-08-11
|
|
8
|
+
|
|
9
|
+
First working release. The published 0.0.1 was a placeholder holding the gem name.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
**Authorization**
|
|
14
|
+
|
|
15
|
+
- `guard_with SomePolicy` declares the policy governing a tool. A tool without one is
|
|
16
|
+
denied (`no_guard_declared`) unless the host opts into `unguarded_tools =
|
|
17
|
+
:allow_with_warning`, which runs the tool unscoped and records `guard: "none"`.
|
|
18
|
+
- `redact :argument_name` keeps an argument's value out of the ledger while keeping its
|
|
19
|
+
name.
|
|
20
|
+
- `scoped(Model)` returns the policy-scoped relation inside a tool body. It is how a
|
|
21
|
+
guarded tool returns anything that is not a record: a count or a summary computed from
|
|
22
|
+
`scoped(...)` is safe because the tool never held unscoped data. A derived value
|
|
23
|
+
returned without it is denied (`unscoped_derived_result`).
|
|
24
|
+
- Policy adapters for plain objects and for Pundit, behind one two-method protocol
|
|
25
|
+
(`authorize`, `scope`). `:auto` picks one and reports which through
|
|
26
|
+
`Reeve.config.resolved_policy_adapter`. Neither Pundit nor any authorization library is
|
|
27
|
+
a runtime dependency.
|
|
28
|
+
- Per-record scoping for relations, arrays, single records, mixed types and derived
|
|
29
|
+
values. A single record outside the principal's scope is refused without naming it.
|
|
30
|
+
|
|
31
|
+
**Audit**
|
|
32
|
+
|
|
33
|
+
- One append-only `reeve_audit_entries` row per invocation, allowed or denied, recording
|
|
34
|
+
the agent, the principal, the tool, post-redaction arguments, returned identifiers, the
|
|
35
|
+
outcome, the rule that decided, and why it decided (`detail`).
|
|
36
|
+
- The write happens in its own transaction, so a tool that raises and rolls back its own
|
|
37
|
+
work still leaves a trace — the invocations most worth recording are the ones that
|
|
38
|
+
failed.
|
|
39
|
+
- A failed ledger write fails the invocation. `audit_failure_mode = :warn` degrades that
|
|
40
|
+
deliberately and must be opted into.
|
|
41
|
+
- `Reeve::Audit::Query` queries the ledger by principal, agent, tool, outcome and time
|
|
42
|
+
range.
|
|
43
|
+
- Entries are read-only after insert and refuse to be destroyed; the generated migration
|
|
44
|
+
documents the `GRANT INSERT, SELECT` that enforces what a library cannot.
|
|
45
|
+
|
|
46
|
+
**Testing kit**
|
|
47
|
+
|
|
48
|
+
- Seven framework-neutral checks (`CrossPrincipalLeak`, `AuditCoverage`, `GuardDeclared`,
|
|
49
|
+
`RulePresent`, `RedactionHolds`, `PrincipalRequired`, `ContractVersion`) plus
|
|
50
|
+
`Checks.run_all`, all plain Ruby that loads no test framework.
|
|
51
|
+
- RSpec front-end (`require "reeve/rspec"`): `deny_access_for`, `audit_every_call`,
|
|
52
|
+
`pass_reeve_check`, and the `"a reeve-compliant server"` shared example group.
|
|
53
|
+
- Minitest front-end (`require "reeve/minitest"`): the same guarantees from a stock
|
|
54
|
+
`rails new` application, with no RSpec installed.
|
|
55
|
+
- Failure messages are built by the checks, so all three front-ends emit identical text.
|
|
56
|
+
|
|
57
|
+
**Adoption**
|
|
58
|
+
|
|
59
|
+
- `rails generate reeve:install` writes the initializer and the ledger migration.
|
|
60
|
+
`principal_resolver` ships as a TODO, because it is the one thing only the host can
|
|
61
|
+
answer, and reeve denies every call until it is filled in.
|
|
62
|
+
- fast-mcp adapter (`require "reeve/fast_mcp"`): the DSL on every tool and the envelope
|
|
63
|
+
around every call, with the principal resolved from the request's headers. Needs Ruby
|
|
64
|
+
3.1+, because fast-mcp does.
|
|
65
|
+
- `Reeve.invoke` gives the same guarantees with no Rails, no ActiveRecord and no MCP
|
|
66
|
+
server library.
|
|
67
|
+
|
|
68
|
+
### Known limitations
|
|
69
|
+
|
|
70
|
+
Stated here because finding them yourself later is worse than reading them now.
|
|
71
|
+
|
|
72
|
+
- **A transaction the host wraps around an invocation takes the ledger row with it.** The
|
|
73
|
+
recorder writes in a savepoint, which protects the trace from a transaction the *tool*
|
|
74
|
+
opens and rolls back, but not from one already open around the whole call — a controller
|
|
75
|
+
that wraps each request, or a test suite using transactional fixtures. Reeve detects this
|
|
76
|
+
and warns, naming the invocation. Real isolation needs a second connection and is not
|
|
77
|
+
portable: on SQLite the enclosing transaction holds the write lock and a second
|
|
78
|
+
connection times out. Hosts needing durability there supply their own `audit_recorder`.
|
|
79
|
+
- **An unscoped fetch by id still discloses existence.** `Invoice.find_by(id:)` denies for
|
|
80
|
+
a record that exists but belongs to someone else, and returns `nil` for one that does not
|
|
81
|
+
exist. Fetching through `scoped(...)` makes both answers `nil`.
|
|
82
|
+
- **A scope-less type must be tied to its policy.** A plain object with an `id` is a
|
|
83
|
+
record, but a type with no relation cannot be checked against a policy scope — only
|
|
84
|
+
against `authorize`, which is often permissive. Such a type is trusted when a policy is
|
|
85
|
+
named for it or a `scoped(...)` call establishes provenance, and denied otherwise.
|
|
86
|
+
- **A call that cannot be recorded fails, even when it was already failing.** An
|
|
87
|
+
unrecordable denial raises `AuditWriteError` carrying the denial as `#during`, rather
|
|
88
|
+
than the denial itself. With no recorder configured at all, that is the first thing you
|
|
89
|
+
will see, and it names the missing ledger.
|
|
90
|
+
- **`AuditCoverage` proves one invocation is recorded**, not every invocation.
|
|
91
|
+
- **The fast-mcp adapter needs Ruby 3.1+**, because fast-mcp depends on dry-schema. The
|
|
92
|
+
core supports 3.0.
|
|
93
|
+
- CI runs SQLite only; MySQL and PostgreSQL rest on ActiveRecord's portability.
|
|
94
|
+
|
|
95
|
+
### Notes
|
|
96
|
+
|
|
97
|
+
- Ruby 3.0+, Rails 7.0+, zero runtime dependencies.
|
|
98
|
+
- Publishing requires MFA on the owner's account (`rubygems_mfa_required`).
|
|
99
|
+
|
|
3
100
|
## [0.0.1] - 2026-08-11
|
|
4
101
|
|
|
5
102
|
- Placeholder release reserving the gem name. No functionality.
|
|
103
|
+
|
|
104
|
+
## Versioning policy
|
|
105
|
+
|
|
106
|
+
Semantic versioning, with one project-specific rule that follows from what this library
|
|
107
|
+
is for:
|
|
108
|
+
|
|
109
|
+
- **A change that narrows what a tool returns is a security fix and may ship in a patch.**
|
|
110
|
+
If reeve was letting a record through that a policy did not permit, closing that is not
|
|
111
|
+
a breaking change, however much it changes behaviour for someone relying on it.
|
|
112
|
+
- **A change that widens access is breaking by definition** — major version, plus a
|
|
113
|
+
migration note saying exactly what became visible that was not visible before.
|
|
114
|
+
- Adding a nullable ledger column is minor and leaves the audit-entry contract version
|
|
115
|
+
alone. Removing or renaming a column, or changing what a value means, is major and bumps
|
|
116
|
+
it.
|
|
117
|
+
- 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.1.0, the first working release.** 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,170 @@ 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
|
+
```
|
|
43
221
|
|
|
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.
|
|
222
|
+
Policies are plain objects unless you want Pundit (`authorize` and `scope`, two methods).
|
|
223
|
+
The ledger is an ActiveRecord table unless you supply your own recorder. Records are
|
|
224
|
+
ActiveRecord unless they are not — a plain object with an `id` works.
|
|
51
225
|
|
|
52
|
-
##
|
|
226
|
+
## What you have not gained
|
|
53
227
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
- fast-mcp adapter, plus a plain interface for everyone else
|
|
228
|
+
Rate limiting, prompt-injection defence, cost control, and connection authentication are
|
|
229
|
+
out of scope. This library governs *what an authenticated agent may touch* and *what it
|
|
230
|
+
touched*. Keep your existing auth.
|
|
58
231
|
|
|
59
|
-
Ruby 3.0+, Rails 7.0+. No runtime dependencies.
|
|
232
|
+
Ruby 3.0+, Rails 7.0+. No runtime dependencies. The fast-mcp adapter needs Ruby 3.1+,
|
|
233
|
+
because fast-mcp does.
|
|
60
234
|
|
|
61
235
|
## License
|
|
62
236
|
|
|
@@ -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,69 @@
|
|
|
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 v1, 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
|
+
end
|
|
57
|
+
|
|
58
|
+
add_index :reeve_audit_entries, :invocation_id, unique: true
|
|
59
|
+
|
|
60
|
+
# One index per query axis in FR-013: by principal, by tool, by agent, by outcome —
|
|
61
|
+
# each paired with occurred_at, since every one of those questions is asked over a
|
|
62
|
+
# time range.
|
|
63
|
+
add_index :reeve_audit_entries, %i[principal_type principal_id occurred_at],
|
|
64
|
+
name: "index_reeve_audit_entries_on_principal"
|
|
65
|
+
add_index :reeve_audit_entries, %i[tool_name occurred_at]
|
|
66
|
+
add_index :reeve_audit_entries, %i[agent_id occurred_at]
|
|
67
|
+
add_index :reeve_audit_entries, %i[outcome occurred_at]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# reeve — per-record authorization and an append-only audit ledger for the MCP tools
|
|
4
|
+
# this application exposes to AI agents.
|
|
5
|
+
#
|
|
6
|
+
# Everything below is a live setting; the only one you must fill in is the first.
|
|
7
|
+
|
|
8
|
+
Reeve.configure do |config|
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# REQUIRED. Who is the agent acting for?
|
|
11
|
+
#
|
|
12
|
+
# Receives a Reeve::Context and returns the principal — the human whose
|
|
13
|
+
# permissions govern this call — or nil to deny it. Raising is treated as nil:
|
|
14
|
+
# reeve fails closed rather than guessing.
|
|
15
|
+
#
|
|
16
|
+
# Where the identity comes from is yours to decide. With the fast-mcp adapter
|
|
17
|
+
# the request's headers arrive as context.metadata[:headers]:
|
|
18
|
+
#
|
|
19
|
+
# config.principal_resolver = lambda do |context|
|
|
20
|
+
# token = context.metadata.dig(:headers, "Authorization")
|
|
21
|
+
# ApiToken.find_by(token: token)&.user
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# TODO: replace this with your own lookup. Until you do, every guarded call
|
|
25
|
+
# denies with the rule `no_principal` — which is the intended behaviour, not a
|
|
26
|
+
# bug: the library is safe before it is configured.
|
|
27
|
+
config.principal_resolver = ->(context) { nil }
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# What happens to a tool with no `guard_with` declaration?
|
|
31
|
+
#
|
|
32
|
+
# :deny — the call returns nothing and is recorded as denied.
|
|
33
|
+
# :allow_with_warning — the call runs UNSCOPED, is logged, and is recorded
|
|
34
|
+
# with guard: "none". For retrofitting an existing
|
|
35
|
+
# server; every such call is a hole until it is
|
|
36
|
+
# declared.
|
|
37
|
+
#
|
|
38
|
+
# Written out rather than defaulted, because it is a decision you should make
|
|
39
|
+
# knowingly.
|
|
40
|
+
config.unguarded_tools = :deny
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Argument names whose values never reach the ledger. Names survive, values do
|
|
44
|
+
# not, recursively through nested hashes. Add per-tool names with `redact` in
|
|
45
|
+
# the tool itself.
|
|
46
|
+
config.redact_arguments = %i[password token secret ssn]
|
|
47
|
+
|
|
48
|
+
# What happens if the ledger write fails?
|
|
49
|
+
#
|
|
50
|
+
# :fail — the invocation fails. A call that cannot be recorded is a call
|
|
51
|
+
# that did not happen.
|
|
52
|
+
# :warn — log it and continue. Opt in knowingly: it trades the audit
|
|
53
|
+
# guarantee for availability.
|
|
54
|
+
config.audit_failure_mode = :fail
|
|
55
|
+
|
|
56
|
+
# Cap on how many record identifiers one entry stores. The true count is
|
|
57
|
+
# always recorded, and the entry says when the list was truncated.
|
|
58
|
+
config.max_recorded_ids = 1000
|
|
59
|
+
|
|
60
|
+
# Where warnings go (unguarded tools, degraded audit mode).
|
|
61
|
+
config.logger = Rails.logger
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The ledger itself is opt-in, so the core stays loadable without ActiveRecord.
|
|
65
|
+
require "reeve/audit"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
# One ledger row: which agent, acting for which principal, called which tool with
|
|
6
|
+
# which arguments, what came back, and which rule decided (FR-009).
|
|
7
|
+
#
|
|
8
|
+
# Append-only (FR-010). The model enforces that as far as a library can: a persisted
|
|
9
|
+
# row is readonly, so `update` and `save` raise, and `destroy` aborts. It cannot
|
|
10
|
+
# enforce it against raw SQL and does not pretend to — the generated migration
|
|
11
|
+
# documents the INSERT+SELECT grant that closes the rest of the gap.
|
|
12
|
+
class Entry < ActiveRecord::Base
|
|
13
|
+
self.table_name = TABLE_NAME
|
|
14
|
+
|
|
15
|
+
ALLOW = "allow"
|
|
16
|
+
DENY = "deny"
|
|
17
|
+
OUTCOMES = [ALLOW, DENY].freeze
|
|
18
|
+
|
|
19
|
+
REQUIRED = %i[invocation_id occurred_at agent_id tool_name outcome rule guard].freeze
|
|
20
|
+
|
|
21
|
+
validates(*REQUIRED, presence: true)
|
|
22
|
+
validates :invocation_id, uniqueness: true
|
|
23
|
+
validates :outcome, inclusion: { in: OUTCOMES, message: "must be allow or deny" }
|
|
24
|
+
|
|
25
|
+
before_destroy { throw :abort }
|
|
26
|
+
|
|
27
|
+
# The contract version this table's shape implements (FR-015). Exposed on the model
|
|
28
|
+
# so an export can stamp the rows it carries.
|
|
29
|
+
def self.contract_version
|
|
30
|
+
CONTRACT_VERSION
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# False while the row is being inserted, true forever after: the insert is the only
|
|
34
|
+
# write the ledger ever performs.
|
|
35
|
+
def readonly?
|
|
36
|
+
persisted?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def allowed?
|
|
40
|
+
outcome == ALLOW
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def denied?
|
|
44
|
+
outcome == DENY
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
# The documented way to read the ledger (FR-013): one scope per query axis —
|
|
6
|
+
# principal, agent, tool, outcome, time range — each backed by one of the composite
|
|
7
|
+
# indexes the generated migration creates.
|
|
8
|
+
#
|
|
9
|
+
# Q = Reeve::Audit::Query
|
|
10
|
+
# Q.for_principal(user)
|
|
11
|
+
# .for_agent("claude-desktop")
|
|
12
|
+
# .between(1.week.ago, Time.current)
|
|
13
|
+
# .pluck(:tool_name, :record_type, :record_ids, :outcome, :rule)
|
|
14
|
+
#
|
|
15
|
+
# The scopes are also extended onto Entry, so a host that would rather work with the
|
|
16
|
+
# model directly gets the same chain. There is deliberately no scope that writes.
|
|
17
|
+
module Query
|
|
18
|
+
# Defined once and used from two places: on Query, +all+ opens on the whole ledger;
|
|
19
|
+
# on Entry (and on a relation being chained), +all+ is the current scope, which is
|
|
20
|
+
# what makes these compose in any order.
|
|
21
|
+
module Scopes
|
|
22
|
+
def for_principal(principal)
|
|
23
|
+
all.where(Query.principal_key(principal))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def for_agent(agent_id)
|
|
27
|
+
all.where(agent_id: agent_id.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def for_tool(tool_name)
|
|
31
|
+
all.where(tool_name: tool_name.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def allowed
|
|
35
|
+
all.where(outcome: Entry::ALLOW)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def denied
|
|
39
|
+
all.where(outcome: Entry::DENY)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# By occurred_at: the ledger is ordered by when the call happened, not by when the
|
|
43
|
+
# row landed.
|
|
44
|
+
def between(from, to)
|
|
45
|
+
all.where(occurred_at: from..to)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
extend Scopes
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
def all
|
|
53
|
+
Entry.all
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# How a principal becomes a pair of columns. Mirrors Reeve::Context#principal_type
|
|
57
|
+
# and #principal_id exactly — a query that derived the key differently would
|
|
58
|
+
# quietly fail to find the rows the envelope wrote.
|
|
59
|
+
#
|
|
60
|
+
# Accepts the principal itself, or `type:`/`id:` for when an incident report is
|
|
61
|
+
# all that survives of them.
|
|
62
|
+
def principal_key(principal)
|
|
63
|
+
return explicit_key(principal) if principal.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
principal_type: principal.class.name,
|
|
67
|
+
principal_id: principal.respond_to?(:id) ? principal.id.to_s : principal.to_s
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def explicit_key(attributes)
|
|
74
|
+
{
|
|
75
|
+
principal_type: attributes[:type]&.to_s,
|
|
76
|
+
principal_id: attributes[:id]&.to_s
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Reeve::Audit::Entry.extend(Reeve::Audit::Query::Scopes)
|