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.
Files changed (35) hide show
  1. toolwrit-0.2.0/PKG-INFO +267 -0
  2. toolwrit-0.2.0/README.md +248 -0
  3. toolwrit-0.2.0/pyproject.toml +39 -0
  4. toolwrit-0.2.0/setup.cfg +4 -0
  5. toolwrit-0.2.0/src/toolwrit/__init__.py +95 -0
  6. toolwrit-0.2.0/src/toolwrit/_js.py +262 -0
  7. toolwrit-0.2.0/src/toolwrit/audit/__init__.py +1 -0
  8. toolwrit-0.2.0/src/toolwrit/audit/chain.py +251 -0
  9. toolwrit-0.2.0/src/toolwrit/audit/verify.py +164 -0
  10. toolwrit-0.2.0/src/toolwrit/budget/__init__.py +1 -0
  11. toolwrit-0.2.0/src/toolwrit/budget/ledger.py +141 -0
  12. toolwrit-0.2.0/src/toolwrit/cli.py +311 -0
  13. toolwrit-0.2.0/src/toolwrit/policy/__init__.py +1 -0
  14. toolwrit-0.2.0/src/toolwrit/policy/constraints.py +445 -0
  15. toolwrit-0.2.0/src/toolwrit/policy/engine.py +208 -0
  16. toolwrit-0.2.0/src/toolwrit/policy/load.py +418 -0
  17. toolwrit-0.2.0/src/toolwrit/policy/match.py +110 -0
  18. toolwrit-0.2.0/src/toolwrit/toolwrit.py +428 -0
  19. toolwrit-0.2.0/src/toolwrit/types.py +262 -0
  20. toolwrit-0.2.0/src/toolwrit.egg-info/PKG-INFO +267 -0
  21. toolwrit-0.2.0/src/toolwrit.egg-info/SOURCES.txt +33 -0
  22. toolwrit-0.2.0/src/toolwrit.egg-info/dependency_links.txt +1 -0
  23. toolwrit-0.2.0/src/toolwrit.egg-info/entry_points.txt +2 -0
  24. toolwrit-0.2.0/src/toolwrit.egg-info/requires.txt +4 -0
  25. toolwrit-0.2.0/src/toolwrit.egg-info/top_level.txt +1 -0
  26. toolwrit-0.2.0/tests/test_audit.py +606 -0
  27. toolwrit-0.2.0/tests/test_cli.py +199 -0
  28. toolwrit-0.2.0/tests/test_constraints.py +446 -0
  29. toolwrit-0.2.0/tests/test_engine.py +450 -0
  30. toolwrit-0.2.0/tests/test_interop.py +409 -0
  31. toolwrit-0.2.0/tests/test_ledger.py +112 -0
  32. toolwrit-0.2.0/tests/test_load.py +502 -0
  33. toolwrit-0.2.0/tests/test_match.py +172 -0
  34. toolwrit-0.2.0/tests/test_plan.py +150 -0
  35. toolwrit-0.2.0/tests/test_toolwrit.py +511 -0
@@ -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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]