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,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
+ )
@@ -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