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.
- arcaeon_ledger-0.2.0/.gitignore +7 -0
- arcaeon_ledger-0.2.0/CHANGELOG.md +13 -0
- arcaeon_ledger-0.2.0/LICENSE +21 -0
- arcaeon_ledger-0.2.0/PKG-INFO +134 -0
- arcaeon_ledger-0.2.0/README.md +118 -0
- arcaeon_ledger-0.2.0/ledger/__init__.py +197 -0
- arcaeon_ledger-0.2.0/ledger/cli.py +45 -0
- arcaeon_ledger-0.2.0/ledger/mcp_server.py +130 -0
- arcaeon_ledger-0.2.0/pyproject.toml +29 -0
- arcaeon_ledger-0.2.0/test_ledger.py +97 -0
|
@@ -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.")
|