ltcai 4.2.0 → 4.3.0

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.
Files changed (38) hide show
  1. package/README.md +26 -21
  2. package/docs/CHANGELOG.md +42 -0
  3. package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
  4. package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
  5. package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
  6. package/docs/V4_3_VALIDATION_REPORT.md +58 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +25 -25
  8. package/frontend/openapi.json +203 -1
  9. package/frontend/src/api/client.ts +7 -0
  10. package/frontend/src/api/openapi.ts +258 -0
  11. package/frontend/src/pages/System.tsx +58 -0
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/archive.py +360 -47
  14. package/latticeai/__init__.py +1 -1
  15. package/latticeai/api/admin.py +11 -0
  16. package/latticeai/api/portability.py +59 -2
  17. package/latticeai/app_factory.py +9 -0
  18. package/latticeai/core/config.py +1 -1
  19. package/latticeai/core/marketplace.py +1 -1
  20. package/latticeai/core/multi_agent.py +1 -1
  21. package/latticeai/core/product_hardening.py +217 -0
  22. package/latticeai/core/workspace_os.py +1 -1
  23. package/latticeai/services/kg_portability.py +147 -4
  24. package/ltcai_cli.py +2 -1
  25. package/package.json +3 -3
  26. package/scripts/clean_release_artifacts.mjs +27 -0
  27. package/scripts/lint_frontend.mjs +5 -0
  28. package/scripts/validate_release_artifacts.py +10 -0
  29. package/src-tauri/Cargo.lock +1 -1
  30. package/src-tauri/Cargo.toml +1 -1
  31. package/src-tauri/src/main.rs +113 -13
  32. package/src-tauri/tauri.conf.json +1 -1
  33. package/static/app/asset-manifest.json +5 -5
  34. package/static/app/assets/{index-C_HAkbAg.js → index-RiJTJliG.js} +3 -3
  35. package/static/app/assets/index-RiJTJliG.js.map +1 -0
  36. package/static/app/assets/{index-CDjiH_se.css → index-yZswHE3d.css} +1 -1
  37. package/static/app/index.html +2 -2
  38. package/static/app/assets/index-C_HAkbAg.js.map +0 -1
@@ -275,18 +275,44 @@ function NetworkPanel() {
275
275
  }
276
276
 
277
277
  function SettingsPanel() {
278
+ const qc = useQueryClient();
278
279
  const { theme, setTheme, mode, setMode } = useAppStore();
279
280
  const health = useQuery({ queryKey: ["health"], queryFn: latticeApi.health });
280
281
  const sys = useQuery({ queryKey: ["sysinfo"], queryFn: latticeApi.sysinfo });
281
282
  const comp = useQuery({ queryKey: ["computerMemory"], queryFn: latticeApi.computerMemory });
282
283
  const storage = useQuery({ queryKey: ["brainStorage"], queryFn: latticeApi.brainStorage });
284
+ const backupHealth = useQuery({ queryKey: ["backupHealth"], queryFn: latticeApi.backupHealth });
283
285
  const [dsn, setDsn] = React.useState("");
284
286
  const [schema, setSchema] = React.useState("lattice_brain");
285
287
  const [dockerConsent, setDockerConsent] = React.useState(false);
288
+ const [archivePath, setArchivePath] = React.useState("");
289
+ const [restorePath, setRestorePath] = React.useState("");
290
+ const [archivePassphrase, setArchivePassphrase] = React.useState("");
291
+ const [restoreConfirm, setRestoreConfirm] = React.useState(false);
286
292
  const docker = useMutation({ mutationFn: (consent: boolean) => latticeApi.dockerPostgres({ consent, dry_run: !consent, port: 5432 }) });
287
293
  const migration = useMutation({
288
294
  mutationFn: () => latticeApi.migratePostgres({ dsn, schema_name: schema || "lattice_brain", dry_run: true }),
289
295
  });
296
+ const archiveCreate = useMutation({
297
+ mutationFn: () => latticeApi.brainArchive({ path: archivePath.trim() || null, passphrase: archivePassphrase }),
298
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["backupHealth"] }),
299
+ });
300
+ const archiveInspect = useMutation({
301
+ mutationFn: () => latticeApi.brainArchiveInspect({ path: restorePath, passphrase: archivePassphrase || null }),
302
+ });
303
+ const archiveVerify = useMutation({
304
+ mutationFn: () => latticeApi.brainArchiveVerify({ path: restorePath, passphrase: archivePassphrase }),
305
+ });
306
+ const archiveDryRun = useMutation({
307
+ mutationFn: () => latticeApi.brainArchiveRestore({ path: restorePath, passphrase: archivePassphrase, dry_run: true, confirm: false }),
308
+ });
309
+ const archiveRestore = useMutation({
310
+ mutationFn: () => latticeApi.brainArchiveRestore({ path: restorePath, passphrase: archivePassphrase, dry_run: false, confirm: restoreConfirm }),
311
+ onSuccess: () => {
312
+ qc.invalidateQueries({ queryKey: ["brainStorage"] });
313
+ qc.invalidateQueries({ queryKey: ["backupHealth"] });
314
+ },
315
+ });
290
316
  return (
291
317
  <div className="grid gap-4 xl:grid-cols-3">
292
318
  <Card>
@@ -311,6 +337,36 @@ function SettingsPanel() {
311
337
  <DataPanel title="Brain storage" result={storage.data} className="xl:col-span-3">
312
338
  {(data) => <JsonView value={data} />}
313
339
  </DataPanel>
340
+ <DataPanel title="Backup health" result={backupHealth.data} className="xl:col-span-3">
341
+ {(data) => <JsonView value={data} />}
342
+ </DataPanel>
343
+ <Card className="xl:col-span-3">
344
+ <CardHeader>
345
+ <CardTitle>.latticebrain portability</CardTitle>
346
+ <CardDescription>Encrypted export, inspect, verify, dry-run restore, and confirmed restore use the Brain portability API.</CardDescription>
347
+ </CardHeader>
348
+ <CardContent className="grid gap-3">
349
+ <div className="grid gap-2 sm:grid-cols-[1fr_1fr]">
350
+ <Input value={archivePath} onChange={(e) => setArchivePath(e.target.value)} placeholder="export path (optional)" />
351
+ <Input value={restorePath} onChange={(e) => setRestorePath(e.target.value)} placeholder="archive path for inspect/restore" />
352
+ </div>
353
+ <Input type="password" value={archivePassphrase} onChange={(e) => setArchivePassphrase(e.target.value)} placeholder="archive passphrase" />
354
+ <div className="flex flex-wrap gap-2">
355
+ <Button onClick={() => archiveCreate.mutate()} disabled={!archivePassphrase || archiveCreate.isPending}>Export archive</Button>
356
+ <Button variant="outline" onClick={() => archiveInspect.mutate()} disabled={!restorePath || archiveInspect.isPending}>Inspect</Button>
357
+ <Button variant="outline" onClick={() => archiveVerify.mutate()} disabled={!restorePath || !archivePassphrase || archiveVerify.isPending}>Verify</Button>
358
+ <Button variant="outline" onClick={() => archiveDryRun.mutate()} disabled={!restorePath || !archivePassphrase || archiveDryRun.isPending}>Restore dry run</Button>
359
+ <label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
360
+ <input type="checkbox" checked={restoreConfirm} onChange={(e) => setRestoreConfirm(e.target.checked)} />
361
+ Confirm restore
362
+ </label>
363
+ <Button variant="destructive" onClick={() => archiveRestore.mutate()} disabled={!restorePath || !archivePassphrase || !restoreConfirm || archiveRestore.isPending}>Restore</Button>
364
+ </div>
365
+ {[archiveCreate.data, archiveInspect.data, archiveVerify.data, archiveDryRun.data, archiveRestore.data].filter(Boolean).map((item, i) => (
366
+ <JsonView key={i} value={item?.data || item?.error} />
367
+ ))}
368
+ </CardContent>
369
+ </Card>
314
370
  <Card className="xl:col-span-3">
315
371
  <CardHeader>
316
372
  <CardTitle>Postgres scale mode</CardTitle>
@@ -355,6 +411,7 @@ function AdminPanel() {
355
411
  const audit = useQuery({ queryKey: ["adminAudit"], queryFn: latticeApi.adminAudit });
356
412
  const roles = useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles });
357
413
  const policies = useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies });
414
+ const hardening = useQuery({ queryKey: ["adminProductHardening"], queryFn: latticeApi.adminProductHardening });
358
415
  const security = useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity });
359
416
  const vpc = useQuery({ queryKey: ["vpcStatus"], queryFn: latticeApi.vpcStatus });
360
417
  return (
@@ -364,6 +421,7 @@ function AdminPanel() {
364
421
  <DataPanel title="Audit" result={audit.data}>{(data) => <EntityList items={(data as Record<string, unknown>).recent_events || data} titleKey="act" metaKey="sev" />}</DataPanel>
365
422
  <DataPanel title="Roles" result={roles.data}>{(data) => <JsonView value={data} />}</DataPanel>
366
423
  <DataPanel title="Policies" result={policies.data}>{(data) => <JsonView value={data} />}</DataPanel>
424
+ <DataPanel title="Product hardening" result={hardening.data}>{(data) => <JsonView value={data} />}</DataPanel>
367
425
  <DataPanel title="Security overview" result={security.data}>{(data) => <JsonView value={data} />}</DataPanel>
368
426
  <DataPanel title="Private VPC" result={vpc.data} className="xl:col-span-2">
369
427
  {(data) => (
@@ -19,7 +19,7 @@ from .storage import (
19
19
  storage_from_env,
20
20
  )
21
21
 
22
- __version__ = "4.2.0"
22
+ __version__ = "4.3.0"
23
23
 
24
24
  __all__ = [
25
25
  "AssembledContext",
@@ -1,8 +1,16 @@
1
- """Encrypted .latticebrain archive support."""
1
+ """Encrypted .latticebrain archive support.
2
+
3
+ The archive is intentionally self-contained and local-only: the encrypted
4
+ payload holds the SQLite brain, blob store, portable JSON state, signed export
5
+ bundles, and public metadata needed to inspect/verify/restore on another
6
+ machine without contacting a service.
7
+ """
2
8
 
3
9
  from __future__ import annotations
4
10
 
5
11
  import base64
12
+ import hashlib
13
+ import io
6
14
  import json
7
15
  import os
8
16
  import shutil
@@ -10,8 +18,8 @@ import tempfile
10
18
  import zipfile
11
19
  from dataclasses import dataclass
12
20
  from datetime import datetime, timezone
13
- from pathlib import Path
14
- from typing import Dict, Optional
21
+ from pathlib import Path, PurePosixPath
22
+ from typing import Any, Dict, Iterable, List, Optional
15
23
 
16
24
  from cryptography.hazmat.primitives import hashes
17
25
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
@@ -20,8 +28,22 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
20
28
 
21
29
 
22
30
  ARCHIVE_FORMAT = "latticebrain.encrypted"
23
- ARCHIVE_VERSION = 1
31
+ ARCHIVE_VERSION = 2
24
32
  KDF_ITERATIONS = 390_000
33
+ PORTABLE_DATA_FILES = (
34
+ "users.json",
35
+ "chat_history.json",
36
+ "workspace_os.json",
37
+ "vpc_config.json",
38
+ "mcp_installs.json",
39
+ "audit_log.json",
40
+ "sso_config.json",
41
+ "hooks.json",
42
+ "invitations.json",
43
+ "agent_registry.json",
44
+ "brain_peers.json",
45
+ )
46
+ PORTABLE_EXPORT_SUFFIXES = (".json", ".zip")
25
47
 
26
48
 
27
49
  def _now() -> str:
@@ -40,10 +62,43 @@ def _derive_key(passphrase: str, salt: bytes) -> bytes:
40
62
  return kdf.derive(passphrase.encode("utf-8"))
41
63
 
42
64
 
65
+ def _sha256_bytes(data: bytes) -> str:
66
+ return hashlib.sha256(data).hexdigest()
67
+
68
+
69
+ def _sha256_file(path: Path) -> str:
70
+ h = hashlib.sha256()
71
+ with open(path, "rb") as fh:
72
+ for block in iter(lambda: fh.read(65536), b""):
73
+ h.update(block)
74
+ return h.hexdigest()
75
+
76
+
77
+ def _safe_json(value: Any) -> Any:
78
+ try:
79
+ json.dumps(value)
80
+ return value
81
+ except TypeError:
82
+ if isinstance(value, dict):
83
+ return {str(k): _safe_json(v) for k, v in value.items()}
84
+ if isinstance(value, (list, tuple)):
85
+ return [_safe_json(v) for v in value]
86
+ return str(value)
87
+
88
+
89
+ def _assert_safe_member(name: str) -> PurePosixPath:
90
+ path = PurePosixPath(name)
91
+ if path.is_absolute() or ".." in path.parts:
92
+ raise ValueError(f"Archive payload contains unsafe path: {name}")
93
+ return path
94
+
95
+
43
96
  @dataclass(frozen=True)
44
97
  class BrainArchivePaths:
45
98
  db_path: Path
46
99
  blob_dir: Optional[Path] = None
100
+ data_dir: Optional[Path] = None
101
+ metadata: Optional[Dict[str, Any]] = None
47
102
 
48
103
 
49
104
  class EncryptedBrainArchive:
@@ -52,6 +107,67 @@ class EncryptedBrainArchive:
52
107
  def __init__(self, paths: BrainArchivePaths) -> None:
53
108
  self.paths = paths
54
109
 
110
+ def _iter_payload_files(self) -> Iterable[tuple[Path, str]]:
111
+ yield Path(self.paths.db_path), "knowledge_graph.sqlite"
112
+ if self.paths.blob_dir and self.paths.blob_dir.exists():
113
+ blob_root = Path(self.paths.blob_dir)
114
+ for file in sorted(blob_root.rglob("*")):
115
+ if file.is_file():
116
+ yield file, f"blobs/{file.relative_to(blob_root).as_posix()}"
117
+ if self.paths.data_dir and Path(self.paths.data_dir).exists():
118
+ data_root = Path(self.paths.data_dir)
119
+ for name in PORTABLE_DATA_FILES:
120
+ file = data_root / name
121
+ if file.is_file():
122
+ yield file, f"data/{name}"
123
+ exports = data_root / "workspace_exports"
124
+ if exports.exists():
125
+ for file in sorted(exports.rglob("*")):
126
+ if not file.is_file():
127
+ continue
128
+ if file.suffix == ".latticebrain":
129
+ continue
130
+ if file.suffix not in PORTABLE_EXPORT_SUFFIXES:
131
+ continue
132
+ yield file, f"workspace_exports/{file.relative_to(exports).as_posix()}"
133
+
134
+ def _build_manifest(self, entries: List[Dict[str, Any]]) -> Dict[str, Any]:
135
+ metadata = _safe_json(self.paths.metadata or {})
136
+ return {
137
+ "format": "latticebrain.payload",
138
+ "format_version": ARCHIVE_VERSION,
139
+ "created_at": _now(),
140
+ "sections": {
141
+ "graph": any(item["path"] == "knowledge_graph.sqlite" for item in entries),
142
+ "blobs": any(item["path"].startswith("blobs/") for item in entries),
143
+ "workspace_state": any(item["path"].startswith("data/") for item in entries),
144
+ "signed_bundles": any(item["path"].startswith("workspace_exports/") for item in entries),
145
+ },
146
+ "metadata": metadata,
147
+ "storage": metadata.get("storage") or {},
148
+ "device_identity": metadata.get("device_identity") or {},
149
+ "provenance": metadata.get("provenance") or {},
150
+ "entries": entries,
151
+ }
152
+
153
+ def _payload_zip_bytes(self) -> tuple[bytes, Dict[str, Any]]:
154
+ entries: List[Dict[str, Any]] = []
155
+ with tempfile.TemporaryDirectory() as tmp_s:
156
+ payload = Path(tmp_s) / "payload.zip"
157
+ with zipfile.ZipFile(payload, "w", zipfile.ZIP_DEFLATED) as zf:
158
+ for src, arcname in self._iter_payload_files():
159
+ _assert_safe_member(arcname)
160
+ zf.write(src, arcname)
161
+ entries.append({
162
+ "path": arcname,
163
+ "bytes": src.stat().st_size,
164
+ "sha256": _sha256_file(src),
165
+ })
166
+ manifest = self._build_manifest(entries)
167
+ manifest_bytes = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
168
+ zf.writestr("manifest.json", manifest_bytes)
169
+ return payload.read_bytes(), manifest
170
+
55
171
  def create(self, destination: Path, *, passphrase: str) -> Dict[str, object]:
56
172
  dest = Path(destination)
57
173
  if dest.suffix != ".latticebrain":
@@ -59,59 +175,226 @@ class EncryptedBrainArchive:
59
175
  if not self.paths.db_path.exists():
60
176
  raise FileNotFoundError(f"Brain database not found: {self.paths.db_path}")
61
177
  dest.parent.mkdir(parents=True, exist_ok=True)
62
- with tempfile.TemporaryDirectory() as tmp_s:
63
- tmp = Path(tmp_s)
64
- payload = tmp / "payload.zip"
65
- with zipfile.ZipFile(payload, "w", zipfile.ZIP_DEFLATED) as zf:
66
- zf.write(self.paths.db_path, "knowledge_graph.sqlite")
67
- if self.paths.blob_dir and self.paths.blob_dir.exists():
68
- for file in self.paths.blob_dir.rglob("*"):
69
- if file.is_file():
70
- zf.write(file, f"blobs/{file.relative_to(self.paths.blob_dir)}")
71
- salt = os.urandom(16)
72
- nonce = os.urandom(12)
73
- key = _derive_key(passphrase, salt)
74
- ciphertext = AESGCM(key).encrypt(nonce, payload.read_bytes(), None)
75
- envelope = {
76
- "format": ARCHIVE_FORMAT,
77
- "format_version": ARCHIVE_VERSION,
78
- "created_at": _now(),
79
- "kdf": {
80
- "name": "PBKDF2HMAC-SHA256",
81
- "iterations": KDF_ITERATIONS,
82
- "salt": base64.b64encode(salt).decode("ascii"),
83
- },
84
- "cipher": {
85
- "name": "AES-256-GCM",
86
- "nonce": base64.b64encode(nonce).decode("ascii"),
87
- },
88
- "payload": base64.b64encode(ciphertext).decode("ascii"),
89
- }
90
- dest.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
91
- return {"path": str(dest), "bytes": dest.stat().st_size, "encrypted": True}
178
+ payload_bytes, manifest = self._payload_zip_bytes()
179
+ salt = os.urandom(16)
180
+ nonce = os.urandom(12)
181
+ key = _derive_key(passphrase, salt)
182
+ ciphertext = AESGCM(key).encrypt(nonce, payload_bytes, None)
183
+ envelope = {
184
+ "format": ARCHIVE_FORMAT,
185
+ "format_version": ARCHIVE_VERSION,
186
+ "created_at": _now(),
187
+ "kdf": {
188
+ "name": "PBKDF2HMAC-SHA256",
189
+ "iterations": KDF_ITERATIONS,
190
+ "salt": base64.b64encode(salt).decode("ascii"),
191
+ },
192
+ "cipher": {
193
+ "name": "AES-256-GCM",
194
+ "nonce": base64.b64encode(nonce).decode("ascii"),
195
+ },
196
+ "payload_sha256": _sha256_bytes(payload_bytes),
197
+ "manifest_summary": {
198
+ "format_version": manifest["format_version"],
199
+ "created_at": manifest["created_at"],
200
+ "sections": manifest["sections"],
201
+ "storage": manifest.get("storage") or {},
202
+ "device_identity": manifest.get("device_identity") or {},
203
+ },
204
+ "payload": base64.b64encode(ciphertext).decode("ascii"),
205
+ }
206
+ dest.write_text(json.dumps(envelope, indent=2), encoding="utf-8")
207
+ return {
208
+ "path": str(dest),
209
+ "bytes": dest.stat().st_size,
210
+ "encrypted": True,
211
+ "format_version": ARCHIVE_VERSION,
212
+ "manifest": {
213
+ "sections": manifest["sections"],
214
+ "storage": manifest.get("storage") or {},
215
+ "entries": len(manifest["entries"]),
216
+ },
217
+ }
92
218
 
93
- def restore(self, source: Path, *, passphrase: str, target: BrainArchivePaths) -> Dict[str, object]:
219
+ def _load_envelope(self, source: Path) -> Dict[str, Any]:
94
220
  src = Path(source)
95
221
  if not src.exists():
96
222
  raise FileNotFoundError(f"Brain archive not found: {src}")
97
- envelope = json.loads(src.read_text(encoding="utf-8"))
223
+ try:
224
+ envelope = json.loads(src.read_text(encoding="utf-8"))
225
+ except json.JSONDecodeError as exc:
226
+ raise ValueError("Archive envelope is not valid JSON.") from exc
98
227
  if envelope.get("format") != ARCHIVE_FORMAT:
99
228
  raise ValueError("Not a .latticebrain encrypted archive.")
100
- salt = base64.b64decode(envelope["kdf"]["salt"])
101
- nonce = base64.b64decode(envelope["cipher"]["nonce"])
102
- ciphertext = base64.b64decode(envelope["payload"])
229
+ version = int(envelope.get("format_version") or 0)
230
+ if version < 1:
231
+ raise ValueError("Archive format version is missing or invalid.")
232
+ if version > ARCHIVE_VERSION:
233
+ raise ValueError(
234
+ f"Archive format version {version} is newer than supported version {ARCHIVE_VERSION}."
235
+ )
236
+ return envelope
237
+
238
+ def inspect(self, source: Path, *, passphrase: Optional[str] = None) -> Dict[str, Any]:
239
+ envelope = self._load_envelope(source)
240
+ summary = {
241
+ "valid_envelope": True,
242
+ "encrypted": True,
243
+ "format": envelope.get("format"),
244
+ "format_version": envelope.get("format_version"),
245
+ "created_at": envelope.get("created_at"),
246
+ "cipher": (envelope.get("cipher") or {}).get("name"),
247
+ "kdf": {
248
+ "name": (envelope.get("kdf") or {}).get("name"),
249
+ "iterations": (envelope.get("kdf") or {}).get("iterations"),
250
+ },
251
+ "manifest_summary": envelope.get("manifest_summary") or {},
252
+ }
253
+ if passphrase:
254
+ verified = self.verify(source, passphrase=passphrase)
255
+ summary["verified"] = verified["ok"]
256
+ summary["manifest"] = verified.get("manifest")
257
+ summary["errors"] = verified.get("errors", [])
258
+ return summary
259
+
260
+ def _decrypt_payload(self, envelope: Dict[str, Any], passphrase: str) -> bytes:
261
+ try:
262
+ salt = base64.b64decode(envelope["kdf"]["salt"])
263
+ nonce = base64.b64decode(envelope["cipher"]["nonce"])
264
+ ciphertext = base64.b64decode(envelope["payload"])
265
+ except Exception as exc:
266
+ raise ValueError("Archive envelope is missing encryption metadata.") from exc
103
267
  key = _derive_key(passphrase, salt)
104
268
  try:
105
- plaintext = AESGCM(key).decrypt(nonce, ciphertext, None)
269
+ payload = AESGCM(key).decrypt(nonce, ciphertext, None)
106
270
  except InvalidTag as exc:
107
271
  raise ValueError("Archive decryption failed; passphrase or archive data is invalid.") from exc
272
+ expected = envelope.get("payload_sha256")
273
+ if expected and _sha256_bytes(payload) != expected:
274
+ raise ValueError("Archive payload integrity check failed (sha256 mismatch).")
275
+ return payload
276
+
277
+ def _read_payload(self, payload: bytes) -> tuple[Dict[str, Any], Dict[str, bytes]]:
278
+ try:
279
+ with zipfile.ZipFile(io.BytesIO(payload)) as zf:
280
+ bad_member = zf.testzip()
281
+ if bad_member:
282
+ raise ValueError(f"Archive payload member is corrupt: {bad_member}")
283
+ names = zf.namelist()
284
+ for name in names:
285
+ _assert_safe_member(name)
286
+ raw = {name: zf.read(name) for name in names if not name.endswith("/")}
287
+ except zipfile.BadZipFile as exc:
288
+ raise ValueError("Archive payload is not a valid ZIP file.") from exc
289
+ if "manifest.json" in raw:
290
+ manifest = json.loads(raw["manifest.json"].decode("utf-8"))
291
+ else:
292
+ manifest = {
293
+ "format": "latticebrain.payload",
294
+ "format_version": 1,
295
+ "created_at": None,
296
+ "sections": {
297
+ "graph": "knowledge_graph.sqlite" in raw,
298
+ "blobs": any(name.startswith("blobs/") for name in raw),
299
+ "workspace_state": False,
300
+ "signed_bundles": False,
301
+ },
302
+ "metadata": {},
303
+ "storage": {},
304
+ "device_identity": {},
305
+ "provenance": {},
306
+ "entries": [
307
+ {"path": name, "bytes": len(data), "sha256": _sha256_bytes(data)}
308
+ for name, data in raw.items()
309
+ if name != "manifest.json"
310
+ ],
311
+ }
312
+ version = int(manifest.get("format_version") or 0)
313
+ if version < 1:
314
+ raise ValueError("Archive manifest format version is missing or invalid.")
315
+ if version > ARCHIVE_VERSION:
316
+ raise ValueError(
317
+ f"Archive manifest version {version} is newer than supported version {ARCHIVE_VERSION}."
318
+ )
319
+ return manifest, raw
320
+
321
+ def verify(self, source: Path, *, passphrase: str) -> Dict[str, Any]:
322
+ try:
323
+ envelope = self._load_envelope(source)
324
+ payload = self._decrypt_payload(envelope, passphrase)
325
+ manifest, raw = self._read_payload(payload)
326
+ if "knowledge_graph.sqlite" not in raw:
327
+ raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
328
+ missing: List[str] = []
329
+ mismatched: List[str] = []
330
+ for entry in manifest.get("entries") or []:
331
+ name = str(entry.get("path") or "")
332
+ if not name or name == "manifest.json":
333
+ continue
334
+ data = raw.get(name)
335
+ if data is None:
336
+ missing.append(name)
337
+ continue
338
+ if entry.get("sha256") and _sha256_bytes(data) != entry["sha256"]:
339
+ mismatched.append(name)
340
+ if missing or mismatched:
341
+ raise ValueError(
342
+ "Archive manifest integrity check failed "
343
+ f"(missing={missing}, mismatched={mismatched})."
344
+ )
345
+ return {
346
+ "ok": True,
347
+ "encrypted": True,
348
+ "format_version": envelope.get("format_version"),
349
+ "manifest": manifest,
350
+ "entries": len([name for name in raw if name != "manifest.json"]),
351
+ "errors": [],
352
+ }
353
+ except (ValueError, FileNotFoundError) as exc:
354
+ return {"ok": False, "encrypted": True, "errors": [str(exc)]}
355
+
356
+ def restore(
357
+ self,
358
+ source: Path,
359
+ *,
360
+ passphrase: str,
361
+ target: BrainArchivePaths,
362
+ dry_run: bool = False,
363
+ confirm: bool = False,
364
+ ) -> Dict[str, object]:
365
+ if not dry_run and not confirm:
366
+ raise ValueError("Explicit confirmation is required before restoring a .latticebrain archive.")
367
+ envelope = self._load_envelope(source)
368
+ payload = self._decrypt_payload(envelope, passphrase)
369
+ manifest, raw = self._read_payload(payload)
370
+ verified = self.verify(source, passphrase=passphrase)
371
+ if not verified["ok"]:
372
+ raise ValueError("; ".join(verified.get("errors") or ["Archive verification failed."]))
373
+ planned = {
374
+ "database": str(target.db_path),
375
+ "blobs": str(target.blob_dir) if target.blob_dir else None,
376
+ "data_dir": str(target.data_dir) if target.data_dir else None,
377
+ "entries": len([name for name in raw if name != "manifest.json"]),
378
+ "sections": manifest.get("sections") or {},
379
+ }
380
+ if dry_run:
381
+ return {
382
+ "restored": False,
383
+ "dry_run": True,
384
+ "verified": True,
385
+ "planned": planned,
386
+ "manifest": manifest,
387
+ }
108
388
  with tempfile.TemporaryDirectory() as tmp_s:
109
389
  tmp = Path(tmp_s)
110
- payload = tmp / "payload.zip"
111
- payload.write_bytes(plaintext)
112
- with zipfile.ZipFile(payload) as zf:
113
- zf.extractall(tmp / "out")
114
- db_src = tmp / "out" / "knowledge_graph.sqlite"
390
+ out = tmp / "out"
391
+ out.mkdir()
392
+ for name, data in raw.items():
393
+ _assert_safe_member(name)
394
+ dest = out / name
395
+ dest.parent.mkdir(parents=True, exist_ok=True)
396
+ dest.write_bytes(data)
397
+ db_src = out / "knowledge_graph.sqlite"
115
398
  if not db_src.exists():
116
399
  raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
117
400
  target.db_path.parent.mkdir(parents=True, exist_ok=True)
@@ -119,7 +402,7 @@ class EncryptedBrainArchive:
119
402
  if sibling.exists():
120
403
  sibling.unlink()
121
404
  shutil.copyfile(db_src, target.db_path)
122
- blobs_src = tmp / "out" / "blobs"
405
+ blobs_src = out / "blobs"
123
406
  if target.blob_dir:
124
407
  if target.blob_dir.exists():
125
408
  shutil.rmtree(target.blob_dir)
@@ -127,7 +410,37 @@ class EncryptedBrainArchive:
127
410
  shutil.copytree(blobs_src, target.blob_dir)
128
411
  else:
129
412
  target.blob_dir.mkdir(parents=True, exist_ok=True)
130
- return {"restored": True, "path": str(target.db_path), "encrypted": True}
413
+ if target.data_dir:
414
+ data_src = out / "data"
415
+ exports_src = out / "workspace_exports"
416
+ target_data = Path(target.data_dir)
417
+ target_data.mkdir(parents=True, exist_ok=True)
418
+ if data_src.exists():
419
+ for file in sorted(data_src.rglob("*")):
420
+ if file.is_file():
421
+ rel = file.relative_to(data_src)
422
+ if rel.as_posix() not in PORTABLE_DATA_FILES:
423
+ continue
424
+ dest = target_data / rel
425
+ dest.parent.mkdir(parents=True, exist_ok=True)
426
+ shutil.copyfile(file, dest)
427
+ if exports_src.exists():
428
+ exports_dest = target_data / "workspace_exports"
429
+ exports_dest.mkdir(parents=True, exist_ok=True)
430
+ for file in sorted(exports_src.rglob("*")):
431
+ if file.is_file():
432
+ rel = file.relative_to(exports_src)
433
+ dest = exports_dest / rel
434
+ dest.parent.mkdir(parents=True, exist_ok=True)
435
+ shutil.copyfile(file, dest)
436
+ return {
437
+ "restored": True,
438
+ "dry_run": False,
439
+ "path": str(target.db_path),
440
+ "encrypted": True,
441
+ "verified": True,
442
+ "manifest": manifest,
443
+ }
131
444
 
132
445
 
133
446
  __all__ = ["BrainArchivePaths", "EncryptedBrainArchive"]
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "4.2.0"
3
+ __version__ = "4.3.0"
@@ -56,6 +56,7 @@ def create_admin_router(
56
56
  invite_gate_enabled: bool,
57
57
  default_port: int,
58
58
  policy_matrix: Optional[Callable[[], List[Dict[str, object]]]] = None,
59
+ product_hardening_status: Optional[Callable[[], Dict[str, object]]] = None,
59
60
  ) -> APIRouter:
60
61
  router = APIRouter()
61
62
 
@@ -155,6 +156,16 @@ def create_admin_router(
155
156
  ]
156
157
  }
157
158
 
159
+ @router.get("/admin/product-hardening")
160
+ async def admin_product_hardening(request: Request):
161
+ require_admin(request)
162
+ if product_hardening_status is None:
163
+ return {
164
+ "available": False,
165
+ "reason": "Product hardening status provider is not configured.",
166
+ }
167
+ return product_hardening_status()
168
+
158
169
  @router.get("/vpc/status")
159
170
  async def vpc_status(request: Request):
160
171
  require_user(request)