reeve 0.0.1 → 0.1.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -0
  3. data/README.md +191 -17
  4. data/lib/generators/reeve/install/install_generator.rb +46 -0
  5. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
  6. data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
  7. data/lib/reeve/audit/entry.rb +48 -0
  8. data/lib/reeve/audit/query.rb +84 -0
  9. data/lib/reeve/audit/recorder.rb +163 -0
  10. data/lib/reeve/audit/redactor.rb +67 -0
  11. data/lib/reeve/audit.rb +88 -0
  12. data/lib/reeve/authorization/adapter.rb +71 -0
  13. data/lib/reeve/authorization/adapters/plain.rb +65 -0
  14. data/lib/reeve/authorization/adapters/pundit.rb +84 -0
  15. data/lib/reeve/authorization/authorizer.rb +23 -0
  16. data/lib/reeve/authorization/current.rb +66 -0
  17. data/lib/reeve/authorization/declaration.rb +73 -0
  18. data/lib/reeve/authorization/guard.rb +97 -0
  19. data/lib/reeve/authorization/registry.rb +118 -0
  20. data/lib/reeve/authorization/scoper.rb +399 -0
  21. data/lib/reeve/authorization.rb +76 -0
  22. data/lib/reeve/configuration.rb +158 -0
  23. data/lib/reeve/context.rb +104 -0
  24. data/lib/reeve/decision.rb +111 -0
  25. data/lib/reeve/errors.rb +89 -0
  26. data/lib/reeve/fast_mcp.rb +24 -0
  27. data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
  28. data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
  29. data/lib/reeve/invocation.rb +260 -0
  30. data/lib/reeve/minitest.rb +19 -0
  31. data/lib/reeve/rspec.rb +18 -0
  32. data/lib/reeve/scope_result.rb +104 -0
  33. data/lib/reeve/testing/assertions.rb +76 -0
  34. data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
  35. data/lib/reeve/testing/checks/base.rb +155 -0
  36. data/lib/reeve/testing/checks/contract_version.rb +73 -0
  37. data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
  38. data/lib/reeve/testing/checks/guard_declared.rb +33 -0
  39. data/lib/reeve/testing/checks/principal_required.rb +60 -0
  40. data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
  41. data/lib/reeve/testing/checks/rule_present.rb +55 -0
  42. data/lib/reeve/testing/checks.rb +95 -0
  43. data/lib/reeve/testing/compliance_assertions.rb +34 -0
  44. data/lib/reeve/testing/compliance_suite.rb +23 -0
  45. data/lib/reeve/testing/ledger.rb +82 -0
  46. data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
  47. data/lib/reeve/testing/matchers/base.rb +84 -0
  48. data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
  49. data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
  50. data/lib/reeve/testing/matchers.rb +33 -0
  51. data/lib/reeve/testing/report.rb +58 -0
  52. data/lib/reeve/testing/result.rb +49 -0
  53. data/lib/reeve/testing.rb +60 -0
  54. data/lib/reeve/version.rb +1 -1
  55. data/lib/reeve.rb +9 -3
  56. metadata +52 -1
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-015: the ledger the host actually migrated implements the entry shape this
7
+ # version of the gem writes.
8
+ #
9
+ # The audit-entry contract is versioned in prose and in +Audit::CONTRACT_VERSION+;
10
+ # what this check adds is the third party to that agreement — the table. A host that
11
+ # upgraded the gem and skipped the migration has a ledger one shape behind, and every
12
+ # other check in this kit would go on passing while columns quietly went unwritten.
13
+ #
14
+ # Reeve::Checks::ContractVersion.new.call
15
+ class ContractVersion < Base
16
+ TABLE = "reeve_audit_entries"
17
+
18
+ # Contract version 1, as documented in contracts/audit-entry.md. This list is
19
+ # deliberately written out rather than read back off the model: a check that
20
+ # derives its expectation from the thing it is checking checks nothing.
21
+ COLUMNS = %w[
22
+ invocation_id occurred_at agent_id agent_name principal_type principal_id
23
+ tool_name arguments outcome rule detail record_type record_ids record_count
24
+ truncated derived guard duration_ms metadata
25
+ ].freeze
26
+
27
+ EXPECTED_VERSION = 1
28
+
29
+ def initialize(tool: nil, expected: EXPECTED_VERSION, ledger: nil)
30
+ super(tool: tool, ledger: ledger)
31
+ @expected = expected
32
+ end
33
+
34
+ def call
35
+ return ledger_unavailable("the audit-entry contract version") unless ledger.available?
36
+
37
+ recorded = ledger.contract_version
38
+ return version_mismatch(recorded) unless recorded == @expected
39
+
40
+ missing = COLUMNS - ledger.columns
41
+ return missing_columns(missing) unless missing.empty?
42
+
43
+ passed("the ledger implements audit-entry contract version #{@expected}",
44
+ version: @expected)
45
+ end
46
+
47
+ private
48
+
49
+ def version_mismatch(recorded)
50
+ failed(
51
+ "expected the ledger to implement audit-entry contract version #{@expected}, " \
52
+ "but it reports version #{recorded.inspect} — this reeve version cannot read " \
53
+ "that shape",
54
+ version: recorded
55
+ )
56
+ end
57
+
58
+ def missing_columns(missing)
59
+ failed(
60
+ "expected the ledger to implement audit-entry contract version #{@expected}, " \
61
+ "but #{TABLE} is missing: #{missing.join(', ')} — run `rails g reeve:install` " \
62
+ "and migrate",
63
+ version: @expected, missing: missing
64
+ )
65
+ end
66
+
67
+ def base_details
68
+ { tool: nil }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-016, FR-003. The check the whole kit exists for: does this tool hand one
7
+ # principal another principal's records?
8
+ #
9
+ # It answers by construction rather than by introspection. The contract's host-setup
10
+ # rule is two fixture principals with *disjoint* records, so invoking the tool once
11
+ # per principal and intersecting the identifiers that come back is decisive: a
12
+ # non-empty intersection is a leak, and no policy needs to be read to know it.
13
+ # A denial contributes an empty set — nothing returned is nothing leaked.
14
+ #
15
+ # Reeve::Checks::CrossPrincipalLeak.new(
16
+ # tool: InvoiceSearchTool, principals: [alice, bob], arguments: { query: "AC" }
17
+ # ).call
18
+ #
19
+ # Two expectations, because two questions are worth asking:
20
+ #
21
+ # :disjoint (default) — no identifier reaches two principals. The compliance
22
+ # question, and the one FR-016 is written about.
23
+ # :nothing — none of these principals may receive any record at all.
24
+ # What `deny_access_for(stranger)` asserts.
25
+ class CrossPrincipalLeak < Base
26
+ EXPECTATIONS = %i[disjoint nothing].freeze
27
+
28
+ def initialize(tool:, principals:, arguments: {}, expect: :disjoint, invoke: nil,
29
+ ledger: nil)
30
+ unless EXPECTATIONS.include?(expect)
31
+ raise ArgumentError,
32
+ "expect must be one of #{EXPECTATIONS.map(&:inspect).join(', ')}, " \
33
+ "got #{expect.inspect}"
34
+ end
35
+
36
+ super(tool: tool, arguments: arguments, invoke: invoke, ledger: ledger)
37
+ @principals = Array(principals)
38
+ @expect = expect
39
+ end
40
+
41
+ def call
42
+ return no_principals if principals.empty?
43
+
44
+ attempts = principals.map { |principal| attempt(principal: principal) }
45
+ @expect == :nothing ? verify_nothing(attempts) : verify_disjoint(attempts)
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :principals
51
+
52
+ def no_principals
53
+ failed("#{tool_label} cannot be checked for cross-principal leaks: no principals " \
54
+ "were supplied (the compliance suite needs two, with disjoint records)",
55
+ leaked: [])
56
+ end
57
+
58
+ # ---- :nothing -------------------------------------------------------------
59
+
60
+ def verify_nothing(attempts)
61
+ offenders = attempts.reject { |attempt| identifiers(attempt.records).empty? }
62
+ return nothing_held(attempts) if offenders.empty?
63
+
64
+ failed(nothing_message(offenders), leaked_details(offenders))
65
+ end
66
+
67
+ def nothing_held(attempts)
68
+ passed(
69
+ "#{tool_label} returned no records to " \
70
+ "#{attempts.map { |a| principal_label(a.principal) }.join(', ')}",
71
+ leaked: [], denials: attempts.filter_map { |a| a.denial&.rule }
72
+ )
73
+ end
74
+
75
+ def nothing_message(offenders)
76
+ offenders.map do |attempt|
77
+ leaked = identifiers(attempt.records)
78
+ "expected #{tool_label} to deny access for #{principal_label(attempt.principal)}, " \
79
+ "but it returned #{pluralize(leaked.size, 'record')} that principal may not " \
80
+ "see: #{format_identifiers(leaked)} #{provenance(attempt.entry)}"
81
+ end.join("; ")
82
+ end
83
+
84
+ # ---- :disjoint ------------------------------------------------------------
85
+
86
+ def verify_disjoint(attempts)
87
+ leak = first_overlap(attempts)
88
+ return disjoint_held(attempts) if leak.nil?
89
+
90
+ owner, other, shared = leak
91
+ failed(disjoint_message(owner, other, shared),
92
+ leaked: shared, rule: owner.entry&.rule,
93
+ principals: [principal_label(owner.principal),
94
+ principal_label(other.principal)])
95
+ end
96
+
97
+ def disjoint_message(owner, other, shared)
98
+ "expected #{tool_label} to return no records belonging to another principal, " \
99
+ "but it returned #{pluralize(shared.size, 'record')} to " \
100
+ "#{principal_label(owner.principal)} that also belong to " \
101
+ "#{principal_label(other.principal)}: #{format_identifiers(shared)} " \
102
+ "#{provenance(owner.entry)}"
103
+ end
104
+
105
+ def first_overlap(attempts)
106
+ attempts.combination(2).each do |owner, other|
107
+ shared = identifiers(owner.records) & identifiers(other.records)
108
+ return [owner, other, shared] unless shared.empty?
109
+ end
110
+ nil
111
+ end
112
+
113
+ def disjoint_held(attempts)
114
+ total = attempts.sum { |attempt| identifiers(attempt.records).size }
115
+ passed(
116
+ "#{tool_label} returned no records belonging to another principal " \
117
+ "(#{pluralize(attempts.size, 'principal')}, #{pluralize(total, 'record')})",
118
+ leaked: [], denials: attempts.filter_map { |a| a.denial&.rule }
119
+ )
120
+ end
121
+
122
+ def leaked_details(offenders)
123
+ {
124
+ leaked: offenders.flat_map { |attempt| identifiers(attempt.records) },
125
+ rule: offenders.first.entry&.rule,
126
+ principals: offenders.map { |attempt| principal_label(attempt.principal) }
127
+ }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-002, FR-004: a tool with no `guard_with` is not neutral, it is denied. This is
7
+ # the check that tells you *before* production which of your tools that is.
8
+ #
9
+ # Reeve::Checks::GuardDeclared.new(tool: InvoiceSearchTool).call
10
+ class GuardDeclared < Base
11
+ def call
12
+ guard = declaration
13
+ return failure if guard.nil?
14
+
15
+ passed(
16
+ "#{tool_label} is guarded by #{guard.policy_name} (action: #{guard.action})",
17
+ policy: guard.policy_name, action: guard.action
18
+ )
19
+ end
20
+
21
+ private
22
+
23
+ def failure
24
+ failed(
25
+ "expected #{tool_label} to declare a guard, but it has no guard_with " \
26
+ "declaration — reeve denies every call to an unguarded tool",
27
+ policy: nil
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-001: with no resolvable principal, the call is denied before any policy is
7
+ # consulted.
8
+ #
9
+ # The evidence is the rule. `no_principal` is only reachable on the path that denies
10
+ # *ahead* of authorization; a denial carrying any other rule means the policy was
11
+ # asked a question about nobody, and got an answer.
12
+ #
13
+ # Reeve::Checks::PrincipalRequired.new(tool: InvoiceSearchTool).call
14
+ class PrincipalRequired < Base
15
+ def call
16
+ made = attempt(principal: nil)
17
+ return held(made) if made.denial&.rule == Decision::NO_PRINCIPAL
18
+ return wrong_rule(made) if made.denied?
19
+ return raised(made) if made.failed?
20
+
21
+ allowed(made)
22
+ end
23
+
24
+ private
25
+
26
+ def held(made)
27
+ passed("#{tool_label} denies with no_principal when no principal resolves",
28
+ rule: made.denial.rule)
29
+ end
30
+
31
+ def wrong_rule(made)
32
+ failed(
33
+ "expected #{tool_label} to deny with no_principal when no principal resolves, " \
34
+ "but it denied with #{made.denial.rule} — the policy was consulted before the " \
35
+ "principal was established",
36
+ rule: made.denial.rule
37
+ )
38
+ end
39
+
40
+ def raised(made)
41
+ failed(
42
+ "expected #{tool_label} to deny with no_principal when no principal resolves, " \
43
+ "but it raised #{made.error.class}: #{made.error.message}",
44
+ rule: nil, error: made.error.class.name
45
+ )
46
+ end
47
+
48
+ def allowed(made)
49
+ returned = identifiers(made.records)
50
+ failed(
51
+ "expected #{tool_label} to deny when no principal resolves, but it allowed the " \
52
+ "call and returned #{pluralize(returned.size, 'record')}: " \
53
+ "#{format_identifiers(returned)}",
54
+ rule: nil, returned: returned
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Reeve
6
+ module Testing
7
+ module Checks
8
+ # FR-011: a value declared sensitive appears in no ledger entry.
9
+ #
10
+ # Proved rather than inspected. The check calls the tool with a unique sentinel in
11
+ # each declared-sensitive argument and then looks for that sentinel in every column
12
+ # of the row that was written — including nested structures, since the redactor
13
+ # recurses and so must the check.
14
+ #
15
+ # It also catches the quieter bug: `redact :ssn` on a tool whose argument is
16
+ # actually named `customer_ssn`. That declaration compiles, registers, and redacts
17
+ # nothing at all, and no amount of looking at written rows would reveal it.
18
+ #
19
+ # Reeve::Checks::RedactionHolds.new(tool: InvoiceSearchTool, principal: alice).call
20
+ class RedactionHolds < Base
21
+ FILLER = "reeve-check"
22
+
23
+ def initialize(tool:, principal:, arguments: {}, invoke: nil, ledger: nil)
24
+ super(tool: tool, arguments: arguments, invoke: invoke, ledger: ledger)
25
+ @principal = principal
26
+ end
27
+
28
+ def call
29
+ return ledger_unavailable("redaction") unless ledger.available?
30
+
31
+ inert = declared_names - accepted_names
32
+ return inert_declaration(inert.first) unless inert.empty? || accepts_anything?
33
+
34
+ names = names_to_probe
35
+ return nothing_declared if names.empty?
36
+
37
+ probe(names)
38
+ end
39
+
40
+ private
41
+
42
+ def probe(names)
43
+ sentinels = names.to_h { |name| [name, "reeve-sentinel-#{SecureRandom.hex(8)}"] }
44
+ made = attempt(principal: @principal, arguments: probe_arguments(sentinels))
45
+ return no_entry(names) if made.rows.empty?
46
+
47
+ leak = first_leak(made.rows, sentinels)
48
+ return held(names) if leak.nil?
49
+
50
+ name, entry, column = leak
51
+ failed(
52
+ "expected #{tool_label} to redact #{name}, but the value passed for it appears " \
53
+ "in audit entry #{entry.id}, in column #{column}",
54
+ redacted: names, leaked: name, entry: entry.id, column: column
55
+ )
56
+ end
57
+
58
+ def first_leak(entries, sentinels)
59
+ entries.each do |entry|
60
+ sentinels.each do |name, sentinel|
61
+ column = column_containing(entry, sentinel)
62
+ return [name, entry, column] if column
63
+ end
64
+ end
65
+ nil
66
+ end
67
+
68
+ def column_containing(entry, sentinel)
69
+ entry.attributes.each do |column, value|
70
+ return column if value.to_s.include?(sentinel)
71
+ end
72
+ nil
73
+ end
74
+
75
+ def held(names)
76
+ passed("#{tool_label} keeps #{names.join(', ')} out of the ledger entirely",
77
+ redacted: names)
78
+ end
79
+
80
+ def nothing_declared
81
+ passed("#{tool_label} declares no redacted argument that it also accepts, so " \
82
+ "there is nothing for the ledger to expose",
83
+ redacted: [])
84
+ end
85
+
86
+ def no_entry(names)
87
+ failed("expected #{tool_label} to redact #{names.join(', ')}, but the invocation " \
88
+ "wrote no audit entry to look in",
89
+ redacted: names)
90
+ end
91
+
92
+ def inert_declaration(name)
93
+ failed(
94
+ "expected #{tool_label} to redact #{name}, but #call accepts no such argument " \
95
+ "(it accepts #{accepted_names.join(', ')}), so the declaration redacts nothing",
96
+ redacted: declared_names, inert: name
97
+ )
98
+ end
99
+
100
+ # Sentinels for the sensitive names, filler for anything else #call insists on.
101
+ def probe_arguments(sentinels)
102
+ required = call_parameters.filter_map { |type, name| name if type == :keyreq }
103
+ filler = required.to_h { |name| [name, FILLER] }
104
+ filler.merge(arguments).merge(sentinels)
105
+ end
106
+
107
+ def names_to_probe
108
+ candidates = (declared_names + global_names).uniq
109
+ accepts_anything? ? candidates : candidates & accepted_names
110
+ end
111
+
112
+ # Only the tool's own declaration is treated as a promise about *this* tool. The
113
+ # process-wide list is a safety net, and is probed only where the tool happens to
114
+ # take an argument by that name.
115
+ def declared_names
116
+ guard = declaration
117
+ guard ? guard.redacted_arguments.dup : []
118
+ end
119
+
120
+ def global_names
121
+ Array(Reeve.config.redact_arguments)
122
+ end
123
+
124
+ def accepted_names
125
+ call_parameters.filter_map { |type, name| name if %i[key keyreq].include?(type) }
126
+ end
127
+
128
+ def accepts_anything?
129
+ call_parameters.any? { |type, _| type == :keyrest }
130
+ end
131
+
132
+ def call_parameters
133
+ return [] unless tool.respond_to?(:instance_method)
134
+
135
+ tool.instance_method(:call).parameters
136
+ rescue NameError
137
+ []
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ module Checks
6
+ # FR-009: every entry explains itself.
7
+ #
8
+ # A ledger row that records *that* a call was denied but not *what* denied it is the
9
+ # row you are holding during an incident, and it answers nothing. The gem's own
10
+ # recorder cannot write one — Entry validates the column — but a host-supplied
11
+ # recorder can, which is exactly the case this check covers.
12
+ #
13
+ # Reeve::Checks::RulePresent.new(tool: InvoiceSearchTool, principal: alice).call
14
+ class RulePresent < Base
15
+ def initialize(tool:, principal:, arguments: {}, invoke: nil, ledger: nil)
16
+ super(tool: tool, arguments: arguments, invoke: invoke, ledger: ledger)
17
+ @principal = principal
18
+ end
19
+
20
+ def call
21
+ return ledger_unavailable("the deciding rule") unless ledger.available?
22
+
23
+ entries = attempt(principal: @principal).rows
24
+ return no_entry if entries.empty?
25
+
26
+ missing = entries.reject { |entry| present?(entry.rule) }
27
+ return held(entries) if missing.empty?
28
+
29
+ failed(
30
+ "expected every audit entry for #{tool_label} to name the rule that decided, " \
31
+ "but entry #{missing.map(&:id).join(', ')} has no rule",
32
+ entries: entries.size, without_rule: missing.map(&:id)
33
+ )
34
+ end
35
+
36
+ private
37
+
38
+ def held(entries)
39
+ passed("every audit entry #{tool_label} produced names the rule that decided",
40
+ rules: entries.map(&:rule))
41
+ end
42
+
43
+ def no_entry
44
+ failed("expected #{tool_label} to write an audit entry naming the rule that " \
45
+ "decided, but it produced no entry at all",
46
+ entries: 0)
47
+ end
48
+
49
+ def present?(value)
50
+ !value.nil? && !value.to_s.strip.empty?
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "checks/base"
4
+ require_relative "checks/guard_declared"
5
+ require_relative "checks/cross_principal_leak"
6
+ require_relative "checks/audit_coverage"
7
+ require_relative "checks/rule_present"
8
+ require_relative "checks/redaction_holds"
9
+ require_relative "checks/principal_required"
10
+ require_relative "checks/contract_version"
11
+
12
+ module Reeve
13
+ module Testing
14
+ # The seven guarantees, as objects.
15
+ #
16
+ # This is the whole of the testing kit's logic. The RSpec matchers and the Minitest
17
+ # assertions are adapters over it and contain no assertions of their own, which is why
18
+ # the same violation reads identically from either — and from neither:
19
+ #
20
+ # report = Reeve::Checks.run_all(principals: [alice, bob])
21
+ # abort report.to_s unless report.passed?
22
+ #
23
+ # Nothing here loads a test framework (FR-026).
24
+ module Checks
25
+ ALL = [
26
+ GuardDeclared,
27
+ CrossPrincipalLeak,
28
+ AuditCoverage,
29
+ RulePresent,
30
+ RedactionHolds,
31
+ PrincipalRequired,
32
+ ContractVersion
33
+ ].freeze
34
+
35
+ # The checks that are asked once about the ledger rather than once per tool.
36
+ GLOBAL = [ContractVersion].freeze
37
+
38
+ # The compliance suite's engine (FR-018): every check, against every registered
39
+ # guarded tool, in one Report.
40
+ #
41
+ # +principals+ must be two fixture principals with disjoint records — that
42
+ # disjointness is what makes a shared identifier proof of a leak.
43
+ def self.run_all(principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
44
+ reports = ALL.map do |check|
45
+ run(check, principals: principals, tools: tools, arguments: arguments,
46
+ invoke: invoke, ledger: ledger)
47
+ end
48
+
49
+ Report.new(reports.flat_map(&:results))
50
+ end
51
+
52
+ # One check, across every tool it applies to. This is what both front-ends build a
53
+ # test method out of, so that a failing suite names the guarantee that broke rather
54
+ # than reporting "compliance" as one undifferentiated red.
55
+ def self.run(check, principals:, tools: nil, arguments: {}, invoke: nil, ledger: nil)
56
+ return Report.new([check.new(ledger: ledger).call]) if GLOBAL.include?(check)
57
+
58
+ subjects = tools || Reeve.registry.map(&:tool_class)
59
+ Report.new(
60
+ subjects.map do |tool|
61
+ build(check, tool: tool, principals: principals, arguments: arguments,
62
+ invoke: invoke, ledger: ledger).call
63
+ end
64
+ )
65
+ end
66
+
67
+ # Every per-tool check for one tool, instantiated but not run.
68
+ def self.for_tool(tool, principals:, arguments: {}, invoke: nil, ledger: nil)
69
+ (ALL - GLOBAL).map do |check|
70
+ build(check, tool: tool, principals: principals, arguments: arguments,
71
+ invoke: invoke, ledger: ledger)
72
+ end
73
+ end
74
+
75
+ # The one place that knows what each check's constructor wants. Front-ends and hosts
76
+ # ask for a check by class and get a configured one back.
77
+ def self.build(check, tool:, principals:, arguments: {}, invoke: nil, ledger: nil)
78
+ common = { tool: tool, arguments: arguments, invoke: invoke, ledger: ledger }
79
+
80
+ case check.check_name
81
+ when "GuardDeclared" then GuardDeclared.new(tool: tool, ledger: ledger)
82
+ when "ContractVersion" then ContractVersion.new(ledger: ledger)
83
+ when "PrincipalRequired" then PrincipalRequired.new(**common)
84
+ when "CrossPrincipalLeak" then CrossPrincipalLeak.new(principals: Array(principals),
85
+ **common)
86
+ else check.new(principal: Array(principals).first, **common)
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ # The name the contract and every host will type. `Reeve::Checks::CrossPrincipalLeak`
93
+ # is the public spelling; the file layout under testing/ is an implementation detail.
94
+ Checks = Testing::Checks
95
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Testing
5
+ # The shared compliance suite (FR-018), for Minitest. Including it *is* the suite:
6
+ # Minitest runs every `test_`-prefixed method it finds.
7
+ #
8
+ # class ComplianceTest < ActiveSupport::TestCase
9
+ # include Reeve::Testing::ComplianceAssertions
10
+ # end
11
+ #
12
+ # Host setup is two fixture principals with disjoint records and nothing else:
13
+ #
14
+ # Reeve::Testing.compliance_principals = -> { [users(:alice), users(:bob)] }
15
+ #
16
+ # One method per check, matching the RSpec shared example group one for one, so a red
17
+ # build names the guarantee that broke rather than reporting "compliance" as one
18
+ # undifferentiated failure.
19
+ module ComplianceAssertions
20
+ include Assertions
21
+
22
+ Checks::ALL.each do |check|
23
+ # CrossPrincipalLeak -> test_reeve_cross_principal_leak
24
+ method_name = "test_reeve_#{check.check_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase}"
25
+
26
+ define_method(method_name) do
27
+ report = Checks.run(check, principals: Testing.compliance_principals)
28
+ assert report.passed?, report.to_s
29
+ report
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The shared compliance suite (FR-018), for RSpec.
4
+ #
5
+ # RSpec.describe "reeve compliance" do
6
+ # it_behaves_like "a reeve-compliant server"
7
+ # end
8
+ #
9
+ # Host setup is two fixture principals with disjoint records and nothing else:
10
+ #
11
+ # Reeve::Testing.compliance_principals = -> { [users(:alice), users(:bob)] }
12
+ #
13
+ # One example per check rather than one for the lot, so a red build names the guarantee
14
+ # that broke — "CrossPrincipalLeak" is an incident, "compliance" is a shrug.
15
+ RSpec.shared_examples "a reeve-compliant server" do
16
+ Reeve::Checks::ALL.each do |check|
17
+ it "satisfies #{check.check_name} for every registered guarded tool" do
18
+ report = Reeve::Checks.run(check, principals: Reeve::Testing.compliance_principals)
19
+
20
+ expect(report).to be_passed, report.to_s
21
+ end
22
+ end
23
+ end