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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # The one write path into the ledger (FR-008).
6
+ #
7
+ # The envelope calls this from an `ensure` block and expects it to raise on failure —
8
+ # Constitution II makes a failed write a failed call unless the host has opted into a
9
+ # degraded mode. Everything here is therefore synchronous: no queue, no thread, no
10
+ # ActiveJob. An asynchronous ledger cannot be relied on and would make FR-012
11
+ # unenforceable.
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.
16
+ #
17
+ # What it does **not** do, corrected after review: `requires_new` is a SAVEPOINT, not
18
+ # an independent transaction. If the host has already opened a transaction *around*
19
+ # the invocation — a controller or middleware that wraps each request, or a test suite
20
+ # using transactional fixtures — the savepoint is released into that transaction, and
21
+ # a later rollback takes the ledger row with it. The write reports success and the
22
+ # envelope has no way to learn otherwise, so the invocation returns records with no
23
+ # surviving trace. The comment here previously claimed independence outright; it did
24
+ # not have it.
25
+ #
26
+ # A genuinely independent write needs a second connection, and that is not portable:
27
+ # on SQLite the enclosing transaction holds the write lock, so a second connection
28
+ # blocks until it times out. Rather than fail every call on the databases where
29
+ # isolation is impossible, the recorder detects the enclosing transaction and warns
30
+ # that the guarantee is suspended for that call. A host that needs durability under a
31
+ # wrapping transaction supplies its own `audit_recorder` — writing to a separate
32
+ # connection, a queue, or an append-only log — which is what that setting is for.
33
+ #
34
+ # The other known limit, unchanged: on a single connection there is a narrow window
35
+ # where the tool's data commits and the ledger write then fails. The caller learns by
36
+ # exception, but the data change has already landed.
37
+ class Recorder
38
+ # Free-form text from a policy or an exception message. Capped rather than trusted:
39
+ # it is the one column whose length the host does not control.
40
+ DETAIL_LIMIT = 1000
41
+
42
+ def self.record(attributes)
43
+ new.record(attributes)
44
+ end
45
+
46
+ def initialize(entry_class: Entry, config: nil)
47
+ @entry_class = entry_class
48
+ @config = config
49
+ end
50
+
51
+ # Returns the entry. Raises if the row could not be written, so the envelope can
52
+ # fail the invocation.
53
+ def record(attributes)
54
+ row = row_for(attributes)
55
+ warn_about_enclosing_transaction(row[:invocation_id])
56
+
57
+ entry_class.transaction(requires_new: true) do
58
+ entry_class.create!(row)
59
+ end
60
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
61
+ # A replayed invocation is the same invocation. One row per invocation_id means
62
+ # a retry is a no-op insert, never a second row (contracts/audit-entry.md).
63
+ existing = entry_class.find_by(invocation_id: row[:invocation_id])
64
+ raise e if existing.nil?
65
+
66
+ existing
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :entry_class
72
+
73
+ def config
74
+ @config || Reeve.config
75
+ end
76
+
77
+ def row_for(attributes)
78
+ ids, count, truncated = identifiers(attributes)
79
+
80
+ identity(attributes).merge(
81
+ arguments: redact(attributes),
82
+ outcome: attributes[:outcome].to_s,
83
+ rule: attributes[:rule],
84
+ detail: detail(attributes),
85
+ record_type: attributes[:record_type],
86
+ record_ids: ids,
87
+ record_count: count,
88
+ truncated: truncated,
89
+ derived: attributes[:derived] ? true : false,
90
+ guard: blank?(attributes[:guard]) ? "policy" : attributes[:guard].to_s,
91
+ duration_ms: attributes[:duration_ms],
92
+ metadata: attributes[:metadata]
93
+ )
94
+ end
95
+
96
+ def identity(attributes)
97
+ {
98
+ invocation_id: attributes[:invocation_id],
99
+ occurred_at: attributes[:occurred_at],
100
+ agent_id: agent_id(attributes),
101
+ agent_name: attributes[:agent_name],
102
+ principal_type: attributes[:principal_type],
103
+ principal_id: attributes[:principal_id]&.to_s,
104
+ tool_name: attributes[:tool_name]
105
+ }
106
+ end
107
+
108
+ # An unidentifiable agent is recorded as unknown, never dropped: attribution is not
109
+ # authorization, and a row that names no agent still answers most of the question.
110
+ def agent_id(attributes)
111
+ blank?(attributes[:agent_id]) ? Context::UNKNOWN_AGENT_ID : attributes[:agent_id]
112
+ end
113
+
114
+ def redact(attributes)
115
+ Redactor.for(attributes[:tool_name], config: config).call(attributes[:arguments])
116
+ end
117
+
118
+ # FR-014: identifiers are capped, never silently dropped — the count stays true and
119
+ # the row says it was truncated. The scoper caps first; this is the backstop that
120
+ # makes the guarantee hold at the ledger regardless of who produced the list.
121
+ def identifiers(attributes)
122
+ ids = Array(attributes[:record_ids]).map(&:to_s)
123
+ count = attributes[:record_count] || ids.size
124
+ truncated = attributes[:truncated] ? true : false
125
+ limit = config.max_recorded_ids
126
+
127
+ return [ids, count, truncated] unless ids.size > limit
128
+
129
+ [ids.first(limit), [count, ids.size].max, true]
130
+ end
131
+
132
+ def detail(attributes)
133
+ value = attributes[:detail]
134
+ return nil if blank?(value)
135
+
136
+ value.to_s[0, DETAIL_LIMIT]
137
+ end
138
+
139
+ # The host's transaction, not the tool's: the tool's own transaction is nested
140
+ # inside this write and is exactly what `requires_new` protects against.
141
+ def warn_about_enclosing_transaction(invocation_id)
142
+ return unless enclosing_transaction?
143
+
144
+ message = "reeve: invocation #{invocation_id} was recorded inside a transaction " \
145
+ "the host opened around it, so the ledger row will be rolled back with " \
146
+ "it. Configure Reeve.config.audit_recorder with a recorder that writes " \
147
+ "outside this transaction if the trace must survive."
148
+ logger = config.logger
149
+ logger ? logger.warn(message) : Kernel.warn(message)
150
+ end
151
+
152
+ def enclosing_transaction?
153
+ entry_class.connection.transaction_open?
154
+ rescue StandardError
155
+ false
156
+ end
157
+
158
+ def blank?(value)
159
+ value.nil? || value.to_s.strip.empty?
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Audit
5
+ # Removes declared-sensitive values from the arguments before they are written
6
+ # (FR-011).
7
+ #
8
+ # Names always survive: an entry that cannot say *which* argument was passed answers
9
+ # nothing after an incident. Values of declared names are replaced, recursively,
10
+ # wherever they appear. Matching is on the name, never on the value — pattern-sniffing
11
+ # a compliance artifact both misses and over-matches (R6).
12
+ #
13
+ # The input is never mutated and never stored: the redacted hash is a new structure,
14
+ # so no unredacted copy exists downstream of this object.
15
+ class Redactor
16
+ MARKER = "[REDACTED]"
17
+
18
+ # The names declared globally, plus the ones this tool's own guard declared.
19
+ def self.for(tool_name, config: Reeve.config, registry: Audit.guard_registry)
20
+ new(Array(config.redact_arguments) + tool_names(tool_name, registry))
21
+ end
22
+
23
+ def self.tool_names(tool_name, registry)
24
+ return [] unless registry.respond_to?(:guard_for)
25
+
26
+ guard = registry.guard_for(tool_name)
27
+ return [] unless guard.respond_to?(:redacted_arguments)
28
+
29
+ Array(guard.redacted_arguments)
30
+ end
31
+ private_class_method :tool_names
32
+
33
+ def initialize(names, marker: MARKER)
34
+ @names = Array(names).map { |name| name.to_s.downcase }.uniq.freeze
35
+ @marker = marker
36
+ end
37
+
38
+ def call(arguments)
39
+ return {} if arguments.nil?
40
+
41
+ redact_hash(arguments)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :names, :marker
47
+
48
+ def redact_hash(hash)
49
+ hash.each_with_object({}) do |(key, value), result|
50
+ result[key] = sensitive?(key) ? marker : redact(value)
51
+ end
52
+ end
53
+
54
+ def redact(value)
55
+ case value
56
+ when Hash then redact_hash(value)
57
+ when Array then value.map { |element| redact(element) }
58
+ else value
59
+ end
60
+ end
61
+
62
+ def sensitive?(key)
63
+ names.include?(key.to_s.downcase)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reeve"
4
+
5
+ begin
6
+ require "active_record"
7
+ rescue LoadError => e
8
+ raise Reeve::ConfigurationError,
9
+ "reeve/audit needs activerecord, which could not be loaded (#{e.message}). " \
10
+ "Add activerecord to your Gemfile, or configure a non-ActiveRecord " \
11
+ "Reeve.config.audit_recorder instead."
12
+ end
13
+
14
+ module Reeve
15
+ # The append-only ledger: one row per guarded invocation, allowed or denied
16
+ # (Constitution II, FR-008).
17
+ #
18
+ # This file is the opt-in boundary. `require "reeve"` must keep working in a bare Ruby
19
+ # process with no ActiveRecord (SC-008), so nothing here is loaded by the core — a host
20
+ # (or the generated initializer) requires "reeve/audit" when it wants the table-backed
21
+ # recorder.
22
+ #
23
+ # require "reeve/audit"
24
+ #
25
+ # Reeve::Audit::Query.for_principal(user)
26
+ # .for_agent("claude-desktop")
27
+ # .between(1.week.ago, Time.current)
28
+ # .pluck(:tool_name, :record_type, :record_ids, :outcome, :rule)
29
+ #
30
+ # == What this module guarantees
31
+ #
32
+ # * exactly one row per invocation, allowed or denied — `invocation_id` is unique, and
33
+ # a replayed invocation is a no-op insert rather than a second row;
34
+ # * `rule` is never null: every row explains itself;
35
+ # * arguments are post-redaction, and no unredacted copy is written anywhere;
36
+ # * `record_count` stays true even when `record_ids` was capped, and `truncated` says so;
37
+ # * `occurred_at` is invocation time, not write time;
38
+ # * no public method updates or deletes an entry.
39
+ #
40
+ # == What it does not
41
+ #
42
+ # * It does not stop a database superuser, a migration, or raw SQL from rewriting the
43
+ # table. Immutability is enforced at the library level; the generated migration
44
+ # documents the `GRANT INSERT, SELECT` that enforces the rest where it can actually be
45
+ # enforced, and the gem makes no stronger claim than that.
46
+ # * No retention, rotation or archival in v1 — the table is host-owned and the host's
47
+ # existing policies apply.
48
+ # * No cryptographic chaining or tamper-evidence in v1. If that lands it arrives as a
49
+ # nullable column, which the audit-entry contract's versioning already permits.
50
+ module Audit
51
+ # The version of the audit-entry shape, as documented in
52
+ # specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
53
+ # column is a MINOR change and leaves this alone; removing or renaming a column, or
54
+ # changing what a value means, is MAJOR and bumps it.
55
+ CONTRACT_VERSION = 1
56
+
57
+ TABLE_NAME = "reeve_audit_entries"
58
+
59
+ class << self
60
+ # Where per-tool redaction declarations come from: the authorization registry, if
61
+ # the authorization module is loaded. Duck-typed and optional on purpose — the
62
+ # ledger is useful on its own, and a host running audit without guards should get
63
+ # the global redaction list rather than a NameError.
64
+ def guard_registry
65
+ return nil unless Reeve.respond_to?(:registry)
66
+
67
+ Reeve.registry
68
+ end
69
+
70
+ # contracts/configuration.md documents `audit_recorder` as defaulting to nil, "which
71
+ # resolves to Reeve::Audit::Recorder at invocation time — the default cannot be the
72
+ # constant itself, since the core loads without ActiveRecord". This is that
73
+ # resolution, performed at the first moment the constant is known to exist: the
74
+ # require of this file. A host that named its own recorder keeps it.
75
+ def install!(config = Reeve.config)
76
+ config.audit_recorder ||= Recorder
77
+ config
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ require_relative "audit/redactor"
84
+ require_relative "audit/entry"
85
+ require_relative "audit/recorder"
86
+ require_relative "audit/query"
87
+
88
+ Reeve::Audit.install!
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ # Chooses the adapter a policy speaks through, and refuses declarations no adapter
6
+ # can serve — at declaration time, so the developer learns immediately rather than on
7
+ # the first denial in production (Constitution VI).
8
+ module Adapter
9
+ BUILT_IN = { plain: Adapters::Plain, pundit: Adapters::Pundit }.freeze
10
+
11
+ module_function
12
+
13
+ def resolve(policy, setting = Reeve.config.policy_adapter)
14
+ case setting
15
+ when :plain then Adapters::Plain.new
16
+ when :pundit then Adapters::Pundit.new
17
+ when :auto then auto_resolve(policy)
18
+ else setting # a host-supplied adapter object; Configuration validated its protocol
19
+ end
20
+ end
21
+
22
+ def resolve_name(policy, setting = Reeve.config.policy_adapter)
23
+ return setting unless setting == :auto
24
+
25
+ Adapters::Pundit.supports?(policy) ? :pundit : :plain
26
+ end
27
+
28
+ # Raises unless some adapter can serve this policy. Called from `guard_with`.
29
+ def validate!(policy, setting = Reeve.config.policy_adapter)
30
+ return true if supported?(policy, setting)
31
+
32
+ raise ConfigurationError, unsupported_message(policy, setting)
33
+ end
34
+
35
+ def supported?(policy, setting = Reeve.config.policy_adapter)
36
+ case setting
37
+ when :plain then Adapters::Plain.supports?(policy)
38
+ when :pundit then Adapters::Pundit.supports?(policy)
39
+ when :auto then Adapters::Pundit.supports?(policy) || Adapters::Plain.supports?(policy)
40
+ else true # a custom adapter is trusted to know its own policies
41
+ end
42
+ end
43
+
44
+ def auto_resolve(policy)
45
+ Adapters::Pundit.supports?(policy) ? Adapters::Pundit.new : Adapters::Plain.new
46
+ end
47
+
48
+ def unsupported_message(policy, setting)
49
+ described = policy.respond_to?(:name) && policy.name ? policy.name : policy.inspect
50
+ missing = Adapters::Plain.missing_methods(policy).map do |method|
51
+ "##{method}"
52
+ end.join(" and ")
53
+
54
+ "#{described} cannot be used as a reeve policy (policy_adapter is #{setting.inspect}). " \
55
+ "A plain policy must respond to #{missing.empty? ? '#authorize and #scope' : missing}; " \
56
+ "a Pundit policy must define a query method and a Scope class."
57
+ end
58
+ end
59
+ end
60
+
61
+ # Reopened to answer one question the adapter layer owns: which adapter a policy will
62
+ # actually be served by. Kept here rather than in the kernel so configuration.rb stays
63
+ # free of any knowledge of policies.
64
+ class Configuration
65
+ # Which adapter `:auto` actually chose. Documented in contracts/policy-adapter.md so
66
+ # the choice is never a mystery, and so the compliance suite can assert on it.
67
+ def resolved_policy_adapter(policy = nil)
68
+ Authorization::Adapter.resolve_name(policy, policy_adapter)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Authorization
5
+ module Adapters
6
+ # Plain policy objects — always available, no dependency on anything.
7
+ #
8
+ # class InvoicePolicy
9
+ # def self.authorize(principal, action, record) = ...
10
+ # def self.scope(principal, relation) = relation.where(owner: principal)
11
+ # end
12
+ class Plain
13
+ REQUIRED_METHODS = %i[authorize scope].freeze
14
+
15
+ def self.supports?(policy)
16
+ REQUIRED_METHODS.all? { |method| policy.respond_to?(method) }
17
+ end
18
+
19
+ def self.missing_methods(policy)
20
+ REQUIRED_METHODS.reject { |method| policy.respond_to?(method) }
21
+ end
22
+
23
+ def authorize(principal:, policy:, action:, record: nil)
24
+ rule = rule_for(policy, action)
25
+ allowed = policy.authorize(principal, action, record)
26
+
27
+ allowed ? Decision.allow(rule: rule) : Decision.deny(rule: rule)
28
+ end
29
+
30
+ def scope(principal:, policy:, relation:)
31
+ scoped = policy.scope(principal, relation)
32
+ return scoped unless scoped.nil?
33
+
34
+ # A nil scope is a policy that did not answer. Answering "everything" would be
35
+ # the dangerous reading, so this is an error rather than a fallback.
36
+ raise Error, "#{rule_for(policy, :scope)} returned nil; a scope must return a relation"
37
+ end
38
+
39
+ def scope_rule(policy)
40
+ rule_for(policy, :scope)
41
+ end
42
+
43
+ # A tool may return more than the type its declared policy governs. Rather than
44
+ # denying every mixed result, reeve looks for the conventional `<Model>Policy`
45
+ # for the other types — and denies when there is not one (unknown_record_type).
46
+ def policy_for(record_class)
47
+ name = "#{record_class.name}Policy"
48
+ return nil unless Object.const_defined?(name)
49
+
50
+ policy = Object.const_get(name)
51
+ self.class.supports?(policy) ? policy : nil
52
+ rescue NameError
53
+ nil
54
+ end
55
+
56
+ private
57
+
58
+ def rule_for(policy, action)
59
+ name = policy.respond_to?(:name) && policy.name ? policy.name : policy.class.name
60
+ "#{name}##{action}"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,84 @@
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
+ # `false` matters: const_defined? otherwise walks up to Object, where any
26
+ # top-level Scope constant would make an unrelated policy look Pundit-shaped.
27
+ policy.const_defined?(:Scope, false) &&
28
+ policy.public_instance_methods.any? { |method| method.to_s.end_with?("?") }
29
+ end
30
+
31
+ def self.missing_methods(policy)
32
+ return [] if supports?(policy)
33
+ return [:Scope] if policy.is_a?(Class) && !policy.const_defined?(:Scope, false)
34
+
35
+ %i[query_method Scope]
36
+ end
37
+
38
+ def authorize(principal:, policy:, action:, record: nil)
39
+ query = "#{action}?"
40
+ rule = "#{policy.name}##{query}"
41
+ subject = record || inferred_subject(policy)
42
+
43
+ if policy.new(principal,
44
+ subject).public_send(query)
45
+ Decision.allow(rule: rule)
46
+ else
47
+ Decision.deny(rule: rule)
48
+ end
49
+ end
50
+
51
+ def scope(principal:, policy:, relation:)
52
+ scoped = policy::Scope.new(principal, relation).resolve
53
+ return scoped unless scoped.nil?
54
+
55
+ raise Error, "#{scope_rule(policy)} returned nil; a scope must return a relation"
56
+ end
57
+
58
+ def scope_rule(policy)
59
+ "#{policy.name}::Scope"
60
+ end
61
+
62
+ # Pundit's own convention, which is what makes per-type scoping of a mixed result
63
+ # possible at all: Invoice => InvoicePolicy.
64
+ def policy_for(record_class)
65
+ name = "#{record_class.name}Policy"
66
+ Object.const_defined?(name) ? Object.const_get(name) : nil
67
+ rescue NameError
68
+ nil
69
+ end
70
+
71
+ private
72
+
73
+ # Pundit policies are constructed with a record; for an index-style check there is
74
+ # no record yet, so the class stands in — the same thing `authorize Invoice` does.
75
+ def inferred_subject(policy)
76
+ name = policy.name.to_s.sub(/Policy\z/, "")
77
+ Object.const_defined?(name) ? Object.const_get(name) : nil
78
+ rescue NameError
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
84
+ 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