legion-lock 0.1.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,19 @@
1
+ Copyright (c) 2026 Legion33shadow
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
14
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
15
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
16
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
17
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
18
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
19
+ OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: legion-lock
3
+ Version: 0.1.0
4
+ Summary: Pin MCP server tool lists in a lockfile; fail CI on tool drift, with signed evidence from the LEGION transparency log
5
+ License: MIT
6
+ Project-URL: Source, https://github.com/Legion33shadow/legion-lock
7
+ Project-URL: Lockfile format, https://github.com/Legion33shadow/legion-lock/blob/main/FORMAT.md
8
+ Keywords: mcp,model-context-protocol,lockfile,tool-pinning,tool-drift,transparency-log,supply-chain,ed25519,rfc9162
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Topic :: Security
12
+ Classifier: Environment :: Console
13
+ Requires-Python: >=3.8
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # legion-lock — pin MCP server tools in a lockfile, fail CI on tool drift
19
+
20
+ An MCP server can change a tool's description or input schema after you approved it, and a model reads those
21
+ descriptions as instructions (tool drift, "rug pull"). `legion-lock` pins the approved tool list of each MCP server in
22
+ `.mcp-tools.lock.json`. `check` fails as soon as a server changed, and names the change: tool added, removed,
23
+ description changed, schema paths changed.
24
+
25
+ Fingerprints come from the public LEGION transparency log of MCP tool lists (`https://tlog.legion-api.com/tlog/v1`).
26
+ Each one is signed (Ed25519), included in an append-only Merkle tree (RFC 9162) and checked against a root key
27
+ fingerprint you pin. The lockfile keeps the proofs: `verify` re-checks them offline.
28
+
29
+ [Version française](README.fr.md)
30
+
31
+ ## Pin in 3 commands
32
+ ```bash
33
+ python3 legion_lock.py add io.guestgraph/mental-model # pin the current state (verified proof) in .mcp-tools.lock.json
34
+ python3 legion_lock.py check # 0 = matches · 1 = DRIFT (per-tool diff) · 2 = invalid proof
35
+ python3 legion_lock.py verify # offline: re-check the proofs stored in the lockfile
36
+ ```
37
+ The id is the one of the official MCP registry. To approve an earlier version: `add ID --at-leaf N`. To prove it
38
+ against a specific signed head: `--at-head S`.
39
+
40
+ Requirements: Python 3.8+, no dependency (`cryptography` is used if installed, otherwise pure-Python Ed25519).
41
+ Files: `legion_lock.py`, plus `vendor/legion_tlog_verify.py` and `vendor/legion_verify_mirror.py`, LEGION's two public
42
+ verifiers.
43
+
44
+ ## Check one live server in one command: `legion_verify_server.py`
45
+ ```bash
46
+ python3 legion_verify_server.py https://mcp.example.com/mcp --save proof.json # live tools/list vs the signed history
47
+ python3 legion_verify_server.py --offline proof.json # re-check the saved proof, no network
48
+ ```
49
+ Verdict: `UNCHANGED` (live list = latest signed version) · `CHANGED` (per-tool diff) · `UNKNOWN` (never signed by the
50
+ log) · `AUTH` (server requires auth). It only sends `initialize` and `tools/list`, never calls a tool. No score.
51
+
52
+ ## Demo (real drift, public log)
53
+ `demo/demo_drift.sh` pins two servers to a state BEFORE a change they really made, then runs `check`:
54
+ `io.guestgraph/mental-model` (tool `diagram`: description and 2 schema paths changed, leaf 16044 → 17659) and
55
+ `ai.daystruct.api/daystruct` (tool `plan_project_development` added, leaf 121 → 14516). Expected: exit 1 with the drift
56
+ named; offline `verify` = 0; hand-edited lockfile = 2. Transcript: `demo/transcript_drift_*.txt`.
57
+ Adversarial tests (lying log, forged archive, rewritten history, forged lockfile): `tests/test_adversarial.py`, 15/15.
58
+
59
+ ## What `check` verifies, in order
60
+ 1. The log keyring starts from the pinned root fingerprint `a7c6a35b…`.
61
+ 2. Your pin and the current state are both signed records, included in signed heads (RFC 9162).
62
+ 3. The current head extends the head of your pin (consistency proof). Otherwise: rewritten history, fork or rollback,
63
+ exit 2.
64
+ 4. Is the current fingerprint the approved one? If not, `check` fetches the archived definitions of both versions,
65
+ accepts them only if they hash back to the signed fingerprints, then names the drift field by field.
66
+
67
+ ## Limits (read them)
68
+ - This is **the signed observation of a single observer** (LEGION). It does not show what the server serves to other
69
+ clients.
70
+ - Only `name`, `description` and `inputSchema` are fingerprinted. A change limited to `annotations`, `outputSchema` or
71
+ `title` is not seen.
72
+ - Delay: a change appears only after LEGION's next observation, then the next signed head (one to three signed cycles a
73
+ day, measured 9–11 Oct 2026). `check --strict` also fails when a newer observation is not signed yet.
74
+ - Servers that require authentication are not observed.
75
+ - The log's Bitcoin anchors are `pending` (no `ots verify` on a full node). `legion-lock` does not rely on them.
76
+
77
+ ## Integrations (`integrations/`)
78
+ - `github/mcp-tools-lock.yml`: on every pull request and daily, `verify` then `check`. The job fails on drift.
79
+ - `pre-commit/pre-commit.sh` (raw git hook).
80
+ - `claude-code/settings.json` + `legion_lock_hook.sh`: `PreToolUse` hook on `mcp__.*`. Claude Code blocks the MCP tool
81
+ call if a pinned server drifted. Result cached 10 min.
82
+
83
+ Lockfile format: [FORMAT.md](FORMAT.md). Read-only MCP server on the same log:
84
+ [legion-mcp](https://github.com/Legion33shadow/legion-mcp). License: MIT.
@@ -0,0 +1,67 @@
1
+ # legion-lock — pin MCP server tools in a lockfile, fail CI on tool drift
2
+
3
+ An MCP server can change a tool's description or input schema after you approved it, and a model reads those
4
+ descriptions as instructions (tool drift, "rug pull"). `legion-lock` pins the approved tool list of each MCP server in
5
+ `.mcp-tools.lock.json`. `check` fails as soon as a server changed, and names the change: tool added, removed,
6
+ description changed, schema paths changed.
7
+
8
+ Fingerprints come from the public LEGION transparency log of MCP tool lists (`https://tlog.legion-api.com/tlog/v1`).
9
+ Each one is signed (Ed25519), included in an append-only Merkle tree (RFC 9162) and checked against a root key
10
+ fingerprint you pin. The lockfile keeps the proofs: `verify` re-checks them offline.
11
+
12
+ [Version française](README.fr.md)
13
+
14
+ ## Pin in 3 commands
15
+ ```bash
16
+ python3 legion_lock.py add io.guestgraph/mental-model # pin the current state (verified proof) in .mcp-tools.lock.json
17
+ python3 legion_lock.py check # 0 = matches · 1 = DRIFT (per-tool diff) · 2 = invalid proof
18
+ python3 legion_lock.py verify # offline: re-check the proofs stored in the lockfile
19
+ ```
20
+ The id is the one of the official MCP registry. To approve an earlier version: `add ID --at-leaf N`. To prove it
21
+ against a specific signed head: `--at-head S`.
22
+
23
+ Requirements: Python 3.8+, no dependency (`cryptography` is used if installed, otherwise pure-Python Ed25519).
24
+ Files: `legion_lock.py`, plus `vendor/legion_tlog_verify.py` and `vendor/legion_verify_mirror.py`, LEGION's two public
25
+ verifiers.
26
+
27
+ ## Check one live server in one command: `legion_verify_server.py`
28
+ ```bash
29
+ python3 legion_verify_server.py https://mcp.example.com/mcp --save proof.json # live tools/list vs the signed history
30
+ python3 legion_verify_server.py --offline proof.json # re-check the saved proof, no network
31
+ ```
32
+ Verdict: `UNCHANGED` (live list = latest signed version) · `CHANGED` (per-tool diff) · `UNKNOWN` (never signed by the
33
+ log) · `AUTH` (server requires auth). It only sends `initialize` and `tools/list`, never calls a tool. No score.
34
+
35
+ ## Demo (real drift, public log)
36
+ `demo/demo_drift.sh` pins two servers to a state BEFORE a change they really made, then runs `check`:
37
+ `io.guestgraph/mental-model` (tool `diagram`: description and 2 schema paths changed, leaf 16044 → 17659) and
38
+ `ai.daystruct.api/daystruct` (tool `plan_project_development` added, leaf 121 → 14516). Expected: exit 1 with the drift
39
+ named; offline `verify` = 0; hand-edited lockfile = 2. Transcript: `demo/transcript_drift_*.txt`.
40
+ Adversarial tests (lying log, forged archive, rewritten history, forged lockfile): `tests/test_adversarial.py`, 15/15.
41
+
42
+ ## What `check` verifies, in order
43
+ 1. The log keyring starts from the pinned root fingerprint `a7c6a35b…`.
44
+ 2. Your pin and the current state are both signed records, included in signed heads (RFC 9162).
45
+ 3. The current head extends the head of your pin (consistency proof). Otherwise: rewritten history, fork or rollback,
46
+ exit 2.
47
+ 4. Is the current fingerprint the approved one? If not, `check` fetches the archived definitions of both versions,
48
+ accepts them only if they hash back to the signed fingerprints, then names the drift field by field.
49
+
50
+ ## Limits (read them)
51
+ - This is **the signed observation of a single observer** (LEGION). It does not show what the server serves to other
52
+ clients.
53
+ - Only `name`, `description` and `inputSchema` are fingerprinted. A change limited to `annotations`, `outputSchema` or
54
+ `title` is not seen.
55
+ - Delay: a change appears only after LEGION's next observation, then the next signed head (one to three signed cycles a
56
+ day, measured 9–11 Oct 2026). `check --strict` also fails when a newer observation is not signed yet.
57
+ - Servers that require authentication are not observed.
58
+ - The log's Bitcoin anchors are `pending` (no `ots verify` on a full node). `legion-lock` does not rely on them.
59
+
60
+ ## Integrations (`integrations/`)
61
+ - `github/mcp-tools-lock.yml`: on every pull request and daily, `verify` then `check`. The job fails on drift.
62
+ - `pre-commit/pre-commit.sh` (raw git hook).
63
+ - `claude-code/settings.json` + `legion_lock_hook.sh`: `PreToolUse` hook on `mcp__.*`. Claude Code blocks the MCP tool
64
+ call if a pinned server drifted. Result cached 10 min.
65
+
66
+ Lockfile format: [FORMAT.md](FORMAT.md). Read-only MCP server on the same log:
67
+ [legion-mcp](https://github.com/Legion33shadow/legion-mcp). License: MIT.
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env python3
2
+ """legion-lock — épingle les outils APPROUVÉS de serveurs MCP et détecte toute dérive, preuves signées à l'appui.
3
+
4
+ legion_lock.py add SERVER_ID [--at-leaf N] [--at-head S] épingle l'état observé (le plus récent, ou la feuille N),
5
+ preuve contre la tête signée courante (ou celle de taille S)
6
+ legion_lock.py check [--strict] [--json] compare au journal public ; code 1 si dérive, avec le diff par outil
7
+ legion_lock.py verify HORS LIGNE : revérifie les preuves stockées dans le lockfile
8
+ legion_lock.py show affiche ce qui est épinglé
9
+
10
+ Source : journal de transparence LEGION (https://tlog.legion-api.com/tlog/v1). Ancre de confiance : empreinte de la clé
11
+ racine, épinglée ci-dessous (à recouper hors bande). Python 3.8+, bibliothèque standard ; `cryptography` utilisé s'il est
12
+ présent, sinon Ed25519 en Python pur (vendor/legion_verify_mirror.py, vecteurs RFC 8032 testés au chargement).
13
+ Codes de sortie : 0 = conforme ; 1 = dérive ; 2 = preuve invalide, réseau ou usage.
14
+ Ce que ça prouve : ce que LEGION a observé et signé pour ce serveur (l'observation d'UN observateur), et que l'historique
15
+ n'a pas été réécrit depuis votre épinglage. Ce que ça ne prouve pas : ce que le serveur montre à d'autres clients."""
16
+ import argparse, base64, datetime, difflib, hashlib, json, os, sys, urllib.error, urllib.parse, urllib.request
17
+
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ sys.path.insert(0, os.path.join(HERE, "vendor"))
20
+ import legion_tlog_verify as T # noqa: E402 (vérifieur public, 369/369 vecteurs officiels)
21
+
22
+ try:
23
+ import cryptography # noqa: F401
24
+ except ImportError: # pas de dépendance obligatoire : Ed25519 pur Python
25
+ import legion_verify_mirror as _V # noqa: E402
26
+ assert _V.ed_selftest() is not False
27
+ T.ed25519_ok = lambda pub, sig, msg: _V.ed_verify_pure(pub, sig, msg)
28
+
29
+ FORMAT = "mcp-tools-lock/0.1"
30
+ DEFAULT_LOCK = ".mcp-tools.lock.json"
31
+ BASE = os.environ.get("LEGION_LOCK_BASE", "https://tlog.legion-api.com/tlog/v1").rstrip("/")
32
+ ROOT_PIN = "a7c6a35b1c2c81d589a30c2ad628679bdea9da4139d49aa3c42cdde6e1c65036"
33
+ FIELDS = ("name", "description", "inputSchema")
34
+
35
+
36
+ class Fail(Exception):
37
+ pass
38
+
39
+
40
+ def get(path, raw=False):
41
+ url = BASE + path
42
+ for attempt in range(3):
43
+ try:
44
+ with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "legion-lock/0.1"}), timeout=30) as r:
45
+ b = r.read(8 << 20)
46
+ return b.decode("utf-8") if raw else json.loads(b)
47
+ except urllib.error.HTTPError as e:
48
+ if e.code == 429 and attempt < 2:
49
+ import time; time.sleep(min(int(e.headers.get("Retry-After") or 5), 30)); continue
50
+ raise Fail("journal : HTTP %d sur %s" % (e.code, path))
51
+ except (urllib.error.URLError, OSError) as e:
52
+ if attempt < 2:
53
+ import time; time.sleep(3); continue
54
+ raise Fail("journal injoignable (%s)" % type(e).__name__)
55
+
56
+
57
+ def keys_from(keyring_text):
58
+ first = json.loads(next(l for l in keyring_text.splitlines() if l.strip()))
59
+ if T.key_pin(first.get("public_key_b64", "")) != ROOT_PIN:
60
+ raise Fail("trousseau : la 1re entrée n'a pas l'empreinte racine épinglée %s…" % ROOT_PIN[:16])
61
+ return T.load_keyring(keyring_text, first["public_key_b64"])
62
+
63
+
64
+ def check_evidence(ev, keys, server_id):
65
+ """Preuve = enregistrement signé + inclusion RFC 9162 + tête signée, clés issues de la racine épinglée."""
66
+ res = T.verify_bundle_offline({"record": ev["record"], "inclusion": ev["inclusion"], "sth": ev["sth"]}, keys)
67
+ if not res["ok"]:
68
+ bad = [k for k in ("record_integrity", "record_signature", "head_signature", "inclusion") if not res.get(k)]
69
+ raise Fail("%s : preuve REJETÉE (%s)" % (server_id, ", ".join(bad)))
70
+ if ev["record"].get("server_id") != server_id:
71
+ raise Fail("%s : la preuve concerne un autre serveur (%s)" % (server_id, ev["record"].get("server_id")))
72
+ return res
73
+
74
+
75
+ def canon_for(algo):
76
+ return T.canon_v1 if algo.endswith("/v1") else T.canon
77
+
78
+
79
+ def tools_from_archive(fp):
80
+ """Définitions archivées, ACCEPTÉES seulement si elles redonnent l'empreinte signée."""
81
+ try:
82
+ tools = get("/tools/" + fp["sha256"])["tools"]
83
+ except Fail:
84
+ return None
85
+ lst = sorted(({k: t[k] for k in FIELDS if k in t} for t in tools), key=lambda t: t["name"])
86
+ if hashlib.sha256(canon_for(fp["algo"])(lst)).hexdigest() != fp["sha256"]:
87
+ raise Fail("archive /tools/%s… : le contenu ne redonne pas l'empreinte signée" % fp["sha256"][:12])
88
+ return {t["name"]: t for t in lst}
89
+
90
+
91
+ def approved_view(rec):
92
+ fp = rec["tools_fingerprint"]
93
+ return {"tools_sha256": fp["sha256"], "algo": fp["algo"], "tool_count": len(fp["per_tool_sha256"]),
94
+ "tools": {n: h for n, h in fp["per_tool_sha256"]}}
95
+
96
+
97
+ def load_lock(p):
98
+ if not os.path.exists(p):
99
+ return {"format": FORMAT, "log": {"base": BASE, "origin": "tlog.legion-api.com/mcp/v0", "root_pin_sha256": ROOT_PIN},
100
+ "keyring": None, "servers": {}}
101
+ d = json.load(open(p, encoding="utf-8"))
102
+ if d.get("format") != FORMAT:
103
+ raise Fail("%s : format inconnu %r" % (p, d.get("format")))
104
+ if d["log"]["root_pin_sha256"] != ROOT_PIN:
105
+ raise Fail("%s : empreinte racine différente de celle de cet outil" % p)
106
+ return d
107
+
108
+
109
+ def save_lock(p, d):
110
+ tmp = p + ".tmp"
111
+ with open(tmp, "w", encoding="utf-8") as f:
112
+ json.dump(d, f, indent=1, ensure_ascii=False, sort_keys=True); f.write("\n")
113
+ os.replace(tmp, p)
114
+
115
+
116
+ def now():
117
+ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
118
+
119
+
120
+ # ---------- commandes
121
+ def cmd_add(a):
122
+ lock = load_lock(a.lock)
123
+ kr = get("/keyring", raw=True); keys = keys_from(kr)
124
+ sid = a.server_id
125
+ if a.at_leaf is None and a.at_head is None:
126
+ b = get("/servers/%s/latest" % urllib.parse.quote(sid, safe=""))
127
+ ev = {"record": b["record"], "inclusion": b["inclusion"], "sth": b["sth"]}
128
+ else:
129
+ if a.at_leaf is None:
130
+ rec = get("/servers/%s/latest" % urllib.parse.quote(sid, safe=""))["record"]
131
+ else:
132
+ rec = get("/records/%d" % a.at_leaf); rec = rec.get("record", rec)
133
+ h = get("/head/%d" % a.at_head) if a.at_head is not None else get("/head"); sth = h.get("sth", h)
134
+ n = sth["tree_head"]["tree_size"]
135
+ ev = {"record": rec, "inclusion": get("/proof/inclusion?leaf_index=%d&tree_size=%d" % (rec["leaf_index"], n)), "sth": sth}
136
+ check_evidence(ev, keys, sid)
137
+ ap = approved_view(ev["record"])
138
+ lock["keyring"] = kr
139
+ lock["servers"][sid] = {"approved": ap, "evidence": ev, "pinned_at": now(),
140
+ "pinned_from": {"leaf_index": ev["record"]["leaf_index"], "observed_at": ev["record"]["observed_at"],
141
+ "tree_size": ev["sth"]["tree_head"]["tree_size"]}}
142
+ save_lock(a.lock, lock)
143
+ print("ÉPINGLÉ %s : %d outils, empreinte %s… (feuille %d, observée %s), preuve vérifiée contre la racine %s…"
144
+ % (sid, ap["tool_count"], ap["tools_sha256"][:16], ev["record"]["leaf_index"], ev["record"]["observed_at"], ROOT_PIN[:16]))
145
+ return 0
146
+
147
+
148
+ def schema_paths(a, b, path=""):
149
+ """Chemins JSON qui diffèrent entre deux schémas (ajout, retrait, valeur)."""
150
+ if isinstance(a, dict) and isinstance(b, dict):
151
+ out = []
152
+ for k in sorted(set(a) | set(b)):
153
+ p = "%s.%s" % (path, k) if path else k
154
+ if k not in a: out.append("+ " + p)
155
+ elif k not in b: out.append("- " + p)
156
+ else: out += schema_paths(a[k], b[k], p)
157
+ return out
158
+ if isinstance(a, list) and isinstance(b, list) and len(a) == len(b):
159
+ return [x for i, (u, v) in enumerate(zip(a, b)) for x in schema_paths(u, v, "%s[%d]" % (path, i))]
160
+ return [] if a == b else ["~ " + (path or "(racine)")]
161
+
162
+
163
+ def describe_drift(ap, rec):
164
+ cur = approved_view(rec)
165
+ old_t, new_t = ap["tools"], cur["tools"]
166
+ added = sorted(set(new_t) - set(old_t)); removed = sorted(set(old_t) - set(new_t))
167
+ modified = sorted(n for n in set(old_t) & set(new_t) if old_t[n] != new_t[n])
168
+ lines, real_mod = [], []
169
+ olda = newa = None
170
+ if modified or ap["algo"] != cur["algo"]:
171
+ olda = tools_from_archive({"sha256": ap["tools_sha256"], "algo": ap["algo"]})
172
+ newa = tools_from_archive({"sha256": cur["tools_sha256"], "algo": cur["algo"]})
173
+ for n in added:
174
+ d = (newa or {}).get(n, {}).get("description", "")
175
+ lines.append("outil %s : AJOUTÉ%s" % (n, (" — « %s »" % d[:120]) if d else ""))
176
+ for n in removed:
177
+ lines.append("outil %s : RETIRÉ" % n)
178
+ for n in modified:
179
+ if olda is None or newa is None:
180
+ lines.append("outil %s : définition changée (archive indisponible : champ non déterminé)" % n); real_mod.append(n); continue
181
+ x, y = olda[n], newa[n]
182
+ if x == y: # même contenu, empreinte recalculée autrement (changement d'algo)
183
+ lines.append("outil %s : contenu IDENTIQUE (empreinte différente seulement par l'algorithme %s -> %s)" % (n, ap["algo"][-3:], cur["algo"][-3:]))
184
+ continue
185
+ real_mod.append(n)
186
+ if x.get("description") != y.get("description"):
187
+ lines.append("outil %s : description changée" % n)
188
+ for l in difflib.unified_diff((x.get("description") or "").split(". "), (y.get("description") or "").split(". "), "approuvée", "actuelle", n=0, lineterm=""):
189
+ if not l.startswith(("---", "+++", "@@")):
190
+ lines.append(" " + l[:160])
191
+ if x.get("inputSchema") != y.get("inputSchema"):
192
+ ps = schema_paths(x.get("inputSchema"), y.get("inputSchema"))
193
+ lines.append("outil %s : inputSchema changé (%d chemin(s)) : %s" % (n, len(ps), ", ".join(ps[:8]) + (" …" if len(ps) > 8 else "")))
194
+ return {"added": added, "removed": removed, "modified": real_mod, "lines": lines,
195
+ "approved_sha256": ap["tools_sha256"], "current_sha256": cur["tools_sha256"]}
196
+
197
+
198
+ def cmd_check(a):
199
+ lock = load_lock(a.lock)
200
+ if not lock["servers"]:
201
+ raise Fail("%s : aucun serveur épinglé (legion_lock.py add SERVER_ID)" % a.lock)
202
+ keys = keys_from(get("/keyring", raw=True))
203
+ report, drift, warn = [], 0, 0
204
+ for sid, ent in sorted(lock["servers"].items()):
205
+ check_evidence(ent["evidence"], keys, sid) # l'épinglage lui-même reste prouvé
206
+ b = get("/servers/%s/latest" % urllib.parse.quote(sid, safe=""))
207
+ cur = {"record": b["record"], "inclusion": b["inclusion"], "sth": b["sth"]}
208
+ check_evidence(cur, keys, sid)
209
+ m, n = ent["evidence"]["sth"]["tree_head"]["tree_size"], cur["sth"]["tree_head"]["tree_size"]
210
+ rm, rn = ent["evidence"]["sth"]["tree_head"]["root_sha256"], cur["sth"]["tree_head"]["root_sha256"]
211
+ if m < n:
212
+ pr = get("/proof/consistency?old_size=%d&new_size=%d" % (m, n))["proof"]
213
+ if not T._verify_consistency(m, n, bytes.fromhex(rm), bytes.fromhex(rn), [bytes.fromhex(h) for h in pr]):
214
+ raise Fail("%s : HISTORIQUE RÉÉCRIT — la tête %d n'étend pas la tête %d de votre épinglage" % (sid, n, m))
215
+ elif m == n and rm != rn:
216
+ raise Fail("%s : FOURCHE — deux racines signées pour la taille %d" % (sid, m))
217
+ elif m > n:
218
+ raise Fail("%s : RETOUR EN ARRIÈRE — le journal sert la taille %d < %d déjà vue" % (sid, n, m))
219
+ ent_r = {"server_id": sid, "pinned_leaf": ent["pinned_from"]["leaf_index"], "current_leaf": cur["record"]["leaf_index"],
220
+ "observed_at": cur["record"]["observed_at"], "head": n, "append_only_since_pin": True}
221
+ if cur["record"]["tools_fingerprint"]["sha256"] == ent["approved"]["tools_sha256"]:
222
+ ent_r["status"] = "CONFORME"
223
+ else:
224
+ d = describe_drift(ent["approved"], cur["record"])
225
+ if d["added"] or d["removed"] or d["modified"]:
226
+ ent_r.update(status="DÉRIVE", drift=d); drift += 1
227
+ else:
228
+ ent_r.update(status="CONFORME", note="empreinte recalculée autrement, contenu identique", drift=d)
229
+ if b.get("newer_uncovered_records"):
230
+ ent_r["warning"] = "%d observation(s) plus récente(s) pas encore dans une tête signée" % b["newer_uncovered_records"]; warn += 1
231
+ report.append(ent_r)
232
+ if a.json:
233
+ print(json.dumps({"ok": drift == 0 and not (a.strict and warn), "servers": report}, indent=1, ensure_ascii=False))
234
+ else:
235
+ for r in report:
236
+ print("%-9s %s (épinglé feuille %d ; actuel feuille %d, observé %s ; tête signée %d, append-only vérifié)"
237
+ % (r["status"], r["server_id"], r["pinned_leaf"], r["current_leaf"], r["observed_at"], r["head"]))
238
+ for l in (r.get("drift") or {}).get("lines", []):
239
+ print(" " + l)
240
+ if r.get("warning"):
241
+ print(" attention : " + r["warning"])
242
+ print("RÉSULTAT : %s" % ("DÉRIVE sur %d serveur(s)" % drift if drift else "aucune dérive"))
243
+ return 1 if drift or (a.strict and warn) else 0
244
+
245
+
246
+ def cmd_verify(a):
247
+ lock = load_lock(a.lock)
248
+ keys = keys_from(lock["keyring"] or "")
249
+ for sid, ent in sorted(lock["servers"].items()):
250
+ res = check_evidence(ent["evidence"], keys, sid)
251
+ if approved_view(ent["evidence"]["record"]) != ent["approved"]:
252
+ raise Fail("%s : l'état « approuvé » du lockfile ne correspond pas à l'enregistrement signé" % sid)
253
+ print("OK %s : feuille %d incluse dans la tête signée %d ; %d outils, empreinte %s… (hors ligne)"
254
+ % (sid, res["leaf_index"], res["tree_size"], ent["approved"]["tool_count"], ent["approved"]["tools_sha256"][:16]))
255
+ print("RÉSULTAT : %d épinglage(s) prouvé(s) hors ligne contre la racine %s…" % (len(lock["servers"]), ROOT_PIN[:16]))
256
+ return 0
257
+
258
+
259
+ def cmd_show(a):
260
+ lock = load_lock(a.lock)
261
+ for sid, ent in sorted(lock["servers"].items()):
262
+ print("%s : %d outils, %s…, feuille %d, épinglé %s" % (sid, ent["approved"]["tool_count"], ent["approved"]["tools_sha256"][:16],
263
+ ent["pinned_from"]["leaf_index"], ent["pinned_at"]))
264
+ return 0
265
+
266
+
267
+ def main(argv=None):
268
+ ap = argparse.ArgumentParser(prog="legion-lock", description=__doc__.split("\n")[0])
269
+ ap.add_argument("--lock", default=DEFAULT_LOCK)
270
+ sub = ap.add_subparsers(dest="cmd", required=True)
271
+ s = sub.add_parser("add"); s.add_argument("server_id"); s.add_argument("--at-leaf", type=int); s.add_argument("--at-head", type=int, help="prouver contre la tête signée de cette taille")
272
+ s = sub.add_parser("check"); s.add_argument("--strict", action="store_true"); s.add_argument("--json", action="store_true")
273
+ sub.add_parser("verify"); sub.add_parser("show")
274
+ a = ap.parse_args(argv)
275
+ try:
276
+ return {"add": cmd_add, "check": cmd_check, "verify": cmd_verify, "show": cmd_show}[a.cmd](a)
277
+ except Fail as e:
278
+ print("ÉCHEC : %s" % e, file=sys.stderr); return 2
279
+ except (ValueError, KeyError, TypeError, json.JSONDecodeError) as e:
280
+ print("ÉCHEC : donnée invalide (%s: %s)" % (type(e).__name__, e), file=sys.stderr); return 2
281
+
282
+
283
+ if __name__ == "__main__":
284
+ sys.exit(main())