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/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)