ltcai 4.1.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 +33 -24
- package/docs/CHANGELOG.md +84 -0
- package/docs/V4_2_BRAIN_CORE_ARCHITECTURE.md +97 -0
- package/docs/V4_2_STORAGE_MIGRATION_REPORT.md +91 -0
- package/docs/V4_2_VALIDATION_REPORT.md +89 -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 +31 -33
- package/frontend/openapi.json +449 -1
- package/frontend/src/api/client.ts +10 -0
- package/frontend/src/api/openapi.ts +542 -0
- package/frontend/src/pages/System.tsx +92 -0
- package/kg_schema.py +1 -1
- package/knowledge_graph.py +4 -4
- package/lattice_brain/__init__.py +70 -0
- package/lattice_brain/_kg_common.py +1 -0
- package/lattice_brain/archive.py +446 -0
- package/lattice_brain/context.py +3 -0
- package/lattice_brain/conversations.py +3 -0
- package/lattice_brain/core.py +82 -0
- package/lattice_brain/discovery.py +1 -0
- package/lattice_brain/documents.py +1 -0
- package/lattice_brain/embeddings.py +82 -0
- package/lattice_brain/identity.py +13 -0
- package/lattice_brain/ingest.py +1 -0
- package/lattice_brain/memory.py +3 -0
- package/lattice_brain/network.py +1 -0
- package/lattice_brain/projection.py +1 -0
- package/lattice_brain/provenance.py +1 -0
- package/lattice_brain/retrieval.py +1 -0
- package/lattice_brain/schema.py +1 -0
- package/lattice_brain/storage/__init__.py +22 -0
- package/lattice_brain/storage/base.py +72 -0
- package/lattice_brain/storage/docker.py +105 -0
- package/lattice_brain/storage/factory.py +31 -0
- package/lattice_brain/storage/migration.py +190 -0
- package/lattice_brain/storage/postgres.py +123 -0
- package/lattice_brain/storage/sqlite.py +128 -0
- package/lattice_brain/store.py +3 -0
- package/lattice_brain/write_master.py +1 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +11 -0
- package/latticeai/api/portability.py +127 -1
- package/latticeai/app_factory.py +26 -10
- package/latticeai/brain/__init__.py +6 -6
- package/latticeai/brain/_kg_common.py +1 -1
- package/latticeai/brain/network.py +1 -1
- package/latticeai/brain/retrieval.py +15 -0
- package/latticeai/brain/store.py +22 -6
- package/latticeai/core/config.py +9 -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 +227 -3
- package/ltcai_cli.py +2 -1
- package/package.json +4 -3
- package/scripts/bump_version.py +3 -0
- package/scripts/clean_release_artifacts.mjs +27 -0
- package/scripts/lint_frontend.mjs +10 -0
- package/scripts/migrate_brain_storage.py +53 -0
- package/scripts/validate_release_artifacts.py +10 -0
- package/scripts/wheel_smoke.py +3 -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 +5 -2
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/{index-CJRAzNnf.js → index-RiJTJliG.js} +3 -3
- package/static/app/assets/index-RiJTJliG.js.map +1 -0
- package/static/app/assets/index-yZswHE3d.css +2 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-CJRAzNnf.js.map +0 -1
- package/static/app/assets/index-CSwBBgf4.css +0 -2
|
@@ -275,10 +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 });
|
|
283
|
+
const storage = useQuery({ queryKey: ["brainStorage"], queryFn: latticeApi.brainStorage });
|
|
284
|
+
const backupHealth = useQuery({ queryKey: ["backupHealth"], queryFn: latticeApi.backupHealth });
|
|
285
|
+
const [dsn, setDsn] = React.useState("");
|
|
286
|
+
const [schema, setSchema] = React.useState("lattice_brain");
|
|
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);
|
|
292
|
+
const docker = useMutation({ mutationFn: (consent: boolean) => latticeApi.dockerPostgres({ consent, dry_run: !consent, port: 5432 }) });
|
|
293
|
+
const migration = useMutation({
|
|
294
|
+
mutationFn: () => latticeApi.migratePostgres({ dsn, schema_name: schema || "lattice_brain", dry_run: true }),
|
|
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
|
+
});
|
|
282
316
|
return (
|
|
283
317
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
284
318
|
<Card>
|
|
@@ -300,6 +334,62 @@ function SettingsPanel() {
|
|
|
300
334
|
<DataPanel title="Host telemetry" result={sys.data}>
|
|
301
335
|
{(data) => <JsonView value={data} />}
|
|
302
336
|
</DataPanel>
|
|
337
|
+
<DataPanel title="Brain storage" result={storage.data} className="xl:col-span-3">
|
|
338
|
+
{(data) => <JsonView value={data} />}
|
|
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>
|
|
370
|
+
<Card className="xl:col-span-3">
|
|
371
|
+
<CardHeader>
|
|
372
|
+
<CardTitle>Postgres scale mode</CardTitle>
|
|
373
|
+
<CardDescription>Opt-in migration and Docker setup; SQLite remains the default.</CardDescription>
|
|
374
|
+
</CardHeader>
|
|
375
|
+
<CardContent className="grid gap-3">
|
|
376
|
+
<div className="grid gap-2 sm:grid-cols-[1fr_220px]">
|
|
377
|
+
<Input value={dsn} onChange={(e) => setDsn(e.target.value)} placeholder="postgresql://user:pass@127.0.0.1:5432/lattice_brain" />
|
|
378
|
+
<Input value={schema} onChange={(e) => setSchema(e.target.value)} placeholder="schema" />
|
|
379
|
+
</div>
|
|
380
|
+
<div className="flex flex-wrap gap-2">
|
|
381
|
+
<Button variant="outline" onClick={() => docker.mutate(false)} disabled={docker.isPending}>Docker plan</Button>
|
|
382
|
+
<label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
|
383
|
+
<input type="checkbox" checked={dockerConsent} onChange={(e) => setDockerConsent(e.target.checked)} />
|
|
384
|
+
Consent to start Docker
|
|
385
|
+
</label>
|
|
386
|
+
<Button onClick={() => docker.mutate(true)} disabled={!dockerConsent || docker.isPending}>Start Docker</Button>
|
|
387
|
+
<Button variant="outline" onClick={() => migration.mutate()} disabled={!dsn || migration.isPending}>Plan migration</Button>
|
|
388
|
+
</div>
|
|
389
|
+
{docker.data ? <JsonView value={docker.data.data || docker.data.error} /> : null}
|
|
390
|
+
{migration.data ? <JsonView value={migration.data.data || migration.data.error} /> : null}
|
|
391
|
+
</CardContent>
|
|
392
|
+
</Card>
|
|
303
393
|
<DataPanel title="Computer memory" result={comp.data} className="xl:col-span-3">
|
|
304
394
|
{(data) => (
|
|
305
395
|
<div className="space-y-3">
|
|
@@ -321,6 +411,7 @@ function AdminPanel() {
|
|
|
321
411
|
const audit = useQuery({ queryKey: ["adminAudit"], queryFn: latticeApi.adminAudit });
|
|
322
412
|
const roles = useQuery({ queryKey: ["adminRoles"], queryFn: latticeApi.adminRoles });
|
|
323
413
|
const policies = useQuery({ queryKey: ["adminPolicies"], queryFn: latticeApi.adminPolicies });
|
|
414
|
+
const hardening = useQuery({ queryKey: ["adminProductHardening"], queryFn: latticeApi.adminProductHardening });
|
|
324
415
|
const security = useQuery({ queryKey: ["adminSecurity"], queryFn: latticeApi.adminSecurity });
|
|
325
416
|
const vpc = useQuery({ queryKey: ["vpcStatus"], queryFn: latticeApi.vpcStatus });
|
|
326
417
|
return (
|
|
@@ -330,6 +421,7 @@ function AdminPanel() {
|
|
|
330
421
|
<DataPanel title="Audit" result={audit.data}>{(data) => <EntityList items={(data as Record<string, unknown>).recent_events || data} titleKey="act" metaKey="sev" />}</DataPanel>
|
|
331
422
|
<DataPanel title="Roles" result={roles.data}>{(data) => <JsonView value={data} />}</DataPanel>
|
|
332
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>
|
|
333
425
|
<DataPanel title="Security overview" result={security.data}>{(data) => <JsonView value={data} />}</DataPanel>
|
|
334
426
|
<DataPanel title="Private VPC" result={vpc.data} className="xl:col-span-2">
|
|
335
427
|
{(data) => (
|
package/kg_schema.py
CHANGED
package/knowledge_graph.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""Compatibility shim for the v4 brain store.
|
|
1
|
+
"""Compatibility shim for the v4.2 lattice-brain store.
|
|
2
2
|
|
|
3
|
-
The implementation now lives under :mod:`
|
|
3
|
+
The implementation now lives under :mod:`lattice_brain`. Root imports are
|
|
4
4
|
kept for older integrations and tests.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from lattice_brain._kg_common import ( # noqa: F401
|
|
8
8
|
EDGE_VERB,
|
|
9
9
|
GRAPH_SCHEMA_VERSION,
|
|
10
10
|
LOCAL_CODE_EXTENSIONS,
|
|
@@ -24,7 +24,7 @@ from latticeai.brain._kg_common import ( # noqa: F401
|
|
|
24
24
|
_slug,
|
|
25
25
|
set_llm_router,
|
|
26
26
|
)
|
|
27
|
-
from
|
|
27
|
+
from lattice_brain.store import KnowledgeGraphStore
|
|
28
28
|
|
|
29
29
|
__all__ = [
|
|
30
30
|
"KnowledgeGraphStore",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""lattice-brain — independent Brain Core package for Lattice AI.
|
|
2
|
+
|
|
3
|
+
Heavy graph modules are lazy-loaded so storage and archive utilities remain
|
|
4
|
+
usable without importing the FastAPI application or creating runtime globals.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .archive import BrainArchivePaths, EncryptedBrainArchive
|
|
8
|
+
from .core import BrainCore, BrainCoreConfig
|
|
9
|
+
from .storage import (
|
|
10
|
+
DockerPostgresPlan,
|
|
11
|
+
DockerPostgresWizard,
|
|
12
|
+
PostgresConfig,
|
|
13
|
+
PostgresEngine,
|
|
14
|
+
SQLiteEngine,
|
|
15
|
+
SQLiteToPostgresMigrator,
|
|
16
|
+
StorageCapabilities,
|
|
17
|
+
StorageEngine,
|
|
18
|
+
StorageUnavailable,
|
|
19
|
+
storage_from_env,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "4.3.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AssembledContext",
|
|
26
|
+
"BrainArchivePaths",
|
|
27
|
+
"BrainCore",
|
|
28
|
+
"BrainCoreConfig",
|
|
29
|
+
"BrainMemory",
|
|
30
|
+
"ContextAssembler",
|
|
31
|
+
"ContextSection",
|
|
32
|
+
"ConversationStore",
|
|
33
|
+
"DockerPostgresPlan",
|
|
34
|
+
"DockerPostgresWizard",
|
|
35
|
+
"EncryptedBrainArchive",
|
|
36
|
+
"KnowledgeGraphStore",
|
|
37
|
+
"PostgresConfig",
|
|
38
|
+
"PostgresEngine",
|
|
39
|
+
"SQLiteEngine",
|
|
40
|
+
"SQLiteToPostgresMigrator",
|
|
41
|
+
"StorageCapabilities",
|
|
42
|
+
"StorageEngine",
|
|
43
|
+
"StorageUnavailable",
|
|
44
|
+
"storage_from_env",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def __getattr__(name: str):
|
|
50
|
+
if name in {"AssembledContext", "ContextAssembler", "ContextSection"}:
|
|
51
|
+
from .context import AssembledContext, ContextAssembler, ContextSection
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
"AssembledContext": AssembledContext,
|
|
55
|
+
"ContextAssembler": ContextAssembler,
|
|
56
|
+
"ContextSection": ContextSection,
|
|
57
|
+
}[name]
|
|
58
|
+
if name == "ConversationStore":
|
|
59
|
+
from .conversations import ConversationStore
|
|
60
|
+
|
|
61
|
+
return ConversationStore
|
|
62
|
+
if name == "BrainMemory":
|
|
63
|
+
from .memory import BrainMemory
|
|
64
|
+
|
|
65
|
+
return BrainMemory
|
|
66
|
+
if name == "KnowledgeGraphStore":
|
|
67
|
+
from .store import KnowledgeGraphStore
|
|
68
|
+
|
|
69
|
+
return KnowledgeGraphStore
|
|
70
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from latticeai.brain._kg_common import * # noqa: F401,F403
|
|
@@ -0,0 +1,446 @@
|
|
|
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
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import tempfile
|
|
18
|
+
import zipfile
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path, PurePosixPath
|
|
22
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
23
|
+
|
|
24
|
+
from cryptography.hazmat.primitives import hashes
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
26
|
+
from cryptography.exceptions import InvalidTag
|
|
27
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ARCHIVE_FORMAT = "latticebrain.encrypted"
|
|
31
|
+
ARCHIVE_VERSION = 2
|
|
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")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _now() -> str:
|
|
50
|
+
return datetime.now(timezone.utc).isoformat()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _derive_key(passphrase: str, salt: bytes) -> bytes:
|
|
54
|
+
if not passphrase:
|
|
55
|
+
raise ValueError("A passphrase is required for encrypted .latticebrain archives.")
|
|
56
|
+
kdf = PBKDF2HMAC(
|
|
57
|
+
algorithm=hashes.SHA256(),
|
|
58
|
+
length=32,
|
|
59
|
+
salt=salt,
|
|
60
|
+
iterations=KDF_ITERATIONS,
|
|
61
|
+
)
|
|
62
|
+
return kdf.derive(passphrase.encode("utf-8"))
|
|
63
|
+
|
|
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
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class BrainArchivePaths:
|
|
98
|
+
db_path: Path
|
|
99
|
+
blob_dir: Optional[Path] = None
|
|
100
|
+
data_dir: Optional[Path] = None
|
|
101
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class EncryptedBrainArchive:
|
|
105
|
+
"""Create and restore encrypted local Brain Core archives."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, paths: BrainArchivePaths) -> None:
|
|
108
|
+
self.paths = paths
|
|
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
|
+
|
|
171
|
+
def create(self, destination: Path, *, passphrase: str) -> Dict[str, object]:
|
|
172
|
+
dest = Path(destination)
|
|
173
|
+
if dest.suffix != ".latticebrain":
|
|
174
|
+
dest = dest.with_suffix(".latticebrain")
|
|
175
|
+
if not self.paths.db_path.exists():
|
|
176
|
+
raise FileNotFoundError(f"Brain database not found: {self.paths.db_path}")
|
|
177
|
+
dest.parent.mkdir(parents=True, exist_ok=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
|
+
}
|
|
218
|
+
|
|
219
|
+
def _load_envelope(self, source: Path) -> Dict[str, Any]:
|
|
220
|
+
src = Path(source)
|
|
221
|
+
if not src.exists():
|
|
222
|
+
raise FileNotFoundError(f"Brain archive not found: {src}")
|
|
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
|
|
227
|
+
if envelope.get("format") != ARCHIVE_FORMAT:
|
|
228
|
+
raise ValueError("Not a .latticebrain encrypted archive.")
|
|
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
|
|
267
|
+
key = _derive_key(passphrase, salt)
|
|
268
|
+
try:
|
|
269
|
+
payload = AESGCM(key).decrypt(nonce, ciphertext, None)
|
|
270
|
+
except InvalidTag as exc:
|
|
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
|
+
}
|
|
388
|
+
with tempfile.TemporaryDirectory() as tmp_s:
|
|
389
|
+
tmp = Path(tmp_s)
|
|
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"
|
|
398
|
+
if not db_src.exists():
|
|
399
|
+
raise ValueError("Archive payload is missing knowledge_graph.sqlite.")
|
|
400
|
+
target.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
401
|
+
for sibling in (target.db_path, Path(str(target.db_path) + "-wal"), Path(str(target.db_path) + "-shm")):
|
|
402
|
+
if sibling.exists():
|
|
403
|
+
sibling.unlink()
|
|
404
|
+
shutil.copyfile(db_src, target.db_path)
|
|
405
|
+
blobs_src = out / "blobs"
|
|
406
|
+
if target.blob_dir:
|
|
407
|
+
if target.blob_dir.exists():
|
|
408
|
+
shutil.rmtree(target.blob_dir)
|
|
409
|
+
if blobs_src.exists():
|
|
410
|
+
shutil.copytree(blobs_src, target.blob_dir)
|
|
411
|
+
else:
|
|
412
|
+
target.blob_dir.mkdir(parents=True, exist_ok=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
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
__all__ = ["BrainArchivePaths", "EncryptedBrainArchive"]
|