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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -0
- data/README.md +191 -17
- data/lib/generators/reeve/install/install_generator.rb +46 -0
- data/lib/generators/reeve/install/templates/create_audit_entries.rb.tt +69 -0
- data/lib/generators/reeve/install/templates/initializer.rb.tt +65 -0
- data/lib/reeve/audit/entry.rb +48 -0
- data/lib/reeve/audit/query.rb +84 -0
- data/lib/reeve/audit/recorder.rb +163 -0
- data/lib/reeve/audit/redactor.rb +67 -0
- data/lib/reeve/audit.rb +88 -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 +84 -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 +104 -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 +73 -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 +9 -3
- metadata +52 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "authorization/declaration"
|
|
4
|
+
require_relative "authorization/registry"
|
|
5
|
+
require_relative "authorization/adapters/plain"
|
|
6
|
+
require_relative "authorization/adapters/pundit"
|
|
7
|
+
require_relative "authorization/adapter"
|
|
8
|
+
require_relative "authorization/current"
|
|
9
|
+
require_relative "authorization/authorizer"
|
|
10
|
+
require_relative "authorization/scoper"
|
|
11
|
+
require_relative "authorization/guard"
|
|
12
|
+
|
|
13
|
+
# Reeve — per-record authorization and an append-only audit ledger for MCP tools.
|
|
14
|
+
module Reeve
|
|
15
|
+
# Per-record authorization: the registry of guard declarations, the policy adapters,
|
|
16
|
+
# and the scoper that narrows a tool's return value to what the principal may see.
|
|
17
|
+
module Authorization
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# The plain interface, and the composition root for every other one. An MCP server
|
|
22
|
+
# adapter builds the Context and calls this; there is no second path into the
|
|
23
|
+
# envelope, which is what makes "was this authorized and recorded?" answerable in
|
|
24
|
+
# one place.
|
|
25
|
+
#
|
|
26
|
+
# Reeve.invoke(tool: InvoiceSearchTool, arguments: { query: "AC" },
|
|
27
|
+
# principal: current_user, agent: { id: "claude-desktop" })
|
|
28
|
+
def invoke(tool:, arguments: {}, principal: :unset, agent: nil, metadata: {}, &body)
|
|
29
|
+
context = Context.new(
|
|
30
|
+
tool_name: tool_name_for(tool),
|
|
31
|
+
agent: agent,
|
|
32
|
+
arguments: arguments,
|
|
33
|
+
metadata: metadata
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
declaration = registry.guard_for(context.tool_name)
|
|
37
|
+
adapter = declaration ? Authorization::Adapter.resolve(declaration.policy) : nil
|
|
38
|
+
|
|
39
|
+
Authorization::Current.with(context: context, declaration: declaration, adapter: adapter) do
|
|
40
|
+
Invocation.call(
|
|
41
|
+
context,
|
|
42
|
+
registry: registry,
|
|
43
|
+
authorizer: Authorization::Authorizer.new,
|
|
44
|
+
scoper: Authorization::Scoper.new,
|
|
45
|
+
config: configuration_for(principal)
|
|
46
|
+
) { body ? body.call : run(tool, arguments) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def run(tool, arguments)
|
|
53
|
+
instance = tool.is_a?(Class) ? tool.new : tool
|
|
54
|
+
arguments.empty? ? instance.call : instance.call(**arguments)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tool_name_for(tool)
|
|
58
|
+
klass = tool.is_a?(Class) ? tool : tool.class
|
|
59
|
+
declaration = registry.for_class(klass)
|
|
60
|
+
return declaration.tool_name if declaration
|
|
61
|
+
return klass.tool_name.to_s if klass.respond_to?(:tool_name) && klass.tool_name
|
|
62
|
+
|
|
63
|
+
Authorization::Declaration.new(tool_class: klass, policy: nil, action: :index).tool_name
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# An explicitly supplied principal is just a resolver that returns it. Routing it
|
|
67
|
+
# through the same resolution step rather than around it keeps one answer to "where
|
|
68
|
+
# did this principal come from" — the envelope still resolves, records and denies
|
|
69
|
+
# identically, and a nil passed in still denies with `no_principal`.
|
|
70
|
+
def configuration_for(principal)
|
|
71
|
+
return config if principal == :unset
|
|
72
|
+
|
|
73
|
+
config.dup.tap { |scoped| scoped.principal_resolver = ->(_context) { principal } }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Reeve — per-record authorization and an append-only audit ledger for MCP tools.
|
|
4
|
+
module Reeve
|
|
5
|
+
# Process-wide settings. See contracts/configuration.md.
|
|
6
|
+
#
|
|
7
|
+
# Every setting validates at assignment rather than at use: a typo in an initializer
|
|
8
|
+
# should fail on the line that caused it, not three weeks later inside a denial path.
|
|
9
|
+
# The one deliberate exception is +principal_resolver+, which may be nil — the library
|
|
10
|
+
# stays loadable in contexts that never invoke a tool, and denies every call until the
|
|
11
|
+
# host sets one (SC-008).
|
|
12
|
+
class Configuration
|
|
13
|
+
UNGUARDED_TOOL_MODES = %i[deny allow_with_warning].freeze
|
|
14
|
+
AUDIT_FAILURE_MODES = %i[fail warn].freeze
|
|
15
|
+
POLICY_ADAPTERS = %i[auto pundit plain].freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_REDACTED_ARGUMENTS = %i[
|
|
18
|
+
password password_confirmation passwd secret token access_token refresh_token
|
|
19
|
+
api_key private_key authorization ssn credit_card card_number cvv pin
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_RECORDED_IDS = 1000
|
|
23
|
+
|
|
24
|
+
SETTINGS = %i[
|
|
25
|
+
principal_resolver unguarded_tools audit_failure_mode redact_arguments
|
|
26
|
+
max_recorded_ids policy_adapter default_action audit_recorder logger
|
|
27
|
+
compliance_principals
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader(*SETTINGS)
|
|
31
|
+
|
|
32
|
+
def initialize
|
|
33
|
+
@principal_resolver = nil
|
|
34
|
+
@unguarded_tools = :deny
|
|
35
|
+
@audit_failure_mode = :fail
|
|
36
|
+
@redact_arguments = DEFAULT_REDACTED_ARGUMENTS.dup
|
|
37
|
+
@max_recorded_ids = DEFAULT_MAX_RECORDED_IDS
|
|
38
|
+
@policy_adapter = :auto
|
|
39
|
+
@default_action = :index
|
|
40
|
+
@audit_recorder = nil
|
|
41
|
+
@logger = nil
|
|
42
|
+
@compliance_principals = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Two fixture principals with disjoint records — the only host setup the compliance
|
|
46
|
+
# suite needs (contracts/testing-kit.md). A callable rather than a value, because in a
|
|
47
|
+
# Rails test suite the fixtures do not exist yet when the helper is loaded.
|
|
48
|
+
def compliance_principals=(principals)
|
|
49
|
+
unless principals.nil? || principals.respond_to?(:call) || principals.is_a?(Array)
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"compliance_principals must be an Array or a callable returning one, " \
|
|
52
|
+
"got #{principals.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@compliance_principals = principals
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def unguarded_tools=(mode)
|
|
59
|
+
@unguarded_tools = require_one_of!(:unguarded_tools, mode, UNGUARDED_TOOL_MODES)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def audit_failure_mode=(mode)
|
|
63
|
+
@audit_failure_mode = require_one_of!(:audit_failure_mode, mode, AUDIT_FAILURE_MODES)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def max_recorded_ids=(limit)
|
|
67
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
68
|
+
raise ArgumentError, "max_recorded_ids must be a positive Integer, got #{limit.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@max_recorded_ids = limit
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def principal_resolver=(resolver)
|
|
75
|
+
unless resolver.nil? || resolver.respond_to?(:call)
|
|
76
|
+
raise ArgumentError,
|
|
77
|
+
"principal_resolver must respond to #call (it receives a Reeve::Context), " \
|
|
78
|
+
"got #{resolver.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@principal_resolver = resolver
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def policy_adapter=(adapter)
|
|
85
|
+
@policy_adapter =
|
|
86
|
+
if adapter.is_a?(Symbol) || adapter.is_a?(String)
|
|
87
|
+
require_one_of!(:policy_adapter, adapter, POLICY_ADAPTERS)
|
|
88
|
+
else
|
|
89
|
+
require_protocol!(:policy_adapter, adapter, %i[authorize scope])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def redact_arguments=(names)
|
|
94
|
+
unless names.is_a?(Array)
|
|
95
|
+
raise ArgumentError, "redact_arguments must be an Array of names, got #{names.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@redact_arguments = names.map(&:to_sym)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def default_action=(action)
|
|
102
|
+
if action.nil? || action.to_s.strip.empty?
|
|
103
|
+
raise ArgumentError,
|
|
104
|
+
"default_action must be a non-blank policy action, got #{action.inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
@default_action = action.to_sym
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def audit_recorder=(recorder)
|
|
111
|
+
@audit_recorder =
|
|
112
|
+
recorder.nil? ? nil : require_protocol!(:audit_recorder, recorder, [:record])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def logger=(logger)
|
|
116
|
+
@logger = logger.nil? ? nil : require_protocol!(:logger, logger, [:warn])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def to_h
|
|
120
|
+
SETTINGS.to_h { |setting| [setting, public_send(setting)] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def require_one_of!(setting, value, allowed)
|
|
126
|
+
symbol = value.is_a?(Symbol) ? value : nil
|
|
127
|
+
return symbol if allowed.include?(symbol)
|
|
128
|
+
|
|
129
|
+
raise ArgumentError,
|
|
130
|
+
"#{setting} must be one of #{allowed.map(&:inspect).join(', ')}, got #{value.inspect}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def require_protocol!(setting, object, methods)
|
|
134
|
+
missing = methods.reject { |method| object.respond_to?(method) }
|
|
135
|
+
return object if missing.empty?
|
|
136
|
+
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"#{setting} must respond to #{missing.map { |m| "##{m}" }.join(', ')} " \
|
|
139
|
+
"(got #{object.inspect})"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
class << self
|
|
144
|
+
def config
|
|
145
|
+
@config ||= Configuration.new
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def configure
|
|
149
|
+
yield(config)
|
|
150
|
+
config
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Public so host test suites can isolate examples from one another.
|
|
154
|
+
def reset_configuration!
|
|
155
|
+
@config = Configuration.new
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
def to_h
|
|
60
|
+
{
|
|
61
|
+
invocation_id: invocation_id,
|
|
62
|
+
occurred_at: invoked_at,
|
|
63
|
+
tool_name: tool_name,
|
|
64
|
+
agent_id: agent_id,
|
|
65
|
+
agent_name: agent_name,
|
|
66
|
+
principal_type: principal_type,
|
|
67
|
+
principal_id: principal_id,
|
|
68
|
+
arguments: arguments
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inspect
|
|
73
|
+
"#<Reeve::Context tool=#{tool_name.inspect} principal=#{principal_id.inspect} " \
|
|
74
|
+
"agent=#{agent_id.inspect} invocation=#{invocation_id.inspect}>"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def validate_tool_name(name)
|
|
80
|
+
string = name&.to_s
|
|
81
|
+
return string if string && !string.strip.empty?
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "tool_name is required and may not be blank (got #{name.inspect})"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_agent(agent)
|
|
87
|
+
attributes = symbolize(:agent, agent)
|
|
88
|
+
id = attributes[:id]
|
|
89
|
+
attributes[:id] = id.nil? || id.to_s.strip.empty? ? UNKNOWN_AGENT_ID : id.to_s
|
|
90
|
+
attributes[:name] = attributes[:name].to_s unless attributes[:name].nil?
|
|
91
|
+
attributes
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def symbolize(field, hash)
|
|
95
|
+
return {} if hash.nil?
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "#{field} must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
98
|
+
|
|
99
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
100
|
+
result[key.respond_to?(:to_sym) ? key.to_sym : key] = value
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
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
|