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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +191 -0
- data/README.md +280 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +77 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +54 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +181 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +97 -0
- data/lib/reeve/authorization/adapter.rb +71 -0
- data/lib/reeve/authorization/adapters/plain.rb +65 -0
- data/lib/reeve/authorization/adapters/pundit.rb +97 -0
- data/lib/reeve/authorization/authorizer.rb +23 -0
- data/lib/reeve/authorization/current.rb +66 -0
- data/lib/reeve/authorization/declaration.rb +73 -0
- data/lib/reeve/authorization/guard.rb +97 -0
- data/lib/reeve/authorization/registry.rb +118 -0
- data/lib/reeve/authorization/scoper.rb +399 -0
- data/lib/reeve/authorization.rb +76 -0
- data/lib/reeve/configuration.rb +158 -0
- data/lib/reeve/context.rb +111 -0
- data/lib/reeve/decision.rb +111 -0
- data/lib/reeve/errors.rb +89 -0
- data/lib/reeve/fast_mcp.rb +24 -0
- data/lib/reeve/integrations/fast_mcp/context_builder.rb +48 -0
- data/lib/reeve/integrations/fast_mcp/tool_extension.rb +55 -0
- data/lib/reeve/invocation.rb +260 -0
- data/lib/reeve/minitest.rb +19 -0
- data/lib/reeve/rspec.rb +18 -0
- data/lib/reeve/scope_result.rb +104 -0
- data/lib/reeve/testing/assertions.rb +76 -0
- data/lib/reeve/testing/checks/audit_coverage.rb +46 -0
- data/lib/reeve/testing/checks/base.rb +155 -0
- data/lib/reeve/testing/checks/contract_version.rb +97 -0
- data/lib/reeve/testing/checks/cross_principal_leak.rb +132 -0
- data/lib/reeve/testing/checks/guard_declared.rb +33 -0
- data/lib/reeve/testing/checks/principal_required.rb +60 -0
- data/lib/reeve/testing/checks/redaction_holds.rb +142 -0
- data/lib/reeve/testing/checks/rule_present.rb +55 -0
- data/lib/reeve/testing/checks.rb +95 -0
- data/lib/reeve/testing/compliance_assertions.rb +34 -0
- data/lib/reeve/testing/compliance_suite.rb +23 -0
- data/lib/reeve/testing/ledger.rb +82 -0
- data/lib/reeve/testing/matchers/audit_every_call.rb +39 -0
- data/lib/reeve/testing/matchers/base.rb +84 -0
- data/lib/reeve/testing/matchers/deny_access_for.rb +38 -0
- data/lib/reeve/testing/matchers/pass_reeve_check.rb +32 -0
- data/lib/reeve/testing/matchers.rb +33 -0
- data/lib/reeve/testing/report.rb +58 -0
- data/lib/reeve/testing/result.rb +49 -0
- data/lib/reeve/testing.rb +60 -0
- data/lib/reeve/version.rb +1 -1
- data/lib/reeve.rb +10 -3
- 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
|
data/lib/reeve/errors.rb
ADDED
|
@@ -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
|