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.
- package/README.md +26 -21
- package/docs/CHANGELOG.md +42 -0
- package/docs/V4_3_PORTABILITY_ARCHITECTURE.md +69 -0
- package/docs/V4_3_PRIVACY_AUDIT.md +60 -0
- package/docs/V4_3_PRODUCT_HARDENING_REPORT.md +53 -0
- package/docs/V4_3_VALIDATION_REPORT.md +58 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +25 -25
- package/frontend/openapi.json +203 -1
- package/frontend/src/api/client.ts +7 -0
- package/frontend/src/api/openapi.ts +258 -0
- package/frontend/src/pages/System.tsx +58 -0
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +360 -47
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +11 -0
- package/latticeai/api/portability.py +59 -2
- package/latticeai/app_factory.py +9 -0
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +1 -1
- package/latticeai/core/product_hardening.py +217 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/kg_portability.py +147 -4
- package/ltcai_cli.py +2 -1
- package/package.json +3 -3
- package/scripts/clean_release_artifacts.mjs +27 -0
- package/scripts/lint_frontend.mjs +5 -0
- package/scripts/validate_release_artifacts.py +10 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +113 -13
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/{index-C_HAkbAg.js → index-RiJTJliG.js} +3 -3
- package/static/app/assets/index-RiJTJliG.js.map +1 -0
- package/static/app/assets/{index-CDjiH_se.css → index-yZswHE3d.css} +1 -1
- package/static/app/index.html +2 -2
- 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) => (
|
package/lattice_brain/archive.py
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 =
|
|
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
|
-
|
|
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"]
|
package/latticeai/__init__.py
CHANGED
package/latticeai/api/admin.py
CHANGED
|
@@ -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)
|