signafaide 0.2.1__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.
signafaide/__init__.py ADDED
@@ -0,0 +1,263 @@
1
+ """signafAIde: signed, chained records of what your AI agents decided.
2
+
3
+ import signafaide
4
+
5
+ @signafaide.record
6
+ def approve_payout(claim_id: str, amount: float): ...
7
+
8
+ Every call writes a ``decision`` record before the function runs and an
9
+ ``outcome`` record after it. Records are signed with the agent's own key and
10
+ chained locally in ``.signafaide/``. Nothing is sent until a human opts in
11
+ (``signafaide init``, or SIGNAFAIDE_API_KEY); then records ship to the service
12
+ in the background. Only hashes leave the machine unless ``store_content=True``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any, Callable, Optional, Union
19
+
20
+ from ._plural import plural
21
+
22
+ from . import runtime
23
+ from ._version import __version__
24
+ from .drand import load_verifier as load_drand_verifier
25
+ from .decorator import NotRecorded, note, record, to_jsonable, wrap_tool, wrap_tools
26
+ from .experiments import ExperimentReport, VerificationFailed, compare
27
+ from .http import SignafaideError
28
+ from .replay import Replay, replay
29
+ from .runtime import Recorder, recording_disabled, test_mode
30
+ from .bitcoin import DEFAULT_URL as DEFAULT_TIMESTAMP_URL, lookup as lookup_timestamps
31
+ from .verifier import (
32
+ DRAND_NOT_CHECKED, KeysRequired, Report, bitcoin_heights, is_loopback_url, load_agent_keys, load_content, load_heads, load_service_keys,
33
+ loads as _loads, plain_http, verify_local, verify_pack,
34
+ )
35
+
36
+ __all__ = [
37
+ "recording_disabled",
38
+ "test_mode",
39
+ "init",
40
+ "record",
41
+ "wrap_tools",
42
+ "wrap_tool",
43
+ "note",
44
+ "flush",
45
+ "stop",
46
+ "verify",
47
+ "status",
48
+ "replay",
49
+ "compare",
50
+ "Replay",
51
+ "ExperimentReport",
52
+ "VerificationFailed",
53
+ "Recorder",
54
+ "Report",
55
+ "SignafaideError",
56
+ "NotRecorded",
57
+ "KeysRequired",
58
+ "to_jsonable",
59
+ "__version__",
60
+ ]
61
+
62
+
63
+ def not_yet(unsent: int, total: int, has_key: bool) -> list:
64
+ """What the local verify could not check because the records have not reached a witness yet."""
65
+ if not unsent:
66
+ return []
67
+ what = f"all {total}" if unsent == total else f"{unsent} of {total}"
68
+ return [
69
+ f"checked here: signatures, chain links and content hashes of {what} record{'' if total == 1 else 's'}, using this machine's keys",
70
+ f"co-signature, public timestamp: not yet for {what} (`signafaide flush` sends them now, or says why it cannot)"
71
+ if has_key
72
+ else "co-signature, public timestamp: not yet. Run `signafaide init` to send hashes, or self-host (SIGNAFAIDE_URL)",
73
+ ]
74
+
75
+
76
+ def init(
77
+ *,
78
+ url: Optional[str] = None,
79
+ api_key: Optional[str] = None,
80
+ home: Optional[str] = None,
81
+ store_content: bool = False,
82
+ quiet: bool = False,
83
+ key_provider: Optional[Callable[[str, str], Any]] = None,
84
+ exit_flush: Optional[float] = None,
85
+ head_file: Optional[str] = None,
86
+ on_head: Optional[Callable[[list], Any]] = None,
87
+ ) -> Recorder:
88
+ """Configure signafAIde (optional). Finds SIGNAFAIDE_API_KEY (env or .env).
89
+ Without one, records stay local (signed, chained, verifiable in ``.signafaide/``)
90
+ and nothing is sent: it never creates a sandbox. A human opts in with
91
+ ``signafaide init`` or by setting the key. Never raises when the service is
92
+ offline: records are kept in the outbox."""
93
+ rec = runtime.configure(url=url, api_key=api_key, home=home, store_content=store_content, quiet=quiet, key_provider=key_provider,
94
+ exit_flush=exit_flush, head_file=head_file, on_head=on_head)
95
+ key = rec.api_key
96
+ if not key:
97
+ return rec
98
+ try:
99
+ rec.resolve_project(key)
100
+ except SignafaideError as e:
101
+ # A wrong key or an unreachable witness is a warning, never an exception in the
102
+ # caller's program (records are kept locally until it is fixed).
103
+ if not quiet:
104
+ import sys
105
+
106
+ print(f"signafaide: {e} Records are kept locally until then.", file=sys.stderr)
107
+ return rec
108
+
109
+
110
+ def flush(timeout: float = 10.0) -> dict:
111
+ """Ship the outbox now. Returns ``{shipped, left, errors}``."""
112
+ return runtime.get().flush(timeout)
113
+
114
+
115
+ def stop(timeout: float = 10.0) -> dict:
116
+ """A clean stop of this instance (§8): ship everything, then write and ship a signed stop note
117
+ per agent, so the next instance's start is a clean restart. Call it in the shutdown handler of
118
+ an ephemeral container. Returns ``{shipped, left, errors, stopped}``."""
119
+ return runtime.get().stop(timeout)
120
+
121
+
122
+ def status() -> dict:
123
+ """Key, env, agents, last receipts and outbox size."""
124
+ return runtime.get().status()
125
+
126
+
127
+ def check_held(rec: Recorder) -> dict:
128
+ """Records ``signafaide repair`` set aside: their signatures (with the key that signed them),
129
+ chain links and content hashes still check; they are not co-signed. Failures name the check."""
130
+ from .crypto import check_content, verify_sig
131
+ from .records import record_hash
132
+
133
+ keys = {k["key_id"]: k for k in rec.state.agent_public_keys()}
134
+ failures, records = [], 0
135
+ segs = rec.state.held_segments()
136
+ for chain, path, recs in segs:
137
+ prev = None
138
+ for r in recs:
139
+ records += 1
140
+ item = f"{chain}#{r['envelope']['seq']} (held, {path.name})"
141
+ k = keys.get(r.get("key_id"))
142
+ if k is None:
143
+ failures.append({"check": "record_sig", "item": item, "message": f"no key {r.get('key_id')} for agent {r['envelope']['agent']} on this machine"})
144
+ elif not verify_sig(k["public_key"], "draft" if r.get("draft") else "record", record_hash(r), r.get("sig", "")):
145
+ failures.append({"check": "record_sig", "item": item, "message": "signature does not verify"})
146
+ if prev is not None and (r["envelope"]["seq"] != prev["envelope"]["seq"] + 1 or r["envelope"]["prev"] != record_hash(prev)):
147
+ failures.append({"check": "chain", "item": item, "message": "does not follow the held record before it"})
148
+ try:
149
+ h = check_content(r.get("content"), r.get("salt"), r["envelope"]["content_hash"])
150
+ if h:
151
+ failures.append({"check": "content", "item": item, "message": f"content hashes to {h}, envelope says {r['envelope']['content_hash']}"})
152
+ except ValueError as e:
153
+ failures.append({"check": "content", "item": item, "message": str(e)})
154
+ prev = r
155
+ return {"records": records, "segments": len(segs), "failures": failures}
156
+
157
+
158
+ def verify(
159
+ pack: Union[str, Path, dict, None] = None,
160
+ *,
161
+ keys: Any = None,
162
+ trust_embedded_keys: bool = False,
163
+ agent_keys: Any = None,
164
+ trust_embedded_agent_keys: bool = False,
165
+ head: Any = None,
166
+ timestamp_check: str = DEFAULT_TIMESTAMP_URL,
167
+ offline: bool = False,
168
+ content: Union[str, Path, None] = None,
169
+ fail_on_pending_send: bool = False,
170
+ ) -> Report:
171
+ """Run the §7 verifier on a pack (path or dict) or, with no pack, on the
172
+ local ``.signafaide`` state. ``print(report)`` shows one line per check and
173
+ the result line; ``report.ok`` is the result.
174
+
175
+ ``keys`` is a file, URL or dict of service keys. Without it the keys are
176
+ fetched from the service (``SIGNAFAIDE_URL``), which is independent of the
177
+ pack. ``agent_keys`` is the customer's manifest (``signafaide keys export``);
178
+ for a pack it defaults to this project's own agent keys in ``.signafaide/agents``.
179
+ ``head`` is a client's ``head.json``; for a pack it defaults to this project's own
180
+ ``.signafaide/head.json``. Public timestamps are checked online against
181
+ ``timestamp_check`` unless ``offline``. Keys over plain http are accepted only from this
182
+ machine and never count as independent. Raises :class:`KeysRequired` when no service
183
+ keys can be had, and OSError or ValueError when an input cannot be read.
184
+
185
+ For local state, a chain that cannot ship (refused by the witness, or wedged here) makes
186
+ the result BLOCKED, which always fails; ``fail_on_pending_send`` also fails while records
187
+ wait to be co-signed (for CI).
188
+ """
189
+ rec = runtime.get()
190
+ notes = []
191
+ service_keys = None
192
+ keys_plain = False
193
+ if keys is not None:
194
+ service_keys = load_service_keys(keys)
195
+ keys_plain = plain_http(keys)
196
+ notes.append(f"service keys: {keys if isinstance(keys, (str, Path)) else 'given'}")
197
+ elif pack is None and not rec.state.chain_heads():
198
+ service_keys = [] # nothing has been shipped, so nothing needs a service key: no request is made
199
+ elif pack is None or not trust_embedded_keys:
200
+ url = f"{rec.url}/.well-known/signafaide-keys.json"
201
+ if url.startswith("http://") and not is_loopback_url(url):
202
+ raise KeysRequired(f"will not fetch service keys over plain http from {url}: anyone on the network path can alter them; pass --keys <file|https url>")
203
+ keys_plain = url.startswith("http://")
204
+ try:
205
+ service_keys = load_service_keys(rec.client().request("GET", "/.well-known/signafaide-keys.json", auth=False))
206
+ notes.append(f"service keys: {url}" + (" (plain http on this machine: not independent)" if keys_plain else ""))
207
+ except (SignafaideError, ValueError) as e:
208
+ if pack is not None or rec.state.chain_heads():
209
+ raise KeysRequired(f"could not load service keys from {url} ({e}); pass --keys <file|url>") from None
210
+ service_keys = [] # nothing has been shipped, so nothing needs a service key
211
+ drand = load_drand_verifier()
212
+ if pack is None:
213
+ report = verify_local(rec.state, service_keys, drand=drand, service_keys_plain_http=keys_plain)
214
+ notes.insert(0, f"local state: {rec.state.home}")
215
+ records = [r for c in rec.state.chains() for r in rec.state.read_all(c)]
216
+ unsent = sum(1 for r in records if "receipt" not in r)
217
+ report.after += not_yet(unsent, len(records), bool(rec.api_key))
218
+ held = check_held(rec)
219
+ if held["segments"]:
220
+ for f in held["failures"]:
221
+ c = report.check(f["check"])
222
+ c.failures.append({"item": f["item"], "message": f["message"]})
223
+ c.status = "fail"
224
+ report.after.append(f"held aside by `signafaide repair`: {plural(held['records'], 'record')} in {plural(held['segments'], 'segment')} "
225
+ "(signatures, links and content hashes checked here; not co-signed)")
226
+ for b in rec.diagnose():
227
+ report.blocked.append(f"{b['chain']}: {b['reason']}; {plural(b['records'], 'record')} cannot ship. Next: {b['next']}")
228
+ if fail_on_pending_send:
229
+ report.not_sent = unsent
230
+ else:
231
+ if isinstance(pack, (str, Path)):
232
+ pack = _loads(Path(pack).read_text())
233
+ if content is None and (rec.state.home / "chain").is_dir():
234
+ content = rec.state.home / "chain"
235
+ contents = load_content(content) if content is not None else None
236
+ independent_agents = None
237
+ if agent_keys is not None:
238
+ independent_agents = load_agent_keys(agent_keys)
239
+ notes.append(f"agent keys: {agent_keys if isinstance(agent_keys, (str, Path)) else 'given'}")
240
+ elif not trust_embedded_agent_keys:
241
+ local = [{"agent": k["agent"], "key_id": k["key_id"], "public_key": k["public_key"], "valid_from": k["created"], "valid_to": k["retired"]}
242
+ for k in rec.state.agent_public_keys() if k.get("created")]
243
+ if local:
244
+ independent_agents = local
245
+ notes.append(f"agent keys: this project's own ({rec.state.home / 'agents'})")
246
+ if head is not None:
247
+ heads = load_heads(head)
248
+ else:
249
+ # This project's own newest signed heads: a pack cut off before them fails.
250
+ heads = load_heads(rec.state.heads()) or None
251
+ if heads:
252
+ notes.append(f"head: this project's own ({rec.state.home / 'head.json'})")
253
+ heights = bitcoin_heights(pack)
254
+ stamps = None if offline or not heights else lookup_timestamps(timestamp_check, heights)
255
+ report = verify_pack(pack, service_keys, trust_embedded_keys=trust_embedded_keys, agent_keys=independent_agents,
256
+ trust_embedded_agent_keys=trust_embedded_agent_keys, heads=heads, bitcoin=stamps, contents=contents, drand=drand,
257
+ service_keys_plain_http=keys_plain, agent_keys_plain_http=plain_http(agent_keys))
258
+ if independent_agents is None and not trust_embedded_agent_keys:
259
+ notes.append("no agent keys: ask the customer for `signafaide keys export -o agent-keys.json` and pass --agent-keys agent-keys.json")
260
+ if drand is None and report.check("drand").checked:
261
+ notes.append(DRAND_NOT_CHECKED)
262
+ report.notes[:0] = notes
263
+ return report
signafaide/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
signafaide/_plural.py ADDED
@@ -0,0 +1,6 @@
1
+ """A count with its noun in the right number."""
2
+
3
+
4
+ def plural(n: int, one: str, many: str = "") -> str:
5
+ """``1 record``, ``2 records``."""
6
+ return f"{n} {one if n == 1 else many or one + 's'}"
signafaide/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
signafaide/bitcoin.py ADDED
@@ -0,0 +1,102 @@
1
+ """Bitcoin block headers for §7 check 11, from an Esplora-style explorer API.
2
+
3
+ ``GET {base}/block-height/{h}`` gives the block hash; ``GET {base}/block/{hash}/header`` gives
4
+ the raw 80-byte header (hex). The header is trusted only when it hashes to that block hash and
5
+ meets its own proof of work (:func:`check_header`). mempool.space and blockstream.info serve
6
+ this API. An explorer over plain http is never asked: anyone on the path could answer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import re
13
+ import sys
14
+ import urllib.error
15
+ import urllib.request
16
+ from typing import Any, Dict, Iterable, Optional, Tuple, Union
17
+
18
+ from ._version import __version__
19
+
20
+ DEFAULT_URL = "https://mempool.space/api"
21
+ _HEX64 = re.compile(r"\A[0-9a-fA-F]{64}\Z")
22
+
23
+
24
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
25
+ def redirect_request(self, *a, **k): # an explorer answer is never a redirect
26
+ return None
27
+
28
+
29
+ def _get(url: str, timeout: float) -> str:
30
+ req = urllib.request.Request(url, headers={"User-Agent": f"signafaide-python/{__version__}", "Accept": "*/*"})
31
+ with urllib.request.build_opener(_NoRedirect).open(req, timeout=timeout) as r:
32
+ return r.read(1 << 20).decode()
33
+
34
+
35
+ #: Why a plain-http explorer is not asked.
36
+ PLAIN_HTTP_EXPLORER = "the explorer URL uses plain http, which anyone on the network path can alter; use https"
37
+
38
+
39
+ def target(bits: int) -> Optional[int]:
40
+ """The target of a compact ``nBits``; None when it is negative, zero or overflows."""
41
+ exponent, mantissa = bits >> 24, bits & 0x7FFFFF
42
+ if bits & 0x800000 or mantissa == 0:
43
+ return None
44
+ t = mantissa << (8 * (exponent - 3)) if exponent >= 3 else mantissa >> (8 * (3 - exponent))
45
+ return t if 0 < t < 1 << 256 else None
46
+
47
+
48
+ def check_header(block_hash: str, header_hex: str) -> bytes:
49
+ """The Merkle root (header byte order) of a header that hashes to ``block_hash`` (display hex)
50
+ and meets its own proof of work. Raises ValueError saying why the header is not trusted."""
51
+ try:
52
+ header = bytes.fromhex(header_hex.strip())
53
+ except (ValueError, AttributeError):
54
+ raise ValueError("the block header is not hex") from None
55
+ if len(header) != 80:
56
+ raise ValueError(f"the block header is {len(header)} bytes, not 80")
57
+ block_id = hashlib.sha256(hashlib.sha256(header).digest()).digest()[::-1]
58
+ if block_id.hex() != str(block_hash).strip().lower():
59
+ raise ValueError(f"the block header does not hash to block {block_hash}")
60
+ bits = int.from_bytes(header[72:76], "little")
61
+ t = target(bits)
62
+ if t is None:
63
+ raise ValueError(f"the block header's nBits {bits:#010x} is not a valid target")
64
+ if int.from_bytes(block_id, "big") > t:
65
+ raise ValueError(f"block {block_hash} does not meet the proof of work its header claims")
66
+ return header[36:68]
67
+
68
+
69
+ def block_header(base: str, height: int, timeout: float = 15.0) -> Dict[str, str]:
70
+ """``{hash, header}`` of block ``height`` as the explorer serves them. Raises ValueError."""
71
+ base = base.rstrip("/")
72
+ try:
73
+ h = _get(f"{base}/block-height/{height}", timeout).strip().lower()
74
+ if not _HEX64.match(h):
75
+ raise ValueError(f"no block at height {height}")
76
+ header = _get(f"{base}/block/{h}/header", timeout).strip().lower()
77
+ except (urllib.error.URLError, OSError, ValueError) as e:
78
+ raise ValueError(f"{base}: {getattr(e, 'reason', e)}") from None
79
+ return {"hash": h, "header": header}
80
+
81
+
82
+ def lookup(base: str, heights: Iterable[int]) -> Dict[int, Tuple[bool, Any]]:
83
+ """height -> (True, {hash, header}) or (False, why it could not be fetched). A plain-http
84
+ explorer is not asked: every height then says why (the check shows NOT CHECKED)."""
85
+ out: Dict[int, Tuple[bool, Any]] = {}
86
+ plain = base.startswith("http://")
87
+ if plain:
88
+ print(f"WARNING: --timestamp-check {base} uses plain http://, which anyone on the network path can alter: public timestamps are not checked; use https://", file=sys.stderr)
89
+ for h in heights:
90
+ if plain:
91
+ out[h] = (False, PLAIN_HTTP_EXPLORER)
92
+ continue
93
+ try:
94
+ out[h] = (True, block_header(base, h))
95
+ except ValueError as e:
96
+ out[h] = (False, str(e))
97
+ return out
98
+
99
+
100
+ def roots_from_vector(m: Dict[str, Union[str, dict]]) -> Dict[int, Tuple[bool, Any]]:
101
+ """The ``bitcoin`` object of a test vector: height -> {hash, header}."""
102
+ return {int(h): (True, r) for h, r in m.items() if isinstance(r, dict)}