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.
@@ -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"
@@ -90,7 +90,9 @@ module Reeve
90
90
  context.principal = principal
91
91
 
92
92
  guard = look_up_guard
93
- return guard if guard.is_a?(Decision) # registry blew up
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 of the seven, straight from Minitest.
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 seven.
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
@@ -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 seven guarantees, as objects.
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 rather than once per tool.
36
- GLOBAL = [ContractVersion].freeze
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
- return Report.new([check.new(ledger: ledger).call]) if GLOBAL.include?(check)
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 || Reeve.registry.map(&:tool_class)
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
- def self.build(check, tool:, principals:, arguments: {}, invoke: nil, ledger: nil)
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