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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +176 -1
  3. data/README.md +202 -12
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +41 -3
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
  6. data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
  7. data/lib/generators/reeve/upgrade/upgrade_generator.rb +40 -2
  8. data/lib/reeve/audit/chain/canonical.rb +129 -0
  9. data/lib/reeve/audit/chain/head.rb +94 -0
  10. data/lib/reeve/audit/chain/keyring.rb +53 -0
  11. data/lib/reeve/audit/chain/normalizer.rb +88 -0
  12. data/lib/reeve/audit/chain/report.rb +134 -0
  13. data/lib/reeve/audit/chain/verification.rb +110 -0
  14. data/lib/reeve/audit/chain/verifier.rb +160 -0
  15. data/lib/reeve/audit/chain/walk.rb +123 -0
  16. data/lib/reeve/audit/chain.rb +168 -0
  17. data/lib/reeve/audit/chain_record.rb +61 -0
  18. data/lib/reeve/audit/isolated_recorder.rb +5 -0
  19. data/lib/reeve/audit/recorder.rb +32 -19
  20. data/lib/reeve/audit.rb +24 -4
  21. data/lib/reeve/authorization.rb +1 -11
  22. data/lib/reeve/configuration.rb +90 -2
  23. data/lib/reeve/decision.rb +6 -1
  24. data/lib/reeve/errors.rb +28 -0
  25. data/lib/reeve/inventory/report.rb +146 -0
  26. data/lib/reeve/inventory.rb +212 -0
  27. data/lib/reeve/invocation.rb +3 -1
  28. data/lib/reeve/ledger_key.rb +47 -0
  29. data/lib/reeve/railtie.rb +15 -0
  30. data/lib/reeve/tasks.rb +62 -0
  31. data/lib/reeve/testing/assertions.rb +2 -2
  32. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  33. data/lib/reeve/testing/checks/endpoint_coverage.rb +133 -0
  34. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  35. data/lib/reeve/testing/checks.rb +43 -7
  36. data/lib/reeve/testing/compliance_assertions.rb +4 -1
  37. data/lib/reeve/testing/compliance_suite.rb +4 -1
  38. data/lib/reeve/testing/ledger.rb +11 -1
  39. data/lib/reeve/testing/matchers/pass_reeve_check.rb +1 -1
  40. data/lib/reeve/testing/report.rb +30 -6
  41. data/lib/reeve/testing/result.rb +28 -8
  42. data/lib/reeve/testing.rb +20 -4
  43. data/lib/reeve/version.rb +1 -1
  44. data/lib/reeve.rb +3 -0
  45. metadata +20 -2
@@ -42,7 +42,7 @@ module Reeve
42
42
  registry: registry,
43
43
  authorizer: Authorization::Authorizer.new,
44
44
  scoper: Authorization::Scoper.new,
45
- config: configuration_for(principal)
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
@@ -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, public_send(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)
@@ -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"
@@ -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?
@@ -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