reeve 0.4.0 → 0.6.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 +176 -1
- data/README.md +202 -12
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +41 -3
- data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
- data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
- data/lib/generators/reeve/upgrade/upgrade_generator.rb +40 -2
- data/lib/reeve/audit/chain/canonical.rb +129 -0
- data/lib/reeve/audit/chain/head.rb +94 -0
- data/lib/reeve/audit/chain/keyring.rb +53 -0
- data/lib/reeve/audit/chain/normalizer.rb +88 -0
- data/lib/reeve/audit/chain/report.rb +134 -0
- data/lib/reeve/audit/chain/verification.rb +110 -0
- data/lib/reeve/audit/chain/verifier.rb +160 -0
- data/lib/reeve/audit/chain/walk.rb +123 -0
- data/lib/reeve/audit/chain.rb +168 -0
- data/lib/reeve/audit/chain_record.rb +61 -0
- data/lib/reeve/audit/isolated_recorder.rb +5 -0
- data/lib/reeve/audit/recorder.rb +32 -19
- data/lib/reeve/audit.rb +24 -4
- data/lib/reeve/authorization.rb +1 -11
- data/lib/reeve/configuration.rb +90 -2
- data/lib/reeve/decision.rb +6 -1
- data/lib/reeve/errors.rb +28 -0
- data/lib/reeve/inventory/report.rb +146 -0
- data/lib/reeve/inventory.rb +212 -0
- data/lib/reeve/invocation.rb +3 -1
- data/lib/reeve/ledger_key.rb +47 -0
- data/lib/reeve/railtie.rb +15 -0
- data/lib/reeve/tasks.rb +62 -0
- data/lib/reeve/testing/assertions.rb +2 -2
- data/lib/reeve/testing/checks/contract_version.rb +2 -1
- data/lib/reeve/testing/checks/endpoint_coverage.rb +133 -0
- data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
- data/lib/reeve/testing/checks.rb +43 -7
- data/lib/reeve/testing/compliance_assertions.rb +4 -1
- data/lib/reeve/testing/compliance_suite.rb +4 -1
- data/lib/reeve/testing/ledger.rb +11 -1
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +1 -1
- data/lib/reeve/testing/report.rb +30 -6
- data/lib/reeve/testing/result.rb +28 -8
- data/lib/reeve/testing.rb +20 -4
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +3 -0
- metadata +20 -2
data/lib/reeve/authorization.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Reeve
|
|
|
42
42
|
registry: registry,
|
|
43
43
|
authorizer: Authorization::Authorizer.new,
|
|
44
44
|
scoper: Authorization::Scoper.new,
|
|
45
|
-
config:
|
|
45
|
+
config: config.with_principal(principal)
|
|
46
46
|
) { body ? body.call : run(tool, arguments) }
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -62,15 +62,5 @@ module Reeve
|
|
|
62
62
|
|
|
63
63
|
Authorization::Declaration.new(tool_class: klass, policy: nil, action: :index).tool_name
|
|
64
64
|
end
|
|
65
|
-
|
|
66
|
-
# An explicitly supplied principal is just a resolver that returns it. Routing it
|
|
67
|
-
# through the same resolution step rather than around it keeps one answer to "where
|
|
68
|
-
# did this principal come from" — the envelope still resolves, records and denies
|
|
69
|
-
# identically, and a nil passed in still denies with `no_principal`.
|
|
70
|
-
def configuration_for(principal)
|
|
71
|
-
return config if principal == :unset
|
|
72
|
-
|
|
73
|
-
config.dup.tap { |scoped| scoped.principal_resolver = ->(_context) { principal } }
|
|
74
|
-
end
|
|
75
65
|
end
|
|
76
66
|
end
|
data/lib/reeve/configuration.rb
CHANGED
|
@@ -24,9 +24,13 @@ module Reeve
|
|
|
24
24
|
SETTINGS = %i[
|
|
25
25
|
principal_resolver unguarded_tools audit_failure_mode redact_arguments
|
|
26
26
|
max_recorded_ids policy_adapter default_action audit_recorder logger
|
|
27
|
-
compliance_principals compliance_tools
|
|
27
|
+
compliance_principals compliance_tools inventory ledger_key retired_ledger_keys
|
|
28
28
|
].freeze
|
|
29
29
|
|
|
30
|
+
# Shown by #to_h and #inspect as fingerprints. A configuration object gets printed —
|
|
31
|
+
# in a console, a bug report, a failing spec — and a secret must not travel with it.
|
|
32
|
+
SECRET_SETTINGS = %i[ledger_key retired_ledger_keys].freeze
|
|
33
|
+
|
|
30
34
|
attr_reader(*SETTINGS)
|
|
31
35
|
|
|
32
36
|
def initialize
|
|
@@ -41,6 +45,71 @@ module Reeve
|
|
|
41
45
|
@logger = nil
|
|
42
46
|
@compliance_principals = nil
|
|
43
47
|
@compliance_tools = nil
|
|
48
|
+
@inventory = nil
|
|
49
|
+
@ledger_key = nil
|
|
50
|
+
@retired_ledger_keys = []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The key that signs the audit ledger's chain (specs/002). Held in configuration, never
|
|
54
|
+
# in the database: that separation is the whole of the tamper-evidence guarantee.
|
|
55
|
+
# Validated here so a short key fails on the initializer line that set it.
|
|
56
|
+
#
|
|
57
|
+
# config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
|
|
58
|
+
def ledger_key=(secret)
|
|
59
|
+
unless secret.nil?
|
|
60
|
+
LedgerKey.validate!(secret, :ledger_key)
|
|
61
|
+
if @retired_ledger_keys.include?(secret)
|
|
62
|
+
raise ArgumentError, "ledger_key is also listed in retired_ledger_keys; a key " \
|
|
63
|
+
"either signs or is retired, not both"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@ledger_key = secret
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Keys that signed older entries, kept so those entries still verify. Never used to
|
|
71
|
+
# sign. Removing one leaves the entries it signed unverifiable — reported as such,
|
|
72
|
+
# never as tampered.
|
|
73
|
+
def retired_ledger_keys=(secrets)
|
|
74
|
+
unless secrets.is_a?(Array)
|
|
75
|
+
raise ArgumentError, "retired_ledger_keys must be an Array of keys, got a #{secrets.class}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
secrets.each { |secret| LedgerKey.validate!(secret, :retired_ledger_keys) }
|
|
79
|
+
if !@ledger_key.nil? && secrets.include?(@ledger_key)
|
|
80
|
+
raise ArgumentError, "retired_ledger_keys includes the current ledger_key; a key " \
|
|
81
|
+
"either signs or is retired, not both"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
@retired_ledger_keys = secrets.dup.freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The fingerprint stored on every row this configuration signs, or nil with no key.
|
|
88
|
+
def ledger_key_id
|
|
89
|
+
@ledger_key && LedgerKey.fingerprint(@ledger_key)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The host's whole tool inventory (Reeve::Inventory). Optional. Setting it is what lets
|
|
93
|
+
# the compliance suite say whether it covered the endpoint or only the tools reeve
|
|
94
|
+
# happened to be told about.
|
|
95
|
+
def inventory=(inventory)
|
|
96
|
+
unless inventory.nil? || inventory.is_a?(Inventory)
|
|
97
|
+
raise ArgumentError, "inventory must be a Reeve::Inventory, got #{inventory.inspect}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
@inventory = inventory
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# This configuration with the principal fixed for one invocation.
|
|
104
|
+
#
|
|
105
|
+
# An explicitly supplied principal is just a resolver that returns it. Routing it
|
|
106
|
+
# through the same resolution step rather than around it keeps one answer to "where
|
|
107
|
+
# did this principal come from" — the envelope still resolves, records and denies
|
|
108
|
+
# identically, and a nil passed in still denies with `no_principal`.
|
|
109
|
+
def with_principal(principal)
|
|
110
|
+
return self if principal == :unset
|
|
111
|
+
|
|
112
|
+
dup.tap { |scoped| scoped.principal_resolver = ->(_context) { principal } }
|
|
44
113
|
end
|
|
45
114
|
|
|
46
115
|
# Two fixture principals with disjoint records — the only host setup the compliance
|
|
@@ -139,11 +208,30 @@ module Reeve
|
|
|
139
208
|
end
|
|
140
209
|
|
|
141
210
|
def to_h
|
|
142
|
-
SETTINGS.to_h { |setting| [setting,
|
|
211
|
+
SETTINGS.to_h { |setting| [setting, shown(setting)] }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def inspect
|
|
215
|
+
"#<#{self.class.name} #{to_h.map do |setting, value|
|
|
216
|
+
"#{setting}=#{value.inspect}"
|
|
217
|
+
end.join(', ')}>"
|
|
143
218
|
end
|
|
144
219
|
|
|
145
220
|
private
|
|
146
221
|
|
|
222
|
+
def shown(setting)
|
|
223
|
+
value = public_send(setting)
|
|
224
|
+
return value unless SECRET_SETTINGS.include?(setting)
|
|
225
|
+
|
|
226
|
+
if value.is_a?(Array)
|
|
227
|
+
value.map do |secret|
|
|
228
|
+
LedgerKey.fingerprint(secret)
|
|
229
|
+
end
|
|
230
|
+
else
|
|
231
|
+
value && LedgerKey.fingerprint(value)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
147
235
|
def require_one_of!(setting, value, allowed)
|
|
148
236
|
symbol = value.is_a?(Symbol) ? value : nil
|
|
149
237
|
return symbol if allowed.include?(symbol)
|
data/lib/reeve/decision.rb
CHANGED
|
@@ -18,6 +18,10 @@ module Reeve
|
|
|
18
18
|
# ledger records it as a deny — the trace of a call that blew up is the one most
|
|
19
19
|
# worth having (R5).
|
|
20
20
|
TOOL_ERROR = "tool_error"
|
|
21
|
+
# A name dispatched through an Inventory that neither routes it through reeve nor
|
|
22
|
+
# exempts it. Not `no_guard_declared`: there may be no class to declare one on, and
|
|
23
|
+
# the fix is in the inventory.
|
|
24
|
+
UNBOUND_TOOL = "unbound_tool"
|
|
21
25
|
|
|
22
26
|
# The one reserved *allow* rule: a tool with no guard, permitted because the host
|
|
23
27
|
# opted into :allow_with_warning. Kept out of RESERVED_RULES, which names deny paths.
|
|
@@ -33,7 +37,8 @@ module Reeve
|
|
|
33
37
|
UNSCOPED_DERIVED_RESULT,
|
|
34
38
|
OUT_OF_SCOPE_RECORD,
|
|
35
39
|
AUDIT_WRITE_FAILED,
|
|
36
|
-
TOOL_ERROR
|
|
40
|
+
TOOL_ERROR,
|
|
41
|
+
UNBOUND_TOOL
|
|
37
42
|
].freeze
|
|
38
43
|
|
|
39
44
|
OUTCOMES = %i[allow deny].freeze
|
data/lib/reeve/errors.rb
CHANGED
|
@@ -57,6 +57,34 @@ module Reeve
|
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
# Raised by Inventory#verify! when a registered tool is not protected: routed through
|
|
61
|
+
# reeve with no guard, not routed at all, or declared in the inventory but not registered
|
|
62
|
+
# with the server. The message is the whole report — tool names, classes and exemption
|
|
63
|
+
# reasons, never an argument, a principal or a record.
|
|
64
|
+
class IncompleteInventoryError < Error
|
|
65
|
+
attr_reader :report
|
|
66
|
+
|
|
67
|
+
def initialize(report)
|
|
68
|
+
@report = report
|
|
69
|
+
super(report.to_s)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Raised when a chained ledger write or a verification has no key to use (specs/002
|
|
74
|
+
# FR-010). On the invocation path the envelope wraps it in AuditWriteError, exactly as it
|
|
75
|
+
# wraps any other ledger failure, so a host rescues one class either way.
|
|
76
|
+
class LedgerKeyError < Error
|
|
77
|
+
def initialize(message = nil)
|
|
78
|
+
super(message || <<~MESSAGE.chomp)
|
|
79
|
+
no ledger key is configured, so the audit ledger cannot be chained. Set one in
|
|
80
|
+
config/initializers/reeve.rb — derived from secret_key_base, it works in every
|
|
81
|
+
environment with no new secret:
|
|
82
|
+
|
|
83
|
+
config.ledger_key = Rails.application.key_generator.generate_key("reeve audit ledger", 32)
|
|
84
|
+
MESSAGE
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
60
88
|
# Raised when the ledger write fails and audit_failure_mode is :fail (FR-012).
|
|
61
89
|
#
|
|
62
90
|
# It carries a rule like any other denial, because the failure is not recorded anywhere
|
|
@@ -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?
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
# What makes a ledger key acceptable, and the name a key goes by (specs/002, research R7).
|
|
5
|
+
#
|
|
6
|
+
# Core rather than under reeve/audit, because configuration validates keys at assignment
|
|
7
|
+
# and configuration loads without ActiveRecord. Validation needs nothing beyond Ruby;
|
|
8
|
+
# `openssl` is required only when a fingerprint is computed, so `require "reeve"` stays on
|
|
9
|
+
# its short stdlib allowlist (spec/load_safety_spec.rb) and a host that never configures
|
|
10
|
+
# a key never loads it.
|
|
11
|
+
module LedgerKey
|
|
12
|
+
# HMAC-SHA256 takes a key of any length, but one shorter than the hash's own output
|
|
13
|
+
# weakens it. Bytes, not characters: that is what the HMAC sees.
|
|
14
|
+
MINIMUM_BYTES = 32
|
|
15
|
+
|
|
16
|
+
FINGERPRINT_LABEL = "reeve-ledger-key-id"
|
|
17
|
+
FINGERPRINT_LENGTH = 16
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The identifier stored on each chained row in place of the key. Derived from the key
|
|
22
|
+
# rather than chosen by the host, so an identifier can never be pinned to the wrong
|
|
23
|
+
# secret, and a renamed key cannot orphan the history it signed. An HMAC output, so it
|
|
24
|
+
# says nothing about the key it names.
|
|
25
|
+
def fingerprint(secret)
|
|
26
|
+
require "openssl"
|
|
27
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, FINGERPRINT_LABEL)[0, FINGERPRINT_LENGTH]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Raises ArgumentError naming the setting and the rule. Never the value: a key that is
|
|
31
|
+
# too short is still a secret, and error messages end up in logs.
|
|
32
|
+
def validate!(secret, setting)
|
|
33
|
+
unless secret.is_a?(String)
|
|
34
|
+
raise ArgumentError, "#{setting} must be a String of at least #{MINIMUM_BYTES} " \
|
|
35
|
+
"bytes, got a #{secret.class}"
|
|
36
|
+
end
|
|
37
|
+
if secret.strip.empty?
|
|
38
|
+
raise ArgumentError, "#{setting} must not be blank; it must be at least " \
|
|
39
|
+
"#{MINIMUM_BYTES} bytes"
|
|
40
|
+
end
|
|
41
|
+
return secret if secret.bytesize >= MINIMUM_BYTES
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "#{setting} must be at least #{MINIMUM_BYTES} bytes, got " \
|
|
44
|
+
"#{secret.bytesize}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
# Registers `bin/rails reeve:verify_ledger`, and does nothing else.
|
|
5
|
+
#
|
|
6
|
+
# Loaded by lib/reeve.rb only when Rails::Railtie is already defined, so `require "reeve"`
|
|
7
|
+
# in a plain Ruby process still loads nothing beyond reeve's own files and its short stdlib
|
|
8
|
+
# allowlist (spec/load_safety_spec.rb).
|
|
9
|
+
class Railtie < ::Rails::Railtie
|
|
10
|
+
rake_tasks do
|
|
11
|
+
require "reeve/tasks"
|
|
12
|
+
Reeve::Tasks.define
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|