signafaide 0.2.1__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 (40) hide show
  1. signafaide-0.2.1/.gitignore +5 -0
  2. signafaide-0.2.1/PKG-INFO +238 -0
  3. signafaide-0.2.1/README.md +217 -0
  4. signafaide-0.2.1/pyproject.toml +42 -0
  5. signafaide-0.2.1/src/signafaide/__init__.py +263 -0
  6. signafaide-0.2.1/src/signafaide/__main__.py +5 -0
  7. signafaide-0.2.1/src/signafaide/_plural.py +6 -0
  8. signafaide-0.2.1/src/signafaide/_version.py +1 -0
  9. signafaide-0.2.1/src/signafaide/bitcoin.py +102 -0
  10. signafaide-0.2.1/src/signafaide/cli.py +449 -0
  11. signafaide-0.2.1/src/signafaide/crypto.py +196 -0
  12. signafaide-0.2.1/src/signafaide/decorator.py +318 -0
  13. signafaide-0.2.1/src/signafaide/drand.py +75 -0
  14. signafaide-0.2.1/src/signafaide/experiments.py +210 -0
  15. signafaide-0.2.1/src/signafaide/http.py +148 -0
  16. signafaide-0.2.1/src/signafaide/jcs.py +125 -0
  17. signafaide-0.2.1/src/signafaide/ots.py +256 -0
  18. signafaide-0.2.1/src/signafaide/records.py +379 -0
  19. signafaide-0.2.1/src/signafaide/replay.py +302 -0
  20. signafaide-0.2.1/src/signafaide/runtime.py +1047 -0
  21. signafaide-0.2.1/src/signafaide/state.py +993 -0
  22. signafaide-0.2.1/src/signafaide/telemetry.py +65 -0
  23. signafaide-0.2.1/src/signafaide/verifier.py +1330 -0
  24. signafaide-0.2.1/tests/conftest.py +74 -0
  25. signafaide-0.2.1/tests/fake_service.py +275 -0
  26. signafaide-0.2.1/tests/helpers.py +23 -0
  27. signafaide-0.2.1/tests/test_adapters.py +143 -0
  28. signafaide-0.2.1/tests/test_cli.py +232 -0
  29. signafaide-0.2.1/tests/test_core.py +236 -0
  30. signafaide-0.2.1/tests/test_record.py +431 -0
  31. signafaide-0.2.1/tests/test_replay_compare.py +132 -0
  32. signafaide-0.2.1/tests/test_round3.py +318 -0
  33. signafaide-0.2.1/tests/test_round4.py +205 -0
  34. signafaide-0.2.1/tests/test_round5.py +120 -0
  35. signafaide-0.2.1/tests/test_round6.py +147 -0
  36. signafaide-0.2.1/tests/test_state.py +246 -0
  37. signafaide-0.2.1/tests/test_telemetry.py +149 -0
  38. signafaide-0.2.1/tests/test_vectors.py +256 -0
  39. signafaide-0.2.1/tests/test_verify.py +421 -0
  40. signafaide-0.2.1/uv.lock +4185 -0
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ dist/
5
+ *.egg-info/
@@ -0,0 +1,238 @@
1
+ Metadata-Version: 2.5
2
+ Name: signafaide
3
+ Version: 0.2.1
4
+ Summary: Signed, chained records of what your AI agents decided. Hash-only by default.
5
+ Project-URL: Homepage, https://signafaide.co.uk
6
+ Project-URL: Documentation, https://signafaide.co.uk/docs
7
+ Project-URL: Repository, https://github.com/AIME-TECH-UK-LIMITED/signafaide-sdk
8
+ Project-URL: Changelog, https://github.com/AIME-TECH-UK-LIMITED/signafaide-sdk/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/AIME-TECH-UK-LIMITED/signafaide-sdk/issues
10
+ License: Apache-2.0
11
+ Keywords: ai-agents,audit-trail,eu-ai-act,provenance,tamper-evident,verifiable
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Security :: Cryptography
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: cryptography>=41
18
+ Requires-Dist: py-ecc>=7
19
+ Provides-Extra: bls
20
+ Description-Content-Type: text/markdown
21
+
22
+ # signafaide
23
+
24
+ Signed, chained records of what your AI agents decided. Each record is signed with the agent's own key and chained on your machine; once you opt in, a witness (the signafAIde service, or one you host) co-signs and publicly timestamps it, so neither side can forge or edit a record alone.
25
+
26
+ - Nothing is sent until a person runs `signafaide init` or sets `SIGNAFAIDE_API_KEY`. After that each record sends its content hash with its tool name, agent name and time (and its chain links), never its inputs or outputs unless you turn on stored content. Tool and agent names are sent as you write them, so don't put personal data in them. Each content hash is salted with 16 random bytes that stay on your machine with the content, so the hash alone cannot be used to guess the content, even when it has only a few possible values.
27
+ - The sandbox `init` creates needs no account, email or card.
28
+ - To keep the evidence in your environment, self-host the witness and set `SIGNAFAIDE_URL` ([self-host](https://signafaide.co.uk/docs/self-host)).
29
+ - Apache-2.0, with a public protocol spec and an independent verifier.
30
+
31
+ ```bash
32
+ uv add signafaide # a uv project
33
+ poetry add signafaide # a Poetry project
34
+ python3 -m venv .venv && .venv/bin/python -m pip install signafaide # otherwise, then add it to requirements.txt
35
+ ```
36
+
37
+ Not a bare `pip install`: most systems' Python refuses it outside a virtual environment (PEP 668, "externally-managed-environment").
38
+
39
+ ```python
40
+ import signafaide
41
+
42
+ @signafaide.record
43
+ def approve_payout(claim_id: str, amount: float): ...
44
+ ```
45
+
46
+ ```bash
47
+ signafaide verify # or .venv/bin/signafaide verify, uv run signafaide verify
48
+ ```
49
+
50
+ That's it. With no API key, records are signed, chained and kept in `.signafaide/`, and nothing is sent. `signafaide verify` checks every signature, chain link and content hash and ends with a result line that says how many of the 12 checks ran, for example `PASS: 4 of 12 checks passed, 8 not checked (nothing co-signed yet)`. A check that was not run is never counted as passed. It also says what still needs the witness:
51
+
52
+ ```
53
+ co-signature, public timestamp: not yet. Run `signafaide init` to send hashes, or self-host (SIGNAFAIDE_URL)
54
+ ```
55
+
56
+ ## Sending hashes to a witness
57
+
58
+ Co-signature and the public timestamp need a witness. A person opts in once:
59
+
60
+ - `signafaide init` creates a free sandbox (no account, email or card), saves `SIGNAFAIDE_API_KEY` to `.env`, adds `.env` to `.gitignore` and prints a link to claim it; or
61
+ - set `SIGNAFAIDE_API_KEY` (a live key from the console), with `SIGNAFAIDE_URL` for a self-hosted witness. `SIGNAFAIDE_URL` alone sends nothing.
62
+
63
+ Records written before the opt-in ship afterwards with their original times. The service accepts records up to 7 days old (an agent key's `valid_from` is at most 7 days before its registration); a local chain that starts earlier stays local, still verifies, and `signafaide flush` reports it as `too_old`. `signafaide.init()` in code only configures: it never creates a sandbox.
64
+
65
+ ## What a call records
66
+
67
+ 1. A `decision` record before the function runs: the tool name, plus `{"input": {<argument>: <value>}, "meta": {...}}`.
68
+ 2. The function runs.
69
+ 3. An `outcome` record after it: `{"output": ...}`, or `{"error": {"type", "message"}}`. The error is raised again unchanged.
70
+
71
+ Records are signed and chained locally in `.signafaide/` (synchronously, in a couple of milliseconds), then, once you opted in, shipped in the background. Sync and async functions both work.
72
+
73
+ ```python
74
+ @signafaide.record(agent="claims-bot", name="approve_payout", meta={"model": "gpt-x"})
75
+ def approve(claim_id: str, amount: float): ...
76
+
77
+ signafaide.note("policy_loaded", {"version": 3}) # anything that is not a tool call
78
+ ```
79
+
80
+ Give each agent an explicit name, for example `@signafaide.record(agent="claims-bot")`; without one it is the project folder's name. It must match `^[a-z0-9][a-z0-9._-]{0,63}$`. In production, one agent name is one chain written by one running instance.
81
+
82
+ ## Frameworks
83
+
84
+ ```python
85
+ tools = signafaide.wrap_tools({"lookup_order": lookup_order, "refund": refund}) # dict: key = name
86
+ tools = signafaide.wrap_tools([lookup_order, refund]) # list
87
+ ```
88
+
89
+ `wrap_tools` (and `wrap_tool` for one) also takes:
90
+
91
+ - **LangChain** tools (`@tool` or any `BaseTool`). The copy records each `invoke` / `ainvoke`, including tool calls, and still converts for `bind_tools`. The outcome holds the tool's own return value, not the `ToolMessage` LangChain wraps it in.
92
+ - **OpenAI Agents SDK** `FunctionTool`s (`@function_tool`). The copy records `on_invoke_tool`.
93
+
94
+ ```python
95
+ from agents import Agent, function_tool
96
+
97
+ @function_tool
98
+ def lookup_order(order_id: str) -> str: ...
99
+
100
+ agent = Agent(name="support", tools=signafaide.wrap_tools([lookup_order]))
101
+ ```
102
+
103
+ Neither framework is a dependency: `wrap_tools` recognises the objects by their shape.
104
+
105
+ ## Strict mode: no receipt, no action
106
+
107
+ ```python
108
+ @signafaide.record(strict=True)
109
+ def wire_funds(account: str, amount: float): ...
110
+ ```
111
+
112
+ The decision is shipped first, and the function runs only after the service has returned its receipt. If the service can't be reached or refuses the record, or there is no API key (`no_key`), `signafaide.NotRecorded` is raised and the function does not run. Agent frameworks often turn a tool's error into a failed tool call and print nothing, so the first time a strict tool is refused in a process the SDK writes one warning line to stderr with the tool's name and the fix.
113
+
114
+ ## Hash-only
115
+
116
+ By default the service receives only the envelope: the name, the time, the chain links and `content_hash`. The hash is `sha256(salt || JCS(content))` with a random 16-byte salt per record, kept next to the content in the local chain, so the service cannot confirm a guess of the content from the hash. When you store content with the service, the salt goes with it, so anyone holding the pack can check the content against the hash. Your inputs and outputs stay in `.signafaide/chain/<project>/<env>/<agent>.jsonl` (`.signafaide/chain/_pending/nokey/<agent>.jsonl` before the opt-in), which is your evidence store. To send the content too (encrypted at rest; live records need the Team plan):
117
+
118
+ ```python
119
+ signafaide.init(store_content=True) # everything
120
+ @signafaide.record(store_content=True) # one tool
121
+ ```
122
+
123
+ ## Offline
124
+
125
+ Nothing waits on the network unless you use strict mode: writing a record never makes a request, and a wrong key or an unreachable witness is a warning, never an exception in your tool. A malformed `SIGNAFAIDE_API_KEY` is caught here and never sent. With an API key, records go to `.signafaide/outbox/` and are shipped in batches by a background thread: every 2 seconds, every 100 records, and once more on exit (at most 3 seconds). If the service can't be reached, they stay on disk and are retried with backoff; a refusal retrying cannot fix (quota used up, a revoked key) stops sending until the configuration changes. They ship on the next run or with `signafaide flush`. Records are never lost on exit. The delay shows as the gap between a record's `time` and its batch's `server_time`.
126
+
127
+ ## Containers that restart
128
+
129
+ A container with no persistent disk continues its chain after the witness's head, and its first record is a signed start note. So that a routine restart is not read as a possible loss, end the instance with `signafaide.stop()`: it ships what is left and then writes a signed stop note. A start right after a stop note is a clean restart; a start with no stop note before it (a crash, a kill, an unreachable witness) is shown as a warning, because records the earlier instance had not sent are not known.
130
+
131
+ ```python
132
+ import signal, sys, signafaide
133
+
134
+ def on_term(signum, frame):
135
+ signafaide.stop() # {shipped, left, errors, stopped}
136
+ sys.exit(0)
137
+
138
+ signal.signal(signal.SIGTERM, on_term)
139
+ ```
140
+
141
+ A program that ends by itself with `SIGNAFAIDE_HEAD_FILE` set writes the stop note at exit, within the exit limit above. `SIGNAFAIDE_INSTANCE_STOP=0` turns that off; `=1` turns it on without a head file.
142
+
143
+ ## Errors
144
+
145
+ Service errors raise or log `signafaide.SignafaideError`. Its message is the service's one-sentence fix, word for word, and `.code` is the error code. A `chain_conflict` (the service's chain for an agent differs from yours) is reported with the service's head and blocks shipping for that agent. The local chain is never rewritten. Run `signafaide verify` and `signafaide status` to see why.
146
+
147
+ ## Verify
148
+
149
+ ```bash
150
+ signafaide verify # the local .signafaide state
151
+ signafaide verify pack.json # a pack from `signafaide export`
152
+ signafaide verify pack.json --keys keys.json --json
153
+ ```
154
+
155
+ There is one line per check (schema, hashes, signatures, chains, Merkle proofs, batch and head signatures, drand, anchors, OpenTimestamps, content), then the result line: `PASS` or `FAIL` and how many checks passed, are pending and were not checked, for example `PASS: 11 of 12 checks passed, 1 pending (public timestamp)`. Pending means co-signed, waiting for the public timestamp (an anchor is made every hour and usually confirms within a few hours); pending and not checked are never counted as passed. The exit code is 0 for PASS, 1 for FAIL and 2 when an input can't be read. Locally, a chain the witness refuses for good (or that an older SDK wrote with time going back) shows as `BLOCKED` with the reason and the next step, and always exits 1; `--fail-on-pending-send` also fails while records wait to be co-signed, for CI. Locally, before anything was co-signed, the witness's checks show `NOT CHECKED` ("nothing co-signed yet") and no request is made. Service keys come from `--keys <file|url>`, or by default from the service's `/.well-known/signafaide-keys.json`. `--trust-embedded-keys` uses the keys inside the pack and prints a warning. The drand BLS signature is checked too, through `py_ecc` (a dependency, as `@noble/curves` is for the TypeScript SDK), so both SDKs count the same checks. A pack's rounds are checked together, so it takes well under a second.
156
+
157
+ ```python
158
+ report = signafaide.verify("pack.json", keys="keys.json")
159
+ print(report) # the same lines
160
+ report.ok # True / False
161
+ ```
162
+
163
+
164
+ **For an auditor** the agent keys must come from you, not from the pack (otherwise a service could add a key of its own): `signafaide keys export -o agent-keys.json` writes every agent key, each signed by itself. The auditor runs `npx signafaide verify pack.json --keys <witness>/.well-known/signafaide-keys.json --agent-keys agent-keys.json --head head.json` (or the Python CLI with the same arguments; `head.json` is your `.signafaide/head.json`, which catches records cut off at the end). Without `--agent-keys` the client-signature check says `NOT INDEPENDENT` and the result is FAIL; `--trust-embedded-agent-keys` accepts the pack's keys and says so in the result line. On your own machine, `signafaide verify pack.json` uses this project's agent keys automatically. Public timestamps are checked online (`--timestamp-check <url>`, default `https://mempool.space/api`); with `--offline` they show `NOT CHECKED`, never confirmed. `signafaide keys rotate --agent X` gives an agent a new key; the SDK signs the registration of the new key with the old one.
165
+
166
+ ## Replay
167
+
168
+ ```bash
169
+ signafaide replay --agent claims-bot --from 2026-11-01 --to 2026-11-13 --html replay.html
170
+ signafaide replay --agent claims-bot --record 3fa2b1c9
171
+ ```
172
+
173
+ Shows the agent's decisions and outcomes in order, with their inputs and outputs from the local chain. Each record is checked against its `content_hash`, its signature and the service's receipt first, so an edited line shows as `FAIL`. `--html` also writes a self-contained page. In code: `r = signafaide.replay(agent="claims-bot", since="2026-11-01")`, then `print(r)`, `r.to_html()`, `r.ok`.
174
+
175
+ ## Experiments: A/B on past decisions
176
+
177
+ ```python
178
+ def approve(claim_id, amount): # same arguments as the recorded tool
179
+ return {"approved": amount < 300}
180
+
181
+ report = signafaide.compare(approve, agent="claims-bot", name="approve_payout", since="2026-11-01", limit=500)
182
+ print(report) # n, agreed, changed, errors, then the changed decisions
183
+ report.upload() # optional: hash-only unless content is stored with the service
184
+ ```
185
+
186
+ The chain is verified first; then each recorded decision with a co-signed outcome is re-run with your candidate, on your machine, with your own models and keys, and compared with the recorded `output` (exact canonical-JSON equality, or `same=lambda recorded, candidate: ...`). A candidate that raises counts as an error. **Candidates must be side-effect free:** compare never writes records, and a recorded tool called from a candidate raises instead of running. CLI: `signafaide compare --module candidate.py:approve --name approve_payout --upload`.
187
+
188
+ ## CLI
189
+
190
+ | command | does |
191
+ |---|---|
192
+ | `signafaide init` | the opt-in: creates a free sandbox if there is no key (no account, email or card), prints the claim link |
193
+ | `signafaide status` | key and env, agents, last receipt, outbox size, quota; BLOCKED chains (exit 1) |
194
+ | `signafaide repair [--agent X] [--dry-run]` | sets aside records no witness can take (kept in `.signafaide/held/`, still verified), gives the agent a new key and starts a new segment |
195
+ | `signafaide verify [pack.json] [--keys ...] [--agent-keys ...] [--head ...] [--offline] [--fail-on-pending-send] [--json]` | verifies; exit 0 PASS, 1 FAIL or BLOCKED, 2 unreadable input |
196
+ | `signafaide keys export [-o agent-keys.json]` / `signafaide keys rotate [--agent X] [--env test\|live]` | the agent-key file for auditors / a new key for an agent |
197
+ | `signafaide keys create [--agent X] [--env test\|live] [--print]` | a new key, registered with the witness, for a secret store; writes no record (`--print`: prints the key file and saves nothing here) |
198
+ | `signafaide export [--agent X] [--from T] [--to T] [-o pack.json]` | downloads a verification pack of the whole project (every agent); `--agent` narrows it to one |
199
+ | `signafaide replay [--agent X] [--from T] [--to T] [--record H] [--html out.html] [--json]` | the verified timeline from the local chain; exit 1 on FAIL |
200
+ | `signafaide compare --module file.py:fn [--agent X] [--name N] [--from T] [--to T] [--limit N] [--upload [--values]]` | re-runs recorded decisions with a candidate and compares; `--values` sends the values too (stored-content decisions only) |
201
+ | `signafaide claim` | prints the claim link again |
202
+ | `signafaide flush` | ships the outbox now (with no key it sends nothing and says so) |
203
+
204
+ ## What is sent besides records
205
+
206
+ Every request carries one header:
207
+
208
+ ```
209
+ X-Signafaide-Client: signafaide-python/0.2.1 (python 3.12.3; adapter=none; agent=claude-code; via=sdk)
210
+ ```
211
+
212
+ It holds the SDK and its version, the Python runtime and its version, and the framework adapter in use (`langchain`, `openai-agents` or `none`). It also holds the coding agent that set the project up (`claude-code`, `codex`, `cursor`, `gemini-cli`, `copilot`, `windsurf` or `unknown`) and how the call was made (`sdk`, `cli` or `mcp`). The agent is found from that tool's environment variables when the project is set up (`signafaide init`) and kept in `.signafaide/config.json`; it is never guessed per process. `signafaide verify` and `signafaide export` also send `POST /v1/events {"name": "verify"}` (or `"export"`) when there is an API key. If that fails, the command is not affected. With no API key nothing at all is sent.
213
+
214
+ The header and the events hold no content, file paths, hostnames or user names. `SIGNAFAIDE_TELEMETRY=0` removes the adapter, the coding agent and the strict-mode flag from the header, stops the coding agent being saved in `.signafaide/config.json`, and turns the events off. The SDK and its version, the runtime and its version, and how the call was made (`via`) are still sent.
215
+
216
+ ## Configuration
217
+
218
+ | variable | default |
219
+ |---|---|
220
+ | `SIGNAFAIDE_API_KEY` | read from the environment, then `.env`; created by `signafaide init`; none: local only |
221
+ | `SIGNAFAIDE_URL` | `https://api.signafaide.co.uk`; a self-hosted witness, or `http://127.0.0.1:7474` for a local dev server. Alone it sends nothing |
222
+ | `SIGNAFAIDE_HOME` | `.signafaide/` in the project root; a mounted volume or `/tmp/signafaide` in production |
223
+ | `SIGNAFAIDE_AGENT_KEY_<NAME>` | an agent's key file content from a secret store (or `key_provider=` in `signafaide.init`); live keys can use `SIGNAFAIDE_AGENT_KEY_LIVE_<NAME>`, made with `signafaide keys create --env live --print` |
224
+ | `SIGNAFAIDE_HEAD_FILE` | unset: a file outside `.signafaide/` (a persistent volume) that also keeps the newest signed heads, for containers whose disk does not last; or `on_head=` in `signafaide.init` |
225
+ | `SIGNAFAIDE_INSTANCE_STOP` | unset: a signed stop note is written at a clean exit when a head file is set; `1` always, `0` never (see Containers that restart) |
226
+ | `SIGNAFAIDE_EXIT_FLUSH_MS` | `3000`: the longest a finished program is kept open to send; `0` never |
227
+ | `SIGNAFAIDE_TEST` | test mode is on by itself under pytest (records in a temporary folder, nothing sent); `1` forces it, `0` turns it off |
228
+ | `SIGNAFAIDE_DISABLED` | `1`: wrapped functions run and nothing is recorded |
229
+ | `SIGNAFAIDE_SEGMENT_BYTES` | `67108864`: a shipped chain file larger than this rolls into a sealed segment |
230
+ | `SIGNAFAIDE_TELEMETRY` | `0` turns off the adapter, agent and strict fields and the events |
231
+
232
+ Running in containers, with replicas, on read-only or ephemeral disks, on serverless or in CI: see the [production guide](https://signafaide.co.uk/docs/production). A record's time never goes back along its chain: if the clock steps back, the record keeps the previous record's time and the clock's reading goes in `meta.wall_time`.
233
+
234
+ Each project and env has its own chain per agent. A new key, for example a live key replacing the sandbox key, starts a fresh chain at seq 1. The old chain stays on disk, and `signafaide status` lists every chain.
235
+
236
+ The project root is the nearest folder with `.signafaide/`, `.git`, `pyproject.toml` or `package.json`. `signafaide init` adds `.signafaide/` to `.gitignore` (never a running program). Agent keys live in `.signafaide/agents/<agent>.key` (sandbox) and `.signafaide/agents/live/<agent>.key` (live), mode 0600, and never leave the machine. Back them up.
237
+
238
+ Python 3.9+. Dependencies: `cryptography` and `py_ecc` (drand BLS).
@@ -0,0 +1,217 @@
1
+ # signafaide
2
+
3
+ Signed, chained records of what your AI agents decided. Each record is signed with the agent's own key and chained on your machine; once you opt in, a witness (the signafAIde service, or one you host) co-signs and publicly timestamps it, so neither side can forge or edit a record alone.
4
+
5
+ - Nothing is sent until a person runs `signafaide init` or sets `SIGNAFAIDE_API_KEY`. After that each record sends its content hash with its tool name, agent name and time (and its chain links), never its inputs or outputs unless you turn on stored content. Tool and agent names are sent as you write them, so don't put personal data in them. Each content hash is salted with 16 random bytes that stay on your machine with the content, so the hash alone cannot be used to guess the content, even when it has only a few possible values.
6
+ - The sandbox `init` creates needs no account, email or card.
7
+ - To keep the evidence in your environment, self-host the witness and set `SIGNAFAIDE_URL` ([self-host](https://signafaide.co.uk/docs/self-host)).
8
+ - Apache-2.0, with a public protocol spec and an independent verifier.
9
+
10
+ ```bash
11
+ uv add signafaide # a uv project
12
+ poetry add signafaide # a Poetry project
13
+ python3 -m venv .venv && .venv/bin/python -m pip install signafaide # otherwise, then add it to requirements.txt
14
+ ```
15
+
16
+ Not a bare `pip install`: most systems' Python refuses it outside a virtual environment (PEP 668, "externally-managed-environment").
17
+
18
+ ```python
19
+ import signafaide
20
+
21
+ @signafaide.record
22
+ def approve_payout(claim_id: str, amount: float): ...
23
+ ```
24
+
25
+ ```bash
26
+ signafaide verify # or .venv/bin/signafaide verify, uv run signafaide verify
27
+ ```
28
+
29
+ That's it. With no API key, records are signed, chained and kept in `.signafaide/`, and nothing is sent. `signafaide verify` checks every signature, chain link and content hash and ends with a result line that says how many of the 12 checks ran, for example `PASS: 4 of 12 checks passed, 8 not checked (nothing co-signed yet)`. A check that was not run is never counted as passed. It also says what still needs the witness:
30
+
31
+ ```
32
+ co-signature, public timestamp: not yet. Run `signafaide init` to send hashes, or self-host (SIGNAFAIDE_URL)
33
+ ```
34
+
35
+ ## Sending hashes to a witness
36
+
37
+ Co-signature and the public timestamp need a witness. A person opts in once:
38
+
39
+ - `signafaide init` creates a free sandbox (no account, email or card), saves `SIGNAFAIDE_API_KEY` to `.env`, adds `.env` to `.gitignore` and prints a link to claim it; or
40
+ - set `SIGNAFAIDE_API_KEY` (a live key from the console), with `SIGNAFAIDE_URL` for a self-hosted witness. `SIGNAFAIDE_URL` alone sends nothing.
41
+
42
+ Records written before the opt-in ship afterwards with their original times. The service accepts records up to 7 days old (an agent key's `valid_from` is at most 7 days before its registration); a local chain that starts earlier stays local, still verifies, and `signafaide flush` reports it as `too_old`. `signafaide.init()` in code only configures: it never creates a sandbox.
43
+
44
+ ## What a call records
45
+
46
+ 1. A `decision` record before the function runs: the tool name, plus `{"input": {<argument>: <value>}, "meta": {...}}`.
47
+ 2. The function runs.
48
+ 3. An `outcome` record after it: `{"output": ...}`, or `{"error": {"type", "message"}}`. The error is raised again unchanged.
49
+
50
+ Records are signed and chained locally in `.signafaide/` (synchronously, in a couple of milliseconds), then, once you opted in, shipped in the background. Sync and async functions both work.
51
+
52
+ ```python
53
+ @signafaide.record(agent="claims-bot", name="approve_payout", meta={"model": "gpt-x"})
54
+ def approve(claim_id: str, amount: float): ...
55
+
56
+ signafaide.note("policy_loaded", {"version": 3}) # anything that is not a tool call
57
+ ```
58
+
59
+ Give each agent an explicit name, for example `@signafaide.record(agent="claims-bot")`; without one it is the project folder's name. It must match `^[a-z0-9][a-z0-9._-]{0,63}$`. In production, one agent name is one chain written by one running instance.
60
+
61
+ ## Frameworks
62
+
63
+ ```python
64
+ tools = signafaide.wrap_tools({"lookup_order": lookup_order, "refund": refund}) # dict: key = name
65
+ tools = signafaide.wrap_tools([lookup_order, refund]) # list
66
+ ```
67
+
68
+ `wrap_tools` (and `wrap_tool` for one) also takes:
69
+
70
+ - **LangChain** tools (`@tool` or any `BaseTool`). The copy records each `invoke` / `ainvoke`, including tool calls, and still converts for `bind_tools`. The outcome holds the tool's own return value, not the `ToolMessage` LangChain wraps it in.
71
+ - **OpenAI Agents SDK** `FunctionTool`s (`@function_tool`). The copy records `on_invoke_tool`.
72
+
73
+ ```python
74
+ from agents import Agent, function_tool
75
+
76
+ @function_tool
77
+ def lookup_order(order_id: str) -> str: ...
78
+
79
+ agent = Agent(name="support", tools=signafaide.wrap_tools([lookup_order]))
80
+ ```
81
+
82
+ Neither framework is a dependency: `wrap_tools` recognises the objects by their shape.
83
+
84
+ ## Strict mode: no receipt, no action
85
+
86
+ ```python
87
+ @signafaide.record(strict=True)
88
+ def wire_funds(account: str, amount: float): ...
89
+ ```
90
+
91
+ The decision is shipped first, and the function runs only after the service has returned its receipt. If the service can't be reached or refuses the record, or there is no API key (`no_key`), `signafaide.NotRecorded` is raised and the function does not run. Agent frameworks often turn a tool's error into a failed tool call and print nothing, so the first time a strict tool is refused in a process the SDK writes one warning line to stderr with the tool's name and the fix.
92
+
93
+ ## Hash-only
94
+
95
+ By default the service receives only the envelope: the name, the time, the chain links and `content_hash`. The hash is `sha256(salt || JCS(content))` with a random 16-byte salt per record, kept next to the content in the local chain, so the service cannot confirm a guess of the content from the hash. When you store content with the service, the salt goes with it, so anyone holding the pack can check the content against the hash. Your inputs and outputs stay in `.signafaide/chain/<project>/<env>/<agent>.jsonl` (`.signafaide/chain/_pending/nokey/<agent>.jsonl` before the opt-in), which is your evidence store. To send the content too (encrypted at rest; live records need the Team plan):
96
+
97
+ ```python
98
+ signafaide.init(store_content=True) # everything
99
+ @signafaide.record(store_content=True) # one tool
100
+ ```
101
+
102
+ ## Offline
103
+
104
+ Nothing waits on the network unless you use strict mode: writing a record never makes a request, and a wrong key or an unreachable witness is a warning, never an exception in your tool. A malformed `SIGNAFAIDE_API_KEY` is caught here and never sent. With an API key, records go to `.signafaide/outbox/` and are shipped in batches by a background thread: every 2 seconds, every 100 records, and once more on exit (at most 3 seconds). If the service can't be reached, they stay on disk and are retried with backoff; a refusal retrying cannot fix (quota used up, a revoked key) stops sending until the configuration changes. They ship on the next run or with `signafaide flush`. Records are never lost on exit. The delay shows as the gap between a record's `time` and its batch's `server_time`.
105
+
106
+ ## Containers that restart
107
+
108
+ A container with no persistent disk continues its chain after the witness's head, and its first record is a signed start note. So that a routine restart is not read as a possible loss, end the instance with `signafaide.stop()`: it ships what is left and then writes a signed stop note. A start right after a stop note is a clean restart; a start with no stop note before it (a crash, a kill, an unreachable witness) is shown as a warning, because records the earlier instance had not sent are not known.
109
+
110
+ ```python
111
+ import signal, sys, signafaide
112
+
113
+ def on_term(signum, frame):
114
+ signafaide.stop() # {shipped, left, errors, stopped}
115
+ sys.exit(0)
116
+
117
+ signal.signal(signal.SIGTERM, on_term)
118
+ ```
119
+
120
+ A program that ends by itself with `SIGNAFAIDE_HEAD_FILE` set writes the stop note at exit, within the exit limit above. `SIGNAFAIDE_INSTANCE_STOP=0` turns that off; `=1` turns it on without a head file.
121
+
122
+ ## Errors
123
+
124
+ Service errors raise or log `signafaide.SignafaideError`. Its message is the service's one-sentence fix, word for word, and `.code` is the error code. A `chain_conflict` (the service's chain for an agent differs from yours) is reported with the service's head and blocks shipping for that agent. The local chain is never rewritten. Run `signafaide verify` and `signafaide status` to see why.
125
+
126
+ ## Verify
127
+
128
+ ```bash
129
+ signafaide verify # the local .signafaide state
130
+ signafaide verify pack.json # a pack from `signafaide export`
131
+ signafaide verify pack.json --keys keys.json --json
132
+ ```
133
+
134
+ There is one line per check (schema, hashes, signatures, chains, Merkle proofs, batch and head signatures, drand, anchors, OpenTimestamps, content), then the result line: `PASS` or `FAIL` and how many checks passed, are pending and were not checked, for example `PASS: 11 of 12 checks passed, 1 pending (public timestamp)`. Pending means co-signed, waiting for the public timestamp (an anchor is made every hour and usually confirms within a few hours); pending and not checked are never counted as passed. The exit code is 0 for PASS, 1 for FAIL and 2 when an input can't be read. Locally, a chain the witness refuses for good (or that an older SDK wrote with time going back) shows as `BLOCKED` with the reason and the next step, and always exits 1; `--fail-on-pending-send` also fails while records wait to be co-signed, for CI. Locally, before anything was co-signed, the witness's checks show `NOT CHECKED` ("nothing co-signed yet") and no request is made. Service keys come from `--keys <file|url>`, or by default from the service's `/.well-known/signafaide-keys.json`. `--trust-embedded-keys` uses the keys inside the pack and prints a warning. The drand BLS signature is checked too, through `py_ecc` (a dependency, as `@noble/curves` is for the TypeScript SDK), so both SDKs count the same checks. A pack's rounds are checked together, so it takes well under a second.
135
+
136
+ ```python
137
+ report = signafaide.verify("pack.json", keys="keys.json")
138
+ print(report) # the same lines
139
+ report.ok # True / False
140
+ ```
141
+
142
+
143
+ **For an auditor** the agent keys must come from you, not from the pack (otherwise a service could add a key of its own): `signafaide keys export -o agent-keys.json` writes every agent key, each signed by itself. The auditor runs `npx signafaide verify pack.json --keys <witness>/.well-known/signafaide-keys.json --agent-keys agent-keys.json --head head.json` (or the Python CLI with the same arguments; `head.json` is your `.signafaide/head.json`, which catches records cut off at the end). Without `--agent-keys` the client-signature check says `NOT INDEPENDENT` and the result is FAIL; `--trust-embedded-agent-keys` accepts the pack's keys and says so in the result line. On your own machine, `signafaide verify pack.json` uses this project's agent keys automatically. Public timestamps are checked online (`--timestamp-check <url>`, default `https://mempool.space/api`); with `--offline` they show `NOT CHECKED`, never confirmed. `signafaide keys rotate --agent X` gives an agent a new key; the SDK signs the registration of the new key with the old one.
144
+
145
+ ## Replay
146
+
147
+ ```bash
148
+ signafaide replay --agent claims-bot --from 2026-11-01 --to 2026-11-13 --html replay.html
149
+ signafaide replay --agent claims-bot --record 3fa2b1c9
150
+ ```
151
+
152
+ Shows the agent's decisions and outcomes in order, with their inputs and outputs from the local chain. Each record is checked against its `content_hash`, its signature and the service's receipt first, so an edited line shows as `FAIL`. `--html` also writes a self-contained page. In code: `r = signafaide.replay(agent="claims-bot", since="2026-11-01")`, then `print(r)`, `r.to_html()`, `r.ok`.
153
+
154
+ ## Experiments: A/B on past decisions
155
+
156
+ ```python
157
+ def approve(claim_id, amount): # same arguments as the recorded tool
158
+ return {"approved": amount < 300}
159
+
160
+ report = signafaide.compare(approve, agent="claims-bot", name="approve_payout", since="2026-11-01", limit=500)
161
+ print(report) # n, agreed, changed, errors, then the changed decisions
162
+ report.upload() # optional: hash-only unless content is stored with the service
163
+ ```
164
+
165
+ The chain is verified first; then each recorded decision with a co-signed outcome is re-run with your candidate, on your machine, with your own models and keys, and compared with the recorded `output` (exact canonical-JSON equality, or `same=lambda recorded, candidate: ...`). A candidate that raises counts as an error. **Candidates must be side-effect free:** compare never writes records, and a recorded tool called from a candidate raises instead of running. CLI: `signafaide compare --module candidate.py:approve --name approve_payout --upload`.
166
+
167
+ ## CLI
168
+
169
+ | command | does |
170
+ |---|---|
171
+ | `signafaide init` | the opt-in: creates a free sandbox if there is no key (no account, email or card), prints the claim link |
172
+ | `signafaide status` | key and env, agents, last receipt, outbox size, quota; BLOCKED chains (exit 1) |
173
+ | `signafaide repair [--agent X] [--dry-run]` | sets aside records no witness can take (kept in `.signafaide/held/`, still verified), gives the agent a new key and starts a new segment |
174
+ | `signafaide verify [pack.json] [--keys ...] [--agent-keys ...] [--head ...] [--offline] [--fail-on-pending-send] [--json]` | verifies; exit 0 PASS, 1 FAIL or BLOCKED, 2 unreadable input |
175
+ | `signafaide keys export [-o agent-keys.json]` / `signafaide keys rotate [--agent X] [--env test\|live]` | the agent-key file for auditors / a new key for an agent |
176
+ | `signafaide keys create [--agent X] [--env test\|live] [--print]` | a new key, registered with the witness, for a secret store; writes no record (`--print`: prints the key file and saves nothing here) |
177
+ | `signafaide export [--agent X] [--from T] [--to T] [-o pack.json]` | downloads a verification pack of the whole project (every agent); `--agent` narrows it to one |
178
+ | `signafaide replay [--agent X] [--from T] [--to T] [--record H] [--html out.html] [--json]` | the verified timeline from the local chain; exit 1 on FAIL |
179
+ | `signafaide compare --module file.py:fn [--agent X] [--name N] [--from T] [--to T] [--limit N] [--upload [--values]]` | re-runs recorded decisions with a candidate and compares; `--values` sends the values too (stored-content decisions only) |
180
+ | `signafaide claim` | prints the claim link again |
181
+ | `signafaide flush` | ships the outbox now (with no key it sends nothing and says so) |
182
+
183
+ ## What is sent besides records
184
+
185
+ Every request carries one header:
186
+
187
+ ```
188
+ X-Signafaide-Client: signafaide-python/0.2.1 (python 3.12.3; adapter=none; agent=claude-code; via=sdk)
189
+ ```
190
+
191
+ It holds the SDK and its version, the Python runtime and its version, and the framework adapter in use (`langchain`, `openai-agents` or `none`). It also holds the coding agent that set the project up (`claude-code`, `codex`, `cursor`, `gemini-cli`, `copilot`, `windsurf` or `unknown`) and how the call was made (`sdk`, `cli` or `mcp`). The agent is found from that tool's environment variables when the project is set up (`signafaide init`) and kept in `.signafaide/config.json`; it is never guessed per process. `signafaide verify` and `signafaide export` also send `POST /v1/events {"name": "verify"}` (or `"export"`) when there is an API key. If that fails, the command is not affected. With no API key nothing at all is sent.
192
+
193
+ The header and the events hold no content, file paths, hostnames or user names. `SIGNAFAIDE_TELEMETRY=0` removes the adapter, the coding agent and the strict-mode flag from the header, stops the coding agent being saved in `.signafaide/config.json`, and turns the events off. The SDK and its version, the runtime and its version, and how the call was made (`via`) are still sent.
194
+
195
+ ## Configuration
196
+
197
+ | variable | default |
198
+ |---|---|
199
+ | `SIGNAFAIDE_API_KEY` | read from the environment, then `.env`; created by `signafaide init`; none: local only |
200
+ | `SIGNAFAIDE_URL` | `https://api.signafaide.co.uk`; a self-hosted witness, or `http://127.0.0.1:7474` for a local dev server. Alone it sends nothing |
201
+ | `SIGNAFAIDE_HOME` | `.signafaide/` in the project root; a mounted volume or `/tmp/signafaide` in production |
202
+ | `SIGNAFAIDE_AGENT_KEY_<NAME>` | an agent's key file content from a secret store (or `key_provider=` in `signafaide.init`); live keys can use `SIGNAFAIDE_AGENT_KEY_LIVE_<NAME>`, made with `signafaide keys create --env live --print` |
203
+ | `SIGNAFAIDE_HEAD_FILE` | unset: a file outside `.signafaide/` (a persistent volume) that also keeps the newest signed heads, for containers whose disk does not last; or `on_head=` in `signafaide.init` |
204
+ | `SIGNAFAIDE_INSTANCE_STOP` | unset: a signed stop note is written at a clean exit when a head file is set; `1` always, `0` never (see Containers that restart) |
205
+ | `SIGNAFAIDE_EXIT_FLUSH_MS` | `3000`: the longest a finished program is kept open to send; `0` never |
206
+ | `SIGNAFAIDE_TEST` | test mode is on by itself under pytest (records in a temporary folder, nothing sent); `1` forces it, `0` turns it off |
207
+ | `SIGNAFAIDE_DISABLED` | `1`: wrapped functions run and nothing is recorded |
208
+ | `SIGNAFAIDE_SEGMENT_BYTES` | `67108864`: a shipped chain file larger than this rolls into a sealed segment |
209
+ | `SIGNAFAIDE_TELEMETRY` | `0` turns off the adapter, agent and strict fields and the events |
210
+
211
+ Running in containers, with replicas, on read-only or ephemeral disks, on serverless or in CI: see the [production guide](https://signafaide.co.uk/docs/production). A record's time never goes back along its chain: if the clock steps back, the record keeps the previous record's time and the clock's reading goes in `meta.wall_time`.
212
+
213
+ Each project and env has its own chain per agent. A new key, for example a live key replacing the sandbox key, starts a fresh chain at seq 1. The old chain stays on disk, and `signafaide status` lists every chain.
214
+
215
+ The project root is the nearest folder with `.signafaide/`, `.git`, `pyproject.toml` or `package.json`. `signafaide init` adds `.signafaide/` to `.gitignore` (never a running program). Agent keys live in `.signafaide/agents/<agent>.key` (sandbox) and `.signafaide/agents/live/<agent>.key` (live), mode 0600, and never leave the machine. Back them up.
216
+
217
+ Python 3.9+. Dependencies: `cryptography` and `py_ecc` (drand BLS).
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "signafaide"
7
+ version = "0.2.1"
8
+ description = "Signed, chained records of what your AI agents decided. Hash-only by default."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ dependencies = ["cryptography>=41", "py_ecc>=7"]
13
+ keywords = ["ai-agents", "audit-trail", "tamper-evident", "verifiable", "eu-ai-act", "provenance"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Topic :: Security :: Cryptography",
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://signafaide.co.uk"
23
+ Documentation = "https://signafaide.co.uk/docs"
24
+ Repository = "https://github.com/AIME-TECH-UK-LIMITED/signafaide-sdk"
25
+ Changelog = "https://github.com/AIME-TECH-UK-LIMITED/signafaide-sdk/blob/main/CHANGELOG.md"
26
+ Issues = "https://github.com/AIME-TECH-UK-LIMITED/signafaide-sdk/issues"
27
+
28
+ [project.optional-dependencies]
29
+ # Kept so `pip install "signafaide[bls]"` still works; BLS is now a regular dependency.
30
+ bls = []
31
+
32
+ [project.scripts]
33
+ signafaide = "signafaide.cli:main"
34
+
35
+ [dependency-groups]
36
+ dev = ["pytest>=7", "langchain-core>=0.3", "openai-agents>=0.2", "py_ecc>=7"]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/signafaide"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]