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 ADDED
@@ -0,0 +1,95 @@
1
+ """
2
+ Toolwrit — a written authority for AI agents.
3
+
4
+ Allowlist the tools, cap the budget, prove what happened.
5
+
6
+ A Python port of the TypeScript reference implementation. The audit chains the
7
+ two produce are byte-compatible: a log written here verifies under
8
+ ``toolwrit verify`` from the npm package, and vice versa.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.2.0"
14
+
15
+ from .audit.chain import (
16
+ GENESIS,
17
+ AuditEntry,
18
+ AuditLog,
19
+ AuditLogOptions,
20
+ canonicalize,
21
+ entry_from_dict,
22
+ entry_to_dict,
23
+ hash_entry,
24
+ )
25
+ from .audit.verify import VerifyFailure, VerifyResult, verify_chain, verify_file
26
+ from .budget.ledger import Ledger, TokenPrice
27
+ from .toolwrit import ApprovalHandler, Toolwrit, ToolwritDenied
28
+ from .policy.engine import evaluate
29
+ from .policy.load import PolicyError, load_policy_file, parse_policy, validate_policy
30
+ from .policy.match import MISSING, matches_any_glob, matches_glob, resolve_path
31
+ from .types import (
32
+ ArgConstraint,
33
+ BudgetLimits,
34
+ BudgetUsage,
35
+ BudgetWarning,
36
+ Decision,
37
+ Effect,
38
+ EvalContext,
39
+ HistoryEntry,
40
+ Policy,
41
+ PolicyRule,
42
+ RateLimit,
43
+ RunPlan,
44
+ ToolCall,
45
+ Violation,
46
+ )
47
+
48
+ __all__ = [
49
+ "__version__",
50
+ # runtime
51
+ "Toolwrit",
52
+ "ToolwritDenied",
53
+ "ApprovalHandler",
54
+ # policy
55
+ "evaluate",
56
+ "load_policy_file",
57
+ "parse_policy",
58
+ "validate_policy",
59
+ "PolicyError",
60
+ "matches_glob",
61
+ "matches_any_glob",
62
+ "resolve_path",
63
+ "MISSING",
64
+ # budget
65
+ "Ledger",
66
+ "TokenPrice",
67
+ # audit
68
+ "AuditLog",
69
+ "AuditLogOptions",
70
+ "AuditEntry",
71
+ "canonicalize",
72
+ "hash_entry",
73
+ "entry_to_dict",
74
+ "entry_from_dict",
75
+ "GENESIS",
76
+ "verify_chain",
77
+ "verify_file",
78
+ "VerifyResult",
79
+ "VerifyFailure",
80
+ # types
81
+ "ArgConstraint",
82
+ "BudgetLimits",
83
+ "BudgetUsage",
84
+ "BudgetWarning",
85
+ "Decision",
86
+ "Effect",
87
+ "EvalContext",
88
+ "HistoryEntry",
89
+ "Policy",
90
+ "PolicyRule",
91
+ "RateLimit",
92
+ "RunPlan",
93
+ "ToolCall",
94
+ "Violation",
95
+ ]
toolwrit/_js.py ADDED
@@ -0,0 +1,262 @@
1
+ """
2
+ JavaScript-compatible primitives.
3
+
4
+ This module exists for exactly one reason: a Toolwrit audit chain written by the
5
+ Python port must verify under the TypeScript ``toolwrit verify``, and vice versa.
6
+ The chain hashes a *canonical string*, so every byte of that string has to be
7
+ produced the way JavaScript would produce it -- which is not the way Python
8
+ does it by default.
9
+
10
+ Three places where the two languages disagree, and which are therefore
11
+ implemented here from the ECMAScript spec rather than borrowed from ``json``:
12
+
13
+ * Numbers. JavaScript has one numeric type (a double) and prints it with
14
+ Number::toString: ``1.0`` is ``"1"``, ``1e21`` is ``"1e+21"``, ``1e16`` is
15
+ ``"10000000000000000"``. Python's ``repr`` switches to exponent notation at a
16
+ different magnitude and always keeps the ``.0``.
17
+ * Strings. ``JSON.stringify`` escapes lone surrogates as ``\\udXXX``; Python's
18
+ ``json`` emits them raw when ``ensure_ascii=False``.
19
+ * Key order. ``canonicalize`` sorts by UTF-16 code unit, which differs from
20
+ Python's code-point order for anything outside the BMP.
21
+
22
+ Everything here is pure and total: no exceptions escape for values the
23
+ serialiser is asked to handle.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import math
30
+ from decimal import ROUND_HALF_UP, Decimal
31
+ from typing import Any, Final
32
+
33
+
34
+ class _Undefined:
35
+ """Stand-in for JavaScript ``undefined``.
36
+
37
+ Python's ``None`` maps onto JSON ``null``, which JavaScript *keeps*; there
38
+ is no built-in value that maps onto the ``undefined`` that JSON.stringify
39
+ *drops*. Serialising an entry that contains a null-valued key as if the key
40
+ were absent would break chain compatibility outright -- ``decision.rule`` is
41
+ legitimately null on every default-deny -- so the two concepts are kept
42
+ distinct and this sentinel carries the "drop me" meaning.
43
+ """
44
+
45
+ _instance: "_Undefined | None" = None
46
+
47
+ def __new__(cls) -> "_Undefined":
48
+ if cls._instance is None:
49
+ cls._instance = super().__new__(cls)
50
+ return cls._instance
51
+
52
+ def __repr__(self) -> str:
53
+ return "UNDEFINED"
54
+
55
+ def __bool__(self) -> bool:
56
+ return False
57
+
58
+
59
+ UNDEFINED: Final[_Undefined] = _Undefined()
60
+
61
+
62
+ # --------------------------------------------------------------------------- #
63
+ # Numbers
64
+ # --------------------------------------------------------------------------- #
65
+
66
+ def js_number(value: float | int) -> str:
67
+ """Format a number the way JavaScript's ``String(number)`` does.
68
+
69
+ ECMAScript Number::toString, section 6.1.6.1.20. The shortest round-tripping
70
+ decimal digits come from Python's own ``repr`` (both languages use the same
71
+ shortest-representation algorithm); only the *layout* of those digits around
72
+ the decimal point differs, and that is what the branches below fix up.
73
+ """
74
+ if isinstance(value, bool): # bool is an int subclass in Python; not in JS.
75
+ return "true" if value else "false"
76
+
77
+ x = _as_double(value)
78
+ if math.isnan(x):
79
+ return "NaN"
80
+ if math.isinf(x):
81
+ return "Infinity" if x > 0 else "-Infinity"
82
+ if x == 0:
83
+ return "0" # JavaScript prints -0 as "0" too.
84
+ if x < 0:
85
+ return "-" + js_number(-x)
86
+
87
+ digits, n = _shortest_digits(x)
88
+ k = len(digits)
89
+
90
+ if k <= n <= 21:
91
+ return digits + "0" * (n - k)
92
+ if 0 < n <= 21:
93
+ return digits[:n] + "." + digits[n:]
94
+ if -6 < n <= 0:
95
+ return "0." + "0" * (-n) + digits
96
+ exponent = n - 1
97
+ sign = "+" if exponent >= 0 else "-"
98
+ mantissa = digits if k == 1 else digits[0] + "." + digits[1:]
99
+ return f"{mantissa}e{sign}{abs(exponent)}"
100
+
101
+
102
+ def js_to_fixed(value: float | int, fraction_digits: int) -> str:
103
+ """``Number.prototype.toFixed``.
104
+
105
+ Rounds the *exact* binary value half-away-from-zero, which is why
106
+ ``(1.005).toFixed(2)`` is ``"1.00"`` in both languages: 1.005 as a double is
107
+ fractionally below 1.005. Python's ``format`` rounds half-to-even and would
108
+ disagree on genuine ties, so the arithmetic is done in ``Decimal``.
109
+ """
110
+ x = _as_double(value)
111
+ if math.isnan(x):
112
+ return "NaN"
113
+ if math.isinf(x) or abs(x) >= 1e21:
114
+ return js_number(x)
115
+
116
+ sign = "-" if x < 0 else ""
117
+ quantum = Decimal(1).scaleb(-fraction_digits)
118
+ rounded = Decimal(abs(x)).quantize(quantum, rounding=ROUND_HALF_UP)
119
+ return f"{sign}{rounded:f}"
120
+
121
+
122
+ def js_round(value: float) -> int:
123
+ """``Math.round``: ties go towards positive infinity, not towards even."""
124
+ return math.floor(value + 0.5)
125
+
126
+
127
+ def js_locale_integer(value: float | int) -> str:
128
+ """``(n).toLocaleString('en-US')`` for the integral case: 12500 -> "12,500"."""
129
+ return f"{int(value):,}"
130
+
131
+
132
+ def _as_double(value: float | int) -> float:
133
+ """Every JavaScript number is a double; a Python int that will not fit is
134
+ the same overflow JSON.parse would produce, so it becomes an infinity."""
135
+ try:
136
+ return float(value)
137
+ except OverflowError:
138
+ return math.inf if value > 0 else -math.inf
139
+
140
+
141
+ def _shortest_digits(x: float) -> tuple[str, int]:
142
+ """Split a positive finite double into (significant digits, exponent) where
143
+ ``x == 0.digits * 10**n`` -- the (s, k, n) triple the spec is written in."""
144
+ text = repr(x)
145
+ if "e" in text or "E" in text:
146
+ mantissa, _, exponent = text.partition("e")
147
+ power = int(exponent)
148
+ else:
149
+ mantissa, power = text, 0
150
+
151
+ integer, _, fraction = mantissa.partition(".")
152
+ significant = (integer + fraction).lstrip("0")
153
+ # value == significant * 10**(power - len(fraction)), so the spec's n --
154
+ # the position of the decimal point relative to the digit string -- is the
155
+ # digit count plus that scale.
156
+ n = len(significant) + power - len(fraction)
157
+ return significant.rstrip("0"), n
158
+
159
+
160
+ # --------------------------------------------------------------------------- #
161
+ # Strings
162
+ # --------------------------------------------------------------------------- #
163
+
164
+ _ESCAPES: Final[dict[int, str]] = {
165
+ 0x08: "\\b",
166
+ 0x09: "\\t",
167
+ 0x0A: "\\n",
168
+ 0x0C: "\\f",
169
+ 0x0D: "\\r",
170
+ 0x22: '\\"',
171
+ 0x5C: "\\\\",
172
+ }
173
+
174
+
175
+ def js_string(value: str) -> str:
176
+ """Quote a string exactly as ``JSON.stringify`` does.
177
+
178
+ Non-ASCII characters stay literal; only C0 controls, the quote, the
179
+ backslash and unpaired surrogates are escaped.
180
+ """
181
+ out = ['"']
182
+ for char in value:
183
+ code = ord(char)
184
+ escape = _ESCAPES.get(code)
185
+ if escape is not None:
186
+ out.append(escape)
187
+ elif code < 0x20 or 0xD800 <= code <= 0xDFFF:
188
+ # A paired surrogate cannot occur here: Python strings hold code
189
+ # points, so anything in this range is lone and must be escaped to
190
+ # stay well-formed, which is what JSON.stringify does.
191
+ out.append(f"\\u{code:04x}")
192
+ else:
193
+ out.append(char)
194
+ out.append('"')
195
+ return "".join(out)
196
+
197
+
198
+ def _utf16_key(text: str) -> bytes:
199
+ """Sort key reproducing JavaScript's ``<`` on strings (UTF-16 code units)."""
200
+ return text.encode("utf-16-be", errors="surrogatepass")
201
+
202
+
203
+ # --------------------------------------------------------------------------- #
204
+ # Structures
205
+ # --------------------------------------------------------------------------- #
206
+
207
+ def to_jsonable(value: Any) -> Any:
208
+ """Convert dataclasses to plain dicts, honouring per-class omission rules.
209
+
210
+ A dataclass field listed in the class's ``JSON_OMIT_IF_NONE`` is dropped
211
+ when it is ``None``, mirroring the TypeScript objects that simply never set
212
+ an optional key. Fields not listed keep their ``None`` and serialise as
213
+ ``null`` -- ``Decision.rule`` being the case that matters.
214
+ """
215
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
216
+ omit = getattr(type(value), "JSON_OMIT_IF_NONE", frozenset())
217
+ out: dict[str, Any] = {}
218
+ for field in dataclasses.fields(value):
219
+ item = getattr(value, field.name)
220
+ if item is UNDEFINED or (item is None and field.name in omit):
221
+ continue
222
+ out[field.name] = to_jsonable(item)
223
+ return out
224
+ if isinstance(value, dict):
225
+ return {k: to_jsonable(v) for k, v in value.items() if v is not UNDEFINED}
226
+ if isinstance(value, (list, tuple)):
227
+ return [to_jsonable(v) for v in value]
228
+ return value
229
+
230
+
231
+ def js_json(value: Any, *, sort_keys: bool = False) -> str:
232
+ """``JSON.stringify(value)`` with no indentation.
233
+
234
+ With ``sort_keys`` this is the canonical form the hash chain commits to;
235
+ without it, it is the plain stringify used inside human-facing messages,
236
+ which must not reorder what the policy author wrote.
237
+ """
238
+ value = to_jsonable(value)
239
+
240
+ if value is None:
241
+ return "null"
242
+ if isinstance(value, bool):
243
+ return "true" if value else "false"
244
+ if isinstance(value, str):
245
+ return js_string(value)
246
+ if isinstance(value, (int, float)):
247
+ text = js_number(value)
248
+ # JSON has no NaN or Infinity; JSON.stringify emits null for both.
249
+ return "null" if text in ("NaN", "Infinity", "-Infinity") else text
250
+ if isinstance(value, (list, tuple)):
251
+ return "[" + ",".join(js_json(item, sort_keys=sort_keys) for item in value) + "]"
252
+ if isinstance(value, dict):
253
+ items = [(k, v) for k, v in value.items() if v is not UNDEFINED]
254
+ if sort_keys:
255
+ items.sort(key=lambda pair: _utf16_key(pair[0]))
256
+ body = ",".join(
257
+ f"{js_string(k)}:{js_json(v, sort_keys=sort_keys)}" for k, v in items
258
+ )
259
+ return "{" + body + "}"
260
+ if value is UNDEFINED:
261
+ return "null"
262
+ raise TypeError(f"cannot serialise {type(value).__name__} as JSON")
@@ -0,0 +1 @@
1
+ """The tamper-evident audit chain and its verifier."""
@@ -0,0 +1,251 @@
1
+ """
2
+ Tamper-evident audit log.
3
+
4
+ Entries form a hash chain: each one commits to the hash of its predecessor,
5
+ so editing or deleting any past entry invalidates every hash after it. This
6
+ is what turns "we had a policy" into "here is what the policy actually did",
7
+ which is the only version an auditor accepts.
8
+
9
+ The format is JSONL -- one entry per line -- so it survives a crash mid-run
10
+ and can be tailed, grepped and shipped to any log pipeline unchanged. A file
11
+ written here verifies under the TypeScript ``toolwrit verify`` and the other way
12
+ round, which is why ``canonicalize`` goes through the JavaScript-compatible
13
+ serialiser in ``toolwrit._js`` rather than through Python's ``json``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import copy
19
+ import hashlib
20
+ import os
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Final, Sequence
23
+
24
+ from .._js import js_json, to_jsonable
25
+ from ..types import Decision, HistoryEntry, ToolCall, Violation
26
+
27
+ #: Hash recorded as the predecessor of the first entry.
28
+ GENESIS: Final[str] = "0" * 64
29
+
30
+
31
+ @dataclass
32
+ class AuditEntry:
33
+ #: 1-based position in the chain.
34
+ seq: int
35
+ #: Milliseconds since epoch, taken from the tool call.
36
+ at: int
37
+ #: Run identifier, so several runs can share one log file.
38
+ run: str
39
+ tool: str
40
+ #: Arguments as evaluated. Redacted fields are replaced before hashing.
41
+ args: dict[str, Any]
42
+ decision: Decision
43
+ #: Budget consumption at the moment of the decision.
44
+ usage: dict[str, float]
45
+ #: Hash of the preceding entry, or GENESIS.
46
+ prev: str
47
+ #: sha256 over the canonical form of this entry, excluding ``hash`` itself.
48
+ hash: str = ""
49
+
50
+ def body(self) -> dict[str, Any]:
51
+ """The entry without its own hash -- what the hash is taken over."""
52
+ return {
53
+ "seq": self.seq,
54
+ "at": self.at,
55
+ "run": self.run,
56
+ "tool": self.tool,
57
+ "args": self.args,
58
+ "decision": self.decision,
59
+ "usage": self.usage,
60
+ "prev": self.prev,
61
+ }
62
+
63
+
64
+ def canonicalize(value: Any) -> str:
65
+ """
66
+ Deterministic JSON: object keys sorted, no incidental whitespace.
67
+
68
+ Without this the chain would depend on the runtime's key ordering, and a log
69
+ written by one process could fail verification in another. UNDEFINED values
70
+ are dropped, matching what JSON.stringify does with ``undefined``; ``None``
71
+ is JSON ``null`` and is kept, because ``decision.rule`` is legitimately null
72
+ on every default-deny and the TypeScript chain commits to it.
73
+ """
74
+ return js_json(value, sort_keys=True)
75
+
76
+
77
+ def hash_entry(body: Any) -> str:
78
+ return hashlib.sha256(canonicalize(body).encode("utf-8")).hexdigest()
79
+
80
+
81
+ def entry_to_dict(entry: AuditEntry) -> dict[str, Any]:
82
+ """The wire form of an entry: plain JSON-able data, hash included."""
83
+ return {**to_jsonable(entry.body()), "hash": entry.hash}
84
+
85
+
86
+ def entry_from_dict(raw: dict[str, Any]) -> AuditEntry:
87
+ """Rebuild an entry from a parsed JSONL line, however malformed."""
88
+ decision_raw = raw.get("decision")
89
+ decision: Any
90
+ if isinstance(decision_raw, dict):
91
+ decision = Decision(
92
+ effect=decision_raw.get("effect"),
93
+ rule=decision_raw.get("rule"),
94
+ reason=decision_raw.get("reason"),
95
+ violations=[
96
+ Violation(
97
+ rule=v.get("rule"),
98
+ constraint=v.get("constraint"),
99
+ message=v.get("message"),
100
+ path=v.get("path"),
101
+ )
102
+ if isinstance(v, dict)
103
+ else v
104
+ for v in (decision_raw.get("violations") or [])
105
+ ],
106
+ )
107
+ # A violation without a path must stay without one, or the recomputed
108
+ # hash would gain a "path":null the original never had.
109
+ for parsed, original in zip(decision.violations, decision_raw.get("violations") or []):
110
+ if isinstance(original, dict) and "path" not in original:
111
+ parsed.path = None
112
+ else:
113
+ decision = decision_raw
114
+
115
+ return AuditEntry(
116
+ seq=raw.get("seq"),
117
+ at=raw.get("at"),
118
+ run=raw.get("run"),
119
+ tool=raw.get("tool"),
120
+ args=raw.get("args"),
121
+ decision=decision,
122
+ usage=raw.get("usage"),
123
+ prev=raw.get("prev"),
124
+ hash=raw.get("hash"),
125
+ )
126
+
127
+
128
+ @dataclass
129
+ class AuditLogOptions:
130
+ #: Run identifier stamped on every entry.
131
+ run: str
132
+ #: When set, entries are appended to this JSONL file as well as buffered.
133
+ file: str | None = None
134
+ #: Argument paths whose values are replaced with "[redacted]" before hashing.
135
+ #: Secrets must never reach the log, but the chain still has to cover the
136
+ #: redacted shape so a redaction cannot be forged after the fact.
137
+ redact: list[str] = field(default_factory=list)
138
+
139
+
140
+ class AuditLog:
141
+ def __init__(
142
+ self,
143
+ run: str,
144
+ file: str | None = None,
145
+ redact: Sequence[str] | None = None,
146
+ ) -> None:
147
+ self._options = AuditLogOptions(run=run, file=file, redact=list(redact or []))
148
+ self._entries: list[AuditEntry] = []
149
+ self._last = GENESIS
150
+ if file:
151
+ parent = os.path.dirname(file)
152
+ if parent:
153
+ os.makedirs(parent, exist_ok=True)
154
+
155
+ def record(
156
+ self,
157
+ call: ToolCall,
158
+ decision: Decision,
159
+ usage: dict[str, float],
160
+ ) -> AuditEntry:
161
+ """Append a decision to the chain and return the entry that was written."""
162
+ entry = AuditEntry(
163
+ seq=len(self._entries) + 1,
164
+ at=call.at,
165
+ run=self._options.run,
166
+ tool=call.tool,
167
+ args=_redact_args(call.args, self._options.redact),
168
+ decision=decision,
169
+ usage=usage,
170
+ prev=self._last,
171
+ )
172
+ entry.hash = hash_entry(entry.body())
173
+ self._entries.append(entry)
174
+ self._last = entry.hash
175
+
176
+ if self._options.file:
177
+ line = js_json(entry_to_dict(entry))
178
+ with open(self._options.file, "a", encoding="utf-8") as handle:
179
+ handle.write(line + "\n")
180
+ return entry
181
+
182
+ def all(self) -> list[AuditEntry]:
183
+ """Entries recorded by this instance, oldest first."""
184
+ return list(self._entries)
185
+
186
+ def head(self) -> str:
187
+ """Hash of the newest entry -- the value a receipt should quote."""
188
+ return self._last
189
+
190
+ def history(self) -> list[HistoryEntry]:
191
+ """History in the shape the policy engine wants for rate limiting."""
192
+ return [
193
+ HistoryEntry(tool=e.tool, rule=e.decision.rule, at=e.at)
194
+ for e in self._entries
195
+ if e.decision.effect != "deny"
196
+ ]
197
+
198
+
199
+ def _redact_args(args: dict[str, Any], paths: Sequence[str]) -> dict[str, Any]:
200
+ # Always clone, even with nothing to redact. Holding the caller's object by
201
+ # reference would let a later mutation of it change what this entry hashes
202
+ # to, silently invalidating an otherwise honest chain. The clone also keeps
203
+ # the caller's object untouched: the agent may still need the real value.
204
+ clone = copy.deepcopy(args)
205
+ if not paths:
206
+ return clone
207
+
208
+ for path in paths:
209
+ segments = path.split(".")
210
+ leaf = segments.pop()
211
+ if not leaf:
212
+ continue
213
+
214
+ cursor: Any = clone
215
+ for segment in segments:
216
+ cursor = _step(cursor, segment)
217
+ if cursor is None:
218
+ break
219
+
220
+ if isinstance(cursor, dict) and leaf in cursor:
221
+ cursor[leaf] = "[redacted]"
222
+ elif isinstance(cursor, list):
223
+ index = _index(leaf)
224
+ if index is not None and index < len(cursor):
225
+ cursor[index] = "[redacted]"
226
+ return clone
227
+
228
+
229
+ def _step(cursor: Any, segment: str) -> Any:
230
+ if isinstance(cursor, dict):
231
+ return cursor.get(segment)
232
+ if isinstance(cursor, list):
233
+ index = _index(segment)
234
+ return cursor[index] if index is not None and index < len(cursor) else None
235
+ return None
236
+
237
+
238
+ def _index(segment: str) -> int | None:
239
+ return int(segment) if segment.isascii() and segment.isdigit() else None
240
+
241
+
242
+ __all__ = [
243
+ "GENESIS",
244
+ "AuditEntry",
245
+ "AuditLog",
246
+ "AuditLogOptions",
247
+ "canonicalize",
248
+ "entry_from_dict",
249
+ "entry_to_dict",
250
+ "hash_entry",
251
+ ]