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,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "securerandom"
5
+
6
+ module Reeve
7
+ # The per-invocation carrier: who is calling, on whose behalf, which tool, with what.
8
+ #
9
+ # Created by an adapter or by the caller of the plain interface — never global, never
10
+ # reused between invocations. The principal is the only mutable field, because the
11
+ # envelope resolves it after the context exists, and clears it in an +ensure+.
12
+ class Context
13
+ UNKNOWN_AGENT_ID = "unknown"
14
+
15
+ attr_reader :invocation_id, :tool_name, :arguments, :metadata, :agent, :invoked_at
16
+ attr_accessor :principal
17
+
18
+ def initialize(tool_name:, principal: nil, agent: nil, arguments: nil, metadata: nil,
19
+ invoked_at: nil, invocation_id: nil)
20
+ @tool_name = validate_tool_name(tool_name)
21
+ @principal = principal
22
+ @agent = build_agent(agent)
23
+ @arguments = symbolize(:arguments, arguments)
24
+ @metadata = symbolize(:metadata, metadata)
25
+ @invoked_at = invoked_at || Time.now
26
+ @invocation_id = (invocation_id || SecureRandom.uuid).to_s
27
+ end
28
+
29
+ def principal_resolved?
30
+ !principal.nil?
31
+ end
32
+
33
+ # Called from the envelope's ensure block: nothing about one invocation may survive
34
+ # into the next on the same thread (data-model invariant 4).
35
+ def clear_principal!
36
+ @principal = nil
37
+ end
38
+
39
+ def principal_type
40
+ principal&.class&.name
41
+ end
42
+
43
+ def principal_id
44
+ return nil if principal.nil?
45
+
46
+ principal.respond_to?(:id) ? principal.id.to_s : principal.to_s
47
+ end
48
+
49
+ def agent_id
50
+ agent[:id]
51
+ end
52
+
53
+ def agent_name
54
+ agent[:name]
55
+ end
56
+
57
+ # The audit-facing projection. The recorder adds the outcome, rule and records;
58
+ # everything here is known before the tool runs.
59
+ #
60
+ # `metadata` belongs here even though nothing in the envelope reads it: the ledger has
61
+ # a column for it, the recorder maps it, and the contract check requires it — but this
62
+ # method used to omit it, so the column was written NULL on every call ever made. The
63
+ # transport detail a reviewer most wants after an incident (which request, which
64
+ # headers, which client) was accepted at the front door and dropped before the write.
65
+ def to_h
66
+ {
67
+ invocation_id: invocation_id,
68
+ occurred_at: invoked_at,
69
+ tool_name: tool_name,
70
+ agent_id: agent_id,
71
+ agent_name: agent_name,
72
+ principal_type: principal_type,
73
+ principal_id: principal_id,
74
+ arguments: arguments,
75
+ metadata: metadata
76
+ }
77
+ end
78
+
79
+ def inspect
80
+ "#<Reeve::Context tool=#{tool_name.inspect} principal=#{principal_id.inspect} " \
81
+ "agent=#{agent_id.inspect} invocation=#{invocation_id.inspect}>"
82
+ end
83
+
84
+ private
85
+
86
+ def validate_tool_name(name)
87
+ string = name&.to_s
88
+ return string if string && !string.strip.empty?
89
+
90
+ raise ArgumentError, "tool_name is required and may not be blank (got #{name.inspect})"
91
+ end
92
+
93
+ def build_agent(agent)
94
+ attributes = symbolize(:agent, agent)
95
+ id = attributes[:id]
96
+ attributes[:id] = id.nil? || id.to_s.strip.empty? ? UNKNOWN_AGENT_ID : id.to_s
97
+ attributes[:name] = attributes[:name].to_s unless attributes[:name].nil?
98
+ attributes
99
+ end
100
+
101
+ def symbolize(field, hash)
102
+ return {} if hash.nil?
103
+
104
+ raise ArgumentError, "#{field} must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
105
+
106
+ hash.each_with_object({}) do |(key, value), result|
107
+ result[key.respond_to?(:to_sym) ? key.to_sym : key] = value
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # The result of a policy evaluation: an outcome, and the rule that produced it.
5
+ #
6
+ # A decision without a rule is not a decision — Constitution II requires every ledger
7
+ # entry to name what decided, so the rule is validated here rather than at write time.
8
+ # Immutable and comparable by value.
9
+ class Decision
10
+ NO_GUARD_DECLARED = "no_guard_declared"
11
+ NO_PRINCIPAL = "no_principal"
12
+ POLICY_ERROR = "policy_error"
13
+ UNKNOWN_RECORD_TYPE = "unknown_record_type"
14
+ UNSCOPED_DERIVED_RESULT = "unscoped_derived_result"
15
+ OUT_OF_SCOPE_RECORD = "out_of_scope_record"
16
+ AUDIT_WRITE_FAILED = "audit_write_failed"
17
+ # An allowed invocation whose tool body raised. No records reached the agent, so the
18
+ # ledger records it as a deny — the trace of a call that blew up is the one most
19
+ # worth having (R5).
20
+ TOOL_ERROR = "tool_error"
21
+
22
+ # The one reserved *allow* rule: a tool with no guard, permitted because the host
23
+ # opted into :allow_with_warning. Kept out of RESERVED_RULES, which names deny paths.
24
+ UNGUARDED_TOOL = "unguarded_tool"
25
+
26
+ # Stable strings. The testing kit and host applications match on them, so a rename
27
+ # here is a breaking change.
28
+ RESERVED_RULES = [
29
+ NO_GUARD_DECLARED,
30
+ NO_PRINCIPAL,
31
+ POLICY_ERROR,
32
+ UNKNOWN_RECORD_TYPE,
33
+ UNSCOPED_DERIVED_RESULT,
34
+ OUT_OF_SCOPE_RECORD,
35
+ AUDIT_WRITE_FAILED,
36
+ TOOL_ERROR
37
+ ].freeze
38
+
39
+ OUTCOMES = %i[allow deny].freeze
40
+
41
+ attr_reader :outcome, :rule, :detail
42
+
43
+ def self.allow(rule:, detail: nil)
44
+ new(outcome: :allow, rule: rule, detail: detail)
45
+ end
46
+
47
+ def self.deny(rule:, detail: nil)
48
+ new(outcome: :deny, rule: rule, detail: detail)
49
+ end
50
+
51
+ def initialize(outcome:, rule:, detail: nil)
52
+ @outcome = validate_outcome(outcome)
53
+ @rule = validate_rule(rule)
54
+ @detail = detail&.to_s
55
+ freeze
56
+ end
57
+
58
+ def allowed?
59
+ outcome == :allow
60
+ end
61
+
62
+ def denied?
63
+ outcome == :deny
64
+ end
65
+
66
+ # True when the rule came from reeve itself rather than from a host policy.
67
+ def reserved_rule?
68
+ RESERVED_RULES.include?(rule)
69
+ end
70
+
71
+ def to_h
72
+ { outcome: outcome.to_s, rule: rule, detail: detail }
73
+ end
74
+
75
+ def ==(other)
76
+ other.is_a?(Decision) &&
77
+ other.outcome == outcome &&
78
+ other.rule == rule &&
79
+ other.detail == detail
80
+ end
81
+ alias eql? ==
82
+
83
+ def hash
84
+ [self.class, outcome, rule, detail].hash
85
+ end
86
+
87
+ def to_s
88
+ "#{outcome}(#{rule})"
89
+ end
90
+
91
+ def inspect
92
+ "#<Reeve::Decision #{outcome} rule=#{rule.inspect} detail=#{detail.inspect}>"
93
+ end
94
+
95
+ private
96
+
97
+ def validate_outcome(outcome)
98
+ symbol = outcome.respond_to?(:to_sym) ? outcome.to_sym : outcome
99
+ return symbol if OUTCOMES.include?(symbol)
100
+
101
+ raise ArgumentError, "outcome must be :allow or :deny, got #{outcome.inspect}"
102
+ end
103
+
104
+ def validate_rule(rule)
105
+ string = rule&.to_s
106
+ return string.freeze unless string.nil? || string.strip.empty?
107
+
108
+ raise ArgumentError, "rule is required and may not be blank (got #{rule.inspect})"
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # Base class for everything reeve raises. Hosts can rescue this one class.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when reeve is asked to run with a configuration it cannot honour.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when a guarded invocation is denied.
11
+ #
12
+ # The message names the tool, the principal and the rule, because the first question
13
+ # a developer asks is "which rule stopped this, and for whom?" (Constitution VI).
14
+ # It deliberately never names a record: see .out_of_scope.
15
+ class DeniedError < Error
16
+ attr_reader :tool_name, :principal_id, :rule, :detail
17
+
18
+ def self.from(decision, tool_name:, principal_id:)
19
+ unless decision.denied?
20
+ raise ArgumentError, "cannot build a DeniedError from an allow decision (#{decision})"
21
+ end
22
+
23
+ new(
24
+ tool_name: tool_name,
25
+ principal_id: principal_id,
26
+ rule: decision.rule,
27
+ detail: decision.detail
28
+ )
29
+ end
30
+
31
+ # FR-006. Fetching a record outside the principal's scope must be indistinguishable
32
+ # from fetching one that does not exist, so this builder accepts no record at all —
33
+ # there is nothing to leak, by construction rather than by discipline.
34
+ def self.out_of_scope(tool_name:, principal_id:)
35
+ new(
36
+ tool_name: tool_name,
37
+ principal_id: principal_id,
38
+ rule: Decision::OUT_OF_SCOPE_RECORD,
39
+ detail: "the requested record is not within this principal's scope"
40
+ )
41
+ end
42
+
43
+ def initialize(tool_name:, principal_id:, rule:, detail: nil)
44
+ @tool_name = tool_name&.to_s
45
+ @principal_id = principal_id&.to_s
46
+ @rule = rule.to_s
47
+ @detail = detail&.to_s
48
+ super(build_message)
49
+ end
50
+
51
+ private
52
+
53
+ def build_message
54
+ principal = principal_id.nil? ? "no principal" : "principal #{principal_id}"
55
+ base = "reeve denied #{tool_name} for #{principal}: #{rule}"
56
+ detail.nil? ? base : "#{base} (#{detail})"
57
+ end
58
+ end
59
+
60
+ # Raised when the ledger write fails and audit_failure_mode is :fail (FR-012).
61
+ #
62
+ # It carries a rule like any other denial, because the failure is not recorded anywhere
63
+ # else: the ledger is the thing that failed, so there is no row to read afterwards. The
64
+ # exception is the only artifact, and a host matching on rules can match on this one.
65
+ class AuditWriteError < Error
66
+ attr_reader :invocation_id, :original_error, :rule
67
+
68
+ # `during` is whatever the invocation was already raising when the ledger write
69
+ # failed — usually a DeniedError, sometimes the tool's own exception. It is carried
70
+ # rather than discarded: the audit failure must win, because a call that cannot be
71
+ # recorded is a failed call, but the developer still needs to see what the call was
72
+ # doing at the time.
73
+ attr_reader :during
74
+
75
+ def initialize(invocation_id:, cause: nil, during: nil)
76
+ @invocation_id = invocation_id&.to_s
77
+ @original_error = cause
78
+ @during = during
79
+ @rule = Decision::AUDIT_WRITE_FAILED
80
+ message = "reeve could not record invocation #{@invocation_id}"
81
+ message = "#{message}: #{cause.message}" if cause
82
+ if during
83
+ message = "#{message} (while the invocation was already failing with " \
84
+ "#{during.class}: #{during.message})"
85
+ end
86
+ super(message)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../reeve"
4
+
5
+ begin
6
+ require "fast_mcp"
7
+ rescue LoadError => e
8
+ raise Reeve::ConfigurationError,
9
+ "reeve/fast_mcp needs the fast-mcp gem, which could not be loaded (#{e.message}). " \
10
+ "Add fast-mcp to your Gemfile, or use Reeve.invoke directly — the core needs no " \
11
+ "MCP server library."
12
+ end
13
+
14
+ require_relative "integrations/fast_mcp/context_builder"
15
+ require_relative "integrations/fast_mcp/tool_extension"
16
+
17
+ module Reeve
18
+ # Adapters for the MCP server libraries reeve rides on. Each is opt-in, conditionally
19
+ # loaded, and never a dependency of the core (Constitution IV).
20
+ module Integrations
21
+ end
22
+ end
23
+
24
+ Reeve::Integrations::FastMcp.install!
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Integrations
5
+ # The fast-mcp bridge: the DSL on every tool, and the envelope around every call.
6
+ module FastMcp
7
+ # Turns what a fast-mcp tool instance knows about its request into the attributes
8
+ # a Reeve::Context is built from.
9
+ #
10
+ # fast-mcp constructs a tool as `tool.new(headers: headers)` per request and calls
11
+ # `call_with_schema_validation!` on it, so the transport's headers are the only
12
+ # per-request context a tool has. They are passed through to the principal resolver
13
+ # untouched: the header that identifies the human is the host's decision, not ours
14
+ # (research R2, resolved against fast-mcp 1.6.0).
15
+ module ContextBuilder
16
+ # Checked in order. The first that answers names the client for attribution only.
17
+ AGENT_HEADERS = %w[X-MCP-Client X-Client-Name User-Agent].freeze
18
+
19
+ module_function
20
+
21
+ def attributes(tool)
22
+ headers = headers_for(tool)
23
+
24
+ {
25
+ agent: { id: agent_id(headers), name: headers["X-MCP-Client"] },
26
+ metadata: { headers: headers }
27
+ }
28
+ end
29
+
30
+ def headers_for(tool)
31
+ headers = tool.respond_to?(:headers) ? tool.headers : nil
32
+ headers.is_a?(Hash) ? headers : {}
33
+ end
34
+
35
+ # Attribution is not authorization: a client that names itself is recorded by that
36
+ # name, and one that does not is recorded as unknown rather than refused.
37
+ def agent_id(headers)
38
+ AGENT_HEADERS.each do |header|
39
+ value = headers[header] || headers[header.downcase]
40
+ return value.to_s unless value.nil? || value.to_s.strip.empty?
41
+ end
42
+
43
+ Context::UNKNOWN_AGENT_ID
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ module Integrations
5
+ # The fast-mcp bridge: the DSL on every tool, and the envelope around every call.
6
+ module FastMcp
7
+ # Routes every fast-mcp tool call through the envelope.
8
+ #
9
+ # Prepended to each tool *subclass* rather than to `FastMcp::Tool` itself: a
10
+ # prepended module precedes the class's own methods, but a module prepended to the
11
+ # parent still sits behind the subclass's `call`. Prepending at `inherited` time
12
+ # works even though `call` is defined afterwards, which is what makes this
13
+ # impossible to forget — there is no "remember to wrap your tool" step.
14
+ module ToolExtension
15
+ def call(**arguments)
16
+ attributes = ContextBuilder.attributes(self)
17
+
18
+ Reeve.invoke(
19
+ tool: self.class,
20
+ arguments: arguments,
21
+ agent: attributes[:agent],
22
+ metadata: attributes[:metadata]
23
+ ) { super(**arguments) }
24
+ end
25
+ end
26
+
27
+ # Installs the extension into every tool defined from here on.
28
+ module Inheritance
29
+ def inherited(subclass)
30
+ super
31
+ subclass.prepend(ToolExtension)
32
+ end
33
+ end
34
+
35
+ module_function
36
+
37
+ # Idempotent: requiring "reeve/fast_mcp" twice must not stack two envelopes around
38
+ # the same call.
39
+ def install!(tool_base = ::FastMcp::Tool)
40
+ tool_base.include(Reeve::Guard) unless tool_base.include?(Reeve::Guard)
41
+
42
+ unless tool_base.singleton_class.include?(Inheritance)
43
+ tool_base.singleton_class.prepend(Inheritance)
44
+ end
45
+
46
+ # Tools defined before this require still get the envelope.
47
+ tool_base.subclasses.each do |subclass|
48
+ subclass.prepend(ToolExtension) unless subclass.include?(ToolExtension)
49
+ end
50
+
51
+ tool_base
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reeve
4
+ # The single funnel every guarded tool call passes through:
5
+ #
6
+ # resolve principal → look up guard → authorize → execute → scope → record → return
7
+ #
8
+ # One envelope rather than middleware or a patched dispatcher, so there is exactly one
9
+ # place where "did this get authorized and recorded?" can be answered — and exactly one
10
+ # place to audit when the answer must be yes.
11
+ #
12
+ # Collaborators are injected and default to null objects that fail closed: a kernel with
13
+ # no authorization module wired in denies everything, and one with no ledger refuses the
14
+ # call rather than performing it silently.
15
+ class Invocation
16
+ # A tool with no guard: there is nothing to consult, so there is nothing to allow.
17
+ class NullRegistry
18
+ def guard_for(_tool_name)
19
+ nil
20
+ end
21
+ end
22
+
23
+ # No policy adapter wired in: nothing can be evaluated, so nothing is permitted.
24
+ class NullAuthorizer
25
+ def authorize(context:, guard:)
26
+ Decision.deny(rule: Decision::POLICY_ERROR, detail: "no policy adapter is configured")
27
+ end
28
+ end
29
+
30
+ # No scoper wired in: a result that cannot be narrowed cannot be returned.
31
+ class NullScoper
32
+ def scope(context:, guard:, result:)
33
+ ScopeResult.deny(
34
+ rule: Decision::UNSCOPED_DERIVED_RESULT,
35
+ detail: "no scoper is configured, so the result could not be narrowed"
36
+ )
37
+ end
38
+ end
39
+
40
+ # Constitution II: a failure to record is a failure of the call. With no ledger
41
+ # configured there is nothing to fail into, so this refuses rather than no-ops.
42
+ class NullRecorder
43
+ def record(_attributes)
44
+ raise Error, "no audit recorder is configured (run `rails g reeve:install`)"
45
+ end
46
+ end
47
+
48
+ def self.call(context, **collaborators, &tool)
49
+ new(context, **collaborators).call(&tool)
50
+ end
51
+
52
+ def initialize(context, registry: nil, authorizer: nil, scoper: nil, recorder: nil,
53
+ config: nil)
54
+ @context = context
55
+ @config = config || Reeve.config
56
+ @registry = registry || NullRegistry.new
57
+ @authorizer = authorizer || NullAuthorizer.new
58
+ @scoper = scoper || NullScoper.new
59
+ @recorder = recorder || @config.audit_recorder || NullRecorder.new
60
+ end
61
+
62
+ def call(&tool)
63
+ started = monotonic_now
64
+ @guard_label = "policy"
65
+ @scope_result = nil
66
+
67
+ @decision = authorize_and_run(&tool)
68
+ raise denial_error(@decision) if @decision.denied?
69
+
70
+ @scope_result.records
71
+ ensure
72
+ # Whatever is already on its way out of this method — a tool error or a denial.
73
+ @in_flight_error = $ERROR_INFO
74
+ @duration_ms = ((monotonic_now - started) * 1000).round
75
+ write_entry
76
+ context.clear_principal!
77
+ end
78
+
79
+ private
80
+
81
+ attr_reader :context, :config, :registry, :authorizer, :scoper, :recorder
82
+
83
+ def authorize_and_run(&tool)
84
+ principal = resolve_principal
85
+ if principal.nil?
86
+ return Decision.deny(rule: Decision::NO_PRINCIPAL,
87
+ detail: principal_denial_detail)
88
+ end
89
+
90
+ context.principal = principal
91
+
92
+ guard = look_up_guard
93
+ return guard if guard.is_a?(Decision) # registry blew up
94
+
95
+ if guard.nil?
96
+ if deny_unguarded?
97
+ return Decision.deny(rule: Decision::NO_GUARD_DECLARED,
98
+ detail: unguarded_detail)
99
+ end
100
+
101
+ return run_unguarded(&tool)
102
+ end
103
+
104
+ decision = authorize(guard)
105
+ return decision if decision.denied?
106
+
107
+ run_guarded(guard, decision, &tool)
108
+ end
109
+
110
+ def resolve_principal
111
+ resolver = config.principal_resolver
112
+ return nil if resolver.nil?
113
+
114
+ resolver.call(context)
115
+ rescue StandardError => e
116
+ # A resolver that raises is a resolver that did not identify anyone (FR-001).
117
+ @principal_error = e
118
+ nil
119
+ end
120
+
121
+ def principal_denial_detail
122
+ return "principal_resolver raised #{@principal_error.class}" if @principal_error
123
+ return "no principal_resolver is configured" if config.principal_resolver.nil?
124
+
125
+ "principal_resolver returned nil"
126
+ end
127
+
128
+ def look_up_guard
129
+ registry.guard_for(context.tool_name)
130
+ rescue StandardError => e
131
+ Decision.deny(rule: Decision::POLICY_ERROR,
132
+ detail: "guard lookup raised #{e.class}: #{e.message}")
133
+ end
134
+
135
+ def deny_unguarded?
136
+ config.unguarded_tools != :allow_with_warning
137
+ end
138
+
139
+ def unguarded_detail
140
+ "#{context.tool_name} has no guard_with declaration"
141
+ end
142
+
143
+ def authorize(guard)
144
+ decision = authorizer.authorize(context: context, guard: guard)
145
+ return decision if decision.is_a?(Decision)
146
+
147
+ Decision.deny(
148
+ rule: Decision::POLICY_ERROR,
149
+ detail: "authorizer returned #{decision.class}, expected a Reeve::Decision"
150
+ )
151
+ rescue StandardError => e
152
+ Decision.deny(rule: Decision::POLICY_ERROR, detail: "policy raised #{e.class}: #{e.message}")
153
+ end
154
+
155
+ def run_guarded(guard, decision, &tool)
156
+ result = execute(&tool)
157
+
158
+ @scope_result = narrow(guard, result)
159
+ @scope_result.denied? ? @scope_result.decision : decision
160
+ end
161
+
162
+ # The degraded mode a host opts into while retrofitting guards onto existing tools
163
+ # (FR-023). The call is unscoped — that is the whole point of the warning.
164
+ def run_unguarded(&tool)
165
+ warn_about_unguarded_tool
166
+ @guard_label = "none"
167
+ result = execute(&tool)
168
+ @scope_result = ScopeResult.unscoped(records: result, rule: Decision::UNGUARDED_TOOL)
169
+ Decision.allow(rule: Decision::UNGUARDED_TOOL, detail: unguarded_detail)
170
+ end
171
+
172
+ def execute(&tool)
173
+ tool.call
174
+ rescue StandardError => e
175
+ # The tool's own failure propagates untouched, but not before the ensure block
176
+ # records it: the invocations most worth having a trace of are the ones that broke.
177
+ @decision = Decision.deny(rule: Decision::TOOL_ERROR, detail: "#{e.class}: #{e.message}")
178
+ @scope_result = ScopeResult.deny(rule: Decision::TOOL_ERROR)
179
+ raise
180
+ end
181
+
182
+ def narrow(guard, result)
183
+ scoped = scoper.scope(context: context, guard: guard, result: result)
184
+ return scoped if scoped.is_a?(ScopeResult)
185
+
186
+ ScopeResult.deny(
187
+ rule: Decision::POLICY_ERROR,
188
+ detail: "scoper returned #{scoped.class}, expected a Reeve::ScopeResult"
189
+ )
190
+ rescue StandardError => e
191
+ ScopeResult.deny(rule: Decision::POLICY_ERROR,
192
+ detail: "scoping raised #{e.class}: #{e.message}")
193
+ end
194
+
195
+ def denial_error(decision)
196
+ if decision.rule == Decision::OUT_OF_SCOPE_RECORD
197
+ DeniedError.out_of_scope(tool_name: context.tool_name, principal_id: context.principal_id)
198
+ else
199
+ DeniedError.from(decision, tool_name: context.tool_name, principal_id: context.principal_id)
200
+ end
201
+ end
202
+
203
+ def write_entry
204
+ return if @entry_written
205
+
206
+ @entry_written = true
207
+ recorder.record(entry_attributes)
208
+ rescue StandardError => e
209
+ handle_write_failure(e)
210
+ end
211
+
212
+ def entry_attributes
213
+ decision = @decision || Decision.deny(rule: Decision::POLICY_ERROR,
214
+ detail: "envelope aborted")
215
+ scope = @scope_result || ScopeResult.deny(rule: decision.rule, detail: decision.detail)
216
+
217
+ context.to_h.merge(scope.to_h).merge(
218
+ outcome: decision.outcome.to_s,
219
+ rule: decision.rule,
220
+ detail: decision.detail,
221
+ guard: @guard_label,
222
+ duration_ms: @duration_ms
223
+ )
224
+ end
225
+
226
+ # Constitution II: a call that cannot be recorded is a failed call, and that holds
227
+ # whether or not the call was failing already.
228
+ #
229
+ # This used to return quietly whenever another exception was in flight, on the
230
+ # reasoning that the developer needs the original error more than the audit error.
231
+ # The reasoning was wrong: denials and tool errors are *most* of what a ledger is
232
+ # consulted about, so the effect was that a broken ledger stayed invisible for exactly
233
+ # the invocations a compliance review looks for, in the default mode, with no
234
+ # exception raised. The in-flight error is now carried on the audit error instead of
235
+ # being traded against it.
236
+ def handle_write_failure(error)
237
+ if config.audit_failure_mode == :warn
238
+ log("reeve could not record invocation #{context.invocation_id}: #{error.message}")
239
+ return
240
+ end
241
+
242
+ raise AuditWriteError.new(
243
+ invocation_id: context.invocation_id, cause: error, during: @in_flight_error
244
+ )
245
+ end
246
+
247
+ def warn_about_unguarded_tool
248
+ log("reeve: #{context.tool_name} has no guard_with declaration and ran unguarded " \
249
+ "(unguarded_tools is :allow_with_warning)")
250
+ end
251
+
252
+ def log(message)
253
+ config.logger&.warn(message)
254
+ end
255
+
256
+ def monotonic_now
257
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
258
+ end
259
+ end
260
+ end