lethe-delete 0.1.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.
- lethe/__init__.py +5 -0
- lethe/audit.py +83 -0
- lethe/cert_schema.py +82 -0
- lethe/certificate.py +126 -0
- lethe/cli.py +119 -0
- lethe/connectors/__init__.py +0 -0
- lethe/connectors/base.py +14 -0
- lethe/connectors/pgvector.py +33 -0
- lethe/connectors/pinecone.py +59 -0
- lethe/core.py +145 -0
- lethe/guard.py +112 -0
- lethe/hashing.py +11 -0
- lethe/integrations/__init__.py +0 -0
- lethe/integrations/langchain.py +226 -0
- lethe/ledger.py +53 -0
- lethe/mcp.py +356 -0
- lethe/models.py +34 -0
- lethe/schemas/certificate-v1.json +55 -0
- lethe/signing.py +45 -0
- lethe/version.py +1 -0
- lethe_delete-0.1.0.dist-info/METADATA +209 -0
- lethe_delete-0.1.0.dist-info/RECORD +26 -0
- lethe_delete-0.1.0.dist-info/WHEEL +5 -0
- lethe_delete-0.1.0.dist-info/entry_points.txt +3 -0
- lethe_delete-0.1.0.dist-info/licenses/LICENSE +201 -0
- lethe_delete-0.1.0.dist-info/top_level.txt +1 -0
lethe/__init__.py
ADDED
lethe/audit.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import psycopg
|
|
6
|
+
|
|
7
|
+
GENESIS = "0" * 64
|
|
8
|
+
|
|
9
|
+
AUDIT_SCHEMA = """
|
|
10
|
+
CREATE TABLE IF NOT EXISTS lethe_audit (
|
|
11
|
+
seq BIGSERIAL PRIMARY KEY,
|
|
12
|
+
entry JSONB NOT NULL,
|
|
13
|
+
prev_hash TEXT NOT NULL,
|
|
14
|
+
entry_hash TEXT NOT NULL,
|
|
15
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
16
|
+
);
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _entry_hash(prev_hash: str, entry: dict) -> str:
|
|
21
|
+
data = prev_hash + json.dumps(entry, sort_keys=True, separators=(",", ":"))
|
|
22
|
+
return hashlib.sha256(data.encode()).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuditLog:
|
|
26
|
+
def __init__(self, conn: psycopg.Connection):
|
|
27
|
+
self.conn = conn
|
|
28
|
+
|
|
29
|
+
def init_schema(self) -> None:
|
|
30
|
+
with self.conn.cursor() as cur:
|
|
31
|
+
cur.execute(AUDIT_SCHEMA)
|
|
32
|
+
self.conn.commit()
|
|
33
|
+
|
|
34
|
+
def _last_hash(self) -> str:
|
|
35
|
+
with self.conn.cursor() as cur:
|
|
36
|
+
cur.execute("SELECT entry_hash FROM lethe_audit ORDER BY seq DESC LIMIT 1")
|
|
37
|
+
row = cur.fetchone()
|
|
38
|
+
return row[0] if row else GENESIS
|
|
39
|
+
|
|
40
|
+
def append(self, entry: dict) -> str:
|
|
41
|
+
prev = self._last_hash()
|
|
42
|
+
h = _entry_hash(prev, entry)
|
|
43
|
+
with self.conn.cursor() as cur:
|
|
44
|
+
cur.execute(
|
|
45
|
+
"INSERT INTO lethe_audit (entry, prev_hash, entry_hash) VALUES (%s, %s, %s)",
|
|
46
|
+
(json.dumps(entry), prev, h),
|
|
47
|
+
)
|
|
48
|
+
self.conn.commit()
|
|
49
|
+
return h
|
|
50
|
+
|
|
51
|
+
def head(self) -> str:
|
|
52
|
+
"""Current tip hash (GENESIS if the log is empty). Record this
|
|
53
|
+
out-of-band; later pass it to verify_chain(expected_head=...) to detect
|
|
54
|
+
tip-truncation (deletion of the most recent entries)."""
|
|
55
|
+
return self._last_hash()
|
|
56
|
+
|
|
57
|
+
def verify_chain(self, expected_head: str | None = None) -> bool:
|
|
58
|
+
"""Verify the hash chain links from genesis to the current tip.
|
|
59
|
+
|
|
60
|
+
SECURITY: an internal walk proves no entry was *altered* or removed from
|
|
61
|
+
the *middle* (that breaks a prev_hash link), but it CANNOT detect tip
|
|
62
|
+
truncation — deleting the most recent entries leaves a self-consistent,
|
|
63
|
+
merely shorter chain. To catch an attacker lopping off the tail (erasing
|
|
64
|
+
evidence of recent erasures), the operator must record the head hash
|
|
65
|
+
out-of-band and pass it as ``expected_head`` — the same pin-it-externally
|
|
66
|
+
discipline the certificate uses for the trusted public key. With no
|
|
67
|
+
entries the head is ``GENESIS``.
|
|
68
|
+
"""
|
|
69
|
+
with self.conn.cursor() as cur:
|
|
70
|
+
cur.execute(
|
|
71
|
+
"SELECT entry, prev_hash, entry_hash FROM lethe_audit ORDER BY seq ASC"
|
|
72
|
+
)
|
|
73
|
+
rows = cur.fetchall()
|
|
74
|
+
prev = GENESIS
|
|
75
|
+
for entry, prev_hash, entry_hash in rows:
|
|
76
|
+
if prev_hash != prev:
|
|
77
|
+
return False
|
|
78
|
+
if _entry_hash(prev_hash, entry) != entry_hash:
|
|
79
|
+
return False
|
|
80
|
+
prev = entry_hash
|
|
81
|
+
if expected_head is not None and not hmac.compare_digest(prev, expected_head):
|
|
82
|
+
return False
|
|
83
|
+
return True
|
lethe/cert_schema.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Machine verification of Lethe certificates: JSON-Schema shape check plus
|
|
2
|
+
key-pinned cryptographic verification with structured, agent-branchable reasons."""
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac as hmac_mod
|
|
8
|
+
import json
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from importlib import resources
|
|
11
|
+
|
|
12
|
+
import jsonschema
|
|
13
|
+
|
|
14
|
+
from .certificate import canonical_payload_bytes
|
|
15
|
+
from .signing import verify_signature
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@lru_cache(maxsize=1)
|
|
19
|
+
def _load_schema() -> dict:
|
|
20
|
+
text = (
|
|
21
|
+
resources.files("lethe").joinpath("schemas/certificate-v1.json").read_text()
|
|
22
|
+
)
|
|
23
|
+
return json.loads(text)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache(maxsize=1)
|
|
27
|
+
def _validator() -> jsonschema.Draft202012Validator:
|
|
28
|
+
return jsonschema.Draft202012Validator(_load_schema())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_schema() -> dict:
|
|
32
|
+
"""Return the certificate v1 JSON Schema.
|
|
33
|
+
|
|
34
|
+
Returns a defensive deep copy: the cached schema dict is shared process-wide,
|
|
35
|
+
so callers must never be handed the mutable original.
|
|
36
|
+
"""
|
|
37
|
+
return copy.deepcopy(_load_schema())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def schema_errors(data) -> list[str]:
|
|
41
|
+
return [
|
|
42
|
+
f"{'/'.join(str(p) for p in e.absolute_path) or '<root>'}: {e.message}"
|
|
43
|
+
for e in sorted(
|
|
44
|
+
_validator().iter_errors(data),
|
|
45
|
+
key=lambda e: [str(x) for x in e.absolute_path],
|
|
46
|
+
)
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def verify_certificate_json(data, trusted_public_key: str) -> dict:
|
|
51
|
+
"""Verify a certificate received as JSON. The trusted key is REQUIRED:
|
|
52
|
+
an unpinned check only proves self-consistency, never authenticity.
|
|
53
|
+
Reasons an agent can branch on, checked in order:
|
|
54
|
+
SCHEMA_MISMATCH -> KEY_MISMATCH -> PAYLOAD_TAMPERED -> BAD_SIGNATURE."""
|
|
55
|
+
errors = schema_errors(data)
|
|
56
|
+
if errors:
|
|
57
|
+
return {"valid": False, "reasons": ["SCHEMA_MISMATCH"], "detail": errors}
|
|
58
|
+
try:
|
|
59
|
+
embedded = base64.b64decode(data["public_key"], validate=True)
|
|
60
|
+
trusted = base64.b64decode(trusted_public_key, validate=True)
|
|
61
|
+
except Exception:
|
|
62
|
+
return {
|
|
63
|
+
"valid": False, "reasons": ["KEY_MISMATCH"],
|
|
64
|
+
"detail": ["public key is not valid base64"],
|
|
65
|
+
}
|
|
66
|
+
if not hmac_mod.compare_digest(embedded, trusted):
|
|
67
|
+
return {
|
|
68
|
+
"valid": False, "reasons": ["KEY_MISMATCH"],
|
|
69
|
+
"detail": ["certificate's embedded key does not match the trusted key"],
|
|
70
|
+
}
|
|
71
|
+
payload_bytes = canonical_payload_bytes(data["payload"])
|
|
72
|
+
if hashlib.sha256(payload_bytes).hexdigest() != data["payload_hash"]:
|
|
73
|
+
return {
|
|
74
|
+
"valid": False, "reasons": ["PAYLOAD_TAMPERED"],
|
|
75
|
+
"detail": ["payload_hash does not match the payload"],
|
|
76
|
+
}
|
|
77
|
+
if not verify_signature(data["public_key"], payload_bytes, data["signature"]):
|
|
78
|
+
return {
|
|
79
|
+
"valid": False, "reasons": ["BAD_SIGNATURE"],
|
|
80
|
+
"detail": ["Ed25519 signature verification failed"],
|
|
81
|
+
}
|
|
82
|
+
return {"valid": True, "reasons": [], "detail": []}
|
lethe/certificate.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from .models import Certificate, LayerResult
|
|
7
|
+
from .signing import Signer, verify_signature
|
|
8
|
+
|
|
9
|
+
CLAIM = (
|
|
10
|
+
"Deleted across the listed retrieval layers and verified absent by re-query "
|
|
11
|
+
"at issue time against the configured endpoint. Not a guarantee of erasure "
|
|
12
|
+
"from backups, model weights, or systems outside Lethe's configured "
|
|
13
|
+
"connectors, nor a guarantee against read replicas, caches, or asynchronous "
|
|
14
|
+
"propagation (e.g. eventually-consistent stores such as Pinecone)."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
CERT_SCHEMA_VERSION = "lethe.cert/1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _canonical_bytes(payload: dict) -> bytes:
|
|
21
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_certificate(
|
|
25
|
+
*,
|
|
26
|
+
request_id: str,
|
|
27
|
+
subject_hash: str,
|
|
28
|
+
layers: list[LayerResult],
|
|
29
|
+
issued_at: str,
|
|
30
|
+
version: str,
|
|
31
|
+
signer: Signer,
|
|
32
|
+
) -> Certificate:
|
|
33
|
+
ordered = sorted(layers, key=lambda l: (l.store, l.namespace))
|
|
34
|
+
|
|
35
|
+
def _is_erasure(l: LayerResult) -> bool:
|
|
36
|
+
# A layer is a genuine, certifiable erasure only when Lethe was handled
|
|
37
|
+
# by a real connector, confirmed the records absent, AND actually removed
|
|
38
|
+
# records this run. deleted_count 0 with verified_absent True means the
|
|
39
|
+
# data was already gone (another subject's forget, or a prior partial
|
|
40
|
+
# run) — Lethe did NOT perform this erasure and must not certify it.
|
|
41
|
+
return l.handled and l.verified_absent and l.deleted_count > 0
|
|
42
|
+
|
|
43
|
+
erasures = [_is_erasure(l) for l in ordered]
|
|
44
|
+
|
|
45
|
+
# all_verified is the load-bearing claim a regulator relies on. It is True
|
|
46
|
+
# ONLY if at least one layer was found AND every layer is a genuine erasure.
|
|
47
|
+
# Empty layers (unknown subject / nothing found) => all([]) would be True;
|
|
48
|
+
# we require a non-empty layer set so "found nothing" can never read as a
|
|
49
|
+
# successful, verified erasure.
|
|
50
|
+
all_verified = bool(ordered) and all(erasures)
|
|
51
|
+
|
|
52
|
+
payload = {
|
|
53
|
+
"schema": CERT_SCHEMA_VERSION,
|
|
54
|
+
"request_id": request_id,
|
|
55
|
+
"subject_hash": subject_hash,
|
|
56
|
+
"issued_at": issued_at,
|
|
57
|
+
"lethe_version": version,
|
|
58
|
+
"claim": CLAIM,
|
|
59
|
+
"all_verified": all_verified,
|
|
60
|
+
# Honest summary of what actually happened, so a zero-deletion or
|
|
61
|
+
# unhandled-store outcome cannot be misread off the layer list.
|
|
62
|
+
"layers_found": len(ordered),
|
|
63
|
+
"records_deleted": sum(l.deleted_count for l in ordered),
|
|
64
|
+
"all_layers_handled": all(l.handled for l in ordered) if ordered else True,
|
|
65
|
+
"layers": [
|
|
66
|
+
{
|
|
67
|
+
"store": l.store,
|
|
68
|
+
"namespace": l.namespace,
|
|
69
|
+
"deleted_count": l.deleted_count,
|
|
70
|
+
"verified_absent": l.verified_absent,
|
|
71
|
+
"requested_count": l.requested_count,
|
|
72
|
+
"handled": l.handled,
|
|
73
|
+
"erased": erased,
|
|
74
|
+
}
|
|
75
|
+
for l, erased in zip(ordered, erasures)
|
|
76
|
+
],
|
|
77
|
+
}
|
|
78
|
+
data = _canonical_bytes(payload)
|
|
79
|
+
return Certificate(
|
|
80
|
+
payload=payload,
|
|
81
|
+
payload_hash=hashlib.sha256(data).hexdigest(),
|
|
82
|
+
signature=signer.sign(data),
|
|
83
|
+
public_key=signer.public_key_b64(),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def verify_certificate(cert: Certificate, trusted_public_key: str) -> bool:
|
|
88
|
+
"""Verify a deletion certificate. Key pinning is MANDATORY.
|
|
89
|
+
|
|
90
|
+
SECURITY: a certificate carries the public key that signed it, so a
|
|
91
|
+
self-verifying check ("does the embedded key validate the embedded
|
|
92
|
+
signature?") only proves internal consistency, NOT authenticity — an
|
|
93
|
+
attacker can mint a fully self-consistent certificate with their own
|
|
94
|
+
keypair. A certificate can therefore only be verified against the
|
|
95
|
+
operator's out-of-band-published key (e.g. from /.well-known), passed as
|
|
96
|
+
``trusted_public_key``; self-consistency-only checks are not proof and
|
|
97
|
+
are no longer offered. The certificate's embedded key must match the
|
|
98
|
+
trusted key before the signature is even checked.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
embedded = base64.b64decode(cert.public_key, validate=True)
|
|
102
|
+
trusted = base64.b64decode(trusted_public_key, validate=True)
|
|
103
|
+
except Exception:
|
|
104
|
+
return False
|
|
105
|
+
if not hmac.compare_digest(embedded, trusted):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
data = _canonical_bytes(cert.payload)
|
|
109
|
+
if hashlib.sha256(data).hexdigest() != cert.payload_hash:
|
|
110
|
+
return False
|
|
111
|
+
return verify_signature(cert.public_key, data, cert.signature)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def canonical_payload_bytes(payload: dict) -> bytes:
|
|
115
|
+
"""Public alias: the exact byte encoding that is hashed and signed."""
|
|
116
|
+
return _canonical_bytes(payload)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def certificate_to_dict(cert: Certificate) -> dict:
|
|
120
|
+
"""The wire/JSON envelope — matches schemas/certificate-v1.json."""
|
|
121
|
+
return {
|
|
122
|
+
"payload": cert.payload,
|
|
123
|
+
"payload_hash": cert.payload_hash,
|
|
124
|
+
"signature": cert.signature,
|
|
125
|
+
"public_key": cert.public_key,
|
|
126
|
+
}
|
lethe/cli.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import psycopg
|
|
5
|
+
|
|
6
|
+
from .audit import AuditLog
|
|
7
|
+
from .certificate import verify_certificate
|
|
8
|
+
from .connectors.pgvector import PgVectorConnector
|
|
9
|
+
from .core import Lethe
|
|
10
|
+
from .ledger import Ledger
|
|
11
|
+
from .models import Certificate
|
|
12
|
+
from .signing import Signer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def cli() -> None:
|
|
17
|
+
"""Lethe — the forget button for AI."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cli.command("keygen")
|
|
21
|
+
@click.option("--out", "out", required=True, help="Path to write the Ed25519 private key.")
|
|
22
|
+
def keygen(out: str) -> None:
|
|
23
|
+
signer = Signer.generate()
|
|
24
|
+
with open(out, "wb") as f:
|
|
25
|
+
f.write(signer.private_bytes())
|
|
26
|
+
click.echo(f"Wrote signing key to {out}")
|
|
27
|
+
click.echo(f"Public key (publish this for verifiers): {signer.public_key_b64()}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@cli.command("init-db")
|
|
31
|
+
@click.option("--database-url", envvar="DATABASE_URL", required=True)
|
|
32
|
+
def init_db(database_url: str) -> None:
|
|
33
|
+
with psycopg.connect(database_url) as conn:
|
|
34
|
+
Ledger(conn).init_schema()
|
|
35
|
+
AuditLog(conn).init_schema()
|
|
36
|
+
click.echo("Lethe schema initialized.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@cli.command("forget")
|
|
40
|
+
@click.argument("subject_id")
|
|
41
|
+
@click.option("--database-url", envvar="DATABASE_URL", required=True)
|
|
42
|
+
@click.option("--salt", envvar="LETHE_SALT", required=True)
|
|
43
|
+
@click.option("--key-file", envvar="LETHE_KEY_FILE", required=True)
|
|
44
|
+
def forget(subject_id: str, database_url: str, salt: str, key_file: str) -> None:
|
|
45
|
+
with open(key_file, "rb") as f:
|
|
46
|
+
signer = Signer.from_private_bytes(f.read())
|
|
47
|
+
with psycopg.connect(database_url) as conn:
|
|
48
|
+
lethe = Lethe(
|
|
49
|
+
ledger=Ledger(conn),
|
|
50
|
+
audit=AuditLog(conn),
|
|
51
|
+
signer=signer,
|
|
52
|
+
connectors={"pgvector": PgVectorConnector(conn)},
|
|
53
|
+
salt=salt,
|
|
54
|
+
)
|
|
55
|
+
cert = lethe.forget(subject_id)
|
|
56
|
+
click.echo(
|
|
57
|
+
json.dumps(
|
|
58
|
+
{
|
|
59
|
+
"payload": cert.payload,
|
|
60
|
+
"payload_hash": cert.payload_hash,
|
|
61
|
+
"signature": cert.signature,
|
|
62
|
+
"public_key": cert.public_key,
|
|
63
|
+
},
|
|
64
|
+
indent=2,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cli.command("verify")
|
|
70
|
+
@click.argument("cert_file")
|
|
71
|
+
@click.option(
|
|
72
|
+
"--public-key",
|
|
73
|
+
"public_key",
|
|
74
|
+
required=True,
|
|
75
|
+
help="The operator's published (trusted) Ed25519 public key, base64. "
|
|
76
|
+
"Required: an unpinned check only proves self-consistency, not authenticity.",
|
|
77
|
+
)
|
|
78
|
+
def verify(cert_file: str, public_key: str) -> None:
|
|
79
|
+
with open(cert_file) as f:
|
|
80
|
+
data = json.load(f)
|
|
81
|
+
cert = Certificate(
|
|
82
|
+
payload=data["payload"],
|
|
83
|
+
payload_hash=data["payload_hash"],
|
|
84
|
+
signature=data["signature"],
|
|
85
|
+
public_key=data["public_key"],
|
|
86
|
+
)
|
|
87
|
+
ok = verify_certificate(cert, trusted_public_key=public_key)
|
|
88
|
+
click.echo("VALID" if ok else "INVALID")
|
|
89
|
+
raise SystemExit(0 if ok else 1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@cli.command("audit-head")
|
|
93
|
+
@click.option("--database-url", envvar="DATABASE_URL", required=True)
|
|
94
|
+
def audit_head(database_url: str) -> None:
|
|
95
|
+
"""Print the current audit-log head hash. Record it out-of-band so you can
|
|
96
|
+
later detect tip-truncation with `verify-audit --expected-head`."""
|
|
97
|
+
with psycopg.connect(database_url) as conn:
|
|
98
|
+
click.echo(AuditLog(conn).head())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@cli.command("verify-audit")
|
|
102
|
+
@click.option("--database-url", envvar="DATABASE_URL", required=True)
|
|
103
|
+
@click.option(
|
|
104
|
+
"--expected-head",
|
|
105
|
+
default=None,
|
|
106
|
+
help="The audit head you recorded out-of-band. Without it, tip-truncation "
|
|
107
|
+
"(deletion of the most recent entries) cannot be detected.",
|
|
108
|
+
)
|
|
109
|
+
def verify_audit(database_url: str, expected_head: str | None) -> None:
|
|
110
|
+
with psycopg.connect(database_url) as conn:
|
|
111
|
+
ok = AuditLog(conn).verify_chain(expected_head=expected_head)
|
|
112
|
+
if expected_head is None:
|
|
113
|
+
click.echo(
|
|
114
|
+
"WARNING: no --expected-head given; internal links checked but "
|
|
115
|
+
"tip-truncation is undetectable.",
|
|
116
|
+
err=True,
|
|
117
|
+
)
|
|
118
|
+
click.echo("VALID" if ok else "INVALID")
|
|
119
|
+
raise SystemExit(0 if ok else 1)
|
|
File without changes
|
lethe/connectors/base.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Protocol, runtime_checkable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@runtime_checkable
|
|
5
|
+
class Connector(Protocol):
|
|
6
|
+
name: str
|
|
7
|
+
|
|
8
|
+
def delete(self, namespace: str, record_ids: list[str]) -> int:
|
|
9
|
+
"""Delete the given records from `namespace`; return rows deleted."""
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
def verify(self, namespace: str, record_ids: list[str]) -> bool:
|
|
13
|
+
"""Return True only if none of `record_ids` remain in `namespace`."""
|
|
14
|
+
...
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import psycopg
|
|
2
|
+
from psycopg import sql
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PgVectorConnector:
|
|
6
|
+
name = "pgvector"
|
|
7
|
+
|
|
8
|
+
def __init__(self, conn: psycopg.Connection, id_column: str = "id"):
|
|
9
|
+
self.conn = conn
|
|
10
|
+
self.id_column = id_column
|
|
11
|
+
|
|
12
|
+
def delete(self, namespace: str, record_ids: list[str]) -> int:
|
|
13
|
+
if not record_ids:
|
|
14
|
+
return 0
|
|
15
|
+
query = sql.SQL("DELETE FROM {tbl} WHERE {col} = ANY(%s)").format(
|
|
16
|
+
tbl=sql.Identifier(namespace), col=sql.Identifier(self.id_column)
|
|
17
|
+
)
|
|
18
|
+
with self.conn.cursor() as cur:
|
|
19
|
+
cur.execute(query, (record_ids,))
|
|
20
|
+
n = cur.rowcount
|
|
21
|
+
self.conn.commit()
|
|
22
|
+
return n
|
|
23
|
+
|
|
24
|
+
def verify(self, namespace: str, record_ids: list[str]) -> bool:
|
|
25
|
+
if not record_ids:
|
|
26
|
+
return True
|
|
27
|
+
query = sql.SQL("SELECT count(*) FROM {tbl} WHERE {col} = ANY(%s)").format(
|
|
28
|
+
tbl=sql.Identifier(namespace), col=sql.Identifier(self.id_column)
|
|
29
|
+
)
|
|
30
|
+
with self.conn.cursor() as cur:
|
|
31
|
+
cur.execute(query, (record_ids,))
|
|
32
|
+
(count,) = cur.fetchone()
|
|
33
|
+
return count == 0
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Pinecone connector: delete records by id and verify their absence.
|
|
2
|
+
|
|
3
|
+
Wraps a Pinecone ``Index`` object (duck-typed — Lethe does not depend on the
|
|
4
|
+
pinecone client). Lethe's ``namespace`` maps to a Pinecone namespace.
|
|
5
|
+
|
|
6
|
+
Three Pinecone realities shape this connector:
|
|
7
|
+
* ``index.delete(ids=...)`` returns no count, so to report an accurate
|
|
8
|
+
``deleted_count`` for the certificate, ``delete()`` first ``fetch``es the
|
|
9
|
+
ids to see how many actually existed, then deletes.
|
|
10
|
+
* Pinecone upserts on id collision, so ids must be unique per record — the
|
|
11
|
+
LetheVectorStore wrapper enforces that up front (see its id_key handling).
|
|
12
|
+
* Pinecone deletes are EVENTUALLY CONSISTENT. ``verify()`` fetches
|
|
13
|
+
immediately after ``delete()``, so ``verified_absent`` reflects what the
|
|
14
|
+
queried endpoint returns at issue time — propagation to every replica is
|
|
15
|
+
not instantaneous. The certificate's claim is scoped to say exactly this;
|
|
16
|
+
do not read a Pinecone ``verified_absent: true`` as a cross-replica proof.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Pinecone caps ids per delete/fetch call; chunk to stay under it.
|
|
20
|
+
_BATCH = 1000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _chunks(seq, n):
|
|
24
|
+
for i in range(0, len(seq), n):
|
|
25
|
+
yield seq[i : i + n]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fetched_ids(resp) -> set:
|
|
29
|
+
# pinecone-client returns a FetchResponse with `.vectors`, or a dict with
|
|
30
|
+
# a "vectors" key, mapping id -> vector. Support both shapes.
|
|
31
|
+
vectors = getattr(resp, "vectors", None)
|
|
32
|
+
if vectors is None and isinstance(resp, dict):
|
|
33
|
+
vectors = resp.get("vectors", {})
|
|
34
|
+
return set((vectors or {}).keys())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PineconeConnector:
|
|
38
|
+
name = "pinecone"
|
|
39
|
+
|
|
40
|
+
def __init__(self, index):
|
|
41
|
+
self.index = index
|
|
42
|
+
|
|
43
|
+
def delete(self, namespace: str, record_ids: list[str]) -> int:
|
|
44
|
+
if not record_ids:
|
|
45
|
+
return 0
|
|
46
|
+
existing: set = set()
|
|
47
|
+
for chunk in _chunks(record_ids, _BATCH):
|
|
48
|
+
existing |= _fetched_ids(self.index.fetch(ids=list(chunk), namespace=namespace))
|
|
49
|
+
for chunk in _chunks(record_ids, _BATCH):
|
|
50
|
+
self.index.delete(ids=list(chunk), namespace=namespace)
|
|
51
|
+
return len(existing)
|
|
52
|
+
|
|
53
|
+
def verify(self, namespace: str, record_ids: list[str]) -> bool:
|
|
54
|
+
if not record_ids:
|
|
55
|
+
return True
|
|
56
|
+
for chunk in _chunks(record_ids, _BATCH):
|
|
57
|
+
if _fetched_ids(self.index.fetch(ids=list(chunk), namespace=namespace)):
|
|
58
|
+
return False
|
|
59
|
+
return True
|
lethe/core.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from .audit import AuditLog
|
|
6
|
+
from .certificate import build_certificate
|
|
7
|
+
from .connectors.base import Connector
|
|
8
|
+
from .hashing import hash_subject
|
|
9
|
+
from .ledger import Ledger
|
|
10
|
+
from .models import Certificate, LayerResult, TagRecord
|
|
11
|
+
from .signing import Signer
|
|
12
|
+
from .version import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Lethe:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
ledger: Ledger,
|
|
20
|
+
audit: AuditLog,
|
|
21
|
+
signer: Signer,
|
|
22
|
+
connectors: dict[str, Connector],
|
|
23
|
+
salt: str,
|
|
24
|
+
):
|
|
25
|
+
self.ledger = ledger
|
|
26
|
+
self.audit = audit
|
|
27
|
+
self.signer = signer
|
|
28
|
+
self.connectors = connectors
|
|
29
|
+
self.salt = salt
|
|
30
|
+
|
|
31
|
+
def _subject_hash(self, subject_id: str) -> str:
|
|
32
|
+
return hash_subject(subject_id, self.salt)
|
|
33
|
+
|
|
34
|
+
def tag(self, subject_id: str, store: str, namespace: str, record_id: str) -> None:
|
|
35
|
+
self.ledger.record(
|
|
36
|
+
TagRecord(
|
|
37
|
+
subject_hash=self._subject_hash(subject_id),
|
|
38
|
+
store=store,
|
|
39
|
+
namespace=namespace,
|
|
40
|
+
record_id=record_id,
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def preview(self, subject_id: str) -> dict:
|
|
45
|
+
"""Read-only blast radius: what forget() WOULD touch, per layer.
|
|
46
|
+
Feeds the MCP two-step guard; deletes nothing, purges nothing."""
|
|
47
|
+
subject_hash = self._subject_hash(subject_id)
|
|
48
|
+
counts: dict[tuple[str, str], int] = defaultdict(int)
|
|
49
|
+
for row in self.ledger.lookup(subject_hash):
|
|
50
|
+
counts[(row.store, row.namespace)] += 1
|
|
51
|
+
return {
|
|
52
|
+
"subject_hash": subject_hash,
|
|
53
|
+
"layers": [
|
|
54
|
+
{"store": store, "namespace": namespace, "count": n}
|
|
55
|
+
for (store, namespace), n in sorted(counts.items())
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
def forget(
|
|
60
|
+
self,
|
|
61
|
+
subject_id: str,
|
|
62
|
+
*,
|
|
63
|
+
request_id: str | None = None,
|
|
64
|
+
now: datetime | None = None,
|
|
65
|
+
) -> Certificate:
|
|
66
|
+
subject_hash = self._subject_hash(subject_id)
|
|
67
|
+
request_id = request_id or str(uuid.uuid4())
|
|
68
|
+
issued_at = (now or datetime.now(timezone.utc)).isoformat()
|
|
69
|
+
|
|
70
|
+
groups: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
71
|
+
for row in self.ledger.lookup(subject_hash):
|
|
72
|
+
groups[(row.store, row.namespace)].append(row.record_id)
|
|
73
|
+
|
|
74
|
+
# Pre-flight audit event: a mid-loop connector failure must never
|
|
75
|
+
# leave real deletions with no audit trace; the started/completed
|
|
76
|
+
# pair brackets every destructive attempt.
|
|
77
|
+
self.audit.append(
|
|
78
|
+
{
|
|
79
|
+
"event": "forget_started",
|
|
80
|
+
"request_id": request_id,
|
|
81
|
+
"subject_hash": subject_hash,
|
|
82
|
+
"issued_at": issued_at,
|
|
83
|
+
"requested_layers": len(groups),
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
layers: list[LayerResult] = []
|
|
88
|
+
for (store, namespace), ids in groups.items():
|
|
89
|
+
connector = self.connectors.get(store)
|
|
90
|
+
if connector is None:
|
|
91
|
+
# A tagged store with no configured connector must NOT crash the
|
|
92
|
+
# loop mid-flight (that would leave already-deleted layers with no
|
|
93
|
+
# certificate). Record it as an unhandled, unverified layer so the
|
|
94
|
+
# certificate is honest and the ledger is preserved for retry once
|
|
95
|
+
# the connector is configured.
|
|
96
|
+
layers.append(
|
|
97
|
+
LayerResult(
|
|
98
|
+
store,
|
|
99
|
+
namespace,
|
|
100
|
+
deleted_count=0,
|
|
101
|
+
verified_absent=False,
|
|
102
|
+
requested_count=len(ids),
|
|
103
|
+
handled=False,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
continue
|
|
107
|
+
deleted = connector.delete(namespace, ids)
|
|
108
|
+
verified = connector.verify(namespace, ids)
|
|
109
|
+
layers.append(
|
|
110
|
+
LayerResult(
|
|
111
|
+
store,
|
|
112
|
+
namespace,
|
|
113
|
+
deleted_count=deleted,
|
|
114
|
+
verified_absent=verified,
|
|
115
|
+
requested_count=len(ids),
|
|
116
|
+
handled=True,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
cert = build_certificate(
|
|
121
|
+
request_id=request_id,
|
|
122
|
+
subject_hash=subject_hash,
|
|
123
|
+
layers=layers,
|
|
124
|
+
issued_at=issued_at,
|
|
125
|
+
version=__version__,
|
|
126
|
+
signer=self.signer,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self.audit.append(
|
|
130
|
+
{
|
|
131
|
+
"event": "forget",
|
|
132
|
+
"request_id": request_id,
|
|
133
|
+
"subject_hash": subject_hash,
|
|
134
|
+
"issued_at": issued_at,
|
|
135
|
+
"payload_hash": cert.payload_hash,
|
|
136
|
+
"all_verified": cert.payload["all_verified"],
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Only purge the provenance map once deletion is fully verified — otherwise
|
|
141
|
+
# we keep the map so the operation can be retried.
|
|
142
|
+
if cert.payload["all_verified"]:
|
|
143
|
+
self.ledger.purge(subject_hash)
|
|
144
|
+
|
|
145
|
+
return cert
|