ltcai 11.0.1 → 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.
Files changed (140) hide show
  1. package/README.md +55 -43
  2. package/docs/CHANGELOG.md +61 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/FEATURE_AUDIT_v11.2.0.md +393 -0
  6. package/docs/LAYOUT_REBUILD_SPEC.md +9 -1
  7. package/docs/ONBOARDING.md +1 -1
  8. package/docs/OPERATIONS.md +1 -1
  9. package/docs/PERFORMANCE.md +71 -18
  10. package/docs/TRUST_MODEL.md +1 -1
  11. package/docs/WHY_LATTICE.md +1 -1
  12. package/docs/architecture.md +6 -2
  13. package/docs/kg-schema.md +1 -1
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/embeddings.py +12 -37
  16. package/lattice_brain/gates.py +125 -0
  17. package/lattice_brain/graph/discovery_index.py +30 -32
  18. package/lattice_brain/graph/fusion.py +35 -4
  19. package/lattice_brain/graph/image_vectors.py +230 -0
  20. package/lattice_brain/graph/ingest.py +11 -5
  21. package/lattice_brain/graph/projection.py +66 -8
  22. package/lattice_brain/graph/provenance.py +27 -2
  23. package/lattice_brain/graph/retrieval.py +113 -2
  24. package/lattice_brain/graph/retrieval_docgen.py +6 -6
  25. package/lattice_brain/graph/schema.py +18 -0
  26. package/lattice_brain/graph/store.py +9 -0
  27. package/lattice_brain/graph/vector_index/selector.py +32 -2
  28. package/lattice_brain/ingestion.py +363 -10
  29. package/lattice_brain/multimodal.py +1258 -0
  30. package/lattice_brain/portability.py +169 -32
  31. package/lattice_brain/runtime/multi_agent.py +1 -1
  32. package/lattice_brain/sealed_box.py +244 -0
  33. package/lattice_brain/self_model.py +77 -22
  34. package/lattice_brain/synthesis.py +24 -1
  35. package/latticeai/__init__.py +1 -1
  36. package/latticeai/api/brain_intelligence.py +4 -0
  37. package/latticeai/api/chat.py +11 -0
  38. package/latticeai/api/chat_helpers.py +16 -3
  39. package/latticeai/api/chat_hybrid.py +32 -1
  40. package/latticeai/api/features.py +70 -0
  41. package/latticeai/api/local_files.py +102 -0
  42. package/latticeai/api/memory.py +128 -1
  43. package/latticeai/api/portability.py +39 -4
  44. package/latticeai/api/review_queue.py +126 -0
  45. package/latticeai/api/search.py +16 -2
  46. package/latticeai/core/agent.py +59 -2
  47. package/latticeai/core/agent_prompts.py +66 -0
  48. package/latticeai/core/config.py +4 -1
  49. package/latticeai/core/context_builder.py +98 -11
  50. package/latticeai/core/embedding_providers.py +528 -0
  51. package/latticeai/core/legacy_compatibility.py +1 -1
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/messages.py +180 -0
  54. package/latticeai/core/model_compat.py +73 -2
  55. package/latticeai/core/workspace_os.py +43 -0
  56. package/latticeai/core/workspace_os_constants.py +1 -1
  57. package/latticeai/core/workspace_reorganization.py +335 -0
  58. package/latticeai/models/model_providers.py +12 -4
  59. package/latticeai/runtime/build_phases.py +33 -2
  60. package/latticeai/runtime/chat_wiring.py +4 -0
  61. package/latticeai/runtime/feature_toggle_wiring.py +163 -0
  62. package/latticeai/runtime/persistence_runtime.py +41 -4
  63. package/latticeai/runtime/router_registration.py +11 -0
  64. package/latticeai/runtime/runtime_context.py +1 -0
  65. package/latticeai/services/app_context.py +8 -0
  66. package/latticeai/services/architecture_readiness.py +1 -1
  67. package/latticeai/services/automation_intelligence.py +22 -2
  68. package/latticeai/services/brain_intelligence.py +123 -7
  69. package/latticeai/services/change_proposals.py +50 -10
  70. package/latticeai/services/command_center.py +10 -4
  71. package/latticeai/services/feature_toggles.py +502 -0
  72. package/latticeai/services/folder_watch.py +122 -1
  73. package/latticeai/services/hybrid_chat.py +56 -5
  74. package/latticeai/services/interop_bridges.py +978 -0
  75. package/latticeai/services/memory_service.py +34 -0
  76. package/latticeai/services/model_capability_registry.py +434 -261
  77. package/latticeai/services/model_catalog.py +95 -61
  78. package/latticeai/services/model_recommendation.py +18 -11
  79. package/latticeai/services/model_runtime.py +1 -1
  80. package/latticeai/services/multimodal_ports.py +112 -0
  81. package/latticeai/services/obsidian_bridge.py +16 -25
  82. package/latticeai/services/product_readiness.py +1 -1
  83. package/latticeai/services/search_service.py +149 -2
  84. package/latticeai/services/self_model_service.py +171 -0
  85. package/latticeai/services/tool_dispatch.py +4 -0
  86. package/latticeai/services/voice_capture.py +27 -1
  87. package/latticeai/setup/auto_setup.py +27 -30
  88. package/latticeai/setup/wizard.py +77 -44
  89. package/package.json +1 -1
  90. package/scripts/check_current_release_docs.mjs +1 -1
  91. package/scripts/check_server_i18n.mjs +1 -0
  92. package/scripts/release_screen_claims.json +22 -0
  93. package/scripts/verify_hf_model_registry.py +253 -218
  94. package/src-tauri/Cargo.lock +1 -1
  95. package/src-tauri/Cargo.toml +1 -1
  96. package/src-tauri/tauri.conf.json +1 -1
  97. package/static/app/asset-manifest.json +37 -37
  98. package/static/app/assets/{Act-D4zSxFR-.js → Act-AWf0SAKp.js} +1 -1
  99. package/static/app/assets/{AdminConsole-w5jBfPt2.js → AdminConsole-D0u8Tiyj.js} +1 -1
  100. package/static/app/assets/{Brain-C2EqQg74.js → Brain-tuhI4sOC.js} +1 -1
  101. package/static/app/assets/BrainHome-Ts7G_Ila.js +2 -0
  102. package/static/app/assets/BrainSignals-jMYgQ2Ar.js +1 -0
  103. package/static/app/assets/{Capture-DPqpGK8d.js → Capture-CqOSzyPr.js} +1 -1
  104. package/static/app/assets/{CommandPalette-CNf7h5fp.js → CommandPalette-DC0Bzh-I.js} +1 -1
  105. package/static/app/assets/{Library-BN0HYOfc.js → Library-CX-bbhmK.js} +1 -1
  106. package/static/app/assets/{LivingBrain-Dfq_wEDI.js → LivingBrain-DBwhto14.js} +1 -1
  107. package/static/app/assets/{ProductFlow-B-3O0rNV.js → ProductFlow-BHA2cfKI.js} +1 -1
  108. package/static/app/assets/{ReviewCard-gZ-tdqFM.js → ReviewCard-BUhCKRNM.js} +1 -1
  109. package/static/app/assets/{System-BElUcSSw.js → System-Bu2t5hn1.js} +1 -1
  110. package/static/app/assets/arrow-left-Dzwa5zRb.js +1 -0
  111. package/static/app/assets/{bot--qYHMtkP.js → bot-Cia42c2h.js} +1 -1
  112. package/static/app/assets/brain-DJMoqrwx.js +1 -0
  113. package/static/app/assets/{button-51Z3rsuv.js → button-2j2Ijzgq.js} +1 -1
  114. package/static/app/assets/{circle-pause-CMIiMaQl.js → circle-pause-BEFeWpVW.js} +1 -1
  115. package/static/app/assets/{circle-play-DZoO_cfG.js → circle-play-ujXMcHxl.js} +1 -1
  116. package/static/app/assets/{cpu-Bs6uc9W9.js → cpu-k4awryFq.js} +1 -1
  117. package/static/app/assets/{download-G-2olkWz.js → download-DFbLJ_ig.js} +1 -1
  118. package/static/app/assets/{folder-open-CTOspnmb.js → folder-open-7y_b6xkM.js} +1 -1
  119. package/static/app/assets/{hard-drive-CewHWJhn.js → hard-drive-Bidh02Kr.js} +1 -1
  120. package/static/app/assets/{index-D7Rr-J2Y.js → index-BpYkzcVm.js} +3 -3
  121. package/static/app/assets/index-DwDl9-8Y.css +2 -0
  122. package/static/app/assets/{input-D4w_BZWl.js → input-DSlJJxRs.js} +1 -1
  123. package/static/app/assets/{permissionCopy-CosBEXAZ.js → permissionCopy-Bpb83Hx9.js} +1 -1
  124. package/static/app/assets/{primitives-d0g9pvzS.js → primitives-BCx6TvfG.js} +1 -1
  125. package/static/app/assets/search-Cgy8cCFJ.js +1 -0
  126. package/static/app/assets/{share-2-NmD7e_oV.js → share-2-BH1M-WNi.js} +1 -1
  127. package/static/app/assets/{shield-alert-CcQeMuju.js → shield-alert-BlKdBXcG.js} +1 -1
  128. package/static/app/assets/{textarea-BPAJDc-0.js → textarea-CCWbUfFB.js} +1 -1
  129. package/static/app/assets/{useFocusTrap-C7YLdTBC.js → useFocusTrap-YdHQ7pJ1.js} +1 -1
  130. package/static/app/assets/{useQuery-DRyD9opW.js → useQuery-CXQiwbVT.js} +1 -1
  131. package/static/app/assets/{utils-DG1_ExrP.js → utils-zqPZJxdx.js} +2 -2
  132. package/static/app/assets/{workspace-CWVf3gsI.js → workspace-DXTihhfU.js} +1 -1
  133. package/static/app/index.html +4 -4
  134. package/static/sw.js +1 -1
  135. package/static/app/assets/BrainHome-CvXS6XiQ.js +0 -2
  136. package/static/app/assets/BrainSignals-DOE_KhOU.js +0 -1
  137. package/static/app/assets/arrow-left-CFNIMjhv.js +0 -1
  138. package/static/app/assets/brain-DDCLjRqO.js +0 -1
  139. package/static/app/assets/index-CkzokZAj.css +0 -2
  140. package/static/app/assets/search-BLCYt75v.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 os.getenv(BRAIN_NETWORK_ENV, "").strip().lower() in _TRUTHY
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: the bundle is signed by this device
473
- # and encrypted with a shared passphrase. Encrypting *to a
474
- # recipient's public key* is not implemented in this release.
475
- "encryption": "passphrase",
476
- "recipient_public_key_encryption": False,
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
- Same passphrase mechanism as the whole-Brain archive (PBKDF2-SHA256 →
616
- AES-256-GCM), a different ``format`` so the two can never be confused.
617
- The header and signature stay outside the ciphertext on purpose: a
618
- recipient can see *who* sent a bundle, and how big it is, before typing
619
- a passphrase into it.
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
- salt = os.urandom(16)
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
- "kdf": {
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
- def read_subgraph_archive(self, path, *, passphrase: str) -> Dict[str, Any]:
657
- """Decrypt an encrypted subgraph bundle back into a signed artifact."""
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
- plaintext = AESGCM(_derive_key(passphrase, salt)).decrypt(nonce, ciphertext, None)
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.0.1"
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
+ ]