arcaeon-ledger 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ *.log.jsonl
7
+ .venv/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 — 2026-08-12
4
+ - Added `authority()` helper + `append(..., authority=...)`: bind WHO wrote each entry and with what permission (resolved principal, capability version, hashed tool schema, trusted time source). It chains like any field, so editing the writer identity breaks the chain too. Composes tamper-evidence with permission-replay. Shipped same-day in response to community feedback on launch.
5
+
6
+
7
+ ## 0.1.0 — 2026-08-12
8
+ - Initial release. Zero-dependency tamper-evident, hash-chained action log for AI agents.
9
+ - Core library: `Ledger(path).append(record)` and `.verify()`; module `verify_file(path)`.
10
+ - Hash chain: `sha256(prev_chain + canonical_json(row_without_chain))[:32]`, genesis-seeded, atomic append (append-binary + flush + fsync).
11
+ - CLI: `ledger verify|append` with nonzero exit on a broken chain (CI/pre-ship gate).
12
+ - MCP server (`python -m ledger.mcp_server`): drop-in `ledger_append` / `ledger_verify` tools for any MCP client, zero-dependency JSON-RPC over stdio.
13
+ - Tested against edit, delete, and reorder tampering, and a full MCP handshake including tamper detection over the wire.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arcaeon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: arcaeon-ledger
3
+ Version: 0.2.0
4
+ Summary: Tamper-evident, hash-chained action log for AI agents. Prove what your agent did.
5
+ Project-URL: Homepage, https://arcaeon.io
6
+ Author: Arcaeon
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: agents,ai,audit,hash-chain,mcp,provenance,tamper-evident
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ledger
18
+
19
+ **Observability tools show you what your agent did. `ledger` lets you _prove_ it.**
20
+
21
+ Every record is hash-chained to the one before it. Edit a row, delete one, or
22
+ reorder history, and every later link breaks — `verify` names the exact line.
23
+ You own the record, and you can prove it wasn't altered. Zero dependencies, one
24
+ JSONL file, two verbs.
25
+
26
+ ```python
27
+ from ledger import Ledger
28
+
29
+ log = Ledger("agent.log.jsonl")
30
+ log.append({"tool": "web.search", "query": "weather in LA", "result_ok": True})
31
+ log.append({"tool": "payment", "amount": "49.00", "currency": "USD"})
32
+
33
+ log.verify() # VerifyResult(ok=True, rows=2, chained=2, ...)
34
+ ```
35
+
36
+ Tampering is caught, not hoped against:
37
+
38
+ ```python
39
+ # someone edits row 1's amount in the file by hand...
40
+ log.verify() # VerifyResult(ok=False, first_break="line 1: chain mismatch")
41
+ ```
42
+
43
+ CLI (wire it into CI or a pre-ship gate — a tampered log exits nonzero):
44
+
45
+ ```
46
+ python -m ledger.cli append agent.log.jsonl '{"tool":"search","ok":true}'
47
+ python -m ledger.cli verify agent.log.jsonl # exit 0 = intact, 1 = broken
48
+ ```
49
+
50
+ ## Prove *who* acted, not just the order
51
+
52
+ A hash chain proves sequence integrity — it can't prove who wrote each entry or
53
+ whether they were allowed to. Attach an `authority` block to bind the actor and
54
+ their permission surface into the chained (tamper-evident) row:
55
+
56
+ ```python
57
+ from ledger import Ledger, authority
58
+
59
+ log = Ledger("agent.log.jsonl")
60
+ log.append(
61
+ {"tool": "payment", "amount": "49.00"},
62
+ authority=authority(
63
+ "agent://billing-7",
64
+ capability_version="v3", # what they were allowed to do
65
+ tool_schema={"name": "payment", "args": ["amount"]}, # hashed, not just named
66
+ time_source="ntp", # trust surface of the clock
67
+ ),
68
+ )
69
+ ```
70
+
71
+ Now the audit question sharpens from *"was this edited?"* to *"was this edited
72
+ **and** was the writer authorized?"* — editing the principal, capability, or
73
+ schema hash breaks the chain like any other tamper. This composes tamper-evidence
74
+ with permission-replay. (Shipped in response to community feedback on launch.)
75
+
76
+ ## Why this exists
77
+
78
+ The loudest unmet pain for agent builders in 2026 is the reliability/audit gap:
79
+ an agent "completes" a task and the result is quietly wrong, and you can't
80
+ reconstruct — or prove — what actually happened. Observability platforms trace
81
+ runs; none give you a **tamper-evident, portable, ownable** record. Regulations
82
+ (EU AI Act Art. 12, tamper-evident AI decision records) are starting to require
83
+ exactly this. `ledger` is the smallest honest version: a cryptographically
84
+ chained action log you drop in, own, and verify.
85
+
86
+ ## How the chain works
87
+
88
+ `chain = sha256(prev_chain + canonical_json(row_without_chain))[:32]`
89
+
90
+ Each row commits to the entire history before it. The first row chains from a
91
+ fixed `"genesis"` seed. Rows without a `chain` field are tolerated only before
92
+ the first chained row (so you can adopt it on an existing log); an unchained row
93
+ appearing *after* the chain begins is itself flagged. On a mismatch, verify
94
+ keeps going from the claimed value so it counts later damage honestly instead of
95
+ cascading one break into noise.
96
+
97
+ The honest limit: `ledger` proves a file wasn't altered *after* writing. It does
98
+ not prove the writer was honest at write time, and it does not by itself defend
99
+ against someone who rewrites the whole chain from a chosen point forward — for
100
+ that you periodically anchor the latest chain value somewhere you don't control
101
+ (a commit, a timestamp service, a witness). That anchoring is on the roadmap;
102
+ the core tamper-evidence is here and tested.
103
+
104
+ ## Drop it into any MCP agent
105
+
106
+ `ledger` ships a zero-dependency MCP server, so any MCP client (Claude Code,
107
+ etc.) can give its agent tamper-evident logging with no code. Wire it in:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "ledger": {
113
+ "command": "python",
114
+ "args": ["-m", "ledger.mcp_server", "--log", "agent.log.jsonl"]
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ The agent then has two tools: `ledger_append(record)` to log an action
121
+ (returns its chain hash) and `ledger_verify()` to prove the whole log is
122
+ intact (or get the exact tampered line back). MCP is JSON-RPC over stdio and
123
+ this server speaks it directly — no SDK, no extra install.
124
+
125
+ ## Status
126
+
127
+ Core library, CLI, and a drop-in **MCP server**, all tested: the library
128
+ against edit / delete / reorder tampering (`test_ledger.py`), the MCP server
129
+ through a full initialize → tools/list → append → verify handshake including
130
+ tamper detection over the wire. Extracted from a hash-chained action ledger
131
+ running in production. A hosted collection tier (retention + compliance export)
132
+ and periodic external anchoring are the next layers.
133
+
134
+ MIT.
@@ -0,0 +1,118 @@
1
+ # ledger
2
+
3
+ **Observability tools show you what your agent did. `ledger` lets you _prove_ it.**
4
+
5
+ Every record is hash-chained to the one before it. Edit a row, delete one, or
6
+ reorder history, and every later link breaks — `verify` names the exact line.
7
+ You own the record, and you can prove it wasn't altered. Zero dependencies, one
8
+ JSONL file, two verbs.
9
+
10
+ ```python
11
+ from ledger import Ledger
12
+
13
+ log = Ledger("agent.log.jsonl")
14
+ log.append({"tool": "web.search", "query": "weather in LA", "result_ok": True})
15
+ log.append({"tool": "payment", "amount": "49.00", "currency": "USD"})
16
+
17
+ log.verify() # VerifyResult(ok=True, rows=2, chained=2, ...)
18
+ ```
19
+
20
+ Tampering is caught, not hoped against:
21
+
22
+ ```python
23
+ # someone edits row 1's amount in the file by hand...
24
+ log.verify() # VerifyResult(ok=False, first_break="line 1: chain mismatch")
25
+ ```
26
+
27
+ CLI (wire it into CI or a pre-ship gate — a tampered log exits nonzero):
28
+
29
+ ```
30
+ python -m ledger.cli append agent.log.jsonl '{"tool":"search","ok":true}'
31
+ python -m ledger.cli verify agent.log.jsonl # exit 0 = intact, 1 = broken
32
+ ```
33
+
34
+ ## Prove *who* acted, not just the order
35
+
36
+ A hash chain proves sequence integrity — it can't prove who wrote each entry or
37
+ whether they were allowed to. Attach an `authority` block to bind the actor and
38
+ their permission surface into the chained (tamper-evident) row:
39
+
40
+ ```python
41
+ from ledger import Ledger, authority
42
+
43
+ log = Ledger("agent.log.jsonl")
44
+ log.append(
45
+ {"tool": "payment", "amount": "49.00"},
46
+ authority=authority(
47
+ "agent://billing-7",
48
+ capability_version="v3", # what they were allowed to do
49
+ tool_schema={"name": "payment", "args": ["amount"]}, # hashed, not just named
50
+ time_source="ntp", # trust surface of the clock
51
+ ),
52
+ )
53
+ ```
54
+
55
+ Now the audit question sharpens from *"was this edited?"* to *"was this edited
56
+ **and** was the writer authorized?"* — editing the principal, capability, or
57
+ schema hash breaks the chain like any other tamper. This composes tamper-evidence
58
+ with permission-replay. (Shipped in response to community feedback on launch.)
59
+
60
+ ## Why this exists
61
+
62
+ The loudest unmet pain for agent builders in 2026 is the reliability/audit gap:
63
+ an agent "completes" a task and the result is quietly wrong, and you can't
64
+ reconstruct — or prove — what actually happened. Observability platforms trace
65
+ runs; none give you a **tamper-evident, portable, ownable** record. Regulations
66
+ (EU AI Act Art. 12, tamper-evident AI decision records) are starting to require
67
+ exactly this. `ledger` is the smallest honest version: a cryptographically
68
+ chained action log you drop in, own, and verify.
69
+
70
+ ## How the chain works
71
+
72
+ `chain = sha256(prev_chain + canonical_json(row_without_chain))[:32]`
73
+
74
+ Each row commits to the entire history before it. The first row chains from a
75
+ fixed `"genesis"` seed. Rows without a `chain` field are tolerated only before
76
+ the first chained row (so you can adopt it on an existing log); an unchained row
77
+ appearing *after* the chain begins is itself flagged. On a mismatch, verify
78
+ keeps going from the claimed value so it counts later damage honestly instead of
79
+ cascading one break into noise.
80
+
81
+ The honest limit: `ledger` proves a file wasn't altered *after* writing. It does
82
+ not prove the writer was honest at write time, and it does not by itself defend
83
+ against someone who rewrites the whole chain from a chosen point forward — for
84
+ that you periodically anchor the latest chain value somewhere you don't control
85
+ (a commit, a timestamp service, a witness). That anchoring is on the roadmap;
86
+ the core tamper-evidence is here and tested.
87
+
88
+ ## Drop it into any MCP agent
89
+
90
+ `ledger` ships a zero-dependency MCP server, so any MCP client (Claude Code,
91
+ etc.) can give its agent tamper-evident logging with no code. Wire it in:
92
+
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "ledger": {
97
+ "command": "python",
98
+ "args": ["-m", "ledger.mcp_server", "--log", "agent.log.jsonl"]
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ The agent then has two tools: `ledger_append(record)` to log an action
105
+ (returns its chain hash) and `ledger_verify()` to prove the whole log is
106
+ intact (or get the exact tampered line back). MCP is JSON-RPC over stdio and
107
+ this server speaks it directly — no SDK, no extra install.
108
+
109
+ ## Status
110
+
111
+ Core library, CLI, and a drop-in **MCP server**, all tested: the library
112
+ against edit / delete / reorder tampering (`test_ledger.py`), the MCP server
113
+ through a full initialize → tools/list → append → verify handshake including
114
+ tamper detection over the wire. Extracted from a hash-chained action ledger
115
+ running in production. A hosted collection tier (retention + compliance export)
116
+ and periodic external anchoring are the next layers.
117
+
118
+ MIT.
@@ -0,0 +1,197 @@
1
+ """ledger — a tamper-evident, append-only action log for AI agents.
2
+
3
+ Observability tools show you what your agent did. `ledger` lets you PROVE it:
4
+ every record is hash-chained to the one before it, so an edit, deletion, or
5
+ reorder anywhere in the history breaks every later link and `verify()` names the
6
+ exact line. Own the record; prove it wasn't altered.
7
+
8
+ Zero dependencies (stdlib only). One JSONL file. Two verbs: append, verify.
9
+
10
+ from ledger import Ledger
11
+ log = Ledger("agent.log.jsonl")
12
+ log.append({"tool": "search", "query": "weather", "result_ok": True})
13
+ log.verify() # -> VerifyResult(ok=True, rows=1, ...)
14
+
15
+ Extracted from a hash-chained action ledger running in production. MIT.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import os
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any, Iterator
26
+
27
+ __version__ = "0.2.0"
28
+ __all__ = ["Ledger", "VerifyResult", "verify_file", "authority"]
29
+
30
+ _GENESIS = "genesis"
31
+ _CHAIN_LEN = 32 # first N hex chars of the sha256 — plenty for tamper-evidence
32
+
33
+
34
+ def _now_iso() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
36
+
37
+
38
+ def _chain(prev: str, obj: dict) -> str:
39
+ """chain = sha256(prev_chain + canonical-json-of-row-without-chain)."""
40
+ body = json.dumps({k: v for k, v in obj.items() if k != "chain"},
41
+ ensure_ascii=False, sort_keys=True)
42
+ return hashlib.sha256((prev + body).encode("utf-8")).hexdigest()[:_CHAIN_LEN]
43
+
44
+
45
+ def authority(principal: str, *, capability_version: str | None = None,
46
+ tool_schema: Any = None, time_source: str = "local") -> dict:
47
+ """Build an `authority` block for an entry: WHO acted, with what authority.
48
+
49
+ A hash chain proves ORDER, not who wrote each entry or whether they were
50
+ allowed to. Attaching this block makes the audit question sharper — "was
51
+ this edited?" becomes "was this edited AND was the writer authorized?" —
52
+ and lets tamper-evidence compose with permission-replay. (Requested by the
53
+ community on launch, 2026-08-12.)
54
+
55
+ - principal: the resolved actor (agent id, user, service).
56
+ - capability_version: the version of the permission/capability set in force.
57
+ - tool_schema: the tool's schema/signature — HASHED, so you bind what the
58
+ tool looked like at call time, not just its name.
59
+ - time_source: where the timestamp came from (e.g. "local", "ntp", an
60
+ external attestation id). Names the trust surface of the clock.
61
+ """
62
+ block: dict[str, Any] = {"principal": principal, "time_source": time_source}
63
+ if capability_version is not None:
64
+ block["capability_version"] = capability_version
65
+ if tool_schema is not None:
66
+ canon = json.dumps(tool_schema, ensure_ascii=False, sort_keys=True)
67
+ block["tool_schema_hash"] = hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]
68
+ return block
69
+
70
+
71
+ @dataclass
72
+ class VerifyResult:
73
+ ok: bool
74
+ rows: int = 0
75
+ chained: int = 0
76
+ prechain: int = 0
77
+ first_break: str | None = None
78
+
79
+ def __bool__(self) -> bool: # `if log.verify(): ...`
80
+ return self.ok
81
+
82
+
83
+ class Ledger:
84
+ """A hash-chained append-only JSONL log. One file, atomic appends."""
85
+
86
+ def __init__(self, path: str | Path):
87
+ self.path = Path(path)
88
+
89
+ # -- write --------------------------------------------------------------
90
+ def append(self, record: dict[str, Any], *, authority: dict | None = None) -> str:
91
+ """Append one record; returns its chain hash. Stamps `ts` if absent.
92
+
93
+ Pass `authority=` (build it with the module-level `authority()` helper)
94
+ to bind WHO wrote the entry and with what permission — it becomes part
95
+ of the chained, tamper-evident row, so the authority surface is proven
96
+ alongside the order.
97
+
98
+ Atomic: append-binary + flush + fsync, so a crash mid-write never
99
+ corrupts the file (a partial line fails to parse and is caught by
100
+ verify, it never silently poisons the chain).
101
+ """
102
+ obj = dict(record)
103
+ if authority is not None:
104
+ obj["authority"] = authority
105
+ obj.setdefault("ts", _now_iso())
106
+ obj.pop("chain", None)
107
+ obj["chain"] = _chain(self._last_chain(), obj)
108
+ line = json.dumps(obj, ensure_ascii=False) + "\n"
109
+ self.path.parent.mkdir(parents=True, exist_ok=True)
110
+ with self.path.open("ab") as fh:
111
+ fh.write(line.encode("utf-8"))
112
+ fh.flush()
113
+ try:
114
+ os.fsync(fh.fileno())
115
+ except OSError:
116
+ pass # network mounts may not support fsync; flush is the floor
117
+ return obj["chain"]
118
+
119
+ def _last_chain(self) -> str:
120
+ """Chain of the last row ('genesis' if empty/missing). Tail-read only."""
121
+ try:
122
+ with self.path.open("rb") as fh:
123
+ fh.seek(0, os.SEEK_END)
124
+ size = fh.tell()
125
+ fh.seek(max(0, size - 8192))
126
+ tail = fh.read().decode("utf-8", errors="replace")
127
+ except OSError:
128
+ return _GENESIS
129
+ for raw in reversed(tail.splitlines()):
130
+ raw = raw.strip()
131
+ if not raw:
132
+ continue
133
+ try:
134
+ return json.loads(raw).get("chain") or _GENESIS
135
+ except ValueError:
136
+ continue
137
+ return _GENESIS
138
+
139
+ # -- read ---------------------------------------------------------------
140
+ def __iter__(self) -> Iterator[dict]:
141
+ try:
142
+ for raw in self.path.read_text(encoding="utf-8",
143
+ errors="replace").splitlines():
144
+ raw = raw.strip()
145
+ if raw:
146
+ try:
147
+ yield json.loads(raw)
148
+ except ValueError:
149
+ continue
150
+ except OSError:
151
+ return
152
+
153
+ # -- verify -------------------------------------------------------------
154
+ def verify(self) -> VerifyResult:
155
+ return verify_file(self.path)
156
+
157
+
158
+ def verify_file(path: str | Path) -> VerifyResult:
159
+ """Recompute the chain over a file; report the first break by line number.
160
+
161
+ Rows with no `chain` field are tolerated ONLY before the first chained row
162
+ (legacy/pre-chain history) — an unchained row appearing after the chain has
163
+ begun is itself a tamper signal. On a mismatch, verification continues from
164
+ the CLAIMED value so later damage is counted honestly rather than cascading.
165
+ """
166
+ res = VerifyResult(ok=True)
167
+ try:
168
+ lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
169
+ except OSError as e:
170
+ return VerifyResult(ok=False, first_break=f"unreadable: {e}")
171
+ prev = _GENESIS
172
+ for i, raw in enumerate(lines, 1):
173
+ raw = raw.strip()
174
+ if not raw:
175
+ continue
176
+ try:
177
+ obj = json.loads(raw)
178
+ except ValueError:
179
+ res.ok = False
180
+ res.first_break = res.first_break or f"line {i}: unparseable"
181
+ continue
182
+ res.rows += 1
183
+ claimed = obj.pop("chain", None)
184
+ if claimed is None:
185
+ if res.chained:
186
+ res.ok = False
187
+ res.first_break = res.first_break or f"line {i}: unchained row after chain began"
188
+ else:
189
+ res.prechain += 1
190
+ continue
191
+ want = _chain(prev, obj)
192
+ if claimed != want:
193
+ res.ok = False
194
+ res.first_break = res.first_break or f"line {i}: chain mismatch"
195
+ prev = claimed
196
+ res.chained += 1
197
+ return res
@@ -0,0 +1,45 @@
1
+ """ledger CLI — verify or append from the command line.
2
+
3
+ python -m ledger.cli verify agent.log.jsonl
4
+ python -m ledger.cli append agent.log.jsonl '{"tool":"search","ok":true}'
5
+
6
+ Exit code 0 = chain intact, 1 = broken (or bad usage). The nonzero exit is the
7
+ point: wire `verify` into CI or a pre-ship gate and a tampered log fails loud.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+
14
+ from ledger import Ledger, verify_file
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ argv = list(sys.argv[1:] if argv is None else argv)
19
+ if len(argv) < 2 or argv[0] not in ("verify", "append"):
20
+ print((__doc__ or "usage: ledger verify|append <path> [record]").strip())
21
+ return 1
22
+ cmd, path = argv[0], argv[1]
23
+ if cmd == "verify":
24
+ r = verify_file(path)
25
+ print(json.dumps(r.__dict__, indent=1))
26
+ return 0 if r.ok else 1
27
+ # append
28
+ if len(argv) < 3:
29
+ print("append needs a JSON record argument")
30
+ return 1
31
+ try:
32
+ record = json.loads(argv[2])
33
+ except ValueError as e:
34
+ print(f"bad JSON: {e}")
35
+ return 1
36
+ if not isinstance(record, dict):
37
+ print("record must be a JSON object")
38
+ return 1
39
+ chain = Ledger(path).append(record)
40
+ print(chain)
41
+ return 0
42
+
43
+
44
+ if __name__ == "__main__":
45
+ sys.exit(main())
@@ -0,0 +1,130 @@
1
+ """ledger MCP server — drop-in tamper-evident logging for any MCP agent.
2
+
3
+ Zero dependencies: MCP is JSON-RPC 2.0 over stdio, so this speaks it directly
4
+ rather than pulling the SDK (keeps the whole product install-free). Any MCP
5
+ client (Claude Code, etc.) can wire it in and its agent gets two tools:
6
+
7
+ ledger_append(record) -> chain hash (log an action, tamper-evidently)
8
+ ledger_verify() -> verify result (prove the log wasn't altered)
9
+
10
+ Run: python -m ledger.mcp_server [--log PATH]
11
+ Wire into an MCP client (e.g. Claude Code .mcp.json):
12
+ { "mcpServers": { "ledger": {
13
+ "command": "python", "args": ["-m", "ledger.mcp_server", "--log", "agent.log.jsonl"] } } }
14
+
15
+ Implements the slice of MCP a tool server needs: initialize, tools/list,
16
+ tools/call. Protocol version 2025-06-18. Notifications are ignored (no id).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import sys
23
+
24
+ from ledger import Ledger
25
+
26
+ PROTOCOL_VERSION = "2025-06-18"
27
+
28
+ TOOLS = [
29
+ {
30
+ "name": "ledger_append",
31
+ "description": ("Append one action record to a tamper-evident, hash-chained "
32
+ "log. Returns the record's chain hash. Use this to log every "
33
+ "consequential action (tool calls, payments, decisions) so the "
34
+ "history can later be proven unaltered."),
35
+ "inputSchema": {
36
+ "type": "object",
37
+ "properties": {
38
+ "record": {
39
+ "type": "object",
40
+ "description": "Any JSON object describing the action (tool, args, "
41
+ "result, actor, etc.). A `ts` timestamp is added if absent.",
42
+ "additionalProperties": True,
43
+ },
44
+ },
45
+ "required": ["record"],
46
+ },
47
+ },
48
+ {
49
+ "name": "ledger_verify",
50
+ "description": ("Verify the hash chain over the whole log. Returns ok + row "
51
+ "counts, and names the exact line of the first break if the log "
52
+ "was edited, had a row deleted, or was reordered."),
53
+ "inputSchema": {"type": "object", "properties": {}},
54
+ },
55
+ ]
56
+
57
+
58
+ def _result(id_, payload):
59
+ return {"jsonrpc": "2.0", "id": id_, "result": payload}
60
+
61
+
62
+ def _error(id_, code, message):
63
+ return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}}
64
+
65
+
66
+ def _text_content(obj) -> dict:
67
+ return {"content": [{"type": "text", "text": json.dumps(obj)}]}
68
+
69
+
70
+ def handle(msg: dict, log: Ledger):
71
+ """Return a response dict, or None for notifications (no id)."""
72
+ mid = msg.get("id")
73
+ method = msg.get("method")
74
+ if mid is None: # notification (e.g. notifications/initialized) — no reply
75
+ return None
76
+
77
+ if method == "initialize":
78
+ return _result(mid, {
79
+ "protocolVersion": PROTOCOL_VERSION,
80
+ "capabilities": {"tools": {}},
81
+ "serverInfo": {"name": "ledger", "version": "0.1.0"},
82
+ })
83
+ if method == "tools/list":
84
+ return _result(mid, {"tools": TOOLS})
85
+ if method == "tools/call":
86
+ params = msg.get("params") or {}
87
+ name = params.get("name")
88
+ args = params.get("arguments") or {}
89
+ try:
90
+ if name == "ledger_append":
91
+ record = args.get("record")
92
+ if not isinstance(record, dict):
93
+ return _result(mid, {**_text_content(
94
+ {"error": "record must be a JSON object"}), "isError": True})
95
+ chain = log.append(record)
96
+ return _result(mid, _text_content({"ok": True, "chain": chain}))
97
+ if name == "ledger_verify":
98
+ r = log.verify()
99
+ return _result(mid, _text_content(r.__dict__))
100
+ return _result(mid, {**_text_content(
101
+ {"error": f"unknown tool {name}"}), "isError": True})
102
+ except Exception as e: # never crash the server on one bad call
103
+ return _result(mid, {**_text_content({"error": str(e)}), "isError": True})
104
+
105
+ return _error(mid, -32601, f"method not found: {method}")
106
+
107
+
108
+ def main(argv=None) -> int:
109
+ ap = argparse.ArgumentParser()
110
+ ap.add_argument("--log", default="agent.log.jsonl", help="ledger file path")
111
+ args = ap.parse_args(argv)
112
+ log = Ledger(args.log)
113
+
114
+ for line in sys.stdin:
115
+ line = line.strip()
116
+ if not line:
117
+ continue
118
+ try:
119
+ msg = json.loads(line)
120
+ except ValueError:
121
+ continue # not JSON-RPC; skip
122
+ resp = handle(msg, log)
123
+ if resp is not None:
124
+ sys.stdout.write(json.dumps(resp) + "\n")
125
+ sys.stdout.flush()
126
+ return 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "arcaeon-ledger"
7
+ version = "0.2.0"
8
+ description = "Tamper-evident, hash-chained action log for AI agents. Prove what your agent did."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Arcaeon" }]
13
+ keywords = ["ai", "agents", "mcp", "audit", "tamper-evident", "provenance", "hash-chain"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Software Development :: Libraries",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.scripts]
23
+ ledger = "ledger.cli:main"
24
+
25
+ [project.urls]
26
+ Homepage = "https://arcaeon.io"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["ledger"]
@@ -0,0 +1,97 @@
1
+ """Tests for ledger — the whole product claim is 'tamper-evident,' so the
2
+ negative test (tampering is CAUGHT at the exact line) is the load-bearing one.
3
+ Run: python test_ledger.py
4
+ """
5
+ import json
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from ledger import Ledger
10
+
11
+
12
+ def test_append_and_verify_clean():
13
+ with tempfile.TemporaryDirectory() as d:
14
+ log = Ledger(Path(d) / "a.jsonl")
15
+ for i in range(5):
16
+ log.append({"tool": "search", "n": i})
17
+ r = log.verify()
18
+ assert r.ok, r
19
+ assert r.rows == 5 and r.chained == 5 and r.prechain == 0
20
+ print("PASS clean append+verify (5 rows chain intact)")
21
+
22
+
23
+ def test_tamper_edit_is_caught():
24
+ with tempfile.TemporaryDirectory() as d:
25
+ p = Path(d) / "b.jsonl"
26
+ log = Ledger(p)
27
+ for i in range(5):
28
+ log.append({"tool": "pay", "amount": i})
29
+ # Tamper: edit row 3's amount in place, keep its (now-wrong) chain.
30
+ lines = p.read_text(encoding="utf-8").splitlines()
31
+ obj = json.loads(lines[2])
32
+ obj["amount"] = 999
33
+ lines[2] = json.dumps(obj, ensure_ascii=False)
34
+ p.write_text("\n".join(lines) + "\n", encoding="utf-8")
35
+ r = log.verify()
36
+ assert not r.ok, "tamper went undetected!"
37
+ assert r.first_break == "line 3: chain mismatch", r.first_break
38
+ print(f"PASS tamper caught at exact line (first_break='line 3: chain mismatch')")
39
+
40
+
41
+ def test_delete_row_is_caught():
42
+ with tempfile.TemporaryDirectory() as d:
43
+ p = Path(d) / "c.jsonl"
44
+ log = Ledger(p)
45
+ for i in range(5):
46
+ log.append({"event": i})
47
+ lines = p.read_text(encoding="utf-8").splitlines()
48
+ del lines[2] # delete row 3
49
+ p.write_text("\n".join(lines) + "\n", encoding="utf-8")
50
+ r = log.verify()
51
+ assert not r.ok, "deletion went undetected!"
52
+ print("PASS deletion caught (removing a row breaks the chain)")
53
+
54
+
55
+ def test_reorder_is_caught():
56
+ with tempfile.TemporaryDirectory() as d:
57
+ p = Path(d) / "e.jsonl"
58
+ log = Ledger(p)
59
+ for i in range(5):
60
+ log.append({"event": i})
61
+ lines = p.read_text(encoding="utf-8").splitlines()
62
+ lines[1], lines[3] = lines[3], lines[1] # swap rows 2 and 4
63
+ p.write_text("\n".join(lines) + "\n", encoding="utf-8")
64
+ r = log.verify()
65
+ assert not r.ok, "reorder went undetected!"
66
+ print("PASS reorder caught (swapping rows breaks the chain)")
67
+
68
+
69
+ def test_authority_block_is_chained():
70
+ from ledger import authority
71
+ with tempfile.TemporaryDirectory() as d:
72
+ p = Path(d) / "auth.jsonl"
73
+ log = Ledger(p)
74
+ auth = authority("agent://scout-7", capability_version="v3",
75
+ tool_schema={"name": "web.search", "args": ["q"]},
76
+ time_source="ntp")
77
+ log.append({"tool": "web.search", "q": "weather"}, authority=auth)
78
+ # the authority block is present, hashed schema, and inside the chain
79
+ row = json.loads(p.read_text(encoding="utf-8").splitlines()[0])
80
+ assert row["authority"]["principal"] == "agent://scout-7"
81
+ assert row["authority"]["capability_version"] == "v3"
82
+ assert len(row["authority"]["tool_schema_hash"]) == 16
83
+ assert log.verify().ok
84
+ # tamper the principal -> chain must break (authority is protected)
85
+ row["authority"]["principal"] = "agent://impostor"
86
+ p.write_text(json.dumps(row) + "\n", encoding="utf-8")
87
+ assert not log.verify().ok, "editing the authority block should break the chain!"
88
+ print("PASS authority block chained + tamper-protected")
89
+
90
+
91
+ if __name__ == "__main__":
92
+ test_append_and_verify_clean()
93
+ test_tamper_edit_is_caught()
94
+ test_delete_row_is_caught()
95
+ test_reorder_is_caught()
96
+ test_authority_block_is_chained()
97
+ print("\nALL PASS — tamper-evidence holds against edit, delete, and reorder.")