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
toolwrit/policy/load.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Policy parsing and validation.
|
|
3
|
+
|
|
4
|
+
Validation is strict and rejects unknown keys. In a security policy a typo
|
|
5
|
+
is not a harmless no-op: ``startWith`` silently ignored would turn a scoped
|
|
6
|
+
filesystem rule into an unscoped one. Fail loudly at load time instead.
|
|
7
|
+
|
|
8
|
+
Throughout this module an absent key and a key written as ``null`` are held
|
|
9
|
+
apart, via the UNDEFINED sentinel. They are not the same thing: an absent
|
|
10
|
+
``budget.calls`` means "no ceiling", while ``calls: null`` is a policy the
|
|
11
|
+
author got wrong and should hear about.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Final
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
from .._js import UNDEFINED, js_json
|
|
23
|
+
from ..types import (
|
|
24
|
+
ArgConstraint,
|
|
25
|
+
BudgetLimits,
|
|
26
|
+
Effect,
|
|
27
|
+
Policy,
|
|
28
|
+
PolicyRule,
|
|
29
|
+
RateLimit,
|
|
30
|
+
RunPlan,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PolicyError(Exception):
|
|
35
|
+
"""A policy that cannot be trusted. Carries the location of the problem."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str, path: str) -> None:
|
|
38
|
+
super().__init__(f"{path}: {message}")
|
|
39
|
+
self.path = path
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
EFFECTS: Final[frozenset[str]] = frozenset({"allow", "deny", "ask"})
|
|
43
|
+
_POLICY_KEYS: Final = ("version", "name", "default", "budget", "plan", "rules")
|
|
44
|
+
_PLAN_KEYS: Final = ("purpose", "approvedBy", "warnAt")
|
|
45
|
+
_RULE_KEYS: Final = ("id", "description", "tools", "effect", "when", "limit")
|
|
46
|
+
_BUDGET_KEYS: Final = ("calls", "tokens", "usd", "bytes", "seconds")
|
|
47
|
+
_LIMIT_KEYS: Final = ("max", "perSeconds")
|
|
48
|
+
_CONSTRAINT_KEYS: Final = (
|
|
49
|
+
"optional", "oneOf", "noneOf", "matches", "startsWith", "excludes",
|
|
50
|
+
"min", "max", "maxLength", "minLength", "urlHosts", "type",
|
|
51
|
+
)
|
|
52
|
+
_ARG_TYPES: Final = ("string", "number", "boolean", "object", "array")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_policy_file(file: str) -> Policy:
|
|
56
|
+
"""Read and validate a policy from a ``.yaml``, ``.yml`` or ``.json`` file."""
|
|
57
|
+
try:
|
|
58
|
+
with open(file, "r", encoding="utf-8") as handle:
|
|
59
|
+
raw = handle.read()
|
|
60
|
+
except OSError as err:
|
|
61
|
+
raise PolicyError(f"cannot read policy file ({err})", file) from err
|
|
62
|
+
return parse_policy(raw, file)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_policy(raw: str, source: str = "policy") -> Policy:
|
|
66
|
+
"""Validate an already-read policy document. ``source`` only decorates errors."""
|
|
67
|
+
try:
|
|
68
|
+
doc = yaml.safe_load(raw)
|
|
69
|
+
except yaml.YAMLError as err:
|
|
70
|
+
raise PolicyError(f"invalid YAML/JSON ({err})", source) from err
|
|
71
|
+
return validate_policy(doc, source)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_policy(doc: Any, source: str = "policy") -> Policy:
|
|
75
|
+
root = _require_object(doc, source)
|
|
76
|
+
_reject_unknown_keys(root, _POLICY_KEYS, source)
|
|
77
|
+
|
|
78
|
+
version = _get(root, "version")
|
|
79
|
+
if version != "1":
|
|
80
|
+
raise PolicyError(
|
|
81
|
+
f"unsupported policy version {_describe_value(version)}; expected \"1\"",
|
|
82
|
+
source,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
name = _get(root, "name")
|
|
86
|
+
if name is not UNDEFINED and not isinstance(name, str):
|
|
87
|
+
raise PolicyError('"name" must be a string', source)
|
|
88
|
+
|
|
89
|
+
default = _get(root, "default")
|
|
90
|
+
if default is not UNDEFINED and default not in EFFECTS:
|
|
91
|
+
raise PolicyError('"default" must be one of allow, deny, ask', source)
|
|
92
|
+
|
|
93
|
+
policy = Policy(
|
|
94
|
+
version="1",
|
|
95
|
+
rules=[],
|
|
96
|
+
name=None if name is UNDEFINED else name,
|
|
97
|
+
default=None if default is UNDEFINED else _as_effect(default),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
budget_raw = _get(root, "budget")
|
|
101
|
+
if budget_raw is not UNDEFINED:
|
|
102
|
+
budget = _require_object(budget_raw, f"{source}.budget")
|
|
103
|
+
_reject_unknown_keys(budget, _BUDGET_KEYS, f"{source}.budget")
|
|
104
|
+
limits: dict[str, float | None] = {}
|
|
105
|
+
for key in _BUDGET_KEYS:
|
|
106
|
+
value = _get(budget, key)
|
|
107
|
+
if value is UNDEFINED:
|
|
108
|
+
limits[key] = None
|
|
109
|
+
continue
|
|
110
|
+
if not _is_number(value) or not math.isfinite(value) or value <= 0:
|
|
111
|
+
raise PolicyError(f'"{key}" must be a positive number', f"{source}.budget")
|
|
112
|
+
limits[key] = value
|
|
113
|
+
policy.budget = BudgetLimits(**limits)
|
|
114
|
+
|
|
115
|
+
plan_raw = _get(root, "plan")
|
|
116
|
+
if plan_raw is not UNDEFINED:
|
|
117
|
+
plan = _require_object(plan_raw, f"{source}.plan")
|
|
118
|
+
_reject_unknown_keys(plan, _PLAN_KEYS, f"{source}.plan")
|
|
119
|
+
|
|
120
|
+
purpose = _get(plan, "purpose")
|
|
121
|
+
if not isinstance(purpose, str) or purpose.strip() == "":
|
|
122
|
+
raise PolicyError('"purpose" must be a non-empty string', f"{source}.plan")
|
|
123
|
+
|
|
124
|
+
approved_by = _get(plan, "approvedBy")
|
|
125
|
+
if approved_by is not UNDEFINED and not isinstance(approved_by, str):
|
|
126
|
+
raise PolicyError('"approvedBy" must be a string', f"{source}.plan")
|
|
127
|
+
|
|
128
|
+
warn_at = _get(plan, "warnAt")
|
|
129
|
+
if warn_at is not UNDEFINED:
|
|
130
|
+
if not isinstance(warn_at, list) or len(warn_at) == 0:
|
|
131
|
+
raise PolicyError('"warnAt" must be a non-empty array', f"{source}.plan")
|
|
132
|
+
for threshold in warn_at:
|
|
133
|
+
if not _is_number(threshold) or not threshold > 0 or threshold > 1:
|
|
134
|
+
raise PolicyError(
|
|
135
|
+
'"warnAt" entries must be fractions greater than 0 and at '
|
|
136
|
+
"most 1, e.g. 0.8",
|
|
137
|
+
f"{source}.plan",
|
|
138
|
+
)
|
|
139
|
+
# A plan with nothing to measure against would report nothing, which reads
|
|
140
|
+
# as "all clear" rather than "not configured". Fail loudly instead.
|
|
141
|
+
if budget_raw is UNDEFINED:
|
|
142
|
+
raise PolicyError(
|
|
143
|
+
'a "plan" requires a "budget" to measure against; add budget limits '
|
|
144
|
+
"or remove the plan",
|
|
145
|
+
source,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
policy.plan = RunPlan(
|
|
149
|
+
purpose=purpose,
|
|
150
|
+
approvedBy=None if approved_by is UNDEFINED else approved_by,
|
|
151
|
+
warnAt=None if warn_at is UNDEFINED else sorted(warn_at),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
rules_raw = _get(root, "rules")
|
|
155
|
+
if not isinstance(rules_raw, list):
|
|
156
|
+
raise PolicyError('"rules" must be an array', source)
|
|
157
|
+
|
|
158
|
+
seen: set[str] = set()
|
|
159
|
+
for i, raw_rule in enumerate(rules_raw):
|
|
160
|
+
parsed = _validate_rule(raw_rule, f"{source}.rules[{i}]")
|
|
161
|
+
if parsed.id in seen:
|
|
162
|
+
raise PolicyError(f'duplicate rule id "{parsed.id}"', f"{source}.rules[{i}]")
|
|
163
|
+
seen.add(parsed.id)
|
|
164
|
+
policy.rules.append(parsed)
|
|
165
|
+
|
|
166
|
+
return policy
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _validate_rule(raw: Any, path: str) -> PolicyRule:
|
|
170
|
+
rule = _require_object(raw, path)
|
|
171
|
+
_reject_unknown_keys(rule, _RULE_KEYS, path)
|
|
172
|
+
|
|
173
|
+
identifier = _get(rule, "id")
|
|
174
|
+
if not isinstance(identifier, str) or identifier == "":
|
|
175
|
+
raise PolicyError('"id" must be a non-empty string', path)
|
|
176
|
+
|
|
177
|
+
description = _get(rule, "description")
|
|
178
|
+
if description is not UNDEFINED and not isinstance(description, str):
|
|
179
|
+
raise PolicyError('"description" must be a string', path)
|
|
180
|
+
|
|
181
|
+
tools = _get(rule, "tools")
|
|
182
|
+
if not isinstance(tools, list) or len(tools) == 0:
|
|
183
|
+
raise PolicyError('"tools" must be a non-empty array of glob patterns', path)
|
|
184
|
+
for tool in tools:
|
|
185
|
+
if not isinstance(tool, str) or tool == "":
|
|
186
|
+
raise PolicyError('every entry in "tools" must be a non-empty string', path)
|
|
187
|
+
|
|
188
|
+
effect = _get(rule, "effect")
|
|
189
|
+
if effect not in EFFECTS:
|
|
190
|
+
raise PolicyError('"effect" must be one of allow, deny, ask', path)
|
|
191
|
+
|
|
192
|
+
out = PolicyRule(
|
|
193
|
+
id=identifier,
|
|
194
|
+
tools=list(tools),
|
|
195
|
+
effect=_as_effect(effect),
|
|
196
|
+
description=None if description is UNDEFINED else description,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
when_raw = _get(rule, "when")
|
|
200
|
+
if when_raw is not UNDEFINED:
|
|
201
|
+
when = _require_object(when_raw, f"{path}.when")
|
|
202
|
+
out.when = {
|
|
203
|
+
arg_path: _validate_constraint(constraint, f'{path}.when["{arg_path}"]')
|
|
204
|
+
for arg_path, constraint in when.items()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
limit_raw = _get(rule, "limit")
|
|
208
|
+
if limit_raw is not UNDEFINED:
|
|
209
|
+
limit = _require_object(limit_raw, f"{path}.limit")
|
|
210
|
+
_reject_unknown_keys(limit, _LIMIT_KEYS, f"{path}.limit")
|
|
211
|
+
|
|
212
|
+
maximum = _get(limit, "max")
|
|
213
|
+
if not _is_number(maximum) or not _is_integer(maximum) or maximum < 0:
|
|
214
|
+
raise PolicyError('"max" must be a non-negative integer', f"{path}.limit")
|
|
215
|
+
|
|
216
|
+
per_seconds = _get(limit, "perSeconds")
|
|
217
|
+
if per_seconds is not UNDEFINED and (
|
|
218
|
+
not _is_number(per_seconds) or per_seconds <= 0
|
|
219
|
+
):
|
|
220
|
+
raise PolicyError('"perSeconds" must be a positive number', f"{path}.limit")
|
|
221
|
+
|
|
222
|
+
out.limit = RateLimit(
|
|
223
|
+
max=int(maximum),
|
|
224
|
+
perSeconds=None if per_seconds is UNDEFINED else per_seconds,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _quantifier_at(pattern: str, index: int) -> str | None:
|
|
231
|
+
"""The quantifier token starting at ``index``, or None if there is not one."""
|
|
232
|
+
if index >= len(pattern):
|
|
233
|
+
return None
|
|
234
|
+
ch = pattern[index]
|
|
235
|
+
if ch in ("*", "+"):
|
|
236
|
+
return ch
|
|
237
|
+
if ch == "{":
|
|
238
|
+
close = pattern.find("}", index)
|
|
239
|
+
if close == -1:
|
|
240
|
+
return None
|
|
241
|
+
body = pattern[index + 1 : close]
|
|
242
|
+
if not re.fullmatch(r"\d*,?\d*", body):
|
|
243
|
+
return None
|
|
244
|
+
parts = body.split(",")
|
|
245
|
+
if len(parts) == 1:
|
|
246
|
+
upper = float(parts[0]) if parts[0] else 0.0
|
|
247
|
+
else:
|
|
248
|
+
upper = float("inf") if parts[1] == "" else (float(parts[1]) if parts[1] else 0.0)
|
|
249
|
+
# Only an open-ended or plural repetition can blow up; {0,1} cannot.
|
|
250
|
+
return "{" + body + "}" if upper > 1 else None
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def nested_quantifier(pattern: str) -> str | None:
|
|
255
|
+
"""
|
|
256
|
+
Reject a regular expression whose shape can backtrack catastrophically.
|
|
257
|
+
|
|
258
|
+
``matches`` patterns are compiled once at load but RUN against argument
|
|
259
|
+
values, which are model output -- the untrusted side. A pattern with a
|
|
260
|
+
quantifier applied to a group that already contains one, ``^(([a-z]+)+@)+x$``
|
|
261
|
+
and its family, takes exponential time on a crafted 44-character value: over
|
|
262
|
+
ten seconds in both implementations, with the whole enforcement point frozen
|
|
263
|
+
because evaluation is synchronous.
|
|
264
|
+
|
|
265
|
+
The check is STRUCTURAL rather than timed on purpose. Rejecting a pattern
|
|
266
|
+
because it ran slowly would make acceptance depend on the machine and the
|
|
267
|
+
regex engine, so the same policy could load here and fail in TypeScript --
|
|
268
|
+
and "the same policy always reaches the same verdict" is the property this
|
|
269
|
+
library is for. A shape test gives both languages the same answer.
|
|
270
|
+
|
|
271
|
+
It is a heuristic, and the docs say so: it catches the classic nested-
|
|
272
|
+
quantifier family, not every pathological pattern. A linear-time engine is
|
|
273
|
+
the real fix and would cost the single-dependency property, so it is a
|
|
274
|
+
deliberate roadmap item rather than a silent gap.
|
|
275
|
+
|
|
276
|
+
Kept byte-for-byte equivalent to the TypeScript ``nestedQuantifier``.
|
|
277
|
+
"""
|
|
278
|
+
stack: list[bool] = []
|
|
279
|
+
in_class = False
|
|
280
|
+
i = 0
|
|
281
|
+
|
|
282
|
+
while i < len(pattern):
|
|
283
|
+
ch = pattern[i]
|
|
284
|
+
|
|
285
|
+
if ch == "\\":
|
|
286
|
+
i += 2 # An escaped character is a literal, never syntax.
|
|
287
|
+
continue
|
|
288
|
+
if in_class:
|
|
289
|
+
if ch == "]":
|
|
290
|
+
in_class = False
|
|
291
|
+
i += 1
|
|
292
|
+
continue
|
|
293
|
+
if ch == "[":
|
|
294
|
+
in_class = True
|
|
295
|
+
i += 1
|
|
296
|
+
continue
|
|
297
|
+
if ch == "(":
|
|
298
|
+
stack.append(False)
|
|
299
|
+
i += 1
|
|
300
|
+
continue
|
|
301
|
+
if ch == ")":
|
|
302
|
+
closed = stack.pop() if stack else None
|
|
303
|
+
if closed is not None:
|
|
304
|
+
nxt = _quantifier_at(pattern, i + 1)
|
|
305
|
+
if nxt is not None:
|
|
306
|
+
if closed:
|
|
307
|
+
return (
|
|
308
|
+
f'nested quantifier: a "{nxt}" applied to a group that '
|
|
309
|
+
"already contains a quantifier"
|
|
310
|
+
)
|
|
311
|
+
# The group is quantified, so the group it sits inside now
|
|
312
|
+
# counts as containing a quantifier too.
|
|
313
|
+
if stack:
|
|
314
|
+
stack[-1] = True
|
|
315
|
+
i += 1
|
|
316
|
+
continue
|
|
317
|
+
if _quantifier_at(pattern, i) is not None and stack:
|
|
318
|
+
stack[-1] = True
|
|
319
|
+
i += 1
|
|
320
|
+
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _validate_constraint(raw: Any, path: str) -> ArgConstraint:
|
|
325
|
+
c = _require_object(raw, path)
|
|
326
|
+
_reject_unknown_keys(c, _CONSTRAINT_KEYS, path)
|
|
327
|
+
|
|
328
|
+
matches = _get(c, "matches")
|
|
329
|
+
if matches is not UNDEFINED:
|
|
330
|
+
if not isinstance(matches, str):
|
|
331
|
+
raise PolicyError('"matches" must be a string', path)
|
|
332
|
+
try:
|
|
333
|
+
re.compile(matches)
|
|
334
|
+
except re.error as err:
|
|
335
|
+
raise PolicyError(f'"matches" is not a valid regexp ({err})', path) from err
|
|
336
|
+
unsafe = nested_quantifier(matches)
|
|
337
|
+
if unsafe is not None:
|
|
338
|
+
raise PolicyError(
|
|
339
|
+
f'"matches" has a shape that can backtrack catastrophically -- {unsafe}. '
|
|
340
|
+
"Argument values come from the model, so a crafted value would freeze "
|
|
341
|
+
'enforcement. Prefer "startsWith", "oneOf" or "excludes", or rewrite the '
|
|
342
|
+
"pattern without the nesting",
|
|
343
|
+
path,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
for key in ("startsWith", "excludes", "urlHosts"):
|
|
347
|
+
value = _get(c, key)
|
|
348
|
+
if value is UNDEFINED:
|
|
349
|
+
continue
|
|
350
|
+
if not isinstance(value, list) or any(not isinstance(e, str) for e in value):
|
|
351
|
+
raise PolicyError(f'"{key}" must be an array of strings', path)
|
|
352
|
+
|
|
353
|
+
for key in ("oneOf", "noneOf"):
|
|
354
|
+
value = _get(c, key)
|
|
355
|
+
if value is not UNDEFINED and not isinstance(value, list):
|
|
356
|
+
raise PolicyError(f'"{key}" must be an array', path)
|
|
357
|
+
|
|
358
|
+
for key in ("min", "max", "maxLength", "minLength"):
|
|
359
|
+
value = _get(c, key)
|
|
360
|
+
if value is not UNDEFINED and not _is_number(value):
|
|
361
|
+
raise PolicyError(f'"{key}" must be a number', path)
|
|
362
|
+
|
|
363
|
+
optional = _get(c, "optional")
|
|
364
|
+
if optional is not UNDEFINED and not isinstance(optional, bool):
|
|
365
|
+
raise PolicyError('"optional" must be a boolean', path)
|
|
366
|
+
|
|
367
|
+
type_ = _get(c, "type")
|
|
368
|
+
if type_ is not UNDEFINED and type_ not in _ARG_TYPES:
|
|
369
|
+
raise PolicyError(
|
|
370
|
+
'"type" must be one of string, number, boolean, object, array', path
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return ArgConstraint(
|
|
374
|
+
**{
|
|
375
|
+
key: (None if (value := _get(c, key)) is UNDEFINED else value)
|
|
376
|
+
for key in _CONSTRAINT_KEYS
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _get(obj: dict[str, Any], key: str) -> Any:
|
|
382
|
+
"""The value at ``key``, or UNDEFINED when the key is absent."""
|
|
383
|
+
return obj.get(key, UNDEFINED)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _describe_value(value: Any) -> str:
|
|
387
|
+
"""How the TypeScript template literal renders a rejected version value."""
|
|
388
|
+
return "undefined" if value is UNDEFINED else js_json(value)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _as_effect(value: Any) -> Effect:
|
|
392
|
+
"""Narrow an already-validated string to the Effect literal type."""
|
|
393
|
+
return value # type: ignore[return-value]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _is_number(value: Any) -> bool:
|
|
397
|
+
"""JavaScript's ``typeof x === 'number'``: booleans are not numbers."""
|
|
398
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _is_integer(value: float) -> bool:
|
|
402
|
+
return math.isfinite(value) and float(value).is_integer()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _require_object(value: Any, path: str) -> dict[str, Any]:
|
|
406
|
+
if not isinstance(value, dict):
|
|
407
|
+
raise PolicyError("expected an object", path)
|
|
408
|
+
return value
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _reject_unknown_keys(obj: dict[str, Any], allowed: tuple[str, ...], path: str) -> None:
|
|
412
|
+
unknown = [key for key in obj if key not in allowed]
|
|
413
|
+
if unknown:
|
|
414
|
+
named = ", ".join('"' + str(key) + '"' for key in unknown)
|
|
415
|
+
raise PolicyError(
|
|
416
|
+
f"unknown field(s) {named}; allowed: {', '.join(allowed)}",
|
|
417
|
+
path,
|
|
418
|
+
)
|
toolwrit/policy/match.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool-name globbing and argument-path resolution.
|
|
3
|
+
|
|
4
|
+
Deliberately tiny and dependency-free: this code sits on the hot path of
|
|
5
|
+
every tool call, and a policy author must be able to predict what it does
|
|
6
|
+
without reading a regex dialect reference.
|
|
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
|
+
|
|
15
|
+
|
|
16
|
+
def matches_glob(pattern: str, name: str) -> bool:
|
|
17
|
+
"""
|
|
18
|
+
``*`` matches any run of characters except the separators ``.`` and ``/``.
|
|
19
|
+
``**`` matches anything, separators included.
|
|
20
|
+
Everything else is literal. Matching is case-sensitive and whole-string.
|
|
21
|
+
"""
|
|
22
|
+
return _glob_to_regexp(pattern).match(name) is not None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@lru_cache(maxsize=None)
|
|
26
|
+
def _glob_to_regexp(pattern: str) -> re.Pattern[str]:
|
|
27
|
+
out = ["^"]
|
|
28
|
+
i = 0
|
|
29
|
+
while i < len(pattern):
|
|
30
|
+
char = pattern[i]
|
|
31
|
+
if char == "*":
|
|
32
|
+
if i + 1 < len(pattern) and pattern[i + 1] == "*":
|
|
33
|
+
out.append(".*")
|
|
34
|
+
i += 1
|
|
35
|
+
else:
|
|
36
|
+
out.append("[^./]*")
|
|
37
|
+
else:
|
|
38
|
+
out.append(re.escape(char))
|
|
39
|
+
i += 1
|
|
40
|
+
# \Z rather than $, which in Python (as in JavaScript without /m) would
|
|
41
|
+
# still let a trailing newline slip past the anchor.
|
|
42
|
+
out.append(r"\Z")
|
|
43
|
+
return re.compile("".join(out), re.DOTALL)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def matches_any_glob(patterns: Sequence[str], name: str) -> bool:
|
|
47
|
+
"""True when any pattern in the list matches. An empty list matches nothing."""
|
|
48
|
+
return any(matches_glob(p, name) for p in patterns)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Missing:
|
|
52
|
+
"""Sentinel distinguishing "resolved to null" from "path does not exist"."""
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return "MISSING"
|
|
56
|
+
|
|
57
|
+
def __bool__(self) -> bool:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
MISSING: Final[_Missing] = _Missing()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _array_index(segment: str) -> int | None:
|
|
65
|
+
"""A path segment as a list index, or None when it does not address one.
|
|
66
|
+
|
|
67
|
+
Mirrors ``Number(segment)`` followed by the ``Number.isInteger`` gate, so
|
|
68
|
+
"length" and "-1" resolve to MISSING rather than to something surprising.
|
|
69
|
+
"""
|
|
70
|
+
text = segment.strip()
|
|
71
|
+
if text == "":
|
|
72
|
+
return 0
|
|
73
|
+
try:
|
|
74
|
+
value = float(text)
|
|
75
|
+
except ValueError:
|
|
76
|
+
return None
|
|
77
|
+
if value < 0 or not value.is_integer():
|
|
78
|
+
return None
|
|
79
|
+
return int(value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_path(args: Any, path: str) -> Any:
|
|
83
|
+
"""
|
|
84
|
+
Resolve a dotted path against an argument object.
|
|
85
|
+
|
|
86
|
+
Numeric segments index into lists (``recipients.0``). A path that runs off
|
|
87
|
+
the end of the object -- or through a None -- yields MISSING rather than
|
|
88
|
+
raising, so a malformed model argument becomes a policy violation, not a
|
|
89
|
+
crash.
|
|
90
|
+
"""
|
|
91
|
+
cursor: Any = args
|
|
92
|
+
|
|
93
|
+
for segment in path.split("."):
|
|
94
|
+
if cursor is None:
|
|
95
|
+
return MISSING
|
|
96
|
+
|
|
97
|
+
if isinstance(cursor, (list, tuple)):
|
|
98
|
+
index = _array_index(segment)
|
|
99
|
+
if index is None or index >= len(cursor):
|
|
100
|
+
return MISSING
|
|
101
|
+
cursor = cursor[index]
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if not isinstance(cursor, dict):
|
|
105
|
+
return MISSING
|
|
106
|
+
if segment not in cursor:
|
|
107
|
+
return MISSING
|
|
108
|
+
cursor = cursor[segment]
|
|
109
|
+
|
|
110
|
+
return cursor
|