legion-guard 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,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: legion-guard
3
+ Version: 0.1.0
4
+ Summary: Verify before connect: check the MCP tools your client receives against your approvals and LEGION's signed log
5
+ License: MIT
6
+ Project-URL: Homepage, https://legion-api.com/verify-before-connect/
7
+ Project-URL: Source, https://github.com/Legion33shadow/legion-mcp
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: mcp>=1.0
12
+ Provides-Extra: crypto
13
+ Requires-Dist: cryptography; extra == "crypto"
14
+ Dynamic: license-file
15
+
16
+ # legion-guard — verify before connect
17
+
18
+ An agent reads an MCP server's tool descriptions as instructions. The server you approved last week can expose other
19
+ tools, or the same tools with other descriptions, today. `legion-guard` checks the `tools/list` **your client actually
20
+ receives**, at connect time, before the agent sees the tools:
21
+
22
+ 1. against the tools **you approved** (lockfile `.mcp-tools.lock.json`, same format as [legion-lock](https://pypi.org/project/legion-lock/));
23
+ 2. against the latest tool list **signed by LEGION's public transparency log** (record + RFC 9162 inclusion proof + signed
24
+ head, checked from a pinned root key). A difference means this client receives a list LEGION did not sign: either the
25
+ server changed since LEGION's last probe, or it serves different tools to different clients.
26
+
27
+ `mode="warn"` reports. `mode="block"` hides unapproved or changed tools from `list_tools()` and refuses `call_tool()` on them.
28
+ The guard never calls a tool itself.
29
+
30
+ ## Use it with the MCP Python SDK (tested)
31
+
32
+ ```python
33
+ import legion_guard
34
+ from mcp import ClientSession
35
+
36
+ async with ClientSession(read, write) as session:
37
+ await session.initialize()
38
+ report = await legion_guard.guard_session(session, "com.dexpaprika/dexpaprika", mode="block")
39
+ tools = await session.list_tools() # only approved, unchanged tools
40
+ ```
41
+
42
+ Frameworks that build on `ClientSession` (for example LangChain's MCP adapters loading tools from a session) see the
43
+ filtered list when the guard runs first; those integrations are not tested yet.
44
+
45
+ ## Command line
46
+
47
+ ```
48
+ python3 -m legion_guard approve https://mcp.dexpaprika.com/streamable-http com.dexpaprika/dexpaprika # after review
49
+ python3 -m legion_guard check https://mcp.dexpaprika.com/streamable-http com.dexpaprika/dexpaprika # exit 1 on drift
50
+ ```
51
+
52
+ Measured on 2026-10-17: the 20-tool list served by `mcp.dexpaprika.com/streamable-http` to this client has the fingerprint
53
+ `20b17583…` that LEGION signed (record 20315, observed on `/sse`) — `MATCHES_SIGNED`.
54
+
55
+ ## Limits
56
+
57
+ - LEGION observes only part of the servers; absence from the log is not a verdict (`UNKNOWN`).
58
+ - A change is not an attack. This does not certify that a server is secure, and says nothing about what the tools do.
59
+ - Approval is yours: the guard compares, it does not judge a description.
60
+ - Fingerprints cover `name`, `description` and `inputSchema` (JCS RFC 8785), as in the LEGION log.
@@ -0,0 +1,45 @@
1
+ # legion-guard — verify before connect
2
+
3
+ An agent reads an MCP server's tool descriptions as instructions. The server you approved last week can expose other
4
+ tools, or the same tools with other descriptions, today. `legion-guard` checks the `tools/list` **your client actually
5
+ receives**, at connect time, before the agent sees the tools:
6
+
7
+ 1. against the tools **you approved** (lockfile `.mcp-tools.lock.json`, same format as [legion-lock](https://pypi.org/project/legion-lock/));
8
+ 2. against the latest tool list **signed by LEGION's public transparency log** (record + RFC 9162 inclusion proof + signed
9
+ head, checked from a pinned root key). A difference means this client receives a list LEGION did not sign: either the
10
+ server changed since LEGION's last probe, or it serves different tools to different clients.
11
+
12
+ `mode="warn"` reports. `mode="block"` hides unapproved or changed tools from `list_tools()` and refuses `call_tool()` on them.
13
+ The guard never calls a tool itself.
14
+
15
+ ## Use it with the MCP Python SDK (tested)
16
+
17
+ ```python
18
+ import legion_guard
19
+ from mcp import ClientSession
20
+
21
+ async with ClientSession(read, write) as session:
22
+ await session.initialize()
23
+ report = await legion_guard.guard_session(session, "com.dexpaprika/dexpaprika", mode="block")
24
+ tools = await session.list_tools() # only approved, unchanged tools
25
+ ```
26
+
27
+ Frameworks that build on `ClientSession` (for example LangChain's MCP adapters loading tools from a session) see the
28
+ filtered list when the guard runs first; those integrations are not tested yet.
29
+
30
+ ## Command line
31
+
32
+ ```
33
+ python3 -m legion_guard approve https://mcp.dexpaprika.com/streamable-http com.dexpaprika/dexpaprika # after review
34
+ python3 -m legion_guard check https://mcp.dexpaprika.com/streamable-http com.dexpaprika/dexpaprika # exit 1 on drift
35
+ ```
36
+
37
+ Measured on 2026-10-17: the 20-tool list served by `mcp.dexpaprika.com/streamable-http` to this client has the fingerprint
38
+ `20b17583…` that LEGION signed (record 20315, observed on `/sse`) — `MATCHES_SIGNED`.
39
+
40
+ ## Limits
41
+
42
+ - LEGION observes only part of the servers; absence from the log is not a verdict (`UNKNOWN`).
43
+ - A change is not an attack. This does not certify that a server is secure, and says nothing about what the tools do.
44
+ - Approval is yours: the guard compares, it does not judge a description.
45
+ - Fingerprints cover `name`, `description` and `inputSchema` (JCS RFC 8785), as in the LEGION log.
@@ -0,0 +1,135 @@
1
+ """legion-guard — verify-before-connect pour les clients MCP.
2
+
3
+ Avant qu'un agent utilise les outils d'un serveur MCP, le garde compare la liste d'outils RÉELLEMENT reçue par CE client :
4
+ 1. aux outils que vous avez approuvés (lockfile `.mcp-tools.lock.json`, format mcp-tools-lock/0.1, le même que legion-lock) ;
5
+ 2. à la dernière liste signée par le journal public LEGION (enregistrement + preuve d'inclusion RFC 9162 + tête signée,
6
+ clés issues de la racine épinglée) — un écart signifie que ce client reçoit autre chose que ce que LEGION a observé
7
+ (changement depuis la dernière sonde, ou contenu différent selon le client).
8
+ Mode "warn" : rapport seulement. Mode "block" : les outils non approuvés ou modifiés sont retirés de list_tools() et leur
9
+ appel est refusé (ToolNotApproved). Lecture seule vis-à-vis du serveur : aucun tools/call n'est fait par le garde.
10
+ Limites : LEGION n'observe qu'une partie des serveurs ; absence du journal n'est pas un verdict. Un changement n'est pas une
11
+ attaque. Rien ici ne certifie qu'un serveur est sûr."""
12
+ import hashlib, json, os, sys, urllib.parse
13
+ _H = os.path.dirname(os.path.abspath(__file__))
14
+ sys.path.insert(0, _H)
15
+ import _lock as L # noqa: E402 (legion-lock : vérifieur, trousseau, lockfile)
16
+ T = L.T
17
+ __version__ = "0.1.0"
18
+ FIELDS = ("name", "description", "inputSchema")
19
+ ALGO = "sha256(JCS-RFC8785(sorted[{name,description,inputSchema}]))/v2"
20
+
21
+
22
+ class ToolNotApproved(Exception):
23
+ pass
24
+
25
+
26
+ def _tool_dict(t):
27
+ d = t if isinstance(t, dict) else t.model_dump(by_alias=True, exclude_none=True)
28
+ return {k: d[k] for k in FIELDS if k in d and d[k] is not None}
29
+
30
+
31
+ def fingerprint(tools, algo=ALGO):
32
+ """Empreinte d'une liste tools/list, même règle que le journal LEGION (par outil et liste complète)."""
33
+ c = L.canon_for(algo)
34
+ ts = sorted((_tool_dict(t) for t in tools), key=lambda t: t["name"])
35
+ return {"sha256": hashlib.sha256(c(ts)).hexdigest(), "algo": algo,
36
+ "per_tool": {t["name"]: hashlib.sha256(c(t)).hexdigest() for t in ts}}
37
+
38
+
39
+ def legion_signed(server_id):
40
+ """Dernier enregistrement signé par LEGION pour ce serveur, preuve vérifiée. None si absent du journal."""
41
+ try:
42
+ b = L.get("/servers/%s/latest" % urllib.parse.quote(server_id, safe=""))
43
+ except L.Fail as e:
44
+ if "HTTP 404" in str(e):
45
+ return None
46
+ raise
47
+ keys = L.keys_from(L.get("/keyring", raw=True))
48
+ L.check_evidence({"record": b["record"], "inclusion": b["inclusion"], "sth": b["sth"]}, keys, server_id)
49
+ fp = b["record"]["tools_fingerprint"]
50
+ return {"sha256": fp["sha256"], "algo": fp["algo"], "per_tool": dict(fp["per_tool_sha256"]),
51
+ "observed_at": b["record"]["observed_at"], "leaf_index": b["record"]["leaf_index"],
52
+ "tree_size": b["sth"]["tree_head"]["tree_size"]}
53
+
54
+
55
+ def evaluate(server_id, tools, lockfile=L.DEFAULT_LOCK, use_legion=True):
56
+ live = fingerprint(tools)
57
+ rep = {"server_id": server_id, "live_tools_sha256": live["sha256"], "tool_count": len(live["per_tool"]), "tools": {}}
58
+ approved = None
59
+ if lockfile and os.path.exists(lockfile):
60
+ ap = (L.load_lock(lockfile).get("servers", {}).get(server_id) or {}).get("approved")
61
+ if ap:
62
+ approved = ap
63
+ if ap.get("algo") and ap["algo"] != live["algo"]:
64
+ live = fingerprint(tools, ap["algo"]); rep["live_tools_sha256"] = live["sha256"]
65
+ for n, h in live["per_tool"].items():
66
+ if approved is None:
67
+ rep["tools"][n] = "NOT_APPROVED"
68
+ elif n not in approved["tools"]:
69
+ rep["tools"][n] = "NEW"
70
+ else:
71
+ rep["tools"][n] = "APPROVED" if approved["tools"][n] == h else "CHANGED"
72
+ if approved is not None:
73
+ rep["removed_since_approval"] = sorted(set(approved["tools"]) - set(live["per_tool"]))
74
+ rep["approval"] = ("NO_APPROVAL" if approved is None else
75
+ "MATCHES_APPROVED" if approved["tools_sha256"] == live["sha256"] else "DIFFERS_FROM_APPROVED")
76
+ if use_legion:
77
+ try:
78
+ s = legion_signed(server_id)
79
+ if s is None:
80
+ rep["legion"] = {"state": "UNKNOWN", "reason": "no signed record for this id; absence from the log is not a verdict"}
81
+ else:
82
+ lv = live if s["algo"] == live["algo"] else fingerprint(tools, s["algo"])
83
+ diff = sorted(n for n in set(lv["per_tool"]) | set(s["per_tool"]) if lv["per_tool"].get(n) != s["per_tool"].get(n))
84
+ rep["legion"] = {"state": "MATCHES_SIGNED" if lv["sha256"] == s["sha256"] else "DIFFERS_FROM_SIGNED",
85
+ "signed_observed_at": s["observed_at"], "leaf_index": s["leaf_index"], "tree_size": s["tree_size"],
86
+ "proof": "record signature + RFC 9162 inclusion + signed head, verified from the pinned root key",
87
+ "differing_tools": diff}
88
+ if diff:
89
+ rep["legion"]["meaning"] = ("this client receives a tool list that LEGION did not sign: either the server changed "
90
+ "since LEGION's last probe, or it serves different tools to different clients")
91
+ except L.Fail as e:
92
+ rep["legion"] = {"state": "UNVERIFIED", "reason": str(e)}
93
+ rep["claim_boundary"] = ("LEGION observes only part of the servers; absence from the log is not a verdict. "
94
+ "A change is not an attack. This does not certify that a server is secure.")
95
+ return rep
96
+
97
+
98
+ def approve(server_id, tools, lockfile=L.DEFAULT_LOCK, note="approved from the live tools/list"):
99
+ """Écrit (ou remplace) l'approbation de ce serveur avec la liste reçue maintenant — à faire après revue humaine."""
100
+ live = fingerprint(tools)
101
+ d = L.load_lock(lockfile)
102
+ d.setdefault("servers", {})[server_id] = {"approved": {"tools_sha256": live["sha256"], "algo": live["algo"],
103
+ "tool_count": len(live["per_tool"]), "tools": live["per_tool"]},
104
+ "approved_at": L.now(), "source": note}
105
+ L.save_lock(lockfile, d)
106
+ return live
107
+
108
+
109
+ async def guard_session(session, server_id, lockfile=L.DEFAULT_LOCK, mode="warn", use_legion=True, on_report=None):
110
+ """À appeler après session.initialize(). Évalue la liste réelle ; en mode "block", ne laisse passer que les outils
111
+ APPROUVÉS (list_tools filtré, call_tool refusé sinon). Renvoie le rapport."""
112
+ if mode not in ("warn", "block"):
113
+ raise ValueError("mode : warn | block")
114
+ res = await session.list_tools()
115
+ rep = evaluate(server_id, res.tools, lockfile, use_legion)
116
+ rep["mode"] = mode
117
+ if on_report:
118
+ on_report(rep)
119
+ if mode == "block":
120
+ allowed = {n for n, s in rep["tools"].items() if s == "APPROVED"}
121
+ ap_hash = (L.load_lock(lockfile)["servers"].get(server_id) or {}).get("approved", {}).get("tools", {}) if lockfile and os.path.exists(lockfile) else {}
122
+ rep["exposed_tools"] = sorted(allowed)
123
+ orig_list, orig_call = session.list_tools, session.call_tool
124
+
125
+ async def list_tools(*a, **k):
126
+ r = await orig_list(*a, **k)
127
+ r.tools = [t for t in r.tools if t.name in allowed and fingerprint([t])["per_tool"][t.name] == ap_hash.get(t.name)]
128
+ return r
129
+
130
+ async def call_tool(name, *a, **k):
131
+ if name not in allowed:
132
+ raise ToolNotApproved("%s : tool %r is %s (not approved in %s)" % (server_id, name, rep["tools"].get(name, "UNKNOWN"), lockfile))
133
+ return await orig_call(name, *a, **k)
134
+ session.list_tools, session.call_tool = list_tools, call_tool
135
+ return rep
@@ -0,0 +1,35 @@
1
+ """CLI : python3 -m legion_guard check|approve URL SERVER_ID [--lock FICHIER] [--mode warn|block] [--no-legion]
2
+ check : se connecte (initialize + tools/list, aucun tools/call), affiche le rapport ; code 0 conforme, 1 écart, 2 erreur
3
+ approve : écrit l'approbation de la liste reçue maintenant dans le lockfile (après revue humaine)"""
4
+ import argparse, asyncio, json, sys
5
+ import legion_guard as G
6
+
7
+
8
+ async def _tools(url):
9
+ from mcp import ClientSession
10
+ from mcp.client.streamable_http import streamable_http_client
11
+ async with streamable_http_client(url) as (r, w, *_):
12
+ async with ClientSession(r, w) as s:
13
+ await s.initialize()
14
+ return (await s.list_tools()).tools
15
+
16
+
17
+ def main(argv=None):
18
+ ap = argparse.ArgumentParser(prog="legion_guard")
19
+ ap.add_argument("cmd", choices=["check", "approve"]); ap.add_argument("url"); ap.add_argument("server_id")
20
+ ap.add_argument("--lock", default=G.L.DEFAULT_LOCK); ap.add_argument("--no-legion", action="store_true")
21
+ a = ap.parse_args(argv)
22
+ try:
23
+ tools = asyncio.run(_tools(a.url))
24
+ except Exception as e:
25
+ print(json.dumps({"ok": None, "error": "connexion MCP impossible : %s" % type(e).__name__})); return 2
26
+ if a.cmd == "approve":
27
+ fp = G.approve(a.server_id, tools, a.lock); print("approuvé : %s (%d outils, %s…) -> %s" % (a.server_id, len(fp["per_tool"]), fp["sha256"][:16], a.lock)); return 0
28
+ rep = G.evaluate(a.server_id, tools, a.lock, not a.no_legion)
29
+ print(json.dumps(rep, indent=1, ensure_ascii=False))
30
+ bad = rep["approval"] == "DIFFERS_FROM_APPROVED" or (rep.get("legion") or {}).get("state") == "DIFFERS_FROM_SIGNED"
31
+ return 1 if bad else 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
@@ -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())
File without changes