toolwrit 0.2.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,445 @@
1
+ """
2
+ Argument constraint evaluation.
3
+
4
+ Each check returns a Violation or nothing. They run in a fixed order so the
5
+ first reported violation for a given value is always the same one -- audit
6
+ diffs stay stable across releases.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from functools import lru_cache
13
+ from typing import Any, Final, Sequence
14
+ from urllib.parse import urlsplit
15
+
16
+ from .._js import js_json, js_number
17
+ from ..types import ArgConstraint, Violation
18
+ from .match import MISSING, resolve_path
19
+
20
+ _STRING_CONSTRAINTS: Final = ("matches", "startsWith", "excludes", "urlHosts")
21
+ _NUMBER_CONSTRAINTS: Final = ("min", "max")
22
+ _LENGTH_CONSTRAINTS: Final = ("minLength", "maxLength")
23
+
24
+ _SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*$")
25
+
26
+ # WHATWG calls these "special" schemes, and in them a backslash is a path
27
+ # separator exactly like "/". Every browser, curl and JavaScript's URL agree;
28
+ # Python's urlsplit does not, which is the whole reason _whatwg_slashes exists.
29
+ _SPECIAL_SCHEMES: Final = frozenset({"http", "https", "ws", "wss", "ftp", "file"})
30
+
31
+ # "file" is special for backslashes but has its own authority rules: a run of
32
+ # slashes is not collapsed (``file:///etc`` is host-less, not host "etc") and
33
+ # userinfo is rejected outright. Keeping it out of the collapsing set is the
34
+ # difference between agreeing with new URL() and inventing a host.
35
+ _AUTHORITY_SCHEMES: Final = _SPECIAL_SCHEMES - {"file"}
36
+
37
+
38
+ def check_args(
39
+ rule_id: str,
40
+ when: dict[str, ArgConstraint],
41
+ args: dict[str, Any],
42
+ ) -> list[Violation]:
43
+ """
44
+ Evaluate every constraint in ``when`` against ``args``.
45
+
46
+ Returns all violations found, so a policy author sees the full picture
47
+ rather than fixing one argument at a time.
48
+ """
49
+ violations: list[Violation] = []
50
+ for path, constraint in when.items():
51
+ violations.extend(_check_one(rule_id, path, constraint, resolve_path(args, path)))
52
+ return violations
53
+
54
+
55
+ def _check_one(
56
+ rule: str,
57
+ path: str,
58
+ c: ArgConstraint,
59
+ value: Any,
60
+ ) -> list[Violation]:
61
+ def v(constraint: str, message: str) -> Violation:
62
+ return Violation(rule=rule, path=path, constraint=constraint, message=message)
63
+
64
+ # An own key explicitly set to None is indistinguishable from an absent one
65
+ # for policy purposes, and treating it as present would let
66
+ # ``{"path": None}`` satisfy a required, scoped constraint.
67
+ if value is MISSING or value is None:
68
+ if c.optional:
69
+ return []
70
+ return [v("required", f'argument "{path}" is required but was not provided')]
71
+
72
+ out: list[Violation] = []
73
+
74
+ if c.type is not None and not _is_type(value, c.type):
75
+ # A type mismatch makes every downstream check meaningless, so stop here.
76
+ return [v("type", f'argument "{path}" must be a {c.type}, got {_describe(value)}')]
77
+
78
+ # Constraints below are type-specific. Skipping one because the value has an
79
+ # unexpected type would fail OPEN: `startsWith: ["/tmp/"]` would be satisfied
80
+ # by the list ["/etc/passwd"], and the rule would still match and allow the
81
+ # call. `args` is untrusted model output, so a wrong type is a violation.
82
+ string_constraints = _present_keys(c, _STRING_CONSTRAINTS)
83
+ number_constraints = _present_keys(c, _NUMBER_CONSTRAINTS)
84
+ length_constraints = _present_keys(c, _LENGTH_CONSTRAINTS)
85
+
86
+ is_string = isinstance(value, str)
87
+ is_number = isinstance(value, (int, float)) and not isinstance(value, bool)
88
+ has_length = _length_of(value) is not None
89
+
90
+ if string_constraints and not is_string:
91
+ out.append(
92
+ v(
93
+ "type",
94
+ f'argument "{path}" must be a string to be checked against '
95
+ f"{_list(string_constraints)}, got {_describe(value)}",
96
+ )
97
+ )
98
+ if number_constraints and not is_number:
99
+ out.append(
100
+ v(
101
+ "type",
102
+ f'argument "{path}" must be a number to be checked against '
103
+ f"{_list(number_constraints)}, got {_describe(value)}",
104
+ )
105
+ )
106
+ if length_constraints and not has_length:
107
+ out.append(
108
+ v(
109
+ "type",
110
+ f'argument "{path}" must be a string or array to be checked against '
111
+ f"{_list(length_constraints)}, got {_describe(value)}",
112
+ )
113
+ )
114
+
115
+ if c.oneOf is not None and not any(_deep_equal(allowed, value) for allowed in c.oneOf):
116
+ out.append(v("oneOf", f'argument "{path}" must be one of {js_json(c.oneOf)}'))
117
+
118
+ if c.noneOf is not None and any(_deep_equal(banned, value) for banned in c.noneOf):
119
+ out.append(v("noneOf", f'argument "{path}" must not be {js_json(value)}'))
120
+
121
+ if is_string:
122
+ if c.matches is not None and _js_regex(c.matches).search(value) is None:
123
+ out.append(v("matches", f'argument "{path}" must match /{c.matches}/'))
124
+ if c.startsWith is not None and not any(value.startswith(p) for p in c.startsWith):
125
+ out.append(
126
+ v(
127
+ "startsWith",
128
+ f'argument "{path}" must start with one of {js_json(c.startsWith)}',
129
+ )
130
+ )
131
+ if c.excludes is not None:
132
+ hit = next((needle for needle in c.excludes if needle in value), None)
133
+ if hit is not None:
134
+ out.append(
135
+ v("excludes", f'argument "{path}" must not contain {js_json(hit)}')
136
+ )
137
+ if c.urlHosts is not None:
138
+ host = _hostname_of(value)
139
+ if host is None:
140
+ out.append(v("urlHosts", f'argument "{path}" is not a parseable URL'))
141
+ elif not any(_host_allowed(host, allowed) for allowed in c.urlHosts):
142
+ out.append(
143
+ v(
144
+ "urlHosts",
145
+ f'host "{host}" is not in the allowed set {js_json(c.urlHosts)}',
146
+ )
147
+ )
148
+
149
+ if is_number:
150
+ if c.min is not None and value < c.min:
151
+ out.append(
152
+ v("min", f'argument "{path}" must be >= {js_number(c.min)}, '
153
+ f"got {js_number(value)}")
154
+ )
155
+ if c.max is not None and value > c.max:
156
+ out.append(
157
+ v("max", f'argument "{path}" must be <= {js_number(c.max)}, '
158
+ f"got {js_number(value)}")
159
+ )
160
+
161
+ length = _length_of(value)
162
+ if length is not None:
163
+ if c.maxLength is not None and length > c.maxLength:
164
+ out.append(
165
+ v(
166
+ "maxLength",
167
+ f'argument "{path}" must have length <= {js_number(c.maxLength)}, '
168
+ f"got {js_number(length)}",
169
+ )
170
+ )
171
+ if c.minLength is not None and length < c.minLength:
172
+ out.append(
173
+ v(
174
+ "minLength",
175
+ f'argument "{path}" must have length >= {js_number(c.minLength)}, '
176
+ f"got {js_number(length)}",
177
+ )
178
+ )
179
+
180
+ return out
181
+
182
+
183
+ def _present_keys(c: ArgConstraint, keys: Sequence[str]) -> list[str]:
184
+ """Which of ``keys`` the author actually set on this constraint."""
185
+ return [key for key in keys if getattr(c, key) is not None]
186
+
187
+
188
+ def _list(names: Sequence[str]) -> str:
189
+ return ", ".join(f'"{n}"' for n in names)
190
+
191
+
192
+ def _is_type(value: Any, type_: str) -> bool:
193
+ if type_ == "array":
194
+ return isinstance(value, list)
195
+ if type_ == "object":
196
+ return isinstance(value, dict)
197
+ if type_ == "boolean":
198
+ return isinstance(value, bool)
199
+ if type_ == "number":
200
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
201
+ return isinstance(value, str)
202
+
203
+
204
+ def _length_of(value: Any) -> int | None:
205
+ if isinstance(value, str):
206
+ # JavaScript's String#length counts UTF-16 code units, so an astral
207
+ # character costs two. Python counts code points; convert to keep a
208
+ # maxLength written against one implementation binding in the other.
209
+ return len(value.encode("utf-16-le", errors="surrogatepass")) // 2
210
+ if isinstance(value, list):
211
+ return len(value)
212
+ return None
213
+
214
+
215
+ def _describe(value: Any) -> str:
216
+ if value is None:
217
+ return "null"
218
+ if isinstance(value, list):
219
+ return "array"
220
+ if isinstance(value, bool):
221
+ return "boolean"
222
+ if isinstance(value, (int, float)):
223
+ return "number"
224
+ if isinstance(value, str):
225
+ return "string"
226
+ return "object"
227
+
228
+
229
+ @lru_cache(maxsize=512)
230
+ def _js_regex(pattern: str) -> re.Pattern[str]:
231
+ r"""Compile ``pattern`` so it means in Python what it means in JavaScript.
232
+
233
+ Python's ``$`` also matches just before a trailing newline; JavaScript's
234
+ (without the ``m`` flag) matches only at the very end. So a policy written
235
+ as ``matches: "^/tmp/[a-z]+$"`` accepts ``"/tmp/abc\n"`` here and rejects it
236
+ there -- the same rule, two verdicts, and the permissive one is Python's.
237
+ Rewriting the anchor to ``\Z`` closes that.
238
+
239
+ Only a ``$`` that is actually an anchor is touched: one that is escaped or
240
+ inside a character class is an ordinary dollar sign in both languages.
241
+ """
242
+ out: list[str] = []
243
+ in_class = False
244
+ i = 0
245
+ while i < len(pattern):
246
+ ch = pattern[i]
247
+ if ch == "\\":
248
+ out.append(pattern[i:i + 2])
249
+ i += 2
250
+ continue
251
+ if in_class:
252
+ in_class = ch != "]"
253
+ elif ch == "[":
254
+ in_class = True
255
+ elif ch == "$":
256
+ out.append(r"\Z")
257
+ i += 1
258
+ continue
259
+ out.append(ch)
260
+ i += 1
261
+ return re.compile("".join(out))
262
+
263
+
264
+ def _after_ipv6_literal(netloc: str) -> str:
265
+ """The part of an authority following a bracketed IPv6 literal.
266
+
267
+ An IPv6 host is full of colons, so "is there a port here?" cannot be asked
268
+ of the whole authority without mistaking the address for one.
269
+ """
270
+ end = netloc.rfind("]")
271
+ return netloc[end + 1:] if netloc.startswith("[") and end != -1 else netloc
272
+
273
+
274
+ def _whatwg_slashes(raw: str) -> str:
275
+ """Backslashes before the query become "/" for a special scheme.
276
+
277
+ ``http://evil.com\\@allowed.com/`` is the attack this exists for. A browser
278
+ reads the backslash as the end of the authority and goes to ``evil.com``;
279
+ ``urlsplit`` reads it as part of the userinfo and reports ``allowed.com``,
280
+ so an allowlist written against what the request will actually do would
281
+ have passed a URL aimed somewhere else entirely.
282
+
283
+ Only the part before the first "?" or "#" is touched: a backslash is an
284
+ ordinary character in a query string or fragment, and rewriting one there
285
+ would change a URL that was never ambiguous.
286
+ """
287
+ if "\\" not in raw:
288
+ return raw
289
+ scheme, colon, rest = raw.partition(":")
290
+ if not colon or scheme.lower() not in _SPECIAL_SCHEMES:
291
+ return raw
292
+ cut = min((i for i in (rest.find("?"), rest.find("#")) if i != -1), default=len(rest))
293
+ return f"{scheme}:{rest[:cut].replace(chr(92), '/')}{rest[cut:]}"
294
+
295
+
296
+ def _hostname_of(raw: str) -> str | None:
297
+ """The hostname of an absolute URL, or None when it will not parse.
298
+
299
+ This has to agree with ``new URL().hostname`` on every input, because the
300
+ two implementations enforce the same policy file and a host read one way
301
+ here and another way there is an allowlist that means two different things.
302
+ ``urlsplit`` is permissive where ``new URL()`` throws and strict where it is
303
+ forgiving, so both directions are corrected below.
304
+ """
305
+ normalized = _whatwg_slashes(raw)
306
+ scheme, colon, rest = normalized.partition(":")
307
+ lowered = scheme.lower()
308
+ if colon and lowered in _AUTHORITY_SCHEMES:
309
+ # WHATWG skips any run of slashes between a special scheme and its
310
+ # authority, so "http:a.com/" and "https:///x" both have a host.
311
+ normalized = f"{scheme}://{rest.lstrip('/')}"
312
+
313
+ try:
314
+ parts = urlsplit(normalized)
315
+ except ValueError:
316
+ return None
317
+ if not _SCHEME.match(parts.scheme):
318
+ return None
319
+ if lowered == "file":
320
+ # A file URL carries neither userinfo nor a port -- new URL() throws on
321
+ # both -- and its authority is legitimately empty in "file:///etc",
322
+ # which is why this is decided before the empty-authority rule below.
323
+ if "@" in parts.netloc:
324
+ return None
325
+ if ":" in _after_ipv6_literal(parts.netloc):
326
+ return None
327
+ if parts.netloc == "":
328
+ return ""
329
+ elif normalized[len(parts.scheme) + 1:].startswith("//") and not parts.netloc:
330
+ # A special scheme must have an authority; a non-special one may
331
+ # declare an empty one, and new URL() reports that as "".
332
+ return None if lowered in _SPECIAL_SCHEMES else ""
333
+ # urlsplit defers port validation until .port is read, so an authority
334
+ # ending in "8443\" yields a perfectly good host and only complains later.
335
+ # new URL() rejects the whole URL, so the port is checked here.
336
+ # An empty port ("host:") is legal and means the default one.
337
+ port = _after_ipv6_literal(parts.netloc.rpartition("@")[2]).partition(":")
338
+ if port[1] and port[2] and not port[2].isdigit():
339
+ return None
340
+
341
+ try:
342
+ host = parts.hostname
343
+ except ValueError:
344
+ return None
345
+ if host is None:
346
+ return "" if parts.netloc == "" else None
347
+ host = host.lower()
348
+ # urlsplit unwraps an IPv6 literal; new URL() keeps the brackets, and an
349
+ # allowlist entry is written to match whichever form is reported.
350
+ if ":" in host:
351
+ return f"[{host}]"
352
+ # IPv4 canonicalisation is part of the *special*-scheme host parser only;
353
+ # for any other scheme WHATWG keeps the host as written, so "0x7f.1" stays
354
+ # itself under "custom://".
355
+ if lowered in _SPECIAL_SCHEMES:
356
+ return _ipv4_canonical(host) or host
357
+ # Outside a special scheme the host is an opaque host, and WHATWG forbids
358
+ # these characters in one outright -- new URL() throws rather than
359
+ # producing a host that no allowlist entry could ever have been written for.
360
+ if any(ch in host for ch in "\\ #/?@[]:<>^|"):
361
+ return None
362
+ return host
363
+
364
+
365
+ def _ipv4_canonical(host: str) -> str | None:
366
+ """WHATWG's IPv4 reading of a host, or None when it is not one.
367
+
368
+ ``http://0x7f.1`` and ``http://2130706433`` both reach 127.0.0.1, and a
369
+ browser shows them that way. An allowlist compared against the literal text
370
+ would treat them as unknown hosts -- and a denylist would miss them
371
+ entirely -- so the host is canonicalised the same way the client will.
372
+
373
+ Parts may be decimal, octal (a leading 0) or hex (0x), and a short address
374
+ lets its last part absorb the rest: "0x7f.1" is 127.0.0.1, not a domain.
375
+ """
376
+ parts = host.split(".")
377
+ if parts and parts[-1] == "": # one trailing dot is allowed, "1.2.3.4."
378
+ parts = parts[:-1]
379
+ if not parts or len(parts) > 4:
380
+ return None
381
+
382
+ numbers: list[int] = []
383
+ for part in parts:
384
+ text = part.lower()
385
+ if text.startswith(("0x", "-0x")) or (text.startswith("0") and len(text) > 1):
386
+ base = 16 if text.startswith("0x") else 8
387
+ digits = text[2:] if base == 16 else text[1:]
388
+ if digits == "" and base == 16:
389
+ numbers.append(0)
390
+ continue
391
+ else:
392
+ base, digits = 10, text
393
+ allowed = "0123456789abcdef"[:16 if base == 16 else (8 if base == 8 else 10)]
394
+ if digits == "" or any(ch not in allowed for ch in digits):
395
+ return None
396
+ numbers.append(int(digits, base))
397
+
398
+ # Every part but the last must fit in a byte; the last absorbs what is left.
399
+ if any(n > 255 for n in numbers[:-1]):
400
+ return None
401
+ if numbers[-1] >= 256 ** (5 - len(numbers)):
402
+ return None
403
+
404
+ value = numbers[-1]
405
+ for index, number in enumerate(numbers[:-1]):
406
+ value += number * 256 ** (3 - index)
407
+ return ".".join(str((value >> shift) & 0xFF) for shift in (24, 16, 8, 0))
408
+
409
+
410
+ def _host_allowed(host: str, allowed: str) -> bool:
411
+ """
412
+ An entry starting with "." matches that domain and every subdomain
413
+ (".example.com" covers "api.example.com" and "example.com"). Anything else
414
+ must match exactly, so an allowlist never widens by accident.
415
+ """
416
+ pattern = allowed.lower()
417
+ if pattern.startswith("."):
418
+ return host == pattern[1:] or host.endswith(pattern)
419
+ return host == pattern
420
+
421
+
422
+ def _deep_equal(a: Any, b: Any) -> bool:
423
+ """Structural equality with JavaScript's type strictness.
424
+
425
+ Python would call ``1 == True`` and ``1 == 1.0`` equal; the first of those
426
+ would let a boolean argument satisfy a numeric ``oneOf``, so booleans are
427
+ compared only to booleans.
428
+ """
429
+ if isinstance(a, bool) != isinstance(b, bool):
430
+ return False
431
+ if isinstance(a, bool):
432
+ return a is b
433
+ if isinstance(a, dict) or isinstance(b, dict):
434
+ if not (isinstance(a, dict) and isinstance(b, dict)) or len(a) != len(b):
435
+ return False
436
+ return all(k in b and _deep_equal(v, b[k]) for k, v in a.items())
437
+ if isinstance(a, list) or isinstance(b, list):
438
+ if not (isinstance(a, list) and isinstance(b, list)) or len(a) != len(b):
439
+ return False
440
+ return all(_deep_equal(x, y) for x, y in zip(a, b))
441
+ if isinstance(a, str) != isinstance(b, str):
442
+ return False
443
+ if a is None or b is None:
444
+ return a is None and b is None
445
+ return a == b
@@ -0,0 +1,208 @@
1
+ """
2
+ The decision function.
3
+
4
+ ``evaluate`` is pure: same policy + same usage + same history + same call
5
+ always yields the same Decision. No clock reads, no I/O, no model in the
6
+ loop. That property is what makes a Toolwrit audit log replayable, and it is
7
+ the difference between this and a guardrail that asks an LLM for permission.
8
+
9
+ Precedence, in order:
10
+ 1. Budget. An exhausted budget denies everything, whatever the rules say.
11
+ 2. A matching rule whose rate limit is spent denies.
12
+ 3. Among matching rules: deny beats ask beats allow.
13
+ 4. No match at all falls through to ``policy.default`` (deny unless set).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+
20
+ from .._js import js_number, js_to_fixed
21
+ from ..types import Decision, EvalContext, PolicyRule, ToolCall, Violation
22
+ from .constraints import check_args
23
+ from .match import matches_any_glob
24
+
25
+
26
+ def evaluate(call: ToolCall, ctx: EvalContext) -> Decision:
27
+ budget_violation = _check_budget(call, ctx)
28
+ if budget_violation is not None:
29
+ return Decision(
30
+ effect="deny",
31
+ rule=None,
32
+ reason=budget_violation.message,
33
+ violations=[budget_violation],
34
+ )
35
+
36
+ matched: list[PolicyRule] = []
37
+ #: Rules that targeted this tool but whose constraints rejected the arguments.
38
+ near_misses: list[Violation] = []
39
+
40
+ for rule in ctx.policy.rules:
41
+ if not matches_any_glob(rule.tools, call.tool):
42
+ continue
43
+
44
+ violations = check_args(rule.id, rule.when, call.args) if rule.when else []
45
+ if violations:
46
+ if rule.effect != "deny":
47
+ near_misses.extend(violations)
48
+ continue
49
+ matched.append(rule)
50
+
51
+ for rule in matched:
52
+ if rule.limit is None:
53
+ continue
54
+ used = _count_recent(ctx, rule.id, call.at, rule.limit.perSeconds)
55
+ if used >= rule.limit.max:
56
+ window = (
57
+ f"{js_number(rule.limit.perSeconds)}s"
58
+ if rule.limit.perSeconds is not None
59
+ else "this run"
60
+ )
61
+ return Decision(
62
+ effect="deny",
63
+ rule=rule.id,
64
+ reason=f'rate limit exhausted for rule "{rule.id}"',
65
+ violations=[
66
+ Violation(
67
+ rule=rule.id,
68
+ constraint="limit",
69
+ message=(
70
+ f'rule "{rule.id}" allows {js_number(rule.limit.max)} '
71
+ f"call(s) per {window}; {js_number(used)} already used"
72
+ ),
73
+ )
74
+ ],
75
+ )
76
+
77
+ denied = next((r for r in matched if r.effect == "deny"), None)
78
+ if denied is not None:
79
+ return Decision(
80
+ effect="deny",
81
+ rule=denied.id,
82
+ reason=_or(denied.description, f'denied by rule "{denied.id}"'),
83
+ violations=[
84
+ Violation(
85
+ rule=denied.id,
86
+ constraint="rule",
87
+ message=_or(
88
+ denied.description,
89
+ f'tool "{call.tool}" is denied by rule "{denied.id}"',
90
+ ),
91
+ )
92
+ ],
93
+ )
94
+
95
+ ask = next((r for r in matched if r.effect == "ask"), None)
96
+ if ask is not None:
97
+ return Decision(
98
+ effect="ask",
99
+ rule=ask.id,
100
+ reason=_or(ask.description, f'rule "{ask.id}" requires approval'),
101
+ violations=[],
102
+ )
103
+
104
+ allow = next((r for r in matched if r.effect == "allow"), None)
105
+ if allow is not None:
106
+ return Decision(
107
+ effect="allow",
108
+ rule=allow.id,
109
+ reason=f'allowed by rule "{allow.id}"',
110
+ violations=[],
111
+ )
112
+
113
+ fallback = ctx.policy.default if ctx.policy.default is not None else "deny"
114
+ if fallback == "allow":
115
+ return Decision(
116
+ effect="allow",
117
+ rule=None,
118
+ reason="no rule matched; policy default is allow",
119
+ violations=[],
120
+ )
121
+
122
+ # Deny-by-default is the common path, so spend the effort on a message the
123
+ # agent can act on: if a rule wanted this tool but rejected the arguments,
124
+ # say which argument rather than the useless "no rule matched".
125
+ return Decision(
126
+ effect=fallback,
127
+ rule=None,
128
+ reason=(
129
+ f'no rule allows "{call.tool}" with these arguments'
130
+ if near_misses
131
+ else f'no rule allows tool "{call.tool}"'
132
+ ),
133
+ violations=(
134
+ near_misses
135
+ if near_misses
136
+ else [
137
+ Violation(
138
+ rule="policy",
139
+ constraint="default",
140
+ message=(
141
+ f'tool "{call.tool}" is not in the policy; '
142
+ f"the default effect is {fallback}"
143
+ ),
144
+ )
145
+ ]
146
+ ),
147
+ )
148
+
149
+
150
+ def _or(value: str | None, fallback: str) -> str:
151
+ """Nullish coalescing: an explicitly empty description is still a description."""
152
+ return fallback if value is None else value
153
+
154
+
155
+ def _check_budget(call: ToolCall, ctx: EvalContext) -> Violation | None:
156
+ limits = ctx.policy.budget
157
+ if limits is None:
158
+ return None
159
+ usage = ctx.usage
160
+
161
+ def over(constraint: str, message: str) -> Violation:
162
+ return Violation(rule="budget", constraint=constraint, message=message)
163
+
164
+ if limits.calls is not None and usage.calls >= limits.calls:
165
+ return over(
166
+ "calls",
167
+ f"call budget exhausted: {js_number(usage.calls)}/"
168
+ f"{js_number(limits.calls)} calls used",
169
+ )
170
+ if limits.tokens is not None and usage.tokens >= limits.tokens:
171
+ return over(
172
+ "tokens",
173
+ f"token budget exhausted: {js_number(usage.tokens)}/"
174
+ f"{js_number(limits.tokens)} tokens used",
175
+ )
176
+ if limits.usd is not None and usage.usd >= limits.usd:
177
+ return over(
178
+ "usd",
179
+ f"spend budget exhausted: ${js_to_fixed(usage.usd, 4)}/"
180
+ f"${js_to_fixed(limits.usd, 4)} used",
181
+ )
182
+ if limits.bytes is not None and usage.bytes >= limits.bytes:
183
+ return over(
184
+ "bytes",
185
+ f"data budget exhausted: {js_number(usage.bytes)}/"
186
+ f"{js_number(limits.bytes)} bytes returned by tools",
187
+ )
188
+ if limits.seconds is not None and usage.started_at is not None:
189
+ elapsed = (call.at - usage.started_at) / 1000
190
+ if elapsed >= limits.seconds:
191
+ return over(
192
+ "seconds",
193
+ f"time budget exhausted: {js_to_fixed(elapsed, 1)}s/"
194
+ f"{js_number(limits.seconds)}s elapsed",
195
+ )
196
+
197
+ return None
198
+
199
+
200
+ def _count_recent(
201
+ ctx: EvalContext,
202
+ rule_id: str,
203
+ now: int,
204
+ per_seconds: float | None,
205
+ ) -> int:
206
+ """Calls already attributed to ``rule_id``, within the sliding window if one is set."""
207
+ floor = -math.inf if per_seconds is None else now - per_seconds * 1000
208
+ return sum(1 for entry in ctx.history if entry.rule == rule_id and entry.at > floor)