vouchwell 0.5.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.
- vouchwell-0.5.0/PKG-INFO +199 -0
- vouchwell-0.5.0/README.md +171 -0
- vouchwell-0.5.0/pyproject.toml +51 -0
- vouchwell-0.5.0/setup.cfg +4 -0
- vouchwell-0.5.0/src/vouchwell/__init__.py +82 -0
- vouchwell-0.5.0/src/vouchwell/canonical.py +139 -0
- vouchwell-0.5.0/src/vouchwell/checkpoint.py +100 -0
- vouchwell-0.5.0/src/vouchwell/hashing.py +72 -0
- vouchwell-0.5.0/src/vouchwell/integrations/__init__.py +26 -0
- vouchwell-0.5.0/src/vouchwell/integrations/langchain.py +93 -0
- vouchwell-0.5.0/src/vouchwell/integrations/openai_agents.py +55 -0
- vouchwell-0.5.0/src/vouchwell/keys.py +84 -0
- vouchwell-0.5.0/src/vouchwell/log.py +453 -0
- vouchwell-0.5.0/src/vouchwell/merkle.py +200 -0
- vouchwell-0.5.0/src/vouchwell/py.typed +0 -0
- vouchwell-0.5.0/src/vouchwell/receipt.py +200 -0
- vouchwell-0.5.0/src/vouchwell/record.py +210 -0
- vouchwell-0.5.0/src/vouchwell/redact.py +126 -0
- vouchwell-0.5.0/src/vouchwell/remote.py +134 -0
- vouchwell-0.5.0/src/vouchwell/unfinished.py +91 -0
- vouchwell-0.5.0/src/vouchwell.egg-info/PKG-INFO +199 -0
- vouchwell-0.5.0/src/vouchwell.egg-info/SOURCES.txt +31 -0
- vouchwell-0.5.0/src/vouchwell.egg-info/dependency_links.txt +1 -0
- vouchwell-0.5.0/src/vouchwell.egg-info/requires.txt +14 -0
- vouchwell-0.5.0/src/vouchwell.egg-info/top_level.txt +1 -0
- vouchwell-0.5.0/tests/test_core.py +149 -0
- vouchwell-0.5.0/tests/test_integrations.py +135 -0
- vouchwell-0.5.0/tests/test_interop.py +153 -0
- vouchwell-0.5.0/tests/test_log.py +236 -0
- vouchwell-0.5.0/tests/test_record.py +104 -0
- vouchwell-0.5.0/tests/test_redact.py +19 -0
- vouchwell-0.5.0/tests/test_remote.py +46 -0
- vouchwell-0.5.0/tests/test_unfinished.py +75 -0
vouchwell-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vouchwell
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Tamper-evident receipts for AI agent actions. Python SDK, wire-compatible with the vouchwell CLI.
|
|
5
|
+
Author: IdeaboticsAI
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/vouchwell/vouchwell
|
|
8
|
+
Project-URL: Source, https://github.com/vouchwell/vouchwell/tree/main/sdk/python
|
|
9
|
+
Project-URL: Issues, https://github.com/vouchwell/vouchwell/issues
|
|
10
|
+
Keywords: ai-agents,audit,transparency-log,merkle,ed25519,eu-ai-act
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Classifier: Topic :: System :: Logging
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: cryptography>=41
|
|
20
|
+
Provides-Extra: langchain
|
|
21
|
+
Requires-Dist: langchain-core>=0.3; extra == "langchain"
|
|
22
|
+
Provides-Extra: openai-agents
|
|
23
|
+
Requires-Dist: openai-agents>=0.2; extra == "openai-agents"
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
26
|
+
Requires-Dist: langchain-core>=0.3; python_version >= "3.10" and extra == "test"
|
|
27
|
+
Requires-Dist: openai-agents>=0.2; python_version >= "3.10" and extra == "test"
|
|
28
|
+
|
|
29
|
+
# vouchwell (Python)
|
|
30
|
+
|
|
31
|
+
Tamper-evident receipts for what your AI agents do, from Python. This is the
|
|
32
|
+
Python SDK for [Vouchwell](https://github.com/vouchwell/vouchwell), and it
|
|
33
|
+
writes exactly the format the `vouchwell` CLI reads:
|
|
34
|
+
|
|
35
|
+
- a log written here passes `vw verify`;
|
|
36
|
+
- a bundle exported here passes `vw check`;
|
|
37
|
+
- logs and bundles written by the JavaScript side open and verify here.
|
|
38
|
+
|
|
39
|
+
CI runs both directions against the real CLI on every commit.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install "vouchwell @ git+https://github.com/vouchwell/vouchwell#subdirectory=sdk/python"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
That installs straight from this repository. The package is not on PyPI yet;
|
|
46
|
+
once it is, `pip install vouchwell` will do the same.
|
|
47
|
+
|
|
48
|
+
Python 3.9+. The one dependency is `cryptography`, for Ed25519.
|
|
49
|
+
|
|
50
|
+
## Record an agent's tool calls
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from vouchwell import ProofLog, Recorder
|
|
54
|
+
|
|
55
|
+
log = ProofLog.open_or_create(".vouchwell")
|
|
56
|
+
rec = Recorder(log, agent="support-bot", principal="ops@acme.com", namespace="stripe")
|
|
57
|
+
|
|
58
|
+
@rec.tool("refund", metrics=lambda order_id, amount_cents: {"amount_usd": amount_cents / 100})
|
|
59
|
+
def refund(order_id: str, amount_cents: int):
|
|
60
|
+
return stripe.Refund.create(payment_intent=order_id, amount=amount_cents)
|
|
61
|
+
|
|
62
|
+
refund("pi_123", 4200)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Each call leaves two signed receipts:
|
|
66
|
+
|
|
67
|
+
1. **An intent receipt, before the call runs.** A crash mid-call still leaves
|
|
68
|
+
evidence it was attempted.
|
|
69
|
+
2. **An outcome receipt, when it returns or raises**, linked to the intent.
|
|
70
|
+
|
|
71
|
+
Async functions work the same. So does a context manager:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
with rec.call("crm.update", params={"id": 42}) as call:
|
|
75
|
+
call.result = crm.update(42)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Arguments and results are **committed to, not stored**. A receipt holds a
|
|
79
|
+
salted hash plus a preview with secrets and personal data masked (API keys,
|
|
80
|
+
card numbers, emails and so on). The salts stay in the log's private
|
|
81
|
+
`salts.jsonl`, so you can later prove what a value was:
|
|
82
|
+
`log.reveal(seq, "params", value)`. Or you can destroy the salts, and the value
|
|
83
|
+
becomes unprovable forever while the receipt still verifies:
|
|
84
|
+
`log.shred(predicate)`.
|
|
85
|
+
|
|
86
|
+
### Check calls before they run
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
def decide(target, params):
|
|
90
|
+
if target.endswith(".delete"):
|
|
91
|
+
return {"outcome": "deny", "rules": ["no-deletes"], "reason": "agents may not delete"}
|
|
92
|
+
return {"outcome": "allow", "policy": "v3"}
|
|
93
|
+
|
|
94
|
+
rec = Recorder(log, agent="support-bot", principal="ops@acme.com", decide=decide)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A refused call never runs. It gets one receipt, and `PolicyDenied` is raised.
|
|
98
|
+
An `escalate` outcome is a denial here, recorded as `policy:no-approver`,
|
|
99
|
+
because nobody in the process can approve it.
|
|
100
|
+
|
|
101
|
+
The full policy language (budgets, rate limits, escalation to a person, Slack
|
|
102
|
+
approvals, monitor mode) lives in the `vw proxy` MCP proxy and in the
|
|
103
|
+
JavaScript `Recorder` in `@vouchwell/core`. For an agent that uses MCP, wrap
|
|
104
|
+
its servers with the proxy instead. A Python port of the policy engine is
|
|
105
|
+
planned.
|
|
106
|
+
|
|
107
|
+
### Use it with LangChain or the OpenAI Agents SDK
|
|
108
|
+
|
|
109
|
+
Wrap the agent's tools once; use the wrapped ones in their place. Each call is
|
|
110
|
+
checked with `decide`, recorded as intent and outcome, and a refused call
|
|
111
|
+
never runs: the model is told why in the tool's reply, as the MCP proxy does,
|
|
112
|
+
instead of the agent crashing.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from vouchwell.integrations.langchain import record_tools # pip install "vouchwell[langchain]"
|
|
116
|
+
agent = create_react_agent(model, record_tools(rec, [search, refund]))
|
|
117
|
+
|
|
118
|
+
from vouchwell.integrations.openai_agents import record_tools # pip install "vouchwell[openai-agents]"
|
|
119
|
+
agent = Agent(name="Support", tools=record_tools(rec, [lookup_order, refund]))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Hosted OpenAI tools (web search and the like) run on OpenAI's side, so they
|
|
123
|
+
are passed through unrecorded. Both adapters need Python 3.10+, and are tested
|
|
124
|
+
in CI against the real packages.
|
|
125
|
+
|
|
126
|
+
### Find calls that never finished
|
|
127
|
+
|
|
128
|
+
Each call's intent is on disk before the tool runs. If the process is killed
|
|
129
|
+
while a call is out, the intent has no outcome, and that is worth knowing:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from vouchwell import find_unfinished
|
|
133
|
+
|
|
134
|
+
found = find_unfinished(log.entries)
|
|
135
|
+
for u in found["unfinished"]:
|
|
136
|
+
print(u["seq"], u["ts"], u["target"], u["principal"])
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`inFlight` holds calls from the last five minutes that may still be running,
|
|
140
|
+
and `abandoned` the ones a recorder gave up on while shutting down. `vw verify`
|
|
141
|
+
reports the same list, and `vw verify --fail-on-unfinished` exits 3 when there
|
|
142
|
+
is one.
|
|
143
|
+
|
|
144
|
+
## Hand over evidence
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
import json
|
|
148
|
+
from vouchwell import verify_bundle
|
|
149
|
+
|
|
150
|
+
log.checkpoint() # sign the current state
|
|
151
|
+
open("evidence.json", "w").write(json.dumps(log.bundle()))
|
|
152
|
+
|
|
153
|
+
# Anyone can verify it, here or with `vw check evidence.json`:
|
|
154
|
+
verify_bundle(json.load(open("evidence.json")))
|
|
155
|
+
# -> {"ok": True, "issues": [], "checked": 12}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
To require an independent witness, pin its key. Get the key from somewhere the
|
|
159
|
+
bundle can't influence:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
verify_bundle(bundle, min_witnesses=1, trusted_witnesses={"pw1…": "<public key>"})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`vw report` (from the npm CLI) turns a log written here into an evidence pack
|
|
166
|
+
for auditors.
|
|
167
|
+
|
|
168
|
+
## Ship to a hub
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from vouchwell import push
|
|
172
|
+
|
|
173
|
+
push(log, "https://hub.acme.com", token=os.environ["VOUCHWELL_TOKEN"], name="support-bot")
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
It sends whatever the hub doesn't have yet, and is safe to call again. Plain
|
|
177
|
+
`http://` is refused unless the hub is on this machine, so the API key never
|
|
178
|
+
crosses a network unencrypted.
|
|
179
|
+
|
|
180
|
+
## What's here
|
|
181
|
+
|
|
182
|
+
| Module | |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| `canonical` | RFC 8785 canonical JSON: UTF-16 key order, ECMAScript number formatting |
|
|
185
|
+
| `merkle` | RFC 6962 trees, inclusion and consistency proofs |
|
|
186
|
+
| `unfinished` | `find_unfinished`: calls sent and never answered |
|
|
187
|
+
| `integrations` | `langchain.record_tools`, `openai_agents.record_tools` |
|
|
188
|
+
| `keys` | Ed25519 identities, canonical base64url |
|
|
189
|
+
| `receipt` | Build, seal, sign and verify receipts |
|
|
190
|
+
| `checkpoint` | Signed tree heads, witness countersignatures |
|
|
191
|
+
| `log` | `ProofLog`, the on-disk log the CLI shares, and `verify_bundle` |
|
|
192
|
+
| `record` | `Recorder`, for Python agents |
|
|
193
|
+
| `remote` | `push` to a hub |
|
|
194
|
+
|
|
195
|
+
Tested against the RFC 6962 Certificate Transparency reference tree and the
|
|
196
|
+
RFC 8032 Ed25519 vectors, plus byte-level canonicalization parity with the
|
|
197
|
+
JavaScript implementation over hundreds of awkward values.
|
|
198
|
+
|
|
199
|
+
License: Apache-2.0.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# vouchwell (Python)
|
|
2
|
+
|
|
3
|
+
Tamper-evident receipts for what your AI agents do, from Python. This is the
|
|
4
|
+
Python SDK for [Vouchwell](https://github.com/vouchwell/vouchwell), and it
|
|
5
|
+
writes exactly the format the `vouchwell` CLI reads:
|
|
6
|
+
|
|
7
|
+
- a log written here passes `vw verify`;
|
|
8
|
+
- a bundle exported here passes `vw check`;
|
|
9
|
+
- logs and bundles written by the JavaScript side open and verify here.
|
|
10
|
+
|
|
11
|
+
CI runs both directions against the real CLI on every commit.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install "vouchwell @ git+https://github.com/vouchwell/vouchwell#subdirectory=sdk/python"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That installs straight from this repository. The package is not on PyPI yet;
|
|
18
|
+
once it is, `pip install vouchwell` will do the same.
|
|
19
|
+
|
|
20
|
+
Python 3.9+. The one dependency is `cryptography`, for Ed25519.
|
|
21
|
+
|
|
22
|
+
## Record an agent's tool calls
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from vouchwell import ProofLog, Recorder
|
|
26
|
+
|
|
27
|
+
log = ProofLog.open_or_create(".vouchwell")
|
|
28
|
+
rec = Recorder(log, agent="support-bot", principal="ops@acme.com", namespace="stripe")
|
|
29
|
+
|
|
30
|
+
@rec.tool("refund", metrics=lambda order_id, amount_cents: {"amount_usd": amount_cents / 100})
|
|
31
|
+
def refund(order_id: str, amount_cents: int):
|
|
32
|
+
return stripe.Refund.create(payment_intent=order_id, amount=amount_cents)
|
|
33
|
+
|
|
34
|
+
refund("pi_123", 4200)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Each call leaves two signed receipts:
|
|
38
|
+
|
|
39
|
+
1. **An intent receipt, before the call runs.** A crash mid-call still leaves
|
|
40
|
+
evidence it was attempted.
|
|
41
|
+
2. **An outcome receipt, when it returns or raises**, linked to the intent.
|
|
42
|
+
|
|
43
|
+
Async functions work the same. So does a context manager:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
with rec.call("crm.update", params={"id": 42}) as call:
|
|
47
|
+
call.result = crm.update(42)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Arguments and results are **committed to, not stored**. A receipt holds a
|
|
51
|
+
salted hash plus a preview with secrets and personal data masked (API keys,
|
|
52
|
+
card numbers, emails and so on). The salts stay in the log's private
|
|
53
|
+
`salts.jsonl`, so you can later prove what a value was:
|
|
54
|
+
`log.reveal(seq, "params", value)`. Or you can destroy the salts, and the value
|
|
55
|
+
becomes unprovable forever while the receipt still verifies:
|
|
56
|
+
`log.shred(predicate)`.
|
|
57
|
+
|
|
58
|
+
### Check calls before they run
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
def decide(target, params):
|
|
62
|
+
if target.endswith(".delete"):
|
|
63
|
+
return {"outcome": "deny", "rules": ["no-deletes"], "reason": "agents may not delete"}
|
|
64
|
+
return {"outcome": "allow", "policy": "v3"}
|
|
65
|
+
|
|
66
|
+
rec = Recorder(log, agent="support-bot", principal="ops@acme.com", decide=decide)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A refused call never runs. It gets one receipt, and `PolicyDenied` is raised.
|
|
70
|
+
An `escalate` outcome is a denial here, recorded as `policy:no-approver`,
|
|
71
|
+
because nobody in the process can approve it.
|
|
72
|
+
|
|
73
|
+
The full policy language (budgets, rate limits, escalation to a person, Slack
|
|
74
|
+
approvals, monitor mode) lives in the `vw proxy` MCP proxy and in the
|
|
75
|
+
JavaScript `Recorder` in `@vouchwell/core`. For an agent that uses MCP, wrap
|
|
76
|
+
its servers with the proxy instead. A Python port of the policy engine is
|
|
77
|
+
planned.
|
|
78
|
+
|
|
79
|
+
### Use it with LangChain or the OpenAI Agents SDK
|
|
80
|
+
|
|
81
|
+
Wrap the agent's tools once; use the wrapped ones in their place. Each call is
|
|
82
|
+
checked with `decide`, recorded as intent and outcome, and a refused call
|
|
83
|
+
never runs: the model is told why in the tool's reply, as the MCP proxy does,
|
|
84
|
+
instead of the agent crashing.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from vouchwell.integrations.langchain import record_tools # pip install "vouchwell[langchain]"
|
|
88
|
+
agent = create_react_agent(model, record_tools(rec, [search, refund]))
|
|
89
|
+
|
|
90
|
+
from vouchwell.integrations.openai_agents import record_tools # pip install "vouchwell[openai-agents]"
|
|
91
|
+
agent = Agent(name="Support", tools=record_tools(rec, [lookup_order, refund]))
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Hosted OpenAI tools (web search and the like) run on OpenAI's side, so they
|
|
95
|
+
are passed through unrecorded. Both adapters need Python 3.10+, and are tested
|
|
96
|
+
in CI against the real packages.
|
|
97
|
+
|
|
98
|
+
### Find calls that never finished
|
|
99
|
+
|
|
100
|
+
Each call's intent is on disk before the tool runs. If the process is killed
|
|
101
|
+
while a call is out, the intent has no outcome, and that is worth knowing:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from vouchwell import find_unfinished
|
|
105
|
+
|
|
106
|
+
found = find_unfinished(log.entries)
|
|
107
|
+
for u in found["unfinished"]:
|
|
108
|
+
print(u["seq"], u["ts"], u["target"], u["principal"])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`inFlight` holds calls from the last five minutes that may still be running,
|
|
112
|
+
and `abandoned` the ones a recorder gave up on while shutting down. `vw verify`
|
|
113
|
+
reports the same list, and `vw verify --fail-on-unfinished` exits 3 when there
|
|
114
|
+
is one.
|
|
115
|
+
|
|
116
|
+
## Hand over evidence
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
import json
|
|
120
|
+
from vouchwell import verify_bundle
|
|
121
|
+
|
|
122
|
+
log.checkpoint() # sign the current state
|
|
123
|
+
open("evidence.json", "w").write(json.dumps(log.bundle()))
|
|
124
|
+
|
|
125
|
+
# Anyone can verify it, here or with `vw check evidence.json`:
|
|
126
|
+
verify_bundle(json.load(open("evidence.json")))
|
|
127
|
+
# -> {"ok": True, "issues": [], "checked": 12}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
To require an independent witness, pin its key. Get the key from somewhere the
|
|
131
|
+
bundle can't influence:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
verify_bundle(bundle, min_witnesses=1, trusted_witnesses={"pw1…": "<public key>"})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`vw report` (from the npm CLI) turns a log written here into an evidence pack
|
|
138
|
+
for auditors.
|
|
139
|
+
|
|
140
|
+
## Ship to a hub
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from vouchwell import push
|
|
144
|
+
|
|
145
|
+
push(log, "https://hub.acme.com", token=os.environ["VOUCHWELL_TOKEN"], name="support-bot")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
It sends whatever the hub doesn't have yet, and is safe to call again. Plain
|
|
149
|
+
`http://` is refused unless the hub is on this machine, so the API key never
|
|
150
|
+
crosses a network unencrypted.
|
|
151
|
+
|
|
152
|
+
## What's here
|
|
153
|
+
|
|
154
|
+
| Module | |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `canonical` | RFC 8785 canonical JSON: UTF-16 key order, ECMAScript number formatting |
|
|
157
|
+
| `merkle` | RFC 6962 trees, inclusion and consistency proofs |
|
|
158
|
+
| `unfinished` | `find_unfinished`: calls sent and never answered |
|
|
159
|
+
| `integrations` | `langchain.record_tools`, `openai_agents.record_tools` |
|
|
160
|
+
| `keys` | Ed25519 identities, canonical base64url |
|
|
161
|
+
| `receipt` | Build, seal, sign and verify receipts |
|
|
162
|
+
| `checkpoint` | Signed tree heads, witness countersignatures |
|
|
163
|
+
| `log` | `ProofLog`, the on-disk log the CLI shares, and `verify_bundle` |
|
|
164
|
+
| `record` | `Recorder`, for Python agents |
|
|
165
|
+
| `remote` | `push` to a hub |
|
|
166
|
+
|
|
167
|
+
Tested against the RFC 6962 Certificate Transparency reference tree and the
|
|
168
|
+
RFC 8032 Ed25519 vectors, plus byte-level canonicalization parity with the
|
|
169
|
+
JavaScript implementation over hundreds of awkward values.
|
|
170
|
+
|
|
171
|
+
License: Apache-2.0.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vouchwell"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "Tamper-evident receipts for AI agent actions. Python SDK, wire-compatible with the vouchwell CLI."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "IdeaboticsAI" }]
|
|
13
|
+
keywords = ["ai-agents", "audit", "transparency-log", "merkle", "ed25519", "eu-ai-act"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Security :: Cryptography",
|
|
20
|
+
"Topic :: System :: Logging",
|
|
21
|
+
]
|
|
22
|
+
# One dependency, for Ed25519: the standard library has none, and signature
|
|
23
|
+
# code is the last thing a security product should write itself.
|
|
24
|
+
dependencies = ["cryptography>=41"]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
# The framework adapters import their framework only when used; these extras
|
|
28
|
+
# install it alongside. Both frameworks need Python 3.10+.
|
|
29
|
+
langchain = ["langchain-core>=0.3"]
|
|
30
|
+
openai-agents = ["openai-agents>=0.2"]
|
|
31
|
+
test = [
|
|
32
|
+
"pytest>=7",
|
|
33
|
+
# So CI runs the adapters against the real frameworks where they install.
|
|
34
|
+
"langchain-core>=0.3; python_version >= '3.10'",
|
|
35
|
+
"openai-agents>=0.2; python_version >= '3.10'",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/vouchwell/vouchwell"
|
|
40
|
+
Source = "https://github.com/vouchwell/vouchwell/tree/main/sdk/python"
|
|
41
|
+
Issues = "https://github.com/vouchwell/vouchwell/issues"
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
vouchwell = ["py.typed"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
pythonpath = ["src"]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Vouchwell for Python: tamper-evident receipts for what AI agents do.
|
|
2
|
+
|
|
3
|
+
Wire-compatible with the ``vouchwell`` npm packages: a log written here
|
|
4
|
+
passes ``vw verify``, a bundle exported here passes ``vw check``, and logs
|
|
5
|
+
and bundles written by the JavaScript side open and verify here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.5.0"
|
|
9
|
+
|
|
10
|
+
from .canonical import canonical_bytes, canonicalize
|
|
11
|
+
from .checkpoint import build_checkpoint, checkpoint_digest, cosign, sign_checkpoint, verify_checkpoint
|
|
12
|
+
from .keys import Identity, generate_identity, identity_from_pem, identity_from_public_key, key_id_for, sign, verify
|
|
13
|
+
from .log import ProofLog, verify_bundle
|
|
14
|
+
from .merkle import (
|
|
15
|
+
MerkleTree,
|
|
16
|
+
consistency_proof,
|
|
17
|
+
inclusion_proof,
|
|
18
|
+
leaf_hash,
|
|
19
|
+
merkle_root,
|
|
20
|
+
node_hash,
|
|
21
|
+
verify_consistency,
|
|
22
|
+
verify_inclusion,
|
|
23
|
+
)
|
|
24
|
+
from .record import PolicyDenied, Recorder
|
|
25
|
+
from .redact import redact
|
|
26
|
+
from .receipt import (
|
|
27
|
+
GENESIS_PREV,
|
|
28
|
+
build_receipt,
|
|
29
|
+
entry_hash,
|
|
30
|
+
open_seal,
|
|
31
|
+
receipt_digest,
|
|
32
|
+
seal,
|
|
33
|
+
sign_receipt,
|
|
34
|
+
verify_chain,
|
|
35
|
+
verify_receipt,
|
|
36
|
+
)
|
|
37
|
+
from .remote import HubError, push
|
|
38
|
+
from .unfinished import DEFAULT_GRACE_MS, find_unfinished
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"__version__",
|
|
42
|
+
"DEFAULT_GRACE_MS",
|
|
43
|
+
"GENESIS_PREV",
|
|
44
|
+
"HubError",
|
|
45
|
+
"Identity",
|
|
46
|
+
"MerkleTree",
|
|
47
|
+
"PolicyDenied",
|
|
48
|
+
"ProofLog",
|
|
49
|
+
"Recorder",
|
|
50
|
+
"build_checkpoint",
|
|
51
|
+
"build_receipt",
|
|
52
|
+
"canonical_bytes",
|
|
53
|
+
"canonicalize",
|
|
54
|
+
"checkpoint_digest",
|
|
55
|
+
"consistency_proof",
|
|
56
|
+
"cosign",
|
|
57
|
+
"entry_hash",
|
|
58
|
+
"find_unfinished",
|
|
59
|
+
"generate_identity",
|
|
60
|
+
"identity_from_pem",
|
|
61
|
+
"identity_from_public_key",
|
|
62
|
+
"inclusion_proof",
|
|
63
|
+
"key_id_for",
|
|
64
|
+
"leaf_hash",
|
|
65
|
+
"merkle_root",
|
|
66
|
+
"node_hash",
|
|
67
|
+
"open_seal",
|
|
68
|
+
"push",
|
|
69
|
+
"receipt_digest",
|
|
70
|
+
"redact",
|
|
71
|
+
"seal",
|
|
72
|
+
"sign",
|
|
73
|
+
"sign_checkpoint",
|
|
74
|
+
"sign_receipt",
|
|
75
|
+
"verify",
|
|
76
|
+
"verify_bundle",
|
|
77
|
+
"verify_chain",
|
|
78
|
+
"verify_checkpoint",
|
|
79
|
+
"verify_consistency",
|
|
80
|
+
"verify_inclusion",
|
|
81
|
+
"verify_receipt",
|
|
82
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""RFC 8785 (JCS) canonical JSON, byte-identical to packages/core/src/canonical.js.
|
|
2
|
+
|
|
3
|
+
Two implementations must produce the same bytes for the same receipt, or a
|
|
4
|
+
receipt signed in Python would not verify in JavaScript. Three details decide
|
|
5
|
+
that, and each is matched here exactly:
|
|
6
|
+
|
|
7
|
+
* object keys sort by UTF-16 code unit, not by code point;
|
|
8
|
+
* control characters escape as lowercase ``\\u00xx``, with the short forms for
|
|
9
|
+
``\\b \\t \\n \\f \\r`` and ``"`` and ``\\``, and everything else is literal;
|
|
10
|
+
* numbers are written the way ECMAScript's ``Number::toString`` writes them,
|
|
11
|
+
with ``-0`` as ``0``, and NaN and infinities refused.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
__all__ = ["canonicalize", "canonical_bytes"]
|
|
21
|
+
|
|
22
|
+
_ESCAPES = {
|
|
23
|
+
0x08: "\\b",
|
|
24
|
+
0x09: "\\t",
|
|
25
|
+
0x0A: "\\n",
|
|
26
|
+
0x0C: "\\f",
|
|
27
|
+
0x0D: "\\r",
|
|
28
|
+
0x22: '\\"',
|
|
29
|
+
0x5C: "\\\\",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#: The largest integer every JSON implementation represents exactly.
|
|
33
|
+
_MAX_SAFE_INTEGER = 2**53 - 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _encode_string(s: str) -> str:
|
|
37
|
+
out = ['"']
|
|
38
|
+
for ch in s:
|
|
39
|
+
cp = ord(ch)
|
|
40
|
+
esc = _ESCAPES.get(cp)
|
|
41
|
+
if esc is not None:
|
|
42
|
+
out.append(esc)
|
|
43
|
+
elif cp < 0x20:
|
|
44
|
+
out.append("\\u%04x" % cp)
|
|
45
|
+
elif 0xD800 <= cp <= 0xDFFF:
|
|
46
|
+
# A lone surrogate has no UTF-8 form. JavaScript's encoder writes
|
|
47
|
+
# U+FFFD in its place, so the bytes hashed there are these.
|
|
48
|
+
out.append("�")
|
|
49
|
+
else:
|
|
50
|
+
out.append(ch)
|
|
51
|
+
out.append('"')
|
|
52
|
+
return "".join(out)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _es_number(x: float) -> str:
|
|
56
|
+
"""ECMAScript Number::toString for a finite, non-zero float.
|
|
57
|
+
|
|
58
|
+
Python's ``repr`` already finds the shortest digit string that round-trips,
|
|
59
|
+
which is what ECMAScript uses too; only the layout differs.
|
|
60
|
+
"""
|
|
61
|
+
sign = "-" if x < 0 else ""
|
|
62
|
+
d = Decimal(repr(abs(x)))
|
|
63
|
+
_, digits, exponent = d.as_tuple()
|
|
64
|
+
s = "".join(map(str, digits)).rstrip("0")
|
|
65
|
+
exponent += len(digits) - len(s) # account for the zeros just stripped
|
|
66
|
+
k = len(s)
|
|
67
|
+
n = exponent + k # the value is 0.s × 10^n
|
|
68
|
+
|
|
69
|
+
if k <= n <= 21:
|
|
70
|
+
body = s + "0" * (n - k)
|
|
71
|
+
elif 0 < n <= 21:
|
|
72
|
+
body = s[:n] + "." + s[n:]
|
|
73
|
+
elif -6 < n <= 0:
|
|
74
|
+
body = "0." + "0" * (-n) + s
|
|
75
|
+
else:
|
|
76
|
+
e = n - 1
|
|
77
|
+
exp = ("+" if e > 0 else "-") + str(abs(e))
|
|
78
|
+
body = (s if k == 1 else s[0] + "." + s[1:]) + "e" + exp
|
|
79
|
+
return sign + body
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _encode_number(n: Any) -> str:
|
|
83
|
+
if isinstance(n, bool): # bool is an int subclass; never a number here
|
|
84
|
+
raise TypeError("booleans are not numbers")
|
|
85
|
+
if isinstance(n, int):
|
|
86
|
+
if abs(n) <= _MAX_SAFE_INTEGER:
|
|
87
|
+
return str(n)
|
|
88
|
+
# Beyond 2**53 a JSON number is a double everywhere else, so it is
|
|
89
|
+
# treated as one here: the same value JavaScript would parse and print.
|
|
90
|
+
try:
|
|
91
|
+
n = float(n)
|
|
92
|
+
except OverflowError:
|
|
93
|
+
raise TypeError(f"cannot canonicalize {n}: too large for a JSON number") from None
|
|
94
|
+
if not math.isfinite(n):
|
|
95
|
+
raise TypeError(f"cannot canonicalize non-finite number: {n}")
|
|
96
|
+
if n == 0:
|
|
97
|
+
return "0"
|
|
98
|
+
if n.is_integer() and abs(n) <= _MAX_SAFE_INTEGER:
|
|
99
|
+
return str(int(n))
|
|
100
|
+
# Everything else, including whole numbers above 2**53, which ECMAScript
|
|
101
|
+
# prints as shortest round-trip digits padded with zeros, not exactly.
|
|
102
|
+
return _es_number(n)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _sort_key(key: str) -> bytes:
|
|
106
|
+
# Big-endian UTF-16 bytes compare in the same order as UTF-16 code units,
|
|
107
|
+
# which is how JavaScript's default sort orders strings.
|
|
108
|
+
return key.encode("utf-16-be", "surrogatepass")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def canonicalize(value: Any) -> str:
|
|
112
|
+
"""The RFC 8785 canonical form of a JSON-compatible value."""
|
|
113
|
+
if value is None:
|
|
114
|
+
return "null"
|
|
115
|
+
if value is True:
|
|
116
|
+
return "true"
|
|
117
|
+
if value is False:
|
|
118
|
+
return "false"
|
|
119
|
+
if isinstance(value, (int, float)):
|
|
120
|
+
return _encode_number(value)
|
|
121
|
+
if isinstance(value, str):
|
|
122
|
+
return _encode_string(value)
|
|
123
|
+
if isinstance(value, (list, tuple)):
|
|
124
|
+
return "[" + ",".join(canonicalize(v) for v in value) + "]"
|
|
125
|
+
if isinstance(value, dict):
|
|
126
|
+
for k in value:
|
|
127
|
+
if not isinstance(k, str):
|
|
128
|
+
raise TypeError(f"object keys must be strings, got {type(k).__name__}")
|
|
129
|
+
parts = [
|
|
130
|
+
_encode_string(k) + ":" + canonicalize(value[k])
|
|
131
|
+
for k in sorted(value, key=_sort_key)
|
|
132
|
+
]
|
|
133
|
+
return "{" + ",".join(parts) + "}"
|
|
134
|
+
raise TypeError(f"cannot canonicalize {type(value).__name__}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def canonical_bytes(value: Any) -> bytes:
|
|
138
|
+
"""Canonical form as UTF-8: what actually gets hashed and signed."""
|
|
139
|
+
return canonicalize(value).encode("utf-8")
|