reeve 0.5.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -1
  3. data/README.md +113 -6
  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/configuration.rb +66 -2
  22. data/lib/reeve/errors.rb +15 -0
  23. data/lib/reeve/ledger_key.rb +47 -0
  24. data/lib/reeve/railtie.rb +15 -0
  25. data/lib/reeve/tasks.rb +62 -0
  26. data/lib/reeve/testing/checks/contract_version.rb +2 -1
  27. data/lib/reeve/testing/checks/ledger_chain.rb +116 -0
  28. data/lib/reeve/testing/checks.rb +29 -4
  29. data/lib/reeve/testing/compliance_assertions.rb +3 -2
  30. data/lib/reeve/testing/compliance_suite.rb +2 -2
  31. data/lib/reeve/testing/ledger.rb +11 -1
  32. data/lib/reeve/testing/report.rb +8 -0
  33. data/lib/reeve/testing.rb +1 -1
  34. data/lib/reeve/version.rb +1 -1
  35. data/lib/reeve.rb +2 -0
  36. metadata +17 -2
@@ -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
@@ -0,0 +1,62 @@
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
+
18
+ module_function
19
+
20
+ # Verifies the ledger, writes the report to `out`, and returns the exit status:
21
+ # 0 intact, 1 broken, 2 could not verify (unverifiable, or no chain).
22
+ #
23
+ # LIMIT caps the breaks listed in the text report (default 100; the totals are never
24
+ # capped). FORMAT=json writes the whole result, every break, and ignores LIMIT.
25
+ def verify_ledger(env: ENV, out: $stdout)
26
+ format = env.fetch("FORMAT", "text")
27
+ unless FORMATS.include?(format)
28
+ raise ArgumentError, "FORMAT must be one of #{FORMATS.join(', ')}, got #{format.inspect}"
29
+ end
30
+
31
+ limit = limit_from(env)
32
+ require "reeve/audit"
33
+ result = Reeve::Audit::Chain.verify
34
+ out.puts(format == "json" ? JSON.generate(result.as_json) : result.to_s(limit: limit))
35
+ EXIT.fetch(result.status)
36
+ end
37
+
38
+ def limit_from(env)
39
+ raw = env.fetch("LIMIT", Reeve::Audit::Chain::Verification::DEFAULT_LIMIT.to_s)
40
+ Integer(raw, 10).tap { |limit| raise ArgumentError if limit.negative? }
41
+ rescue ArgumentError, TypeError
42
+ raise ArgumentError, "LIMIT must be a whole number of breaks to list, got #{raw.inspect}"
43
+ end
44
+
45
+ # The Rake task, defined on the current Rake application. Depends on :environment, so
46
+ # the host's initializer — and with it the ledger key — is loaded first.
47
+ #
48
+ # The DSL is borrowed by a throwaway object, so Reeve::Tasks itself never gains `task`,
49
+ # `sh` and the rest of Rake's methods.
50
+ def define
51
+ Object.new.extend(Rake::DSL).instance_exec do
52
+ namespace :reeve do
53
+ desc "Verify the audit ledger's chain " \
54
+ "(exit 0 intact, 1 broken, 2 could not verify; LIMIT=, FORMAT=json)"
55
+ task verify_ledger: :environment do
56
+ exit Reeve::Tasks.verify_ledger
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ 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
49
50
  ].freeze
50
51
 
51
- EXPECTED_VERSION = 2
52
+ EXPECTED_VERSION = 3
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
@@ -10,7 +10,8 @@ module Reeve
10
10
  # the ledger is absent.
11
11
  #
12
12
  # A host with its own non-ActiveRecord recorder can pass any object with these six
13
- # methods as `ledger:` to any ledger-reading check.
13
+ # methods as `ledger:` to any ledger-reading check; LedgerChain also asks for
14
+ # #verify_chain, and is skipped by a ledger that has none.
14
15
  class Ledger
15
16
  def self.default
16
17
  new
@@ -67,6 +68,15 @@ module Reeve
67
68
  entry_class.column_names.map(&:to_s)
68
69
  end
69
70
 
71
+ # The chain's verification of the whole ledger (specs/002), or nil when the ledger is
72
+ # not loaded. The kit's only way to the chain: it names Reeve::Audit::Chain here, at
73
+ # call time, so `require "reeve/testing"` still loads with no ActiveRecord at all.
74
+ def verify_chain
75
+ return nil unless available? && Reeve::Audit.const_defined?(:Chain, false)
76
+
77
+ Reeve::Audit::Chain.verify(entry_class: entry_class)
78
+ end
79
+
70
80
  private
71
81
 
72
82
  def entry_class
@@ -31,6 +31,14 @@ module Reeve
31
31
  !results.empty? && results.all?(&:skipped?)
32
32
  end
33
33
 
34
+ # A check had nothing to examine — a per-tool check run over no tools at all. passed?
35
+ # is still true then, since nothing failed; a test framework shows the skip, but a
36
+ # gate with no framework (a CI script, a deploy step) has only this to stop on. A skip
37
+ # for a missing inventory is not this: there the tools existed and were checked.
38
+ def nothing_checked?
39
+ results.any? { |result| result.skipped? && result.details[:coverage] == :none }
40
+ end
41
+
34
42
  def failures
35
43
  results.select(&:failed?)
36
44
  end
data/lib/reeve/testing.rb CHANGED
@@ -18,7 +18,7 @@ module Reeve
18
18
  #
19
19
  # require "reeve/testing"
20
20
  # report = Reeve::Checks.run_all(principals: [alice, bob])
21
- # abort report.to_s unless report.passed?
21
+ # abort report.to_s if report.failed? || report.nothing_checked?
22
22
  module Testing
23
23
  class << self
24
24
  # The two fixture principals the compliance suite runs every tool against. Set once,
data/lib/reeve/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Reeve
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/reeve.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "reeve/version"
4
4
  require_relative "reeve/errors"
5
+ require_relative "reeve/ledger_key"
5
6
  require_relative "reeve/decision"
6
7
  require_relative "reeve/configuration"
7
8
  require_relative "reeve/context"
@@ -9,6 +10,7 @@ require_relative "reeve/scope_result"
9
10
  require_relative "reeve/invocation"
10
11
  require_relative "reeve/authorization"
11
12
  require_relative "reeve/inventory"
13
+ require_relative "reeve/railtie" if defined?(Rails::Railtie)
12
14
 
13
15
  # Reeve — per-record authorization and an append-only audit ledger for the MCP tools
14
16
  # a Rails application exposes to AI agents.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reeve
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Victor Velazquez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
11
+ date: 2026-10-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Reeve makes it safe for a Rails application to expose MCP (Model Context Protocol)
@@ -31,9 +31,20 @@ files:
31
31
  - lib/generators/reeve/install/templates/create_audit_entries.rb.tt
32
32
  - lib/generators/reeve/install/templates/initializer.rb.tt
33
33
  - lib/generators/reeve/upgrade/templates/add_contract_version_to_reeve_audit_entries.rb.tt
34
+ - lib/generators/reeve/upgrade/templates/add_ledger_chain_to_reeve_audit_entries.rb.tt
34
35
  - lib/generators/reeve/upgrade/upgrade_generator.rb
35
36
  - lib/reeve.rb
36
37
  - lib/reeve/audit.rb
38
+ - lib/reeve/audit/chain.rb
39
+ - lib/reeve/audit/chain/canonical.rb
40
+ - lib/reeve/audit/chain/head.rb
41
+ - lib/reeve/audit/chain/keyring.rb
42
+ - lib/reeve/audit/chain/normalizer.rb
43
+ - lib/reeve/audit/chain/report.rb
44
+ - lib/reeve/audit/chain/verification.rb
45
+ - lib/reeve/audit/chain/verifier.rb
46
+ - lib/reeve/audit/chain/walk.rb
47
+ - lib/reeve/audit/chain_record.rb
37
48
  - lib/reeve/audit/entry.rb
38
49
  - lib/reeve/audit/isolated_recorder.rb
39
50
  - lib/reeve/audit/query.rb
@@ -59,9 +70,12 @@ files:
59
70
  - lib/reeve/inventory.rb
60
71
  - lib/reeve/inventory/report.rb
61
72
  - lib/reeve/invocation.rb
73
+ - lib/reeve/ledger_key.rb
62
74
  - lib/reeve/minitest.rb
75
+ - lib/reeve/railtie.rb
63
76
  - lib/reeve/rspec.rb
64
77
  - lib/reeve/scope_result.rb
78
+ - lib/reeve/tasks.rb
65
79
  - lib/reeve/testing.rb
66
80
  - lib/reeve/testing/assertions.rb
67
81
  - lib/reeve/testing/checks.rb
@@ -71,6 +85,7 @@ files:
71
85
  - lib/reeve/testing/checks/cross_principal_leak.rb
72
86
  - lib/reeve/testing/checks/endpoint_coverage.rb
73
87
  - lib/reeve/testing/checks/guard_declared.rb
88
+ - lib/reeve/testing/checks/ledger_chain.rb
74
89
  - lib/reeve/testing/checks/principal_required.rb
75
90
  - lib/reeve/testing/checks/redaction_holds.rb
76
91
  - lib/reeve/testing/checks/rule_present.rb