reeve 0.0.1 → 0.2.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 +191 -0
  3. data/README.md +280 -17
  4. data/lib/generators/reeve/install/install_generator.rb +46 -0
  5. data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +77 -0
  6. data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
  7. data/lib/reeve/audit/entry.rb +54 -0
  8. data/lib/reeve/audit/query.rb +84 -0
  9. data/lib/reeve/audit/recorder.rb +181 -0
  10. data/lib/reeve/audit/redactor.rb +67 -0
  11. data/lib/reeve/audit.rb +97 -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 +97 -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 +111 -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 +97 -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 +10 -3
  56. metadata +53 -2
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ module Adapters
6
+ # Pundit policies, bridged rather than depended on. Pundit itself is never required
7
+ # by this gem (Constitution IV); this file only speaks its conventions, and reports
8
+ # that it supports nothing when Pundit is absent.
9
+ #
10
+ # class InvoicePolicy
11
+ # def index? = ...
12
+ # class Scope < ApplicationPolicy::Scope
13
+ # def resolve = scope.where(owner: user)
14
+ # end
15
+ # end
16
+ class Pundit
17
+ def self.pundit_loaded?
18
+ defined?(::Pundit) ? true : false
19
+ end
20
+
21
+ def self.supports?(policy)
22
+ return false unless pundit_loaded?
23
+ return false unless policy.is_a?(Class)
24
+
25
+ scope_defined?(policy) &&
26
+ policy.public_instance_methods.any? { |method| method.to_s.end_with?("?") }
27
+ end
28
+
29
+ # Pundit policies ordinarily inherit their Scope from a base policy
30
+ # (`class LeadPolicy < LeadBasePolicy`), so asking only the policy's own namespace
31
+ # rejected the common case. This walks the ancestry instead, stopping before
32
+ # Object — which is the whole reason the check was narrow to begin with: a
33
+ # top-level `Scope` constant must never make an unrelated class look
34
+ # Pundit-shaped. `policy::Scope` resolves along the same ancestry, so what
35
+ # `supports?` accepts is exactly what `scope` can later reach.
36
+ def self.scope_defined?(policy)
37
+ policy_ancestry(policy).any? { |ancestor| ancestor.const_defined?(:Scope, false) }
38
+ end
39
+
40
+ def self.policy_ancestry(policy)
41
+ policy.ancestors.take_while { |ancestor| ancestor != Object }
42
+ end
43
+
44
+ def self.missing_methods(policy)
45
+ return [] if supports?(policy)
46
+ return [:Scope] if policy.is_a?(Class) && !scope_defined?(policy)
47
+
48
+ %i[query_method Scope]
49
+ end
50
+
51
+ def authorize(principal:, policy:, action:, record: nil)
52
+ query = "#{action}?"
53
+ rule = "#{policy.name}##{query}"
54
+ subject = record || inferred_subject(policy)
55
+
56
+ if policy.new(principal,
57
+ subject).public_send(query)
58
+ Decision.allow(rule: rule)
59
+ else
60
+ Decision.deny(rule: rule)
61
+ end
62
+ end
63
+
64
+ def scope(principal:, policy:, relation:)
65
+ scoped = policy::Scope.new(principal, relation).resolve
66
+ return scoped unless scoped.nil?
67
+
68
+ raise Error, "#{scope_rule(policy)} returned nil; a scope must return a relation"
69
+ end
70
+
71
+ def scope_rule(policy)
72
+ "#{policy.name}::Scope"
73
+ end
74
+
75
+ # Pundit's own convention, which is what makes per-type scoping of a mixed result
76
+ # possible at all: Invoice => InvoicePolicy.
77
+ def policy_for(record_class)
78
+ name = "#{record_class.name}Policy"
79
+ Object.const_defined?(name) ? Object.const_get(name) : nil
80
+ rescue NameError
81
+ nil
82
+ end
83
+
84
+ private
85
+
86
+ # Pundit policies are constructed with a record; for an index-style check there is
87
+ # no record yet, so the class stands in — the same thing `authorize Invoice` does.
88
+ def inferred_subject(policy)
89
+ name = policy.name.to_s.sub(/Policy\z/, "")
90
+ Object.const_defined?(name) ? Object.const_get(name) : nil
91
+ rescue NameError
92
+ nil
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ # The envelope's `authorizer` collaborator: turns a guard declaration plus a principal
6
+ # into a Decision, through whichever adapter the policy speaks.
7
+ #
8
+ # It deliberately does not rescue. The envelope converts a raising policy into
9
+ # `policy_error` and fails closed; swallowing it here would hide which policy broke.
10
+ class Authorizer
11
+ def authorize(context:, guard:)
12
+ adapter = Adapter.resolve(guard.policy)
13
+
14
+ adapter.authorize(
15
+ principal: context.principal,
16
+ policy: guard.policy,
17
+ action: guard.action,
18
+ record: nil
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ # The invocation in progress on this thread.
6
+ #
7
+ # `scoped(Model)` is called from inside a tool body, which has no reference to the
8
+ # envelope, so the envelope's caller publishes the state here and clears it in an
9
+ # ensure. Fiber-local storage (Thread#[]) rather than thread-global, so concurrent
10
+ # invocations — the normal case for an MCP server — cannot see each other's principal.
11
+ module Current
12
+ KEY = :reeve_current_invocation
13
+
14
+ module_function
15
+
16
+ def state
17
+ Thread.current[KEY]
18
+ end
19
+
20
+ def start(context:, declaration:, adapter:)
21
+ previous = state
22
+ Thread.current[KEY] =
23
+ State.new(context: context, declaration: declaration, adapter: adapter)
24
+ previous
25
+ end
26
+
27
+ def finish(previous = nil)
28
+ Thread.current[KEY] = previous
29
+ end
30
+
31
+ def with(context:, declaration:, adapter:)
32
+ previous = start(context: context, declaration: declaration, adapter: adapter)
33
+ yield(state)
34
+ ensure
35
+ finish(previous)
36
+ end
37
+
38
+ def active?
39
+ !state.nil?
40
+ end
41
+
42
+ # Mutable for exactly two reasons: recording that `scoped` was used, and the size of
43
+ # what it scoped. Everything else about an invocation is fixed when it starts.
44
+ class State
45
+ attr_reader :context, :declaration, :adapter
46
+ attr_accessor :scoped_source_count
47
+
48
+ def initialize(context:, declaration:, adapter:)
49
+ @context = context
50
+ @declaration = declaration
51
+ @adapter = adapter
52
+ @scoped_used = false
53
+ @scoped_source_count = nil
54
+ end
55
+
56
+ def scoped_used!
57
+ @scoped_used = true
58
+ end
59
+
60
+ def scoped_used?
61
+ @scoped_used
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ # What `guard_with` records for one tool class: which policy governs it, which action
6
+ # to check, and which of its arguments never reach the ledger in the clear.
7
+ class Declaration
8
+ attr_reader :tool_class, :policy, :action, :redacted_arguments
9
+
10
+ def initialize(tool_class:, policy:, action:, redacted_arguments: [])
11
+ @tool_class = tool_class
12
+ @policy = policy
13
+ @action = action.to_sym
14
+ @redacted_arguments = redacted_arguments.map(&:to_sym).freeze
15
+ end
16
+
17
+ # Resolved on first use rather than at construction: `guard_with` runs inside the
18
+ # class body, and a class defined as `Class.new { ... }` has no name until the
19
+ # constant it is assigned to exists.
20
+ def tool_name
21
+ @tool_name ||= derive_tool_name
22
+ end
23
+
24
+ def with(policy: self.policy, action: self.action,
25
+ redacted_arguments: self.redacted_arguments)
26
+ self.class.new(
27
+ tool_class: tool_class, policy: policy, action: action,
28
+ redacted_arguments: redacted_arguments
29
+ )
30
+ end
31
+
32
+ def for_subclass(subclass)
33
+ self.class.new(
34
+ tool_class: subclass, policy: policy, action: action,
35
+ redacted_arguments: redacted_arguments
36
+ )
37
+ end
38
+
39
+ def policy_name
40
+ policy.respond_to?(:name) && policy.name ? policy.name : policy.class.name
41
+ end
42
+
43
+ private
44
+
45
+ # The name the envelope looks a guard up by. MCP server libraries let a tool name
46
+ # itself; when it does, that name wins, because that is the name the protocol —
47
+ # and therefore the ledger — will use.
48
+ def derive_tool_name
49
+ declared = tool_class.respond_to?(:tool_name) ? tool_class.tool_name : nil
50
+ return declared.to_s unless declared.nil? || declared.to_s.empty?
51
+
52
+ name = tool_class.name
53
+ if name.nil? || name.empty?
54
+ raise ConfigurationError,
55
+ "#{tool_class.inspect} has no name, so reeve cannot identify it in the " \
56
+ "ledger. Assign it to a constant, or define `def self.tool_name`."
57
+ end
58
+
59
+ underscore(name)
60
+ end
61
+
62
+ # ActiveSupport is not a dependency of the core, so this is deliberately small:
63
+ # it handles the CamelCase and Namespaced::CamelCase shapes tool classes actually
64
+ # have, and nothing more.
65
+ def underscore(name)
66
+ name.gsub("::", "_")
67
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
68
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
69
+ .downcase
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # The tool-side surface: two macros and one helper. `include Reeve::Guard` in a tool
5
+ # class (the fast-mcp adapter does it for you) and declare which policy governs it.
6
+ #
7
+ # class InvoiceSearchTool
8
+ # include Reeve::Guard
9
+ # guard_with InvoicePolicy
10
+ # redact :customer_ssn
11
+ #
12
+ # def call(query:)
13
+ # Invoice.where("number LIKE ?", "%#{query}%")
14
+ # end
15
+ # end
16
+ module Guard
17
+ def self.included(base)
18
+ base.extend(ClassMethods)
19
+ end
20
+
21
+ # Class-level DSL. See contracts/tool-dsl.md.
22
+ module ClassMethods
23
+ # Declares which policy governs this tool. Absence is not neutral: a tool with no
24
+ # declaration is denied by the envelope (FR-002, FR-004).
25
+ def guard_with(policy, action: nil)
26
+ Authorization::Adapter.validate!(policy)
27
+
28
+ @reeve_guard = Reeve.registry.register(
29
+ tool_class: self,
30
+ policy: policy,
31
+ action: action,
32
+ redacted_arguments: pending_redactions + inherited_redactions
33
+ )
34
+ end
35
+
36
+ # Argument names this tool never writes to the ledger in the clear, on top of the
37
+ # process-wide list (FR-011).
38
+ def redact(*names)
39
+ symbols = names.flatten.map(&:to_sym)
40
+ @pending_redactions = pending_redactions | symbols
41
+
42
+ declaration = reeve_guard
43
+ return symbols if declaration.nil?
44
+
45
+ @reeve_guard = Reeve.registry.add(
46
+ declaration.with(redacted_arguments: declaration.redacted_arguments | symbols)
47
+ )
48
+ symbols
49
+ end
50
+
51
+ # This tool's declaration, inherited from a superclass when it has none of its own.
52
+ def reeve_guard
53
+ Reeve.registry.for_class(self)
54
+ end
55
+
56
+ def guarded?
57
+ !reeve_guard.nil?
58
+ end
59
+
60
+ # A subclass of a guarded tool is itself guarded, and is registered under its own
61
+ # name so the envelope — which only ever has a name — can find it.
62
+ def inherited(subclass)
63
+ super
64
+ declaration = reeve_guard
65
+ # An anonymous subclass has no name to be looked up by; it still inherits the
66
+ # declaration through the ancestry walk in Registry#for_class.
67
+ return if declaration.nil? || subclass.name.nil?
68
+
69
+ Reeve.registry.add(declaration.for_subclass(subclass))
70
+ end
71
+
72
+ def pending_redactions
73
+ @pending_redactions ||= []
74
+ end
75
+
76
+ private
77
+
78
+ def inherited_redactions
79
+ return [] unless superclass.respond_to?(:reeve_guard)
80
+
81
+ inherited = superclass.reeve_guard
82
+ inherited ? inherited.redacted_arguments : []
83
+ end
84
+ end
85
+
86
+ # The scoped relation for the invoking principal. This is how a guarded tool returns
87
+ # anything that is not a record: a count, a sum, a rendered summary. Computing from
88
+ # `scoped(...)` means the tool never held unscoped data, so the derived value is safe
89
+ # by construction rather than by promise (R4).
90
+ def scoped(model_or_relation)
91
+ state = Authorization::Current.state
92
+ raise Error, "scoped(...) may only be called inside a guarded invocation" if state.nil?
93
+
94
+ Authorization::Scoper.scoped_relation(state, model_or_relation)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Reeve — per-record authorization and an append-only audit ledger for MCP tools.
4
+ module Reeve
5
+ module Authorization
6
+ # Every `guard_with` declaration in the process, keyed by tool class.
7
+ #
8
+ # Two lookups matter: by class, which the DSL and inheritance use, and by tool name,
9
+ # which is all the envelope has. Enumeration is what lets the compliance suite ask the
10
+ # question that matters — "is every tool this application exposes actually guarded?"
11
+ class Registry
12
+ include Enumerable
13
+
14
+ def initialize
15
+ @declarations = {}
16
+ @name_index = nil # invalidated on every add; see #name_index
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ # The DSL's `guard_with`. Declaring twice on one class is a mistake worth naming;
21
+ # `add` is the quiet path used by `redact` and by inheritance, which refine an
22
+ # existing declaration rather than compete with it.
23
+ def register(tool_class:, policy:, action: nil, redacted_arguments: [])
24
+ warn_about_redeclaration_of(tool_class)
25
+
26
+ declaration = Declaration.new(
27
+ tool_class: tool_class,
28
+ policy: policy,
29
+ action: action || Reeve.config.default_action,
30
+ redacted_arguments: redacted_arguments
31
+ )
32
+ add(declaration)
33
+ end
34
+
35
+ def add(declaration)
36
+ @mutex.synchronize do
37
+ @declarations[declaration.tool_class] = declaration
38
+ @name_index = nil
39
+ declaration
40
+ end
41
+ end
42
+
43
+ # Walks the ancestry, so a subclass inherits its parent's guard and may override it
44
+ # simply by declaring its own.
45
+ def for_class(tool_class)
46
+ return nil unless tool_class.respond_to?(:ancestors)
47
+
48
+ tool_class.ancestors.each do |ancestor|
49
+ declaration = @declarations[ancestor]
50
+ return declaration if declaration
51
+ end
52
+ nil
53
+ end
54
+
55
+ # The envelope's lookup. Returns nil for an unknown tool, which is a denial.
56
+ def guard_for(tool_name)
57
+ name_index[tool_name.to_s]
58
+ end
59
+
60
+ def each(&block)
61
+ @declarations.values.each(&block)
62
+ end
63
+
64
+ def size
65
+ @declarations.size
66
+ end
67
+
68
+ def empty?
69
+ @declarations.empty?
70
+ end
71
+
72
+ # Forgets one tool. Test suites build throwaway tools, and the compliance suite
73
+ # walks this registry — a fixture left behind fails a later, unrelated example.
74
+ def remove(tool_class)
75
+ @mutex.synchronize do
76
+ @declarations.delete(tool_class)
77
+ @name_index = nil
78
+ end
79
+ end
80
+
81
+ def reset!
82
+ @mutex.synchronize do
83
+ @declarations = {}
84
+ @name_index = nil
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def name_index
91
+ @name_index ||= @declarations.values.to_h do |declaration|
92
+ [declaration.tool_name, declaration]
93
+ end
94
+ end
95
+
96
+ def warn_about_redeclaration_of(tool_class)
97
+ return unless @declarations.key?(tool_class)
98
+
99
+ message = "reeve: #{tool_class} declared guard_with more than once; " \
100
+ "the later declaration replaces the earlier one"
101
+ logger = Reeve.config.logger
102
+ logger ? logger.warn(message) : Kernel.warn(message)
103
+ end
104
+ end
105
+ end
106
+
107
+ class << self
108
+ # The process-wide registry the DSL writes to.
109
+ def registry
110
+ @registry ||= Authorization::Registry.new
111
+ end
112
+
113
+ # Public so host test suites can isolate examples from one another.
114
+ def reset_registry!
115
+ registry.reset!
116
+ end
117
+ end
118
+ end