reeve 0.5.0 → 0.7.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +281 -3
  3. data/README.md +151 -6
  4. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +63 -3
  5. data/lib/generators/reeve/install/templates/initializer.rb.tt +20 -0
  6. data/lib/generators/reeve/upgrade/templates/add_key_epochs_to_reeve_audit_entries.rb.tt +35 -0
  7. data/lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt +51 -0
  8. data/lib/generators/reeve/upgrade/upgrade_generator.rb +25 -2
  9. data/lib/generators/reeve/upgrade/upgrade_notes.rb +51 -0
  10. data/lib/reeve/audit/chain/canonical.rb +144 -0
  11. data/lib/reeve/audit/chain/epoch_rules.rb +159 -0
  12. data/lib/reeve/audit/chain/epochs.rb +77 -0
  13. data/lib/reeve/audit/chain/head.rb +122 -0
  14. data/lib/reeve/audit/chain/keyring.rb +53 -0
  15. data/lib/reeve/audit/chain/normalizer.rb +88 -0
  16. data/lib/reeve/audit/chain/report.rb +177 -0
  17. data/lib/reeve/audit/chain/rotation.rb +124 -0
  18. data/lib/reeve/audit/chain/schema.rb +59 -0
  19. data/lib/reeve/audit/chain/verification.rb +131 -0
  20. data/lib/reeve/audit/chain/verifier.rb +168 -0
  21. data/lib/reeve/audit/chain/walk.rb +137 -0
  22. data/lib/reeve/audit/chain.rb +207 -0
  23. data/lib/reeve/audit/chain_epoch.rb +24 -0
  24. data/lib/reeve/audit/chain_record.rb +66 -0
  25. data/lib/reeve/audit/isolated_recorder.rb +5 -0
  26. data/lib/reeve/audit/recorder.rb +32 -19
  27. data/lib/reeve/audit.rb +37 -6
  28. data/lib/reeve/configuration.rb +66 -2
  29. data/lib/reeve/errors.rb +15 -0
  30. data/lib/reeve/ledger_key.rb +47 -0
  31. data/lib/reeve/railtie.rb +16 -0
  32. data/lib/reeve/tasks.rb +91 -0
  33. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  34. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  35. data/lib/reeve/testing/checks.rb +29 -4
  36. data/lib/reeve/testing/compliance_assertions.rb +3 -2
  37. data/lib/reeve/testing/compliance_suite.rb +2 -2
  38. data/lib/reeve/testing/ledger.rb +11 -1
  39. data/lib/reeve/testing/report.rb +8 -0
  40. data/lib/reeve/testing.rb +1 -1
  41. data/lib/reeve/version.rb +1 -1
  42. data/lib/reeve.rb +2 -0
  43. metadata +24 -2
@@ -10,9 +10,10 @@ module Reeve
10
10
  # ActiveJob. An asynchronous ledger cannot be relied on and would make FR-012
11
11
  # unenforceable.
12
12
  #
13
- # The insert runs in `requires_new: true`, which is what makes the trace survive a
14
- # tool body that opens a transaction and rolls it back (R5) — the case this was built
15
- # for, and the one it does solve.
13
+ # Every row goes through Chain#append! (specs/002), which links it to the row before it
14
+ # and runs the insert in `requires_new: true` — what makes the trace survive a tool body
15
+ # that opens a transaction and rolls it back (R5), the case this was built for and the
16
+ # one it does solve.
16
17
  #
17
18
  # What it does **not** do, corrected after review: `requires_new` is a SAVEPOINT, not
18
19
  # an independent transaction. If the host has already opened a transaction *around*
@@ -54,20 +55,28 @@ module Reeve
54
55
 
55
56
  # Returns the entry. Raises if the row could not be written, so the envelope can
56
57
  # fail the invocation.
58
+ #
59
+ # A replayed invocation is the same invocation: one row per invocation_id means a
60
+ # retry returns the existing row and never writes a second (contracts/audit-entry.md).
61
+ # Chain#append! owns that, because it must also consume no sequence number.
62
+ #
63
+ # With no ledger key (specs/002 FR-010): under :fail, Chain raises LedgerKeyError and
64
+ # the envelope fails the call like any other ledger failure. Under :warn the row is
65
+ # still written, at contract 3 and unchained, and the log says so every time —
66
+ # :warn keeps calls running, it does not drop traces, and verification reports the
67
+ # row as unchained rather than letting it pass unseen.
57
68
  def record(attributes)
58
69
  row = row_for(attributes)
59
70
  warn_about_enclosing_transaction(row[:invocation_id])
60
71
 
61
- entry_class.transaction(requires_new: true) do
62
- entry_class.create!(row)
63
- end
64
- rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
65
- # A replayed invocation is the same invocation. One row per invocation_id means
66
- # a retry is a no-op insert, never a second row (contracts/audit-entry.md).
67
- existing = entry_class.find_by(invocation_id: row[:invocation_id])
68
- raise e if existing.nil?
72
+ keyring = Chain::Keyring.from(config)
73
+ chain = Chain.new(entry_class: entry_class, keyring: keyring)
74
+ return chain.append!(row) if keyring.signing? || config.audit_failure_mode == :fail
69
75
 
70
- existing
76
+ warn("reeve: invocation #{row[:invocation_id]} was recorded unchained: no ledger key is " \
77
+ "configured and audit_failure_mode is :warn. Verification will report the row as " \
78
+ "unchained. Set config.ledger_key to chain the ledger.")
79
+ chain.append_unchained!(row)
71
80
  end
72
81
 
73
82
  private
@@ -78,6 +87,11 @@ module Reeve
78
87
  @config || Reeve.config
79
88
  end
80
89
 
90
+ def warn(message)
91
+ logger = config.logger
92
+ logger ? logger.warn(message) : Kernel.warn(message)
93
+ end
94
+
81
95
  def row_for(attributes)
82
96
  ids, count, truncated = identifiers(attributes)
83
97
 
@@ -163,13 +177,12 @@ module Reeve
163
177
  def warn_about_enclosing_transaction(invocation_id)
164
178
  return unless enclosing_transaction?
165
179
 
166
- message = "reeve: invocation #{invocation_id} was recorded inside a transaction " \
167
- "the host opened around it, so the ledger row will be rolled back with " \
168
- "it. Set Reeve.config.audit_recorder = Reeve::Audit::IsolatedRecorder " \
169
- "to write on a connection the transaction cannot reach (needs a " \
170
- "database with concurrent writers; not SQLite)."
171
- logger = config.logger
172
- logger ? logger.warn(message) : Kernel.warn(message)
180
+ warn("reeve: invocation #{invocation_id} was recorded inside a transaction " \
181
+ "the host opened around it, so the ledger row will be rolled back with " \
182
+ "it, and the ledger chain stays locked until it ends, so every other " \
183
+ "guarded call waits behind it. Set Reeve.config.audit_recorder = " \
184
+ "Reeve::Audit::IsolatedRecorder to write on a connection the transaction " \
185
+ "cannot reach (needs a database with concurrent writers; not SQLite).")
173
186
  end
174
187
 
175
188
  def enclosing_transaction?
data/lib/reeve/audit.rb CHANGED
@@ -44,9 +44,14 @@ module Reeve
44
44
  # documents the `GRANT INSERT, SELECT` that enforces the rest where it can actually be
45
45
  # enforced, and the gem makes no stronger claim than that.
46
46
  # * No retention, rotation or archival yet — the table is host-owned and the host's
47
- # existing policies apply.
48
- # * No cryptographic chaining or tamper-evidence yet. If that lands it arrives as a
49
- # nullable column, which the audit-entry contract's versioning already permits.
47
+ # existing policies apply. Deleting rows breaks the chain, by design.
48
+ # * Tamper-evidence, not tamper-proofing. Every row is chained to the one before it
49
+ # (Reeve::Audit::Chain, contract 3), so a change made outside the library is reported
50
+ # by verification — after the fact; it prevents nothing. It does not detect a change
51
+ # made by someone holding the ledger key, the newest rows deleted by a role that can
52
+ # also rewind the chain record's head, the whole ledger deleted, or anything about
53
+ # rows written before contract 3. specs/001-guardrails-core/contracts/audit-entry.md,
54
+ # guarantee 9, is the authoritative statement.
50
55
  #
51
56
  # ("yet" rather than "in v1": the gem version and the audit-entry contract version are
52
57
  # different numbers that move independently, and writing v1 for one of them read as the
@@ -54,14 +59,25 @@ module Reeve
54
59
  module Audit
55
60
  # The version of the audit-entry shape, as documented in
56
61
  # specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
57
- # column is a MINOR change and leaves this alone; removing or renaming a column, or
58
- # changing what a value means, is MAJOR and bumps it.
62
+ # column is a MINOR change and leaves this alone — unless what its absence means, or
63
+ # what an existing value covers, differs between rows written before and after it;
64
+ # removing or renaming a column, or changing what a value means, is MAJOR and bumps it.
65
+ #
66
+ # 4 — entries name their key epoch (specs/003). One nullable column, `chain_epoch`, but
67
+ # it is inside the digest, so `chain_digest` on a contract-4 row covers something a
68
+ # contract-3 digest does not, and a NULL epoch means "before key epochs" on a contract-3
69
+ # row where a chained contract-4 row always has one.
70
+ #
71
+ # 3 — entries are chained (specs/002). The three chain columns are nullable, so the
72
+ # shape change is additive, but what a NULL digest means changed: on a contract-3 row
73
+ # it is a row written unchained, which verification reports, where on an older row it
74
+ # only means the row predates chaining.
59
75
  #
60
76
  # 2 — `metadata` carries the transport detail the caller passed. Through version 1 it
61
77
  # was written NULL on every row regardless of what was passed, so anything mapping
62
78
  # version 1 rows could reasonably have read the column as "always empty". The shape
63
79
  # did not change; what a value means did.
64
- CONTRACT_VERSION = 2
80
+ CONTRACT_VERSION = 4
65
81
 
66
82
  TABLE_NAME = "reeve_audit_entries"
67
83
 
@@ -94,5 +110,20 @@ require_relative "audit/entry"
94
110
  require_relative "audit/recorder"
95
111
  require_relative "audit/isolated_recorder"
96
112
  require_relative "audit/query"
113
+ require_relative "audit/chain/keyring"
114
+ require_relative "audit/chain/normalizer"
115
+ require_relative "audit/chain/canonical"
116
+ require_relative "audit/chain_record"
117
+ require_relative "audit/chain_epoch"
118
+ require_relative "audit/chain/verification"
119
+ require_relative "audit/chain/report"
120
+ require_relative "audit/chain/head"
121
+ require_relative "audit/chain/epochs"
122
+ require_relative "audit/chain/rotation"
123
+ require_relative "audit/chain/epoch_rules"
124
+ require_relative "audit/chain/schema"
125
+ require_relative "audit/chain/walk"
126
+ require_relative "audit/chain/verifier"
127
+ require_relative "audit/chain"
97
128
 
98
129
  Reeve::Audit.install!
@@ -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 inventory
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
@@ -42,6 +46,47 @@ module Reeve
42
46
  @compliance_principals = nil
43
47
  @compliance_tools = nil
44
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)
45
90
  end
46
91
 
47
92
  # The host's whole tool inventory (Reeve::Inventory). Optional. Setting it is what lets
@@ -163,11 +208,30 @@ module Reeve
163
208
  end
164
209
 
165
210
  def to_h
166
- 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(', ')}>"
167
218
  end
168
219
 
169
220
  private
170
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
+
171
235
  def require_one_of!(setting, value, allowed)
172
236
  symbol = value.is_a?(Symbol) ? value : nil
173
237
  return symbol if allowed.include?(symbol)
data/lib/reeve/errors.rb CHANGED
@@ -70,6 +70,21 @@ module Reeve
70
70
  end
71
71
  end
72
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
+
73
88
  # Raised when the ledger write fails and audit_failure_mode is :fail (FR-012).
74
89
  #
75
90
  # It carries a rule like any other denial, because the failure is not recorded anywhere
@@ -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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # Registers `bin/rails reeve:verify_ledger` and `bin/rails reeve:rotate_ledger_key`, and
5
+ # does nothing else.
6
+ #
7
+ # Loaded by lib/reeve.rb only when Rails::Railtie is already defined, so `require "reeve"`
8
+ # in a plain Ruby process still loads nothing beyond reeve's own files and its short stdlib
9
+ # allowlist (spec/load_safety_spec.rb).
10
+ class Railtie < ::Rails::Railtie
11
+ rake_tasks do
12
+ require "reeve/tasks"
13
+ Reeve::Tasks.define
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Reeve
6
+ # `bin/rails reeve:verify_ledger` (specs/002 FR-017, contracts/ledger-chain.md "Rake").
7
+ #
8
+ # A Ruby file rather than a .rake one on purpose: reeve.gemspec packages lib/**/*.rb, and a
9
+ # .rake file would pass every spec in the working tree and be missing from the gem users
10
+ # install. Registered by Reeve::Railtie; callable without Rake for anything else that
11
+ # wants the same answer — a deploy gate, a scheduled job, a console.
12
+ #
13
+ # status = Reeve::Tasks.verify_ledger(env: { "LIMIT" => "0" })
14
+ module Tasks
15
+ EXIT = { intact: 0, broken: 1, unverifiable: 2, no_chain: 2 }.freeze
16
+ FORMATS = %w[text json].freeze
17
+ ROTATE_EXIT = { rotated: 0, current: 0, refused: 1 }.freeze
18
+
19
+ module_function
20
+
21
+ # Verifies the ledger, writes the report to `out`, and returns the exit status:
22
+ # 0 intact, 1 broken, 2 could not verify (unverifiable, or no chain).
23
+ #
24
+ # LIMIT caps the breaks listed in the text report (default 100; the totals are never
25
+ # capped). FORMAT=json writes the whole result, every break, and ignores LIMIT.
26
+ def verify_ledger(env: ENV, out: $stdout)
27
+ format = env.fetch("FORMAT", "text")
28
+ unless FORMATS.include?(format)
29
+ raise ArgumentError, "FORMAT must be one of #{FORMATS.join(', ')}, got #{format.inspect}"
30
+ end
31
+
32
+ limit = limit_from(env)
33
+ require "reeve/audit"
34
+ result = Reeve::Audit::Chain.verify
35
+ out.puts(format == "json" ? JSON.generate(result.as_json) : result.to_s(limit: limit))
36
+ EXIT.fetch(result.status)
37
+ end
38
+
39
+ # Makes the configured ledger key the latest epoch's key (specs/003, contracts/
40
+ # key-rotation.md), writes what happened to `out`, and returns the exit status: 0 rotated
41
+ # or already current, 1 refused.
42
+ #
43
+ # ACCEPT_UNVERIFIABLE=1 opens the epoch even though the outgoing epoch's key is not
44
+ # configured here — for a key that is lost, and only then.
45
+ def rotate_ledger_key(env: ENV, out: $stdout)
46
+ accept = accept_unverifiable_from(env)
47
+ require "reeve/audit"
48
+ rotation = Reeve::Audit::Chain.rotate_key!(accept_unverifiable: accept)
49
+ out.puts(rotation.to_s)
50
+ ROTATE_EXIT.fetch(rotation.status)
51
+ end
52
+
53
+ def accept_unverifiable_from(env)
54
+ raw = env.fetch("ACCEPT_UNVERIFIABLE", "")
55
+ return false if raw.empty?
56
+ return true if raw == "1"
57
+
58
+ raise ArgumentError, "ACCEPT_UNVERIFIABLE must be 1 or unset, got #{raw.inspect}"
59
+ end
60
+
61
+ def limit_from(env)
62
+ raw = env.fetch("LIMIT", Reeve::Audit::Chain::Verification::DEFAULT_LIMIT.to_s)
63
+ Integer(raw, 10).tap { |limit| raise ArgumentError if limit.negative? }
64
+ rescue ArgumentError, TypeError
65
+ raise ArgumentError, "LIMIT must be a whole number of breaks to list, got #{raw.inspect}"
66
+ end
67
+
68
+ # The Rake task, defined on the current Rake application. Depends on :environment, so
69
+ # the host's initializer — and with it the ledger key — is loaded first.
70
+ #
71
+ # The DSL is borrowed by a throwaway object, so Reeve::Tasks itself never gains `task`,
72
+ # `sh` and the rest of Rake's methods.
73
+ def define
74
+ Object.new.extend(Rake::DSL).instance_exec do
75
+ namespace :reeve do
76
+ desc "Verify the audit ledger's chain " \
77
+ "(exit 0 intact, 1 broken, 2 could not verify; LIMIT=, FORMAT=json)"
78
+ task verify_ledger: :environment do
79
+ exit Reeve::Tasks.verify_ledger
80
+ end
81
+
82
+ desc "Make config.ledger_key the ledger's current key epoch (exit 0 rotated or " \
83
+ "current, 1 refused; ACCEPT_UNVERIFIABLE=1 if the old key is lost)"
84
+ task rotate_ledger_key: :environment do
85
+ exit Reeve::Tasks.rotate_ledger_key
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -46,9 +46,10 @@ module Reeve
46
46
  invocation_id occurred_at agent_id agent_name principal_type principal_id
47
47
  tool_name arguments outcome rule detail record_type record_ids record_count
48
48
  truncated derived guard duration_ms metadata contract_version
49
+ chain_sequence chain_key_id chain_digest chain_epoch
49
50
  ].freeze
50
51
 
51
- EXPECTED_VERSION = 2
52
+ EXPECTED_VERSION = 4
52
53
 
53
54
  def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
54
55
  super(tool: tool, ledger: ledger)
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # Whether the ledger's chain holds (specs/002 FR-018).
7
+ #
8
+ # It writes entries of its own first — the first compliance tool, once per fixture
9
+ # principal — because a suite with transactional fixtures starts every example with
10
+ # an empty ledger, and a check that only looked would find nothing and say nothing.
11
+ # Then it verifies the whole ledger the run can see, not only its own rows: a break an
12
+ # earlier run left behind is reported, not hidden.
13
+ #
14
+ # * **intact** — passed; the message is the verification's own summary line, so it
15
+ # states the range covered and what was not.
16
+ # * **broken, unverifiable, or no chain after writing** — failed. The run signed its own
17
+ # entries, so a key it lacks or a recorder that did not chain is a configuration
18
+ # error, not an absence.
19
+ # * **no key configured** — failed, naming the setting.
20
+ # * **nothing to write or read** (no compliance tools, ledger not loaded) — skipped,
21
+ # saying the chain was not verified. Never passed.
22
+ #
23
+ # Reeve::Checks::LedgerChain.new.call
24
+ class LedgerChain < Base
25
+ NOT_VERIFIED = "the ledger chain was not verified"
26
+
27
+ def initialize(tools: nil, principals: nil, arguments: {}, invoke: nil, ledger: nil)
28
+ @tools = tools
29
+ @principals = principals
30
+ super(tool: nil, arguments: arguments, invoke: invoke, ledger: ledger)
31
+ end
32
+
33
+ def call
34
+ return skipped("there is no compliance tool to write an entry with") if subject.nil?
35
+ return skipped(unreadable_reason) unless readable?
36
+ return no_key if Reeve.config.ledger_key.nil?
37
+
38
+ unwritten = write_entries
39
+ return unwritten_entry(unwritten) if unwritten
40
+
41
+ judge(ledger.verify_chain)
42
+ end
43
+
44
+ private
45
+
46
+ def subject
47
+ @subject ||= Array(@tools || Testing.compliance_tools).first
48
+ end
49
+
50
+ def principals
51
+ configured = Testing.compliance_principals? ? Testing.compliance_principals : nil
52
+ list = Array(@principals || configured)
53
+ list.empty? ? [nil] : list
54
+ end
55
+
56
+ # Each principal on its own, so one refusal does not stop the next. A denial, or the
57
+ # tool's own error, still leaves a ledger entry — the envelope records before it
58
+ # re-raises — which is all this needs. A failed ledger write leaves none, and is
59
+ # returned as the reason the chain could not be checked.
60
+ #
61
+ # Returns nil when entries were written, or the reason none were — including the case
62
+ # where every invocation failed before reaching reeve, which a chain left by an
63
+ # earlier run must not be allowed to pass for.
64
+ def write_entries
65
+ before = ledger.count
66
+ principals.each do |principal|
67
+ invoke.call(tool: subject, arguments: arguments, principal: principal)
68
+ rescue Reeve::AuditWriteError => e
69
+ return "#{e.class}: #{e.message}"
70
+ rescue StandardError
71
+ next
72
+ end
73
+ ledger.count == before ? "its invocations wrote no entry" : nil
74
+ end
75
+
76
+ def readable?
77
+ ledger.available? && ledger.respond_to?(:verify_chain)
78
+ end
79
+
80
+ def unwritten_entry(reason)
81
+ failed("#{NOT_VERIFIED}: it wrote no entry of its own — #{reason}",
82
+ coverage: :unwritten)
83
+ end
84
+
85
+ def unreadable_reason
86
+ ledger.unavailable_reason || "this ledger cannot verify a chain"
87
+ end
88
+
89
+ def judge(verification)
90
+ return skipped("the audit ledger is not loaded") if verification.nil?
91
+
92
+ summary = verification.to_s
93
+ return passed(summary.lines.first.chomp, coverage: :intact) if verification.intact?
94
+
95
+ failed(summary, coverage: verification.status)
96
+ end
97
+
98
+ def no_key
99
+ failed("expected the ledger to be chained, but no ledger key is configured: set " \
100
+ "config.ledger_key (the generated initializer derives one from secret_key_base)",
101
+ coverage: :no_key)
102
+ end
103
+
104
+ # Every skip says, first, that the chain was not verified — then why.
105
+ def skipped(reason)
106
+ Result.skipped(check: check_name, message: "#{NOT_VERIFIED}: #{reason}",
107
+ details: { coverage: :unverified })
108
+ end
109
+
110
+ def base_details
111
+ {}
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -9,6 +9,7 @@ require_relative "checks/redaction_holds"
9
9
  require_relative "checks/principal_required"
10
10
  require_relative "checks/contract_version"
11
11
  require_relative "checks/endpoint_coverage"
12
+ require_relative "checks/ledger_chain"
12
13
 
13
14
  module Reeve
14
15
  module Testing
@@ -19,7 +20,7 @@ module Reeve
19
20
  # the same violation reads identically from either — and from neither:
20
21
  #
21
22
  # report = Reeve::Checks.run_all(principals: [alice, bob])
22
- # abort report.to_s unless report.passed?
23
+ # abort report.to_s if report.failed? || report.nothing_checked?
23
24
  #
24
25
  # Nothing here loads a test framework (FR-026).
25
26
  module Checks
@@ -31,12 +32,13 @@ module Reeve
31
32
  RedactionHolds,
32
33
  PrincipalRequired,
33
34
  ContractVersion,
34
- EndpointCoverage
35
+ EndpointCoverage,
36
+ LedgerChain
35
37
  ].freeze
36
38
 
37
39
  # The checks that are asked once — about the ledger, or about the endpoint as a
38
40
  # whole — rather than once per tool.
39
- GLOBAL = [ContractVersion, EndpointCoverage].freeze
41
+ GLOBAL = [ContractVersion, EndpointCoverage, LedgerChain].freeze
40
42
 
41
43
  # The compliance suite's engine (FR-018): every check, against every registered
42
44
  # guarded tool, in one Report.
@@ -58,10 +60,12 @@ module Reeve
58
60
  def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
59
61
  if GLOBAL.include?(check)
60
62
  return Report.new([build(check, tool: nil, principals: principals, tools: tools,
61
- ledger: ledger).call])
63
+ invoke: invoke, ledger: ledger).call])
62
64
  end
63
65
 
64
66
  subjects = tools || Testing.compliance_tools
67
+ return Report.new([nothing_to_check(check, tools)]) if subjects.empty?
68
+
65
69
  Report.new(
66
70
  subjects.map do |tool|
67
71
  build(check, tool: tool, principals: principals, arguments: arguments,
@@ -70,6 +74,25 @@ module Reeve
70
74
  )
71
75
  end
72
76
 
77
+ # A per-tool check over no tools establishes nothing, and an empty Report reads as
78
+ # passed — the green that means "did not look". So an empty population is one skip,
79
+ # saying which of the three ways the run came to have nothing in it.
80
+ def self.nothing_to_check(check, tools)
81
+ reason =
82
+ if !tools.nil? then "the run was given tools: []"
83
+ elsif Testing.compliance_tools_narrowed? then "compliance_tools is []"
84
+ else "the registry is empty and no endpoint inventory routes a tool"
85
+ end
86
+
87
+ Result.skipped(
88
+ check: check.check_name,
89
+ message: "no compliance tools to check, so #{check.check_name} established " \
90
+ "nothing — #{reason}",
91
+ details: { coverage: :none }
92
+ )
93
+ end
94
+ private_class_method :nothing_to_check
95
+
73
96
  # Every per-tool check for one tool, instantiated but not run.
74
97
  def self.for_tool(tool, principals:, arguments: {}, invoke: nil, ledger: nil)
75
98
  (ALL - GLOBAL).map do |check|
@@ -91,6 +114,8 @@ module Reeve
91
114
  when "GuardDeclared" then GuardDeclared.new(tool: tool, ledger: ledger)
92
115
  when "ContractVersion" then ContractVersion.new(ledger: ledger)
93
116
  when "EndpointCoverage" then EndpointCoverage.new(tools: tools, ledger: ledger)
117
+ when "LedgerChain" then LedgerChain.new(tools: tools, principals: principals,
118
+ invoke: invoke, ledger: ledger)
94
119
  when "PrincipalRequired" then PrincipalRequired.new(**common)
95
120
  when "CrossPrincipalLeak" then CrossPrincipalLeak.new(principals: Array(principals),
96
121
  **common)
@@ -15,8 +15,9 @@ 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. A check that could establish nothing either way is
19
- # skipped with its reason, never passed.
18
+ # undifferentiated failure. A check that could establish nothing either way — no
19
+ # inventory declared, or no tools to run a per-tool check over — is skipped with its
20
+ # reason, never passed.
20
21
  module ComplianceAssertions
21
22
  include Assertions
22
23
 
@@ -12,8 +12,8 @@
12
12
  #
13
13
  # One example per check rather than one for the lot, so a red build names the guarantee
14
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
+ # could establish nothing either way (EndpointCoverage with no inventory, or a per-tool
16
+ # check with no tools to run over) is skipped, with its reason, rather than passed.
17
17
  RSpec.shared_examples "a reeve-compliant server" do
18
18
  Reeve::Checks::ALL.each do |check|
19
19
  it "satisfies #{check.check_name} for every registered guarded tool" do