ltcai 11.1.0 → 11.2.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 +53 -53
- package/docs/CHANGELOG.md +33 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
- package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/architecture.md +6 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/gates.py +125 -0
- package/lattice_brain/graph/fusion.py +35 -4
- package/lattice_brain/graph/projection.py +66 -8
- package/lattice_brain/graph/schema.py +9 -0
- package/lattice_brain/graph/store.py +9 -0
- package/lattice_brain/graph/vector_index/selector.py +32 -2
- package/lattice_brain/ingestion.py +175 -27
- package/lattice_brain/multimodal.py +525 -5
- package/lattice_brain/portability.py +169 -32
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/sealed_box.py +244 -0
- package/lattice_brain/synthesis.py +24 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +4 -0
- package/latticeai/api/chat.py +11 -0
- package/latticeai/api/chat_helpers.py +16 -3
- package/latticeai/api/chat_hybrid.py +32 -1
- package/latticeai/api/features.py +70 -0
- package/latticeai/api/local_files.py +102 -0
- package/latticeai/api/portability.py +39 -4
- package/latticeai/api/review_queue.py +126 -0
- package/latticeai/api/search.py +16 -2
- package/latticeai/core/agent.py +55 -2
- package/latticeai/core/config.py +4 -1
- package/latticeai/core/context_builder.py +6 -3
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/messages.py +143 -0
- package/latticeai/core/model_compat.py +73 -2
- package/latticeai/core/workspace_os_constants.py +1 -1
- package/latticeai/models/model_providers.py +12 -4
- package/latticeai/runtime/build_phases.py +28 -0
- package/latticeai/runtime/chat_wiring.py +4 -0
- package/latticeai/runtime/feature_toggle_wiring.py +163 -0
- package/latticeai/runtime/router_registration.py +11 -0
- package/latticeai/services/app_context.py +8 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_intelligence.py +22 -2
- package/latticeai/services/brain_intelligence.py +123 -7
- package/latticeai/services/command_center.py +10 -4
- package/latticeai/services/feature_toggles.py +502 -0
- package/latticeai/services/folder_watch.py +122 -1
- package/latticeai/services/hybrid_chat.py +56 -5
- package/latticeai/services/interop_bridges.py +978 -0
- package/latticeai/services/model_capability_registry.py +434 -261
- package/latticeai/services/model_catalog.py +95 -61
- package/latticeai/services/model_recommendation.py +18 -11
- package/latticeai/services/model_runtime.py +1 -1
- package/latticeai/services/multimodal_ports.py +26 -1
- package/latticeai/services/obsidian_bridge.py +16 -25
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +149 -2
- package/latticeai/services/tool_dispatch.py +4 -0
- package/latticeai/setup/auto_setup.py +27 -30
- package/latticeai/setup/wizard.py +77 -44
- package/package.json +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/check_server_i18n.mjs +1 -0
- package/scripts/release_screen_claims.json +13 -0
- package/scripts/verify_hf_model_registry.py +253 -218
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +37 -37
- package/static/app/assets/{Act-D0HWqtn0.js → Act-AWf0SAKp.js} +1 -1
- package/static/app/assets/{AdminConsole-D-QDW-A4.js → AdminConsole-D0u8Tiyj.js} +1 -1
- package/static/app/assets/{Brain-CzCsI1mi.js → Brain-tuhI4sOC.js} +1 -1
- package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
- package/static/app/assets/{BrainSignals-2dHQNkns.js → BrainSignals-jMYgQ2Ar.js} +1 -1
- package/static/app/assets/{Capture-CT8v1StE.js → Capture-CqOSzyPr.js} +1 -1
- package/static/app/assets/{CommandPalette-DoLXC2KH.js → CommandPalette-DC0Bzh-I.js} +1 -1
- package/static/app/assets/{Library-DDoxFE5c.js → Library-CX-bbhmK.js} +1 -1
- package/static/app/assets/{LivingBrain-BXMWIK_2.js → LivingBrain-DBwhto14.js} +1 -1
- package/static/app/assets/{ProductFlow-DOYf7JIs.js → ProductFlow-BHA2cfKI.js} +1 -1
- package/static/app/assets/{ReviewCard-COQsqidK.js → ReviewCard-BUhCKRNM.js} +1 -1
- package/static/app/assets/{System-BRllvYXd.js → System-Bu2t5hn1.js} +1 -1
- package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
- package/static/app/assets/{bot-4BvN07ux.js → bot-Cia42c2h.js} +1 -1
- package/static/app/assets/brain-DJMoqrwx.js +1 -0
- package/static/app/assets/{button-CDjtnAoU.js → button-2j2Ijzgq.js} +1 -1
- package/static/app/assets/{circle-pause-D_RMn7tp.js → circle-pause-BEFeWpVW.js} +1 -1
- package/static/app/assets/{circle-play-B5OpB8ae.js → circle-play-ujXMcHxl.js} +1 -1
- package/static/app/assets/{cpu-BIlWInHf.js → cpu-k4awryFq.js} +1 -1
- package/static/app/assets/{download-BtjXfL3z.js → download-DFbLJ_ig.js} +1 -1
- package/static/app/assets/{folder-open-DefMpxI2.js → folder-open-7y_b6xkM.js} +1 -1
- package/static/app/assets/{hard-drive-BQ8NZVkw.js → hard-drive-Bidh02Kr.js} +1 -1
- package/static/app/assets/{index-0AvoEBzJ.js → index-BpYkzcVm.js} +3 -3
- package/static/app/assets/{index-vtEfYvQY.css → index-DwDl9-8Y.css} +1 -1
- package/static/app/assets/{input-B_5ZJ9oy.js → input-DSlJJxRs.js} +1 -1
- package/static/app/assets/{permissionCopy-BqZ5tsgL.js → permissionCopy-Bpb83Hx9.js} +1 -1
- package/static/app/assets/{primitives-CVwew78r.js → primitives-BCx6TvfG.js} +1 -1
- package/static/app/assets/search-Cgy8cCFJ.js +1 -0
- package/static/app/assets/{share-2-D5zg_0fY.js → share-2-BH1M-WNi.js} +1 -1
- package/static/app/assets/{shield-alert-B5pZzkUb.js → shield-alert-BlKdBXcG.js} +1 -1
- package/static/app/assets/{textarea-nEVIweKY.js → textarea-CCWbUfFB.js} +1 -1
- package/static/app/assets/{useFocusTrap-Cm99AHlz.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
- package/static/app/assets/{useQuery-Dm__N6bL.js → useQuery-CXQiwbVT.js} +1 -1
- package/static/app/assets/{utils-DcDMoZIe.js → utils-zqPZJxdx.js} +2 -2
- package/static/app/assets/{workspace-LtRRSKTf.js → workspace-DXTihhfU.js} +1 -1
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/BrainHome-Btns-_TA.js +0 -2
- package/static/app/assets/arrow-left-DnyMzss-.js +0 -1
- package/static/app/assets/brain-uMb_5hnO.js +0 -1
- package/static/app/assets/search-DkhnOKZt.js +0 -1
|
@@ -38,7 +38,14 @@ from .archive import (
|
|
|
38
38
|
EncryptedBrainArchive,
|
|
39
39
|
_derive_key,
|
|
40
40
|
)
|
|
41
|
+
from .gates import FeatureGate
|
|
41
42
|
from .quiet import quiet
|
|
43
|
+
from .sealed_box import (
|
|
44
|
+
SEALED_BOX_ALGORITHM,
|
|
45
|
+
load_recipient_identity,
|
|
46
|
+
public_key_fingerprint,
|
|
47
|
+
seal,
|
|
48
|
+
)
|
|
42
49
|
from .storage import (
|
|
43
50
|
DockerPostgresWizard,
|
|
44
51
|
PostgresEngine,
|
|
@@ -64,6 +71,9 @@ SUBGRAPH_ARCHIVE_FORMAT = "latticebrain.subgraph"
|
|
|
64
71
|
#: Brain is a network act, and local-first means those are never on by default.
|
|
65
72
|
BRAIN_NETWORK_ENV = "LATTICEAI_BRAIN_NETWORK"
|
|
66
73
|
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
|
74
|
+
#: How a bundle can be encrypted. ``passphrase`` needs a secret agreed out of
|
|
75
|
+
#: band; ``recipient_public_key`` needs nothing secret to travel at all.
|
|
76
|
+
ENCRYPTION_MODES = ("passphrase", "recipient_public_key")
|
|
67
77
|
#: A received bundle becomes review items, never a merge. The cap keeps one
|
|
68
78
|
#: peer from flooding the inbox; what it dropped is reported, not hidden.
|
|
69
79
|
SUBGRAPH_PROPOSAL_CAP = 200
|
|
@@ -82,6 +92,17 @@ BRAIN_NETWORK_DISABLED_DETAIL = (
|
|
|
82
92
|
)
|
|
83
93
|
|
|
84
94
|
|
|
95
|
+
#: The share gate, resolved when it is asked. The environment variable is still
|
|
96
|
+
#: the default answer — an untouched install behaves exactly as it did — but a
|
|
97
|
+
#: settings surface can now bind a resolver and move it without a restart.
|
|
98
|
+
BRAIN_NETWORK_GATE = FeatureGate(
|
|
99
|
+
BRAIN_NETWORK_ENV,
|
|
100
|
+
default=False,
|
|
101
|
+
name="brain_network",
|
|
102
|
+
detail=BRAIN_NETWORK_DISABLED_DETAIL,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
85
106
|
class BrainNetworkDisabled(PermissionError):
|
|
86
107
|
"""Raised when a share surface is used while the opt-in flag is off."""
|
|
87
108
|
|
|
@@ -91,7 +112,7 @@ class BrainNetworkDisabled(PermissionError):
|
|
|
91
112
|
|
|
92
113
|
def brain_network_enabled() -> bool:
|
|
93
114
|
"""True only when the operator explicitly opted in (default: off)."""
|
|
94
|
-
return
|
|
115
|
+
return BRAIN_NETWORK_GATE.enabled()
|
|
95
116
|
|
|
96
117
|
|
|
97
118
|
def require_brain_network() -> None:
|
|
@@ -365,6 +386,41 @@ class KGPortabilityService:
|
|
|
365
386
|
# importable locally (origin='unsigned-legacy') — signatures are
|
|
366
387
|
# mandatory only on the Brain Network peer path.
|
|
367
388
|
self._identity = device_identity
|
|
389
|
+
# The receiving X25519 key is created lazily: a Brain that never asks
|
|
390
|
+
# anyone to seal anything to it should not be minting key material on
|
|
391
|
+
# every startup (v11.2.0).
|
|
392
|
+
self._recipient: Any = None
|
|
393
|
+
self._recipient_loaded = False
|
|
394
|
+
|
|
395
|
+
# ── recipient key (v11.2.0 sealed-box receipt) ───────────────────────────
|
|
396
|
+
def _recipient_identity(self) -> Any:
|
|
397
|
+
"""This Brain's X25519 receiving key, created on first use.
|
|
398
|
+
|
|
399
|
+
``None`` when the data directory cannot hold one — reported as a state
|
|
400
|
+
by :meth:`recipient_key`, never raised into a status read.
|
|
401
|
+
"""
|
|
402
|
+
if not self._recipient_loaded:
|
|
403
|
+
self._recipient = load_recipient_identity(self._data_dir)
|
|
404
|
+
self._recipient_loaded = True
|
|
405
|
+
return self._recipient
|
|
406
|
+
|
|
407
|
+
def recipient_key(self) -> Dict[str, Any]:
|
|
408
|
+
"""The public half to hand a sender, so they can seal a bundle to us.
|
|
409
|
+
|
|
410
|
+
Publishing this is safe by construction: a public key encrypts, it does
|
|
411
|
+
not decrypt, and it says nothing about what this Brain contains.
|
|
412
|
+
"""
|
|
413
|
+
require_brain_network()
|
|
414
|
+
identity = self._recipient_identity()
|
|
415
|
+
if identity is None:
|
|
416
|
+
return {
|
|
417
|
+
"available": False,
|
|
418
|
+
"detail": (
|
|
419
|
+
"a receiving key could not be created in the data directory; "
|
|
420
|
+
"sealed bundles cannot be opened on this machine"
|
|
421
|
+
),
|
|
422
|
+
}
|
|
423
|
+
return {"available": True, **identity.describe()}
|
|
368
424
|
|
|
369
425
|
def available(self) -> bool:
|
|
370
426
|
return self._enable and self._kg is not None
|
|
@@ -469,11 +525,14 @@ class KGPortabilityService:
|
|
|
469
525
|
"signing": self._identity is not None,
|
|
470
526
|
"device": self._share_device(),
|
|
471
527
|
"proposal_cap": SUBGRAPH_PROPOSAL_CAP,
|
|
472
|
-
# Stated rather than implied:
|
|
473
|
-
# and encrypted with a shared passphrase
|
|
474
|
-
# recipient's public key
|
|
475
|
-
|
|
476
|
-
"
|
|
528
|
+
# Stated rather than implied: a bundle is always signed by this
|
|
529
|
+
# device, and encrypted either with a shared passphrase or — since
|
|
530
|
+
# 11.2.0 — to the recipient's own public key, so nothing secret has
|
|
531
|
+
# to travel ahead of it.
|
|
532
|
+
"encryption": list(ENCRYPTION_MODES),
|
|
533
|
+
"recipient_public_key_encryption": True,
|
|
534
|
+
"sealed_box_algorithm": SEALED_BOX_ALGORITHM,
|
|
535
|
+
"gate": BRAIN_NETWORK_GATE.describe(),
|
|
477
536
|
"detail": None if enabled else BRAIN_NETWORK_DISABLED_DETAIL,
|
|
478
537
|
}
|
|
479
538
|
|
|
@@ -607,17 +666,28 @@ class KGPortabilityService:
|
|
|
607
666
|
self,
|
|
608
667
|
dest_path=None,
|
|
609
668
|
*,
|
|
610
|
-
passphrase: str,
|
|
669
|
+
passphrase: Optional[str] = None,
|
|
670
|
+
recipient_public_key: Optional[str] = None,
|
|
611
671
|
**selection: Any,
|
|
612
672
|
) -> Dict[str, Any]:
|
|
613
673
|
"""Write the signed subgraph as an encrypted ``.latticebrain`` file.
|
|
614
674
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
675
|
+
Exactly one recipient mechanism, named explicitly:
|
|
676
|
+
|
|
677
|
+
* ``passphrase`` — the 11.1.0 mechanism, identical to the whole-Brain
|
|
678
|
+
archive (PBKDF2-SHA256 → AES-256-GCM). Simple, and it means a secret
|
|
679
|
+
has to reach the receiver through some other channel first.
|
|
680
|
+
* ``recipient_public_key`` — an X25519 sealed box (11.2.0). The
|
|
681
|
+
receiver publishes a public key; nothing secret travels at all.
|
|
682
|
+
|
|
683
|
+
Supplying both is refused rather than silently preferring one: which
|
|
684
|
+
key opens a bundle is not a detail to guess at. A different ``format``
|
|
685
|
+
from the whole-Brain archive keeps the two from ever being confused,
|
|
686
|
+
and the header and signature stay *outside* the ciphertext on purpose —
|
|
687
|
+
a recipient can see who sent a bundle, and how big it is, before
|
|
688
|
+
deciding to open it.
|
|
620
689
|
"""
|
|
690
|
+
mode = self._encryption_mode(passphrase, recipient_public_key)
|
|
621
691
|
artifact = self.export_subgraph(**selection)
|
|
622
692
|
self._exports_dir.mkdir(parents=True, exist_ok=True)
|
|
623
693
|
dest = Path(dest_path) if dest_path else self._exports_dir / f"subgraph-{_stamp()}.latticebrain"
|
|
@@ -625,36 +695,77 @@ class KGPortabilityService:
|
|
|
625
695
|
dest = dest.with_suffix(".latticebrain")
|
|
626
696
|
body = {key: value for key, value in artifact.items() if key not in {"header", "signature"}}
|
|
627
697
|
plaintext = json.dumps(body, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
628
|
-
|
|
629
|
-
nonce = os.urandom(12)
|
|
630
|
-
ciphertext = AESGCM(_derive_key(passphrase, salt)).encrypt(nonce, plaintext, None)
|
|
631
|
-
envelope = {
|
|
698
|
+
envelope: Dict[str, Any] = {
|
|
632
699
|
"format": SUBGRAPH_ARCHIVE_FORMAT,
|
|
633
700
|
"format_version": SUBGRAPH_FORMAT_VERSION,
|
|
634
701
|
"created_at": utc_now_iso(),
|
|
635
|
-
"
|
|
636
|
-
"name": "PBKDF2HMAC-SHA256",
|
|
637
|
-
"iterations": KDF_ITERATIONS,
|
|
638
|
-
"salt": base64.b64encode(salt).decode("ascii"),
|
|
639
|
-
},
|
|
640
|
-
"cipher": {"name": "AES-256-GCM", "nonce": base64.b64encode(nonce).decode("ascii")},
|
|
702
|
+
"encryption": mode,
|
|
641
703
|
"header": artifact["header"],
|
|
642
704
|
"signature": artifact["signature"],
|
|
643
|
-
"payload": base64.b64encode(ciphertext).decode("ascii"),
|
|
644
705
|
}
|
|
706
|
+
recipient: Optional[str] = None
|
|
707
|
+
if mode == "recipient_public_key":
|
|
708
|
+
sealed = seal(plaintext, recipient_public_key=str(recipient_public_key))
|
|
709
|
+
recipient = str(sealed["recipient_fingerprint"])
|
|
710
|
+
envelope["sealed_box"] = sealed
|
|
711
|
+
else:
|
|
712
|
+
salt = os.urandom(16)
|
|
713
|
+
nonce = os.urandom(12)
|
|
714
|
+
ciphertext = AESGCM(_derive_key(str(passphrase), salt)).encrypt(nonce, plaintext, None)
|
|
715
|
+
envelope["kdf"] = {
|
|
716
|
+
"name": "PBKDF2HMAC-SHA256",
|
|
717
|
+
"iterations": KDF_ITERATIONS,
|
|
718
|
+
"salt": base64.b64encode(salt).decode("ascii"),
|
|
719
|
+
}
|
|
720
|
+
envelope["cipher"] = {
|
|
721
|
+
"name": "AES-256-GCM",
|
|
722
|
+
"nonce": base64.b64encode(nonce).decode("ascii"),
|
|
723
|
+
}
|
|
724
|
+
envelope["payload"] = base64.b64encode(ciphertext).decode("ascii")
|
|
645
725
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
646
726
|
dest.write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
647
727
|
return {
|
|
648
728
|
"path": str(dest),
|
|
649
729
|
"bytes": dest.stat().st_size,
|
|
650
730
|
"encrypted": True,
|
|
731
|
+
"encryption": mode,
|
|
732
|
+
"recipient_fingerprint": recipient,
|
|
651
733
|
"format": SUBGRAPH_ARCHIVE_FORMAT,
|
|
652
734
|
"header": artifact["header"],
|
|
653
735
|
"counts": artifact["header"]["counts"],
|
|
654
736
|
}
|
|
655
737
|
|
|
656
|
-
|
|
657
|
-
|
|
738
|
+
@staticmethod
|
|
739
|
+
def _encryption_mode(
|
|
740
|
+
passphrase: Optional[str], recipient_public_key: Optional[str]
|
|
741
|
+
) -> str:
|
|
742
|
+
"""``passphrase`` | ``recipient_public_key`` — exactly one, validated."""
|
|
743
|
+
has_passphrase = bool(str(passphrase or "").strip())
|
|
744
|
+
wanted_key = str(recipient_public_key or "").strip()
|
|
745
|
+
if has_passphrase and wanted_key:
|
|
746
|
+
raise ValueError(
|
|
747
|
+
"Choose one: a passphrase, or a recipient public key. "
|
|
748
|
+
"A bundle sealed both ways would hide which key really opens it."
|
|
749
|
+
)
|
|
750
|
+
if wanted_key:
|
|
751
|
+
public_key_fingerprint(wanted_key) # validates; raises on garbage
|
|
752
|
+
return "recipient_public_key"
|
|
753
|
+
if has_passphrase:
|
|
754
|
+
return "passphrase"
|
|
755
|
+
raise ValueError(
|
|
756
|
+
"A subgraph bundle must be encrypted: supply a passphrase or the "
|
|
757
|
+
"recipient's public key."
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def read_subgraph_archive(
|
|
761
|
+
self, path, *, passphrase: Optional[str] = None
|
|
762
|
+
) -> Dict[str, Any]:
|
|
763
|
+
"""Decrypt an encrypted subgraph bundle back into a signed artifact.
|
|
764
|
+
|
|
765
|
+
The envelope says how it was sealed, so the reader never has to guess:
|
|
766
|
+
a passphrase bundle needs the passphrase, a sealed-box bundle needs
|
|
767
|
+
this Brain's own receiving key and no passphrase at all.
|
|
768
|
+
"""
|
|
658
769
|
require_brain_network()
|
|
659
770
|
source = Path(path)
|
|
660
771
|
if not source.exists():
|
|
@@ -665,6 +776,36 @@ class KGPortabilityService:
|
|
|
665
776
|
raise ValueError("Subgraph bundle is not valid JSON.") from exc
|
|
666
777
|
if envelope.get("format") != SUBGRAPH_ARCHIVE_FORMAT:
|
|
667
778
|
raise ValueError("Not a .latticebrain subgraph bundle.")
|
|
779
|
+
if envelope.get("encryption") == "recipient_public_key":
|
|
780
|
+
plaintext = self._unseal_bundle(envelope)
|
|
781
|
+
else:
|
|
782
|
+
plaintext = self._decrypt_with_passphrase(envelope, passphrase)
|
|
783
|
+
body = json.loads(plaintext.decode("utf-8"))
|
|
784
|
+
return {
|
|
785
|
+
"header": envelope.get("header") or {},
|
|
786
|
+
**body,
|
|
787
|
+
"signature": envelope.get("signature") or {},
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
def _unseal_bundle(self, envelope: Dict[str, Any]) -> bytes:
|
|
791
|
+
"""Open a sealed box with this Brain's receiving key, or say why not."""
|
|
792
|
+
identity = self._recipient_identity()
|
|
793
|
+
if identity is None:
|
|
794
|
+
raise ValueError(
|
|
795
|
+
"This bundle is sealed to a public key, but no receiving key "
|
|
796
|
+
"could be loaded on this machine."
|
|
797
|
+
)
|
|
798
|
+
block = envelope.get("sealed_box")
|
|
799
|
+
if not isinstance(block, dict):
|
|
800
|
+
raise ValueError("Subgraph bundle is missing encryption metadata.")
|
|
801
|
+
return bytes(identity.unseal(block))
|
|
802
|
+
|
|
803
|
+
@staticmethod
|
|
804
|
+
def _decrypt_with_passphrase(
|
|
805
|
+
envelope: Dict[str, Any], passphrase: Optional[str]
|
|
806
|
+
) -> bytes:
|
|
807
|
+
if not str(passphrase or "").strip():
|
|
808
|
+
raise ValueError("This bundle is passphrase-encrypted; a passphrase is required.")
|
|
668
809
|
try:
|
|
669
810
|
salt = base64.b64decode(envelope["kdf"]["salt"])
|
|
670
811
|
nonce = base64.b64decode(envelope["cipher"]["nonce"])
|
|
@@ -672,17 +813,13 @@ class KGPortabilityService:
|
|
|
672
813
|
except (KeyError, TypeError, ValueError) as exc:
|
|
673
814
|
raise ValueError("Subgraph bundle is missing encryption metadata.") from exc
|
|
674
815
|
try:
|
|
675
|
-
|
|
816
|
+
return bytes(
|
|
817
|
+
AESGCM(_derive_key(str(passphrase), salt)).decrypt(nonce, ciphertext, None)
|
|
818
|
+
)
|
|
676
819
|
except InvalidTag as exc:
|
|
677
820
|
raise ValueError(
|
|
678
821
|
"Subgraph decryption failed; the passphrase or the bundle is invalid."
|
|
679
822
|
) from exc
|
|
680
|
-
body = json.loads(plaintext.decode("utf-8"))
|
|
681
|
-
return {
|
|
682
|
-
"header": envelope.get("header") or {},
|
|
683
|
-
**body,
|
|
684
|
-
"signature": envelope.get("signature") or {},
|
|
685
|
-
}
|
|
686
823
|
|
|
687
824
|
def verify_subgraph(self, artifact: Dict[str, Any]) -> Dict[str, Any]:
|
|
688
825
|
"""Signature + payload-digest verdict for a received bundle.
|
|
@@ -49,7 +49,7 @@ from typing import Any, Callable, Dict, List, Optional
|
|
|
49
49
|
from ..utils import now_iso as _now
|
|
50
50
|
from .contracts import multi_agent_contract
|
|
51
51
|
|
|
52
|
-
MULTI_AGENT_VERSION = "11.
|
|
52
|
+
MULTI_AGENT_VERSION = "11.2.0"
|
|
53
53
|
|
|
54
54
|
AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
|
|
55
55
|
CORE_PIPELINE = ("planner", "executor", "reviewer")
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Sealing a bundle to a recipient's public key (v11.2.0).
|
|
2
|
+
|
|
3
|
+
Through 11.1.0 a shared subgraph was encrypted with a passphrase, which means
|
|
4
|
+
the sender and the receiver must first agree on a secret over some *other*
|
|
5
|
+
channel — and whatever that channel is, the passphrase is now in it. This
|
|
6
|
+
module removes that step: the receiver publishes an X25519 public key, the
|
|
7
|
+
sender seals a bundle to it, and only the holder of the matching private key
|
|
8
|
+
can open it. Nothing secret ever travels.
|
|
9
|
+
|
|
10
|
+
The construction is the standard sealed box, assembled from primitives
|
|
11
|
+
``cryptography`` already ships (it is a core dependency; nothing new is added):
|
|
12
|
+
|
|
13
|
+
1. the sender generates a **single-use ephemeral X25519 keypair**;
|
|
14
|
+
2. ``ephemeral_private × recipient_public`` yields a shared secret only the two
|
|
15
|
+
of them can compute;
|
|
16
|
+
3. **HKDF-SHA256** stretches that secret into a 256-bit key, with both public
|
|
17
|
+
keys mixed into ``info`` so a key derived for one recipient can never be
|
|
18
|
+
reused for another;
|
|
19
|
+
4. **AES-256-GCM** encrypts the payload under it.
|
|
20
|
+
|
|
21
|
+
Forward secrecy comes free: the ephemeral private key is discarded the moment
|
|
22
|
+
the box is sealed, so a later compromise of the recipient's long-term key still
|
|
23
|
+
does not decrypt an already-sent bundle — nothing else in the envelope can
|
|
24
|
+
reconstruct the shared secret.
|
|
25
|
+
|
|
26
|
+
The sender's Ed25519 signature over the bundle header is **untouched** by all
|
|
27
|
+
of this. Signing says who wrote it; sealing says who may read it. Collapsing
|
|
28
|
+
the two into one keypair is the usual way to end up with neither.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import base64
|
|
34
|
+
import hashlib
|
|
35
|
+
import os
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Dict, Optional
|
|
38
|
+
|
|
39
|
+
from cryptography.exceptions import InvalidTag
|
|
40
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
41
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
|
42
|
+
X25519PrivateKey,
|
|
43
|
+
X25519PublicKey,
|
|
44
|
+
)
|
|
45
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
46
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
47
|
+
|
|
48
|
+
#: Named in the envelope so a future construction can be told apart from this
|
|
49
|
+
#: one instead of being silently mis-decrypted.
|
|
50
|
+
SEALED_BOX_ALGORITHM = "x25519-hkdf-sha256-aes256gcm"
|
|
51
|
+
#: Domain separation for the KDF — this key schedule is for subgraph bundles
|
|
52
|
+
#: and nothing else.
|
|
53
|
+
HKDF_INFO_PREFIX = b"latticeai.subgraph.sealed-box.v1"
|
|
54
|
+
KEY_BYTES = 32
|
|
55
|
+
NONCE_BYTES = 12
|
|
56
|
+
SALT_BYTES = 16
|
|
57
|
+
#: Filename of the recipient key under the data directory (0600, like the
|
|
58
|
+
#: device identity's key file).
|
|
59
|
+
RECIPIENT_KEY_FILENAME = "subgraph_recipient.key"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _b64(data: bytes) -> str:
|
|
63
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _unb64(text: str) -> bytes:
|
|
67
|
+
padded = str(text) + "=" * (-len(str(text)) % 4)
|
|
68
|
+
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _raw_public(key: X25519PublicKey) -> bytes:
|
|
72
|
+
return key.public_bytes(
|
|
73
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def public_key_fingerprint(public_key_b64: str) -> str:
|
|
78
|
+
"""Human-comparable fingerprint of an X25519 public key.
|
|
79
|
+
|
|
80
|
+
Raises ``ValueError`` when the input is not a usable key — callers use this
|
|
81
|
+
as the validation gate before sealing anything to a pasted string.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
raw = _unb64(public_key_b64)
|
|
85
|
+
X25519PublicKey.from_public_bytes(raw)
|
|
86
|
+
except Exception as exc: # noqa: BLE001 — any parse failure is one answer
|
|
87
|
+
raise ValueError(f"not a valid X25519 public key: {exc}") from exc
|
|
88
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
89
|
+
return ":".join(digest[i : i + 4] for i in range(0, 16, 4))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _derive(shared: bytes, salt: bytes, ephemeral: bytes, recipient: bytes) -> bytes:
|
|
93
|
+
return HKDF(
|
|
94
|
+
algorithm=hashes.SHA256(),
|
|
95
|
+
length=KEY_BYTES,
|
|
96
|
+
salt=salt,
|
|
97
|
+
info=HKDF_INFO_PREFIX + b"|" + ephemeral + b"|" + recipient,
|
|
98
|
+
).derive(shared)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def seal(plaintext: bytes, *, recipient_public_key: str) -> Dict[str, Any]:
|
|
102
|
+
"""Encrypt ``plaintext`` so only the recipient's private key can open it.
|
|
103
|
+
|
|
104
|
+
Returns the JSON-safe crypto block that goes in the bundle envelope. The
|
|
105
|
+
recipient's own public key is echoed back into it so a receiver holding
|
|
106
|
+
several keys can tell at a glance whether this box was meant for them —
|
|
107
|
+
without trying to decrypt it, and without the sender having to be asked.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
recipient_raw = _unb64(recipient_public_key)
|
|
111
|
+
peer = X25519PublicKey.from_public_bytes(recipient_raw)
|
|
112
|
+
except Exception as exc: # noqa: BLE001 — a bad key is a caller error, said plainly
|
|
113
|
+
raise ValueError(f"not a valid X25519 public key: {exc}") from exc
|
|
114
|
+
ephemeral = X25519PrivateKey.generate()
|
|
115
|
+
ephemeral_raw = _raw_public(ephemeral.public_key())
|
|
116
|
+
salt = os.urandom(SALT_BYTES)
|
|
117
|
+
nonce = os.urandom(NONCE_BYTES)
|
|
118
|
+
key = _derive(ephemeral.exchange(peer), salt, ephemeral_raw, recipient_raw)
|
|
119
|
+
ciphertext = AESGCM(key).encrypt(nonce, plaintext, None)
|
|
120
|
+
return {
|
|
121
|
+
"algorithm": SEALED_BOX_ALGORITHM,
|
|
122
|
+
"ephemeral_public_key": _b64(ephemeral_raw),
|
|
123
|
+
"recipient_public_key": _b64(recipient_raw),
|
|
124
|
+
"recipient_fingerprint": public_key_fingerprint(recipient_public_key),
|
|
125
|
+
"salt": _b64(salt),
|
|
126
|
+
"nonce": _b64(nonce),
|
|
127
|
+
"ciphertext": _b64(ciphertext),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def unseal(block: Dict[str, Any], private_key: X25519PrivateKey) -> bytes:
|
|
132
|
+
"""Open a sealed box with the recipient's private key.
|
|
133
|
+
|
|
134
|
+
Fail-closed and specific: an unknown algorithm, a missing field, a box
|
|
135
|
+
addressed to a different key, and a failed tag check each say which one
|
|
136
|
+
happened rather than collapsing into "decryption failed".
|
|
137
|
+
"""
|
|
138
|
+
algorithm = str((block or {}).get("algorithm") or "")
|
|
139
|
+
if algorithm != SEALED_BOX_ALGORITHM:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"unsupported sealed-box algorithm: {algorithm or 'missing'}"
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
ephemeral_raw = _unb64(block["ephemeral_public_key"])
|
|
145
|
+
salt = _unb64(block["salt"])
|
|
146
|
+
nonce = _unb64(block["nonce"])
|
|
147
|
+
ciphertext = _unb64(block["ciphertext"])
|
|
148
|
+
peer = X25519PublicKey.from_public_bytes(ephemeral_raw)
|
|
149
|
+
except Exception as exc: # noqa: BLE001 — a malformed envelope is one answer
|
|
150
|
+
raise ValueError(f"sealed bundle is missing encryption metadata: {exc}") from exc
|
|
151
|
+
recipient_raw = _raw_public(private_key.public_key())
|
|
152
|
+
addressed = str(block.get("recipient_public_key") or "")
|
|
153
|
+
if addressed and _unb64(addressed) != recipient_raw:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"this bundle was sealed to a different recipient key "
|
|
156
|
+
f"({block.get('recipient_fingerprint') or 'unknown fingerprint'})."
|
|
157
|
+
)
|
|
158
|
+
key = _derive(private_key.exchange(peer), salt, ephemeral_raw, recipient_raw)
|
|
159
|
+
try:
|
|
160
|
+
return bytes(AESGCM(key).decrypt(nonce, ciphertext, None))
|
|
161
|
+
except InvalidTag as exc:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
"Sealed bundle decryption failed; the recipient key or the bundle is invalid."
|
|
164
|
+
) from exc
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class RecipientIdentity:
|
|
168
|
+
"""This Brain's X25519 receiving keypair — loads or creates, file-backed.
|
|
169
|
+
|
|
170
|
+
Deliberately *not* the device's Ed25519 identity. That key signs, and a
|
|
171
|
+
signing key that also decrypts is a key whose compromise costs twice. The
|
|
172
|
+
private half lives in a 0600 file under the data directory and never leaves
|
|
173
|
+
the machine; :meth:`describe` reports exactly that rather than implying a
|
|
174
|
+
keychain this class does not use.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, data_dir: Any) -> None:
|
|
178
|
+
self._data_dir = Path(data_dir)
|
|
179
|
+
self._key_file = self._data_dir / RECIPIENT_KEY_FILENAME
|
|
180
|
+
self._private = self._load_or_create()
|
|
181
|
+
|
|
182
|
+
def _load_or_create(self) -> X25519PrivateKey:
|
|
183
|
+
if self._key_file.exists():
|
|
184
|
+
return X25519PrivateKey.from_private_bytes(
|
|
185
|
+
_unb64(self._key_file.read_text(encoding="utf-8").strip())
|
|
186
|
+
)
|
|
187
|
+
key = X25519PrivateKey.generate()
|
|
188
|
+
raw = key.private_bytes(
|
|
189
|
+
serialization.Encoding.Raw,
|
|
190
|
+
serialization.PrivateFormat.Raw,
|
|
191
|
+
serialization.NoEncryption(),
|
|
192
|
+
)
|
|
193
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
self._key_file.write_text(_b64(raw), encoding="utf-8")
|
|
195
|
+
os.chmod(self._key_file, 0o600)
|
|
196
|
+
return key
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def public_key_b64(self) -> str:
|
|
200
|
+
return _b64(_raw_public(self._private.public_key()))
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def fingerprint(self) -> str:
|
|
204
|
+
return public_key_fingerprint(self.public_key_b64)
|
|
205
|
+
|
|
206
|
+
def describe(self) -> Dict[str, Any]:
|
|
207
|
+
return {
|
|
208
|
+
"public_key": self.public_key_b64,
|
|
209
|
+
"fingerprint": self.fingerprint,
|
|
210
|
+
"algorithm": SEALED_BOX_ALGORITHM,
|
|
211
|
+
"storage": "file",
|
|
212
|
+
"path": str(self._key_file),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
def seal_to_self(self, plaintext: bytes) -> Dict[str, Any]:
|
|
216
|
+
"""Seal to this Brain's own key — the honest way to test a round trip."""
|
|
217
|
+
return seal(plaintext, recipient_public_key=self.public_key_b64)
|
|
218
|
+
|
|
219
|
+
def unseal(self, block: Dict[str, Any]) -> bytes:
|
|
220
|
+
return unseal(block, self._private)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def load_recipient_identity(data_dir: Any) -> Optional[RecipientIdentity]:
|
|
224
|
+
"""Best-effort recipient key, or ``None`` when the directory is unusable.
|
|
225
|
+
|
|
226
|
+
Sharing is opt-in and a missing key is a *state*, not a crash: the status
|
|
227
|
+
surface reports "no receiving key" rather than failing a status read.
|
|
228
|
+
"""
|
|
229
|
+
try:
|
|
230
|
+
return RecipientIdentity(data_dir)
|
|
231
|
+
except (OSError, ValueError): # noqa: BLE001 — unwritable dir / corrupt key file
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
__all__ = [
|
|
236
|
+
"HKDF_INFO_PREFIX",
|
|
237
|
+
"RECIPIENT_KEY_FILENAME",
|
|
238
|
+
"SEALED_BOX_ALGORITHM",
|
|
239
|
+
"RecipientIdentity",
|
|
240
|
+
"load_recipient_identity",
|
|
241
|
+
"public_key_fingerprint",
|
|
242
|
+
"seal",
|
|
243
|
+
"unseal",
|
|
244
|
+
]
|
|
@@ -35,6 +35,7 @@ import os
|
|
|
35
35
|
from datetime import datetime, timezone
|
|
36
36
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple
|
|
37
37
|
|
|
38
|
+
from .gates import FeatureGate
|
|
38
39
|
from .graph.proactive import ProactiveBrain
|
|
39
40
|
from .graph.schema import KGStoreV2
|
|
40
41
|
from .quality import content_signature
|
|
@@ -60,6 +61,19 @@ CONTRADICTION_RESOLUTIONS = ("keep_old", "replace", "keep_both_temporal")
|
|
|
60
61
|
SYNTHESIS_THRESHOLD_ENV = "LATTICEAI_SYNTHESIS_THRESHOLD"
|
|
61
62
|
DEFAULT_SYNTHESIS_THRESHOLD = 25
|
|
62
63
|
|
|
64
|
+
#: Whether the Brain is allowed to start a pass *by itself*. Default on — this
|
|
65
|
+
#: is the behaviour 11.1.0 shipped — and it governs only the automatic path
|
|
66
|
+
#: (:meth:`BrainSynthesizer.run_if_due`). An explicit run a person asked for is
|
|
67
|
+
#: never gated: turning "tidy up on its own" off means the Brain stops deciding
|
|
68
|
+
#: when, not that the button stops working.
|
|
69
|
+
SYNTHESIS_ENV = "LATTICEAI_SYNTHESIS"
|
|
70
|
+
SYNTHESIS_GATE = FeatureGate(
|
|
71
|
+
SYNTHESIS_ENV,
|
|
72
|
+
default=True,
|
|
73
|
+
name="synthesis",
|
|
74
|
+
detail="The Brain reviews accumulated material on its own and proposes tidy-ups.",
|
|
75
|
+
)
|
|
76
|
+
|
|
63
77
|
_MIN_SHARED_TOKENS = 3
|
|
64
78
|
_MIN_CLUSTER_MEMBERS = 3
|
|
65
79
|
_EDGE_SIMILARITY = 0.35
|
|
@@ -516,7 +530,14 @@ class BrainSynthesizer:
|
|
|
516
530
|
self, result: Any, *, workspace_id: Optional[str] = None,
|
|
517
531
|
user_email: Optional[str] = None,
|
|
518
532
|
) -> Optional[Dict[str, Any]]:
|
|
519
|
-
"""Run synthesis only when this ingest pushed the counter over.
|
|
533
|
+
"""Run synthesis only when this ingest pushed the counter over.
|
|
534
|
+
|
|
535
|
+
The gate is checked *before* the counter moves, so "off" means off: a
|
|
536
|
+
long import while the switch is down does not bank a pass that fires the
|
|
537
|
+
instant someone turns it back on.
|
|
538
|
+
"""
|
|
539
|
+
if not SYNTHESIS_GATE.enabled():
|
|
540
|
+
return None
|
|
520
541
|
if not self.observe_ingest(result):
|
|
521
542
|
return None
|
|
522
543
|
return self.run(workspace_id=workspace_id, user_email=user_email)
|
|
@@ -792,6 +813,8 @@ __all__ = [
|
|
|
792
813
|
"ContradictionResolutionError",
|
|
793
814
|
"EDGE_KIND",
|
|
794
815
|
"ProposalDesk",
|
|
816
|
+
"SYNTHESIS_ENV",
|
|
817
|
+
"SYNTHESIS_GATE",
|
|
795
818
|
"SYNTHESIS_REVIEW_SOURCE",
|
|
796
819
|
"SYNTHESIS_THRESHOLD_ENV",
|
|
797
820
|
"SynthesisTrigger",
|
package/latticeai/__init__.py
CHANGED
|
@@ -85,6 +85,10 @@ def create_brain_intelligence_router(
|
|
|
85
85
|
Fixed contract consumed by the frontend:
|
|
86
86
|
``{"status": "ready"|"pending"|"unavailable", "pending_items": int,
|
|
87
87
|
"total_items": int, "detail": str}``.
|
|
88
|
+
|
|
89
|
+
Additive since v11.2.0: ``breakdown`` splits the backlog into
|
|
90
|
+
``embedded``/``missing``/``stale``/``queued`` when the store can. The
|
|
91
|
+
four keys above are unchanged, so existing readers see no difference.
|
|
88
92
|
"""
|
|
89
93
|
user = require_user(request)
|
|
90
94
|
scope = gate_read(request)
|
package/latticeai/api/chat.py
CHANGED
|
@@ -111,6 +111,16 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
111
111
|
get_history_user=context.require("get_history_user"),
|
|
112
112
|
)
|
|
113
113
|
|
|
114
|
+
def review_sink() -> object:
|
|
115
|
+
"""The Review Center queue, resolved per request (may be absent).
|
|
116
|
+
|
|
117
|
+
Cloud-derived knowledge is staged here rather than written, so the
|
|
118
|
+
hybrid turn needs the live queue — which is wired after this router's
|
|
119
|
+
context is built, hence a provider rather than a captured value.
|
|
120
|
+
"""
|
|
121
|
+
provider = context.review_queue
|
|
122
|
+
return None if provider is None else provider()
|
|
123
|
+
|
|
114
124
|
def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
|
|
115
125
|
"""Mirror persisted web exchanges to an injected bridge, never echoing it."""
|
|
116
126
|
if context.on_chat_message is None or source == "telegram":
|
|
@@ -445,6 +455,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
445
455
|
chat_service=chat_service,
|
|
446
456
|
notify=notify_chat_message,
|
|
447
457
|
model_id=selected_model_id,
|
|
458
|
+
review_queue=review_sink(),
|
|
448
459
|
)
|
|
449
460
|
if hybrid_response is not None:
|
|
450
461
|
return hybrid_response
|