cortexm 0.3.0__py3-none-any.whl
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.
- context_m.py +17 -0
- cortexm/__init__.py +45 -0
- cortexm/accel.py +403 -0
- cortexm/api/__init__.py +0 -0
- cortexm/api/chaos.py +118 -0
- cortexm/api/memory.py +635 -0
- cortexm/bench/__init__.py +0 -0
- cortexm/bench/abilities.py +311 -0
- cortexm/bench/baselines.py +89 -0
- cortexm/bench/beam_loader.py +317 -0
- cortexm/bench/generator.py +376 -0
- cortexm/bench/harness.py +211 -0
- cortexm/bench/messy.py +218 -0
- cortexm/bench/micro.py +251 -0
- cortexm/bench/ood.py +443 -0
- cortexm/bench/run.py +137 -0
- cortexm/bridge/__init__.py +0 -0
- cortexm/bridge/dates.py +178 -0
- cortexm/bridge/decoders.py +204 -0
- cortexm/bridge/enrich.py +255 -0
- cortexm/bridge/extractor.py +316 -0
- cortexm/bridge/fallback.py +332 -0
- cortexm/bridge/onnx_runtime.py +158 -0
- cortexm/bridge/patterns.py +760 -0
- cortexm/bridge/ppr.py +104 -0
- cortexm/bridge/prefilter.py +188 -0
- cortexm/bridge/query_extract.py +420 -0
- cortexm/bridge/reader.py +1174 -0
- cortexm/bridge/rerank.py +204 -0
- cortexm/bridge/writer.py +492 -0
- cortexm/cli.py +295 -0
- cortexm/cognition/__init__.py +53 -0
- cortexm/cognition/abstraction.py +192 -0
- cortexm/cognition/analogy.py +159 -0
- cortexm/cognition/engine.py +204 -0
- cortexm/cognition/gaps.py +365 -0
- cortexm/cognition/scanner.py +204 -0
- cortexm/config.py +375 -0
- cortexm/cortexm.py +8 -0
- cortexm/enterprise/__init__.py +0 -0
- cortexm/enterprise/audit.py +178 -0
- cortexm/enterprise/governance.py +239 -0
- cortexm/errors.py +35 -0
- cortexm/features/__init__.py +0 -0
- cortexm/features/git.py +204 -0
- cortexm/features/prefetch.py +88 -0
- cortexm/features/zk.py +105 -0
- cortexm/federation/__init__.py +39 -0
- cortexm/federation/crdt.py +275 -0
- cortexm/federation/fabric.py +109 -0
- cortexm/federation/hlc.py +80 -0
- cortexm/federation/node.py +145 -0
- cortexm/federation/schema_report.py +73 -0
- cortexm/federation/transport.py +164 -0
- cortexm/index/__init__.py +19 -0
- cortexm/index/nsg.py +386 -0
- cortexm/mcp/__init__.py +0 -0
- cortexm/mcp/server.py +985 -0
- cortexm/metrics.py +62 -0
- cortexm/migrate/__init__.py +0 -0
- cortexm/migrate/importers.py +192 -0
- cortexm/provenance/__init__.py +78 -0
- cortexm/provenance/agent.py +214 -0
- cortexm/provenance/cose.py +201 -0
- cortexm/provenance/scitt.py +258 -0
- cortexm/provenance/vc.py +250 -0
- cortexm/security/__init__.py +0 -0
- cortexm/security/crypto.py +162 -0
- cortexm/security/hashes.py +140 -0
- cortexm/security/injection.py +149 -0
- cortexm/security/mind.py +154 -0
- cortexm/security/pii.py +265 -0
- cortexm/security/rbac.py +169 -0
- cortexm/security/sandbox.py +131 -0
- cortexm/security/zk_hamming.py +142 -0
- cortexm/security/zk_sql.py +485 -0
- cortexm/server/__init__.py +0 -0
- cortexm/server/metrics.py +88 -0
- cortexm/server/rest.py +936 -0
- cortexm/server/sparql.py +984 -0
- cortexm/text/__init__.py +0 -0
- cortexm/text/dissim.py +252 -0
- cortexm/text/embedder.py +155 -0
- cortexm/text/fuzzy.py +218 -0
- cortexm/text/idiolect.py +253 -0
- cortexm/text/labse.py +374 -0
- cortexm/text/tokenizer.py +79 -0
- cortexm/trace/__init__.py +0 -0
- cortexm/trace/blob_arena.py +277 -0
- cortexm/trace/consolidate.py +337 -0
- cortexm/trace/contradictions.py +69 -0
- cortexm/trace/dedup.py +114 -0
- cortexm/trace/edges.py +214 -0
- cortexm/trace/fact.py +121 -0
- cortexm/trace/fade.py +245 -0
- cortexm/trace/lifecycle.py +112 -0
- cortexm/trace/rebuild.py +173 -0
- cortexm/trace/rules.py +171 -0
- cortexm/trace/store.py +680 -0
- cortexm/trace/structural.py +183 -0
- cortexm/trace/tmt.py +335 -0
- cortexm/util.py +148 -0
- cortexm/vsa/__init__.py +0 -0
- cortexm/vsa/attribution.py +149 -0
- cortexm/vsa/cleanup.py +161 -0
- cortexm/vsa/codecs.py +397 -0
- cortexm/vsa/hologram_overlay.py +139 -0
- cortexm/vsa/index.py +163 -0
- cortexm/vsa/ops.py +149 -0
- cortexm/vsa/palace.py +446 -0
- cortexm/vsa/role_vectors.py +236 -0
- cortexm/vsa/slb.py +78 -0
- cortexm/vsa/tlsh_trie.py +137 -0
- cortexm/vsa/working_memory.py +249 -0
- cortexm-0.3.0.dist-info/METADATA +482 -0
- cortexm-0.3.0.dist-info/RECORD +120 -0
- cortexm-0.3.0.dist-info/WHEEL +5 -0
- cortexm-0.3.0.dist-info/entry_points.txt +2 -0
- cortexm-0.3.0.dist-info/licenses/LICENSE +190 -0
- cortexm-0.3.0.dist-info/top_level.txt +2 -0
cortexm/metrics.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Process-wide counters backing the μ=0 (zero-LLM-ingest) protocol.
|
|
2
|
+
|
|
3
|
+
Every code path that could invoke a language model must route through
|
|
4
|
+
``call_llm``. The default implementation raises: the deterministic core
|
|
5
|
+
physically cannot make an LLM call. Optional async enrichment (never used
|
|
6
|
+
during benchmarks) increments the counter so audits can prove honesty.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
_LOCK = threading.Lock()
|
|
14
|
+
_LLM_CALLS = 0
|
|
15
|
+
_INGESTED_TOKENS = 0
|
|
16
|
+
_INGESTED_MESSAGES = 0
|
|
17
|
+
_EXTRACTED_FACTS = 0
|
|
18
|
+
_RETRIEVALS = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def llm_calls() -> int:
|
|
22
|
+
return _LLM_CALLS
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bump_llm_call() -> None:
|
|
26
|
+
global _LLM_CALLS
|
|
27
|
+
with _LOCK:
|
|
28
|
+
_LLM_CALLS += 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def bump_ingest(tokens: int = 0, messages: int = 0, facts: int = 0) -> None:
|
|
32
|
+
global _INGESTED_TOKENS, _INGESTED_MESSAGES, _EXTRACTED_FACTS
|
|
33
|
+
with _LOCK:
|
|
34
|
+
_INGESTED_TOKENS += tokens
|
|
35
|
+
_INGESTED_MESSAGES += messages
|
|
36
|
+
_EXTRACTED_FACTS += facts
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bump_retrieval() -> None:
|
|
40
|
+
global _RETRIEVALS
|
|
41
|
+
with _LOCK:
|
|
42
|
+
_RETRIEVALS += 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def counters() -> dict:
|
|
46
|
+
return {
|
|
47
|
+
"llm_calls": _LLM_CALLS, # must be 0 under μ=0 protocol
|
|
48
|
+
"ingested_tokens": _INGESTED_TOKENS,
|
|
49
|
+
"ingested_messages": _INGESTED_MESSAGES,
|
|
50
|
+
"extracted_facts": _EXTRACTED_FACTS,
|
|
51
|
+
"retrievals": _RETRIEVALS,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reset_counters() -> None:
|
|
56
|
+
global _LLM_CALLS, _INGESTED_TOKENS, _INGESTED_MESSAGES, _EXTRACTED_FACTS, _RETRIEVALS
|
|
57
|
+
with _LOCK:
|
|
58
|
+
_LLM_CALLS = 0
|
|
59
|
+
_INGESTED_TOKENS = 0
|
|
60
|
+
_INGESTED_MESSAGES = 0
|
|
61
|
+
_EXTRACTED_FACTS = 0
|
|
62
|
+
_RETRIEVALS = 0
|
|
File without changes
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Migration importers — Mem0 / Zep / Chroma → Context-M.
|
|
2
|
+
|
|
3
|
+
The plan's Trojan-Horse migration tooling: read a competitor's local
|
|
4
|
+
store, convert to Trace triples (μ=0 re-extraction on raw text where
|
|
5
|
+
possible — timestamps preserved), and keep the original ids in
|
|
6
|
+
provenance for audit.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
from cortexm.errors import MigrationError
|
|
17
|
+
from cortexm.util import parse_ts
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _iter_mem0_rows(path: str):
|
|
21
|
+
"""Yield (text, created_at) from a Mem0 SQLite store.
|
|
22
|
+
|
|
23
|
+
Mem0's local store keeps a ``history`` table with JSON payloads
|
|
24
|
+
containing the original conversation plus extracted memories.
|
|
25
|
+
"""
|
|
26
|
+
if not os.path.exists(path):
|
|
27
|
+
raise MigrationError(f"mem0 store not found: {path}")
|
|
28
|
+
conn = sqlite3.connect(path)
|
|
29
|
+
conn.row_factory = sqlite3.Row
|
|
30
|
+
try:
|
|
31
|
+
tables = {r[0] for r in conn.execute(
|
|
32
|
+
"SELECT name FROM sqlite_master WHERE type='table'")}
|
|
33
|
+
if "history" in tables:
|
|
34
|
+
for row in conn.execute(
|
|
35
|
+
"SELECT data, created_at, updated_at FROM history "
|
|
36
|
+
"ORDER BY id"):
|
|
37
|
+
raw = row["data"] or ""
|
|
38
|
+
created = row["created_at"] or row["updated_at"]
|
|
39
|
+
try:
|
|
40
|
+
payload = json.loads(raw)
|
|
41
|
+
except (json.JSONDecodeError, TypeError):
|
|
42
|
+
payload = None
|
|
43
|
+
texts: list[str] = []
|
|
44
|
+
if isinstance(payload, dict):
|
|
45
|
+
for msg in payload.get("messages", []):
|
|
46
|
+
if isinstance(msg, dict) and msg.get("content"):
|
|
47
|
+
texts.append(str(msg["content"]))
|
|
48
|
+
for mem in payload.get("memories", []):
|
|
49
|
+
if isinstance(mem, str):
|
|
50
|
+
texts.append(mem)
|
|
51
|
+
elif isinstance(raw, str) and raw.strip():
|
|
52
|
+
texts.append(raw.strip())
|
|
53
|
+
for t in texts:
|
|
54
|
+
if t and len(t) > 2:
|
|
55
|
+
yield t, created
|
|
56
|
+
elif "memories" in tables:
|
|
57
|
+
cols = {r[1] for r in conn.execute("PRAGMA table_info(memories)")}
|
|
58
|
+
text_col = "data" if "data" in cols else (
|
|
59
|
+
"memory" if "memory" in cols else "text")
|
|
60
|
+
for row in conn.execute(f"SELECT {text_col} AS t, created_at "
|
|
61
|
+
f"FROM memories ORDER BY rowid"):
|
|
62
|
+
if row["t"]:
|
|
63
|
+
yield str(row["t"]), row["created_at"]
|
|
64
|
+
else:
|
|
65
|
+
raise MigrationError(
|
|
66
|
+
f"unrecognized mem0 schema (tables: {sorted(tables)})")
|
|
67
|
+
finally:
|
|
68
|
+
conn.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _iter_zep_rows(path: str):
|
|
72
|
+
"""Yield from a Zep JSONL export: {"subject","relation","object",
|
|
73
|
+
"valid_at","invalid_at"} or {"text","created_at"} rows."""
|
|
74
|
+
if not os.path.exists(path):
|
|
75
|
+
raise MigrationError(f"zep export not found: {path}")
|
|
76
|
+
with open(path, encoding="utf-8") as fh:
|
|
77
|
+
for line in fh:
|
|
78
|
+
line = line.strip()
|
|
79
|
+
if not line:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
row = json.loads(line)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
continue
|
|
85
|
+
yield row
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _iter_chroma_rows(path: str):
|
|
89
|
+
"""Yield documents from a chroma.sqlite3 store."""
|
|
90
|
+
if not os.path.exists(path):
|
|
91
|
+
raise MigrationError(f"chroma store not found: {path}")
|
|
92
|
+
conn = sqlite3.connect(path)
|
|
93
|
+
conn.row_factory = sqlite3.Row
|
|
94
|
+
try:
|
|
95
|
+
tables = {r[0] for r in conn.execute(
|
|
96
|
+
"SELECT name FROM sqlite_master WHERE type='table'")}
|
|
97
|
+
if "embeddings" in tables:
|
|
98
|
+
cols = {r[1] for r in conn.execute(
|
|
99
|
+
"PRAGMA table_info(embeddings)")}
|
|
100
|
+
doc_col = None
|
|
101
|
+
for c in ("document", "documents", "content", "data"):
|
|
102
|
+
if c in cols:
|
|
103
|
+
doc_col = c
|
|
104
|
+
break
|
|
105
|
+
if doc_col:
|
|
106
|
+
for row in conn.execute(
|
|
107
|
+
f"SELECT {doc_col} AS doc FROM embeddings"):
|
|
108
|
+
if row["doc"]:
|
|
109
|
+
yield str(row["doc"]), None
|
|
110
|
+
return
|
|
111
|
+
if "documents" in tables:
|
|
112
|
+
for row in conn.execute("SELECT * FROM documents"):
|
|
113
|
+
for v in row:
|
|
114
|
+
if v:
|
|
115
|
+
yield str(v), None
|
|
116
|
+
return
|
|
117
|
+
raise MigrationError(f"unrecognized chroma schema: {sorted(tables)}")
|
|
118
|
+
finally:
|
|
119
|
+
conn.close()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
def import_mem0(memory, path: str, user_id: str = "migrated",
|
|
124
|
+
batch: int = 200) -> dict:
|
|
125
|
+
"""Import a Mem0 store into Context-M (μ=0 re-extraction)."""
|
|
126
|
+
n_msgs, n_facts = 0, 0
|
|
127
|
+
buf, last_ts = [], None
|
|
128
|
+
for text, created in _iter_mem0_rows(path):
|
|
129
|
+
buf.append(text)
|
|
130
|
+
last_ts = created
|
|
131
|
+
if len(buf) >= batch:
|
|
132
|
+
out = memory.add(buf, user_id=user_id,
|
|
133
|
+
timestamp=parse_ts(last_ts) or
|
|
134
|
+
datetime.now(timezone.utc))
|
|
135
|
+
n_msgs += len(buf)
|
|
136
|
+
n_facts += out["stats"]["facts_inserted"]
|
|
137
|
+
buf = []
|
|
138
|
+
if buf:
|
|
139
|
+
out = memory.add(buf, user_id=user_id,
|
|
140
|
+
timestamp=parse_ts(last_ts) or
|
|
141
|
+
datetime.now(timezone.utc))
|
|
142
|
+
n_msgs += len(buf)
|
|
143
|
+
n_facts += out["stats"]["facts_inserted"]
|
|
144
|
+
return {"source": "mem0", "messages": n_msgs, "facts": n_facts}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def import_zep(memory, path: str, user_id: str = "migrated") -> dict:
|
|
148
|
+
"""Import a Zep JSONL export. Graph triples keep bi-temporal windows;
|
|
149
|
+
raw text rows are re-extracted (μ=0)."""
|
|
150
|
+
from cortexm.trace.fact import make_fact
|
|
151
|
+
n_triples, n_texts = 0, 0
|
|
152
|
+
commit = memory.store.create_commit("migrate: zep")
|
|
153
|
+
for row in _iter_zep_rows(path):
|
|
154
|
+
if all(k in row for k in ("subject", "relation", "object")):
|
|
155
|
+
f = make_fact(row["subject"], row["relation"], row["object"],
|
|
156
|
+
now=parse_ts(row.get("valid_at")) or
|
|
157
|
+
datetime.now(timezone.utc),
|
|
158
|
+
valid_to=row.get("invalid_at"),
|
|
159
|
+
user_id=user_id, confidence=0.8,
|
|
160
|
+
provenance={"migrated_from": "zep"})
|
|
161
|
+
memory.store.insert_fact(f, commit)
|
|
162
|
+
memory.palace.add(f.id, memory.palace.encode_fact(f))
|
|
163
|
+
n_triples += 1
|
|
164
|
+
elif row.get("text"):
|
|
165
|
+
memory.add(row["text"], user_id=user_id)
|
|
166
|
+
n_texts += 1
|
|
167
|
+
memory.store.end_batch()
|
|
168
|
+
memory.reader.invalidate_caches()
|
|
169
|
+
return {"source": "zep", "triples": n_triples, "texts": n_texts}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def import_chroma(memory, path: str, user_id: str = "migrated",
|
|
173
|
+
batch: int = 200) -> dict:
|
|
174
|
+
"""Import documents from a Chroma store (μ=0 re-extraction; foreign
|
|
175
|
+
embeddings are NOT portable across embedding models)."""
|
|
176
|
+
n_msgs, n_facts = 0, 0
|
|
177
|
+
buf = []
|
|
178
|
+
for doc, _ts in _iter_chroma_rows(path):
|
|
179
|
+
buf.append(doc)
|
|
180
|
+
if len(buf) >= batch:
|
|
181
|
+
out = memory.add(buf, user_id=user_id)
|
|
182
|
+
n_msgs += len(buf)
|
|
183
|
+
n_facts += out["stats"]["facts_inserted"]
|
|
184
|
+
buf = []
|
|
185
|
+
if buf:
|
|
186
|
+
out = memory.add(buf, user_id=user_id)
|
|
187
|
+
n_msgs += len(buf)
|
|
188
|
+
n_facts += out["stats"]["facts_inserted"]
|
|
189
|
+
return {"source": "chroma", "documents": n_msgs, "facts": n_facts}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
MIGRATORS = {"mem0": import_mem0, "zep": import_zep, "chroma": import_chroma}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Provenance Standards Stack — enterprise-grade provenance wrappers.
|
|
2
|
+
|
|
3
|
+
Context-M's internal integrity is BLAKE3 source hashing + a hash-chained
|
|
4
|
+
audit log. This is cryptographically sound but NOT standards-compliant.
|
|
5
|
+
Enterprise security reviews ask: "Does it support W3C VC? C2PA? SCITT?"
|
|
6
|
+
— this layer answers "yes" by wrapping the existing BLAKE3 hashes into
|
|
7
|
+
standards-compliant envelopes.
|
|
8
|
+
|
|
9
|
+
Three sub-modules:
|
|
10
|
+
|
|
11
|
+
cose.py — COSE Sign1 envelopes (RFC 9052) using Ed25519. Every
|
|
12
|
+
memory commit gets wrapped in a COSE Sign1 with the
|
|
13
|
+
agent's Ed25519 key, so external verifiers can confirm
|
|
14
|
+
the commit came from a known agent without trusting the
|
|
15
|
+
host's audit log.
|
|
16
|
+
|
|
17
|
+
vc.py — W3C Verifiable Credentials 2.0 export. A memory range
|
|
18
|
+
(e.g. "all facts about user=alice from 2026-01-01 to
|
|
19
|
+
2026-02-01") can be exported as a W3C VC Data Integrity
|
|
20
|
+
proof (eddsa-jcs-2022). The VC contains the BLAKE3 root
|
|
21
|
+
of the range + the COSE Sign1 envelope that proves the
|
|
22
|
+
agent signed it.
|
|
23
|
+
|
|
24
|
+
scitt.py — SCITT-signed statements. Forwards a COSE Sign1 to a
|
|
25
|
+
(mocked) SCITT transparency log, returns a receipt that
|
|
26
|
+
can be verified by a third party.
|
|
27
|
+
|
|
28
|
+
Design: BLAKE3 source hashing stays as the internal integrity mechanism.
|
|
29
|
+
This layer SITS ON TOP for external interoperability. No replacement —
|
|
30
|
+
just a wrapper. Existing `verify_integrity()` stays; the standards
|
|
31
|
+
layer adds `verify_with_standards()` that calls both.
|
|
32
|
+
|
|
33
|
+
Pure Python — no halo2, no BBS+, no JWT libs. Ed25519 uses the
|
|
34
|
+
Python stdlib `hashlib` for hashing and a tiny ed25519 implementation
|
|
35
|
+
fallback if `cryptography` is not available. The COSE/VC/SCITT wire
|
|
36
|
+
formats are JSON (CBOR is preferred per RFC 9052, but JSON works for
|
|
37
|
+
the prototype).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from cortexm.provenance.cose import (
|
|
41
|
+
CoseSign1Envelope,
|
|
42
|
+
sign_commit,
|
|
43
|
+
verify_commit,
|
|
44
|
+
)
|
|
45
|
+
from cortexm.provenance.vc import (
|
|
46
|
+
VerifiableCredential,
|
|
47
|
+
export_memory_range_vc,
|
|
48
|
+
verify_vc,
|
|
49
|
+
)
|
|
50
|
+
from cortexm.provenance.scitt import (
|
|
51
|
+
ScittStatement,
|
|
52
|
+
ScittReceipt,
|
|
53
|
+
submit_to_scitt,
|
|
54
|
+
verify_receipt,
|
|
55
|
+
reset_scitt_log,
|
|
56
|
+
get_scitt_service_did,
|
|
57
|
+
)
|
|
58
|
+
from cortexm.provenance.agent import (
|
|
59
|
+
Ed25519AgentKey,
|
|
60
|
+
get_default_agent,
|
|
61
|
+
set_default_agent,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
# cose
|
|
66
|
+
"CoseSign1Envelope", "Ed25519AgentKey",
|
|
67
|
+
"sign_commit", "verify_commit",
|
|
68
|
+
# vc
|
|
69
|
+
"VerifiableCredential",
|
|
70
|
+
"export_memory_range_vc", "verify_vc",
|
|
71
|
+
# scitt
|
|
72
|
+
"ScittStatement", "ScittReceipt",
|
|
73
|
+
"submit_to_scitt", "verify_receipt",
|
|
74
|
+
"reset_scitt_log", "get_scitt_service_did",
|
|
75
|
+
# agent
|
|
76
|
+
"Ed25519AgentKey",
|
|
77
|
+
"get_default_agent", "set_default_agent",
|
|
78
|
+
]
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""ProvenanceAgent — Ed25519 identity for memory commits.
|
|
2
|
+
|
|
3
|
+
Each agent (LLM instance, microservice, scheduled job) that writes to
|
|
4
|
+
the Trace has an Ed25519 signing key. The agent's public key is
|
|
5
|
+
exposed as a `did:key` identifier so external verifiers can resolve
|
|
6
|
+
the key from the DID without an external registry.
|
|
7
|
+
|
|
8
|
+
The agent key is persisted to a tiny PEM-ish file (path from
|
|
9
|
+
Config.provenance_agent_key_path). If no path is set, the agent uses
|
|
10
|
+
a process-local ephemeral key (regenerated on restart — fine for
|
|
11
|
+
tests, not for production cross-system verification).
|
|
12
|
+
|
|
13
|
+
Uses the `cryptography` library for Ed25519 (constant-time, audited).
|
|
14
|
+
If `cryptography` is unavailable, falls back to an HMAC-SHA256 pseudo-
|
|
15
|
+
signature clearly labeled "DEV-MODE" — NOT for production.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import os
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Ed25519AgentKey:
|
|
29
|
+
"""An Ed25519 signing keypair for a provenance agent."""
|
|
30
|
+
private_key: bytes # 32-byte seed (Ed25519 form)
|
|
31
|
+
public_key: bytes # 32-byte pubkey
|
|
32
|
+
did: str = "" # did:key identifier — derived from pubkey
|
|
33
|
+
label: str = "context-m-agent"
|
|
34
|
+
dev_mode: bool = False # True when using HMAC fallback (no cryptography lib)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def generate(cls, label: str = "context-m-agent") -> "Ed25519AgentKey":
|
|
38
|
+
"""Generate a fresh Ed25519 keypair."""
|
|
39
|
+
try:
|
|
40
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
41
|
+
Ed25519PrivateKey)
|
|
42
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
43
|
+
Encoding, PrivateFormat, NoEncryption, PublicFormat)
|
|
44
|
+
priv = Ed25519PrivateKey.generate()
|
|
45
|
+
priv_bytes = priv.private_bytes(
|
|
46
|
+
encoding=Encoding.Raw,
|
|
47
|
+
format=PrivateFormat.Raw,
|
|
48
|
+
encryption_algorithm=NoEncryption(),
|
|
49
|
+
)
|
|
50
|
+
pub_bytes = priv.public_key().public_bytes(
|
|
51
|
+
encoding=Encoding.Raw,
|
|
52
|
+
format=PublicFormat.Raw,
|
|
53
|
+
)
|
|
54
|
+
did = _did_key_from_pubkey(pub_bytes)
|
|
55
|
+
return cls(
|
|
56
|
+
private_key=priv_bytes,
|
|
57
|
+
public_key=pub_bytes,
|
|
58
|
+
did=did,
|
|
59
|
+
label=label,
|
|
60
|
+
)
|
|
61
|
+
except ImportError:
|
|
62
|
+
# DEV fallback: HMAC-SHA256 — NOT for production.
|
|
63
|
+
seed = os.urandom(32)
|
|
64
|
+
return cls(
|
|
65
|
+
private_key=seed,
|
|
66
|
+
public_key=seed, # symmetric — fallback only
|
|
67
|
+
did=f"did:dev:{hashlib.sha256(seed).hexdigest()[:32]}",
|
|
68
|
+
label=label,
|
|
69
|
+
dev_mode=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_pem(cls, pem_path: str,
|
|
74
|
+
label: str = "context-m-agent") -> "Ed25519AgentKey":
|
|
75
|
+
"""Load a keypair from a PEM-ish file. Generates one if missing."""
|
|
76
|
+
if not os.path.exists(pem_path):
|
|
77
|
+
key = cls.generate(label=label)
|
|
78
|
+
key.save_pem(pem_path)
|
|
79
|
+
return key
|
|
80
|
+
try:
|
|
81
|
+
with open(pem_path, "rb") as f:
|
|
82
|
+
text = f.read().decode("utf-8")
|
|
83
|
+
lines = [l.strip() for l in text.splitlines()
|
|
84
|
+
if l and not l.startswith("-")]
|
|
85
|
+
priv = base64.b64decode(lines[0])
|
|
86
|
+
pub = base64.b64decode(lines[1]) if len(lines) > 1 else b""
|
|
87
|
+
if len(priv) != 32:
|
|
88
|
+
raise ValueError("private key must be 32 bytes")
|
|
89
|
+
if not pub or len(pub) != 32:
|
|
90
|
+
# regenerate pub from priv
|
|
91
|
+
try:
|
|
92
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
93
|
+
Ed25519PrivateKey)
|
|
94
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
95
|
+
Encoding, PublicFormat)
|
|
96
|
+
pub = (Ed25519PrivateKey.from_private_bytes(priv)
|
|
97
|
+
.public_key().public_bytes(
|
|
98
|
+
encoding=Encoding.Raw,
|
|
99
|
+
format=PublicFormat.Raw))
|
|
100
|
+
except ImportError:
|
|
101
|
+
pub = priv
|
|
102
|
+
did = _did_key_from_pubkey(pub) if len(pub) == 32 else f"did:dev:{hashlib.sha256(priv).hexdigest()[:32]}"
|
|
103
|
+
return cls(
|
|
104
|
+
private_key=priv, public_key=pub, did=did, label=label,
|
|
105
|
+
dev_mode=(len(pub) != 32))
|
|
106
|
+
except Exception:
|
|
107
|
+
key = cls.generate(label=label)
|
|
108
|
+
key.save_pem(pem_path)
|
|
109
|
+
return key
|
|
110
|
+
|
|
111
|
+
def save_pem(self, pem_path: str) -> None:
|
|
112
|
+
"""Persist the keypair to a tiny PEM-ish file."""
|
|
113
|
+
os.makedirs(os.path.dirname(pem_path) or ".", exist_ok=True)
|
|
114
|
+
text = (
|
|
115
|
+
f"-----BEGIN ED25519 PRIVATE KEY-----\n"
|
|
116
|
+
f"{base64.b64encode(self.private_key).decode()}\n"
|
|
117
|
+
f"-----END ED25519 PRIVATE KEY-----\n"
|
|
118
|
+
f"-----BEGIN ED25519 PUBLIC KEY-----\n"
|
|
119
|
+
f"{base64.b64encode(self.public_key).decode()}\n"
|
|
120
|
+
f"-----END ED25519 PUBLIC KEY-----\n"
|
|
121
|
+
f"LABEL: {self.label}\n"
|
|
122
|
+
)
|
|
123
|
+
with open(pem_path, "w") as f:
|
|
124
|
+
f.write(text)
|
|
125
|
+
try:
|
|
126
|
+
os.chmod(pem_path, 0o600)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def sign(self, message: bytes) -> bytes:
|
|
131
|
+
"""Sign a message with the agent's private key."""
|
|
132
|
+
if self.dev_mode:
|
|
133
|
+
# DEV fallback — HMAC-SHA256, clearly labeled
|
|
134
|
+
mac = hmac.new(self.private_key, message, hashlib.sha256).digest()
|
|
135
|
+
return b"DEV-MODE:" + mac
|
|
136
|
+
try:
|
|
137
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
138
|
+
Ed25519PrivateKey)
|
|
139
|
+
priv = Ed25519PrivateKey.from_private_bytes(self.private_key)
|
|
140
|
+
return priv.sign(message)
|
|
141
|
+
except Exception:
|
|
142
|
+
# crypto lib missing at sign time — fall back
|
|
143
|
+
mac = hmac.new(self.private_key, message, hashlib.sha256).digest()
|
|
144
|
+
return b"DEV-MODE:" + mac
|
|
145
|
+
|
|
146
|
+
def verify(self, message: bytes, signature: bytes) -> bool:
|
|
147
|
+
"""Verify a signature with the agent's public key."""
|
|
148
|
+
if signature.startswith(b"DEV-MODE:"):
|
|
149
|
+
# DEV fallback verify
|
|
150
|
+
expected = hmac.new(self.private_key, message,
|
|
151
|
+
hashlib.sha256).digest()
|
|
152
|
+
actual = signature[len(b"DEV-MODE:"):]
|
|
153
|
+
return hmac.compare_digest(expected, actual)
|
|
154
|
+
try:
|
|
155
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
156
|
+
Ed25519PublicKey)
|
|
157
|
+
pub = Ed25519PublicKey.from_public_bytes(self.public_key)
|
|
158
|
+
try:
|
|
159
|
+
pub.verify(signature, message)
|
|
160
|
+
return True
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
163
|
+
except ImportError:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _did_key_from_pubkey(pubkey: bytes) -> str:
|
|
168
|
+
"""Encode a 32-byte Ed25519 pubkey as a did:key identifier.
|
|
169
|
+
|
|
170
|
+
Format: did:key:z<base58btc-multibase-prefix><pubkey>
|
|
171
|
+
The multicodec prefix for Ed25519 is 0xed01.
|
|
172
|
+
"""
|
|
173
|
+
prefixed = b"\xed\x01" + pubkey
|
|
174
|
+
encoded = _base58btc_encode(prefixed)
|
|
175
|
+
return f"did:key:z{encoded}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _base58btc_encode(data: bytes) -> str:
|
|
179
|
+
"""Encode bytes in base58 with the Bitcoin alphabet."""
|
|
180
|
+
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
181
|
+
num = int.from_bytes(data, "big")
|
|
182
|
+
encoded = ""
|
|
183
|
+
while num > 0:
|
|
184
|
+
num, rem = divmod(num, 58)
|
|
185
|
+
encoded = alphabet[rem] + encoded
|
|
186
|
+
n_zeros = 0
|
|
187
|
+
for b in data:
|
|
188
|
+
if b == 0:
|
|
189
|
+
n_zeros += 1
|
|
190
|
+
else:
|
|
191
|
+
break
|
|
192
|
+
return "1" * n_zeros + encoded
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------- default agent registry ------------------------------------
|
|
196
|
+
|
|
197
|
+
_default_agent = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_default_agent() -> Ed25519AgentKey:
|
|
201
|
+
"""Get the process-default agent key. Generates one on first call."""
|
|
202
|
+
global _default_agent
|
|
203
|
+
if _default_agent is None:
|
|
204
|
+
_default_agent = Ed25519AgentKey.generate()
|
|
205
|
+
return _default_agent
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def set_default_agent(agent: Ed25519AgentKey) -> None:
|
|
209
|
+
"""Set the process-default agent key (e.g. loaded from PEM)."""
|
|
210
|
+
global _default_agent
|
|
211
|
+
_default_agent = agent
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = ["Ed25519AgentKey", "get_default_agent", "set_default_agent"]
|