vouchwell 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vouchwell/__init__.py +82 -0
- vouchwell/canonical.py +139 -0
- vouchwell/checkpoint.py +100 -0
- vouchwell/hashing.py +72 -0
- vouchwell/integrations/__init__.py +26 -0
- vouchwell/integrations/langchain.py +93 -0
- vouchwell/integrations/openai_agents.py +55 -0
- vouchwell/keys.py +84 -0
- vouchwell/log.py +453 -0
- vouchwell/merkle.py +200 -0
- vouchwell/py.typed +0 -0
- vouchwell/receipt.py +200 -0
- vouchwell/record.py +210 -0
- vouchwell/redact.py +126 -0
- vouchwell/remote.py +134 -0
- vouchwell/unfinished.py +91 -0
- vouchwell-0.5.0.dist-info/METADATA +199 -0
- vouchwell-0.5.0.dist-info/RECORD +20 -0
- vouchwell-0.5.0.dist-info/WHEEL +5 -0
- vouchwell-0.5.0.dist-info/top_level.txt +1 -0
vouchwell/__init__.py
ADDED
|
@@ -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
|
+
]
|
vouchwell/canonical.py
ADDED
|
@@ -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")
|
vouchwell/checkpoint.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Signed checkpoints: a log's size, root and head at a moment, signed by the
|
|
2
|
+
log and countersigned by witnesses. Mirrors packages/core/src/checkpoint.js."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .hashing import CHECKPOINT_PREFIX, hash_object
|
|
9
|
+
from .keys import Identity, sign, verify
|
|
10
|
+
from .receipt import now_iso
|
|
11
|
+
|
|
12
|
+
CHECKPOINT_VERSION = 1
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_checkpoint(*, log: str, size: int, root: str, head: str, ts: Optional[str] = None) -> dict:
|
|
16
|
+
return {"v": CHECKPOINT_VERSION, "log": log, "size": size, "root": root, "head": head, "ts": ts or now_iso()}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def checkpoint_digest(body: dict) -> bytes:
|
|
20
|
+
return hash_object(CHECKPOINT_PREFIX, body)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sign_checkpoint(identity: Identity, body: dict, role: str = "log") -> dict:
|
|
24
|
+
return {"body": body, "sigs": [{"role": role, "kid": identity.kid, "sig": sign(identity, checkpoint_digest(body))}]}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cosign(checkpoint: dict, witness: Identity) -> dict:
|
|
28
|
+
"""Add (or replace) a witness signature."""
|
|
29
|
+
sig = sign(witness, checkpoint_digest(checkpoint["body"]))
|
|
30
|
+
others = [s for s in checkpoint["sigs"] if s.get("kid") != witness.kid]
|
|
31
|
+
return {"body": checkpoint["body"], "sigs": [*others, {"role": "witness", "kid": witness.kid, "sig": sig, "ts": now_iso()}]}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verify_checkpoint(
|
|
35
|
+
checkpoint: dict,
|
|
36
|
+
keyring: dict,
|
|
37
|
+
min_witnesses: int = 0,
|
|
38
|
+
trusted_witnesses: Optional[dict] = None,
|
|
39
|
+
) -> dict:
|
|
40
|
+
"""Check a checkpoint's signatures.
|
|
41
|
+
|
|
42
|
+
With ``trusted_witnesses`` (kid → public key, obtained from outside the
|
|
43
|
+
evidence), witness signatures are checked against those keys only, and
|
|
44
|
+
witnesses not on the list are ignored rather than counted. Without it,
|
|
45
|
+
witness signatures are checked against ``keyring`` and count only as a
|
|
46
|
+
claim; ``min_witnesses`` then cannot be met from the evidence itself.
|
|
47
|
+
"""
|
|
48
|
+
issues: list[str] = []
|
|
49
|
+
signers: list[str] = []
|
|
50
|
+
witnesses = 0
|
|
51
|
+
pinned = isinstance(trusted_witnesses, dict)
|
|
52
|
+
if not isinstance(checkpoint, dict) or not isinstance(checkpoint.get("body"), dict) or not isinstance(checkpoint.get("sigs"), list):
|
|
53
|
+
return {"ok": False, "issues": ["malformed checkpoint"], "signers": signers, "witnesses": 0, "pinned": pinned}
|
|
54
|
+
body = checkpoint["body"]
|
|
55
|
+
if body.get("v") != CHECKPOINT_VERSION:
|
|
56
|
+
issues.append(f"unsupported checkpoint version {body.get('v')}")
|
|
57
|
+
digest = checkpoint_digest(body)
|
|
58
|
+
has_log_sig = False
|
|
59
|
+
# Each witness counts once, however often its signature is repeated;
|
|
60
|
+
# keyed by public key, so one key pinned under two names is one witness.
|
|
61
|
+
counted: set = set()
|
|
62
|
+
for s in checkpoint["sigs"]:
|
|
63
|
+
if not isinstance(s, dict):
|
|
64
|
+
issues.append("malformed signature entry")
|
|
65
|
+
continue
|
|
66
|
+
kid, role = s.get("kid"), s.get("role")
|
|
67
|
+
if pinned and role == "witness":
|
|
68
|
+
if kid not in trusted_witnesses:
|
|
69
|
+
continue
|
|
70
|
+
if trusted_witnesses[kid] in counted:
|
|
71
|
+
continue
|
|
72
|
+
if verify(trusted_witnesses[kid], digest, s.get("sig", "")):
|
|
73
|
+
counted.add(trusted_witnesses[kid])
|
|
74
|
+
signers.append(kid)
|
|
75
|
+
witnesses += 1
|
|
76
|
+
else:
|
|
77
|
+
issues.append(f"invalid witness signature from {kid}")
|
|
78
|
+
continue
|
|
79
|
+
if pinned and role == "log" and kid in trusted_witnesses:
|
|
80
|
+
issues.append(f"signature from pinned witness {kid} is labelled as the log's")
|
|
81
|
+
continue
|
|
82
|
+
if kid not in keyring:
|
|
83
|
+
issues.append(f"no public key for signer {kid}")
|
|
84
|
+
continue
|
|
85
|
+
if not verify(keyring[kid], digest, s.get("sig", "")):
|
|
86
|
+
issues.append(f"invalid {role} signature from {kid}")
|
|
87
|
+
continue
|
|
88
|
+
if role == "witness":
|
|
89
|
+
if keyring[kid] in counted:
|
|
90
|
+
continue
|
|
91
|
+
counted.add(keyring[kid])
|
|
92
|
+
witnesses += 1 # unpinned: a claim, not evidence
|
|
93
|
+
signers.append(kid)
|
|
94
|
+
if role == "log":
|
|
95
|
+
has_log_sig = True
|
|
96
|
+
if not has_log_sig:
|
|
97
|
+
issues.append("checkpoint carries no valid log signature")
|
|
98
|
+
if witnesses < min_witnesses:
|
|
99
|
+
issues.append(f"only {witnesses} valid witness signature(s), policy requires {min_witnesses}")
|
|
100
|
+
return {"ok": not issues, "issues": issues, "signers": signers, "witnesses": witnesses, "pinned": pinned}
|
vouchwell/hashing.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Domain-separated SHA-256, and the byte encodings the wire format uses.
|
|
2
|
+
|
|
3
|
+
Mirrors packages/core/src/hash.js. Every hash carries a one-byte domain tag,
|
|
4
|
+
so a leaf can never also be read as an interior node (the second-preimage
|
|
5
|
+
attack RFC 6962 closed), nor a receipt digest as a checkpoint digest.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .canonical import canonical_bytes
|
|
17
|
+
|
|
18
|
+
LEAF_PREFIX = b"\x00"
|
|
19
|
+
NODE_PREFIX = b"\x01"
|
|
20
|
+
RECEIPT_PREFIX = b"\x02"
|
|
21
|
+
CHECKPOINT_PREFIX = b"\x03"
|
|
22
|
+
|
|
23
|
+
_HEX = re.compile(r"^[0-9a-f]*$")
|
|
24
|
+
_B64URL = re.compile(r"^[A-Za-z0-9_-]*$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sha256(*parts: bytes) -> bytes:
|
|
28
|
+
h = hashlib.sha256()
|
|
29
|
+
for p in parts:
|
|
30
|
+
h.update(p)
|
|
31
|
+
return h.digest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def hash_object(prefix: bytes, value: Any) -> bytes:
|
|
35
|
+
"""SHA-256 of a value's canonical form, under a domain tag."""
|
|
36
|
+
return sha256(prefix, canonical_bytes(value))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def to_hex(b: bytes) -> str:
|
|
40
|
+
return b.hex()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def from_hex(s: str) -> bytes:
|
|
44
|
+
"""Lowercase hex only, as the wire format writes it."""
|
|
45
|
+
if not isinstance(s, str) or not _HEX.match(s) or len(s) % 2:
|
|
46
|
+
raise ValueError(f"not lowercase hex: {str(s)[:32]!r}")
|
|
47
|
+
return bytes.fromhex(s)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def b64url(b: bytes) -> str:
|
|
51
|
+
"""Unpadded base64url."""
|
|
52
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def from_b64url(s: str) -> bytes:
|
|
56
|
+
"""Decode unpadded base64url, accepting only the one canonical spelling.
|
|
57
|
+
|
|
58
|
+
A final character with unused bits set (``QB`` where ``QA`` is canonical)
|
|
59
|
+
decodes to the same bytes, so without this check two strings would name the
|
|
60
|
+
same key or signature. The JavaScript verifier refuses them; so does this.
|
|
61
|
+
"""
|
|
62
|
+
if not isinstance(s, str) or not _B64URL.match(s) or len(s) % 4 == 1:
|
|
63
|
+
raise ValueError("not base64url")
|
|
64
|
+
raw = base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
65
|
+
if b64url(raw) != s:
|
|
66
|
+
raise ValueError("not canonical base64url")
|
|
67
|
+
return raw
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def equal_bytes(a: bytes, b: bytes) -> bool:
|
|
71
|
+
"""Constant-time comparison."""
|
|
72
|
+
return hmac.compare_digest(a, b)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Adapters for agent frameworks. Each module imports its framework lazily, so
|
|
2
|
+
``vouchwell`` itself still needs nothing but ``cryptography``.
|
|
3
|
+
|
|
4
|
+
- ``vouchwell.integrations.langchain``: ``record_tools`` for LangChain /
|
|
5
|
+
LangGraph tools.
|
|
6
|
+
- ``vouchwell.integrations.openai_agents``: ``record_tools`` for the OpenAI
|
|
7
|
+
Agents SDK's function tools.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..record import PolicyDenied
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def refusal(err: PolicyDenied) -> str:
|
|
18
|
+
"""What the model is told when a call is refused: the same words the MCP
|
|
19
|
+
proxy sends, so an agent behaves the same whichever way it is wrapped."""
|
|
20
|
+
d: Any = err.decision
|
|
21
|
+
rules = ", ".join(d.get("rules") or []) or "none"
|
|
22
|
+
return (
|
|
23
|
+
f"Blocked by Vouchwell policy. {d.get('reason') or ''}\n"
|
|
24
|
+
f"Rules: {rules}\n"
|
|
25
|
+
f"This refusal is recorded as receipt {err.receipt['seq']} in log {err.receipt['log']}."
|
|
26
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Record, and check, every call a LangChain or LangGraph agent makes to its tools.
|
|
2
|
+
|
|
3
|
+
from vouchwell import ProofLog, Recorder
|
|
4
|
+
from vouchwell.integrations.langchain import record_tools
|
|
5
|
+
|
|
6
|
+
rec = Recorder(ProofLog.open(".vouchwell"), agent="support-bot", principal="ops@acme.com", decide=my_rules)
|
|
7
|
+
tools = record_tools(rec, [search, refund]) # use these in place of the originals
|
|
8
|
+
agent = create_react_agent(model, tools)
|
|
9
|
+
|
|
10
|
+
Each wrapped tool records an intent before it runs and an outcome after,
|
|
11
|
+
exactly as ``Recorder.tool`` does. A call the ``decide`` hook refuses never
|
|
12
|
+
runs, and the model is told why, in the tool's reply, rather than the agent
|
|
13
|
+
crashing: the MCP proxy's behaviour.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Sequence
|
|
19
|
+
|
|
20
|
+
from ..record import PolicyDenied, Recorder
|
|
21
|
+
from . import refusal
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
from langchain_core.tools import BaseTool, StructuredTool
|
|
25
|
+
except ImportError as err: # pragma: no cover - exercised only without the package
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"vouchwell.integrations.langchain needs langchain-core: pip install langchain-core"
|
|
28
|
+
) from err
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def record_tools(recorder: Recorder, tools: Sequence[BaseTool]) -> list[BaseTool]:
|
|
32
|
+
"""Wrap each tool; returns new tools with the same names, descriptions and
|
|
33
|
+
argument schemas. The originals are untouched."""
|
|
34
|
+
return [record_tool(recorder, t) for t in tools]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def record_tool(recorder: Recorder, tool: BaseTool) -> BaseTool:
|
|
38
|
+
"""Wrap one tool. See ``record_tools``."""
|
|
39
|
+
if not isinstance(tool, BaseTool):
|
|
40
|
+
raise TypeError(f"record_tool expects a LangChain BaseTool, got {type(tool).__name__}")
|
|
41
|
+
structured = tool.args_schema is not None
|
|
42
|
+
|
|
43
|
+
def params_of(args: tuple, kwargs: dict) -> Any:
|
|
44
|
+
return dict(kwargs) if structured else (args[0] if args else kwargs.get("tool_input", ""))
|
|
45
|
+
|
|
46
|
+
def invoke_input(args: tuple, kwargs: dict) -> Any:
|
|
47
|
+
return dict(kwargs) if structured else params_of(args, kwargs)
|
|
48
|
+
|
|
49
|
+
def run(*args: Any, **kwargs: Any) -> Any:
|
|
50
|
+
try:
|
|
51
|
+
with recorder.call(tool.name, params_of(args, kwargs)) as c:
|
|
52
|
+
c.result = _content(tool.invoke(invoke_input(args, kwargs)))
|
|
53
|
+
return c.result
|
|
54
|
+
except PolicyDenied as err:
|
|
55
|
+
return refusal(err)
|
|
56
|
+
|
|
57
|
+
async def arun(*args: Any, **kwargs: Any) -> Any:
|
|
58
|
+
try:
|
|
59
|
+
with recorder.call(tool.name, params_of(args, kwargs)) as c:
|
|
60
|
+
c.result = _content(await tool.ainvoke(invoke_input(args, kwargs)))
|
|
61
|
+
return c.result
|
|
62
|
+
except PolicyDenied as err:
|
|
63
|
+
return refusal(err)
|
|
64
|
+
|
|
65
|
+
if structured:
|
|
66
|
+
return StructuredTool.from_function(
|
|
67
|
+
func=run,
|
|
68
|
+
coroutine=arun,
|
|
69
|
+
name=tool.name,
|
|
70
|
+
description=tool.description,
|
|
71
|
+
args_schema=tool.args_schema,
|
|
72
|
+
return_direct=tool.return_direct,
|
|
73
|
+
infer_schema=False,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def run_text(tool_input: str) -> Any:
|
|
77
|
+
return run(tool_input)
|
|
78
|
+
|
|
79
|
+
async def arun_text(tool_input: str) -> Any:
|
|
80
|
+
return await arun(tool_input)
|
|
81
|
+
|
|
82
|
+
return StructuredTool.from_function(
|
|
83
|
+
func=run_text,
|
|
84
|
+
coroutine=arun_text,
|
|
85
|
+
name=tool.name,
|
|
86
|
+
description=tool.description,
|
|
87
|
+
return_direct=tool.return_direct,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _content(output: Any) -> Any:
|
|
92
|
+
"""A ToolMessage is recorded as what it carries."""
|
|
93
|
+
return getattr(output, "content", output)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Record, and check, every call an OpenAI Agents SDK agent makes to its function tools.
|
|
2
|
+
|
|
3
|
+
from agents import Agent, function_tool
|
|
4
|
+
from vouchwell import ProofLog, Recorder
|
|
5
|
+
from vouchwell.integrations.openai_agents import record_tools
|
|
6
|
+
|
|
7
|
+
rec = Recorder(ProofLog.open(".vouchwell"), agent="support-bot", principal="ops@acme.com", decide=my_rules)
|
|
8
|
+
agent = Agent(name="Support", tools=record_tools(rec, [lookup_order, refund]))
|
|
9
|
+
|
|
10
|
+
Each wrapped tool records an intent before it runs and an outcome after. A
|
|
11
|
+
call the ``decide`` hook refuses never runs; the model is told why in the
|
|
12
|
+
tool's reply. Tools that are not function tools (hosted tools such as web
|
|
13
|
+
search run on OpenAI's side) are passed through unchanged, since nothing
|
|
14
|
+
here sees them run.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
import json
|
|
21
|
+
from typing import Any, Sequence
|
|
22
|
+
|
|
23
|
+
from ..record import PolicyDenied, Recorder
|
|
24
|
+
from . import refusal
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from agents import FunctionTool
|
|
28
|
+
except ImportError as err: # pragma: no cover - exercised only without the package
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"vouchwell.integrations.openai_agents needs the OpenAI Agents SDK: pip install openai-agents"
|
|
31
|
+
) from err
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def record_tools(recorder: Recorder, tools: Sequence[Any]) -> list[Any]:
|
|
35
|
+
"""Wrap each function tool; returns new tools. The originals are untouched."""
|
|
36
|
+
return [record_tool(recorder, t) if isinstance(t, FunctionTool) else t for t in tools]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def record_tool(recorder: Recorder, tool: FunctionTool) -> FunctionTool:
|
|
40
|
+
"""Wrap one function tool. See ``record_tools``."""
|
|
41
|
+
original = tool.on_invoke_tool
|
|
42
|
+
|
|
43
|
+
async def on_invoke_tool(ctx: Any, input_json: str) -> Any:
|
|
44
|
+
try:
|
|
45
|
+
params: Any = json.loads(input_json) if input_json else {}
|
|
46
|
+
except ValueError:
|
|
47
|
+
params = {"raw": input_json}
|
|
48
|
+
try:
|
|
49
|
+
with recorder.call(tool.name, params) as c:
|
|
50
|
+
c.result = await original(ctx, input_json)
|
|
51
|
+
return c.result
|
|
52
|
+
except PolicyDenied as err:
|
|
53
|
+
return refusal(err)
|
|
54
|
+
|
|
55
|
+
return dataclasses.replace(tool, on_invoke_tool=on_invoke_tool)
|
vouchwell/keys.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Ed25519 identities, matching packages/core/src/keys.js.
|
|
2
|
+
|
|
3
|
+
A key id is ``pw1`` plus the first 16 bytes of SHA-256 over the raw 32-byte
|
|
4
|
+
public key, in hex. Public keys and signatures travel as canonical, unpadded
|
|
5
|
+
base64url. Private keys are stored as PKCS#8 PEM, the same file the
|
|
6
|
+
JavaScript side writes, so either can open a log the other created.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from cryptography.exceptions import InvalidSignature
|
|
15
|
+
from cryptography.hazmat.primitives import serialization
|
|
16
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
17
|
+
|
|
18
|
+
from .hashing import b64url, from_b64url, sha256
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Identity:
|
|
23
|
+
kid: str
|
|
24
|
+
public_key: str
|
|
25
|
+
private_key: Optional[Ed25519PrivateKey] = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def can_sign(self) -> bool:
|
|
29
|
+
return self.private_key is not None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def key_id_for(raw_public: bytes) -> str:
|
|
33
|
+
return "pw1" + sha256(raw_public)[:16].hex()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _raw(pub: Ed25519PublicKey) -> bytes:
|
|
37
|
+
return pub.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_identity() -> tuple[Identity, str]:
|
|
41
|
+
"""A new identity, and its private key as PKCS#8 PEM for storing."""
|
|
42
|
+
key = Ed25519PrivateKey.generate()
|
|
43
|
+
raw = _raw(key.public_key())
|
|
44
|
+
pem = key.private_bytes(
|
|
45
|
+
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
|
|
46
|
+
).decode("ascii")
|
|
47
|
+
return Identity(key_id_for(raw), b64url(raw), key), pem
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def identity_from_pem(pem: str) -> Identity:
|
|
51
|
+
key = serialization.load_pem_private_key(pem.encode("ascii"), password=None)
|
|
52
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
53
|
+
raise TypeError(f"expected an Ed25519 private key, got {type(key).__name__}")
|
|
54
|
+
raw = _raw(key.public_key())
|
|
55
|
+
return Identity(key_id_for(raw), b64url(raw), key)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def identity_from_public_key(public_key: str) -> Identity:
|
|
59
|
+
raw = from_b64url(public_key)
|
|
60
|
+
if len(raw) != 32:
|
|
61
|
+
raise ValueError(f"Ed25519 public key must be 32 bytes, got {len(raw)}")
|
|
62
|
+
return Identity(key_id_for(raw), b64url(raw))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sign(identity: Identity, message: bytes) -> str:
|
|
66
|
+
if identity.private_key is None:
|
|
67
|
+
raise ValueError(f"identity {identity.kid} is verify-only; cannot sign")
|
|
68
|
+
return b64url(identity.private_key.sign(message))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def verify(public_key: str, message: bytes, signature: str) -> bool:
|
|
72
|
+
"""Never raises: a malformed key or signature is simply not valid."""
|
|
73
|
+
try:
|
|
74
|
+
sig = from_b64url(signature)
|
|
75
|
+
raw = from_b64url(public_key)
|
|
76
|
+
except (ValueError, TypeError):
|
|
77
|
+
return False
|
|
78
|
+
if len(sig) != 64 or len(raw) != 32:
|
|
79
|
+
return False
|
|
80
|
+
try:
|
|
81
|
+
Ed25519PublicKey.from_public_bytes(raw).verify(sig, message)
|
|
82
|
+
return True
|
|
83
|
+
except (InvalidSignature, ValueError):
|
|
84
|
+
return False
|