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 ADDED
@@ -0,0 +1,3 @@
1
+ """agentguard: guardrails and evals for AI agents."""
2
+
3
+ __version__ = "0.3.0"
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