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/toolwrit.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The runtime object an application holds.
|
|
3
|
+
|
|
4
|
+
Toolwrit ties together the three pieces that have to agree with each other:
|
|
5
|
+
the policy engine (may this happen), the ledger (is there capacity left) and
|
|
6
|
+
the audit chain (what actually happened). Keeping them behind one object is
|
|
7
|
+
what stops an integration from recording a decision it did not enforce.
|
|
8
|
+
|
|
9
|
+
There are two guard methods rather than one. Python cannot await from a
|
|
10
|
+
synchronous frame, so ``guard`` serves callers whose tool and approval handler
|
|
11
|
+
are ordinary functions, and ``guard_async`` serves everyone else -- it awaits
|
|
12
|
+
whatever it is given and accepts plain functions unchanged. Both run the same
|
|
13
|
+
evaluate-record-enforce sequence; only the calling convention differs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import inspect
|
|
20
|
+
import uuid
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from typing import Any, Awaitable, Callable, Sequence, TypeVar
|
|
23
|
+
|
|
24
|
+
from .audit.chain import AuditEntry, AuditLog, canonicalize
|
|
25
|
+
from .budget.ledger import Ledger, TokenPrice, now_ms
|
|
26
|
+
from ._js import js_locale_integer, js_number, js_round, js_to_fixed, to_jsonable
|
|
27
|
+
from .policy.engine import evaluate
|
|
28
|
+
from .types import (
|
|
29
|
+
BudgetUsage,
|
|
30
|
+
BudgetWarning,
|
|
31
|
+
Decision,
|
|
32
|
+
EvalContext,
|
|
33
|
+
Policy,
|
|
34
|
+
ToolCall,
|
|
35
|
+
Violation,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T")
|
|
39
|
+
|
|
40
|
+
#: Called when a rule's effect is "ask". Return True to permit the call.
|
|
41
|
+
#: Without a handler, "ask" is treated as a denial: an unattended run must not
|
|
42
|
+
#: silently upgrade itself to allowed.
|
|
43
|
+
ApprovalHandler = Callable[[ToolCall, Decision], "bool | Awaitable[bool]"]
|
|
44
|
+
|
|
45
|
+
#: Warn thresholds used when a plan does not name its own.
|
|
46
|
+
DEFAULT_WARN_AT = [0.8, 0.95]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ToolwritDenied(Exception):
|
|
50
|
+
"""Thrown when a guarded call is refused. Catch this to feed the agent an error."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, tool: str, decision: Decision, audit_hash: str) -> None:
|
|
53
|
+
super().__init__(f"toolwrit: {tool} denied — {decision.reason}")
|
|
54
|
+
self.tool = tool
|
|
55
|
+
self.decision = decision
|
|
56
|
+
#: Hash of the audit entry recording the refusal. Quote it in support tickets.
|
|
57
|
+
self.audit_hash = audit_hash
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def size_of(result: Any) -> int:
|
|
61
|
+
"""
|
|
62
|
+
Size of a tool result, in bytes.
|
|
63
|
+
|
|
64
|
+
Deterministic by construction: the same result always measures the same, so
|
|
65
|
+
a replayed audit log reaches the same verdict. Structured results are
|
|
66
|
+
measured by their canonical JSON length, which equals what the TypeScript
|
|
67
|
+
implementation gets from JSON.stringify -- sorting keys reorders bytes but
|
|
68
|
+
does not add or remove any. A result that cannot be serialised falls back to
|
|
69
|
+
its string form and is therefore UNDER-counted: documented rather than
|
|
70
|
+
hidden, because under-counting a volume ceiling fails open.
|
|
71
|
+
"""
|
|
72
|
+
if result is None:
|
|
73
|
+
return 0
|
|
74
|
+
if isinstance(result, str):
|
|
75
|
+
return len(result.encode("utf-8"))
|
|
76
|
+
if isinstance(result, (bytes, bytearray, memoryview)):
|
|
77
|
+
return len(bytes(result))
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return len(canonicalize(result).encode("utf-8"))
|
|
81
|
+
except Exception:
|
|
82
|
+
return len(str(result).encode("utf-8"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _format(value: float) -> str:
|
|
86
|
+
"""Readable numbers in warning text: 12500 -> "12,500", 1.5 -> "1.50"."""
|
|
87
|
+
return js_locale_integer(value) if float(value).is_integer() else js_to_fixed(value, 2)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Toolwrit:
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
policy: Policy,
|
|
94
|
+
*,
|
|
95
|
+
run: str | None = None,
|
|
96
|
+
audit_file: str | None = None,
|
|
97
|
+
redact: Sequence[str] | None = None,
|
|
98
|
+
on_ask: ApprovalHandler | None = None,
|
|
99
|
+
on_warn: Callable[[BudgetWarning], None] | None = None,
|
|
100
|
+
now: Callable[[], int] | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self._policy = policy
|
|
103
|
+
self._on_ask = on_ask
|
|
104
|
+
self._on_warn = on_warn
|
|
105
|
+
self.run = run or str(uuid.uuid4())
|
|
106
|
+
self._now = now or now_ms
|
|
107
|
+
self._ledger = Ledger()
|
|
108
|
+
self._audit = AuditLog(run=self.run, file=audit_file, redact=redact)
|
|
109
|
+
#: "dimension:threshold" keys already reported, so each fires exactly once.
|
|
110
|
+
self._warned: set[str] = set()
|
|
111
|
+
self._lock: asyncio.Lock | None = None
|
|
112
|
+
self._lock_loop: object | None = None
|
|
113
|
+
|
|
114
|
+
# The approved envelope goes into the chain before anything can consume it.
|
|
115
|
+
# Recording it as an ordinary entry keeps the chain uniform -- one shape,
|
|
116
|
+
# one verifier -- and means an operator cannot later claim a different
|
|
117
|
+
# budget was authorised than the one the run actually started under.
|
|
118
|
+
if policy.plan is not None:
|
|
119
|
+
plan = policy.plan
|
|
120
|
+
self._audit.record(
|
|
121
|
+
self._to_call(
|
|
122
|
+
"toolwrit:plan",
|
|
123
|
+
{
|
|
124
|
+
"purpose": plan.purpose,
|
|
125
|
+
"approvedBy": plan.approvedBy,
|
|
126
|
+
"budget": to_jsonable(policy.budget)
|
|
127
|
+
if policy.budget is not None
|
|
128
|
+
else None,
|
|
129
|
+
"warnAt": plan.warnAt
|
|
130
|
+
if plan.warnAt is not None
|
|
131
|
+
else list(DEFAULT_WARN_AT),
|
|
132
|
+
},
|
|
133
|
+
),
|
|
134
|
+
Decision(
|
|
135
|
+
effect="allow",
|
|
136
|
+
rule="plan",
|
|
137
|
+
reason="run plan recorded",
|
|
138
|
+
violations=[],
|
|
139
|
+
),
|
|
140
|
+
{"calls": 0, "tokens": 0, "usd": 0, "bytes": 0},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def check(self, tool: str, args: dict[str, Any] | None = None) -> Decision:
|
|
144
|
+
"""
|
|
145
|
+
Evaluate a call without recording or executing anything.
|
|
146
|
+
Use this for dry-runs and for showing an operator what a policy would do.
|
|
147
|
+
"""
|
|
148
|
+
return evaluate(self._to_call(tool, args or {}), self._context())
|
|
149
|
+
|
|
150
|
+
def guard(
|
|
151
|
+
self,
|
|
152
|
+
tool: str,
|
|
153
|
+
args: dict[str, Any],
|
|
154
|
+
execute: Callable[[], T],
|
|
155
|
+
) -> T:
|
|
156
|
+
"""
|
|
157
|
+
Evaluate, record, and -- if permitted -- run ``execute``.
|
|
158
|
+
|
|
159
|
+
The decision is written to the audit chain before ``execute`` is invoked,
|
|
160
|
+
so a crash inside the tool still leaves evidence that the call was
|
|
161
|
+
authorised. A refusal raises ToolwritDenied rather than returning a
|
|
162
|
+
sentinel, because a silently-skipped side effect is the worst possible
|
|
163
|
+
failure mode here.
|
|
164
|
+
"""
|
|
165
|
+
call = self._to_call(tool, args)
|
|
166
|
+
decision = evaluate(call, self._context())
|
|
167
|
+
|
|
168
|
+
if decision.effect == "ask":
|
|
169
|
+
approved = self._ask(call, decision)
|
|
170
|
+
if inspect.isawaitable(approved):
|
|
171
|
+
# Close it rather than leaking a never-awaited coroutine: the
|
|
172
|
+
# handler has not run any of its body yet, so nothing is lost.
|
|
173
|
+
getattr(approved, "close", lambda: None)()
|
|
174
|
+
raise TypeError(
|
|
175
|
+
"the configured on_ask handler is asynchronous; use guard_async"
|
|
176
|
+
)
|
|
177
|
+
decision = self._resolve_ask(decision, approved)
|
|
178
|
+
|
|
179
|
+
self._enforce(tool, call, decision)
|
|
180
|
+
return self._measure(call, execute())
|
|
181
|
+
|
|
182
|
+
async def guard_async(
|
|
183
|
+
self,
|
|
184
|
+
tool: str,
|
|
185
|
+
args: dict[str, Any],
|
|
186
|
+
execute: Callable[[], T | Awaitable[T]],
|
|
187
|
+
) -> T:
|
|
188
|
+
"""``guard`` for async callers: awaits the approval handler and the tool."""
|
|
189
|
+
call = self._to_call(tool, args)
|
|
190
|
+
|
|
191
|
+
# Deciding, recording and counting is one critical section. Awaiting an
|
|
192
|
+
# approval handler used to leave it interruptible: concurrent calls all
|
|
193
|
+
# evaluated against the same pre-approval snapshot, so three guards
|
|
194
|
+
# against a budget of one were all approved and all executed. The tool
|
|
195
|
+
# itself still runs outside the lock, and the lock is only ever
|
|
196
|
+
# contended while an approval is genuinely pending.
|
|
197
|
+
async with self._decision_lock():
|
|
198
|
+
decision = evaluate(call, self._context())
|
|
199
|
+
|
|
200
|
+
if decision.effect == "ask":
|
|
201
|
+
approved = self._ask(call, decision)
|
|
202
|
+
if inspect.isawaitable(approved):
|
|
203
|
+
approved = await approved
|
|
204
|
+
decision = self._resolve_ask(decision, approved)
|
|
205
|
+
|
|
206
|
+
self._enforce(tool, call, decision)
|
|
207
|
+
|
|
208
|
+
# A bytes ceiling is the one budget whose consumption is unknowable
|
|
209
|
+
# until after the tool has run, so concurrent calls would each be
|
|
210
|
+
# decided against a total none of them had contributed to yet. Where
|
|
211
|
+
# a ceiling is declared the tool runs inside the critical section
|
|
212
|
+
# too, bounding a run at the limit plus one call -- the bound the
|
|
213
|
+
# documentation promises. A policy with no bytes budget keeps full
|
|
214
|
+
# concurrency, so the cost falls only on the feature that needs it.
|
|
215
|
+
if self._policy.budget is not None and self._policy.budget.bytes is not None:
|
|
216
|
+
inside = execute()
|
|
217
|
+
if inspect.isawaitable(inside):
|
|
218
|
+
inside = await inside
|
|
219
|
+
return self._measure(call, inside) # type: ignore[arg-type]
|
|
220
|
+
|
|
221
|
+
result = execute()
|
|
222
|
+
if inspect.isawaitable(result):
|
|
223
|
+
result = await result
|
|
224
|
+
return self._measure(call, result) # type: ignore[arg-type]
|
|
225
|
+
|
|
226
|
+
def meter(
|
|
227
|
+
self,
|
|
228
|
+
input_tokens: float,
|
|
229
|
+
output_tokens: float,
|
|
230
|
+
price: TokenPrice | Mapping[str, float] | None = None,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""
|
|
233
|
+
Record model consumption against the budget. Call after every model turn.
|
|
234
|
+
|
|
235
|
+
``price`` may be a TokenPrice or a plain mapping such as
|
|
236
|
+
``{"input": 0.003, "output": 0.015}``.
|
|
237
|
+
"""
|
|
238
|
+
self._ledger.add_usage(input_tokens, output_tokens, price, self._now())
|
|
239
|
+
self._report_progress()
|
|
240
|
+
|
|
241
|
+
def spend(self, usd: float) -> None:
|
|
242
|
+
"""Record non-token spend, e.g. a metered third-party API call."""
|
|
243
|
+
self._ledger.add_spend(usd, self._now())
|
|
244
|
+
self._report_progress()
|
|
245
|
+
|
|
246
|
+
def usage(self) -> BudgetUsage:
|
|
247
|
+
"""Current consumption. Useful for progress bars and for tests."""
|
|
248
|
+
return self._ledger.snapshot()
|
|
249
|
+
|
|
250
|
+
def entries(self) -> list[AuditEntry]:
|
|
251
|
+
"""The audit chain recorded so far."""
|
|
252
|
+
return self._audit.all()
|
|
253
|
+
|
|
254
|
+
def head(self) -> str:
|
|
255
|
+
"""Hash of the newest audit entry -- the receipt for this run so far."""
|
|
256
|
+
return self._audit.head()
|
|
257
|
+
|
|
258
|
+
# -- internals ---------------------------------------------------------- #
|
|
259
|
+
|
|
260
|
+
def _context(self) -> EvalContext:
|
|
261
|
+
return EvalContext(
|
|
262
|
+
policy=self._policy,
|
|
263
|
+
usage=self._ledger.snapshot(),
|
|
264
|
+
history=self._audit.history(),
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def _enforce(self, tool: str, call: ToolCall, decision: Decision) -> None:
|
|
268
|
+
"""Record the verdict, then act on it. Recording comes first, always."""
|
|
269
|
+
entry = self._audit.record(call, decision, self._usage_for_audit())
|
|
270
|
+
if decision.effect != "allow":
|
|
271
|
+
raise ToolwritDenied(tool, decision, entry.hash)
|
|
272
|
+
self._ledger.count_call(call.at)
|
|
273
|
+
|
|
274
|
+
def _decision_lock(self) -> asyncio.Lock:
|
|
275
|
+
"""
|
|
276
|
+
The async critical section's lock, created on the running loop.
|
|
277
|
+
|
|
278
|
+
Built lazily because a Toolwrit may be constructed outside any event loop,
|
|
279
|
+
and an asyncio.Lock bound to the wrong loop is worse than none. The
|
|
280
|
+
synchronous ``guard`` does not take it: it has no await point, so it
|
|
281
|
+
cannot interleave with itself on one thread. Mixing ``guard`` and
|
|
282
|
+
``guard_async`` on one instance is therefore not protected -- pick one.
|
|
283
|
+
"""
|
|
284
|
+
loop = asyncio.get_running_loop()
|
|
285
|
+
if self._lock is None or self._lock_loop is not loop:
|
|
286
|
+
self._lock = asyncio.Lock()
|
|
287
|
+
self._lock_loop = loop
|
|
288
|
+
return self._lock
|
|
289
|
+
|
|
290
|
+
def _measure(self, call: ToolCall, result: T) -> T:
|
|
291
|
+
"""
|
|
292
|
+
The volume half of containment: record how much the tool returned.
|
|
293
|
+
|
|
294
|
+
Attributed to the call's own timestamp rather than a fresh clock read,
|
|
295
|
+
so the ledger does not depend on how long the tool happened to take.
|
|
296
|
+
"""
|
|
297
|
+
self._ledger.add_bytes(size_of(result), call.at)
|
|
298
|
+
self._report_progress()
|
|
299
|
+
return result
|
|
300
|
+
|
|
301
|
+
def _ask(self, call: ToolCall, decision: Decision) -> Any:
|
|
302
|
+
return None if self._on_ask is None else self._on_ask(call, decision)
|
|
303
|
+
|
|
304
|
+
def _resolve_ask(self, decision: Decision, approved: Any) -> Decision:
|
|
305
|
+
if self._on_ask is None:
|
|
306
|
+
return Decision(
|
|
307
|
+
effect="deny",
|
|
308
|
+
rule=decision.rule,
|
|
309
|
+
reason=f"{decision.reason} (no approval handler configured)",
|
|
310
|
+
violations=[
|
|
311
|
+
Violation(
|
|
312
|
+
rule=decision.rule if decision.rule is not None else "policy",
|
|
313
|
+
constraint="ask",
|
|
314
|
+
message=(
|
|
315
|
+
"this call requires approval but no onAsk handler was "
|
|
316
|
+
"configured; denying by default"
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
if approved:
|
|
323
|
+
return Decision(
|
|
324
|
+
effect="allow",
|
|
325
|
+
rule=decision.rule,
|
|
326
|
+
reason=f"{decision.reason} (approved)",
|
|
327
|
+
violations=list(decision.violations),
|
|
328
|
+
)
|
|
329
|
+
return Decision(
|
|
330
|
+
effect="deny",
|
|
331
|
+
rule=decision.rule,
|
|
332
|
+
reason=f"{decision.reason} (approval refused)",
|
|
333
|
+
violations=[
|
|
334
|
+
Violation(
|
|
335
|
+
rule=decision.rule if decision.rule is not None else "policy",
|
|
336
|
+
constraint="ask",
|
|
337
|
+
message="approval was refused by the configured handler",
|
|
338
|
+
)
|
|
339
|
+
],
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def _report_progress(self) -> None:
|
|
343
|
+
"""
|
|
344
|
+
Fire any warn thresholds the latest consumption has crossed.
|
|
345
|
+
|
|
346
|
+
Warnings are recorded in the audit chain as well as delivered to on_warn,
|
|
347
|
+
so "nobody told me it was at 95%" is answerable from the log rather than
|
|
348
|
+
from whether a Slack message happened to be delivered.
|
|
349
|
+
"""
|
|
350
|
+
plan, budget = self._policy.plan, self._policy.budget
|
|
351
|
+
if plan is None or budget is None:
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
thresholds = plan.warnAt if plan.warnAt is not None else DEFAULT_WARN_AT
|
|
355
|
+
usage = self._ledger.snapshot()
|
|
356
|
+
elapsed = (
|
|
357
|
+
0 if usage.started_at is None else (self._now() - usage.started_at) / 1000
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
dimensions: list[tuple[str, float, float | None]] = [
|
|
361
|
+
("calls", usage.calls, budget.calls),
|
|
362
|
+
("tokens", usage.tokens, budget.tokens),
|
|
363
|
+
("usd", usage.usd, budget.usd),
|
|
364
|
+
("bytes", usage.bytes, budget.bytes),
|
|
365
|
+
("seconds", elapsed, budget.seconds),
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
for dimension, used, limit in dimensions:
|
|
369
|
+
if limit is None or limit <= 0:
|
|
370
|
+
continue
|
|
371
|
+
fraction = used / limit
|
|
372
|
+
|
|
373
|
+
for threshold in thresholds:
|
|
374
|
+
key = f"{dimension}:{js_number(threshold)}"
|
|
375
|
+
if fraction < threshold or key in self._warned:
|
|
376
|
+
continue
|
|
377
|
+
self._warned.add(key)
|
|
378
|
+
|
|
379
|
+
warning = BudgetWarning(
|
|
380
|
+
dimension=dimension, # type: ignore[arg-type]
|
|
381
|
+
threshold=threshold,
|
|
382
|
+
used=used,
|
|
383
|
+
limit=limit,
|
|
384
|
+
message=(
|
|
385
|
+
f'run "{self.run}" ({plan.purpose}) has used '
|
|
386
|
+
f"{_format(used)}/{_format(limit)} {dimension} — "
|
|
387
|
+
f"{js_round(fraction * 100)}% of the approved envelope"
|
|
388
|
+
),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
self._audit.record(
|
|
392
|
+
self._to_call(
|
|
393
|
+
"toolwrit:warning",
|
|
394
|
+
{
|
|
395
|
+
"dimension": warning.dimension,
|
|
396
|
+
"threshold": warning.threshold,
|
|
397
|
+
"used": warning.used,
|
|
398
|
+
"limit": warning.limit,
|
|
399
|
+
"message": warning.message,
|
|
400
|
+
},
|
|
401
|
+
),
|
|
402
|
+
Decision(
|
|
403
|
+
effect="allow",
|
|
404
|
+
rule="plan",
|
|
405
|
+
reason=warning.message,
|
|
406
|
+
violations=[],
|
|
407
|
+
),
|
|
408
|
+
{
|
|
409
|
+
"calls": usage.calls,
|
|
410
|
+
"tokens": usage.tokens,
|
|
411
|
+
"usd": usage.usd,
|
|
412
|
+
"bytes": usage.bytes,
|
|
413
|
+
},
|
|
414
|
+
)
|
|
415
|
+
if self._on_warn is not None:
|
|
416
|
+
self._on_warn(warning)
|
|
417
|
+
|
|
418
|
+
def _to_call(self, tool: str, args: dict[str, Any]) -> ToolCall:
|
|
419
|
+
return ToolCall(id=str(uuid.uuid4()), tool=tool, args=args, at=self._now())
|
|
420
|
+
|
|
421
|
+
def _usage_for_audit(self) -> dict[str, float]:
|
|
422
|
+
usage = self._ledger.snapshot()
|
|
423
|
+
return {
|
|
424
|
+
"calls": usage.calls,
|
|
425
|
+
"tokens": usage.tokens,
|
|
426
|
+
"usd": usage.usd,
|
|
427
|
+
"bytes": usage.bytes,
|
|
428
|
+
}
|
toolwrit/types.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core contracts for Toolwrit.
|
|
3
|
+
|
|
4
|
+
Everything in this file is data. The policy engine is a pure function over
|
|
5
|
+
these shapes: no network, no model calls, no randomness. That is the whole
|
|
6
|
+
point -- an enforcement decision must be reproducible from the policy, the
|
|
7
|
+
call and the ledger state alone, so it can be replayed during an audit.
|
|
8
|
+
|
|
9
|
+
Each dataclass declares ``JSON_OMIT_IF_NONE``: the optional fields that are
|
|
10
|
+
simply *absent* from the JSON form rather than present-and-null. The
|
|
11
|
+
distinction is load-bearing, because the audit chain hashes that JSON and
|
|
12
|
+
``Decision.rule`` -- which is not in any omit set -- must serialise as
|
|
13
|
+
``"rule":null`` to match the TypeScript chain.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, ClassVar, Literal, Sequence
|
|
20
|
+
|
|
21
|
+
Effect = Literal["allow", "deny", "ask"]
|
|
22
|
+
ArgType = Literal["string", "number", "boolean", "object", "array"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ToolCall:
|
|
27
|
+
"""A single tool invocation an agent wants to make."""
|
|
28
|
+
|
|
29
|
+
#: Caller-supplied id, unique within a run. Used to correlate audit entries.
|
|
30
|
+
id: str
|
|
31
|
+
#: Tool name as the agent asked for it, e.g. "fs.write" or "github/create_issue".
|
|
32
|
+
tool: str
|
|
33
|
+
#: Arguments the agent proposed. Untrusted: this is model output.
|
|
34
|
+
args: dict[str, Any]
|
|
35
|
+
#: Milliseconds since epoch. Injected so evaluation stays deterministic in tests.
|
|
36
|
+
at: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class BudgetLimits:
|
|
41
|
+
"""What a run is allowed to consume before Toolwrit cuts it off."""
|
|
42
|
+
|
|
43
|
+
JSON_OMIT_IF_NONE: ClassVar[frozenset[str]] = frozenset(
|
|
44
|
+
{"calls", "tokens", "usd", "bytes", "seconds"}
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
#: Hard ceiling on total tool calls in the run.
|
|
48
|
+
calls: float | None = None
|
|
49
|
+
#: Hard ceiling on input+output tokens attributed to the run.
|
|
50
|
+
tokens: float | None = None
|
|
51
|
+
#: Hard ceiling on estimated spend, in USD.
|
|
52
|
+
usd: float | None = None
|
|
53
|
+
#: Hard ceiling on bytes returned BY tools across the run.
|
|
54
|
+
#: Bulk exfiltration is a permitted action repeated, not a forbidden one,
|
|
55
|
+
#: so only the running total gives it away. A result's size is unknowable
|
|
56
|
+
#: before the tool runs, so the breaching call completes and the NEXT one
|
|
57
|
+
#: is refused: this bounds a run at the limit plus one call.
|
|
58
|
+
bytes: float | None = None
|
|
59
|
+
#: Wall-clock ceiling for the run, in seconds, measured from the first call.
|
|
60
|
+
seconds: float | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BudgetUsage:
|
|
65
|
+
"""Live consumption for a run, compared against BudgetLimits."""
|
|
66
|
+
|
|
67
|
+
calls: int = 0
|
|
68
|
+
tokens: float = 0
|
|
69
|
+
usd: float = 0
|
|
70
|
+
#: Bytes returned by tools so far.
|
|
71
|
+
bytes: float = 0
|
|
72
|
+
#: Milliseconds since epoch of the first metered event, or None before it.
|
|
73
|
+
started_at: int | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ArgConstraint:
|
|
78
|
+
"""
|
|
79
|
+
Constraints applied to a single argument, addressed by a dotted path
|
|
80
|
+
(``path``, ``body.recipients.0``). Every constraint present must hold; an
|
|
81
|
+
absent value fails any constraint other than ``optional``.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
JSON_OMIT_IF_NONE: ClassVar[frozenset[str]] = frozenset(
|
|
85
|
+
{
|
|
86
|
+
"optional", "oneOf", "noneOf", "matches", "startsWith", "excludes",
|
|
87
|
+
"min", "max", "maxLength", "minLength", "urlHosts", "type",
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
#: Allow the argument to be missing entirely. Default false.
|
|
92
|
+
optional: bool | None = None
|
|
93
|
+
#: Value must be one of these (deep-equality for objects).
|
|
94
|
+
oneOf: list[Any] | None = None
|
|
95
|
+
#: Value must NOT be one of these.
|
|
96
|
+
noneOf: list[Any] | None = None
|
|
97
|
+
#: String must match this regular expression (anchored by the author, not by us).
|
|
98
|
+
matches: str | None = None
|
|
99
|
+
#: String must start with one of these prefixes. Used for filesystem scoping.
|
|
100
|
+
startsWith: list[str] | None = None
|
|
101
|
+
#: String must NOT contain any of these substrings. Cheap traversal guard.
|
|
102
|
+
excludes: list[str] | None = None
|
|
103
|
+
#: Inclusive numeric bounds.
|
|
104
|
+
min: float | None = None
|
|
105
|
+
max: float | None = None
|
|
106
|
+
#: Length bounds for strings and arrays.
|
|
107
|
+
maxLength: float | None = None
|
|
108
|
+
minLength: float | None = None
|
|
109
|
+
#: Value must parse as a URL whose hostname is in this list (exact or ".suffix").
|
|
110
|
+
urlHosts: list[str] | None = None
|
|
111
|
+
#: Runtime type check.
|
|
112
|
+
type: ArgType | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class RateLimit:
|
|
117
|
+
"""Per-rule throttle, evaluated against the audit ledger."""
|
|
118
|
+
|
|
119
|
+
JSON_OMIT_IF_NONE: ClassVar[frozenset[str]] = frozenset({"perSeconds"})
|
|
120
|
+
|
|
121
|
+
#: Maximum matching calls allowed inside the window.
|
|
122
|
+
max: int = 0
|
|
123
|
+
#: Sliding window length in seconds. Omit for "per run".
|
|
124
|
+
perSeconds: float | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class PolicyRule:
|
|
129
|
+
"""One clause of a policy document."""
|
|
130
|
+
|
|
131
|
+
JSON_OMIT_IF_NONE: ClassVar[frozenset[str]] = frozenset(
|
|
132
|
+
{"description", "when", "limit"}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
#: Stable identifier, surfaced in decisions and audit entries.
|
|
136
|
+
id: str
|
|
137
|
+
#: Tool name globs this rule applies to. ``*`` matches any run of characters
|
|
138
|
+
#: except ``.`` and ``/``; ``**`` matches anything, separators included.
|
|
139
|
+
#:
|
|
140
|
+
#: So ``**`` is the match-every-tool pattern. A bare ``*`` matches only names
|
|
141
|
+
#: with no separator in them -- it does NOT match "fs.read". Reach for ``**``
|
|
142
|
+
#: when you mean "everything", particularly on a deny rule, where a glob
|
|
143
|
+
#: that under-matches leaves a hole rather than failing closed.
|
|
144
|
+
tools: list[str] = field(default_factory=list)
|
|
145
|
+
effect: Effect = "allow"
|
|
146
|
+
#: Human-readable justification. Shows up in denial messages and reports.
|
|
147
|
+
description: str | None = None
|
|
148
|
+
#: Argument constraints, keyed by dotted path. All must hold for the rule to match.
|
|
149
|
+
when: dict[str, ArgConstraint] | None = None
|
|
150
|
+
#: Throttle for calls matching this rule. Exceeding it denies.
|
|
151
|
+
limit: RateLimit | None = None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class RunPlan:
|
|
156
|
+
"""
|
|
157
|
+
The envelope a run declares before it starts, and a human approves once.
|
|
158
|
+
|
|
159
|
+
This is what makes capacity limits usable rather than annoying. A global
|
|
160
|
+
threshold ("50MB is suspicious") is either too loose to catch anything or
|
|
161
|
+
too tight to live with, because it has to guess at every job at once. A
|
|
162
|
+
declared envelope does not guess: a render job that asked for an hour and
|
|
163
|
+
two gigabytes gets exactly that without interruption, and the signal is not
|
|
164
|
+
"this looks like a lot" but "this run left the envelope its operator
|
|
165
|
+
approved". Approve at the start, hear nothing until a warn threshold.
|
|
166
|
+
|
|
167
|
+
The plan is written into the audit chain as its first entry, so what was
|
|
168
|
+
authorised is part of the tamper-evident record, not just what happened.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
JSON_OMIT_IF_NONE: ClassVar[frozenset[str]] = frozenset({"approvedBy", "warnAt"})
|
|
172
|
+
|
|
173
|
+
#: What this run is for, in the operator's words. Carried into the audit log.
|
|
174
|
+
purpose: str
|
|
175
|
+
#: Who approved the envelope. Recorded, never verified by Toolwrit itself.
|
|
176
|
+
approvedBy: str | None = None
|
|
177
|
+
#: Fractions of the budget (0-1) at which the run reports to a human.
|
|
178
|
+
#: Each threshold fires at most once. Defaults to [0.8, 0.95].
|
|
179
|
+
warnAt: list[float] | None = None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class BudgetWarning:
|
|
184
|
+
"""Emitted when consumption crosses one of the plan's warn thresholds."""
|
|
185
|
+
|
|
186
|
+
#: Which budget dimension crossed.
|
|
187
|
+
dimension: Literal["calls", "tokens", "usd", "bytes", "seconds"]
|
|
188
|
+
#: The threshold that fired, as a fraction of the limit.
|
|
189
|
+
threshold: float
|
|
190
|
+
#: Consumption and ceiling for that dimension.
|
|
191
|
+
used: float
|
|
192
|
+
limit: float
|
|
193
|
+
#: Ready-to-send summary, e.g. for a Slack message.
|
|
194
|
+
message: str
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class Policy:
|
|
199
|
+
"""A complete, self-contained enforcement policy."""
|
|
200
|
+
|
|
201
|
+
JSON_OMIT_IF_NONE: ClassVar[frozenset[str]] = frozenset(
|
|
202
|
+
{"name", "default", "budget", "plan"}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
#: Schema version. Only "1" exists today; unknown versions are rejected.
|
|
206
|
+
version: Literal["1"] = "1"
|
|
207
|
+
rules: list[PolicyRule] = field(default_factory=list)
|
|
208
|
+
#: Free-text label carried into audit exports.
|
|
209
|
+
name: str | None = None
|
|
210
|
+
#: Effect when no rule matches. Defaults to "deny" -- Toolwrit is deny-by-default.
|
|
211
|
+
default: Effect | None = None
|
|
212
|
+
budget: BudgetLimits | None = None
|
|
213
|
+
#: The declared, pre-approved envelope for a run. See RunPlan.
|
|
214
|
+
plan: RunPlan | None = None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class Violation:
|
|
219
|
+
"""Why a call was refused, or which constraint a rule turned on."""
|
|
220
|
+
|
|
221
|
+
JSON_OMIT_IF_NONE: ClassVar[frozenset[str]] = frozenset({"path"})
|
|
222
|
+
|
|
223
|
+
#: Rule that produced this violation, or "budget" / "policy" for engine-level ones.
|
|
224
|
+
rule: str
|
|
225
|
+
#: Constraint name that failed, e.g. "startsWith" or "usd".
|
|
226
|
+
constraint: str
|
|
227
|
+
#: Operator-facing explanation. Safe to show to the agent as a tool error.
|
|
228
|
+
message: str
|
|
229
|
+
#: Dotted argument path, when the violation is argument-specific.
|
|
230
|
+
path: str | None = None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class Decision:
|
|
235
|
+
"""The engine's verdict on one tool call."""
|
|
236
|
+
|
|
237
|
+
effect: Effect
|
|
238
|
+
#: Id of the rule that decided this, or None when the default applied.
|
|
239
|
+
rule: str | None
|
|
240
|
+
#: Short reason, suitable for a log line.
|
|
241
|
+
reason: str
|
|
242
|
+
#: Empty for a clean allow.
|
|
243
|
+
violations: list[Violation] = field(default_factory=list)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class HistoryEntry:
|
|
248
|
+
"""A prior call, in the only shape the engine needs for rate limiting."""
|
|
249
|
+
|
|
250
|
+
tool: str
|
|
251
|
+
rule: str | None
|
|
252
|
+
at: int
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class EvalContext:
|
|
257
|
+
"""State the engine reads but never mutates."""
|
|
258
|
+
|
|
259
|
+
policy: Policy
|
|
260
|
+
usage: BudgetUsage = field(default_factory=BudgetUsage)
|
|
261
|
+
#: Prior calls in this run, oldest first. Used only for rate limiting.
|
|
262
|
+
history: Sequence[HistoryEntry] = field(default_factory=tuple)
|