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.
- toolwrit/__init__.py +95 -0
- toolwrit/_js.py +262 -0
- toolwrit/audit/__init__.py +1 -0
- toolwrit/audit/chain.py +251 -0
- toolwrit/audit/verify.py +164 -0
- toolwrit/budget/__init__.py +1 -0
- toolwrit/budget/ledger.py +141 -0
- toolwrit/cli.py +311 -0
- toolwrit/policy/__init__.py +1 -0
- toolwrit/policy/constraints.py +445 -0
- toolwrit/policy/engine.py +208 -0
- toolwrit/policy/load.py +418 -0
- toolwrit/policy/match.py +110 -0
- toolwrit/toolwrit.py +428 -0
- toolwrit/types.py +262 -0
- toolwrit-0.2.0.dist-info/METADATA +267 -0
- toolwrit-0.2.0.dist-info/RECORD +20 -0
- toolwrit-0.2.0.dist-info/WHEEL +5 -0
- toolwrit-0.2.0.dist-info/entry_points.txt +2 -0
- toolwrit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|