toolwrit 0.2.0__tar.gz
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-0.2.0/PKG-INFO +267 -0
- toolwrit-0.2.0/README.md +248 -0
- toolwrit-0.2.0/pyproject.toml +39 -0
- toolwrit-0.2.0/setup.cfg +4 -0
- toolwrit-0.2.0/src/toolwrit/__init__.py +95 -0
- toolwrit-0.2.0/src/toolwrit/_js.py +262 -0
- toolwrit-0.2.0/src/toolwrit/audit/__init__.py +1 -0
- toolwrit-0.2.0/src/toolwrit/audit/chain.py +251 -0
- toolwrit-0.2.0/src/toolwrit/audit/verify.py +164 -0
- toolwrit-0.2.0/src/toolwrit/budget/__init__.py +1 -0
- toolwrit-0.2.0/src/toolwrit/budget/ledger.py +141 -0
- toolwrit-0.2.0/src/toolwrit/cli.py +311 -0
- toolwrit-0.2.0/src/toolwrit/policy/__init__.py +1 -0
- toolwrit-0.2.0/src/toolwrit/policy/constraints.py +445 -0
- toolwrit-0.2.0/src/toolwrit/policy/engine.py +208 -0
- toolwrit-0.2.0/src/toolwrit/policy/load.py +418 -0
- toolwrit-0.2.0/src/toolwrit/policy/match.py +110 -0
- toolwrit-0.2.0/src/toolwrit/toolwrit.py +428 -0
- toolwrit-0.2.0/src/toolwrit/types.py +262 -0
- toolwrit-0.2.0/src/toolwrit.egg-info/PKG-INFO +267 -0
- toolwrit-0.2.0/src/toolwrit.egg-info/SOURCES.txt +33 -0
- toolwrit-0.2.0/src/toolwrit.egg-info/dependency_links.txt +1 -0
- toolwrit-0.2.0/src/toolwrit.egg-info/entry_points.txt +2 -0
- toolwrit-0.2.0/src/toolwrit.egg-info/requires.txt +4 -0
- toolwrit-0.2.0/src/toolwrit.egg-info/top_level.txt +1 -0
- toolwrit-0.2.0/tests/test_audit.py +606 -0
- toolwrit-0.2.0/tests/test_cli.py +199 -0
- toolwrit-0.2.0/tests/test_constraints.py +446 -0
- toolwrit-0.2.0/tests/test_engine.py +450 -0
- toolwrit-0.2.0/tests/test_interop.py +409 -0
- toolwrit-0.2.0/tests/test_ledger.py +112 -0
- toolwrit-0.2.0/tests/test_load.py +502 -0
- toolwrit-0.2.0/tests/test_match.py +172 -0
- toolwrit-0.2.0/tests/test_plan.py +150 -0
- toolwrit-0.2.0/tests/test_toolwrit.py +511 -0
toolwrit-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolwrit
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A written authority for AI agents. Allowlist the tools, cap the budget, prove what happened.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/emektor/toolwrit
|
|
7
|
+
Project-URL: Repository, https://github.com/emektor/toolwrit
|
|
8
|
+
Project-URL: Issues, https://github.com/emektor/toolwrit/issues
|
|
9
|
+
Keywords: ai,agent,guardrails,mcp,tool-calling,policy,audit,llm,security
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pyyaml>=6
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
19
|
+
|
|
20
|
+
# toolwrit
|
|
21
|
+
|
|
22
|
+
A written authority for AI agents. Allowlist the tools, cap the budget, prove
|
|
23
|
+
what happened.
|
|
24
|
+
|
|
25
|
+
This is the Python port of
|
|
26
|
+
[Toolwrit](https://github.com/emektor/toolwrit). **The full documentation lives
|
|
27
|
+
in the main project's docs** — the policy language, the threat model and the
|
|
28
|
+
design rationale are the same here, clause for clause:
|
|
29
|
+
|
|
30
|
+
- [the overview](https://github.com/emektor/toolwrit#readme) — run plans, the
|
|
31
|
+
bytes ceiling, receipts and anchoring.
|
|
32
|
+
- [`policy-reference.md`](https://github.com/emektor/toolwrit/blob/main/js/docs/policy-reference.md) — every
|
|
33
|
+
policy field with a worked example.
|
|
34
|
+
- [`threat-model.md`](https://github.com/emektor/toolwrit/blob/main/js/docs/threat-model.md) — what is and is
|
|
35
|
+
not defended against.
|
|
36
|
+
- [`python.md`](https://github.com/emektor/toolwrit/blob/main/js/docs/python.md) — the Python-specific
|
|
37
|
+
reference: the full API table, the sync/async split, and everything below in
|
|
38
|
+
more detail.
|
|
39
|
+
|
|
40
|
+
What follows is only what a Python user needs on top of that.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install toolwrit
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Until the first release is published:
|
|
47
|
+
`pip install "git+https://github.com/emektor/toolwrit.git#subdirectory=python"`
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from toolwrit import Toolwrit, ToolwritDenied, load_policy_file
|
|
51
|
+
|
|
52
|
+
toolwrit = Toolwrit(load_policy_file("policy.yaml"), audit_file="audit.jsonl")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
contents = toolwrit.guard("fs.read", {"path": "/tmp/notes"}, lambda: read_file())
|
|
56
|
+
except ToolwritDenied as denied:
|
|
57
|
+
print(denied.decision.reason, denied.audit_hash)
|
|
58
|
+
|
|
59
|
+
toolwrit.meter(1200, 350) # input tokens, output tokens
|
|
60
|
+
print(toolwrit.usage()) # BudgetUsage(calls=…, tokens=…, usd=…, bytes=…, started_at=…)
|
|
61
|
+
print(toolwrit.head()) # the head of this run's audit chain
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
toolwrit check --policy policy.yaml --tool fs.read --args '{"path":"/tmp/x"}'
|
|
66
|
+
toolwrit explain --policy policy.yaml
|
|
67
|
+
toolwrit verify audit.jsonl
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Run plans
|
|
71
|
+
|
|
72
|
+
A `plan:` block declares the envelope a run is approved for, and it works here
|
|
73
|
+
exactly as it does in TypeScript.
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
budget:
|
|
77
|
+
calls: 200
|
|
78
|
+
usd: 5.00
|
|
79
|
+
bytes: 20000000
|
|
80
|
+
seconds: 3600
|
|
81
|
+
|
|
82
|
+
plan:
|
|
83
|
+
purpose: nightly CRM export for the EU region
|
|
84
|
+
approvedBy: ergin
|
|
85
|
+
warnAt: [0.8, 0.95] # the default
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The argument for declaring an envelope rather than setting a global threshold:
|
|
89
|
+
a global threshold has to guess at every job at once, so it is either too loose
|
|
90
|
+
to catch anything or too tight to leave switched on — and an alert people mute
|
|
91
|
+
is the same as no alert. A declared envelope does not guess. Approve once at
|
|
92
|
+
the start, hear nothing until a threshold.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
toolwrit = Toolwrit(
|
|
96
|
+
load_policy_file("export.yaml"),
|
|
97
|
+
run="nightly-2026-09-12",
|
|
98
|
+
audit_file="audit.jsonl",
|
|
99
|
+
on_warn=lambda w: print("on_warn ->", w.message),
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
on_warn -> run "nightly-2026-09-12" (nightly CRM export for the EU region) has used 16,000,000/20,000,000 bytes — 80% of the approved envelope
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Two properties worth knowing:
|
|
108
|
+
|
|
109
|
+
- **The plan is entry 1 of the audit chain**, written by the constructor before
|
|
110
|
+
anything can be guarded. What was *authorised* is in the tamper-evident record
|
|
111
|
+
alongside what happened.
|
|
112
|
+
- **Each threshold fires at most once per dimension**, across `calls`, `tokens`,
|
|
113
|
+
`usd`, `bytes` and `seconds`, and every warning is recorded in the chain as a
|
|
114
|
+
`toolwrit:warning` entry as well as delivered to `on_warn`. Keep `on_warn`
|
|
115
|
+
non-blocking; it runs inline with metering.
|
|
116
|
+
|
|
117
|
+
A `plan` requires a `budget` to measure against, `purpose` must be non-empty,
|
|
118
|
+
and `warnAt` entries must be greater than 0 and at most 1. All three are load
|
|
119
|
+
errors, with the same messages as the TypeScript implementation.
|
|
120
|
+
|
|
121
|
+
## The bytes ceiling
|
|
122
|
+
|
|
123
|
+
`budget.bytes` caps the total size of what tools **return** over a run. Bulk
|
|
124
|
+
exfiltration is rarely a forbidden action — it is a permitted action repeated
|
|
125
|
+
until something is drained, so every individual call passes the allowlist and
|
|
126
|
+
only the running total gives it away. Unlike `usd` and `tokens`, this dimension
|
|
127
|
+
needs no cooperation from your integration: `guard` and `guard_async` feed it.
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
call 5: ok, bytes now 20000000
|
|
131
|
+
call 6: toolwrit: crm.search denied — data budget exhausted: 20000000/20000000 bytes returned by tools
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**The limitation, stated up front.** A result's size is not knowable before the
|
|
135
|
+
tool runs, so the measurement happens afterwards: the call that blows the
|
|
136
|
+
ceiling **completes**, and the **next** one is refused. A bytes ceiling bounds a
|
|
137
|
+
run at the limit *plus one call*, never exactly the limit. If one call can
|
|
138
|
+
return everything, cap the response at the tool.
|
|
139
|
+
|
|
140
|
+
Sizes: `None` → 0; `str` → its UTF-8 byte length; `bytes`/`bytearray`/
|
|
141
|
+
`memoryview` → their length; anything else → the UTF-8 byte length of its
|
|
142
|
+
canonical JSON, which equals what TypeScript gets from `JSON.stringify`. A
|
|
143
|
+
result that cannot be serialised falls back to `str(result)` and is therefore
|
|
144
|
+
**under-counted** — documented rather than hidden, because under-counting a
|
|
145
|
+
volume ceiling fails open.
|
|
146
|
+
|
|
147
|
+
## Receipts and anchoring: use the TypeScript CLI
|
|
148
|
+
|
|
149
|
+
Run receipts (`summarize`, `verifyAgainstReceipt`) and the `toolwrit receipt`,
|
|
150
|
+
`toolwrit anchor` and `toolwrit verify --against` subcommands are **not ported**.
|
|
151
|
+
Because the chains are compatible, that is not a blocker — point the TypeScript
|
|
152
|
+
CLI at a Python-written log:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
toolwrit receipt audit.jsonl # written by toolwrit for Python
|
|
156
|
+
toolwrit anchor audit.jsonl --to anchors.jsonl
|
|
157
|
+
toolwrit verify audit.jsonl --against anchors.jsonl
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
This matters more than it sounds. Verifying a chain in isolation **cannot**
|
|
161
|
+
detect that its tail was cut off: a prefix of a valid chain is itself a valid
|
|
162
|
+
chain and verifies clean, so an agent that deletes its last twenty entries hands
|
|
163
|
+
you a log that passes. Comparing against a receipt anchored at the time closes
|
|
164
|
+
that hole — and only if the anchor lives where the agent cannot rewrite it,
|
|
165
|
+
which Toolwrit cannot enforce.
|
|
166
|
+
|
|
167
|
+
The Python `toolwrit verify` **refuses** `--against` rather than ignoring it:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
$ toolwrit verify truncated.jsonl --against anchors.jsonl
|
|
171
|
+
toolwrit: `verify --against <anchor>` is not implemented in the Python CLI; ...
|
|
172
|
+
$ echo $?
|
|
173
|
+
1
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Accepting the flag and dropping it would print `ok` and exit 0 on precisely the
|
|
177
|
+
truncated log the flag exists to catch — a fail-open in the one command whose
|
|
178
|
+
job is detecting tampering. Every subcommand rejects flags it does not
|
|
179
|
+
implement for the same reason. Use the TypeScript CLI for anchoring; it reads
|
|
180
|
+
chains written here.
|
|
181
|
+
|
|
182
|
+
## Chain compatibility
|
|
183
|
+
|
|
184
|
+
An audit chain written here verifies under the TypeScript `toolwrit verify`, and
|
|
185
|
+
one written there verifies here — same canonical string, same SHA-256, same
|
|
186
|
+
head hash. `tests/test_interop.py` proves it in both directions, and goes
|
|
187
|
+
further than "both say ok": it builds the same chain with both implementations
|
|
188
|
+
and asserts the entry hashes are equal, which no pair of mutually incompatible
|
|
189
|
+
serialisers could manage.
|
|
190
|
+
|
|
191
|
+
Getting there is mostly about numbers. JavaScript has one numeric type and
|
|
192
|
+
prints it with `Number::toString` — `1.0` is `"1"`, `1e21` is `"1e+21"`, `1e16`
|
|
193
|
+
is `"10000000000000000"` — where Python's `repr` keeps the `.0` and switches to
|
|
194
|
+
exponent notation at a different magnitude. `toolwrit._js` implements the
|
|
195
|
+
ECMAScript algorithm directly; `test_audit.py` pins the values one by one.
|
|
196
|
+
|
|
197
|
+
## Differences from the TypeScript version
|
|
198
|
+
|
|
199
|
+
Behaviour is otherwise identical. These are the exceptions, and none of them
|
|
200
|
+
loosens a policy.
|
|
201
|
+
|
|
202
|
+
**`None` is `null`, not `undefined`.** JavaScript has two empty values and
|
|
203
|
+
`JSON.stringify` treats them differently: `null` is serialised, `undefined` is
|
|
204
|
+
dropped. Python has one. Since `decision.rule` is legitimately `null` on every
|
|
205
|
+
default-deny and the TypeScript chain commits to it, `canonicalize` **keeps**
|
|
206
|
+
`None` as `null` — dropping it would break the chain outright. The "drop me"
|
|
207
|
+
meaning is carried by an explicit `toolwrit._js.UNDEFINED` sentinel instead.
|
|
208
|
+
|
|
209
|
+
The visible consequence is in constraints: an argument key present but set to
|
|
210
|
+
`None` counts as **absent**, so it fails a non-`optional` constraint with a
|
|
211
|
+
`required` violation. TypeScript would call `{"path": null}` a present null and
|
|
212
|
+
report a `type` violation instead. Both refuse the call; only the violation
|
|
213
|
+
name differs.
|
|
214
|
+
|
|
215
|
+
**`guard` is split in two.** Python cannot await from a synchronous frame, so
|
|
216
|
+
`guard(tool, args, execute)` serves ordinary functions and
|
|
217
|
+
`await guard_async(tool, args, execute)` awaits whatever it is given — an async
|
|
218
|
+
tool, an async `on_ask` handler, or plain functions unchanged. Passing an async
|
|
219
|
+
`on_ask` to the synchronous `guard` raises `TypeError` rather than guessing.
|
|
220
|
+
|
|
221
|
+
**`toolwrit run` is not ported.** The MCP stdio proxy and the Anthropic/OpenAI SDK
|
|
222
|
+
adapters stay in the TypeScript package; `toolwrit run` here exits 1 and says so.
|
|
223
|
+
`check`, `explain` and `verify` behave identically, exit codes included.
|
|
224
|
+
|
|
225
|
+
**Run receipts and anchoring are not ported.** There is no `summarize` or
|
|
226
|
+
`verify_against_receipt`, and no `receipt`, `anchor` or working `verify
|
|
227
|
+
--against` in this CLI. Use the TypeScript CLI over the Python-written log; see
|
|
228
|
+
the section above.
|
|
229
|
+
|
|
230
|
+
**Regexes are Python regexes.** A `matches:` pattern is compiled with `re`, not
|
|
231
|
+
V8's `RegExp`. The common syntax is shared, but the dialects diverge on named
|
|
232
|
+
groups (`(?P<x>)` vs `(?<x>)`), lookbehind and the Unicode semantics of `\d`
|
|
233
|
+
and `\w`. A policy shared between the two implementations should stay inside
|
|
234
|
+
the common subset.
|
|
235
|
+
|
|
236
|
+
**URLs are parsed with `urllib.parse`,** with an explicit absolute-URL gate
|
|
237
|
+
(scheme required, declared authority must exist) standing in for the throw that
|
|
238
|
+
WHATWG `new URL()` performs. The tested behaviour matches, including the
|
|
239
|
+
`https://example.com@evil.com/` case. Two untested corners differ: an IPv6 host
|
|
240
|
+
comes back as `::1` rather than `[::1]`, and an internationalised domain is not
|
|
241
|
+
converted to punycode, so an IDN `urlHosts` entry must be written in the same
|
|
242
|
+
form the argument uses.
|
|
243
|
+
|
|
244
|
+
**YAML is YAML 1.1.** PyYAML implements 1.1, the TypeScript `yaml` package
|
|
245
|
+
implements 1.2. The difference a policy author can hit is bare `yes`/`no`/
|
|
246
|
+
`on`/`off`, which PyYAML reads as booleans and the TypeScript reads as strings.
|
|
247
|
+
Quote them. JSON parses identically under both.
|
|
248
|
+
|
|
249
|
+
**The JSONL byte layout differs cosmetically.** Within a violation object this
|
|
250
|
+
port writes `path` last where the TypeScript writes it second. The canonical
|
|
251
|
+
form sorts keys, so the hashes are unaffected and each implementation verifies
|
|
252
|
+
the other's file; only a byte-for-byte `diff` of the two logs shows it.
|
|
253
|
+
|
|
254
|
+
## Development
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
pip install -e '.[test]'
|
|
258
|
+
python -m pytest
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The cross-language tests need `node` and a built copy of the TypeScript
|
|
262
|
+
implementation; they look for it at `../js` or at `$TOOLWRIT_TS_ROOT`, and skip
|
|
263
|
+
when it is absent.
|
|
264
|
+
|
|
265
|
+
## Licence
|
|
266
|
+
|
|
267
|
+
Apache-2.0.
|
toolwrit-0.2.0/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# toolwrit
|
|
2
|
+
|
|
3
|
+
A written authority for AI agents. Allowlist the tools, cap the budget, prove
|
|
4
|
+
what happened.
|
|
5
|
+
|
|
6
|
+
This is the Python port of
|
|
7
|
+
[Toolwrit](https://github.com/emektor/toolwrit). **The full documentation lives
|
|
8
|
+
in the main project's docs** — the policy language, the threat model and the
|
|
9
|
+
design rationale are the same here, clause for clause:
|
|
10
|
+
|
|
11
|
+
- [the overview](https://github.com/emektor/toolwrit#readme) — run plans, the
|
|
12
|
+
bytes ceiling, receipts and anchoring.
|
|
13
|
+
- [`policy-reference.md`](https://github.com/emektor/toolwrit/blob/main/js/docs/policy-reference.md) — every
|
|
14
|
+
policy field with a worked example.
|
|
15
|
+
- [`threat-model.md`](https://github.com/emektor/toolwrit/blob/main/js/docs/threat-model.md) — what is and is
|
|
16
|
+
not defended against.
|
|
17
|
+
- [`python.md`](https://github.com/emektor/toolwrit/blob/main/js/docs/python.md) — the Python-specific
|
|
18
|
+
reference: the full API table, the sync/async split, and everything below in
|
|
19
|
+
more detail.
|
|
20
|
+
|
|
21
|
+
What follows is only what a Python user needs on top of that.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install toolwrit
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Until the first release is published:
|
|
28
|
+
`pip install "git+https://github.com/emektor/toolwrit.git#subdirectory=python"`
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from toolwrit import Toolwrit, ToolwritDenied, load_policy_file
|
|
32
|
+
|
|
33
|
+
toolwrit = Toolwrit(load_policy_file("policy.yaml"), audit_file="audit.jsonl")
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
contents = toolwrit.guard("fs.read", {"path": "/tmp/notes"}, lambda: read_file())
|
|
37
|
+
except ToolwritDenied as denied:
|
|
38
|
+
print(denied.decision.reason, denied.audit_hash)
|
|
39
|
+
|
|
40
|
+
toolwrit.meter(1200, 350) # input tokens, output tokens
|
|
41
|
+
print(toolwrit.usage()) # BudgetUsage(calls=…, tokens=…, usd=…, bytes=…, started_at=…)
|
|
42
|
+
print(toolwrit.head()) # the head of this run's audit chain
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
toolwrit check --policy policy.yaml --tool fs.read --args '{"path":"/tmp/x"}'
|
|
47
|
+
toolwrit explain --policy policy.yaml
|
|
48
|
+
toolwrit verify audit.jsonl
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Run plans
|
|
52
|
+
|
|
53
|
+
A `plan:` block declares the envelope a run is approved for, and it works here
|
|
54
|
+
exactly as it does in TypeScript.
|
|
55
|
+
|
|
56
|
+
```yaml
|
|
57
|
+
budget:
|
|
58
|
+
calls: 200
|
|
59
|
+
usd: 5.00
|
|
60
|
+
bytes: 20000000
|
|
61
|
+
seconds: 3600
|
|
62
|
+
|
|
63
|
+
plan:
|
|
64
|
+
purpose: nightly CRM export for the EU region
|
|
65
|
+
approvedBy: ergin
|
|
66
|
+
warnAt: [0.8, 0.95] # the default
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The argument for declaring an envelope rather than setting a global threshold:
|
|
70
|
+
a global threshold has to guess at every job at once, so it is either too loose
|
|
71
|
+
to catch anything or too tight to leave switched on — and an alert people mute
|
|
72
|
+
is the same as no alert. A declared envelope does not guess. Approve once at
|
|
73
|
+
the start, hear nothing until a threshold.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
toolwrit = Toolwrit(
|
|
77
|
+
load_policy_file("export.yaml"),
|
|
78
|
+
run="nightly-2026-09-12",
|
|
79
|
+
audit_file="audit.jsonl",
|
|
80
|
+
on_warn=lambda w: print("on_warn ->", w.message),
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
on_warn -> run "nightly-2026-09-12" (nightly CRM export for the EU region) has used 16,000,000/20,000,000 bytes — 80% of the approved envelope
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Two properties worth knowing:
|
|
89
|
+
|
|
90
|
+
- **The plan is entry 1 of the audit chain**, written by the constructor before
|
|
91
|
+
anything can be guarded. What was *authorised* is in the tamper-evident record
|
|
92
|
+
alongside what happened.
|
|
93
|
+
- **Each threshold fires at most once per dimension**, across `calls`, `tokens`,
|
|
94
|
+
`usd`, `bytes` and `seconds`, and every warning is recorded in the chain as a
|
|
95
|
+
`toolwrit:warning` entry as well as delivered to `on_warn`. Keep `on_warn`
|
|
96
|
+
non-blocking; it runs inline with metering.
|
|
97
|
+
|
|
98
|
+
A `plan` requires a `budget` to measure against, `purpose` must be non-empty,
|
|
99
|
+
and `warnAt` entries must be greater than 0 and at most 1. All three are load
|
|
100
|
+
errors, with the same messages as the TypeScript implementation.
|
|
101
|
+
|
|
102
|
+
## The bytes ceiling
|
|
103
|
+
|
|
104
|
+
`budget.bytes` caps the total size of what tools **return** over a run. Bulk
|
|
105
|
+
exfiltration is rarely a forbidden action — it is a permitted action repeated
|
|
106
|
+
until something is drained, so every individual call passes the allowlist and
|
|
107
|
+
only the running total gives it away. Unlike `usd` and `tokens`, this dimension
|
|
108
|
+
needs no cooperation from your integration: `guard` and `guard_async` feed it.
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
call 5: ok, bytes now 20000000
|
|
112
|
+
call 6: toolwrit: crm.search denied — data budget exhausted: 20000000/20000000 bytes returned by tools
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**The limitation, stated up front.** A result's size is not knowable before the
|
|
116
|
+
tool runs, so the measurement happens afterwards: the call that blows the
|
|
117
|
+
ceiling **completes**, and the **next** one is refused. A bytes ceiling bounds a
|
|
118
|
+
run at the limit *plus one call*, never exactly the limit. If one call can
|
|
119
|
+
return everything, cap the response at the tool.
|
|
120
|
+
|
|
121
|
+
Sizes: `None` → 0; `str` → its UTF-8 byte length; `bytes`/`bytearray`/
|
|
122
|
+
`memoryview` → their length; anything else → the UTF-8 byte length of its
|
|
123
|
+
canonical JSON, which equals what TypeScript gets from `JSON.stringify`. A
|
|
124
|
+
result that cannot be serialised falls back to `str(result)` and is therefore
|
|
125
|
+
**under-counted** — documented rather than hidden, because under-counting a
|
|
126
|
+
volume ceiling fails open.
|
|
127
|
+
|
|
128
|
+
## Receipts and anchoring: use the TypeScript CLI
|
|
129
|
+
|
|
130
|
+
Run receipts (`summarize`, `verifyAgainstReceipt`) and the `toolwrit receipt`,
|
|
131
|
+
`toolwrit anchor` and `toolwrit verify --against` subcommands are **not ported**.
|
|
132
|
+
Because the chains are compatible, that is not a blocker — point the TypeScript
|
|
133
|
+
CLI at a Python-written log:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
toolwrit receipt audit.jsonl # written by toolwrit for Python
|
|
137
|
+
toolwrit anchor audit.jsonl --to anchors.jsonl
|
|
138
|
+
toolwrit verify audit.jsonl --against anchors.jsonl
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This matters more than it sounds. Verifying a chain in isolation **cannot**
|
|
142
|
+
detect that its tail was cut off: a prefix of a valid chain is itself a valid
|
|
143
|
+
chain and verifies clean, so an agent that deletes its last twenty entries hands
|
|
144
|
+
you a log that passes. Comparing against a receipt anchored at the time closes
|
|
145
|
+
that hole — and only if the anchor lives where the agent cannot rewrite it,
|
|
146
|
+
which Toolwrit cannot enforce.
|
|
147
|
+
|
|
148
|
+
The Python `toolwrit verify` **refuses** `--against` rather than ignoring it:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
$ toolwrit verify truncated.jsonl --against anchors.jsonl
|
|
152
|
+
toolwrit: `verify --against <anchor>` is not implemented in the Python CLI; ...
|
|
153
|
+
$ echo $?
|
|
154
|
+
1
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Accepting the flag and dropping it would print `ok` and exit 0 on precisely the
|
|
158
|
+
truncated log the flag exists to catch — a fail-open in the one command whose
|
|
159
|
+
job is detecting tampering. Every subcommand rejects flags it does not
|
|
160
|
+
implement for the same reason. Use the TypeScript CLI for anchoring; it reads
|
|
161
|
+
chains written here.
|
|
162
|
+
|
|
163
|
+
## Chain compatibility
|
|
164
|
+
|
|
165
|
+
An audit chain written here verifies under the TypeScript `toolwrit verify`, and
|
|
166
|
+
one written there verifies here — same canonical string, same SHA-256, same
|
|
167
|
+
head hash. `tests/test_interop.py` proves it in both directions, and goes
|
|
168
|
+
further than "both say ok": it builds the same chain with both implementations
|
|
169
|
+
and asserts the entry hashes are equal, which no pair of mutually incompatible
|
|
170
|
+
serialisers could manage.
|
|
171
|
+
|
|
172
|
+
Getting there is mostly about numbers. JavaScript has one numeric type and
|
|
173
|
+
prints it with `Number::toString` — `1.0` is `"1"`, `1e21` is `"1e+21"`, `1e16`
|
|
174
|
+
is `"10000000000000000"` — where Python's `repr` keeps the `.0` and switches to
|
|
175
|
+
exponent notation at a different magnitude. `toolwrit._js` implements the
|
|
176
|
+
ECMAScript algorithm directly; `test_audit.py` pins the values one by one.
|
|
177
|
+
|
|
178
|
+
## Differences from the TypeScript version
|
|
179
|
+
|
|
180
|
+
Behaviour is otherwise identical. These are the exceptions, and none of them
|
|
181
|
+
loosens a policy.
|
|
182
|
+
|
|
183
|
+
**`None` is `null`, not `undefined`.** JavaScript has two empty values and
|
|
184
|
+
`JSON.stringify` treats them differently: `null` is serialised, `undefined` is
|
|
185
|
+
dropped. Python has one. Since `decision.rule` is legitimately `null` on every
|
|
186
|
+
default-deny and the TypeScript chain commits to it, `canonicalize` **keeps**
|
|
187
|
+
`None` as `null` — dropping it would break the chain outright. The "drop me"
|
|
188
|
+
meaning is carried by an explicit `toolwrit._js.UNDEFINED` sentinel instead.
|
|
189
|
+
|
|
190
|
+
The visible consequence is in constraints: an argument key present but set to
|
|
191
|
+
`None` counts as **absent**, so it fails a non-`optional` constraint with a
|
|
192
|
+
`required` violation. TypeScript would call `{"path": null}` a present null and
|
|
193
|
+
report a `type` violation instead. Both refuse the call; only the violation
|
|
194
|
+
name differs.
|
|
195
|
+
|
|
196
|
+
**`guard` is split in two.** Python cannot await from a synchronous frame, so
|
|
197
|
+
`guard(tool, args, execute)` serves ordinary functions and
|
|
198
|
+
`await guard_async(tool, args, execute)` awaits whatever it is given — an async
|
|
199
|
+
tool, an async `on_ask` handler, or plain functions unchanged. Passing an async
|
|
200
|
+
`on_ask` to the synchronous `guard` raises `TypeError` rather than guessing.
|
|
201
|
+
|
|
202
|
+
**`toolwrit run` is not ported.** The MCP stdio proxy and the Anthropic/OpenAI SDK
|
|
203
|
+
adapters stay in the TypeScript package; `toolwrit run` here exits 1 and says so.
|
|
204
|
+
`check`, `explain` and `verify` behave identically, exit codes included.
|
|
205
|
+
|
|
206
|
+
**Run receipts and anchoring are not ported.** There is no `summarize` or
|
|
207
|
+
`verify_against_receipt`, and no `receipt`, `anchor` or working `verify
|
|
208
|
+
--against` in this CLI. Use the TypeScript CLI over the Python-written log; see
|
|
209
|
+
the section above.
|
|
210
|
+
|
|
211
|
+
**Regexes are Python regexes.** A `matches:` pattern is compiled with `re`, not
|
|
212
|
+
V8's `RegExp`. The common syntax is shared, but the dialects diverge on named
|
|
213
|
+
groups (`(?P<x>)` vs `(?<x>)`), lookbehind and the Unicode semantics of `\d`
|
|
214
|
+
and `\w`. A policy shared between the two implementations should stay inside
|
|
215
|
+
the common subset.
|
|
216
|
+
|
|
217
|
+
**URLs are parsed with `urllib.parse`,** with an explicit absolute-URL gate
|
|
218
|
+
(scheme required, declared authority must exist) standing in for the throw that
|
|
219
|
+
WHATWG `new URL()` performs. The tested behaviour matches, including the
|
|
220
|
+
`https://example.com@evil.com/` case. Two untested corners differ: an IPv6 host
|
|
221
|
+
comes back as `::1` rather than `[::1]`, and an internationalised domain is not
|
|
222
|
+
converted to punycode, so an IDN `urlHosts` entry must be written in the same
|
|
223
|
+
form the argument uses.
|
|
224
|
+
|
|
225
|
+
**YAML is YAML 1.1.** PyYAML implements 1.1, the TypeScript `yaml` package
|
|
226
|
+
implements 1.2. The difference a policy author can hit is bare `yes`/`no`/
|
|
227
|
+
`on`/`off`, which PyYAML reads as booleans and the TypeScript reads as strings.
|
|
228
|
+
Quote them. JSON parses identically under both.
|
|
229
|
+
|
|
230
|
+
**The JSONL byte layout differs cosmetically.** Within a violation object this
|
|
231
|
+
port writes `path` last where the TypeScript writes it second. The canonical
|
|
232
|
+
form sorts keys, so the hashes are unaffected and each implementation verifies
|
|
233
|
+
the other's file; only a byte-for-byte `diff` of the two logs shows it.
|
|
234
|
+
|
|
235
|
+
## Development
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
pip install -e '.[test]'
|
|
239
|
+
python -m pytest
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The cross-language tests need `node` and a built copy of the TypeScript
|
|
243
|
+
implementation; they look for it at `../js` or at `$TOOLWRIT_TS_ROOT`, and skip
|
|
244
|
+
when it is absent.
|
|
245
|
+
|
|
246
|
+
## Licence
|
|
247
|
+
|
|
248
|
+
Apache-2.0.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "toolwrit"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "A written authority for AI agents. Allowlist the tools, cap the budget, prove what happened."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
keywords = [
|
|
13
|
+
"ai", "agent", "guardrails", "mcp", "tool-calling",
|
|
14
|
+
"policy", "audit", "llm", "security",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["pyyaml>=6"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/emektor/toolwrit"
|
|
26
|
+
Repository = "https://github.com/emektor/toolwrit"
|
|
27
|
+
Issues = "https://github.com/emektor/toolwrit/issues"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
test = ["pytest>=7"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
toolwrit = "toolwrit.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
toolwrit-0.2.0/setup.cfg
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
|
+
]
|