superlocalmemory 4.0.4 → 4.0.6
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.
- package/CHANGELOG.md +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +3 -2
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +2 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +230 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""``slm gdpr`` — GDPR subject-rights CLI for enterprise DPOs.
|
|
6
|
+
|
|
7
|
+
Subcommands
|
|
8
|
+
-----------
|
|
9
|
+
status Compliance posture: receipts, audit counts, known gaps.
|
|
10
|
+
Safe read-only. Never prints raw memory content.
|
|
11
|
+
export Art.15/20 subject access / data portability.
|
|
12
|
+
Requires --profile. Writes JSON to --output FILE or stdout.
|
|
13
|
+
erase Art.17 right to erasure — IRREVERSIBLE.
|
|
14
|
+
Requires --profile PROFILE AND --yes (both mandatory).
|
|
15
|
+
Default output with neither flag is a dry-run preview.
|
|
16
|
+
verify Check HMAC integrity of an erasure receipt.
|
|
17
|
+
Exit 0 = VERIFIED, 1 = TAMPERED, 2 = NOT_FOUND.
|
|
18
|
+
|
|
19
|
+
Every subcommand accepts --json for evidence-pipeline integration.
|
|
20
|
+
Exit codes: 0 success, 1 erasure/verify failure, 2 refusal/not-found.
|
|
21
|
+
|
|
22
|
+
Safety design (DPO-grade):
|
|
23
|
+
- erase is the only irreversible operation. It refuses to run unless BOTH
|
|
24
|
+
--profile PROFILE AND --yes are supplied. A dry-run is always printed first
|
|
25
|
+
when --dry-run is given (or when --yes is absent).
|
|
26
|
+
- --json never emits raw memory content in status or verify output.
|
|
27
|
+
- verify exits non-zero on tampered receipts so a pipeline can branch on it.
|
|
28
|
+
- status honestly reports known compliance gaps (backups, code_graph) rather
|
|
29
|
+
than claiming completeness it cannot support.
|
|
30
|
+
|
|
31
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json as _json
|
|
37
|
+
import sys
|
|
38
|
+
from argparse import Namespace
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
# I/O helpers and GDPR compliance constants extracted to gdpr_io.py to keep
|
|
42
|
+
# this file under the 800-line hard cap (coding-style.md).
|
|
43
|
+
from superlocalmemory.cli.gdpr_io import (
|
|
44
|
+
ART_COVERAGE as _ART_COVERAGE,
|
|
45
|
+
KNOWN_GAPS as _KNOWN_GAPS,
|
|
46
|
+
_audit_chain_path,
|
|
47
|
+
_data_root,
|
|
48
|
+
_db_path,
|
|
49
|
+
_die,
|
|
50
|
+
_json_envelope,
|
|
51
|
+
_print_json,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
# status
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _cmd_gdpr_status(args: Namespace) -> None:
|
|
60
|
+
"""``slm gdpr status [--profile P] [--json]``."""
|
|
61
|
+
use_json = getattr(args, "json", False)
|
|
62
|
+
profile = getattr(args, "profile", None)
|
|
63
|
+
|
|
64
|
+
root = _data_root()
|
|
65
|
+
db_path = root / "memory.db"
|
|
66
|
+
db_exists = db_path.exists()
|
|
67
|
+
|
|
68
|
+
receipts: list[dict] = []
|
|
69
|
+
audit_count = 0
|
|
70
|
+
audit_recent: list[dict] = []
|
|
71
|
+
profiles_found: list[str] = []
|
|
72
|
+
|
|
73
|
+
if db_exists:
|
|
74
|
+
try:
|
|
75
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
76
|
+
with memory_read(db_path) as conn:
|
|
77
|
+
conn.row_factory = _json_row_factory(conn)
|
|
78
|
+
# Receipts — never include audit_hash (HMAC key material)
|
|
79
|
+
scope_clause = "WHERE profile_id = ?" if profile else ""
|
|
80
|
+
scope_params = (profile,) if profile else ()
|
|
81
|
+
rows = conn.execute(
|
|
82
|
+
f"SELECT erasure_id, profile_id, subject_type, subject_id, "
|
|
83
|
+
f"requested_by, fact_count, state, all_erased, requested_at, "
|
|
84
|
+
f"completed_at FROM erasure_receipts "
|
|
85
|
+
f"{scope_clause} ORDER BY completed_at DESC LIMIT 50",
|
|
86
|
+
scope_params,
|
|
87
|
+
).fetchall()
|
|
88
|
+
for r in rows:
|
|
89
|
+
receipts.append(_safe_receipt_row(r))
|
|
90
|
+
except Exception:
|
|
91
|
+
pass # Table absent on a fresh install — not an error
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
95
|
+
with memory_read(db_path) as conn:
|
|
96
|
+
# Profile list
|
|
97
|
+
profile_rows = conn.execute(
|
|
98
|
+
"SELECT profile_id FROM profiles"
|
|
99
|
+
).fetchall()
|
|
100
|
+
profiles_found = [r[0] for r in profile_rows if r]
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
if db_exists:
|
|
105
|
+
try:
|
|
106
|
+
from superlocalmemory.compliance.audit import AuditChain
|
|
107
|
+
chain = AuditChain(str(_audit_chain_path()))
|
|
108
|
+
scope_filter = {"profile_id": profile} if profile else {}
|
|
109
|
+
audit_recent = chain.query(limit=10, **scope_filter)
|
|
110
|
+
audit_count = len(chain.query(limit=100_000, **scope_filter))
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
data = {
|
|
115
|
+
"data_root": str(root),
|
|
116
|
+
"db_exists": db_exists,
|
|
117
|
+
"active_profile": profile or "default",
|
|
118
|
+
"profiles": profiles_found,
|
|
119
|
+
"receipts": {
|
|
120
|
+
"count": len(receipts),
|
|
121
|
+
"entries": receipts,
|
|
122
|
+
},
|
|
123
|
+
"audit": {
|
|
124
|
+
"count": audit_count,
|
|
125
|
+
"recent_10": _sanitise_audit(audit_recent),
|
|
126
|
+
},
|
|
127
|
+
"coverage": _ART_COVERAGE,
|
|
128
|
+
"known_gaps": _KNOWN_GAPS,
|
|
129
|
+
"next_actions": [
|
|
130
|
+
"slm gdpr export --profile PROFILE --output export.json",
|
|
131
|
+
"slm gdpr erase --profile PROFILE --dry-run",
|
|
132
|
+
"slm gdpr verify --receipt-id RECEIPT_ID",
|
|
133
|
+
],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if use_json:
|
|
137
|
+
_print_json(_json_envelope("gdpr-status", data=data))
|
|
138
|
+
else:
|
|
139
|
+
_print_human_status(data)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _json_row_factory(conn):
|
|
143
|
+
"""Return a row_factory that makes rows indexable by position."""
|
|
144
|
+
import sqlite3
|
|
145
|
+
return sqlite3.Row
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _safe_receipt_row(row) -> dict:
|
|
149
|
+
"""Convert an erasure_receipts row to a safe-to-emit dict (no HMAC)."""
|
|
150
|
+
try:
|
|
151
|
+
keys = (
|
|
152
|
+
"erasure_id", "profile_id", "subject_type", "subject_id",
|
|
153
|
+
"requested_by", "fact_count", "state", "all_erased",
|
|
154
|
+
"requested_at", "completed_at",
|
|
155
|
+
)
|
|
156
|
+
if hasattr(row, "keys"):
|
|
157
|
+
return {k: row[k] for k in keys if k in row.keys()}
|
|
158
|
+
return dict(zip(keys, row))
|
|
159
|
+
except Exception:
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _sanitise_audit(events: list) -> list[dict]:
|
|
164
|
+
"""Strip any raw content from audit events; keep only metadata fields."""
|
|
165
|
+
safe: list[dict] = []
|
|
166
|
+
_ALLOWED = {
|
|
167
|
+
"event_id", "operation", "agent_id", "profile_id",
|
|
168
|
+
"timestamp", "created_at",
|
|
169
|
+
}
|
|
170
|
+
for ev in events:
|
|
171
|
+
if isinstance(ev, dict):
|
|
172
|
+
safe.append({k: v for k, v in ev.items() if k in _ALLOWED})
|
|
173
|
+
else:
|
|
174
|
+
safe.append({})
|
|
175
|
+
return safe
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _print_human_status(data: dict) -> None:
|
|
179
|
+
root = data["data_root"]
|
|
180
|
+
db = "EXISTS" if data["db_exists"] else "NOT FOUND"
|
|
181
|
+
print(f"\nGDPR Compliance Status")
|
|
182
|
+
print(f" Data root : {root}")
|
|
183
|
+
print(f" Database : {db}")
|
|
184
|
+
print(f" Profiles : {', '.join(data['profiles']) or '(none)'}")
|
|
185
|
+
print(f" Receipts : {data['receipts']['count']}")
|
|
186
|
+
print(f" Audit events: {data['audit']['count']}")
|
|
187
|
+
print()
|
|
188
|
+
print("Coverage:")
|
|
189
|
+
for k, v in data["coverage"].items():
|
|
190
|
+
print(f" {k}: {v}")
|
|
191
|
+
print()
|
|
192
|
+
print("Known gaps (not yet covered):")
|
|
193
|
+
for gap in data["known_gaps"]:
|
|
194
|
+
print(f" [{gap['ref']}] {gap['summary']}")
|
|
195
|
+
print()
|
|
196
|
+
print("Next steps:")
|
|
197
|
+
for action in data["next_actions"]:
|
|
198
|
+
print(f" slm {action}" if not action.startswith("slm") else f" {action}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
# export
|
|
203
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def _cmd_gdpr_export(args: Namespace) -> None:
|
|
206
|
+
"""``slm gdpr export --profile P [--output FILE] [--json]``."""
|
|
207
|
+
use_json = getattr(args, "json", False)
|
|
208
|
+
profile = getattr(args, "profile", None)
|
|
209
|
+
output_file = getattr(args, "output", None)
|
|
210
|
+
|
|
211
|
+
if not profile:
|
|
212
|
+
if use_json:
|
|
213
|
+
_print_json(_json_envelope(
|
|
214
|
+
"gdpr-export",
|
|
215
|
+
error={
|
|
216
|
+
"code": "MISSING_PROFILE",
|
|
217
|
+
"message": "Art.15 export requires --profile PROFILE_ID.",
|
|
218
|
+
"hint": "Run `slm gdpr status` to list known profiles.",
|
|
219
|
+
},
|
|
220
|
+
))
|
|
221
|
+
else:
|
|
222
|
+
_die("Art.15 export requires --profile PROFILE_ID.\n"
|
|
223
|
+
" Run `slm gdpr status` to list known profiles.")
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
|
|
226
|
+
root = _data_root()
|
|
227
|
+
db_path = root / "memory.db"
|
|
228
|
+
|
|
229
|
+
if not db_path.exists():
|
|
230
|
+
err = {
|
|
231
|
+
"code": "NO_DATABASE",
|
|
232
|
+
"message": "No SuperLocalMemory database found at the data root.",
|
|
233
|
+
"hint": (
|
|
234
|
+
f"Data root: {root}. "
|
|
235
|
+
"Start the daemon (`slm serve`) to create a database, "
|
|
236
|
+
"or set SLM_DATA_DIR to the correct path."
|
|
237
|
+
),
|
|
238
|
+
}
|
|
239
|
+
if use_json:
|
|
240
|
+
_print_json(_json_envelope("gdpr-export", error=err))
|
|
241
|
+
else:
|
|
242
|
+
_die(f"{err['message']}\n {err['hint']}")
|
|
243
|
+
sys.exit(2)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
247
|
+
from superlocalmemory.compliance.gdpr import GDPRCompliance
|
|
248
|
+
|
|
249
|
+
# Do NOT call db.initialize() — GDPRCompliance drives all DDL via
|
|
250
|
+
# _profile_scoped_tables() / sqlite_master and works with the existing
|
|
251
|
+
# schema. Pattern mirrors commands.py:3233.
|
|
252
|
+
db = DatabaseManager(db_path)
|
|
253
|
+
gdpr = GDPRCompliance(db, data_root=root)
|
|
254
|
+
export_data = gdpr.export_profile_data(profile)
|
|
255
|
+
except Exception as exc:
|
|
256
|
+
if use_json:
|
|
257
|
+
_print_json(_json_envelope(
|
|
258
|
+
"gdpr-export",
|
|
259
|
+
error={"code": "EXPORT_ERROR", "message": str(exc)},
|
|
260
|
+
))
|
|
261
|
+
else:
|
|
262
|
+
_die(f"Export failed: {exc}\n Run `slm gdpr status` to check DB state.")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
|
|
265
|
+
total = export_data.get("total_items", 0)
|
|
266
|
+
|
|
267
|
+
if output_file:
|
|
268
|
+
try:
|
|
269
|
+
out_path = Path(output_file)
|
|
270
|
+
out_path.write_text(
|
|
271
|
+
_json.dumps(export_data, indent=2, default=str), encoding="utf-8"
|
|
272
|
+
)
|
|
273
|
+
except OSError as exc:
|
|
274
|
+
if use_json:
|
|
275
|
+
_print_json(_json_envelope(
|
|
276
|
+
"gdpr-export",
|
|
277
|
+
error={"code": "WRITE_ERROR", "message": str(exc)},
|
|
278
|
+
))
|
|
279
|
+
else:
|
|
280
|
+
_die(f"Failed to write export to {output_file}: {exc}")
|
|
281
|
+
sys.exit(1)
|
|
282
|
+
|
|
283
|
+
confirmation = {
|
|
284
|
+
"profile": profile,
|
|
285
|
+
"total_items": total,
|
|
286
|
+
"output_file": str(out_path.resolve()),
|
|
287
|
+
"exported_at": export_data.get("exported_at"),
|
|
288
|
+
"note": "Output file contains personal data. Handle as confidential.",
|
|
289
|
+
}
|
|
290
|
+
if use_json:
|
|
291
|
+
_print_json(_json_envelope("gdpr-export", data=confirmation))
|
|
292
|
+
else:
|
|
293
|
+
print(f"Art.15 export complete — {total} items written to {out_path.resolve()}")
|
|
294
|
+
print(" Handle as confidential personal data.")
|
|
295
|
+
else:
|
|
296
|
+
# No output file: emit the full export to stdout
|
|
297
|
+
if use_json:
|
|
298
|
+
# Wrap in envelope but the data payload IS the export
|
|
299
|
+
_print_json(_json_envelope("gdpr-export", data=export_data))
|
|
300
|
+
else:
|
|
301
|
+
print(_json.dumps(export_data, indent=2, default=str))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
# erase
|
|
306
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
def _cmd_gdpr_erase(args: Namespace) -> None:
|
|
309
|
+
"""``slm gdpr erase --profile P [--yes] [--dry-run] [--json]``."""
|
|
310
|
+
use_json = getattr(args, "json", False)
|
|
311
|
+
profile = getattr(args, "profile", None)
|
|
312
|
+
confirmed = getattr(args, "yes", False)
|
|
313
|
+
dry_run = getattr(args, "dry_run", False)
|
|
314
|
+
|
|
315
|
+
# ── SAFETY GATE 1: profile must be named explicitly ──────────────────────
|
|
316
|
+
if not profile:
|
|
317
|
+
msg = (
|
|
318
|
+
"Art.17 erasure requires --profile PROFILE_ID.\n"
|
|
319
|
+
" This operation is IRREVERSIBLE. Name the profile explicitly.\n"
|
|
320
|
+
" Run `slm gdpr status` to list known profiles.\n"
|
|
321
|
+
" Run `slm gdpr erase --profile PROFILE --dry-run` to preview."
|
|
322
|
+
)
|
|
323
|
+
if use_json:
|
|
324
|
+
_print_json(_json_envelope(
|
|
325
|
+
"gdpr-erase",
|
|
326
|
+
error={
|
|
327
|
+
"code": "MISSING_PROFILE",
|
|
328
|
+
"message": (
|
|
329
|
+
"Art.17 erasure requires --profile PROFILE_ID. "
|
|
330
|
+
"This operation is IRREVERSIBLE. "
|
|
331
|
+
"Run with --dry-run first."
|
|
332
|
+
),
|
|
333
|
+
"hint": "slm gdpr status → list known profiles",
|
|
334
|
+
},
|
|
335
|
+
))
|
|
336
|
+
else:
|
|
337
|
+
print(msg, file=sys.stderr)
|
|
338
|
+
sys.exit(2)
|
|
339
|
+
|
|
340
|
+
# ── SAFETY GATE 2: "default" profile cannot be erased ───────────────────
|
|
341
|
+
if profile == "default":
|
|
342
|
+
msg = (
|
|
343
|
+
"Cannot erase the 'default' profile via GDPR Art.17. "
|
|
344
|
+
"The default profile is a system profile. "
|
|
345
|
+
"Use a named profile: slm profile list"
|
|
346
|
+
)
|
|
347
|
+
if use_json:
|
|
348
|
+
_print_json(_json_envelope(
|
|
349
|
+
"gdpr-erase",
|
|
350
|
+
error={"code": "DEFAULT_PROFILE", "message": msg},
|
|
351
|
+
))
|
|
352
|
+
else:
|
|
353
|
+
_die(msg, code=2)
|
|
354
|
+
sys.exit(2)
|
|
355
|
+
|
|
356
|
+
root = _data_root()
|
|
357
|
+
db_path = root / "memory.db"
|
|
358
|
+
|
|
359
|
+
if not db_path.exists():
|
|
360
|
+
err = {
|
|
361
|
+
"code": "NO_DATABASE",
|
|
362
|
+
"message": "No SuperLocalMemory database found at the data root.",
|
|
363
|
+
"hint": (
|
|
364
|
+
f"Data root: {root}. "
|
|
365
|
+
"Start the daemon (`slm serve`) to create a database."
|
|
366
|
+
),
|
|
367
|
+
}
|
|
368
|
+
if use_json:
|
|
369
|
+
_print_json(_json_envelope("gdpr-erase", error=err))
|
|
370
|
+
else:
|
|
371
|
+
_die(f"{err['message']}\n {err['hint']}")
|
|
372
|
+
sys.exit(2)
|
|
373
|
+
|
|
374
|
+
# ── DRY-RUN: show exactly what would be erased ───────────────────────────
|
|
375
|
+
if dry_run or not confirmed:
|
|
376
|
+
_show_erase_preview(profile, root, db_path, use_json)
|
|
377
|
+
if not confirmed:
|
|
378
|
+
# Explicit --dry-run with no --yes → preview succeeded (exit 0).
|
|
379
|
+
# Implicit dry-run (neither --dry-run nor --yes) → refused (exit 2).
|
|
380
|
+
# A pipeline that doesn't supply --yes must see a non-zero exit.
|
|
381
|
+
if dry_run:
|
|
382
|
+
sys.exit(0)
|
|
383
|
+
if use_json:
|
|
384
|
+
_print_json(_json_envelope(
|
|
385
|
+
"gdpr-erase",
|
|
386
|
+
error={
|
|
387
|
+
"code": "CONFIRMATION_REQUIRED",
|
|
388
|
+
"message": (
|
|
389
|
+
"Erasure aborted — --yes not supplied. "
|
|
390
|
+
"This is a dry-run preview. "
|
|
391
|
+
"Re-run with --yes to confirm (IRREVERSIBLE)."
|
|
392
|
+
),
|
|
393
|
+
"hint": f"slm gdpr erase --profile {profile} --yes",
|
|
394
|
+
},
|
|
395
|
+
))
|
|
396
|
+
else:
|
|
397
|
+
print(
|
|
398
|
+
"\nErasure aborted — no --yes flag supplied.\n"
|
|
399
|
+
" Re-run with --yes to confirm (IRREVERSIBLE):\n"
|
|
400
|
+
f" slm gdpr erase --profile {profile} --yes",
|
|
401
|
+
file=sys.stderr,
|
|
402
|
+
)
|
|
403
|
+
sys.exit(2)
|
|
404
|
+
return # dry-run with --dry-run flag: preview only, exit 0
|
|
405
|
+
|
|
406
|
+
# ── LIVE ERASURE ─────────────────────────────────────────────────────────
|
|
407
|
+
try:
|
|
408
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
409
|
+
from superlocalmemory.compliance.gdpr import GDPRCompliance
|
|
410
|
+
|
|
411
|
+
db = DatabaseManager(db_path)
|
|
412
|
+
gdpr = GDPRCompliance(db, data_root=root)
|
|
413
|
+
counts = gdpr.forget_profile(profile)
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
if use_json:
|
|
416
|
+
_print_json(_json_envelope(
|
|
417
|
+
"gdpr-erase",
|
|
418
|
+
error={
|
|
419
|
+
"code": "ERASE_ERROR",
|
|
420
|
+
"message": str(exc),
|
|
421
|
+
"hint": "The erasure may be incomplete. Check the audit_chain.db receipt.",
|
|
422
|
+
},
|
|
423
|
+
))
|
|
424
|
+
else:
|
|
425
|
+
print(f"error: Erasure failed: {exc}", file=sys.stderr)
|
|
426
|
+
print(" The erasure may be incomplete. Check the audit_chain.db receipt.", file=sys.stderr)
|
|
427
|
+
sys.exit(1)
|
|
428
|
+
|
|
429
|
+
complete = counts.get("erasure_complete", 0)
|
|
430
|
+
result_data = {
|
|
431
|
+
"profile": profile,
|
|
432
|
+
"erasure_complete": bool(complete),
|
|
433
|
+
"counts": counts,
|
|
434
|
+
"note": (
|
|
435
|
+
"Erasure recorded in audit_chain.db. "
|
|
436
|
+
"Backups/ remain as an outstanding obligation (C1). "
|
|
437
|
+
"Run `slm gdpr verify --profile PROFILE` to confirm receipts."
|
|
438
|
+
),
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if use_json:
|
|
442
|
+
_print_json(_json_envelope("gdpr-erase", data=result_data))
|
|
443
|
+
else:
|
|
444
|
+
status_str = "COMPLETE" if complete else "INCOMPLETE (check counts)"
|
|
445
|
+
print(f"Art.17 erasure for profile '{profile}': {status_str}")
|
|
446
|
+
for k, v in counts.items():
|
|
447
|
+
print(f" {k}: {v}")
|
|
448
|
+
print()
|
|
449
|
+
print(" Backups/ contain outstanding obligation — see C1 gap.")
|
|
450
|
+
print(" Verify receipts: slm gdpr verify --profile", profile)
|
|
451
|
+
|
|
452
|
+
if not complete:
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _show_erase_preview(
|
|
457
|
+
profile: str, root: Path, db_path: Path, use_json: bool
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Show what would be erased without making any changes."""
|
|
460
|
+
tables: list[str] = []
|
|
461
|
+
row_counts: dict[str, int] = {}
|
|
462
|
+
|
|
463
|
+
if db_path.exists():
|
|
464
|
+
try:
|
|
465
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
466
|
+
with memory_read(db_path) as conn:
|
|
467
|
+
# Discover profile-scoped tables (mirroring GDPRCompliance logic)
|
|
468
|
+
_NON_MEMORY_SCOPED = {"profiles", "erasure_receipts"}
|
|
469
|
+
tbl_rows = conn.execute(
|
|
470
|
+
"SELECT name FROM sqlite_master WHERE type='table'"
|
|
471
|
+
).fetchall()
|
|
472
|
+
for (tname,) in tbl_rows:
|
|
473
|
+
if tname.startswith("sqlite_") or tname in _NON_MEMORY_SCOPED:
|
|
474
|
+
continue
|
|
475
|
+
try:
|
|
476
|
+
cols = {
|
|
477
|
+
r[1] for r in conn.execute(
|
|
478
|
+
f"PRAGMA table_info({tname})"
|
|
479
|
+
).fetchall()
|
|
480
|
+
}
|
|
481
|
+
if "profile_id" not in cols:
|
|
482
|
+
continue
|
|
483
|
+
cnt = conn.execute(
|
|
484
|
+
f"SELECT COUNT(*) FROM {tname} WHERE profile_id = ?",
|
|
485
|
+
(profile,),
|
|
486
|
+
).fetchone()[0]
|
|
487
|
+
tables.append(tname)
|
|
488
|
+
row_counts[tname] = cnt
|
|
489
|
+
except Exception:
|
|
490
|
+
continue
|
|
491
|
+
except Exception:
|
|
492
|
+
pass
|
|
493
|
+
|
|
494
|
+
preview = {
|
|
495
|
+
"dry_run": True,
|
|
496
|
+
"profile": profile,
|
|
497
|
+
"would_erase": row_counts,
|
|
498
|
+
"tables_affected": len(tables),
|
|
499
|
+
"total_rows": sum(row_counts.values()),
|
|
500
|
+
"also_purged": [
|
|
501
|
+
"learning.db (sidecar)",
|
|
502
|
+
"active_brain_cache.db",
|
|
503
|
+
"vector store entries",
|
|
504
|
+
],
|
|
505
|
+
"survives": [
|
|
506
|
+
"erasure_receipts (tamper-evident audit chain)",
|
|
507
|
+
"profiles record (for GDPR accountability)",
|
|
508
|
+
"backups/ (outstanding obligation — C1)",
|
|
509
|
+
],
|
|
510
|
+
"warning": (
|
|
511
|
+
"This operation is IRREVERSIBLE. "
|
|
512
|
+
"Re-run with --yes to confirm."
|
|
513
|
+
),
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if use_json:
|
|
517
|
+
_print_json(_json_envelope("gdpr-erase-dryrun", data=preview))
|
|
518
|
+
else:
|
|
519
|
+
print(f"\nGDPR Art.17 dry-run for profile '{profile}':")
|
|
520
|
+
print(f" Tables to erase : {preview['tables_affected']}")
|
|
521
|
+
print(f" Total rows : {preview['total_rows']}")
|
|
522
|
+
for t, c in row_counts.items():
|
|
523
|
+
print(f" {t}: {c} rows")
|
|
524
|
+
print(" Also purged: " + ", ".join(preview["also_purged"]))
|
|
525
|
+
print(" Survives : " + ", ".join(preview["survives"]))
|
|
526
|
+
print()
|
|
527
|
+
print(f" WARNING: {preview['warning']}")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
531
|
+
# verify
|
|
532
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
def _cmd_gdpr_verify(args: Namespace) -> None:
|
|
535
|
+
"""``slm gdpr verify --receipt-id ID [--profile P] [--json]``.
|
|
536
|
+
|
|
537
|
+
Exit codes:
|
|
538
|
+
0 — receipt found and HMAC is intact (VERIFIED)
|
|
539
|
+
1 — receipt found but HMAC fails (TAMPERED — integrity violation)
|
|
540
|
+
2 — receipt not found (NOT_FOUND)
|
|
541
|
+
"""
|
|
542
|
+
use_json = getattr(args, "json", False)
|
|
543
|
+
receipt_id = getattr(args, "receipt_id", None)
|
|
544
|
+
profile = getattr(args, "profile", None)
|
|
545
|
+
|
|
546
|
+
# Allow listing all receipts for a profile
|
|
547
|
+
list_mode = not receipt_id and profile
|
|
548
|
+
|
|
549
|
+
root = _data_root()
|
|
550
|
+
db_path = root / "memory.db"
|
|
551
|
+
|
|
552
|
+
if not db_path.exists():
|
|
553
|
+
if use_json:
|
|
554
|
+
_print_json(_json_envelope(
|
|
555
|
+
"gdpr-verify",
|
|
556
|
+
error={
|
|
557
|
+
"code": "NOT_FOUND",
|
|
558
|
+
"message": "No database found at the data root.",
|
|
559
|
+
"hint": f"Data root: {root}",
|
|
560
|
+
},
|
|
561
|
+
))
|
|
562
|
+
else:
|
|
563
|
+
_die(f"No database at {db_path}. Run `slm gdpr status` first.", code=2)
|
|
564
|
+
sys.exit(2)
|
|
565
|
+
|
|
566
|
+
if list_mode:
|
|
567
|
+
_list_receipts(profile, db_path, use_json)
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
if not receipt_id:
|
|
571
|
+
if use_json:
|
|
572
|
+
_print_json(_json_envelope(
|
|
573
|
+
"gdpr-verify",
|
|
574
|
+
error={
|
|
575
|
+
"code": "MISSING_RECEIPT_ID",
|
|
576
|
+
"message": "Provide --receipt-id ID to verify a specific receipt.",
|
|
577
|
+
"hint": "slm gdpr verify --profile PROFILE → list receipts",
|
|
578
|
+
},
|
|
579
|
+
))
|
|
580
|
+
else:
|
|
581
|
+
_die(
|
|
582
|
+
"Provide --receipt-id ID to verify, or --profile PROFILE to list.\n"
|
|
583
|
+
" Example: slm gdpr verify --receipt-id <id>",
|
|
584
|
+
code=2,
|
|
585
|
+
)
|
|
586
|
+
sys.exit(2)
|
|
587
|
+
|
|
588
|
+
# Check if receipt exists first (to distinguish NOT_FOUND from TAMPERED)
|
|
589
|
+
receipt_meta: dict = {}
|
|
590
|
+
exists = False
|
|
591
|
+
try:
|
|
592
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
593
|
+
with memory_read(db_path) as conn:
|
|
594
|
+
params: tuple = (receipt_id,)
|
|
595
|
+
extra = ""
|
|
596
|
+
if profile:
|
|
597
|
+
extra = " AND profile_id = ?"
|
|
598
|
+
params = (receipt_id, profile)
|
|
599
|
+
row = conn.execute(
|
|
600
|
+
"SELECT erasure_id, profile_id, subject_type, subject_id, "
|
|
601
|
+
"fact_count, state, all_erased, requested_at, completed_at "
|
|
602
|
+
f"FROM erasure_receipts WHERE erasure_id = ?{extra}",
|
|
603
|
+
params,
|
|
604
|
+
).fetchone()
|
|
605
|
+
if row:
|
|
606
|
+
exists = True
|
|
607
|
+
receipt_meta = {
|
|
608
|
+
"erasure_id": row[0],
|
|
609
|
+
"profile_id": row[1],
|
|
610
|
+
"subject_type": row[2],
|
|
611
|
+
"subject_id": row[3],
|
|
612
|
+
"fact_count": row[4],
|
|
613
|
+
"state": row[5],
|
|
614
|
+
"all_erased": bool(row[6]),
|
|
615
|
+
"requested_at": row[7],
|
|
616
|
+
"completed_at": row[8],
|
|
617
|
+
}
|
|
618
|
+
except Exception as exc:
|
|
619
|
+
if use_json:
|
|
620
|
+
_print_json(_json_envelope(
|
|
621
|
+
"gdpr-verify",
|
|
622
|
+
error={"code": "DB_ERROR", "message": str(exc)},
|
|
623
|
+
))
|
|
624
|
+
else:
|
|
625
|
+
_die(f"Could not read database: {exc}", code=1)
|
|
626
|
+
sys.exit(1)
|
|
627
|
+
|
|
628
|
+
if not exists:
|
|
629
|
+
result = {
|
|
630
|
+
"erasure_id": receipt_id,
|
|
631
|
+
"status": "NOT_FOUND",
|
|
632
|
+
"verified": False,
|
|
633
|
+
"message": "Receipt ID not found in erasure_receipts.",
|
|
634
|
+
}
|
|
635
|
+
if use_json:
|
|
636
|
+
_print_json(_json_envelope("gdpr-verify", data=result))
|
|
637
|
+
else:
|
|
638
|
+
print(f"NOT_FOUND: Receipt '{receipt_id}' does not exist.")
|
|
639
|
+
sys.exit(2)
|
|
640
|
+
|
|
641
|
+
# Run HMAC verification
|
|
642
|
+
hmac_ok = False
|
|
643
|
+
verify_error: str | None = None
|
|
644
|
+
try:
|
|
645
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
646
|
+
from superlocalmemory.core.transactions.erasure import verify_receipt
|
|
647
|
+
|
|
648
|
+
with memory_read(db_path) as conn:
|
|
649
|
+
hmac_ok = verify_receipt(
|
|
650
|
+
conn, receipt_id, profile_id=profile
|
|
651
|
+
)
|
|
652
|
+
except Exception as exc:
|
|
653
|
+
verify_error = str(exc)
|
|
654
|
+
|
|
655
|
+
if verify_error:
|
|
656
|
+
result = {
|
|
657
|
+
"erasure_id": receipt_id,
|
|
658
|
+
"status": "VERIFY_ERROR",
|
|
659
|
+
"verified": False,
|
|
660
|
+
"error": verify_error,
|
|
661
|
+
"receipt": receipt_meta,
|
|
662
|
+
}
|
|
663
|
+
if use_json:
|
|
664
|
+
_print_json(_json_envelope("gdpr-verify", data=result))
|
|
665
|
+
else:
|
|
666
|
+
print(f"VERIFY_ERROR: {verify_error}", file=sys.stderr)
|
|
667
|
+
sys.exit(1)
|
|
668
|
+
|
|
669
|
+
status_str = "VERIFIED" if hmac_ok else "TAMPERED"
|
|
670
|
+
result = {
|
|
671
|
+
"erasure_id": receipt_id,
|
|
672
|
+
"status": status_str,
|
|
673
|
+
"verified": hmac_ok,
|
|
674
|
+
"receipt": receipt_meta,
|
|
675
|
+
"message": (
|
|
676
|
+
"HMAC chain is intact — deletion can be proven."
|
|
677
|
+
if hmac_ok else
|
|
678
|
+
"HMAC mismatch — receipt may have been tampered with. "
|
|
679
|
+
"Do NOT rely on this receipt as Art.17 evidence."
|
|
680
|
+
),
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if use_json:
|
|
684
|
+
_print_json(_json_envelope("gdpr-verify", data=result))
|
|
685
|
+
else:
|
|
686
|
+
print(f"{status_str}: receipt '{receipt_id}'")
|
|
687
|
+
for k, v in receipt_meta.items():
|
|
688
|
+
print(f" {k}: {v}")
|
|
689
|
+
print(f" HMAC: {'OK' if hmac_ok else 'MISMATCH'}")
|
|
690
|
+
|
|
691
|
+
sys.exit(0 if hmac_ok else 1)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _list_receipts(profile: str, db_path: Path, use_json: bool) -> None:
|
|
695
|
+
"""List all erasure receipts for a profile."""
|
|
696
|
+
receipts: list[dict] = []
|
|
697
|
+
try:
|
|
698
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
699
|
+
with memory_read(db_path) as conn:
|
|
700
|
+
rows = conn.execute(
|
|
701
|
+
"SELECT erasure_id, profile_id, subject_type, subject_id, "
|
|
702
|
+
"fact_count, state, all_erased, requested_at, completed_at "
|
|
703
|
+
"FROM erasure_receipts WHERE profile_id = ? "
|
|
704
|
+
"ORDER BY completed_at DESC",
|
|
705
|
+
(profile,),
|
|
706
|
+
).fetchall()
|
|
707
|
+
for r in rows:
|
|
708
|
+
receipts.append({
|
|
709
|
+
"erasure_id": r[0],
|
|
710
|
+
"profile_id": r[1],
|
|
711
|
+
"subject_type": r[2],
|
|
712
|
+
"subject_id": r[3],
|
|
713
|
+
"fact_count": r[4],
|
|
714
|
+
"state": r[5],
|
|
715
|
+
"all_erased": bool(r[6]),
|
|
716
|
+
"requested_at": r[7],
|
|
717
|
+
"completed_at": r[8],
|
|
718
|
+
})
|
|
719
|
+
except Exception as exc:
|
|
720
|
+
if use_json:
|
|
721
|
+
_print_json(_json_envelope(
|
|
722
|
+
"gdpr-verify",
|
|
723
|
+
error={"code": "DB_ERROR", "message": str(exc)},
|
|
724
|
+
))
|
|
725
|
+
else:
|
|
726
|
+
_die(f"Could not list receipts: {exc}")
|
|
727
|
+
sys.exit(1)
|
|
728
|
+
|
|
729
|
+
result = {
|
|
730
|
+
"profile": profile,
|
|
731
|
+
"count": len(receipts),
|
|
732
|
+
"receipts": receipts,
|
|
733
|
+
"hint": "Use --receipt-id ID to verify a specific receipt's HMAC.",
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if use_json:
|
|
737
|
+
_print_json(_json_envelope("gdpr-verify", data=result))
|
|
738
|
+
else:
|
|
739
|
+
print(f"Erasure receipts for profile '{profile}': {len(receipts)}")
|
|
740
|
+
for r in receipts:
|
|
741
|
+
print(
|
|
742
|
+
f" [{r['erasure_id'][:16]}...] "
|
|
743
|
+
f"state={r['state']} "
|
|
744
|
+
f"facts={r['fact_count']} "
|
|
745
|
+
f"complete={r['all_erased']}"
|
|
746
|
+
)
|
|
747
|
+
if receipts:
|
|
748
|
+
print("\nVerify a receipt: slm gdpr verify --receipt-id <id>")
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
752
|
+
# Public entry point
|
|
753
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
754
|
+
|
|
755
|
+
def cmd_gdpr(args: Namespace) -> None:
|
|
756
|
+
"""Dispatch ``slm gdpr`` subcommands."""
|
|
757
|
+
sub = getattr(args, "gdpr_command", None)
|
|
758
|
+
handlers = {
|
|
759
|
+
"status": _cmd_gdpr_status,
|
|
760
|
+
"export": _cmd_gdpr_export,
|
|
761
|
+
"erase": _cmd_gdpr_erase,
|
|
762
|
+
"verify": _cmd_gdpr_verify,
|
|
763
|
+
}
|
|
764
|
+
handler = handlers.get(sub)
|
|
765
|
+
if handler:
|
|
766
|
+
handler(args)
|
|
767
|
+
else:
|
|
768
|
+
print(
|
|
769
|
+
"Usage: slm gdpr <status|export|erase|verify> [options]\n"
|
|
770
|
+
"\n"
|
|
771
|
+
" slm gdpr status [--profile P] [--json]\n"
|
|
772
|
+
" slm gdpr export --profile P [--output FILE] [--json]\n"
|
|
773
|
+
" slm gdpr erase --profile P --yes [--dry-run] [--json]\n"
|
|
774
|
+
" slm gdpr verify --receipt-id ID [--profile P] [--json]\n"
|
|
775
|
+
"\n"
|
|
776
|
+
"All subcommands accept --json for evidence-pipeline integration.\n"
|
|
777
|
+
"Exit codes: 0=success, 1=failure/tampered, 2=refused/not-found.\n"
|
|
778
|
+
)
|
|
779
|
+
sys.exit(1)
|