agentguard-eval 0.3.0__py3-none-any.whl
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.
- agentguard/__init__.py +3 -0
- agentguard/cli.py +101 -0
- agentguard/gate/__init__.py +50 -0
- agentguard/gate/governor.py +256 -0
- agentguard/gate/ledger.py +143 -0
- agentguard/gate/policy.py +215 -0
- agentguard/provenance/__init__.py +46 -0
- agentguard/provenance/adapters.py +142 -0
- agentguard/provenance/check.py +301 -0
- agentguard/provenance/failure.py +100 -0
- agentguard/provenance/ids.py +25 -0
- agentguard/provenance/types.py +85 -0
- agentguard/provenance/values.py +233 -0
- agentguard/py.typed +0 -0
- agentguard/pytest_plugin.py +85 -0
- agentguard/shell/__init__.py +37 -0
- agentguard/shell/model.py +308 -0
- agentguard_eval-0.3.0.dist-info/METADATA +184 -0
- agentguard_eval-0.3.0.dist-info/RECORD +22 -0
- agentguard_eval-0.3.0.dist-info/WHEEL +4 -0
- agentguard_eval-0.3.0.dist-info/entry_points.txt +5 -0
- agentguard_eval-0.3.0.dist-info/licenses/LICENSE +21 -0
agentguard/__init__.py
ADDED
agentguard/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The ``agentguard`` command line.
|
|
2
|
+
|
|
3
|
+
- ``agentguard check <log.json>``: check an agent transcript (agentguard, OpenAI or Anthropic format) for invented values. Exit code 1
|
|
4
|
+
when it's flagged, so it can gate a CI job over saved runs. ``--ids-only`` keeps only
|
|
5
|
+
high-confidence invented-ID flags.
|
|
6
|
+
- ``agentguard gate <tool> [--cost C] [--amount A] [--limit L] [--spent S]``: ask the policy
|
|
7
|
+
gate what it would do with a proposed tool call.
|
|
8
|
+
- ``agentguard bench``: run the shell-command gate benchmark and print the scorecard.
|
|
9
|
+
- ``agentguard verify-ledger <ledger.json>``: recompute every hash in an approval ledger and
|
|
10
|
+
name the first entry that was edited, cut or reordered. Exit code 1 if it doesn't verify.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
from agentguard.gate import Budget, evaluate, load_entries, verify_chain
|
|
21
|
+
from agentguard.provenance import check_transcript, id_flags, to_transcript
|
|
22
|
+
from agentguard.shell import format_report, run_bench
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
26
|
+
with open(args.log, encoding="utf-8") as fh:
|
|
27
|
+
transcript = to_transcript(json.load(fh))
|
|
28
|
+
verdict = check_transcript(transcript)
|
|
29
|
+
flags = id_flags(verdict) if args.ids_only else list(verdict.flags)
|
|
30
|
+
if not flags:
|
|
31
|
+
print("honest: no ungrounded values found.")
|
|
32
|
+
return 0
|
|
33
|
+
for flag in flags:
|
|
34
|
+
values = f" [{', '.join(flag.values)}]" if flag.values else ""
|
|
35
|
+
print(f"{flag.kind}: {flag.message}{values}")
|
|
36
|
+
return 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cmd_gate(args: argparse.Namespace) -> int:
|
|
40
|
+
call_args: dict[str, object] = {}
|
|
41
|
+
if args.amount is not None:
|
|
42
|
+
call_args["amount_cents"] = args.amount
|
|
43
|
+
decision = evaluate(
|
|
44
|
+
args.tool,
|
|
45
|
+
estimated_cost_cents=args.cost,
|
|
46
|
+
args=call_args,
|
|
47
|
+
budget=Budget(spent_cents=args.spent, limit_cents=args.limit),
|
|
48
|
+
)
|
|
49
|
+
print(f"{decision.verdict.value} ({decision.decided_by})")
|
|
50
|
+
for reason in decision.reasons:
|
|
51
|
+
print(f" - {reason}")
|
|
52
|
+
return 0 if decision.verdict.value == "ALLOW" else 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cmd_bench(_args: argparse.Namespace) -> int:
|
|
56
|
+
print(format_report(run_bench()))
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmd_verify_ledger(args: argparse.Namespace) -> int:
|
|
61
|
+
with open(args.ledger, encoding="utf-8") as fh:
|
|
62
|
+
try:
|
|
63
|
+
chain = load_entries(json.load(fh))
|
|
64
|
+
except ValueError as exc:
|
|
65
|
+
print(f"not a ledger: {exc}")
|
|
66
|
+
return 2
|
|
67
|
+
result = verify_chain(chain)
|
|
68
|
+
print(("valid: " if result.valid else "BROKEN: ") + result.message)
|
|
69
|
+
return 0 if result.valid else 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
73
|
+
parser = argparse.ArgumentParser(prog="agentguard", description="Guardrails and evals for AI agents.")
|
|
74
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
75
|
+
|
|
76
|
+
p_check = sub.add_parser("check", help="check an agent transcript (JSON) for invented values")
|
|
77
|
+
p_check.add_argument("log", help="path to a transcript JSON file")
|
|
78
|
+
p_check.add_argument("--ids-only", action="store_true", help="keep only high-confidence invented-ID flags")
|
|
79
|
+
p_check.set_defaults(func=_cmd_check)
|
|
80
|
+
|
|
81
|
+
p_gate = sub.add_parser("gate", help="ask the policy gate about a proposed tool call")
|
|
82
|
+
p_gate.add_argument("tool")
|
|
83
|
+
p_gate.add_argument("--cost", type=int, default=0, help="the agent's estimated cost, in cents")
|
|
84
|
+
p_gate.add_argument("--amount", type=int, default=None, help="amount_cents the call would move")
|
|
85
|
+
p_gate.add_argument("--limit", type=int, default=50_000, help="budget cap, in cents")
|
|
86
|
+
p_gate.add_argument("--spent", type=int, default=0, help="cents already spent")
|
|
87
|
+
p_gate.set_defaults(func=_cmd_gate)
|
|
88
|
+
|
|
89
|
+
p_bench = sub.add_parser("bench", help="run the shell-command gate benchmark")
|
|
90
|
+
p_bench.set_defaults(func=_cmd_bench)
|
|
91
|
+
|
|
92
|
+
p_ledger = sub.add_parser("verify-ledger", help="check an approval ledger for tampering")
|
|
93
|
+
p_ledger.add_argument("ledger", help="path to a ledger JSON file (a list, or an object with 'chain')")
|
|
94
|
+
p_ledger.set_defaults(func=_cmd_verify_ledger)
|
|
95
|
+
|
|
96
|
+
args = parser.parse_args(argv)
|
|
97
|
+
return int(args.func(args))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The policy gate: decide whether an agent's tool call may run, then run the approval workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentguard.gate.governor import (
|
|
6
|
+
APPROVAL_TTL_SECONDS,
|
|
7
|
+
ApprovalAuthError,
|
|
8
|
+
GatedAction,
|
|
9
|
+
Governor,
|
|
10
|
+
SelfApprovalError,
|
|
11
|
+
State,
|
|
12
|
+
mint_approval_token,
|
|
13
|
+
verify_approval_token,
|
|
14
|
+
)
|
|
15
|
+
from agentguard.gate.ledger import ChainVerification, Entry, load_entries, verify_chain
|
|
16
|
+
from agentguard.gate.policy import (
|
|
17
|
+
POLICY,
|
|
18
|
+
UNATTENDED_WINDOW_SECONDS,
|
|
19
|
+
UNATTENDED_WRITE_LIMIT,
|
|
20
|
+
Budget,
|
|
21
|
+
Decision,
|
|
22
|
+
History,
|
|
23
|
+
Verdict,
|
|
24
|
+
evaluate,
|
|
25
|
+
money,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"APPROVAL_TTL_SECONDS",
|
|
30
|
+
"POLICY",
|
|
31
|
+
"UNATTENDED_WINDOW_SECONDS",
|
|
32
|
+
"UNATTENDED_WRITE_LIMIT",
|
|
33
|
+
"ApprovalAuthError",
|
|
34
|
+
"Budget",
|
|
35
|
+
"ChainVerification",
|
|
36
|
+
"Decision",
|
|
37
|
+
"Entry",
|
|
38
|
+
"GatedAction",
|
|
39
|
+
"Governor",
|
|
40
|
+
"History",
|
|
41
|
+
"SelfApprovalError",
|
|
42
|
+
"State",
|
|
43
|
+
"Verdict",
|
|
44
|
+
"evaluate",
|
|
45
|
+
"load_entries",
|
|
46
|
+
"mint_approval_token",
|
|
47
|
+
"money",
|
|
48
|
+
"verify_approval_token",
|
|
49
|
+
"verify_chain",
|
|
50
|
+
]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The approval workflow around the policy gate.
|
|
2
|
+
|
|
3
|
+
``evaluate`` says what *should* happen to one call. A ``Governor`` makes it happen over time:
|
|
4
|
+
it holds the actions waiting for a person, checks who is approving, charges the budget only
|
|
5
|
+
when something actually runs, and writes every step to a tamper-evident ledger.
|
|
6
|
+
|
|
7
|
+
The rules carried over from ``agent-governance``, each one a bug that project fixed:
|
|
8
|
+
|
|
9
|
+
- The agent that proposed an action can't approve it.
|
|
10
|
+
- An approval needs a token only the named reviewer's key can make, bound to that action.
|
|
11
|
+
- An approval only counts for 24 hours after the request (configurable).
|
|
12
|
+
- The budget is checked again at the moment an approved action would run, because other
|
|
13
|
+
actions may have spent while it waited.
|
|
14
|
+
- A loop of unattended writes from one agent gets slowed down to "ask a person".
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Callable, Mapping
|
|
23
|
+
from dataclasses import dataclass, field, replace
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from enum import Enum
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from agentguard.gate.ledger import ChainVerification, Entry, append_entry, verify_chain
|
|
29
|
+
from agentguard.gate.policy import UNATTENDED_WINDOW_SECONDS, Budget, Decision, History, Verdict, evaluate, money
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"APPROVAL_TTL_SECONDS",
|
|
33
|
+
"ApprovalAuthError",
|
|
34
|
+
"GatedAction",
|
|
35
|
+
"Governor",
|
|
36
|
+
"SelfApprovalError",
|
|
37
|
+
"State",
|
|
38
|
+
"mint_approval_token",
|
|
39
|
+
"verify_approval_token",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
APPROVAL_TTL_SECONDS = 24 * 60 * 60
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class State(str, Enum):
|
|
46
|
+
PENDING_APPROVAL = "PENDING_APPROVAL"
|
|
47
|
+
APPROVED = "APPROVED"
|
|
48
|
+
REJECTED = "REJECTED"
|
|
49
|
+
AUTO_ALLOWED = "AUTO_ALLOWED"
|
|
50
|
+
BLOCKED = "BLOCKED"
|
|
51
|
+
EXECUTED = "EXECUTED"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SelfApprovalError(Exception):
|
|
55
|
+
"""The agent that proposed an action tried to approve it."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ApprovalAuthError(Exception):
|
|
59
|
+
"""An approval arrived without a valid token for the named reviewer."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _norm(name: str) -> str:
|
|
63
|
+
return name.strip().lower()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def mint_approval_token(key: str, proposal_id: str, reviewer: str) -> str:
|
|
67
|
+
"""HMAC that binds one reviewer to one proposal. Same bytes as the TypeScript version."""
|
|
68
|
+
message = f"{proposal_id}:{_norm(reviewer)}".encode()
|
|
69
|
+
return hmac.new(key.encode(), message, hashlib.sha256).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify_approval_token(keys: Mapping[str, str], proposal_id: str, reviewer: str, token: str | None) -> bool:
|
|
73
|
+
if token is None:
|
|
74
|
+
return False
|
|
75
|
+
key = {_norm(k): v for k, v in keys.items()}.get(_norm(reviewer))
|
|
76
|
+
if key is None:
|
|
77
|
+
return False
|
|
78
|
+
return hmac.compare_digest(mint_approval_token(key, proposal_id, reviewer), token)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class GatedAction:
|
|
83
|
+
id: str
|
|
84
|
+
agent: str
|
|
85
|
+
tool: str
|
|
86
|
+
args: dict[str, Any]
|
|
87
|
+
estimated_cost_cents: int
|
|
88
|
+
created_at: float
|
|
89
|
+
decision: Decision
|
|
90
|
+
state: State
|
|
91
|
+
reviewed_by: str | None = None
|
|
92
|
+
review_note: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _iso(ts: float) -> str:
|
|
96
|
+
# Millisecond ISO-8601 in UTC with a Z, matching JavaScript's Date.toISOString().
|
|
97
|
+
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(ts * 1000) % 1000:03d}Z"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Governor:
|
|
102
|
+
"""Run agent tool calls through the gate, a human approval step, and a ledger.
|
|
103
|
+
|
|
104
|
+
``reviewer_keys`` maps each person allowed to approve to their secret. ``clock`` returns
|
|
105
|
+
seconds since the epoch; tests pass a fake one.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
budget_limit_cents: int = 50_000
|
|
109
|
+
reviewer_keys: Mapping[str, str] = field(default_factory=dict)
|
|
110
|
+
approval_ttl_seconds: float = APPROVAL_TTL_SECONDS
|
|
111
|
+
clock: Callable[[], float] = time.time
|
|
112
|
+
spent_cents: int = 0
|
|
113
|
+
_actions: list[GatedAction] = field(default_factory=list, repr=False)
|
|
114
|
+
_ledger: list[Entry] = field(default_factory=list, repr=False)
|
|
115
|
+
|
|
116
|
+
# -- reading state ---------------------------------------------------------------------
|
|
117
|
+
@property
|
|
118
|
+
def budget(self) -> Budget:
|
|
119
|
+
return Budget(spent_cents=self.spent_cents, limit_cents=self.budget_limit_cents)
|
|
120
|
+
|
|
121
|
+
def actions(self) -> list[GatedAction]:
|
|
122
|
+
return list(self._actions)
|
|
123
|
+
|
|
124
|
+
def pending(self) -> list[GatedAction]:
|
|
125
|
+
return [a for a in self._actions if a.state is State.PENDING_APPROVAL]
|
|
126
|
+
|
|
127
|
+
def get(self, action_id: str) -> GatedAction | None:
|
|
128
|
+
return next((a for a in self._actions if a.id == action_id), None)
|
|
129
|
+
|
|
130
|
+
def ledger(self) -> list[Entry]:
|
|
131
|
+
return list(self._ledger)
|
|
132
|
+
|
|
133
|
+
def verify(self) -> ChainVerification:
|
|
134
|
+
return verify_chain(self._ledger)
|
|
135
|
+
|
|
136
|
+
def mint_token(self, action_id: str, reviewer: str) -> str:
|
|
137
|
+
"""A token for a reviewer whose key this governor holds (what your UI would call)."""
|
|
138
|
+
key = {_norm(k): v for k, v in self.reviewer_keys.items()}.get(_norm(reviewer))
|
|
139
|
+
if key is None:
|
|
140
|
+
raise ApprovalAuthError(f"{reviewer!r} is not a registered reviewer.")
|
|
141
|
+
return mint_approval_token(key, action_id, reviewer)
|
|
142
|
+
|
|
143
|
+
# -- the workflow ----------------------------------------------------------------------
|
|
144
|
+
def propose(
|
|
145
|
+
self, agent: str, tool: str, args: dict[str, Any] | None = None, *, estimated_cost_cents: int = 0
|
|
146
|
+
) -> GatedAction:
|
|
147
|
+
now = self.clock()
|
|
148
|
+
action_id = f"act_{len(self._actions) + 1:04d}"
|
|
149
|
+
args = dict(args or {})
|
|
150
|
+
self._record("PROPOSED", agent, action_id, {"tool": tool, "estimated": _money_or_raw(estimated_cost_cents)})
|
|
151
|
+
decision = evaluate(
|
|
152
|
+
tool,
|
|
153
|
+
estimated_cost_cents=estimated_cost_cents,
|
|
154
|
+
args=args,
|
|
155
|
+
budget=self.budget,
|
|
156
|
+
history=History(recent_unattended_writes=self._recent_unattended_writes(agent, now)),
|
|
157
|
+
)
|
|
158
|
+
self._record(
|
|
159
|
+
"POLICY_EVALUATED",
|
|
160
|
+
"policy-engine",
|
|
161
|
+
action_id,
|
|
162
|
+
{"verdict": decision.verdict.value, "tier": decision.tier, "decidedBy": decision.decided_by},
|
|
163
|
+
)
|
|
164
|
+
action = GatedAction(action_id, agent, tool, args, estimated_cost_cents, now, decision, State.PENDING_APPROVAL)
|
|
165
|
+
if decision.verdict is Verdict.DENY:
|
|
166
|
+
action.state = State.BLOCKED
|
|
167
|
+
self._record("BLOCKED", "policy-engine", action_id, {"why": decision.reason})
|
|
168
|
+
elif decision.verdict is Verdict.ALLOW:
|
|
169
|
+
action.state = State.AUTO_ALLOWED
|
|
170
|
+
self._execute(action)
|
|
171
|
+
self._actions.append(action)
|
|
172
|
+
return action
|
|
173
|
+
|
|
174
|
+
def decide(
|
|
175
|
+
self, action_id: str, approve: bool, reviewer: str, *, note: str | None = None, token: str | None = None
|
|
176
|
+
) -> GatedAction | None:
|
|
177
|
+
"""Approve or reject a pending action. Returns None if there's no such pending action."""
|
|
178
|
+
action = self.get(action_id)
|
|
179
|
+
if action is None or action.state is not State.PENDING_APPROVAL:
|
|
180
|
+
return None
|
|
181
|
+
# Separation of duties, checked before anything is written. Withdrawing your own is fine.
|
|
182
|
+
if approve and _norm(reviewer) == _norm(action.agent):
|
|
183
|
+
raise SelfApprovalError(f"{action.agent!r} proposed this action, so it can't also approve it.")
|
|
184
|
+
if approve and not verify_approval_token(self.reviewer_keys, action_id, reviewer, token):
|
|
185
|
+
raise ApprovalAuthError(f"{reviewer!r} did not present a valid approval token.")
|
|
186
|
+
|
|
187
|
+
# Only a yes expires: rejecting a stale request is how the queue gets cleared. Checked
|
|
188
|
+
# after authentication, so a forged attempt can't change anything.
|
|
189
|
+
waited = self.clock() - action.created_at
|
|
190
|
+
if approve and waited > self.approval_ttl_seconds:
|
|
191
|
+
why = (
|
|
192
|
+
f"This request expired: it was made {int(waited // 3600)}h ago, and an approval only counts "
|
|
193
|
+
f"within {_hours(self.approval_ttl_seconds)}h. Ask the agent to propose it again."
|
|
194
|
+
)
|
|
195
|
+
action.reviewed_by, action.review_note = reviewer, note
|
|
196
|
+
self._block(action, why, "approval-expired", extra={"reviewer": reviewer})
|
|
197
|
+
return action
|
|
198
|
+
|
|
199
|
+
action.reviewed_by, action.review_note = reviewer, note
|
|
200
|
+
if not approve:
|
|
201
|
+
action.state = State.REJECTED
|
|
202
|
+
self._record("REJECTED", reviewer, action_id, {"note": note})
|
|
203
|
+
return action
|
|
204
|
+
|
|
205
|
+
action.state = State.APPROVED
|
|
206
|
+
self._record("APPROVED", reviewer, action_id, {"note": note})
|
|
207
|
+
# The cap was checked at proposal time, but other actions may have spent since.
|
|
208
|
+
projected = self.spent_cents + action.decision.cost_cents
|
|
209
|
+
if projected > self.budget_limit_cents:
|
|
210
|
+
why = (
|
|
211
|
+
f"Budget changed while this waited: {money(action.decision.cost_cents)} would bring spend to "
|
|
212
|
+
f"{money(projected)}, over the {money(self.budget_limit_cents)} cap."
|
|
213
|
+
)
|
|
214
|
+
self._block(action, why, "budget-exceeded-at-approval")
|
|
215
|
+
return action
|
|
216
|
+
self._execute(action)
|
|
217
|
+
return action
|
|
218
|
+
|
|
219
|
+
# -- internals -------------------------------------------------------------------------
|
|
220
|
+
def _recent_unattended_writes(self, agent: str, now: float) -> int:
|
|
221
|
+
return sum(
|
|
222
|
+
1
|
|
223
|
+
for a in self._actions
|
|
224
|
+
if a.agent == agent
|
|
225
|
+
and a.decision.verdict is Verdict.ALLOW
|
|
226
|
+
and a.decision.tier == 2
|
|
227
|
+
and now - a.created_at < UNATTENDED_WINDOW_SECONDS
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _execute(self, action: GatedAction) -> None:
|
|
231
|
+
# Spend is counted only when something runs, and it's the gate's cost, not the estimate.
|
|
232
|
+
self.spent_cents += action.decision.cost_cents
|
|
233
|
+
action.state = State.EXECUTED
|
|
234
|
+
self._record("EXECUTED", "runtime", action.id, {"tool": action.tool, "cost": money(action.decision.cost_cents)})
|
|
235
|
+
|
|
236
|
+
def _block(self, action: GatedAction, why: str, decided_by: str, extra: dict[str, Any] | None = None) -> None:
|
|
237
|
+
action.state = State.BLOCKED
|
|
238
|
+
action.decision = replace(
|
|
239
|
+
action.decision, verdict=Verdict.DENY, reasons=(*action.decision.reasons, why), decided_by=decided_by
|
|
240
|
+
)
|
|
241
|
+
self._record("BLOCKED", "policy-engine", action.id, {"why": why, **(extra or {})})
|
|
242
|
+
|
|
243
|
+
def _record(self, event: str, actor: str, action_id: str, detail: dict[str, Any]) -> None:
|
|
244
|
+
append_entry(self._ledger, at=_iso(self.clock()), event=event, actor=actor, proposal_id=action_id, detail=detail)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _money_or_raw(cents: object) -> object:
|
|
248
|
+
# A malformed estimate is logged as given; the gate refuses it a moment later.
|
|
249
|
+
if isinstance(cents, int) and not isinstance(cents, bool):
|
|
250
|
+
return money(cents)
|
|
251
|
+
return repr(cents)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _hours(seconds: float) -> str:
|
|
255
|
+
hours = seconds / 3600
|
|
256
|
+
return str(int(hours)) if hours.is_integer() else str(hours)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""A tamper-evident audit ledger.
|
|
2
|
+
|
|
3
|
+
Every entry carries the hash of the entry before it, so the ledger is a chain. Change one
|
|
4
|
+
field in one old entry and its hash stops matching, and every entry after it is orphaned. You
|
|
5
|
+
can append to history, but you can't quietly rewrite it.
|
|
6
|
+
|
|
7
|
+
Entries serialize with the same field names and the same hashed bytes as the TypeScript
|
|
8
|
+
``agent-governance`` project, so a ledger written by either one verifies in the other.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
from collections.abc import Iterable, Mapping
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
__all__ = ["GENESIS", "ChainVerification", "Entry", "append_entry", "hash_entry", "load_entries", "verify_chain"]
|
|
20
|
+
|
|
21
|
+
GENESIS = "GENESIS"
|
|
22
|
+
|
|
23
|
+
EVENTS = ("PROPOSED", "POLICY_EVALUATED", "APPROVED", "REJECTED", "EXECUTED", "BLOCKED")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Entry:
|
|
28
|
+
"""One line in the ledger. ``hash`` is SHA-256 over every other field."""
|
|
29
|
+
|
|
30
|
+
seq: int
|
|
31
|
+
at: str
|
|
32
|
+
event: str
|
|
33
|
+
actor: str
|
|
34
|
+
proposal_id: str
|
|
35
|
+
detail: dict[str, Any] = field(default_factory=dict)
|
|
36
|
+
prev_hash: str = GENESIS
|
|
37
|
+
hash: str = ""
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"seq": self.seq,
|
|
42
|
+
"at": self.at,
|
|
43
|
+
"event": self.event,
|
|
44
|
+
"actor": self.actor,
|
|
45
|
+
"proposalId": self.proposal_id,
|
|
46
|
+
"detail": self.detail,
|
|
47
|
+
"prevHash": self.prev_hash,
|
|
48
|
+
"hash": self.hash,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: Mapping[str, Any]) -> Entry:
|
|
53
|
+
return cls(
|
|
54
|
+
seq=data["seq"],
|
|
55
|
+
at=data["at"],
|
|
56
|
+
event=data["event"],
|
|
57
|
+
actor=data["actor"],
|
|
58
|
+
proposal_id=data["proposalId"],
|
|
59
|
+
detail=dict(data["detail"]),
|
|
60
|
+
prev_hash=data["prevHash"],
|
|
61
|
+
hash=data["hash"],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ChainVerification:
|
|
67
|
+
valid: bool
|
|
68
|
+
length: int
|
|
69
|
+
#: The first sequence number where the chain stops adding up.
|
|
70
|
+
broken_at: int | None
|
|
71
|
+
message: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _canonical(entry: Entry) -> str:
|
|
75
|
+
# The same bytes JavaScript's JSON.stringify produces for this array. Field order is fixed:
|
|
76
|
+
# change it and every ledger ever written stops verifying, which is the point.
|
|
77
|
+
return json.dumps(
|
|
78
|
+
[entry.seq, entry.at, entry.event, entry.actor, entry.proposal_id, entry.detail, entry.prev_hash],
|
|
79
|
+
separators=(",", ":"),
|
|
80
|
+
ensure_ascii=False,
|
|
81
|
+
allow_nan=False,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def hash_entry(entry: Entry) -> str:
|
|
86
|
+
return hashlib.sha256(_canonical(entry).encode("utf-8")).hexdigest()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def append_entry(
|
|
90
|
+
chain: list[Entry], *, at: str, event: str, actor: str, proposal_id: str, detail: dict[str, Any]
|
|
91
|
+
) -> Entry:
|
|
92
|
+
"""Seal a new entry onto the end of ``chain`` and return it."""
|
|
93
|
+
if event not in EVENTS:
|
|
94
|
+
raise ValueError(f"unknown ledger event {event!r}")
|
|
95
|
+
previous = chain[-1] if chain else None
|
|
96
|
+
entry = Entry(
|
|
97
|
+
seq=len(chain) + 1,
|
|
98
|
+
at=at,
|
|
99
|
+
event=event,
|
|
100
|
+
actor=actor,
|
|
101
|
+
proposal_id=proposal_id,
|
|
102
|
+
detail=detail,
|
|
103
|
+
prev_hash=previous.hash if previous else GENESIS,
|
|
104
|
+
)
|
|
105
|
+
entry.hash = hash_entry(entry)
|
|
106
|
+
chain.append(entry)
|
|
107
|
+
return entry
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def verify_chain(chain: Iterable[Entry]) -> ChainVerification:
|
|
111
|
+
"""Recompute every hash. Reports the first entry that doesn't add up, not just that one doesn't."""
|
|
112
|
+
entries = list(chain)
|
|
113
|
+
expected_prev = GENESIS
|
|
114
|
+
for entry in entries:
|
|
115
|
+
if entry.prev_hash != expected_prev:
|
|
116
|
+
return ChainVerification(
|
|
117
|
+
False,
|
|
118
|
+
len(entries),
|
|
119
|
+
entry.seq,
|
|
120
|
+
f"Entry #{entry.seq} points at the wrong parent. The chain was cut or reordered here.",
|
|
121
|
+
)
|
|
122
|
+
if hash_entry(entry) != entry.hash:
|
|
123
|
+
return ChainVerification(
|
|
124
|
+
False,
|
|
125
|
+
len(entries),
|
|
126
|
+
entry.seq,
|
|
127
|
+
f"Entry #{entry.seq} was edited after it was written. Its contents no longer match its hash.",
|
|
128
|
+
)
|
|
129
|
+
expected_prev = entry.hash
|
|
130
|
+
message = "Empty ledger." if not entries else f"All {len(entries)} entries verify against their hashes."
|
|
131
|
+
return ChainVerification(True, len(entries), None, message)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_entries(data: Any) -> list[Entry]:
|
|
135
|
+
"""Read a ledger from parsed JSON: a list of entries, or an object with a ``chain`` list."""
|
|
136
|
+
if isinstance(data, Mapping):
|
|
137
|
+
data = data.get("chain")
|
|
138
|
+
if not isinstance(data, list):
|
|
139
|
+
raise ValueError("a ledger is a JSON list of entries (or an object with a 'chain' list)")
|
|
140
|
+
try:
|
|
141
|
+
return [Entry.from_dict(item) for item in data]
|
|
142
|
+
except (KeyError, TypeError) as exc:
|
|
143
|
+
raise ValueError(f"ledger entry is missing a field: {exc}") from exc
|