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.
@@ -0,0 +1,215 @@
1
+ """A deterministic policy gate for agent tool calls.
2
+
3
+ Every tool call is a *proposal*. ``evaluate`` returns one of three verdicts: run it, hold it
4
+ for a person, or refuse it. The gate is a pure function of the proposal, the budget, and a
5
+ small history object, so the same inputs always give the same verdict, which is what makes it
6
+ testable. Ported from the TypeScript ``agent-governance`` project; the reason strings match it
7
+ word for word, because they end up in ledger entries whose hashes must agree across the two.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import math
14
+ import re
15
+ from dataclasses import dataclass
16
+ from enum import Enum
17
+
18
+ __all__ = [
19
+ "POLICY",
20
+ "UNATTENDED_WINDOW_SECONDS",
21
+ "UNATTENDED_WRITE_LIMIT",
22
+ "Budget",
23
+ "Decision",
24
+ "History",
25
+ "Verdict",
26
+ "evaluate",
27
+ "money",
28
+ ]
29
+
30
+
31
+ class Verdict(str, Enum):
32
+ """What the gate decided."""
33
+
34
+ ALLOW = "ALLOW"
35
+ REQUIRE_APPROVAL = "REQUIRE_APPROVAL"
36
+ DENY = "DENY"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class _Rule:
41
+ id: str
42
+ describe: str
43
+ tier: int
44
+ match: tuple[str, ...]
45
+
46
+
47
+ # Policy is data, not conditionals buried in a service. Each rule is a row you can read and
48
+ # reorder. Tier 1 reads run unattended, tier 2 internal writes run but are recorded, tier 3
49
+ # (money, customers, production, deletes) waits for a human.
50
+ POLICY: tuple[_Rule, ...] = (
51
+ _Rule("read-only", "Read-only lookups cannot change anything, so they run unattended.", 1,
52
+ ("crm.read", "db.query", "search.*", "calendar.read")),
53
+ _Rule("internal-write", "Writes to internal records are reversible and stay inside the company.", 2,
54
+ ("crm.update", "ticket.create", "notify.internal", "db.insert")),
55
+ _Rule("money-movement", "Anything that moves money needs a person to sign off.", 3,
56
+ ("stripe.*", "payout.*", "invoice.void", "refund.*")),
57
+ _Rule("customer-contact", "A message to a customer cannot be unsent.", 3,
58
+ ("email.send", "sms.send", "whatsapp.send")),
59
+ _Rule("destructive", "Deletes and production deploys need a person to sign off.", 3,
60
+ ("db.delete", "db.drop", "deploy.production", "s3.delete")),
61
+ )
62
+
63
+ # Lowercase ASCII segments joined by dots, at least two of them: ``stripe.refund``. Matched
64
+ # with ``fullmatch``: Python's ``$`` also matches just before a trailing newline, so a name like
65
+ # ``"refund.create\n"`` would otherwise smuggle itself past this check.
66
+ _TOOL_NAME = re.compile(r"[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+")
67
+
68
+ # A single write is routine; a loop creating hundreds of tickets is a runaway agent. Past this
69
+ # many unattended tier-2 writes from one agent inside the window, the next one waits for a person.
70
+ UNATTENDED_WRITE_LIMIT = 10
71
+ UNATTENDED_WINDOW_SECONDS = 60
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Budget:
76
+ """A spend cap, in cents. ``spent_cents`` is what has been charged so far."""
77
+
78
+ spent_cents: int
79
+ limit_cents: int
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class History:
84
+ """What the gate knows about recent activity. The caller supplies it; the gate stays pure."""
85
+
86
+ recent_unattended_writes: int = 0
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Decision:
91
+ """The gate's answer, with every reason it is safe to show a human reviewer."""
92
+
93
+ verdict: Verdict
94
+ tier: int
95
+ reasons: tuple[str, ...]
96
+ decided_by: str
97
+ #: What the gate charged against the budget: the larger of the estimate and any amount the
98
+ #: arguments move. 0 when the gate refused before it could trust either number.
99
+ cost_cents: int
100
+
101
+ @property
102
+ def reason(self) -> str:
103
+ """The final reason, i.e. the one that produced the verdict."""
104
+ return self.reasons[-1] if self.reasons else ""
105
+
106
+
107
+ def money(cents: int) -> str:
108
+ """Render cents as a dollar string: ``money(8400)`` -> ``"$84.00"``."""
109
+ return f"${cents / 100:.2f}"
110
+
111
+
112
+ def _is_cents(value: object) -> bool:
113
+ """A whole, non-negative amount the budget arithmetic can trust (and not a bool)."""
114
+ return (
115
+ isinstance(value, int)
116
+ and not isinstance(value, bool)
117
+ and value >= 0
118
+ ) or (isinstance(value, float) and value.is_integer() and value >= 0 and math.isfinite(value))
119
+
120
+
121
+ def _matches(pattern: str, tool: str) -> bool:
122
+ if pattern.endswith("*"):
123
+ return tool.startswith(pattern[:-1])
124
+ return pattern == tool
125
+
126
+
127
+ def _rule_for(tool: str) -> _Rule | None:
128
+ for rule in POLICY:
129
+ if any(_matches(pattern, tool) for pattern in rule.match):
130
+ return rule
131
+ return None
132
+
133
+
134
+ def evaluate(
135
+ tool: str,
136
+ *,
137
+ estimated_cost_cents: int = 0,
138
+ args: dict[str, object] | None = None,
139
+ budget: Budget,
140
+ history: History | None = None,
141
+ ) -> Decision:
142
+ """Decide whether a proposed tool call may run, in seven ordered checks.
143
+
144
+ The order is load-bearing and asserted by the tests: a malformed tool name is refused
145
+ before it can borrow another rule's tier, the budget is checked before the tier, and an
146
+ understated cost is escalated even on a tier that would otherwise run unattended.
147
+ """
148
+ args = args or {}
149
+ history = history or History()
150
+ reasons: list[str] = []
151
+
152
+ # 0. A tool name has to look like a tool name before it is matched. Wildcards match by
153
+ # prefix, so without this a name like "search.x/../refund.create" would ride "search.*".
154
+ if not _TOOL_NAME.fullmatch(tool):
155
+ return Decision(Verdict.DENY, 3, (f"{json.dumps(tool, ensure_ascii=False)} is not a well-formed tool name.",), "malformed-tool", 0)
156
+
157
+ # 1. Unknown tools fail closed.
158
+ rule = _rule_for(tool)
159
+ if rule is None:
160
+ return Decision(Verdict.DENY, 3, (f'"{tool}" is not a tool this agent is allowed to call.',), "unknown-tool", 0)
161
+ reasons.append(rule.describe)
162
+
163
+ # 2. Both numbers must be ones the math can trust: the estimate and any amount the
164
+ # arguments move. A negative or NaN cost would slip past the cap.
165
+ if not _is_cents(estimated_cost_cents):
166
+ reasons.append(f"An estimated cost of {estimated_cost_cents} cents is not a whole, non-negative amount.")
167
+ return Decision(Verdict.DENY, rule.tier, tuple(reasons), "malformed-cost", 0)
168
+ arg_amount = args.get("amount_cents")
169
+ if arg_amount is not None and not _is_cents(arg_amount):
170
+ reasons.append(f"The arguments ask to move {arg_amount} cents, which is not a whole, non-negative amount.")
171
+ return Decision(Verdict.DENY, rule.tier, tuple(reasons), "malformed-cost", 0)
172
+
173
+ # The estimate is the agent's word; amount_cents is what the call will do. Charge the
174
+ # larger, so understating the estimate can't buy room under the cap.
175
+ estimate = int(estimated_cost_cents)
176
+ moves = int(arg_amount) if isinstance(arg_amount, (int, float)) and _is_cents(arg_amount) else 0
177
+ cost = max(estimate, moves)
178
+ understated = cost > estimate
179
+
180
+ # 3. Budget is checked before anything else, because a runaway spend is the failure that
181
+ # costs real money while everyone is asleep.
182
+ projected = budget.spent_cents + cost
183
+ if projected > budget.limit_cents:
184
+ reasons.append(f"This would put spend at {money(projected)} against a {money(budget.limit_cents)} cap.")
185
+ return Decision(Verdict.DENY, rule.tier, tuple(reasons), "budget-exceeded", cost)
186
+
187
+ # 4. An agent that estimates one number and moves a bigger one gets a human's eyes, even on
188
+ # a tier that would normally run unattended, and the human sees the real amount.
189
+ if understated:
190
+ reasons.append(f"The agent estimated {money(estimate)}, but the arguments move {money(cost)} — escalated for review.")
191
+ return Decision(Verdict.REQUIRE_APPROVAL, 3, tuple(reasons), "cost-understated", cost)
192
+
193
+ # 5. One action asking for more than a quarter of the whole budget is worth a second look
194
+ # even when it fits.
195
+ if cost > budget.limit_cents / 4:
196
+ reasons.append(f"{money(cost)} in one action is unusually large — escalated for review.")
197
+ return Decision(Verdict.REQUIRE_APPROVAL, 3, tuple(reasons), "large-single-spend", cost)
198
+
199
+ if rule.tier == 3:
200
+ reasons.append("Tier 3 always waits for a human.")
201
+ return Decision(Verdict.REQUIRE_APPROVAL, 3, tuple(reasons), rule.id, cost)
202
+
203
+ if rule.tier == 2:
204
+ # A runaway loop of individually harmless writes is still a runaway. Reads are never slowed.
205
+ recent = history.recent_unattended_writes
206
+ if recent >= UNATTENDED_WRITE_LIMIT:
207
+ reasons.append(
208
+ f"This agent has already run {recent} unattended writes in the last "
209
+ f"{UNATTENDED_WINDOW_SECONDS}s (limit {UNATTENDED_WRITE_LIMIT}), so the next one waits for a person."
210
+ )
211
+ return Decision(Verdict.REQUIRE_APPROVAL, 2, tuple(reasons), "unattended-rate", cost)
212
+ reasons.append("Tier 2 runs unattended but is recorded in full.")
213
+ return Decision(Verdict.ALLOW, 2, tuple(reasons), rule.id, cost)
214
+
215
+ return Decision(Verdict.ALLOW, 1, tuple(reasons), rule.id, cost)
@@ -0,0 +1,46 @@
1
+ """Provenance checking: is an agent's answer backed by what its tools returned?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentguard.provenance.adapters import from_anthropic, from_openai, strip_tool_markup, to_transcript
6
+ from agentguard.provenance.check import check_transcript, check_turns
7
+ from agentguard.provenance.failure import acknowledges_failure, classify_result
8
+ from agentguard.provenance.ids import id_flags
9
+ from agentguard.provenance.types import (
10
+ Flag,
11
+ FlagKind,
12
+ Step,
13
+ Transcript,
14
+ TurnVerdict,
15
+ Verdict,
16
+ load_transcript,
17
+ )
18
+ from agentguard.provenance.values import (
19
+ extract_values,
20
+ is_id_shaped,
21
+ normalize_number,
22
+ payload_values,
23
+ )
24
+
25
+ __all__ = [
26
+ "Flag",
27
+ "FlagKind",
28
+ "Step",
29
+ "Transcript",
30
+ "TurnVerdict",
31
+ "Verdict",
32
+ "acknowledges_failure",
33
+ "check_transcript",
34
+ "check_turns",
35
+ "classify_result",
36
+ "extract_values",
37
+ "from_anthropic",
38
+ "from_openai",
39
+ "id_flags",
40
+ "is_id_shaped",
41
+ "load_transcript",
42
+ "normalize_number",
43
+ "payload_values",
44
+ "strip_tool_markup",
45
+ "to_transcript",
46
+ ]
@@ -0,0 +1,142 @@
1
+ """Read real agent logs: OpenAI chat messages and Anthropic messages.
2
+
3
+ Maps either format onto the checker's transcript shape, so you can point it at what your agent
4
+ actually produced. Ported from tool-truth's ``adapters.ts``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Any
12
+
13
+ from agentguard.provenance.types import Step, Transcript, load_transcript
14
+
15
+ __all__ = ["from_anthropic", "from_openai", "strip_tool_markup", "to_transcript"]
16
+
17
+
18
+ def _parse_payload(content: Any) -> Any:
19
+ """Tool outputs usually arrive as strings; JSON is parsed so its fields can be checked."""
20
+ if not isinstance(content, str):
21
+ return content
22
+ s = content.strip()
23
+ if not (s.startswith("{") or s.startswith("[")):
24
+ return content
25
+ try:
26
+ return json.loads(s)
27
+ except ValueError:
28
+ return content
29
+
30
+
31
+ def _parse_args(raw: Any) -> dict[str, Any]:
32
+ if isinstance(raw, dict):
33
+ return raw
34
+ if isinstance(raw, str):
35
+ try:
36
+ value = json.loads(raw)
37
+ except ValueError:
38
+ return {}
39
+ return value if isinstance(value, dict) else {}
40
+ return {}
41
+
42
+
43
+ def _tag(name: str) -> re.Pattern[str]:
44
+ # Tags may carry a namespace prefix (``<ns:invoke ...>``), so it's matched optionally.
45
+ return re.compile(rf"<(?:[\w-]+:)?{name}\b[\s\S]*?(</(?:[\w-]+:)?{name}>|$)")
46
+
47
+
48
+ _MARKUP = [_tag("function_calls"), _tag("invoke"), _tag("tool_call")]
49
+
50
+
51
+ def strip_tool_markup(text: str) -> str:
52
+ """Remove tool calls a model also wrote into its message text, e.g. Claude's
53
+ ``<function_calls><invoke name=...>``. That's an action, not a statement to the user, and the
54
+ structured call is already captured, so tool names aren't read as invented claims."""
55
+ for pattern in _MARKUP:
56
+ text = pattern.sub(" ", text)
57
+ return text
58
+
59
+
60
+ def from_openai(messages: list[dict[str, Any]]) -> Transcript:
61
+ """OpenAI chat-completions messages (``tool_calls`` and role ``tool``)."""
62
+ steps: list[Step] = []
63
+ tool_names: dict[str, str] = {}
64
+ for m in messages:
65
+ role = m.get("role")
66
+ content = m.get("content")
67
+ if role in ("system", "developer") and isinstance(content, str):
68
+ steps.append(Step(role="system", content=content))
69
+ elif role == "user" and isinstance(content, str):
70
+ steps.append(Step(role="user", content=content))
71
+ elif role == "assistant":
72
+ for call in m.get("tool_calls") or []:
73
+ fn = call.get("function") or {}
74
+ cid = str(call.get("id", ""))
75
+ tool = str(fn.get("name", "unknown"))
76
+ tool_names[cid] = tool
77
+ steps.append(Step(role="tool_call", id=cid, tool=tool, args=_parse_args(fn.get("arguments"))))
78
+ said = strip_tool_markup(content) if isinstance(content, str) else ""
79
+ if said.strip():
80
+ steps.append(Step(role="assistant", content=said))
81
+ elif role == "tool":
82
+ cid = str(m.get("tool_call_id", ""))
83
+ steps.append(Step(role="tool_result", id=cid, tool=tool_names.get(cid, "unknown"), payload=_parse_payload(content)))
84
+ return Transcript(steps=steps)
85
+
86
+
87
+ def from_anthropic(messages: list[dict[str, Any]]) -> Transcript:
88
+ """Anthropic messages API, with content as a string or as blocks."""
89
+ steps: list[Step] = []
90
+ tool_names: dict[str, str] = {}
91
+ for m in messages:
92
+ content = m.get("content")
93
+ blocks: list[dict[str, Any]] = (
94
+ [{"type": "text", "text": content}] if isinstance(content, str) else content if isinstance(content, list) else []
95
+ )
96
+ if m.get("role") == "user":
97
+ for b in blocks:
98
+ if b.get("type") == "text" and isinstance(b.get("text"), str):
99
+ steps.append(Step(role="user", content=b["text"]))
100
+ if b.get("type") == "tool_result":
101
+ tid = str(b.get("tool_use_id", ""))
102
+ inner = b.get("content")
103
+ if isinstance(inner, list):
104
+ inner = "".join(x.get("text", "") if isinstance(x.get("text"), str) else "" for x in inner)
105
+ steps.append(
106
+ Step(
107
+ role="tool_result",
108
+ id=tid,
109
+ tool=tool_names.get(tid, "unknown"),
110
+ status="error" if b.get("is_error") is True else "ok",
111
+ payload=_parse_payload(inner),
112
+ )
113
+ )
114
+ elif m.get("role") == "assistant":
115
+ for b in blocks:
116
+ if b.get("type") == "tool_use":
117
+ tid = str(b.get("id", ""))
118
+ tool = str(b.get("name", "unknown"))
119
+ tool_names[tid] = tool
120
+ steps.append(Step(role="tool_call", id=tid, tool=tool, args=_parse_args(b.get("input"))))
121
+ elif b.get("type") == "text" and isinstance(b.get("text"), str) and b["text"].strip():
122
+ steps.append(Step(role="assistant", content=b["text"]))
123
+ return Transcript(steps=steps)
124
+
125
+
126
+ def to_transcript(data: Any) -> Transcript:
127
+ """Accept any of the three shapes: agentguard's own ``{"steps": [...]}``, an OpenAI message
128
+ list, or an Anthropic message list (bare, or under ``"messages"``)."""
129
+ if isinstance(data, dict) and isinstance(data.get("steps"), list):
130
+ return load_transcript(data)
131
+ if isinstance(data, list):
132
+ messages = data
133
+ elif isinstance(data, dict) and isinstance(data.get("messages"), list):
134
+ messages = data["messages"]
135
+ else:
136
+ messages = []
137
+ looks_anthropic = any(
138
+ isinstance(m.get("content"), list)
139
+ and any(isinstance(b, dict) and b.get("type") in ("tool_use", "tool_result") for b in m["content"])
140
+ for m in messages
141
+ )
142
+ return from_anthropic(messages) if looks_anthropic else from_openai(messages)