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/__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."""
|
toolwrit/audit/chain.py
ADDED
|
@@ -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
|
+
]
|