apsimo 1.3.0__py3-none-any.whl

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 (614) hide show
  1. apsimo/__init__.py +38 -0
  2. apsimo/__main__.py +6 -0
  3. apsimo/agent/__init__.py +6 -0
  4. apsimo/agent/client.py +276 -0
  5. apsimo/agent/models.py +46 -0
  6. apsimo/agents/__init__.py +20 -0
  7. apsimo/agents/models.py +264 -0
  8. apsimo/agents/store.py +861 -0
  9. apsimo/agents/websocket.py +522 -0
  10. apsimo/api/__init__.py +1 -0
  11. apsimo/api/auth_telemetry.py +287 -0
  12. apsimo/api/authority.py +1203 -0
  13. apsimo/api/contact_grants.py +347 -0
  14. apsimo/api/middleware.py +483 -0
  15. apsimo/api/routers/__init__.py +1 -0
  16. apsimo/api/routers/commitment_work.py +265 -0
  17. apsimo/api/routers/context_gate.py +123 -0
  18. apsimo/api/routers/executions.py +140 -0
  19. apsimo/api/routers/followup_plans.py +147 -0
  20. apsimo/api/routers/governed_actions.py +162 -0
  21. apsimo/api/routers/host.py +14473 -0
  22. apsimo/api/routers/initiative_work.py +115 -0
  23. apsimo/api/routers/mining.py +104 -0
  24. apsimo/api/routers/observations.py +110 -0
  25. apsimo/api/routers/social_state.py +225 -0
  26. apsimo/api/routers/task_queue.py +2715 -0
  27. apsimo/api/routers/temporal_followups.py +251 -0
  28. apsimo/api/routers/transport.py +110 -0
  29. apsimo/api/routers/transport_ingress_api.py +240 -0
  30. apsimo/api/schemas/__init__.py +1 -0
  31. apsimo/api/schemas/host.py +1949 -0
  32. apsimo/autonomy/cli.py +110 -0
  33. apsimo/autonomy/condition_worker.py +437 -0
  34. apsimo/autonomy/config.py +424 -0
  35. apsimo/autonomy/loop.py +4316 -0
  36. apsimo/autonomy/registry.py +339 -0
  37. apsimo/autonomy/scheduler.py +1822 -0
  38. apsimo/autonomy/synthesis.py +449 -0
  39. apsimo/backup.py +962 -0
  40. apsimo/beliefs/__init__.py +23 -0
  41. apsimo/beliefs/contradictions.py +109 -0
  42. apsimo/beliefs/decay.py +61 -0
  43. apsimo/beliefs/engine.py +479 -0
  44. apsimo/beliefs/models.py +67 -0
  45. apsimo/beliefs/promotion.py +41 -0
  46. apsimo/beliefs/resolve.py +58 -0
  47. apsimo/beliefs/source_claims.py +690 -0
  48. apsimo/beliefs/source_projection.py +883 -0
  49. apsimo/beliefs/source_time.py +208 -0
  50. apsimo/beliefs/store.py +133 -0
  51. apsimo/briefings/aggregators.py +824 -0
  52. apsimo/briefings/composer.py +420 -0
  53. apsimo/briefings/config.py +55 -0
  54. apsimo/briefings/delivery.py +439 -0
  55. apsimo/briefings/engagement.py +97 -0
  56. apsimo/briefings/engine.py +274 -0
  57. apsimo/briefings/enhancer.py +99 -0
  58. apsimo/briefings/models.py +183 -0
  59. apsimo/briefings/scheduler.py +382 -0
  60. apsimo/briefings/store.py +435 -0
  61. apsimo/chain/__init__.py +48 -0
  62. apsimo/chain/block.py +100 -0
  63. apsimo/chain/cli.py +704 -0
  64. apsimo/chain/genesis.py +443 -0
  65. apsimo/chain/identity.py +416 -0
  66. apsimo/chain/keys.py +1025 -0
  67. apsimo/chain/local_keys.py +187 -0
  68. apsimo/chain/manager.py +290 -0
  69. apsimo/chain/node.py +163 -0
  70. apsimo/chain/plugin_transactions.py +371 -0
  71. apsimo/chain/protocol.py +220 -0
  72. apsimo/chain/state_machine.py +676 -0
  73. apsimo/chain/storage.py +503 -0
  74. apsimo/chain/transactions.py +250 -0
  75. apsimo/chain/validation.py +397 -0
  76. apsimo/channels/__init__.py +1 -0
  77. apsimo/channels/manifest.py +31 -0
  78. apsimo/channels/migrations/001_channels_schema.sql +12 -0
  79. apsimo/channels/phone_gateways.py +42 -0
  80. apsimo/channels/presence.py +188 -0
  81. apsimo/channels/router.py +235 -0
  82. apsimo/channels/store.py +231 -0
  83. apsimo/cli.py +2688 -0
  84. apsimo/cognition/__init__.py +11 -0
  85. apsimo/cognition/charter.py +398 -0
  86. apsimo/cognition/drive_governance.py +3530 -0
  87. apsimo/cognition/evidence_pipeline.py +1627 -0
  88. apsimo/cognition/external_events.py +932 -0
  89. apsimo/cognition/goal_spine.py +3488 -0
  90. apsimo/cognition/introspection.py +214 -0
  91. apsimo/cognition/prompt.py +150 -0
  92. apsimo/cognition/runtime.py +108 -0
  93. apsimo/cognition/trigger.py +154 -0
  94. apsimo/commitments/__init__.py +18 -0
  95. apsimo/commitments/local_work.py +355 -0
  96. apsimo/commitments/store.py +1052 -0
  97. apsimo/commitments/work.py +91 -0
  98. apsimo/compat.py +53 -0
  99. apsimo/compression/__init__.py +467 -0
  100. apsimo/connectors/__init__.py +21 -0
  101. apsimo/connectors/base.py +152 -0
  102. apsimo/connectors/caldav_calendar.py +125 -0
  103. apsimo/connectors/fs_documents.py +85 -0
  104. apsimo/connectors/imap_email.py +138 -0
  105. apsimo/connectors/manager.py +218 -0
  106. apsimo/connectors/webhook_pull.py +88 -0
  107. apsimo/contacts/__init__.py +33 -0
  108. apsimo/contacts/comms.py +357 -0
  109. apsimo/contacts/config.py +79 -0
  110. apsimo/contacts/exporters/__init__.py +1 -0
  111. apsimo/contacts/exporters/vcard.py +71 -0
  112. apsimo/contacts/identity_links.py +251 -0
  113. apsimo/contacts/importer.py +280 -0
  114. apsimo/contacts/importers/__init__.py +1 -0
  115. apsimo/contacts/importers/batch.py +43 -0
  116. apsimo/contacts/importers/macos_contacts.py +101 -0
  117. apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
  118. apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
  119. apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
  120. apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
  121. apsimo/contacts/migrations/005_identity_links.sql +27 -0
  122. apsimo/contacts/models.py +308 -0
  123. apsimo/contacts/scoring.py +16 -0
  124. apsimo/contacts/store.py +1623 -0
  125. apsimo/contacts/transport_ingress.py +252 -0
  126. apsimo/contacts/world_bridge.py +314 -0
  127. apsimo/contextgate/__init__.py +69 -0
  128. apsimo/contextgate/chunker.py +169 -0
  129. apsimo/contextgate/estimate.py +54 -0
  130. apsimo/contextgate/gate.py +313 -0
  131. apsimo/contextgate/retrieve.py +115 -0
  132. apsimo/delivery/__init__.py +16 -0
  133. apsimo/delivery/bridge.py +1260 -0
  134. apsimo/delivery/channels.py +526 -0
  135. apsimo/delivery/classification.py +50 -0
  136. apsimo/delivery/rate_limiter.py +268 -0
  137. apsimo/delivery/reachout_policy.py +206 -0
  138. apsimo/directed/__init__.py +22 -0
  139. apsimo/directed/audit.py +167 -0
  140. apsimo/directed/intake.py +95 -0
  141. apsimo/directed/models.py +191 -0
  142. apsimo/directed/service.py +509 -0
  143. apsimo/directives/__init__.py +25 -0
  144. apsimo/directives/evidence.py +87 -0
  145. apsimo/directives/extractor.py +188 -0
  146. apsimo/directives/guard.py +364 -0
  147. apsimo/directives/models.py +206 -0
  148. apsimo/directives/service.py +372 -0
  149. apsimo/directives/store.py +167 -0
  150. apsimo/doctor.py +2173 -0
  151. apsimo/environment.py +43 -0
  152. apsimo/events/__init__.py +33 -0
  153. apsimo/events/broadcaster.py +98 -0
  154. apsimo/events/bus.py +217 -0
  155. apsimo/events/journal.py +863 -0
  156. apsimo/events/stream.py +131 -0
  157. apsimo/events/types.py +150 -0
  158. apsimo/execution_results.py +357 -0
  159. apsimo/feedback/__init__.py +5 -0
  160. apsimo/feedback/store.py +76 -0
  161. apsimo/feeds/__init__.py +19 -0
  162. apsimo/feeds/cli.py +84 -0
  163. apsimo/feeds/engine.py +437 -0
  164. apsimo/feeds/example-feed.yaml +77 -0
  165. apsimo/feeds/hermes_cron.py +126 -0
  166. apsimo/feeds/manager.py +235 -0
  167. apsimo/feeds/spec.py +250 -0
  168. apsimo/feeds/template.py +202 -0
  169. apsimo/gate/__init__.py +18 -0
  170. apsimo/gate/audit.py +61 -0
  171. apsimo/gate/communication_policy.py +166 -0
  172. apsimo/gate/config.py +72 -0
  173. apsimo/gate/context_provenance.py +170 -0
  174. apsimo/gate/env_risk.py +226 -0
  175. apsimo/gate/guard_audit.py +353 -0
  176. apsimo/gate/layers/__init__.py +1 -0
  177. apsimo/gate/layers/base.py +15 -0
  178. apsimo/gate/layers/l1_recipient.py +66 -0
  179. apsimo/gate/layers/l2_pii.py +134 -0
  180. apsimo/gate/layers/l3_cross_context.py +50 -0
  181. apsimo/gate/layers/l4_trust_tier.py +78 -0
  182. apsimo/gate/layers/l5_injection.py +199 -0
  183. apsimo/gate/layers/l6_review.py +86 -0
  184. apsimo/gate/layers/l7_delay.py +100 -0
  185. apsimo/gate/layers/tom2_epistemic.py +185 -0
  186. apsimo/gate/models.py +64 -0
  187. apsimo/gate/pending_dispatch.py +5 -0
  188. apsimo/gate/pipeline.py +206 -0
  189. apsimo/gate/rejection.py +259 -0
  190. apsimo/gate/response_guard.py +700 -0
  191. apsimo/gate/rulesets/injection_v1.yaml +51 -0
  192. apsimo/gate/surface_policy.py +189 -0
  193. apsimo/gate/taint.py +226 -0
  194. apsimo/genesis.json +9 -0
  195. apsimo/goals/__init__.py +100 -0
  196. apsimo/goals/config.py +38 -0
  197. apsimo/goals/decomposer.py +421 -0
  198. apsimo/goals/engine.py +617 -0
  199. apsimo/goals/inference.py +354 -0
  200. apsimo/goals/models.py +302 -0
  201. apsimo/goals/priority.py +270 -0
  202. apsimo/goals/queue_bridge.py +149 -0
  203. apsimo/goals/replan.py +450 -0
  204. apsimo/goals/schema.sql +89 -0
  205. apsimo/goals/store.py +692 -0
  206. apsimo/governed_actions.py +1708 -0
  207. apsimo/harness_integration/__init__.py +45 -0
  208. apsimo/harness_integration/context.py +41 -0
  209. apsimo/harness_integration/skills.py +231 -0
  210. apsimo/identity/__init__.py +26 -0
  211. apsimo/identity/participants.py +181 -0
  212. apsimo/identity/resolver.py +329 -0
  213. apsimo/identity_bootstrap/__init__.py +5 -0
  214. apsimo/identity_bootstrap/builder.py +208 -0
  215. apsimo/identity_bootstrap/corpus.py +443 -0
  216. apsimo/identity_bootstrap/models.py +54 -0
  217. apsimo/identity_bootstrap/runner.py +353 -0
  218. apsimo/identity_bootstrap/seeders/__init__.py +25 -0
  219. apsimo/identity_bootstrap/seeders/briefings.py +109 -0
  220. apsimo/identity_bootstrap/seeders/chain.py +57 -0
  221. apsimo/identity_bootstrap/seeders/goals.py +128 -0
  222. apsimo/identity_bootstrap/seeders/memory.py +191 -0
  223. apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
  224. apsimo/identity_bootstrap/seeders/relationship.py +152 -0
  225. apsimo/identity_bootstrap/seeders/sessions.py +67 -0
  226. apsimo/identity_bootstrap/seeders/skills.py +92 -0
  227. apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
  228. apsimo/identity_bootstrap/seeders/world_model.py +143 -0
  229. apsimo/identity_bootstrap/self_query.py +92 -0
  230. apsimo/identity_bootstrap/self_reflection.py +155 -0
  231. apsimo/identity_bootstrap/skill.py +37 -0
  232. apsimo/identity_bootstrap/verifier.py +436 -0
  233. apsimo/initiatives/__init__.py +20 -0
  234. apsimo/initiatives/action_registry.py +454 -0
  235. apsimo/initiatives/approval_authority.py +2105 -0
  236. apsimo/initiatives/approval_policy.py +123 -0
  237. apsimo/initiatives/assignment.py +263 -0
  238. apsimo/initiatives/backup_evidence.py +100 -0
  239. apsimo/initiatives/context_freshness.py +103 -0
  240. apsimo/initiatives/models.py +318 -0
  241. apsimo/initiatives/native_work.py +270 -0
  242. apsimo/initiatives/standing_approvals.py +232 -0
  243. apsimo/initiatives/store.py +1081 -0
  244. apsimo/initiatives/temporal_followup.py +410 -0
  245. apsimo/intelligence/__init__.py +1 -0
  246. apsimo/intelligence/cognition/__init__.py +24 -0
  247. apsimo/intelligence/cognition/gap_detector.py +148 -0
  248. apsimo/intelligence/cognition/metalearner.py +547 -0
  249. apsimo/intelligence/cognition/metrics_collector.py +217 -0
  250. apsimo/intelligence/cognition/performance_index.py +299 -0
  251. apsimo/intelligence/cognition/registry.py +192 -0
  252. apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
  253. apsimo/intelligence/cognition/types.py +16 -0
  254. apsimo/intelligence/components/__init__.py +66 -0
  255. apsimo/intelligence/components/anomaly_detector.py +413 -0
  256. apsimo/intelligence/components/initiative_engine.py +2643 -0
  257. apsimo/intelligence/components/preference_learner.py +521 -0
  258. apsimo/intelligence/components/research_orchestrator.py +358 -0
  259. apsimo/intelligence/components/self_directed_thinker.py +221 -0
  260. apsimo/intelligence/components/self_reflector.py +252 -0
  261. apsimo/intelligence/components/session_continuity.py +154 -0
  262. apsimo/intelligence/components/task_planner.py +320 -0
  263. apsimo/intelligence/components/tool_learner.py +217 -0
  264. apsimo/intelligence/graph/__init__.py +79 -0
  265. apsimo/intelligence/graph/client.py +2483 -0
  266. apsimo/intelligence/graph/consolidator.py +405 -0
  267. apsimo/intelligence/graph/distiller.py +312 -0
  268. apsimo/intelligence/graph/migrations.py +129 -0
  269. apsimo/intelligence/graph/queries.py +248 -0
  270. apsimo/intelligence/graph/recall.py +281 -0
  271. apsimo/intelligence/graph/reconciler.py +144 -0
  272. apsimo/intelligence/graph/schema.py +337 -0
  273. apsimo/intelligence/graph/selection.py +252 -0
  274. apsimo/intelligence/learning/__init__.py +17 -0
  275. apsimo/intelligence/learning/continuous_learner.py +245 -0
  276. apsimo/intelligence/learning/feedback_store.py +321 -0
  277. apsimo/intelligence/mind_model/__init__.py +1 -0
  278. apsimo/intelligence/mind_model/graph_baseline.py +136 -0
  279. apsimo/intelligence/mind_model/signal_collector.py +361 -0
  280. apsimo/intelligence/relationships/__init__.py +11 -0
  281. apsimo/intelligence/relationships/profiler.py +389 -0
  282. apsimo/intelligence/relationships/scorer.py +560 -0
  283. apsimo/intelligence/relationships/signal_floor.py +66 -0
  284. apsimo/intelligence/relationships/trust_tiers.py +300 -0
  285. apsimo/intelligence/synthesis/__init__.py +40 -0
  286. apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
  287. apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
  288. apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
  289. apsimo/intelligence/synthesis/insight_store.py +79 -0
  290. apsimo/intelligence/synthesis/insight_validator.py +183 -0
  291. apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
  292. apsimo/intelligence/turn_middleware/__init__.py +15 -0
  293. apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
  294. apsimo/mcp/__init__.py +41 -0
  295. apsimo/mcp/__main__.py +6 -0
  296. apsimo/mcp/config.py +287 -0
  297. apsimo/mcp/server.py +501 -0
  298. apsimo/migrations.py +187 -0
  299. apsimo/mining/__init__.py +27 -0
  300. apsimo/mining/corpus.py +239 -0
  301. apsimo/mining/escalations.py +289 -0
  302. apsimo/mining/models.py +169 -0
  303. apsimo/mining/store.py +210 -0
  304. apsimo/models/__init__.py +30 -0
  305. apsimo/models/memory.py +80 -0
  306. apsimo/models/mesh.py +72 -0
  307. apsimo/models/person.py +104 -0
  308. apsimo/models/signal.py +108 -0
  309. apsimo/observations/__init__.py +15 -0
  310. apsimo/observations/store.py +277 -0
  311. apsimo/patterns/__init__.py +6 -0
  312. apsimo/patterns/extract.py +187 -0
  313. apsimo/patterns/store.py +227 -0
  314. apsimo/persona/__init__.py +1 -0
  315. apsimo/persona/engine.py +611 -0
  316. apsimo/persona/manifest.py +140 -0
  317. apsimo/projects/__init__.py +28 -0
  318. apsimo/projects/engine.py +1681 -0
  319. apsimo/projects/event_outbox.py +188 -0
  320. apsimo/projects/models.py +216 -0
  321. apsimo/projects/planner.py +181 -0
  322. apsimo/projects/store.py +1446 -0
  323. apsimo/proposals/__init__.py +12 -0
  324. apsimo/proposals/engine.py +114 -0
  325. apsimo/proposals/models.py +207 -0
  326. apsimo/qualification/__init__.py +1 -0
  327. apsimo/qualification/cases.py +75 -0
  328. apsimo/qualification/cli.py +51 -0
  329. apsimo/qualification/memory_cases.py +209 -0
  330. apsimo/qualification/records.py +92 -0
  331. apsimo/qualification/report.py +87 -0
  332. apsimo/qualification/runner.py +311 -0
  333. apsimo/qualification/structured_cases.py +131 -0
  334. apsimo/reasoning/__init__.py +13 -0
  335. apsimo/reasoning/executor.py +506 -0
  336. apsimo/reasoning/loop.py +373 -0
  337. apsimo/reasoning/native_tools/__init__.py +16 -0
  338. apsimo/reasoning/native_tools/calculate.py +141 -0
  339. apsimo/reasoning/native_tools/file_ops.py +150 -0
  340. apsimo/reasoning/native_tools/web_search.py +49 -0
  341. apsimo/reasoning/tool_policy.py +182 -0
  342. apsimo/redact/__init__.py +176 -0
  343. apsimo/repos/__init__.py +5 -0
  344. apsimo/repos/mirrors.py +204 -0
  345. apsimo/research/__init__.py +41 -0
  346. apsimo/research/artifact.py +482 -0
  347. apsimo/research/gatherer.py +387 -0
  348. apsimo/research/pipeline.py +513 -0
  349. apsimo/research/search/__init__.py +7 -0
  350. apsimo/research/search/base.py +41 -0
  351. apsimo/research/search/brave.py +59 -0
  352. apsimo/research/search/cache.py +51 -0
  353. apsimo/research/search/duckduckgo.py +103 -0
  354. apsimo/research/search/orchestrator.py +119 -0
  355. apsimo/research/search/serpapi.py +59 -0
  356. apsimo/research/search/tavily.py +59 -0
  357. apsimo/research/synthesizer.py +309 -0
  358. apsimo/router/__init__.py +30 -0
  359. apsimo/router/complexity_scorer.py +148 -0
  360. apsimo/router/endpoints.py +153 -0
  361. apsimo/router/fallback.py +58 -0
  362. apsimo/router/functions.py +243 -0
  363. apsimo/router/native_policy.py +52 -0
  364. apsimo/router/router.py +762 -0
  365. apsimo/router/self_learning.py +174 -0
  366. apsimo/router/tiers.py +677 -0
  367. apsimo/sandbox/__init__.py +21 -0
  368. apsimo/sandbox/backend.py +195 -0
  369. apsimo/sandbox/manager.py +173 -0
  370. apsimo/scope_bounds.py +7 -0
  371. apsimo/secrets/__init__.py +6 -0
  372. apsimo/secrets/backends/__init__.py +8 -0
  373. apsimo/secrets/backends/base.py +42 -0
  374. apsimo/secrets/backends/env.py +110 -0
  375. apsimo/secrets/backends/keyring.py +72 -0
  376. apsimo/secrets/backends/onepassword.py +232 -0
  377. apsimo/secrets/cli.py +191 -0
  378. apsimo/secrets/manager.py +160 -0
  379. apsimo/secrets/migration.py +101 -0
  380. apsimo/secrets/types.py +98 -0
  381. apsimo/seed.py +41 -0
  382. apsimo/self_model/__init__.py +37 -0
  383. apsimo/self_model/appraisals.py +673 -0
  384. apsimo/self_model/benchmark.py +1314 -0
  385. apsimo/self_model/brief.py +40 -0
  386. apsimo/self_model/event_concerns.py +1128 -0
  387. apsimo/self_model/execution_forecasts.py +353 -0
  388. apsimo/self_model/expectations.py +1595 -0
  389. apsimo/self_model/experiments.py +1150 -0
  390. apsimo/self_model/journal.py +148 -0
  391. apsimo/self_model/judgments.py +705 -0
  392. apsimo/self_model/native_outcomes.py +55 -0
  393. apsimo/self_model/params.py +220 -0
  394. apsimo/self_model/perspective.py +246 -0
  395. apsimo/self_model/reconcile.py +183 -0
  396. apsimo/self_model/reply_forecasts.py +381 -0
  397. apsimo/self_model/runtime_forecasts.py +296 -0
  398. apsimo/self_model/runtime_models.py +67 -0
  399. apsimo/self_model/settlement.py +207 -0
  400. apsimo/self_model/situation.py +1731 -0
  401. apsimo/self_model/store.py +883 -0
  402. apsimo/self_model/supervised.py +137 -0
  403. apsimo/self_model/thinker.py +99 -0
  404. apsimo/self_model/trust.py +388 -0
  405. apsimo/self_model/workspace.py +2388 -0
  406. apsimo/server.py +4197 -0
  407. apsimo/services/__init__.py +1 -0
  408. apsimo/services/agent_bridge.py +474 -0
  409. apsimo/services/initiative_executor.py +914 -0
  410. apsimo/services/instance.py +297 -0
  411. apsimo/sessions/__init__.py +22 -0
  412. apsimo/sessions/config.py +13 -0
  413. apsimo/sessions/context_loader.py +88 -0
  414. apsimo/sessions/federation_session.py +75 -0
  415. apsimo/sessions/isolated_session.py +98 -0
  416. apsimo/sessions/reports.py +84 -0
  417. apsimo/sessions/store.py +148 -0
  418. apsimo/setup.py +2818 -0
  419. apsimo/setup_hermes.py +879 -0
  420. apsimo/setup_local_work.py +218 -0
  421. apsimo/setup_native_goals.py +134 -0
  422. apsimo/setup_native_reviews.py +115 -0
  423. apsimo/skills/__init__.py +10 -0
  424. apsimo/skills/base.py +108 -0
  425. apsimo/skills/budget.py +28 -0
  426. apsimo/skills/executor.py +493 -0
  427. apsimo/skills/executors/__init__.py +1 -0
  428. apsimo/skills/executors/behavioral_correction.py +75 -0
  429. apsimo/skills/executors/capability_gap.py +38 -0
  430. apsimo/skills/executors/data_quality.py +163 -0
  431. apsimo/skills/executors/knowledge_acquisition.py +41 -0
  432. apsimo/skills/executors/operational_hygiene.py +185 -0
  433. apsimo/skills/executors/subsystem_health.py +169 -0
  434. apsimo/skills/hermes_export.py +431 -0
  435. apsimo/skills/index.py +123 -0
  436. apsimo/skills/learning/__init__.py +21 -0
  437. apsimo/skills/learning/novelty_detector.py +206 -0
  438. apsimo/skills/learning/pattern_extractor.py +199 -0
  439. apsimo/skills/learning/triggers.py +159 -0
  440. apsimo/skills/loader.py +246 -0
  441. apsimo/skills/migrations/002_progressive_loading.sql +6 -0
  442. apsimo/skills/migrations/backfill_triggers.py +20 -0
  443. apsimo/skills/models.py +202 -0
  444. apsimo/skills/packager.py +128 -0
  445. apsimo/skills/protocols.py +70 -0
  446. apsimo/skills/registry.py +191 -0
  447. apsimo/skills/runtime.py +58 -0
  448. apsimo/skills/sandbox_runner.py +229 -0
  449. apsimo/skills/scheduler.py +129 -0
  450. apsimo/skills/schema.py +79 -0
  451. apsimo/skills/security/__init__.py +12 -0
  452. apsimo/skills/security/guards.py +53 -0
  453. apsimo/skills/security/scanner.py +223 -0
  454. apsimo/skills_memory/__init__.py +26 -0
  455. apsimo/skills_memory/distill.py +159 -0
  456. apsimo/skills_memory/models.py +85 -0
  457. apsimo/skills_memory/retrieve.py +62 -0
  458. apsimo/skills_memory/store.py +172 -0
  459. apsimo/surprise/__init__.py +6 -0
  460. apsimo/surprise/accumulation.py +57 -0
  461. apsimo/surprise/scorer.py +102 -0
  462. apsimo/surprise/store.py +203 -0
  463. apsimo/task_queue/__init__.py +69 -0
  464. apsimo/task_queue/action_receipts.py +148 -0
  465. apsimo/task_queue/approval_relay_canary.py +108 -0
  466. apsimo/task_queue/config.py +85 -0
  467. apsimo/task_queue/contract.py +361 -0
  468. apsimo/task_queue/events.py +130 -0
  469. apsimo/task_queue/governor.py +1031 -0
  470. apsimo/task_queue/handlers/__init__.py +16 -0
  471. apsimo/task_queue/handlers/base.py +37 -0
  472. apsimo/task_queue/handlers/inference.py +640 -0
  473. apsimo/task_queue/handlers/monitoring.py +116 -0
  474. apsimo/task_queue/handlers/registry.py +75 -0
  475. apsimo/task_queue/handlers/subtask_handler.py +173 -0
  476. apsimo/task_queue/handlers/system_maintenance.py +147 -0
  477. apsimo/task_queue/mesh_integration.py +111 -0
  478. apsimo/task_queue/models.py +317 -0
  479. apsimo/task_queue/queue_manager.py +8286 -0
  480. apsimo/task_queue/routing.py +287 -0
  481. apsimo/task_queue/scheduler.py +252 -0
  482. apsimo/task_queue/schema.sql +197 -0
  483. apsimo/task_queue/work_control.py +342 -0
  484. apsimo/task_queue/worker.py +993 -0
  485. apsimo/telemetry.py +145 -0
  486. apsimo/tom/__init__.py +6 -0
  487. apsimo/tom/affect.py +387 -0
  488. apsimo/tom/approvals.py +171 -0
  489. apsimo/tom/arcs.py +896 -0
  490. apsimo/tom/asymmetry.py +131 -0
  491. apsimo/tom/eligibility.py +248 -0
  492. apsimo/tom/engagement.py +214 -0
  493. apsimo/tom/exposure.py +214 -0
  494. apsimo/tom/extractor.py +306 -0
  495. apsimo/tom/fact_adapters.py +144 -0
  496. apsimo/tom/facts.py +326 -0
  497. apsimo/tom/integration.py +592 -0
  498. apsimo/tom/leveled.py +118 -0
  499. apsimo/tom/levels.py +247 -0
  500. apsimo/tom/recipient_audit.py +995 -0
  501. apsimo/tom/recipient_simulator.py +593 -0
  502. apsimo/tom/source_lineage.py +93 -0
  503. apsimo/tom/tom2.py +277 -0
  504. apsimo/tom/visibility.py +559 -0
  505. apsimo/tom/visibility_store.py +414 -0
  506. apsimo/tools/__init__.py +0 -0
  507. apsimo/tools/definitions.py +740 -0
  508. apsimo/tools/handlers.py +943 -0
  509. apsimo/toolsmith/__init__.py +26 -0
  510. apsimo/toolsmith/authority.py +166 -0
  511. apsimo/toolsmith/engine.py +559 -0
  512. apsimo/toolsmith/integrity.py +100 -0
  513. apsimo/toolsmith/miner.py +145 -0
  514. apsimo/toolsmith/policy.py +110 -0
  515. apsimo/toolsmith/registry.py +635 -0
  516. apsimo/turns/__init__.py +17 -0
  517. apsimo/turns/audio.py +134 -0
  518. apsimo/turns/documents.py +235 -0
  519. apsimo/turns/executions.py +486 -0
  520. apsimo/turns/hermes_history.py +245 -0
  521. apsimo/turns/hermes_kanban.py +268 -0
  522. apsimo/turns/hermes_work.py +96 -0
  523. apsimo/turns/idempotency.py +752 -0
  524. apsimo/turns/local_work.py +115 -0
  525. apsimo/turns/media.py +581 -0
  526. apsimo/turns/reported_workers.py +196 -0
  527. apsimo/turns/source_annotations.py +283 -0
  528. apsimo/turns/source_attribution.py +154 -0
  529. apsimo/turns/source_read.py +351 -0
  530. apsimo/turns/source_vectors.py +263 -0
  531. apsimo/turns/video.py +210 -0
  532. apsimo/util/autonomy_preset.py +220 -0
  533. apsimo/util/instance.py +92 -0
  534. apsimo/util/model_output.py +25 -0
  535. apsimo/util/quiet_hours.py +27 -0
  536. apsimo/util/session_safety.py +37 -0
  537. apsimo/util/temporal.py +343 -0
  538. apsimo/vector/__init__.py +75 -0
  539. apsimo/vector/backfill.py +171 -0
  540. apsimo/vector/caption.py +114 -0
  541. apsimo/vector/collections.py +51 -0
  542. apsimo/vector/config.py +102 -0
  543. apsimo/vector/embedder.py +670 -0
  544. apsimo/vector/image_preprocess.py +406 -0
  545. apsimo/vector/image_store.py +296 -0
  546. apsimo/vector/indexes.py +162 -0
  547. apsimo/vector/migrate.py +334 -0
  548. apsimo/vector/multimodal_provider.py +417 -0
  549. apsimo/vector/multimodal_types.py +87 -0
  550. apsimo/vector/openai_provider.py +119 -0
  551. apsimo/vector/query.py +49 -0
  552. apsimo/vector/reranker.py +565 -0
  553. apsimo/vector/safety_image.py +159 -0
  554. apsimo/vector/scanner.py +197 -0
  555. apsimo/vector/setup.py +289 -0
  556. apsimo/vector/store.py +533 -0
  557. apsimo/vector/tiers.py +263 -0
  558. apsimo/work_orders.py +925 -0
  559. apsimo/workers/__init__.py +21 -0
  560. apsimo/workers/agent_bridge.py +640 -0
  561. apsimo/workers/colony_worker.py +382 -0
  562. apsimo/workers/queue_worker.py +441 -0
  563. apsimo/workers/skills_sync.py +152 -0
  564. apsimo/world_model/__init__.py +71 -0
  565. apsimo/world_model/causal_maintenance.py +131 -0
  566. apsimo/world_model/causal_policy.py +43 -0
  567. apsimo/world_model/causal_query.py +125 -0
  568. apsimo/world_model/confidence.py +54 -0
  569. apsimo/world_model/config.py +64 -0
  570. apsimo/world_model/constants.py +97 -0
  571. apsimo/world_model/entities.py +145 -0
  572. apsimo/world_model/expectation_resolvers.py +177 -0
  573. apsimo/world_model/extraction/__init__.py +7 -0
  574. apsimo/world_model/extraction/base.py +62 -0
  575. apsimo/world_model/extraction/conversation_extractor.py +262 -0
  576. apsimo/world_model/extraction/detector.py +74 -0
  577. apsimo/world_model/extraction/document_extractor.py +78 -0
  578. apsimo/world_model/extraction/formats/__init__.py +24 -0
  579. apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
  580. apsimo/world_model/extraction/formats/html_fmt.py +72 -0
  581. apsimo/world_model/extraction/formats/json_fmt.py +68 -0
  582. apsimo/world_model/extraction/formats/pdf.py +43 -0
  583. apsimo/world_model/extraction/formats/text.py +27 -0
  584. apsimo/world_model/extraction/llm_extractor.py +164 -0
  585. apsimo/world_model/extraction/pipeline.py +73 -0
  586. apsimo/world_model/integrations/__init__.py +5 -0
  587. apsimo/world_model/integrations/mind_model_bridge.py +115 -0
  588. apsimo/world_model/integrations/social_intel_bridge.py +120 -0
  589. apsimo/world_model/jobs/__init__.py +4 -0
  590. apsimo/world_model/jobs/extraction_job.py +168 -0
  591. apsimo/world_model/llm_extract.py +572 -0
  592. apsimo/world_model/neo4j/__init__.py +5 -0
  593. apsimo/world_model/neo4j/backend.py +654 -0
  594. apsimo/world_model/observations.py +155 -0
  595. apsimo/world_model/populator.py +307 -0
  596. apsimo/world_model/postgres/__init__.py +1 -0
  597. apsimo/world_model/postgres/backend.py +683 -0
  598. apsimo/world_model/relationships.py +25 -0
  599. apsimo/world_model/resolution/__init__.py +13 -0
  600. apsimo/world_model/resolution/entity_resolver.py +232 -0
  601. apsimo/world_model/resolution/merge_audit.py +16 -0
  602. apsimo/world_model/resolution/merge_workflow.py +117 -0
  603. apsimo/world_model/source_reports.py +121 -0
  604. apsimo/world_model/sqlite/__init__.py +4 -0
  605. apsimo/world_model/sqlite/backend.py +855 -0
  606. apsimo/world_model/sqlite/schema.sql +132 -0
  607. apsimo/world_model/store.py +545 -0
  608. apsimo-1.3.0.dist-info/METADATA +78 -0
  609. apsimo-1.3.0.dist-info/RECORD +614 -0
  610. apsimo-1.3.0.dist-info/WHEEL +5 -0
  611. apsimo-1.3.0.dist-info/entry_points.txt +11 -0
  612. apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
  613. apsimo-1.3.0.dist-info/top_level.txt +2 -0
  614. colony_sidecar/__init__.py +4 -0
apsimo/chain/keys.py ADDED
@@ -0,0 +1,1025 @@
1
+ """Colony key resilience: Shamir Secret Sharing, encrypted share persistence, key rotation.
2
+
3
+ The private key is NEVER stored in plaintext at rest or between signing calls.
4
+ All reconstruction happens in memory and is zeroed immediately after use.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import secrets
13
+ import time
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Callable, List, Optional, Protocol, Tuple, Union
18
+
19
+ try:
20
+ import httpx as _httpx
21
+ except ImportError:
22
+ _httpx = None # type: ignore
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Share primitives
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ @dataclass
33
+ class KeyShare:
34
+ """A single encrypted Shamir share."""
35
+
36
+ colony_id: str
37
+ share_index: int # 1-based, range [1, n]
38
+ n: int # total shares
39
+ k: int # reconstruction threshold
40
+ # No defaults — callers must supply explicit salt and nonce so that
41
+ # randomness generation is visible at the call site (SEC-14-L-07)
42
+ argon2_salt: bytes = field()
43
+ aes_nonce: bytes = field()
44
+ version: int = 1
45
+ ciphertext: bytes = b"" # encrypted share bytes
46
+ tag: bytes = b"" # GCM authentication tag
47
+
48
+ def to_dict(self) -> dict:
49
+ return {
50
+ "version": self.version,
51
+ "colony_id": self.colony_id,
52
+ "share_index": self.share_index,
53
+ "n": self.n,
54
+ "k": self.k,
55
+ "argon2_salt": self.argon2_salt.hex(),
56
+ "aes_nonce": self.aes_nonce.hex(),
57
+ "ciphertext": self.ciphertext.hex(),
58
+ "tag": self.tag.hex(),
59
+ }
60
+
61
+ @classmethod
62
+ def from_dict(cls, d: dict) -> "KeyShare":
63
+ return cls(
64
+ colony_id=d["colony_id"],
65
+ share_index=d["share_index"],
66
+ n=d["n"],
67
+ k=d["k"],
68
+ version=d.get("version", 1),
69
+ argon2_salt=bytes.fromhex(d["argon2_salt"]),
70
+ aes_nonce=bytes.fromhex(d["aes_nonce"]),
71
+ ciphertext=bytes.fromhex(d["ciphertext"]),
72
+ tag=bytes.fromhex(d["tag"]),
73
+ )
74
+
75
+
76
+ class ShareBackend(Protocol):
77
+ """Protocol for key share storage backends."""
78
+
79
+ def store_share(self, share: KeyShare) -> None:
80
+ """Persist an encrypted share to this backend."""
81
+ ...
82
+
83
+ def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
84
+ """Retrieve a share. Returns None if not present."""
85
+ ...
86
+
87
+ def delete_share(self, colony_id: str, share_index: int) -> None:
88
+ """Delete a share (used during rotation)."""
89
+ ...
90
+
91
+ def list_shares(self, colony_id: str) -> List[int]:
92
+ """Return the list of share indices stored in this backend."""
93
+ ...
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Shamir implementation over GF(2^8)
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ class ShamirKeyManager:
102
+ """Split and reconstruct Ed25519 private keys using Shamir Secret Sharing.
103
+
104
+ The scheme operates over GF(2^8), applying the polynomial independently
105
+ to each of the 32 bytes of the private key.
106
+
107
+ Requirements:
108
+ - MUST support n in range [3, 20] and k in range [2, n].
109
+ - MUST zero all intermediate key material from memory after use.
110
+ - MUST validate that reconstructed key length matches 32 bytes.
111
+ - MUST raise ValueError if fewer than k shares are provided.
112
+ """
113
+
114
+ GF256_EXP: List[int] = []
115
+ GF256_LOG: List[int] = []
116
+
117
+ def __init__(self) -> None:
118
+ if not self.GF256_EXP:
119
+ self._init_field_tables()
120
+
121
+ @classmethod
122
+ def _init_field_tables(cls) -> None:
123
+ """Precompute GF(2^8) exponential and logarithm tables."""
124
+ cls.GF256_EXP = [0] * 512
125
+ cls.GF256_LOG = [0] * 256
126
+ x = 1
127
+ for i in range(255):
128
+ cls.GF256_EXP[i] = x
129
+ cls.GF256_LOG[x] = i
130
+ x = cls._gf_mul_slow(x, 3)
131
+ for i in range(255, 512):
132
+ cls.GF256_EXP[i] = cls.GF256_EXP[i - 255]
133
+
134
+ @staticmethod
135
+ def _gf_mul_slow(a: int, b: int) -> int:
136
+ """GF(2^8) multiplication with primitive polynomial x^8+x^4+x^3+x+1."""
137
+ p = 0
138
+ for _ in range(8):
139
+ if b & 1:
140
+ p ^= a
141
+ hi = a & 0x80
142
+ a = (a << 1) & 0xFF
143
+ if hi:
144
+ a ^= 0x1B
145
+ b >>= 1
146
+ return p
147
+
148
+ def _gf_mul(self, a: int, b: int) -> int:
149
+ if a == 0 or b == 0:
150
+ return 0
151
+ return self.GF256_EXP[(self.GF256_LOG[a] + self.GF256_LOG[b]) % 255]
152
+
153
+ def _gf_div(self, a: int, b: int) -> int:
154
+ if b == 0:
155
+ raise ZeroDivisionError("GF(2^8) division by zero")
156
+ if a == 0:
157
+ return 0
158
+ return self.GF256_EXP[(self.GF256_LOG[a] - self.GF256_LOG[b]) % 255]
159
+
160
+ def _eval_polynomial(self, coefficients: List[int], x: int) -> int:
161
+ """Evaluate polynomial at x in GF(2^8)."""
162
+ result = 0
163
+ for coeff in reversed(coefficients):
164
+ result = self._gf_mul(result, x) ^ coeff
165
+ return result
166
+
167
+ def split(self, key: bytes, n: int, k: int) -> List[tuple]:
168
+ """Split a 32-byte private key into n shares with threshold k.
169
+
170
+ Args:
171
+ key: 32-byte Ed25519 private key.
172
+ n: Total number of shares to produce.
173
+ k: Minimum shares required for reconstruction.
174
+
175
+ Returns:
176
+ List of (x, share_bytes) tuples where x is in range [1, n].
177
+
178
+ Raises:
179
+ ValueError: If key length != 32, n < 3, k < 2, or k > n.
180
+ """
181
+ if len(key) != 32:
182
+ raise ValueError(f"Key must be 32 bytes, got {len(key)}")
183
+ if n < 3 or n > 20:
184
+ raise ValueError(f"n must be in [3, 20], got {n}")
185
+ if k < 2 or k > n:
186
+ raise ValueError(f"k must be in [2, n], got k={k} n={n}")
187
+
188
+ shares: List[tuple] = [(x, bytearray()) for x in range(1, n + 1)]
189
+ for byte_pos in range(32):
190
+ coefficients = [key[byte_pos]] + [
191
+ secrets.randbelow(256) for _ in range(k - 1)
192
+ ]
193
+ for i, (x, share_buf) in enumerate(shares):
194
+ y = self._eval_polynomial(coefficients, x)
195
+ share_buf.append(y)
196
+ # Zero coefficients
197
+ for i in range(len(coefficients)):
198
+ coefficients[i] = 0
199
+
200
+ return [(x, bytes(buf)) for x, buf in shares]
201
+
202
+ def reconstruct(self, shares: List[tuple], k: int) -> bytes:
203
+ """Reconstruct the private key from k or more shares.
204
+
205
+ Args:
206
+ shares: List of (x, share_bytes) tuples.
207
+ k: Threshold (used for validation only; any k+ shares reconstruct).
208
+
209
+ Returns:
210
+ 32-byte reconstructed private key.
211
+
212
+ Raises:
213
+ ValueError: If fewer than k shares provided or share lengths differ.
214
+ """
215
+ if len(shares) < k:
216
+ raise ValueError(
217
+ f"Need at least {k} shares to reconstruct, got {len(shares)}"
218
+ )
219
+
220
+ share_len = len(shares[0][1])
221
+ if any(len(s) != share_len for _, s in shares):
222
+ raise ValueError("All shares must be the same length")
223
+
224
+ key_bytes = bytearray(share_len)
225
+ for byte_pos in range(share_len):
226
+ points = [(x, share[byte_pos]) for x, share in shares]
227
+ key_bytes[byte_pos] = self._lagrange_interpolate(points, 0)
228
+
229
+ return bytes(key_bytes)
230
+
231
+ def _lagrange_interpolate(self, points: List[tuple], x: int) -> int:
232
+ """Lagrange interpolation at x in GF(2^8)."""
233
+ result = 0
234
+ for i, (xi, yi) in enumerate(points):
235
+ numerator = denominator = 1
236
+ for j, (xj, _) in enumerate(points):
237
+ if i != j:
238
+ numerator = self._gf_mul(numerator, x ^ xj)
239
+ denominator = self._gf_mul(denominator, xi ^ xj)
240
+ lagrange_coeff = self._gf_div(numerator, denominator)
241
+ result ^= self._gf_mul(yi, lagrange_coeff)
242
+ return result
243
+
244
+ def zero_key(self, key: bytearray) -> None:
245
+ """Zero key material in memory (SEC-13-L-01: portable memoryview approach)."""
246
+ if isinstance(key, bytearray):
247
+ memoryview(key)[:] = b'\x00' * len(key)
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Encrypted share persistence
252
+ # ---------------------------------------------------------------------------
253
+
254
+
255
+ class KeyShareStore:
256
+ """Encrypted share persistence using AES-256-GCM + Argon2id.
257
+
258
+ Requirements:
259
+ - MUST encrypt every share before writing to any backend.
260
+ - MUST bind share to its colony_id and share_index via AAD.
261
+ - MUST support multiple backends (local file, remote node, backup file).
262
+ - MUST detect and reject share transplantation (wrong colony_id or index).
263
+ """
264
+
265
+ def __init__(
266
+ self,
267
+ colony_id: str,
268
+ network_id: str,
269
+ backends: List[ShareBackend],
270
+ ) -> None:
271
+ self.colony_id = colony_id
272
+ self.network_id = network_id
273
+ self.backends = backends
274
+
275
+ def _aad(self, share_index: int) -> bytes:
276
+ """Compute authenticated additional data binding share to context."""
277
+ return (
278
+ bytes.fromhex(self.colony_id)
279
+ + bytes([share_index])
280
+ + bytes.fromhex(self.network_id)
281
+ )
282
+
283
+ def encrypt_share(
284
+ self,
285
+ raw_share: bytes,
286
+ share_index: int,
287
+ n: int,
288
+ k: int,
289
+ passphrase: bytes,
290
+ ) -> KeyShare:
291
+ """Encrypt a raw share blob.
292
+
293
+ Args:
294
+ raw_share: 32-byte raw share from ShamirKeyManager.split().
295
+ share_index: 1-based index of this share.
296
+ n: Total shares in the scheme.
297
+ k: Reconstruction threshold.
298
+ passphrase: Bytes passphrase for Argon2id KDF.
299
+
300
+ Returns:
301
+ KeyShare with encrypted ciphertext and tag set.
302
+ """
303
+ from argon2.low_level import Type, hash_secret_raw
304
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
305
+
306
+ salt = os.urandom(16)
307
+ key = hash_secret_raw(
308
+ secret=passphrase,
309
+ salt=salt,
310
+ time_cost=3,
311
+ memory_cost=65536,
312
+ parallelism=4,
313
+ hash_len=32,
314
+ type=Type.ID,
315
+ )
316
+ nonce = os.urandom(12)
317
+ aesgcm = AESGCM(key)
318
+ aad = self._aad(share_index)
319
+ ct_with_tag = aesgcm.encrypt(nonce, raw_share, aad)
320
+ ciphertext = ct_with_tag[:-16]
321
+ tag = ct_with_tag[-16:]
322
+
323
+ share = KeyShare(
324
+ colony_id=self.colony_id,
325
+ share_index=share_index,
326
+ n=n,
327
+ k=k,
328
+ argon2_salt=salt,
329
+ aes_nonce=nonce,
330
+ ciphertext=ciphertext,
331
+ tag=tag,
332
+ )
333
+
334
+ # Zero derived key (SEC-13-L-01: portable memoryview approach)
335
+ key_buf = bytearray(key)
336
+ memoryview(key_buf)[:] = b'\x00' * len(key_buf)
337
+
338
+ return share
339
+
340
+ def decrypt_share(self, share: KeyShare, passphrase: bytes) -> bytes:
341
+ """Decrypt a KeyShare back to raw share bytes.
342
+
343
+ Raises:
344
+ ValueError: If colony_id mismatch (transplantation attack).
345
+ cryptography.exceptions.InvalidTag: If decryption fails.
346
+ """
347
+ if share.colony_id != self.colony_id:
348
+ raise ValueError(
349
+ f"Share colony_id mismatch: expected {self.colony_id}, "
350
+ f"got {share.colony_id}"
351
+ )
352
+
353
+ from argon2.low_level import Type, hash_secret_raw
354
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
355
+
356
+ key = hash_secret_raw(
357
+ secret=passphrase,
358
+ salt=share.argon2_salt,
359
+ time_cost=3,
360
+ memory_cost=65536,
361
+ parallelism=4,
362
+ hash_len=32,
363
+ type=Type.ID,
364
+ )
365
+ aesgcm = AESGCM(key)
366
+ aad = self._aad(share.share_index)
367
+ raw = aesgcm.decrypt(
368
+ share.aes_nonce,
369
+ share.ciphertext + share.tag,
370
+ aad,
371
+ )
372
+
373
+ # Zero derived key (SEC-13-L-01: portable memoryview approach)
374
+ key_buf = bytearray(key)
375
+ memoryview(key_buf)[:] = b'\x00' * len(key_buf)
376
+
377
+ return raw
378
+
379
+ def distribute(self, encrypted_shares: List[KeyShare]) -> None:
380
+ """Distribute encrypted shares across configured backends round-robin."""
381
+ for i, share in enumerate(encrypted_shares):
382
+ backend = self.backends[i % len(self.backends)]
383
+ backend.store_share(share)
384
+
385
+ def collect(
386
+ self, k: int, passphrase_fn: Callable[[int], bytes]
387
+ ) -> List[tuple]:
388
+ """Collect and decrypt at least k shares from available backends.
389
+
390
+ Args:
391
+ k: Minimum shares to collect.
392
+ passphrase_fn: Callable(share_index) -> passphrase bytes.
393
+
394
+ Returns:
395
+ List of (share_index, raw_share_bytes) tuples.
396
+
397
+ Raises:
398
+ RuntimeError: If fewer than k shares can be collected.
399
+ """
400
+ raw_shares: List[tuple] = []
401
+ seen_indices: set = set()
402
+
403
+ for backend in self.backends:
404
+ indices = backend.list_shares(self.colony_id)
405
+ for idx in indices:
406
+ if idx in seen_indices:
407
+ continue
408
+ share = backend.retrieve_share(self.colony_id, idx)
409
+ if share is None:
410
+ continue
411
+ passphrase = passphrase_fn(idx)
412
+ try:
413
+ raw = self.decrypt_share(share, passphrase)
414
+ raw_shares.append((idx, raw))
415
+ seen_indices.add(idx)
416
+ except Exception:
417
+ continue
418
+ if len(raw_shares) >= k:
419
+ break
420
+ if len(raw_shares) >= k:
421
+ break
422
+
423
+ if len(raw_shares) < k:
424
+ raise RuntimeError(
425
+ f"Could only collect {len(raw_shares)} shares, need {k}. "
426
+ "Check that enough share backends are reachable."
427
+ )
428
+ return raw_shares
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # High-level key manager
433
+ # ---------------------------------------------------------------------------
434
+
435
+
436
+ class ColonyKeyManager:
437
+ """High-level manager for colony signing operations.
438
+
439
+ Owns the split/reconstruct/sign lifecycle. The private key is NEVER
440
+ stored on this object between signing calls.
441
+
442
+ Requirements:
443
+ - MUST reconstruct key in memory only.
444
+ - MUST zero key immediately after signing.
445
+ - MUST NOT cache the reconstructed private key between calls.
446
+ """
447
+
448
+ def __init__(
449
+ self,
450
+ colony_id: str,
451
+ shamir: ShamirKeyManager,
452
+ store: KeyShareStore,
453
+ k: int,
454
+ passphrase_fn: Callable[[int], bytes],
455
+ audit_log: Optional[Callable[[str], None]] = None,
456
+ ) -> None:
457
+ self.colony_id = colony_id
458
+ self.shamir = shamir
459
+ self.store = store
460
+ self.k = k
461
+ self.passphrase_fn = passphrase_fn
462
+ self._audit = audit_log or (lambda msg: None)
463
+
464
+ def sign(self, payload: bytes) -> str:
465
+ """Sign payload bytes, returning hex-encoded Ed25519 signature."""
466
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
467
+ Ed25519PrivateKey,
468
+ )
469
+
470
+ self._audit(f"key_reconstruct colony={self.colony_id}")
471
+ raw_shares = self.store.collect(self.k, self.passphrase_fn)
472
+ raw_key = bytearray(
473
+ self.shamir.reconstruct([(idx, s) for idx, s in raw_shares], self.k)
474
+ )
475
+
476
+ try:
477
+ private_key = Ed25519PrivateKey.from_private_bytes(bytes(raw_key))
478
+ signature = private_key.sign(payload)
479
+ return signature.hex()
480
+ finally:
481
+ self.shamir.zero_key(raw_key)
482
+ self._audit(f"key_zeroed colony={self.colony_id}")
483
+
484
+ def public_key_hex(self) -> str:
485
+ """Derive public key from reconstructed private key."""
486
+ from cryptography.hazmat.primitives import serialization
487
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
488
+ Ed25519PrivateKey,
489
+ )
490
+
491
+ raw_shares = self.store.collect(self.k, self.passphrase_fn)
492
+ raw_key = bytearray(
493
+ self.shamir.reconstruct([(idx, s) for idx, s in raw_shares], self.k)
494
+ )
495
+
496
+ try:
497
+ private_key = Ed25519PrivateKey.from_private_bytes(bytes(raw_key))
498
+ pub = private_key.public_key().public_bytes(
499
+ encoding=serialization.Encoding.Raw,
500
+ format=serialization.PublicFormat.Raw,
501
+ )
502
+ return pub.hex()
503
+ finally:
504
+ self.shamir.zero_key(raw_key)
505
+
506
+ def rotate(
507
+ self,
508
+ new_private_key: bytes,
509
+ n: int,
510
+ passphrase_fn: Optional[Callable[[int], bytes]] = None,
511
+ ) -> str:
512
+ """Rotate to a new private key: split, encrypt, distribute.
513
+
514
+ Signs a colony_rotate_key payload with the OLD key first,
515
+ then replaces shares. Returns hex-encoded new public key.
516
+
517
+ Args:
518
+ new_private_key: 32-byte new Ed25519 private key.
519
+ n: Number of shares for the new scheme.
520
+ passphrase_fn: Passphrase callable for new shares (defaults to existing).
521
+
522
+ Returns:
523
+ Hex-encoded new public key.
524
+ """
525
+ from cryptography.hazmat.primitives import serialization
526
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
527
+ Ed25519PrivateKey,
528
+ )
529
+
530
+ pf = passphrase_fn or self.passphrase_fn
531
+
532
+ # Derive new public key
533
+ new_pk = Ed25519PrivateKey.from_private_bytes(new_private_key)
534
+ new_pubkey_hex = new_pk.public_key().public_bytes(
535
+ encoding=serialization.Encoding.Raw,
536
+ format=serialization.PublicFormat.Raw,
537
+ ).hex()
538
+
539
+ # Split new key and encrypt shares
540
+ new_shares = self.shamir.split(new_private_key, n, self.k)
541
+ encrypted: List[KeyShare] = []
542
+ for x, raw_share in new_shares:
543
+ enc = self.store.encrypt_share(raw_share, x, n, self.k, pf(x))
544
+ encrypted.append(enc)
545
+
546
+ # Delete old shares from all backends
547
+ old_indices = []
548
+ for backend in self.store.backends:
549
+ old_indices.extend(backend.list_shares(self.colony_id))
550
+ for backend in self.store.backends:
551
+ for idx in set(old_indices):
552
+ backend.delete_share(self.colony_id, idx)
553
+
554
+ # Distribute new shares
555
+ self.store.distribute(encrypted)
556
+
557
+ # Update passphrase_fn if new one was provided
558
+ if passphrase_fn is not None:
559
+ self.passphrase_fn = passphrase_fn
560
+
561
+ logger.info("Key rotation complete for colony %s", self.colony_id)
562
+ return new_pubkey_hex
563
+
564
+
565
+ # ---------------------------------------------------------------------------
566
+ # Share backends
567
+ # ---------------------------------------------------------------------------
568
+
569
+
570
+ class LocalFileShareBackend:
571
+ """Stores encrypted shares as JSON files on the local filesystem.
572
+
573
+ Share files are stored at: data_dir / "shares" / f"share_{index:02d}.json"
574
+ """
575
+
576
+ def __init__(self, data_dir: Path) -> None:
577
+ self.data_dir = data_dir
578
+ self.shares_dir = data_dir / "shares"
579
+ self.shares_dir.mkdir(parents=True, exist_ok=True)
580
+
581
+ def store_share(self, share: KeyShare) -> None:
582
+ path = self.shares_dir / f"share_{share.share_index:02d}.json"
583
+ path.write_text(json.dumps(share.to_dict(), indent=2))
584
+ path.chmod(0o600)
585
+
586
+ def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
587
+ path = self.shares_dir / f"share_{share_index:02d}.json"
588
+ if not path.exists():
589
+ return None
590
+ return KeyShare.from_dict(json.loads(path.read_text()))
591
+
592
+ def delete_share(self, colony_id: str, share_index: int) -> None:
593
+ path = self.shares_dir / f"share_{share_index:02d}.json"
594
+ if path.exists():
595
+ # Overwrite with random bytes before deletion
596
+ path.write_bytes(os.urandom(path.stat().st_size))
597
+ path.unlink()
598
+
599
+ def list_shares(self, colony_id: str) -> List[int]:
600
+ indices = []
601
+ for f in self.shares_dir.glob("share_*.json"):
602
+ try:
603
+ indices.append(int(f.stem.split("_")[1]))
604
+ except (IndexError, ValueError):
605
+ continue
606
+ return sorted(indices)
607
+
608
+
609
+ class RemoteNodeShareBackend:
610
+ """Stores encrypted shares on a remote mesh node via mTLS HTTP API.
611
+
612
+ Uses httpx with mutual TLS (client cert + server CA verification) for
613
+ authenticated share transport across Colony mesh nodes.
614
+
615
+ API contract (served by the remote node at BASE_PATH):
616
+ PUT /{colony_id}/{share_index} — store or replace a share
617
+ GET /{colony_id}/{share_index} — retrieve a share (404 → None)
618
+ DELETE /{colony_id}/{share_index} — delete a share (404 → silent)
619
+ GET /{colony_id} — list indices {"indices": [...]}
620
+
621
+ Args:
622
+ node_id: Identifier for the remote peer (used in log messages).
623
+ endpoint: Base URL of the remote node, e.g. ``https://host:9443``.
624
+ client_cert: mTLS client identity — ``(cert_path, key_path)`` or
625
+ ``(cert_path, key_path, password)``. ``None`` disables
626
+ client-side certificate.
627
+ ca_cert: Path to the CA bundle used to verify the server cert,
628
+ ``True`` (default trust store) or ``False`` (skip verify).
629
+ ``None`` leaves httpx at its default (True).
630
+ timeout: Per-request timeout in seconds.
631
+ max_retries: Number of attempts before giving up on transient errors.
632
+ """
633
+
634
+ BASE_PATH = "/internal/shares"
635
+ DEFAULT_TIMEOUT: float = 10.0
636
+ DEFAULT_MAX_RETRIES: int = 3
637
+ RETRY_BACKOFF_BASE: float = 0.5 # seconds; doubles each attempt
638
+
639
+ def __init__(
640
+ self,
641
+ node_id: str,
642
+ endpoint: str,
643
+ client_cert: Optional[Union[Tuple[str, str], Tuple[str, str, str]]] = None,
644
+ ca_cert: Optional[Union[str, bool]] = None,
645
+ timeout: float = DEFAULT_TIMEOUT,
646
+ max_retries: int = DEFAULT_MAX_RETRIES,
647
+ # Legacy kwarg accepted but ignored — mTLS is now configured via
648
+ # client_cert / ca_cert.
649
+ mtls_ctx=None,
650
+ ) -> None:
651
+ if _httpx is None:
652
+ raise RuntimeError(
653
+ "httpx is required for RemoteNodeShareBackend. "
654
+ "Install it with: pip install httpx"
655
+ )
656
+ self.node_id = node_id
657
+ self.endpoint = endpoint.rstrip("/")
658
+ self._client_cert = client_cert
659
+ self._ca_cert = ca_cert
660
+ self._timeout = timeout
661
+ self._max_retries = max_retries
662
+ self._client: Optional["_httpx.Client"] = None
663
+
664
+ # ------------------------------------------------------------------
665
+ # Client lifecycle
666
+ # ------------------------------------------------------------------
667
+
668
+ def _build_client(self) -> "_httpx.Client":
669
+ """Construct an httpx.Client with the configured mTLS parameters."""
670
+ kwargs: dict = {"timeout": self._timeout}
671
+ if self._ca_cert is not None:
672
+ kwargs["verify"] = self._ca_cert
673
+ if self._client_cert is not None:
674
+ kwargs["cert"] = self._client_cert
675
+ return _httpx.Client(**kwargs)
676
+
677
+ @property
678
+ def _http(self) -> "_httpx.Client":
679
+ """Return the active client, rebuilding lazily if needed."""
680
+ if self._client is None or self._client.is_closed:
681
+ self._client = self._build_client()
682
+ return self._client
683
+
684
+ def rotate_certificates(
685
+ self,
686
+ client_cert: Optional[Union[Tuple[str, str], Tuple[str, str, str]]] = None,
687
+ ca_cert: Optional[Union[str, bool]] = None,
688
+ ) -> None:
689
+ """Swap TLS certificates. The next request will use the new certs.
690
+
691
+ Only the provided arguments are updated; omit a parameter to leave it
692
+ unchanged.
693
+ """
694
+ if client_cert is not None:
695
+ self._client_cert = client_cert
696
+ if ca_cert is not None:
697
+ self._ca_cert = ca_cert
698
+ # Tear down the existing client; new one is built lazily.
699
+ if self._client is not None and not self._client.is_closed:
700
+ self._client.close()
701
+ self._client = None
702
+
703
+ def close(self) -> None:
704
+ """Close the underlying HTTP client."""
705
+ if self._client is not None and not self._client.is_closed:
706
+ self._client.close()
707
+ self._client = None
708
+
709
+ def __enter__(self) -> "RemoteNodeShareBackend":
710
+ return self
711
+
712
+ def __exit__(self, *_) -> None:
713
+ self.close()
714
+
715
+ # ------------------------------------------------------------------
716
+ # Internal request helper with exponential-backoff retry
717
+ # ------------------------------------------------------------------
718
+
719
+ def _request(self, method: str, path: str, **kwargs) -> "_httpx.Response":
720
+ """Execute a request against the remote node, retrying on transient failures.
721
+
722
+ 4xx responses are *not* retried (caller error).
723
+ 5xx, timeouts, and network errors are retried up to ``max_retries`` times.
724
+
725
+ Raises:
726
+ httpx.HTTPStatusError: On a non-retryable HTTP error (4xx).
727
+ RuntimeError: When all retry attempts are exhausted.
728
+ """
729
+ url = f"{self.endpoint}{path}"
730
+ last_exc: Optional[Exception] = None
731
+
732
+ for attempt in range(self._max_retries):
733
+ try:
734
+ resp = self._http.request(method, url, **kwargs)
735
+ resp.raise_for_status()
736
+ return resp
737
+ except _httpx.HTTPStatusError as exc:
738
+ if exc.response.status_code < 500:
739
+ raise # 4xx — don't retry
740
+ last_exc = exc
741
+ logger.warning(
742
+ "Server error (attempt %d/%d) %s %s: HTTP %d",
743
+ attempt + 1, self._max_retries, method, url,
744
+ exc.response.status_code,
745
+ )
746
+ except _httpx.TimeoutException as exc:
747
+ last_exc = exc
748
+ logger.warning(
749
+ "Timeout (attempt %d/%d) %s %s",
750
+ attempt + 1, self._max_retries, method, url,
751
+ )
752
+ except _httpx.NetworkError as exc:
753
+ last_exc = exc
754
+ logger.warning(
755
+ "Network error (attempt %d/%d) %s %s: %s",
756
+ attempt + 1, self._max_retries, method, url, exc,
757
+ )
758
+ # Recreate client — the connection may be in a broken state.
759
+ if self._client is not None and not self._client.is_closed:
760
+ self._client.close()
761
+ self._client = None
762
+
763
+ if attempt < self._max_retries - 1:
764
+ time.sleep(self.RETRY_BACKOFF_BASE * (2 ** attempt))
765
+
766
+ raise RuntimeError(
767
+ f"Remote share backend '{self.node_id}' unreachable after "
768
+ f"{self._max_retries} attempt(s): {last_exc}"
769
+ ) from last_exc
770
+
771
+ # ------------------------------------------------------------------
772
+ # ShareBackend interface
773
+ # ------------------------------------------------------------------
774
+
775
+ def store_share(self, share: KeyShare) -> None:
776
+ """Persist a share to the remote node via PUT."""
777
+ try:
778
+ self._request(
779
+ "PUT",
780
+ f"{self.BASE_PATH}/{share.colony_id}/{share.share_index}",
781
+ json=share.to_dict(),
782
+ )
783
+ except Exception as exc:
784
+ logger.error(
785
+ "store_share failed [node=%s colony=%s index=%d]: %s",
786
+ self.node_id, share.colony_id, share.share_index, exc,
787
+ )
788
+ raise
789
+
790
+ def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
791
+ """Retrieve a share from the remote node. Returns ``None`` if absent."""
792
+ try:
793
+ resp = self._request(
794
+ "GET",
795
+ f"{self.BASE_PATH}/{colony_id}/{share_index}",
796
+ )
797
+ return KeyShare.from_dict(resp.json())
798
+ except _httpx.HTTPStatusError as exc:
799
+ if exc.response.status_code == 404:
800
+ return None
801
+ logger.error(
802
+ "retrieve_share failed [node=%s colony=%s index=%d]: HTTP %d",
803
+ self.node_id, colony_id, share_index, exc.response.status_code,
804
+ )
805
+ return None
806
+ except Exception as exc:
807
+ logger.error(
808
+ "retrieve_share failed [node=%s colony=%s index=%d]: %s",
809
+ self.node_id, colony_id, share_index, exc,
810
+ )
811
+ return None
812
+
813
+ def delete_share(self, colony_id: str, share_index: int) -> None:
814
+ """Delete a share from the remote node. 404 is silently ignored."""
815
+ try:
816
+ self._request(
817
+ "DELETE",
818
+ f"{self.BASE_PATH}/{colony_id}/{share_index}",
819
+ )
820
+ except _httpx.HTTPStatusError as exc:
821
+ if exc.response.status_code == 404:
822
+ return # Already absent — treat as success.
823
+ logger.error(
824
+ "delete_share failed [node=%s colony=%s index=%d]: HTTP %d",
825
+ self.node_id, colony_id, share_index, exc.response.status_code,
826
+ )
827
+ raise
828
+ except Exception as exc:
829
+ logger.error(
830
+ "delete_share failed [node=%s colony=%s index=%d]: %s",
831
+ self.node_id, colony_id, share_index, exc,
832
+ )
833
+ raise
834
+
835
+ def list_shares(self, colony_id: str) -> List[int]:
836
+ """Return sorted share indices held by the remote node.
837
+
838
+ Returns an empty list on any transport failure so callers degrade
839
+ gracefully during network partitions.
840
+ """
841
+ try:
842
+ resp = self._request("GET", f"{self.BASE_PATH}/{colony_id}")
843
+ return sorted(resp.json().get("indices", []))
844
+ except Exception as exc:
845
+ logger.error(
846
+ "list_shares failed [node=%s colony=%s]: %s",
847
+ self.node_id, colony_id, exc,
848
+ )
849
+ return []
850
+
851
+
852
+ class BackupFileShareBackend:
853
+ """Read/write a portable .colonyshare backup file.
854
+
855
+ Holds exactly one share. Writes are atomic (tmp + os.replace) and
856
+ include a SHA-256 checksum of the share payload so tampering can be
857
+ detected on read.
858
+
859
+ ``colony_id`` and ``network_id`` must be provided either at
860
+ construction or via an existing file on disk; otherwise ``store_share``
861
+ cannot build a valid payload.
862
+ """
863
+
864
+ _FILE_TYPE = "colony_key_share"
865
+ _FILE_VERSION = 2 # v2 adds checksum
866
+
867
+ def __init__(
868
+ self,
869
+ file_path: Path,
870
+ colony_id: Optional[str] = None,
871
+ network_id: Optional[str] = None,
872
+ *,
873
+ overwrite: bool = False,
874
+ ) -> None:
875
+ self.file_path = Path(file_path)
876
+ self._share: Optional[KeyShare] = None
877
+ self._colony_id = colony_id
878
+ self._network_id = network_id
879
+ self._overwrite = overwrite
880
+
881
+ @staticmethod
882
+ def _checksum(share_dict: dict) -> str:
883
+ import hashlib
884
+ payload = json.dumps(share_dict, sort_keys=True).encode("utf-8")
885
+ return hashlib.sha256(payload).hexdigest()
886
+
887
+ def _load(self) -> None:
888
+ if self._share is None and self.file_path.exists():
889
+ data = json.loads(self.file_path.read_text())
890
+ share_dict = data["share"]
891
+ # If a checksum is present (v2+), verify it.
892
+ stored = data.get("checksum")
893
+ if stored is not None and stored != self._checksum(share_dict):
894
+ raise ValueError(
895
+ f"Checksum mismatch in backup file {self.file_path}"
896
+ )
897
+ self._share = KeyShare.from_dict(share_dict)
898
+ # Populate colony/network IDs from file if not set at ctor.
899
+ if self._colony_id is None:
900
+ self._colony_id = data.get("colony_id")
901
+ if self._network_id is None:
902
+ self._network_id = data.get("network_id")
903
+
904
+ def store_share(self, share: KeyShare) -> None:
905
+ """Atomically write ``share`` to the backup file.
906
+
907
+ Refuses to overwrite a file holding a *different* share_index
908
+ unless the backend was constructed with ``overwrite=True``.
909
+ """
910
+ # Resolve metadata — prefer ctor values, fall back to existing file.
911
+ if self._colony_id is None or self._network_id is None:
912
+ self._load()
913
+ if self._colony_id is None or self._network_id is None:
914
+ raise ValueError(
915
+ "colony_id and network_id are required to write a backup file"
916
+ )
917
+
918
+ if self.file_path.exists() and not self._overwrite:
919
+ try:
920
+ existing = json.loads(self.file_path.read_text())
921
+ existing_index = existing.get("share", {}).get("share_index")
922
+ if existing_index is not None and existing_index != share.share_index:
923
+ raise ValueError(
924
+ f"Backup already holds share_index={existing_index}; "
925
+ f"refusing to overwrite with share_index={share.share_index}"
926
+ )
927
+ except (json.JSONDecodeError, KeyError):
928
+ pass # Corrupt file; will be replaced.
929
+
930
+ share_dict = share.to_dict()
931
+ data = {
932
+ "file_type": self._FILE_TYPE,
933
+ "file_version": self._FILE_VERSION,
934
+ "colony_id": self._colony_id,
935
+ "network_id": self._network_id,
936
+ "created_at": datetime.now(timezone.utc).isoformat(),
937
+ "share": share_dict,
938
+ "checksum": self._checksum(share_dict),
939
+ }
940
+
941
+ tmp_path = self.file_path.with_suffix(self.file_path.suffix + ".tmp")
942
+ tmp_path.parent.mkdir(parents=True, exist_ok=True)
943
+ tmp_path.write_text(json.dumps(data, indent=2))
944
+ tmp_path.chmod(0o600)
945
+ os.replace(tmp_path, self.file_path)
946
+ self._share = share
947
+
948
+ def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
949
+ self._load()
950
+ if self._share and self._share.share_index == share_index:
951
+ return self._share
952
+ return None
953
+
954
+ def delete_share(self, colony_id: str, share_index: int) -> None:
955
+ self._load()
956
+ if self._share and self._share.share_index == share_index:
957
+ try:
958
+ self.file_path.unlink()
959
+ except FileNotFoundError:
960
+ pass
961
+ self._share = None
962
+
963
+ def list_shares(self, colony_id: str) -> List[int]:
964
+ self._load()
965
+ if self._share:
966
+ return [self._share.share_index]
967
+ return []
968
+
969
+
970
+ class InMemoryShareBackend:
971
+ """In-memory backend for testing."""
972
+
973
+ def __init__(self) -> None:
974
+ self._shares: dict = {} # share_index -> KeyShare
975
+
976
+ def store_share(self, share: KeyShare) -> None:
977
+ self._shares[share.share_index] = share
978
+
979
+ def retrieve_share(self, colony_id: str, share_index: int) -> Optional[KeyShare]:
980
+ return self._shares.get(share_index)
981
+
982
+ def delete_share(self, colony_id: str, share_index: int) -> None:
983
+ self._shares.pop(share_index, None)
984
+
985
+ def list_shares(self, colony_id: str) -> List[int]:
986
+ return sorted(self._shares.keys())
987
+
988
+
989
+ # ---------------------------------------------------------------------------
990
+ # Backup file helpers
991
+ # ---------------------------------------------------------------------------
992
+
993
+
994
+ def export_share_file(
995
+ share: KeyShare,
996
+ colony_id: str,
997
+ network_id: str,
998
+ output_path: Path,
999
+ ) -> None:
1000
+ """Write a .colonyshare backup file."""
1001
+ data = {
1002
+ "file_type": "colony_key_share",
1003
+ "file_version": 1,
1004
+ "colony_id": colony_id,
1005
+ "network_id": network_id,
1006
+ "created_at": datetime.now(timezone.utc).isoformat(),
1007
+ "share": share.to_dict(),
1008
+ }
1009
+ output_path.write_text(json.dumps(data, indent=2))
1010
+ output_path.chmod(0o600)
1011
+
1012
+
1013
+ def import_share_file(file_path: Path) -> tuple:
1014
+ """Read a .colonyshare backup file.
1015
+
1016
+ Returns:
1017
+ (colony_id, network_id, KeyShare) tuple.
1018
+ """
1019
+ data = json.loads(file_path.read_text())
1020
+ if data.get("file_type") != "colony_key_share":
1021
+ raise ValueError(f"Not a colony_key_share file: {file_path}")
1022
+ colony_id = data["colony_id"]
1023
+ network_id = data["network_id"]
1024
+ share = KeyShare.from_dict(data["share"])
1025
+ return colony_id, network_id, share