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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# reeve — per-record authorization and an append-only audit ledger for the MCP tools
|
|
4
|
+
# this application exposes to AI agents.
|
|
5
|
+
#
|
|
6
|
+
# Everything below is a live setting; the only one you must fill in is the first.
|
|
7
|
+
|
|
8
|
+
Reeve.configure do |config|
|
|
9
|
+
# ---------------------------------------------------------------------------
|
|
10
|
+
# REQUIRED. Who is the agent acting for?
|
|
11
|
+
#
|
|
12
|
+
# Receives a Reeve::Context and returns the principal — the human whose
|
|
13
|
+
# permissions govern this call — or nil to deny it. Raising is treated as nil:
|
|
14
|
+
# reeve fails closed rather than guessing.
|
|
15
|
+
#
|
|
16
|
+
# Where the identity comes from is yours to decide. With the fast-mcp adapter
|
|
17
|
+
# the request's headers arrive as context.metadata[:headers]:
|
|
18
|
+
#
|
|
19
|
+
# config.principal_resolver = lambda do |context|
|
|
20
|
+
# token = context.metadata.dig(:headers, "Authorization")
|
|
21
|
+
# ApiToken.find_by(token: token)&.user
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# TODO: replace this with your own lookup. Until you do, every guarded call
|
|
25
|
+
# denies with the rule `no_principal` — which is the intended behaviour, not a
|
|
26
|
+
# bug: the library is safe before it is configured.
|
|
27
|
+
config.principal_resolver = ->(context) { nil }
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# What happens to a tool with no `guard_with` declaration?
|
|
31
|
+
#
|
|
32
|
+
# :deny — the call returns nothing and is recorded as denied.
|
|
33
|
+
# :allow_with_warning — the call runs UNSCOPED, is logged, and is recorded
|
|
34
|
+
# with guard: "none". For retrofitting an existing
|
|
35
|
+
# server; every such call is a hole until it is
|
|
36
|
+
# declared.
|
|
37
|
+
#
|
|
38
|
+
# Written out rather than defaulted, because it is a decision you should make
|
|
39
|
+
# knowingly.
|
|
40
|
+
config.unguarded_tools = :deny
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Argument names whose values never reach the ledger. Names survive, values do
|
|
44
|
+
# not, recursively through nested hashes. Add per-tool names with `redact` in
|
|
45
|
+
# the tool itself.
|
|
46
|
+
config.redact_arguments = %i[password token secret ssn]
|
|
47
|
+
|
|
48
|
+
# What happens if the ledger write fails?
|
|
49
|
+
#
|
|
50
|
+
# :fail — the invocation fails. A call that cannot be recorded is a call
|
|
51
|
+
# that did not happen.
|
|
52
|
+
# :warn — log it and continue. Opt in knowingly: it trades the audit
|
|
53
|
+
# guarantee for availability.
|
|
54
|
+
config.audit_failure_mode = :fail
|
|
55
|
+
|
|
56
|
+
# Cap on how many record identifiers one entry stores. The true count is
|
|
57
|
+
# always recorded, and the entry says when the list was truncated.
|
|
58
|
+
config.max_recorded_ids = 1000
|
|
59
|
+
|
|
60
|
+
# Where warnings go (unguarded tools, degraded audit mode).
|
|
61
|
+
config.logger = Rails.logger
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The ledger itself is opt-in, so the core stays loadable without ActiveRecord.
|
|
65
|
+
require "reeve/audit"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
# One ledger row: which agent, acting for which principal, called which tool with
|
|
6
|
+
# which arguments, what came back, and which rule decided (FR-009).
|
|
7
|
+
#
|
|
8
|
+
# Append-only (FR-010). The model enforces that as far as a library can: a persisted
|
|
9
|
+
# row is readonly, so `update` and `save` raise, and `destroy` aborts. It cannot
|
|
10
|
+
# enforce it against raw SQL and does not pretend to — the generated migration
|
|
11
|
+
# documents the INSERT+SELECT grant that closes the rest of the gap.
|
|
12
|
+
class Entry < ActiveRecord::Base
|
|
13
|
+
self.table_name = TABLE_NAME
|
|
14
|
+
|
|
15
|
+
ALLOW = "allow"
|
|
16
|
+
DENY = "deny"
|
|
17
|
+
OUTCOMES = [ALLOW, DENY].freeze
|
|
18
|
+
|
|
19
|
+
REQUIRED = %i[
|
|
20
|
+
invocation_id occurred_at agent_id tool_name outcome rule guard contract_version
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
validates(*REQUIRED, presence: true)
|
|
24
|
+
validates :invocation_id, uniqueness: true
|
|
25
|
+
validates :outcome, inclusion: { in: OUTCOMES, message: "must be allow or deny" }
|
|
26
|
+
|
|
27
|
+
before_destroy { throw :abort }
|
|
28
|
+
|
|
29
|
+
# The contract version this build of the gem writes (FR-015).
|
|
30
|
+
#
|
|
31
|
+
# Class-level, and deliberately not the same question as `entry.contract_version`:
|
|
32
|
+
# this is what the gem implements *now*, while the column on each row is the shape
|
|
33
|
+
# that row was actually written under. They differ for every row written before an
|
|
34
|
+
# upgrade, which is the whole reason the column exists.
|
|
35
|
+
def self.contract_version
|
|
36
|
+
CONTRACT_VERSION
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# False while the row is being inserted, true forever after: the insert is the only
|
|
40
|
+
# write the ledger ever performs.
|
|
41
|
+
def readonly?
|
|
42
|
+
persisted?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def allowed?
|
|
46
|
+
outcome == ALLOW
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def denied?
|
|
50
|
+
outcome == DENY
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reeve
|
|
4
|
+
module Audit
|
|
5
|
+
# The documented way to read the ledger (FR-013): one scope per query axis —
|
|
6
|
+
# principal, agent, tool, outcome, time range — each backed by one of the composite
|
|
7
|
+
# indexes the generated migration creates.
|
|
8
|
+
#
|
|
9
|
+
# Q = Reeve::Audit::Query
|
|
10
|
+
# Q.for_principal(user)
|
|
11
|
+
# .for_agent("claude-desktop")
|
|
12
|
+
# .between(1.week.ago, Time.current)
|
|
13
|
+
# .pluck(:tool_name, :record_type, :record_ids, :outcome, :rule)
|
|
14
|
+
#
|
|
15
|
+
# The scopes are also extended onto Entry, so a host that would rather work with the
|
|
16
|
+
# model directly gets the same chain. There is deliberately no scope that writes.
|
|
17
|
+
module Query
|
|
18
|
+
# Defined once and used from two places: on Query, +all+ opens on the whole ledger;
|
|
19
|
+
# on Entry (and on a relation being chained), +all+ is the current scope, which is
|
|
20
|
+
# what makes these compose in any order.
|
|
21
|
+
module Scopes
|
|
22
|
+
def for_principal(principal)
|
|
23
|
+
all.where(Query.principal_key(principal))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def for_agent(agent_id)
|
|
27
|
+
all.where(agent_id: agent_id.to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def for_tool(tool_name)
|
|
31
|
+
all.where(tool_name: tool_name.to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def allowed
|
|
35
|
+
all.where(outcome: Entry::ALLOW)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def denied
|
|
39
|
+
all.where(outcome: Entry::DENY)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# By occurred_at: the ledger is ordered by when the call happened, not by when the
|
|
43
|
+
# row landed.
|
|
44
|
+
def between(from, to)
|
|
45
|
+
all.where(occurred_at: from..to)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
extend Scopes
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
def all
|
|
53
|
+
Entry.all
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# How a principal becomes a pair of columns. Mirrors Reeve::Context#principal_type
|
|
57
|
+
# and #principal_id exactly — a query that derived the key differently would
|
|
58
|
+
# quietly fail to find the rows the envelope wrote.
|
|
59
|
+
#
|
|
60
|
+
# Accepts the principal itself, or `type:`/`id:` for when an incident report is
|
|
61
|
+
# all that survives of them.
|
|
62
|
+
def principal_key(principal)
|
|
63
|
+
return explicit_key(principal) if principal.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
principal_type: principal.class.name,
|
|
67
|
+
principal_id: principal.respond_to?(:id) ? principal.id.to_s : principal.to_s
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def explicit_key(attributes)
|
|
74
|
+
{
|
|
75
|
+
principal_type: attributes[:type]&.to_s,
|
|
76
|
+
principal_id: attributes[:id]&.to_s
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Reeve::Audit::Entry.extend(Reeve::Audit::Query::Scopes)
|
|
@@ -0,0 +1,181 @@
|
|
|
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: redact_metadata(attributes),
|
|
93
|
+
# Stamped from the constant rather than from the caller: the row records the
|
|
94
|
+
# shape the gem that wrote it implements, which is not something an adapter or
|
|
95
|
+
# a host is in a position to assert.
|
|
96
|
+
contract_version: CONTRACT_VERSION
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def identity(attributes)
|
|
101
|
+
{
|
|
102
|
+
invocation_id: attributes[:invocation_id],
|
|
103
|
+
occurred_at: attributes[:occurred_at],
|
|
104
|
+
agent_id: agent_id(attributes),
|
|
105
|
+
agent_name: attributes[:agent_name],
|
|
106
|
+
principal_type: attributes[:principal_type],
|
|
107
|
+
principal_id: attributes[:principal_id]&.to_s,
|
|
108
|
+
tool_name: attributes[:tool_name]
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# An unidentifiable agent is recorded as unknown, never dropped: attribution is not
|
|
113
|
+
# authorization, and a row that names no agent still answers most of the question.
|
|
114
|
+
def agent_id(attributes)
|
|
115
|
+
blank?(attributes[:agent_id]) ? Context::UNKNOWN_AGENT_ID : attributes[:agent_id]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def redact(attributes)
|
|
119
|
+
Redactor.for(attributes[:tool_name], config: config).call(attributes[:arguments])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Metadata is transport detail, and the transport is where the credentials are: the
|
|
123
|
+
# fast-mcp bridge puts the whole header hash in here, `Authorization` included. It
|
|
124
|
+
# goes through the same redactor as the arguments — which recurses into nested
|
|
125
|
+
# hashes and already knows `authorization`, `token` and friends by name — so
|
|
126
|
+
# recording metadata does not turn the ledger into a place bearer tokens accumulate.
|
|
127
|
+
# nil stays nil rather than becoming `{}`: a call that carried no metadata should
|
|
128
|
+
# not be indistinguishable from one whose metadata was emptied.
|
|
129
|
+
def redact_metadata(attributes)
|
|
130
|
+
metadata = attributes[:metadata]
|
|
131
|
+
return nil if metadata.nil? || metadata.empty?
|
|
132
|
+
|
|
133
|
+
Redactor.for(attributes[:tool_name], config: config).call(metadata)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# FR-014: identifiers are capped, never silently dropped — the count stays true and
|
|
137
|
+
# the row says it was truncated. The scoper caps first; this is the backstop that
|
|
138
|
+
# makes the guarantee hold at the ledger regardless of who produced the list.
|
|
139
|
+
def identifiers(attributes)
|
|
140
|
+
ids = Array(attributes[:record_ids]).map(&:to_s)
|
|
141
|
+
count = attributes[:record_count] || ids.size
|
|
142
|
+
truncated = attributes[:truncated] ? true : false
|
|
143
|
+
limit = config.max_recorded_ids
|
|
144
|
+
|
|
145
|
+
return [ids, count, truncated] unless ids.size > limit
|
|
146
|
+
|
|
147
|
+
[ids.first(limit), [count, ids.size].max, true]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def detail(attributes)
|
|
151
|
+
value = attributes[:detail]
|
|
152
|
+
return nil if blank?(value)
|
|
153
|
+
|
|
154
|
+
value.to_s[0, DETAIL_LIMIT]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The host's transaction, not the tool's: the tool's own transaction is nested
|
|
158
|
+
# inside this write and is exactly what `requires_new` protects against.
|
|
159
|
+
def warn_about_enclosing_transaction(invocation_id)
|
|
160
|
+
return unless enclosing_transaction?
|
|
161
|
+
|
|
162
|
+
message = "reeve: invocation #{invocation_id} was recorded inside a transaction " \
|
|
163
|
+
"the host opened around it, so the ledger row will be rolled back with " \
|
|
164
|
+
"it. Configure Reeve.config.audit_recorder with a recorder that writes " \
|
|
165
|
+
"outside this transaction if the trace must survive."
|
|
166
|
+
logger = config.logger
|
|
167
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def enclosing_transaction?
|
|
171
|
+
entry_class.connection.transaction_open?
|
|
172
|
+
rescue StandardError
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def blank?(value)
|
|
177
|
+
value.nil? || value.to_s.strip.empty?
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
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
|
data/lib/reeve/audit.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
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 yet — the table is host-owned and the host's
|
|
47
|
+
# existing policies apply.
|
|
48
|
+
# * No cryptographic chaining or tamper-evidence yet. If that lands it arrives as a
|
|
49
|
+
# nullable column, which the audit-entry contract's versioning already permits.
|
|
50
|
+
#
|
|
51
|
+
# ("yet" rather than "in v1": the gem version and the audit-entry contract version are
|
|
52
|
+
# different numbers that move independently, and writing v1 for one of them read as the
|
|
53
|
+
# other.)
|
|
54
|
+
module Audit
|
|
55
|
+
# The version of the audit-entry shape, as documented in
|
|
56
|
+
# specs/001-guardrails-core/contracts/audit-entry.md (FR-015). Adding a nullable
|
|
57
|
+
# column is a MINOR change and leaves this alone; removing or renaming a column, or
|
|
58
|
+
# changing what a value means, is MAJOR and bumps it.
|
|
59
|
+
#
|
|
60
|
+
# 2 — `metadata` carries the transport detail the caller passed. Through version 1 it
|
|
61
|
+
# was written NULL on every row regardless of what was passed, so anything mapping
|
|
62
|
+
# version 1 rows could reasonably have read the column as "always empty". The shape
|
|
63
|
+
# did not change; what a value means did.
|
|
64
|
+
CONTRACT_VERSION = 2
|
|
65
|
+
|
|
66
|
+
TABLE_NAME = "reeve_audit_entries"
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# Where per-tool redaction declarations come from: the authorization registry, if
|
|
70
|
+
# the authorization module is loaded. Duck-typed and optional on purpose — the
|
|
71
|
+
# ledger is useful on its own, and a host running audit without guards should get
|
|
72
|
+
# the global redaction list rather than a NameError.
|
|
73
|
+
def guard_registry
|
|
74
|
+
return nil unless Reeve.respond_to?(:registry)
|
|
75
|
+
|
|
76
|
+
Reeve.registry
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# contracts/configuration.md documents `audit_recorder` as defaulting to nil, "which
|
|
80
|
+
# resolves to Reeve::Audit::Recorder at invocation time — the default cannot be the
|
|
81
|
+
# constant itself, since the core loads without ActiveRecord". This is that
|
|
82
|
+
# resolution, performed at the first moment the constant is known to exist: the
|
|
83
|
+
# require of this file. A host that named its own recorder keeps it.
|
|
84
|
+
def install!(config = Reeve.config)
|
|
85
|
+
config.audit_recorder ||= Recorder
|
|
86
|
+
config
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
require_relative "audit/redactor"
|
|
93
|
+
require_relative "audit/entry"
|
|
94
|
+
require_relative "audit/recorder"
|
|
95
|
+
require_relative "audit/query"
|
|
96
|
+
|
|
97
|
+
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
|