reeve 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +145 -1
- data/README.md +143 -2
- data/lib/reeve/authorization/guard.rb +53 -0
- data/lib/reeve/authorization/registry.rb +43 -0
- data/lib/reeve/authorization.rb +1 -11
- data/lib/reeve/configuration.rb +47 -1
- data/lib/reeve/decision.rb +6 -1
- data/lib/reeve/errors.rb +13 -0
- data/lib/reeve/integrations/fast_mcp/tool_extension.rb +5 -1
- data/lib/reeve/inventory/report.rb +146 -0
- data/lib/reeve/inventory.rb +212 -0
- data/lib/reeve/invocation.rb +49 -1
- data/lib/reeve/testing/assertions.rb +2 -2
- data/lib/reeve/testing/checks/endpoint_coverage.rb +133 -0
- data/lib/reeve/testing/checks.rb +18 -7
- data/lib/reeve/testing/compliance_assertions.rb +3 -1
- data/lib/reeve/testing/compliance_suite.rb +4 -1
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +1 -1
- data/lib/reeve/testing/report.rb +22 -6
- data/lib/reeve/testing/result.rb +28 -8
- data/lib/reeve/testing.rb +29 -0
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +1 -0
- metadata +5 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
class Inventory
|
|
5
|
+
# Every registered name, in one of four states, plus whatever the inventory declares
|
|
6
|
+
# that the server does not register.
|
|
7
|
+
#
|
|
8
|
+
# The output names tools, tool classes and exemption reasons, and nothing else. It is
|
|
9
|
+
# built before any invocation and knows no arguments, principals or records, so it is
|
|
10
|
+
# safe to print in a CI log or a boot failure — there is nothing in it to redact.
|
|
11
|
+
#
|
|
12
|
+
# reeve inventory: 6 registered
|
|
13
|
+
# 4 protected and guarded
|
|
14
|
+
# 1 routed through reeve but unguarded
|
|
15
|
+
# 1 bypasses reeve
|
|
16
|
+
# 0 exempted
|
|
17
|
+
# coverage: incomplete
|
|
18
|
+
class Report
|
|
19
|
+
attr_reader :entries
|
|
20
|
+
|
|
21
|
+
def initialize(registered, routes, reasons)
|
|
22
|
+
@entries = (classify(registered, routes, reasons) +
|
|
23
|
+
unregistered(registered, routes, reasons)).freeze
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Every registered name is either guarded or deliberately exempt, and the inventory
|
|
28
|
+
# names nothing the server does not register.
|
|
29
|
+
#
|
|
30
|
+
# That last part is not tidiness. A route for a name the server does not register is
|
|
31
|
+
# almost always a rename or a typo — and the real name, the one being dispatched, is
|
|
32
|
+
# then the one with no route.
|
|
33
|
+
def complete?
|
|
34
|
+
unguarded.empty? && bypassing.empty? && unregistered_entries.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def registered
|
|
38
|
+
entries.reject { |entry| entry.status == :unregistered }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def guarded
|
|
42
|
+
select(:guarded)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def unguarded
|
|
46
|
+
select(:unguarded)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bypassing
|
|
50
|
+
select(:bypass)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def exempted
|
|
54
|
+
select(:exempt)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def unregistered_entries
|
|
58
|
+
select(:unregistered)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The classes routed to a registered name.
|
|
62
|
+
def routed_tools
|
|
63
|
+
entries.filter_map(&:tool).uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_s
|
|
67
|
+
[summary, *entries.filter_map { |entry| detail(entry) }].join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inspect
|
|
71
|
+
"#<Reeve::Inventory::Report #{registered.size} registered, " \
|
|
72
|
+
"coverage: #{complete? ? 'complete' : 'incomplete'}>"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def select(status)
|
|
78
|
+
entries.select { |entry| entry.status == status }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def classify(registered, routes, reasons)
|
|
82
|
+
registered.map do |name|
|
|
83
|
+
tool = routes[name]
|
|
84
|
+
if tool
|
|
85
|
+
status = Reeve.registry.for_class(tool) ? :guarded : :unguarded
|
|
86
|
+
Entry.new(name: name, status: status, tool: tool)
|
|
87
|
+
elsif reasons.key?(name)
|
|
88
|
+
Entry.new(name: name, status: :exempt, reason: reasons[name])
|
|
89
|
+
else
|
|
90
|
+
Entry.new(name: name, status: :bypass)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def unregistered(registered, routes, reasons)
|
|
96
|
+
(routes.keys | reasons.keys).reject { |name| registered.include?(name) }.map do |name|
|
|
97
|
+
Entry.new(name: name, status: :unregistered, tool: routes[name],
|
|
98
|
+
reason: reasons[name])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def summary
|
|
103
|
+
["reeve inventory: #{registered.size} registered", *counts.map { |line| " #{line}" },
|
|
104
|
+
"coverage: #{complete? ? 'complete' : 'incomplete'}"].join("\n")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def counts
|
|
108
|
+
lines = [
|
|
109
|
+
"#{guarded.size} protected and guarded",
|
|
110
|
+
"#{unguarded.size} routed through reeve but unguarded",
|
|
111
|
+
"#{bypassing.size} #{bypassing.size == 1 ? 'bypasses' : 'bypass'} reeve",
|
|
112
|
+
"#{exempted.size} exempted"
|
|
113
|
+
]
|
|
114
|
+
return lines if unregistered_entries.empty?
|
|
115
|
+
|
|
116
|
+
lines << "#{unregistered_entries.size} declared but not registered"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# One line saying what is wrong and one saying what to do, for everything but a
|
|
120
|
+
# guarded tool — which needs neither. Exemptions are listed even when coverage is
|
|
121
|
+
# complete: a deliberate hole is still a hole, and it should be read every time.
|
|
122
|
+
def detail(entry)
|
|
123
|
+
case entry.status
|
|
124
|
+
when :unguarded
|
|
125
|
+
"\nUNGUARDED #{entry.name}\n routed to #{label(entry.tool)}, which has no " \
|
|
126
|
+
"guard_with declaration — reeve denies it, or runs it unscoped under " \
|
|
127
|
+
":allow_with_warning"
|
|
128
|
+
when :bypass
|
|
129
|
+
"\nBYPASS #{entry.name}\n registered with the server but neither routed " \
|
|
130
|
+
"through reeve nor exempted — route it to a guarded tool, or exempt it with a " \
|
|
131
|
+
"reason"
|
|
132
|
+
when :exempt
|
|
133
|
+
"\nEXEMPT #{entry.name}\n #{entry.reason}"
|
|
134
|
+
when :unregistered
|
|
135
|
+
what = entry.tool ? "routed to #{label(entry.tool)}" : "exempted"
|
|
136
|
+
"\nNOT REGISTERED #{entry.name}\n #{what}, but the server registers no tool " \
|
|
137
|
+
"by that name — if it was renamed, the new name is unbound"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def label(tool)
|
|
142
|
+
tool.name || tool.inspect
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
# The host's whole tool inventory, stated against what reeve was told about.
|
|
5
|
+
#
|
|
6
|
+
# Reeve only sees an invocation that reaches `Reeve.invoke`. A server that registers its
|
|
7
|
+
# tools as names and handler blocks can route some of them through the envelope and
|
|
8
|
+
# dispatch the rest directly, and the direct ones are invisible: neither guarded nor
|
|
9
|
+
# reported as unguarded. Every guarantee reeve makes — deny by default, scoping, the
|
|
10
|
+
# ledger — is skipped together for them, and nothing anywhere says so. The registry
|
|
11
|
+
# cannot answer "is every tool this endpoint dispatches protected?" because the tools
|
|
12
|
+
# that are not are, by construction, the ones it never heard of.
|
|
13
|
+
#
|
|
14
|
+
# The inventory makes the host's list of registered names the thing being checked:
|
|
15
|
+
#
|
|
16
|
+
# McpInventory = Reeve::Inventory.new(
|
|
17
|
+
# registered: -> { McpServer.tool_names },
|
|
18
|
+
# routed: -> { { "search_invoices" => InvoiceSearchTool } },
|
|
19
|
+
# exemptions: { "healthcheck" => { reason: "returns no application data" } }
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
# McpInventory.verify! # at boot, or in CI
|
|
23
|
+
#
|
|
24
|
+
# and, optionally, the place the host dispatches through, so that a name it has no
|
|
25
|
+
# entry for is refused rather than run:
|
|
26
|
+
#
|
|
27
|
+
# McpInventory.dispatch(name, arguments: args) { handlers.fetch(name).call(args) }
|
|
28
|
+
#
|
|
29
|
+
# Every part takes a value or a callable returning one. A Rails initializer cannot
|
|
30
|
+
# reference application constants, and a server registers its tools after the
|
|
31
|
+
# initializer runs, so the lists are read when asked for rather than when declared.
|
|
32
|
+
#
|
|
33
|
+
# Nothing here depends on an MCP library. A list of names is the whole protocol.
|
|
34
|
+
class Inventory
|
|
35
|
+
# One registered name, classified. +tool+ is the class it is routed to, if any;
|
|
36
|
+
# +reason+ is the stated reason for an exemption.
|
|
37
|
+
Entry = Struct.new(:name, :status, :tool, :reason, keyword_init: true)
|
|
38
|
+
|
|
39
|
+
def initialize(registered:, routed: {}, exemptions: {})
|
|
40
|
+
@registered = registered
|
|
41
|
+
@routed = routed
|
|
42
|
+
@exemptions = exemptions
|
|
43
|
+
|
|
44
|
+
# Literal values are checked now, so a blank exemption reason fails on the line that
|
|
45
|
+
# declared it. Callables cannot be until they are asked; #report checks those.
|
|
46
|
+
resolve unless [registered, routed, exemptions].any? { |part| part.respond_to?(:call) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The classification, read fresh. Raises ArgumentError if the inventory is malformed:
|
|
50
|
+
# a report built from a list it could not read would be a report about nothing.
|
|
51
|
+
def report
|
|
52
|
+
Report.new(*resolve)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The strict gate: the report when coverage is complete, IncompleteInventoryError
|
|
56
|
+
# otherwise. Call it where failing is what you want — boot, a deploy step, CI.
|
|
57
|
+
def verify!
|
|
58
|
+
report.tap do |result|
|
|
59
|
+
raise IncompleteInventoryError, result unless result.complete?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The tool classes the inventory routes through reeve. The testing kit certifies these.
|
|
64
|
+
def routed_tools
|
|
65
|
+
routes.values.uniq
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The dispatch boundary. A host that sends every tool call through here cannot have a
|
|
69
|
+
# tool that bypasses reeve, because the three answers are exhaustive:
|
|
70
|
+
#
|
|
71
|
+
# * **routed** — runs through `Reeve.invoke` with the class it is routed to. A handler
|
|
72
|
+
# block, if given, is the body, so a registry of blocks needs one guarded class per
|
|
73
|
+
# tool to say which policy governs it and nothing else.
|
|
74
|
+
# * **exempt** — the handler runs as it would have without reeve. That is what the
|
|
75
|
+
# exemption says, and why it needs a reason.
|
|
76
|
+
# * **neither** — governed by `config.unguarded_tools`, exactly like a tool with no
|
|
77
|
+
# guard. Under `:deny`, the default, the call is refused with `unbound_tool`; under
|
|
78
|
+
# `:allow_with_warning` it runs unscoped. Either way it is recorded, so a name the
|
|
79
|
+
# inventory missed shows up in the ledger rather than nowhere.
|
|
80
|
+
#
|
|
81
|
+
# +registered+ is not consulted. The host's own dispatcher already decided the name
|
|
82
|
+
# exists; what this decides is whether reeve stands in front of it.
|
|
83
|
+
def dispatch(name, arguments: {}, principal: :unset, agent: nil, metadata: {}, &handler)
|
|
84
|
+
name = name.to_s
|
|
85
|
+
tool = routes[name]
|
|
86
|
+
if tool
|
|
87
|
+
return Reeve.invoke(tool: tool, arguments: arguments, principal: principal,
|
|
88
|
+
agent: agent, metadata: metadata, &handler)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if handler.nil?
|
|
92
|
+
raise ArgumentError, "#{name} is not routed to a tool, so dispatch needs the " \
|
|
93
|
+
"handler block that runs it"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return handler.call if exemption_reasons.key?(name)
|
|
97
|
+
|
|
98
|
+
dispatch_unbound(name, arguments: arguments, principal: principal, agent: agent,
|
|
99
|
+
metadata: metadata, &handler)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def inspect
|
|
103
|
+
"#<Reeve::Inventory>"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def resolve
|
|
109
|
+
names = registered_names
|
|
110
|
+
routes = self.routes
|
|
111
|
+
reasons = exemption_reasons
|
|
112
|
+
|
|
113
|
+
both = routes.keys & reasons.keys
|
|
114
|
+
unless both.empty?
|
|
115
|
+
raise ArgumentError, "a tool is either routed through reeve or exempt from it, " \
|
|
116
|
+
"not both: #{both.join(', ')}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
[names, routes, reasons]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def registered_names
|
|
123
|
+
names = read(:registered, @registered)
|
|
124
|
+
unless names.respond_to?(:to_a) && !names.is_a?(Hash)
|
|
125
|
+
raise ArgumentError, "registered must list tool names, got #{names.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
names.to_a.map { |name| name_from(:registered, name) }.uniq
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def routes
|
|
132
|
+
read_hash(:routed, @routed).to_h do |name, tool|
|
|
133
|
+
unless tool.is_a?(Class)
|
|
134
|
+
raise ArgumentError, "routed must map each name to a tool class; " \
|
|
135
|
+
"#{name} maps to #{tool.inspect}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
[name_from(:routed, name), tool]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# An exemption with no reason is a hole someone forgot about. With one, it is a
|
|
143
|
+
# decision someone can be asked about.
|
|
144
|
+
def exemption_reasons
|
|
145
|
+
read_hash(:exemptions, @exemptions).to_h do |name, options|
|
|
146
|
+
name = name_from(:exemptions, name)
|
|
147
|
+
reason = options.is_a?(Hash) ? (options[:reason] || options["reason"]) : nil
|
|
148
|
+
if reason.nil? || reason.to_s.strip.empty?
|
|
149
|
+
raise ArgumentError, "the exemption for #{name} needs a reason, e.g. " \
|
|
150
|
+
"{ \"#{name}\" => { reason: \"returns no application data\" } }"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
[name, reason.to_s.strip]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def read_hash(part, source)
|
|
158
|
+
value = read(part, source)
|
|
159
|
+
return value if value.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
raise ArgumentError, "#{part} must be a Hash keyed by tool name, got #{value.inspect}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def read(part, source)
|
|
165
|
+
value = source.respond_to?(:call) ? source.call : source
|
|
166
|
+
raise ArgumentError, "#{part} is nil" if value.nil?
|
|
167
|
+
|
|
168
|
+
value
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def name_from(part, name)
|
|
172
|
+
string = name.respond_to?(:to_str) || name.is_a?(Symbol) ? name.to_s.strip : nil
|
|
173
|
+
return string unless string.nil? || string.empty?
|
|
174
|
+
|
|
175
|
+
raise ArgumentError, "#{part} contains a blank or non-string tool name: #{name.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A name the inventory has no entry for still goes through the envelope, under its
|
|
179
|
+
# own name and with nothing to look up. The registry answer below is what makes the
|
|
180
|
+
# refusal name the real cause — the inventory, not a missing `guard_with` on a class
|
|
181
|
+
# that may not exist.
|
|
182
|
+
def dispatch_unbound(name, arguments:, principal:, agent:, metadata:, &handler)
|
|
183
|
+
config = Reeve.config.with_principal(principal)
|
|
184
|
+
context = Context.new(tool_name: name, agent: agent, arguments: arguments,
|
|
185
|
+
metadata: metadata)
|
|
186
|
+
|
|
187
|
+
Invocation.call(context, registry: Unbound.new(name, config), config: config, &handler)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# The registry the envelope consults for an unbound name. Under `:deny` it answers
|
|
191
|
+
# with the refusal itself; under `:allow_with_warning` it has no guard to offer, and
|
|
192
|
+
# the envelope runs the tool unscoped and records `guard: "none"` — the same worklist
|
|
193
|
+
# an unguarded class lands on.
|
|
194
|
+
class Unbound
|
|
195
|
+
def initialize(name, config)
|
|
196
|
+
@name = name
|
|
197
|
+
@config = config
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def guard_for(_tool_name)
|
|
201
|
+
return nil if @config.unguarded_tools == :allow_with_warning
|
|
202
|
+
|
|
203
|
+
Decision.deny(
|
|
204
|
+
rule: Decision::UNBOUND_TOOL,
|
|
205
|
+
detail: "#{@name} is neither routed through reeve nor exempted in the inventory"
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
require_relative "inventory/report"
|
data/lib/reeve/invocation.rb
CHANGED
|
@@ -90,7 +90,9 @@ module Reeve
|
|
|
90
90
|
context.principal = principal
|
|
91
91
|
|
|
92
92
|
guard = look_up_guard
|
|
93
|
-
|
|
93
|
+
# A registry may answer with a refusal instead of a guard: the lookup raised, or an
|
|
94
|
+
# Inventory is refusing a name it has no entry for.
|
|
95
|
+
return guard if guard.is_a?(Decision)
|
|
94
96
|
|
|
95
97
|
if guard.nil?
|
|
96
98
|
if deny_unguarded?
|
|
@@ -152,13 +154,51 @@ module Reeve
|
|
|
152
154
|
Decision.deny(rule: Decision::POLICY_ERROR, detail: "policy raised #{e.class}: #{e.message}")
|
|
153
155
|
end
|
|
154
156
|
|
|
157
|
+
# A denial has to mean nothing happened.
|
|
158
|
+
#
|
|
159
|
+
# Authorization runs before the tool, but with the *class* as its subject — for an
|
|
160
|
+
# index-style check there is no record yet. The per-record answer only exists once the
|
|
161
|
+
# tool has returned something to scope. So a write tool used to mutate the record and
|
|
162
|
+
# then be refused its response: the caller saw `out_of_scope_record`, the ledger
|
|
163
|
+
# recorded a denial, and the row in the host's database had already changed. A denial
|
|
164
|
+
# in a compliance artifact that follows a committed write is not a partial guarantee,
|
|
165
|
+
# it is a false statement.
|
|
166
|
+
#
|
|
167
|
+
# Running the tool inside a transaction closes that. `requires_new` matters: under a
|
|
168
|
+
# transaction the host opened around the invocation this is a savepoint, so rolling
|
|
169
|
+
# back takes the tool's work and leaves the host's alone.
|
|
170
|
+
#
|
|
171
|
+
# What it cannot undo is anything that was never in the transaction — a sent email, a
|
|
172
|
+
# webhook, a file. A tool that reaches outside the database before it knows whether it
|
|
173
|
+
# is allowed to must ask first; `authorize!` in the tool body is for exactly that.
|
|
155
174
|
def run_guarded(guard, decision, &tool)
|
|
175
|
+
return run_and_narrow(guard, decision, &tool) unless rollback_available?
|
|
176
|
+
|
|
177
|
+
::ActiveRecord::Base.transaction(requires_new: true) do
|
|
178
|
+
outcome = run_and_narrow(guard, decision, &tool)
|
|
179
|
+
raise ::ActiveRecord::Rollback if @scope_result.denied?
|
|
180
|
+
|
|
181
|
+
outcome
|
|
182
|
+
end || @scope_result.decision
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def run_and_narrow(guard, decision, &tool)
|
|
156
186
|
result = execute(&tool)
|
|
157
187
|
|
|
158
188
|
@scope_result = narrow(guard, result)
|
|
159
189
|
@scope_result.denied? ? @scope_result.decision : decision
|
|
160
190
|
end
|
|
161
191
|
|
|
192
|
+
# The core runs with no ActiveRecord at all (SC-008), and a host may have the library
|
|
193
|
+
# loaded without a connection. Neither can roll anything back, and neither should be
|
|
194
|
+
# made to fail here — they get the behaviour they had before, which the ledger still
|
|
195
|
+
# records truthfully because it records what the scoper decided either way.
|
|
196
|
+
def rollback_available?
|
|
197
|
+
defined?(::ActiveRecord::Base) && ::ActiveRecord::Base.connected?
|
|
198
|
+
rescue StandardError
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
162
202
|
# The degraded mode a host opts into while retrofitting guards onto existing tools
|
|
163
203
|
# (FR-023). The call is unscoped — that is the whole point of the warning.
|
|
164
204
|
def run_unguarded(&tool)
|
|
@@ -171,6 +211,14 @@ module Reeve
|
|
|
171
211
|
|
|
172
212
|
def execute(&tool)
|
|
173
213
|
tool.call
|
|
214
|
+
rescue DeniedError => e
|
|
215
|
+
# A denial the tool raised on itself through `authorize!`. It is an authorization
|
|
216
|
+
# outcome, not a crash, and recording it as `tool_error` would file a policy saying
|
|
217
|
+
# no under the same rule as a NoMethodError. The rule the policy gave is carried
|
|
218
|
+
# through to the ledger unchanged.
|
|
219
|
+
@decision = Decision.deny(rule: e.rule, detail: e.detail)
|
|
220
|
+
@scope_result = ScopeResult.deny(rule: e.rule, detail: e.detail)
|
|
221
|
+
raise
|
|
174
222
|
rescue StandardError => e
|
|
175
223
|
# The tool's own failure propagates untouched, but not before the ensure block
|
|
176
224
|
# records it: the invocations most worth having a trace of are the ones that broke.
|
|
@@ -56,14 +56,14 @@ module Reeve
|
|
|
56
56
|
)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# SC-009's escape hatch: any
|
|
59
|
+
# SC-009's escape hatch: any check, straight from Minitest.
|
|
60
60
|
def assert_reeve_check(check)
|
|
61
61
|
result = check.call
|
|
62
62
|
assert result.passed?, result.message
|
|
63
63
|
result
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
# A whole Report at once, for a host that would rather have one test than
|
|
66
|
+
# A whole Report at once, for a host that would rather have one test than one per check.
|
|
67
67
|
def assert_reeve_compliance(principals: nil, **options)
|
|
68
68
|
report = Checks.run_all(
|
|
69
69
|
principals: principals || Testing.compliance_principals, **options
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Testing
|
|
5
|
+
module Checks
|
|
6
|
+
# Whether the run covered the endpoint, or only the tools reeve was told about.
|
|
7
|
+
#
|
|
8
|
+
# Every other check walks a population reeve assembled itself: the classes that
|
|
9
|
+
# included `Reeve::Guard`. A server that dispatches some tools without ever handing
|
|
10
|
+
# them to reeve leaves them out of that population, and the run goes green on the
|
|
11
|
+
# subset it could see. This check compares the run against the host's own list of
|
|
12
|
+
# registered names (`config.inventory`), so a green run can only mean the endpoint.
|
|
13
|
+
#
|
|
14
|
+
# Four answers, and only one of them is a pass:
|
|
15
|
+
#
|
|
16
|
+
# * **no inventory declared** — skipped. Reeve cannot know what the server registers
|
|
17
|
+
# unless told, and a pass here would be the green that means "did not look".
|
|
18
|
+
# * **a subset** — skipped, when the host narrowed the run with `compliance_tools` or
|
|
19
|
+
# `tools:`. That is the incremental-adoption path; the run says what it certified
|
|
20
|
+
# and that it is not the endpoint, without turning the build red mid-retrofit.
|
|
21
|
+
# * **incomplete** — failed: a registered tool bypasses reeve or reaches it unguarded,
|
|
22
|
+
# and the run claimed to cover everything.
|
|
23
|
+
# * **complete** — passed.
|
|
24
|
+
#
|
|
25
|
+
# Reeve::Checks::EndpointCoverage.new.call
|
|
26
|
+
# Reeve::Checks::EndpointCoverage.new(inventory: McpInventory).call
|
|
27
|
+
class EndpointCoverage < Base
|
|
28
|
+
def initialize(inventory: :configured, tools: nil, ledger: nil)
|
|
29
|
+
super(ledger: ledger)
|
|
30
|
+
@inventory = inventory == :configured ? Reeve.config.inventory : inventory
|
|
31
|
+
@tools = tools
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
return unverified if inventory.nil?
|
|
36
|
+
|
|
37
|
+
report = inventory.report
|
|
38
|
+
if narrowed?
|
|
39
|
+
subset(report)
|
|
40
|
+
elsif report.complete?
|
|
41
|
+
complete(report)
|
|
42
|
+
else
|
|
43
|
+
incomplete(report)
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
failed("the endpoint inventory could not be read, so its coverage is unknown: " \
|
|
47
|
+
"#{e.class}: #{e.message}", coverage: :unknown)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :inventory
|
|
53
|
+
|
|
54
|
+
def narrowed?
|
|
55
|
+
!@tools.nil? || Testing.compliance_tools_narrowed?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def certified
|
|
59
|
+
@certified ||= @tools || Testing.compliance_tools
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unverified
|
|
63
|
+
skipped(
|
|
64
|
+
"no endpoint inventory is declared, so this run certifies the " \
|
|
65
|
+
"#{pluralize(certified.size, 'tool')} reeve knows about and cannot say whether " \
|
|
66
|
+
"the server dispatches others outside it — set config.inventory to a " \
|
|
67
|
+
"Reeve::Inventory to verify the endpoint",
|
|
68
|
+
coverage: :unverified
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def complete(report)
|
|
73
|
+
passed(
|
|
74
|
+
"every one of the #{pluralize(report.registered.size, 'tool')} the server " \
|
|
75
|
+
"registers is protected and guarded (#{report.guarded.size}) or exempted " \
|
|
76
|
+
"(#{report.exempted.size})",
|
|
77
|
+
coverage: :complete
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def incomplete(report)
|
|
82
|
+
failed(
|
|
83
|
+
"expected every tool the server registers to be protected by reeve or " \
|
|
84
|
+
"exempted, but #{gaps(report)}\n\n#{report}",
|
|
85
|
+
coverage: :incomplete
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Complete coverage with a narrowed run is still a subset: the inventory is sound,
|
|
90
|
+
# but this run did not certify all of it.
|
|
91
|
+
def subset(report)
|
|
92
|
+
routed = report.routed_tools
|
|
93
|
+
missing = routed - certified
|
|
94
|
+
lines = [
|
|
95
|
+
"this run certifies a subset, not the endpoint: " \
|
|
96
|
+
"#{(routed - missing).size} of the #{pluralize(routed.size, 'tool')} routed " \
|
|
97
|
+
"through reeve (compliance_tools or tools: narrowed it)"
|
|
98
|
+
]
|
|
99
|
+
lines << "not certified: #{missing.map { |tool| tool.name || tool.inspect }.join(', ')}" \
|
|
100
|
+
unless missing.empty?
|
|
101
|
+
lines << "" << report.to_s unless report.complete?
|
|
102
|
+
|
|
103
|
+
skipped(lines.join("\n"), coverage: :subset)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def gaps(report)
|
|
107
|
+
[
|
|
108
|
+
gap(report.bypassing, "bypasses it", "bypass it"),
|
|
109
|
+
gap(report.unguarded, "is routed through it unguarded",
|
|
110
|
+
"are routed through it unguarded"),
|
|
111
|
+
gap(report.unregistered_entries, "declared in the inventory is not registered",
|
|
112
|
+
"declared in the inventory are not registered")
|
|
113
|
+
].compact.join(", ")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def gap(entries, one, many)
|
|
117
|
+
return nil if entries.empty?
|
|
118
|
+
|
|
119
|
+
"#{entries.size} #{entries.size == 1 ? one : many}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Only this check can come back with nothing established either way.
|
|
123
|
+
def skipped(message, details = {})
|
|
124
|
+
Result.skipped(check: check_name, message: message, details: details)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def base_details
|
|
128
|
+
{}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/reeve/testing/checks.rb
CHANGED
|
@@ -8,10 +8,11 @@ require_relative "checks/rule_present"
|
|
|
8
8
|
require_relative "checks/redaction_holds"
|
|
9
9
|
require_relative "checks/principal_required"
|
|
10
10
|
require_relative "checks/contract_version"
|
|
11
|
+
require_relative "checks/endpoint_coverage"
|
|
11
12
|
|
|
12
13
|
module Reeve
|
|
13
14
|
module Testing
|
|
14
|
-
# The
|
|
15
|
+
# The guarantees, as objects.
|
|
15
16
|
#
|
|
16
17
|
# This is the whole of the testing kit's logic. The RSpec matchers and the Minitest
|
|
17
18
|
# assertions are adapters over it and contain no assertions of their own, which is why
|
|
@@ -29,11 +30,13 @@ module Reeve
|
|
|
29
30
|
RulePresent,
|
|
30
31
|
RedactionHolds,
|
|
31
32
|
PrincipalRequired,
|
|
32
|
-
ContractVersion
|
|
33
|
+
ContractVersion,
|
|
34
|
+
EndpointCoverage
|
|
33
35
|
].freeze
|
|
34
36
|
|
|
35
|
-
# The checks that are asked once about the ledger
|
|
36
|
-
|
|
37
|
+
# The checks that are asked once — about the ledger, or about the endpoint as a
|
|
38
|
+
# whole — rather than once per tool.
|
|
39
|
+
GLOBAL = [ContractVersion, EndpointCoverage].freeze
|
|
37
40
|
|
|
38
41
|
# The compliance suite's engine (FR-018): every check, against every registered
|
|
39
42
|
# guarded tool, in one Report.
|
|
@@ -53,9 +56,12 @@ module Reeve
|
|
|
53
56
|
# test method out of, so that a failing suite names the guarantee that broke rather
|
|
54
57
|
# than reporting "compliance" as one undifferentiated red.
|
|
55
58
|
def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
|
|
56
|
-
|
|
59
|
+
if GLOBAL.include?(check)
|
|
60
|
+
return Report.new([build(check, tool: nil, principals: principals, tools: tools,
|
|
61
|
+
ledger: ledger).call])
|
|
62
|
+
end
|
|
57
63
|
|
|
58
|
-
subjects = tools ||
|
|
64
|
+
subjects = tools || Testing.compliance_tools
|
|
59
65
|
Report.new(
|
|
60
66
|
subjects.map do |tool|
|
|
61
67
|
build(check, tool: tool, principals: principals, arguments: arguments,
|
|
@@ -74,12 +80,17 @@ module Reeve
|
|
|
74
80
|
|
|
75
81
|
# The one place that knows what each check's constructor wants. Front-ends and hosts
|
|
76
82
|
# ask for a check by class and get a configured one back.
|
|
77
|
-
|
|
83
|
+
#
|
|
84
|
+
# +tools+ is the population the run was given, which only EndpointCoverage reads: an
|
|
85
|
+
# explicit list is a narrowed run, and it must say so.
|
|
86
|
+
def self.build(check, tool:, principals:, arguments: {}, invoke: nil, ledger: nil,
|
|
87
|
+
tools: nil)
|
|
78
88
|
common = { tool: tool, arguments: arguments, invoke: invoke, ledger: ledger }
|
|
79
89
|
|
|
80
90
|
case check.check_name
|
|
81
91
|
when "GuardDeclared" then GuardDeclared.new(tool: tool, ledger: ledger)
|
|
82
92
|
when "ContractVersion" then ContractVersion.new(ledger: ledger)
|
|
93
|
+
when "EndpointCoverage" then EndpointCoverage.new(tools: tools, ledger: ledger)
|
|
83
94
|
when "PrincipalRequired" then PrincipalRequired.new(**common)
|
|
84
95
|
when "CrossPrincipalLeak" then CrossPrincipalLeak.new(principals: Array(principals),
|
|
85
96
|
**common)
|
|
@@ -15,7 +15,8 @@ module Reeve
|
|
|
15
15
|
#
|
|
16
16
|
# One method per check, matching the RSpec shared example group one for one, so a red
|
|
17
17
|
# build names the guarantee that broke rather than reporting "compliance" as one
|
|
18
|
-
# undifferentiated failure.
|
|
18
|
+
# undifferentiated failure. A check that could establish nothing either way is
|
|
19
|
+
# skipped with its reason, never passed.
|
|
19
20
|
module ComplianceAssertions
|
|
20
21
|
include Assertions
|
|
21
22
|
|
|
@@ -25,6 +26,7 @@ module Reeve
|
|
|
25
26
|
|
|
26
27
|
define_method(method_name) do
|
|
27
28
|
report = Checks.run(check, principals: Testing.compliance_principals)
|
|
29
|
+
skip(report.to_s) if report.skipped?
|
|
28
30
|
assert report.passed?, report.to_s
|
|
29
31
|
report
|
|
30
32
|
end
|
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
# Reeve::Testing.compliance_principals = -> { [users(:alice), users(:bob)] }
|
|
12
12
|
#
|
|
13
13
|
# One example per check rather than one for the lot, so a red build names the guarantee
|
|
14
|
-
# that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug.
|
|
14
|
+
# that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug. A check that
|
|
15
|
+
# could establish nothing either way (EndpointCoverage with no inventory) is skipped, with
|
|
16
|
+
# its reason, rather than passed.
|
|
15
17
|
RSpec.shared_examples "a reeve-compliant server" do
|
|
16
18
|
Reeve::Checks::ALL.each do |check|
|
|
17
19
|
it "satisfies #{check.check_name} for every registered guarded tool" do
|
|
18
20
|
report = Reeve::Checks.run(check, principals: Reeve::Testing.compliance_principals)
|
|
21
|
+
skip(report.to_s) if report.skipped?
|
|
19
22
|
|
|
20
23
|
expect(report).to be_passed, report.to_s
|
|
21
24
|
end
|