@remnic/core 9.4.2 → 9.5.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 (337) hide show
  1. package/dist/access-admin-ops-surface.d.ts +5 -5
  2. package/dist/access-admin-ops-surface.js +26 -26
  3. package/dist/access-boundary.d.ts +26 -7
  4. package/dist/access-boundary.js +31 -27
  5. package/dist/access-cli.js +79 -77
  6. package/dist/access-cli.js.map +1 -1
  7. package/dist/access-http.d.ts +51 -5
  8. package/dist/access-http.js +38 -36
  9. package/dist/access-identity-continuity-surface.d.ts +4 -4
  10. package/dist/access-identity-continuity-surface.js +26 -26
  11. package/dist/access-lcm-surface.d.ts +5 -5
  12. package/dist/access-lcm-surface.js +26 -26
  13. package/dist/access-mcp.d.ts +15 -5
  14. package/dist/access-mcp.js +35 -33
  15. package/dist/access-observe-write-surface.d.ts +5 -5
  16. package/dist/access-observe-write-surface.js +26 -26
  17. package/dist/access-operations-batch.js +31 -30
  18. package/dist/access-operations.d.ts +7 -7
  19. package/dist/access-operations.js +33 -32
  20. package/dist/access-recall-surface.d.ts +5 -5
  21. package/dist/access-recall-surface.js +26 -26
  22. package/dist/access-schema.js +4 -4
  23. package/dist/{access-service-m-2VO_Ig.d.ts → access-service-DZX0sYAv.d.ts} +3 -3
  24. package/dist/access-service.d.ts +5 -5
  25. package/dist/access-service.js +26 -26
  26. package/dist/access-surface-catalog.d.ts +5 -5
  27. package/dist/access-surface-catalog.js +1 -0
  28. package/dist/access-surface-catalog.js.map +1 -1
  29. package/dist/access-token-capabilities.d.ts +174 -0
  30. package/dist/access-token-capabilities.js +33 -0
  31. package/dist/action-confidence.d.ts +1 -1
  32. package/dist/active-memory-bridge.d.ts +1 -1
  33. package/dist/active-recall.d.ts +1 -1
  34. package/dist/active-recall.js +1 -1
  35. package/dist/adapters/index.js +4 -4
  36. package/dist/adapters/registry.js +2 -2
  37. package/dist/{auto-sync-HMHQHUAG.js → auto-sync-G6IU7L6C.js} +3 -3
  38. package/dist/behavior-learner.d.ts +1 -1
  39. package/dist/behavior-signals.d.ts +1 -1
  40. package/dist/bootstrap.d.ts +4 -4
  41. package/dist/briefing.d.ts +1 -1
  42. package/dist/briefing.js +7 -7
  43. package/dist/buffer-surprise-report.d.ts +1 -1
  44. package/dist/buffer.d.ts +1 -1
  45. package/dist/calibration.d.ts +1 -1
  46. package/dist/capabilities.d.ts +1 -1
  47. package/dist/{capsule-crypto-CZJSLEFG.js → capsule-crypto-RAARDY3M.js} +2 -2
  48. package/dist/capsule-crypto-RAARDY3M.js.map +1 -0
  49. package/dist/{catalog-BxWokdC2.d.ts → catalog-DoH2b0OR.d.ts} +1 -1
  50. package/dist/causal-behavior.d.ts +1 -1
  51. package/dist/causal-behavior.js +5 -5
  52. package/dist/causal-chain.js +3 -3
  53. package/dist/causal-consolidation.d.ts +1 -1
  54. package/dist/causal-consolidation.js +11 -11
  55. package/dist/causal-retrieval.js +3 -3
  56. package/dist/causal-trajectory-graph.d.ts +1 -1
  57. package/dist/causal-trajectory.js +1 -1
  58. package/dist/{chunk-7QWMTOIF.js → chunk-2B7DP7AP.js} +31 -31
  59. package/dist/{chunk-4BFRBP45.js → chunk-2JFDHI2K.js} +2 -2
  60. package/dist/{chunk-OHDJ34CC.js → chunk-2SFEFR5I.js} +2 -2
  61. package/dist/{chunk-E2SPGGUI.js → chunk-4OKLXWDZ.js} +13 -6
  62. package/dist/chunk-4OKLXWDZ.js.map +1 -0
  63. package/dist/{chunk-ILFJ7AVS.js → chunk-4WNLSSHL.js} +35 -21
  64. package/dist/chunk-4WNLSSHL.js.map +1 -0
  65. package/dist/{chunk-YDSWRD33.js → chunk-5K5VEDFY.js} +4 -4
  66. package/dist/{chunk-CZAZX7JP.js → chunk-7BAJAXGB.js} +4 -4
  67. package/dist/{chunk-CXLNZXG5.js → chunk-7XGSIUP5.js} +1 -1
  68. package/dist/{chunk-ZPOROUTX.js → chunk-AOYJVCFN.js} +5 -5
  69. package/dist/{chunk-IC6QX5TC.js → chunk-BGMSRN6I.js} +5 -5
  70. package/dist/{chunk-5QZDMSSN.js → chunk-CXEL3Y22.js} +70 -70
  71. package/dist/{chunk-M5QKGHCR.js → chunk-CYWCKFCL.js} +5 -5
  72. package/dist/{chunk-2U2A5HF4.js → chunk-D2XYYU5Q.js} +5 -5
  73. package/dist/chunk-D7FOKIY5.js +173 -0
  74. package/dist/chunk-D7FOKIY5.js.map +1 -0
  75. package/dist/{chunk-DGYCWCQ7.js → chunk-DZSAXE3T.js} +2 -2
  76. package/dist/{chunk-2BFLDH5F.js → chunk-EEIROWEG.js} +2 -2
  77. package/dist/{chunk-6ZIETXZ2.js → chunk-H5PFR5ZT.js} +2 -2
  78. package/dist/{chunk-2FOD2YY3.js → chunk-I74ZEBMA.js} +236 -103
  79. package/dist/chunk-I74ZEBMA.js.map +1 -0
  80. package/dist/chunk-IC2TCZJ2.js +54 -0
  81. package/dist/chunk-IC2TCZJ2.js.map +1 -0
  82. package/dist/{chunk-66FLU5BT.js → chunk-II6WHCCW.js} +6 -6
  83. package/dist/{chunk-YPEMNNNY.js → chunk-ITWTKT2R.js} +6 -6
  84. package/dist/{chunk-AKUCU7CI.js → chunk-JI5AVXYO.js} +5 -5
  85. package/dist/{chunk-27FLVMDC.js → chunk-KMF67QDQ.js} +7 -7
  86. package/dist/{chunk-FLVL4L3L.js → chunk-KTULX4YO.js} +4 -4
  87. package/dist/{chunk-FZGGZAAU.js → chunk-L2QJQSHL.js} +146 -2
  88. package/dist/chunk-L2QJQSHL.js.map +1 -0
  89. package/dist/{chunk-4P4626RY.js → chunk-L4UZBJNM.js} +4 -4
  90. package/dist/{chunk-JLR3JSTL.js → chunk-L6V3WJ55.js} +3 -3
  91. package/dist/{chunk-6BISDEN6.js → chunk-LNHJ32NK.js} +14 -14
  92. package/dist/{chunk-437S3G37.js → chunk-LNQUAJNC.js} +1 -1
  93. package/dist/{chunk-LOR6ZM5R.js → chunk-MFDJ63U5.js} +2 -2
  94. package/dist/{chunk-HRCACVHL.js → chunk-NFTABV56.js} +2 -2
  95. package/dist/{chunk-OUMTBFSQ.js → chunk-NKCPUHCI.js} +5 -5
  96. package/dist/{chunk-KCJ5H6XQ.js → chunk-ODSYJXDU.js} +2 -2
  97. package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
  98. package/dist/{chunk-NG7YBCJO.js → chunk-SNBEVJBX.js} +4 -4
  99. package/dist/{chunk-CZAA5N5S.js → chunk-SNNIUDRS.js} +2 -2
  100. package/dist/{chunk-P6SYWE4G.js → chunk-STCCFQU5.js} +9 -9
  101. package/dist/{chunk-X4A62ET7.js → chunk-TI7IHDBQ.js} +17 -17
  102. package/dist/{chunk-BGTAL46Y.js → chunk-TV7C3HCB.js} +2 -2
  103. package/dist/{chunk-EPGWWCZF.js → chunk-UBGAOYBP.js} +6 -6
  104. package/dist/{chunk-M7GGMGMZ.js → chunk-UDCDGP6A.js} +15 -15
  105. package/dist/{chunk-2MR3MFQB.js → chunk-UPSAT4PY.js} +2 -2
  106. package/dist/{chunk-CGS7UKWM.js → chunk-VCW74PNH.js} +2 -2
  107. package/dist/{chunk-DR6PEJU3.js → chunk-VWEWK2BM.js} +44 -59
  108. package/dist/chunk-VWEWK2BM.js.map +1 -0
  109. package/dist/{chunk-CLX5VDNY.js → chunk-Y4RKAQZ7.js} +4 -4
  110. package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
  111. package/dist/{cli-EiBBOt3r.d.ts → cli-1dghPCO2.d.ts} +3 -3
  112. package/dist/cli.d.ts +6 -6
  113. package/dist/cli.js +57 -55
  114. package/dist/compounding/engine.d.ts +1 -1
  115. package/dist/compounding/engine.js +7 -7
  116. package/dist/compounding/preference-consolidator.d.ts +1 -1
  117. package/dist/compression-optimizer.d.ts +1 -1
  118. package/dist/config.d.ts +1 -1
  119. package/dist/config.js +1 -1
  120. package/dist/connectors/codex-materialize-runner.d.ts +1 -1
  121. package/dist/connectors/codex-materialize-runner.js +7 -7
  122. package/dist/connectors/codex-materialize.d.ts +1 -1
  123. package/dist/connectors/index.d.ts +1 -1
  124. package/dist/connectors/index.js +11 -9
  125. package/dist/consolidation-provenance-check.d.ts +1 -1
  126. package/dist/consolidation-undo.d.ts +1 -1
  127. package/dist/contradiction/index.d.ts +1 -1
  128. package/dist/contradiction/index.js +4 -4
  129. package/dist/conversation-index/backend.d.ts +1 -1
  130. package/dist/conversation-index/backend.js +2 -2
  131. package/dist/conversation-index/chunker.d.ts +1 -1
  132. package/dist/conversation-index/faiss-adapter.d.ts +1 -1
  133. package/dist/conversation-index/indexer.d.ts +1 -1
  134. package/dist/conversation-index/search.d.ts +1 -1
  135. package/dist/dashboard-runtime.js +2 -2
  136. package/dist/day-summary.d.ts +1 -1
  137. package/dist/delinearize.d.ts +1 -1
  138. package/dist/direct-answer-wiring.d.ts +1 -1
  139. package/dist/direct-answer.d.ts +1 -1
  140. package/dist/embedding-fallback.d.ts +1 -1
  141. package/dist/enrichment/index.d.ts +1 -1
  142. package/dist/entity-retrieval.d.ts +1 -1
  143. package/dist/entity-retrieval.js +7 -7
  144. package/dist/entity-schema.d.ts +1 -1
  145. package/dist/explicit-capture.d.ts +4 -4
  146. package/dist/extraction-faithfulness.d.ts +1 -1
  147. package/dist/extraction-judge-telemetry.d.ts +1 -1
  148. package/dist/extraction-judge-training.d.ts +1 -1
  149. package/dist/extraction-judge.d.ts +1 -1
  150. package/dist/extraction.d.ts +1 -1
  151. package/dist/fallback-llm.d.ts +1 -1
  152. package/dist/{first-start-migration-DY6YCRC2.js → first-start-migration-VBJQL6OI.js} +5 -5
  153. package/dist/graph-dashboard-diff.d.ts +1 -1
  154. package/dist/graph-dashboard-key.d.ts +1 -1
  155. package/dist/graph-dashboard-parser.d.ts +1 -1
  156. package/dist/graph-edge-reinforcement.d.ts +1 -1
  157. package/dist/graph-snapshot.d.ts +1 -1
  158. package/dist/graph.d.ts +1 -1
  159. package/dist/identity-continuity.d.ts +1 -1
  160. package/dist/importance.d.ts +1 -1
  161. package/dist/index.d.ts +13 -10
  162. package/dist/index.js +115 -93
  163. package/dist/intent.d.ts +1 -1
  164. package/dist/lcm/engine.d.ts +1 -1
  165. package/dist/lcm/engine.js +2 -2
  166. package/dist/lcm/index.d.ts +1 -1
  167. package/dist/lcm/index.js +2 -2
  168. package/dist/lcm/tools.d.ts +1 -1
  169. package/dist/lifecycle.d.ts +1 -1
  170. package/dist/live-connectors-runner.d.ts +1 -1
  171. package/dist/local-llm.d.ts +1 -1
  172. package/dist/local-model-endpoint.d.ts +1 -1
  173. package/dist/maintenance/memory-governance.d.ts +1 -1
  174. package/dist/maintenance/memory-governance.js +8 -8
  175. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  176. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  177. package/dist/mcp-memory-inspector-app.d.ts +5 -5
  178. package/dist/mcp-read-only-tools.d.ts +16 -0
  179. package/dist/mcp-read-only-tools.js +8 -0
  180. package/dist/mcp-read-only-tools.js.map +1 -0
  181. package/dist/memory-action-policy.d.ts +1 -1
  182. package/dist/memory-cache.d.ts +1 -1
  183. package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
  184. package/dist/memory-projection-store.d.ts +1 -1
  185. package/dist/memory-provenance.d.ts +1 -1
  186. package/dist/memory-worth-outcomes.d.ts +1 -1
  187. package/dist/models-json.d.ts +1 -1
  188. package/dist/namespaces/migrate.d.ts +2 -2
  189. package/dist/namespaces/migrate.js +17 -17
  190. package/dist/namespaces/principal.d.ts +1 -1
  191. package/dist/namespaces/search.d.ts +1 -1
  192. package/dist/namespaces/search.js +9 -9
  193. package/dist/namespaces/storage.d.ts +2 -2
  194. package/dist/namespaces/storage.js +7 -7
  195. package/dist/native-knowledge.d.ts +1 -1
  196. package/dist/operator-toolkit.d.ts +1 -1
  197. package/dist/operator-toolkit.js +22 -22
  198. package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
  199. package/dist/orchestration/maintenance.d.ts +2 -2
  200. package/dist/orchestration/maintenance.js +9 -9
  201. package/dist/{orchestrator-B2Y28Z9t.d.ts → orchestrator-Dh3WSW_O.d.ts} +3 -3
  202. package/dist/orchestrator.d.ts +4 -4
  203. package/dist/orchestrator.js +81 -79
  204. package/dist/patterns-cli.d.ts +1 -1
  205. package/dist/policy-runtime.d.ts +1 -1
  206. package/dist/provenance.d.ts +1 -1
  207. package/dist/{qmd-Db9mOkzB.d.ts → qmd-DoUVxqND.d.ts} +1 -1
  208. package/dist/qmd-preflight.d.ts +2 -2
  209. package/dist/qmd-recall-cache.d.ts +1 -1
  210. package/dist/qmd.d.ts +2 -2
  211. package/dist/recall-disclosure-escalation.d.ts +1 -1
  212. package/dist/recall-explain-renderer.d.ts +1 -1
  213. package/dist/recall-planner-llm.d.ts +1 -1
  214. package/dist/recall-state.d.ts +1 -1
  215. package/dist/recall-tag-filter.d.ts +1 -1
  216. package/dist/recall-timings.d.ts +1 -1
  217. package/dist/recall-xray-cli.d.ts +1 -1
  218. package/dist/recall-xray-renderer.d.ts +1 -1
  219. package/dist/recall-xray.d.ts +1 -1
  220. package/dist/resolve-auth-token.d.ts +1 -1
  221. package/dist/resume-bundles.js +2 -2
  222. package/dist/retrieval-agents.d.ts +2 -2
  223. package/dist/retrieval-tiers.d.ts +1 -1
  224. package/dist/routing/engine.d.ts +1 -1
  225. package/dist/routing/store.d.ts +1 -1
  226. package/dist/search/embed-helper.d.ts +1 -1
  227. package/dist/search/factory.d.ts +1 -1
  228. package/dist/search/factory.js +8 -8
  229. package/dist/search/index.d.ts +1 -1
  230. package/dist/search/index.js +12 -12
  231. package/dist/search/lancedb-backend.d.ts +1 -1
  232. package/dist/search/lancedb-backend.js +2 -2
  233. package/dist/search/meilisearch-backend.d.ts +1 -1
  234. package/dist/search/meilisearch-backend.js +2 -2
  235. package/dist/search/noop-backend.d.ts +1 -1
  236. package/dist/search/orama-backend.d.ts +1 -1
  237. package/dist/search/orama-backend.js +2 -2
  238. package/dist/search/port.d.ts +1 -1
  239. package/dist/search/remote-backend.d.ts +1 -1
  240. package/dist/{semantic-consolidation-BDVIbLRw.d.ts → semantic-consolidation-Dof1865o.d.ts} +1 -1
  241. package/dist/semantic-consolidation.d.ts +2 -2
  242. package/dist/semantic-consolidation.js +9 -9
  243. package/dist/semantic-rule-promotion.js +7 -7
  244. package/dist/semantic-rule-verifier.d.ts +1 -1
  245. package/dist/semantic-rule-verifier.js +8 -8
  246. package/dist/session-observer-bands.d.ts +1 -1
  247. package/dist/session-observer-state.d.ts +1 -1
  248. package/dist/shared-context/manager.d.ts +1 -1
  249. package/dist/signal.d.ts +1 -1
  250. package/dist/storage.d.ts +1 -1
  251. package/dist/storage.js +6 -6
  252. package/dist/summarizer.d.ts +1 -1
  253. package/dist/summary-snapshot.d.ts +1 -1
  254. package/dist/temporal-supersession.d.ts +1 -1
  255. package/dist/temporal-validity.d.ts +1 -1
  256. package/dist/threading.d.ts +1 -1
  257. package/dist/tier-migration.d.ts +1 -1
  258. package/dist/tier-routing.d.ts +1 -1
  259. package/dist/{tier-stats-TG6GUZ6N.js → tier-stats-5XF4UBRQ.js} +4 -4
  260. package/dist/tokens.d.ts +17 -2
  261. package/dist/tokens.js +3 -1
  262. package/dist/topics.d.ts +1 -1
  263. package/dist/transcript.d.ts +1 -1
  264. package/dist/transfer/backup.js +2 -2
  265. package/dist/transfer/capsule-export.js +3 -3
  266. package/dist/transfer/capsule-import.js +3 -3
  267. package/dist/trust-score-stage.d.ts +1 -1
  268. package/dist/trust-score.d.ts +1 -1
  269. package/dist/{types-ljkd82rn.d.ts → types-C7HUgOuA.d.ts} +1 -1
  270. package/dist/types.d.ts +1 -1
  271. package/dist/utility-runtime.d.ts +1 -1
  272. package/dist/verified-recall.js +8 -8
  273. package/package.json +2 -2
  274. package/src/access-boundary.test.ts +62 -0
  275. package/src/access-boundary.ts +165 -126
  276. package/src/access-http.test.ts +781 -0
  277. package/src/access-http.ts +334 -108
  278. package/src/access-mcp.test.ts +264 -0
  279. package/src/access-mcp.ts +45 -70
  280. package/src/access-operations-batch.ts +35 -12
  281. package/src/access-surface-catalog.test.ts +22 -14
  282. package/src/access-surface-catalog.ts +6 -3
  283. package/src/access-token-capabilities.test.ts +844 -0
  284. package/src/access-token-capabilities.ts +391 -0
  285. package/src/chat/chat-mcp.test.ts +232 -0
  286. package/src/chat/chat-session.ts +34 -0
  287. package/src/index.ts +14 -0
  288. package/src/mcp-read-only-tools.ts +61 -0
  289. package/src/tokens.ts +32 -3
  290. package/dist/chunk-2FOD2YY3.js.map +0 -1
  291. package/dist/chunk-DR6PEJU3.js.map +0 -1
  292. package/dist/chunk-E2SPGGUI.js.map +0 -1
  293. package/dist/chunk-FZGGZAAU.js.map +0 -1
  294. package/dist/chunk-ILFJ7AVS.js.map +0 -1
  295. /package/dist/{capsule-crypto-CZJSLEFG.js.map → access-token-capabilities.js.map} +0 -0
  296. /package/dist/{auto-sync-HMHQHUAG.js.map → auto-sync-G6IU7L6C.js.map} +0 -0
  297. /package/dist/{chunk-7QWMTOIF.js.map → chunk-2B7DP7AP.js.map} +0 -0
  298. /package/dist/{chunk-4BFRBP45.js.map → chunk-2JFDHI2K.js.map} +0 -0
  299. /package/dist/{chunk-OHDJ34CC.js.map → chunk-2SFEFR5I.js.map} +0 -0
  300. /package/dist/{chunk-YDSWRD33.js.map → chunk-5K5VEDFY.js.map} +0 -0
  301. /package/dist/{chunk-CZAZX7JP.js.map → chunk-7BAJAXGB.js.map} +0 -0
  302. /package/dist/{chunk-CXLNZXG5.js.map → chunk-7XGSIUP5.js.map} +0 -0
  303. /package/dist/{chunk-ZPOROUTX.js.map → chunk-AOYJVCFN.js.map} +0 -0
  304. /package/dist/{chunk-IC6QX5TC.js.map → chunk-BGMSRN6I.js.map} +0 -0
  305. /package/dist/{chunk-5QZDMSSN.js.map → chunk-CXEL3Y22.js.map} +0 -0
  306. /package/dist/{chunk-M5QKGHCR.js.map → chunk-CYWCKFCL.js.map} +0 -0
  307. /package/dist/{chunk-2U2A5HF4.js.map → chunk-D2XYYU5Q.js.map} +0 -0
  308. /package/dist/{chunk-DGYCWCQ7.js.map → chunk-DZSAXE3T.js.map} +0 -0
  309. /package/dist/{chunk-2BFLDH5F.js.map → chunk-EEIROWEG.js.map} +0 -0
  310. /package/dist/{chunk-6ZIETXZ2.js.map → chunk-H5PFR5ZT.js.map} +0 -0
  311. /package/dist/{chunk-66FLU5BT.js.map → chunk-II6WHCCW.js.map} +0 -0
  312. /package/dist/{chunk-YPEMNNNY.js.map → chunk-ITWTKT2R.js.map} +0 -0
  313. /package/dist/{chunk-AKUCU7CI.js.map → chunk-JI5AVXYO.js.map} +0 -0
  314. /package/dist/{chunk-27FLVMDC.js.map → chunk-KMF67QDQ.js.map} +0 -0
  315. /package/dist/{chunk-FLVL4L3L.js.map → chunk-KTULX4YO.js.map} +0 -0
  316. /package/dist/{chunk-4P4626RY.js.map → chunk-L4UZBJNM.js.map} +0 -0
  317. /package/dist/{chunk-JLR3JSTL.js.map → chunk-L6V3WJ55.js.map} +0 -0
  318. /package/dist/{chunk-6BISDEN6.js.map → chunk-LNHJ32NK.js.map} +0 -0
  319. /package/dist/{chunk-437S3G37.js.map → chunk-LNQUAJNC.js.map} +0 -0
  320. /package/dist/{chunk-LOR6ZM5R.js.map → chunk-MFDJ63U5.js.map} +0 -0
  321. /package/dist/{chunk-HRCACVHL.js.map → chunk-NFTABV56.js.map} +0 -0
  322. /package/dist/{chunk-OUMTBFSQ.js.map → chunk-NKCPUHCI.js.map} +0 -0
  323. /package/dist/{chunk-KCJ5H6XQ.js.map → chunk-ODSYJXDU.js.map} +0 -0
  324. /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
  325. /package/dist/{chunk-NG7YBCJO.js.map → chunk-SNBEVJBX.js.map} +0 -0
  326. /package/dist/{chunk-CZAA5N5S.js.map → chunk-SNNIUDRS.js.map} +0 -0
  327. /package/dist/{chunk-P6SYWE4G.js.map → chunk-STCCFQU5.js.map} +0 -0
  328. /package/dist/{chunk-X4A62ET7.js.map → chunk-TI7IHDBQ.js.map} +0 -0
  329. /package/dist/{chunk-BGTAL46Y.js.map → chunk-TV7C3HCB.js.map} +0 -0
  330. /package/dist/{chunk-EPGWWCZF.js.map → chunk-UBGAOYBP.js.map} +0 -0
  331. /package/dist/{chunk-M7GGMGMZ.js.map → chunk-UDCDGP6A.js.map} +0 -0
  332. /package/dist/{chunk-2MR3MFQB.js.map → chunk-UPSAT4PY.js.map} +0 -0
  333. /package/dist/{chunk-CGS7UKWM.js.map → chunk-VCW74PNH.js.map} +0 -0
  334. /package/dist/{chunk-CLX5VDNY.js.map → chunk-Y4RKAQZ7.js.map} +0 -0
  335. /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
  336. /package/dist/{first-start-migration-DY6YCRC2.js.map → first-start-migration-VBJQL6OI.js.map} +0 -0
  337. /package/dist/{tier-stats-TG6GUZ6N.js.map → tier-stats-5XF4UBRQ.js.map} +0 -0
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Per-token capabilities (issue #1837).
3
+ *
4
+ * A token entry MAY carry an explicit, versioned capabilities record. The
5
+ * record's PRESENCE is mechanically detectable, which is load-bearing for
6
+ * security:
7
+ *
8
+ * - ABSENT `capabilities` field ⇒ a PRE-FEATURE legacy entry (minted
9
+ * before this feature). It is UNRESTRICTED — full access — purely for
10
+ * backward compatibility. This is the ONLY path by which "full access"
11
+ * is granted by omission.
12
+ *
13
+ * - PRESENT `capabilities` record (always carries a `version` marker) ⇒ a
14
+ * token minted by the capability-aware path. Even a new token minted
15
+ * without `--ops`/`--namespaces` records an EXPLICIT unrestricted record
16
+ * (`{ version: 1 }`) — a deliberate, auditable choice, never an
17
+ * accidentally-omitted field. Within the record, the two axes follow
18
+ * absent-vs-present semantics:
19
+ * * axis ABSENT (e.g. no `ops` key) ⇒ unrestricted on that axis.
20
+ * * axis PRESENT and empty (`ops: []`) ⇒ deny all (fail-closed).
21
+ * * axis PRESENT and non-empty ⇒ allow only the listed values.
22
+ *
23
+ * Malformed capability input at mint time (bad version, non-array axes,
24
+ * non-string elements, unknown op names against the catalog, malformed
25
+ * namespace values) is REJECTED — a token is never created from invalid
26
+ * input (rule: invalid input rejected, not defaulted).
27
+ *
28
+ * This module owns the pure capability logic (shape validation, allow-list
29
+ * checks) and the per-request AsyncLocalStorage carrying the presenting
30
+ * token's resolved capabilities down to the access boundary. It imports
31
+ * nothing else in the package except the forbidden-error class, so it can be
32
+ * reused by the token store, the boundary, and the HTTP/MCP surfaces without
33
+ * cycles.
34
+ */
35
+
36
+ import { AsyncLocalStorage } from "node:async_hooks";
37
+ import { EngramAccessForbiddenError } from "./access-errors.js";
38
+
39
+ /** Current capability-record schema version. */
40
+ export const TOKEN_CAPABILITIES_VERSION = 1 as const;
41
+
42
+ /**
43
+ * Versioned capabilities record stored on a token entry. Presence of this
44
+ * record (vs. an absent field) is what distinguishes a capability-aware
45
+ * token from a pre-feature legacy entry.
46
+ */
47
+ export interface TokenCapabilities {
48
+ readonly version: typeof TOKEN_CAPABILITIES_VERSION;
49
+ /**
50
+ * Operations allow-list. `undefined` (key absent) ⇒ unrestricted on this
51
+ * axis. `[]` ⇒ deny all ops (fail-closed). Non-empty ⇒ allow only listed.
52
+ */
53
+ ops?: string[];
54
+ /**
55
+ * Namespaces allow-list. `undefined` ⇒ unrestricted. `[]` ⇒ deny all.
56
+ * Non-empty ⇒ allow only listed.
57
+ */
58
+ namespaces?: string[];
59
+ }
60
+
61
+ /**
62
+ * Namespace value validation. Namespace identifiers are flexible (default,
63
+ * team, self, principal-prefixed, project-prefixed) but must never contain
64
+ * whitespace, path separators, traversal segments, or control characters —
65
+ * such values are malformed at mint time and rejected, never silently
66
+ * coerced.
67
+ */
68
+ const NAMESPACE_VALUE_MAX_LENGTH = 128;
69
+ // Non-empty, no whitespace, no path separators, no control chars. `.` and
70
+ // `..` are rejected separately so legitimate dotted names (e.g. a domain)
71
+ // remain allowed while traversal is not.
72
+ const NAMESPACE_VALUE_PATTERN = /^[^\s\/\\\x00-\x1F]+$/;
73
+
74
+ /** True when `value` is an acceptable namespace allow-list entry. */
75
+ export function isValidNamespaceValue(value: unknown): value is string {
76
+ if (typeof value !== "string" || value.length === 0 || value.length > NAMESPACE_VALUE_MAX_LENGTH) {
77
+ return false;
78
+ }
79
+ if (!NAMESPACE_VALUE_PATTERN.test(value)) return false;
80
+ if (value === "." || value === "..") return false;
81
+ return true;
82
+ }
83
+
84
+ function isStringArray(value: unknown): value is unknown[] {
85
+ return Array.isArray(value) && value.every((v) => typeof v === "string");
86
+ }
87
+
88
+ function dedupeSort(values: string[]): string[] {
89
+ return [...new Set(values)].sort();
90
+ }
91
+
92
+ /**
93
+ * Parse one axis (ops or namespaces) for PERSISTENCE/LOAD. Returns the
94
+ * cleaned array (deduped, sorted; possibly empty to preserve "present ⇒
95
+ * fail-closed") or `undefined` when the key is absent. Throws on a malformed
96
+ * shape (non-array, non-string elements, malformed namespace values). Op
97
+ * validation against the catalog happens only at mint time.
98
+ */
99
+ function parseAxis(
100
+ obj: Record<string, unknown>,
101
+ key: "ops" | "namespaces",
102
+ validateValue: (v: unknown) => boolean,
103
+ valueLabel: string,
104
+ ): string[] | undefined {
105
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) return undefined;
106
+ const value = obj[key];
107
+ if (value === undefined || value === null) return undefined;
108
+ if (!isStringArray(value)) {
109
+ throw new Error(`capabilities ${key} must be an array of non-empty strings`);
110
+ }
111
+ const cleaned: string[] = [];
112
+ for (const v of value) {
113
+ if (typeof v !== "string" || v.length === 0) {
114
+ throw new Error(`capabilities ${key} must be an array of non-empty strings`);
115
+ }
116
+ if (!validateValue(v)) {
117
+ throw new Error(`capabilities ${key} contains a malformed ${valueLabel}: ${JSON.stringify(v)}`);
118
+ }
119
+ cleaned.push(v);
120
+ }
121
+ // Preserve presence: an explicitly empty array stays [] (fail-closed),
122
+ // NOT coerced to undefined (which would mean unrestricted).
123
+ return dedupeSort(cleaned);
124
+ }
125
+
126
+ /**
127
+ * Normalize a capabilities record for PERSISTENCE/LOAD. Returns `undefined`
128
+ * ONLY when the record is entirely absent (a pre-feature legacy entry ⇒
129
+ * unrestricted). When present, an absent/`undefined` `version` is COERCED to
130
+ * the current default (1) — mirroring {@link validateCapabilitiesForMint} — so
131
+ * a record that mint-validated also load-normalizes without a schema throw.
132
+ * An explicitly-present WRONG version (not 1) is still rejected. Throws on a
133
+ * malformed record (non-object, explicitly-wrong version, malformed axes).
134
+ */
135
+ export function normalizeCapabilities(raw: unknown): TokenCapabilities | undefined {
136
+ if (raw === undefined || raw === null) return undefined;
137
+ if (typeof raw !== "object" || Array.isArray(raw)) {
138
+ throw new Error("capabilities must be an object with a version marker");
139
+ }
140
+ const obj = raw as Record<string, unknown>;
141
+ // Coerce absent/undefined version → 1, mirroring validateCapabilitiesForMint
142
+ // (version 1 is the only schema version; coercing keeps mint-validated
143
+ // records reloadable). Only an explicitly-present WRONG version throws.
144
+ if (obj.version !== undefined && obj.version !== TOKEN_CAPABILITIES_VERSION) {
145
+ throw new Error(`capabilities.version must be ${TOKEN_CAPABILITIES_VERSION}`);
146
+ }
147
+ const ops = parseAxis(obj, "ops", () => true, "operation");
148
+ const namespaces = parseAxis(obj, "namespaces", isValidNamespaceValue, "namespace");
149
+ const result: TokenCapabilities = { version: TOKEN_CAPABILITIES_VERSION };
150
+ if (ops !== undefined) result.ops = ops;
151
+ if (namespaces !== undefined) result.namespaces = namespaces;
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * Validate a capabilities record at MINT time. ALWAYS returns a versioned
157
+ * record (never `undefined`) so every newly-minted token carries an explicit
158
+ * capability decision — a new token can never gain full access by OMITTING
159
+ * the field. When `raw` is absent/null, the result is an explicit
160
+ * unrestricted record (`{ version: 1 }`), a deliberate/auditable choice. A
161
+ * present-but-`undefined` (or absent) `version` is coerced to the current
162
+ * default (1); an explicitly-wrong version is rejected. This version rule is
163
+ * identical to {@link normalizeCapabilities} (mint/load symmetry). Rejects
164
+ * unknown op names against the catalog and malformed namespace values;
165
+ * throws a plain Error — the caller must NOT create a token when this throws.
166
+ */
167
+ export function validateCapabilitiesForMint(
168
+ raw: unknown,
169
+ validOps: readonly string[],
170
+ ): TokenCapabilities {
171
+ const result: TokenCapabilities = { version: TOKEN_CAPABILITIES_VERSION };
172
+ if (raw === undefined || raw === null) {
173
+ return result;
174
+ }
175
+ if (typeof raw !== "object" || Array.isArray(raw)) {
176
+ throw new Error("capabilities must be an object with optional ops/namespaces arrays");
177
+ }
178
+ const obj = raw as Record<string, unknown>;
179
+ if (Object.prototype.hasOwnProperty.call(obj, "version") && obj.version !== undefined) {
180
+ if (obj.version !== TOKEN_CAPABILITIES_VERSION) {
181
+ throw new Error(`capabilities.version must be ${TOKEN_CAPABILITIES_VERSION}`);
182
+ }
183
+ }
184
+ const validOpSet = new Set(validOps);
185
+
186
+ if (Object.prototype.hasOwnProperty.call(obj, "ops") && obj.ops !== undefined && obj.ops !== null) {
187
+ if (!isStringArray(obj.ops)) {
188
+ throw new Error("capabilities ops must be an array of non-empty strings");
189
+ }
190
+ const ops = obj.ops as unknown[];
191
+ for (const v of ops) {
192
+ if (typeof v !== "string" || v.length === 0) {
193
+ throw new Error("capabilities ops must be an array of non-empty strings");
194
+ }
195
+ }
196
+ // Reject unknown op names against the catalog (unknown ⇒ reject, never drop).
197
+ const unknown = ops.filter((op) => typeof op === "string" && !validOpSet.has(op));
198
+ if (unknown.length > 0) {
199
+ throw new Error(
200
+ `capabilities ops contains unknown operation name(s): ${unknown.map((o) => JSON.stringify(o)).join(", ")}`,
201
+ );
202
+ }
203
+ // Preserve presence (empty stays [] ⇒ deny all ops).
204
+ result.ops = dedupeSort(ops as string[]);
205
+ }
206
+
207
+ if (
208
+ Object.prototype.hasOwnProperty.call(obj, "namespaces") &&
209
+ obj.namespaces !== undefined &&
210
+ obj.namespaces !== null
211
+ ) {
212
+ if (!isStringArray(obj.namespaces)) {
213
+ throw new Error("capabilities namespaces must be an array of non-empty strings");
214
+ }
215
+ const namespaces = obj.namespaces as unknown[];
216
+ for (const v of namespaces) {
217
+ if (typeof v !== "string" || v.length === 0) {
218
+ throw new Error("capabilities namespaces must be an array of non-empty strings");
219
+ }
220
+ if (!isValidNamespaceValue(v)) {
221
+ throw new Error(`capabilities namespaces contains a malformed namespace: ${JSON.stringify(v)}`);
222
+ }
223
+ }
224
+ result.namespaces = dedupeSort(namespaces as string[]);
225
+ }
226
+
227
+ return result;
228
+ }
229
+
230
+ /**
231
+ * True when `caps` imposes any restriction (a present record with at least
232
+ * one axis key). `undefined` (legacy / absent record) ⇒ unrestricted. A
233
+ * present `{ version: 1 }` record with no axes ⇒ also unrestricted (but
234
+ * auditable — it is a deliberate new-token decision, not omission).
235
+ */
236
+ export function isCapabilityRestricted(caps: TokenCapabilities | undefined | null): boolean {
237
+ if (!caps) return false;
238
+ return caps.ops !== undefined || caps.namespaces !== undefined;
239
+ }
240
+
241
+ /**
242
+ * True when `caps` permits `op`.
243
+ * - record ABSENT (legacy) ⇒ unrestricted (true).
244
+ * - ops axis ABSENT ⇒ unrestricted on this axis (true).
245
+ * - ops axis PRESENT ⇒ `op` must be listed; `[]` ⇒ false (deny all).
246
+ */
247
+ export function capabilityAllowsOp(caps: TokenCapabilities | undefined | null, op: string): boolean {
248
+ if (caps?.ops === undefined) return true;
249
+ return caps.ops.includes(op);
250
+ }
251
+
252
+ /**
253
+ * True when `caps` permits `namespace`.
254
+ * - record ABSENT / namespaces axis ABSENT ⇒ unrestricted (true).
255
+ * - namespaces axis PRESENT ⇒ `namespace` must be listed; undefined
256
+ * namespace ⇒ false; `[]` ⇒ false (deny all).
257
+ */
258
+ export function capabilityAllowsNamespace(
259
+ caps: TokenCapabilities | undefined | null,
260
+ namespace: string | undefined,
261
+ ): boolean {
262
+ if (caps?.namespaces === undefined) return true;
263
+ if (namespace === undefined) return false;
264
+ return caps.namespaces.includes(namespace);
265
+ }
266
+
267
+ /**
268
+ * Throw {@link EngramAccessForbiddenError} when `caps` restricts ops and
269
+ * `op` is not permitted. No-op for unrestricted tokens.
270
+ */
271
+ export function assertOperationAllowed(
272
+ caps: TokenCapabilities | undefined | null,
273
+ op: string,
274
+ ): void {
275
+ if (!capabilityAllowsOp(caps, op)) {
276
+ throw new EngramAccessForbiddenError(
277
+ `token is not permitted to call operation: ${op}`,
278
+ );
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Throw {@link EngramAccessForbiddenError} when `caps` restricts namespaces
284
+ * and `namespace` is not permitted. No-op for unrestricted tokens.
285
+ */
286
+ export function assertNamespaceAllowed(
287
+ caps: TokenCapabilities | undefined | null,
288
+ namespace: string | undefined,
289
+ ): void {
290
+ if (!capabilityAllowsNamespace(caps, namespace)) {
291
+ throw new EngramAccessForbiddenError(
292
+ namespace === undefined
293
+ ? "token is scoped to specific namespaces and this operation requires an explicit namespace"
294
+ : `token is not permitted to access namespace: ${namespace}`,
295
+ );
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Resolve the EFFECTIVE namespace an operation runs against: a present,
301
+ * non-empty request namespace wins, otherwise the server default — the same
302
+ * resolution downstream storage applies
303
+ * (`getWritableStorageForNamespace(undefined)` → `defaultNamespace`). Empty
304
+ * strings are treated as absent. Centralized so every invocation surface
305
+ * (HTTP query/body, MCP `tools/call`, id-loaded records, legacy pairs that
306
+ * carry no namespace) applies ONE rule and none can dodge the check by
307
+ * dropping the param. (issue #1850)
308
+ */
309
+ export function resolveEffectiveNamespace(
310
+ namespace: string | undefined,
311
+ defaultNamespace: string | undefined,
312
+ ): string | undefined {
313
+ const normalized = namespace !== undefined && namespace.length > 0 ? namespace : undefined;
314
+ return normalized ?? defaultNamespace;
315
+ }
316
+
317
+ /**
318
+ * The single effective-namespace allow-list chokepoint (issues #1837/#1850).
319
+ * Throws {@link EngramAccessForbiddenError} when `caps` carries a namespaces
320
+ * allow-list and the EFFECTIVE namespace (request value OR server default) is
321
+ * not a member. FAIL CLOSED:
322
+ * - a scoped token whose allow-list does not cover the effective namespace
323
+ * (explicit OR defaulted) is rejected;
324
+ * - an absent server default (unconfigured) is also rejected for scoped
325
+ * tokens, so omitting `?namespace=` can never bypass the allow-list.
326
+ * No-op for unrestricted tokens (no namespaces axis) so legacy/unrestricted
327
+ * behavior is unchanged. Both the HTTP transport (`resolveNamespace`) and the
328
+ * MCP dispatch route through this ONE function so no surface is missed.
329
+ */
330
+ export function enforceNamespaceAllowList(
331
+ caps: TokenCapabilities | undefined | null,
332
+ namespace: string | undefined,
333
+ defaultNamespace: string | undefined,
334
+ ): void {
335
+ if (caps?.namespaces === undefined) return; // unrestricted token — unchanged
336
+ const effective = resolveEffectiveNamespace(namespace, defaultNamespace);
337
+ if (effective === undefined || !caps.namespaces.includes(effective)) {
338
+ throw new EngramAccessForbiddenError(
339
+ namespace === undefined
340
+ ? "token is scoped to specific namespaces; the server default namespace is not permitted — supply an allowed namespace"
341
+ : `token is not permitted to access namespace: ${namespace}`,
342
+ );
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Fleet-wide / global maintenance operation guard (issue #1850 round 10).
348
+ *
349
+ * A distinct escalation class from the id-addressed routes (round 9) and the
350
+ * param-namespace ops (round 4): some boundary operations inherently run
351
+ * ACROSS ALL namespaces — or against a single global layer (compression
352
+ * guidelines, shared context, compounding) that is not namespace-partitioned
353
+ * — and carry NO `namespace` argument, so the MCP `tools/call` effective-
354
+ * namespace chokepoint ({@link enforceNamespaceAllowList}, which only fires
355
+ * when `toolAcceptsNamespace`) never applies. Without this guard a bearer
356
+ * scoped to ONE tenant could trigger maintenance that mutates state in EVERY
357
+ * namespace (privilege escalation).
358
+ *
359
+ * Fail CLOSED exactly like the other shared guards: a namespace-SCOPED token
360
+ * (namespaces axis present) is rejected; unrestricted / legacy tokens (absent
361
+ * record or no namespaces axis — i.e. cron and internal callers that never
362
+ * bind a capability record) are unaffected. Reuses the EXISTING capability
363
+ * model via {@link capabilityAllowsNamespace} — no new "all"/"*" scope concept.
364
+ * Must run BEFORE any side effect so a denial never leaks a partial mutation.
365
+ */
366
+ export function assertFleetWideOperationAllowed(
367
+ caps: TokenCapabilities | undefined | null,
368
+ ): void {
369
+ // capabilityAllowsNamespace returns false for ANY scoped token when the
370
+ // namespace is undefined (a fleet-wide op has no single namespace), and
371
+ // true for unrestricted/legacy tokens — exactly the desired policy.
372
+ if (!capabilityAllowsNamespace(caps, undefined)) {
373
+ throw new EngramAccessForbiddenError(
374
+ "token is scoped to specific namespaces; this maintenance operation runs across all namespaces and is not permitted for a namespace-scoped token",
375
+ );
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Per-request AsyncLocalStorage carrying the presenting token's resolved
381
+ * capabilities. The HTTP surface binds the resolved capabilities with
382
+ * `.run(caps, handler)` for the WHOLE authorized request dispatch (NOT
383
+ * `.enterWith()`, which mutates the current async resource and does not
384
+ * reliably isolate the store across the handler's awaits / concurrent
385
+ * requests — risking a fail-open read of undefined mid-handler); the
386
+ * access boundary reads `.getStore()` to enforce the ops allow-list for
387
+ * every boundary operation without each dispatch site threading the value
388
+ * explicitly. Unset (CLI, direct boundary callers, tests) ⇒ undefined ⇒
389
+ * unrestricted (issue #1850 round 7).
390
+ */
391
+ export const tokenCapabilityStore = new AsyncLocalStorage<TokenCapabilities | undefined>();
@@ -13,6 +13,7 @@ import { test } from "node:test";
13
13
 
14
14
  import { EngramMcpServer } from "../access-mcp.js";
15
15
  import type { EngramAccessService } from "../access-service.js";
16
+ import { tokenCapabilityStore } from "../access-token-capabilities.js";
16
17
  import { parseConfig } from "../config.js";
17
18
  import { DEFAULT_CHAT_CONFIG } from "./chat-config.js";
18
19
 
@@ -133,3 +134,234 @@ test("MCP memory_chat: strips internal error from structuredContent (Thread 17)"
133
134
  assert.ok(typeof parsed.reply === "string", "reply must be present");
134
135
  await rm(memoryDir, { recursive: true, force: true });
135
136
  });
137
+
138
+ // ──────────────────────────────────────────────────────────────────────────
139
+ // Issue #1850 round 6: MCP memory_chat dispatches via processChatMessage,
140
+ // bypassing op.run() (the boundary hook every other callTool branch routes
141
+ // through). It must still enforce the per-token ops allow-list (op-gate) AND
142
+ // the chat-session namespace, matching the HTTP /engram/v1/chat/message route.
143
+ // ──────────────────────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Narrow a tools/call JSON-RPC response into just the fields the r6 tests
147
+ * assert on. Reads through `Record<string, unknown>` + `typeof` guards rather
148
+ * than an inline fabricated-shape cast, so a malformed envelope can never read
149
+ * a property the compiler never verified.
150
+ */
151
+ function readToolOutcome(response: Record<string, unknown> | null): {
152
+ isError: boolean;
153
+ text: string;
154
+ } {
155
+ const result = response?.result;
156
+ if (!(result && typeof result === "object")) return { isError: false, text: "" };
157
+ const r = result as Record<string, unknown>;
158
+ const content = r.content;
159
+ const firstEntry = Array.isArray(content) ? content[0] : undefined;
160
+ const firstText =
161
+ firstEntry && typeof firstEntry === "object" && firstEntry !== null
162
+ ? (firstEntry as Record<string, unknown>).text
163
+ : undefined;
164
+ return {
165
+ isError: r.isError === true,
166
+ text: typeof firstText === "string" ? firstText : "",
167
+ };
168
+ }
169
+
170
+ function makeChatService(memoryDir: string, llm: { chatCompletion: () => Promise<unknown> }): EngramAccessService {
171
+ return makeBaseService({
172
+ fallbackLlmRef: llm,
173
+ memoryDir,
174
+ configRef: parseConfig({ memoryDir, chat: { ...DEFAULT_CHAT_CONFIG, enabled: true } }),
175
+ });
176
+ }
177
+
178
+ test("MCP memory_chat: deny-all (ops:[]) token is rejected before dispatch (issue #1850 r6)", async () => {
179
+ const memoryDir = await mkdtemp(join(tmpdir(), "chat-mcp-opgate-deny-"));
180
+ let dispatched = false;
181
+ const service = makeChatService(memoryDir, {
182
+ chatCompletion: async () => {
183
+ dispatched = true;
184
+ return { content: "x" };
185
+ },
186
+ });
187
+ const server = new EngramMcpServer(service, { chatVisible: true, principal: "alice" });
188
+
189
+ const outcome = await tokenCapabilityStore.run({ version: 1, ops: [] }, async () =>
190
+ readToolOutcome(await server.handleRequest(makeCallRequest("engram.memory_chat", { message: "hi" }))),
191
+ );
192
+ assert.equal(outcome.isError, true, "a deny-all token must be rejected at the op-gate");
193
+ assert.match(outcome.text, /not permitted to call operation/i);
194
+ assert.equal(dispatched, false, "processChatMessage (the LLM) must never be reached");
195
+ await rm(memoryDir, { recursive: true, force: true });
196
+ });
197
+
198
+ test("MCP memory_chat: ops-scoped token without chat_message is rejected (issue #1850 r6)", async () => {
199
+ const memoryDir = await mkdtemp(join(tmpdir(), "chat-mcp-opgate-scope-"));
200
+ let dispatched = false;
201
+ const service = makeChatService(memoryDir, {
202
+ chatCompletion: async () => {
203
+ dispatched = true;
204
+ return { content: "x" };
205
+ },
206
+ });
207
+ const server = new EngramMcpServer(service, { chatVisible: true, principal: "alice" });
208
+
209
+ // Token permits recall but NOT chat_message.
210
+ const outcome = await tokenCapabilityStore.run({ version: 1, ops: ["recall"] }, async () =>
211
+ readToolOutcome(await server.handleRequest(makeCallRequest("engram.memory_chat", { message: "hi" }))),
212
+ );
213
+ assert.equal(outcome.isError, true, "an ops-scoped token without chat_message must be rejected");
214
+ assert.match(outcome.text, /not permitted to call operation/i);
215
+ assert.equal(dispatched, false, "processChatMessage (the LLM) must never be reached");
216
+ await rm(memoryDir, { recursive: true, force: true });
217
+ });
218
+
219
+ test("MCP memory_chat: ops-scoped token WITH chat_message is allowed (issue #1850 r6)", async () => {
220
+ const memoryDir = await mkdtemp(join(tmpdir(), "chat-mcp-opgate-allow-"));
221
+ const service = makeChatService(memoryDir, { chatCompletion: async () => ({ content: "sure [id: x]." }) });
222
+ const server = new EngramMcpServer(service, { chatVisible: true, principal: "alice" });
223
+
224
+ const outcome = await tokenCapabilityStore.run({ version: 1, ops: ["chat_message"] }, async () =>
225
+ readToolOutcome(await server.handleRequest(makeCallRequest("engram.memory_chat", { message: "hi" }))),
226
+ );
227
+ assert.equal(outcome.isError, false, "a token whose ops include chat_message must pass the gate");
228
+ const parsed = JSON.parse(outcome.text) as Record<string, unknown>;
229
+ assert.ok(typeof parsed.reply === "string" && (parsed.reply as string).length > 0, "a reply must be produced");
230
+ await rm(memoryDir, { recursive: true, force: true });
231
+ });
232
+
233
+ test("MCP memory_chat: unrestricted token is allowed (issue #1850 r6)", async () => {
234
+ const memoryDir = await mkdtemp(join(tmpdir(), "chat-mcp-unrestricted-"));
235
+ const service = makeChatService(memoryDir, { chatCompletion: async () => ({ content: "hello [id: y]." }) });
236
+ const server = new EngramMcpServer(service, { chatVisible: true, principal: "alice" });
237
+
238
+ // Explicit-unrestricted record (version present, no axes) ⇒ both gates no-op.
239
+ const outcome = await tokenCapabilityStore.run({ version: 1 }, async () =>
240
+ readToolOutcome(await server.handleRequest(makeCallRequest("engram.memory_chat", { message: "hi" }))),
241
+ );
242
+ assert.equal(outcome.isError, false, "an unrestricted token must pass both gates");
243
+ const parsed = JSON.parse(outcome.text) as Record<string, unknown>;
244
+ assert.ok(typeof parsed.reply === "string", "a reply must be produced");
245
+ await rm(memoryDir, { recursive: true, force: true });
246
+ });
247
+
248
+ test("MCP memory_chat: namespace-scoped token cannot resume a cross-namespace session (issue #1850 r6)", async () => {
249
+ const memoryDir = await mkdtemp(join(tmpdir(), "chat-mcp-xnamespace-"));
250
+ let dispatchCount = 0;
251
+ const llm = {
252
+ chatCompletion: async () => {
253
+ dispatchCount += 1;
254
+ return { content: "ok [id: z]." };
255
+ },
256
+ };
257
+ const service = makeChatService(memoryDir, llm);
258
+ const server = new EngramMcpServer(service, { chatVisible: true, principal: "alice" });
259
+
260
+ // 1. Create a session bound to namespace "team-a" under an unrestricted token.
261
+ const createOutcome = readToolOutcome(
262
+ await server.handleRequest(makeCallRequest("engram.memory_chat", { message: "remember this" }), {
263
+ namespaceOverride: "team-a",
264
+ }),
265
+ );
266
+ assert.equal(createOutcome.isError, false, "session creation under an unrestricted token must succeed");
267
+ const created = JSON.parse(createOutcome.text) as Record<string, unknown>;
268
+ assert.ok(typeof created.chatSessionId === "string", "a chatSessionId must be returned to resume");
269
+ const chatSessionId = created.chatSessionId as string;
270
+ assert.equal(dispatchCount, 1, "the create call dispatched the LLM exactly once");
271
+
272
+ // 2. Resume that session under a token scoped to a DIFFERENT namespace. The
273
+ // resumed session is an id-loaded record whose stored namespace ("team-a")
274
+ // must be gated against the token's allow-list (["team-b"]) — rejected.
275
+ const resumeOutcome = await tokenCapabilityStore.run({ version: 1, namespaces: ["team-b"] }, async () =>
276
+ readToolOutcome(
277
+ await server.handleRequest(
278
+ makeCallRequest("engram.memory_chat", { message: "follow up", chatSessionId }),
279
+ ),
280
+ ),
281
+ );
282
+ assert.equal(resumeOutcome.isError, true, "a cross-namespace resume must be rejected");
283
+ assert.match(resumeOutcome.text, /not permitted to access namespace/i);
284
+ assert.equal(dispatchCount, 1, "the rejected resume must not have dispatched the LLM a second time");
285
+ await rm(memoryDir, { recursive: true, force: true });
286
+ });
287
+
288
+ test("MCP memory_chat: NEW session namespace is gated — scoped token cannot start a chat in a disallowed namespace (issue #1850 r8)", async () => {
289
+ // The resume path already gates the session's stored namespace (r6). A NEW
290
+ // chat turn (no chatSessionId) previously forwarded scope.namespace straight
291
+ // to processChatMessage with NO allow-list gate, so a namespace-scoped token
292
+ // could mint a session in a namespace outside its allow-list. The new-session
293
+ // path must resolve the EFFECTIVE namespace (scope.namespace OR server
294
+ // default) and fail closed against the token allow-list — same chokepoint the
295
+ // HTTP /engram/v1/chat/message route gained in r7.
296
+ const memoryDir = await mkdtemp(join(tmpdir(), "chat-mcp-new-ns-gate-"));
297
+ let dispatchCount = 0;
298
+ const llm = {
299
+ chatCompletion: async () => {
300
+ dispatchCount += 1;
301
+ return { content: "ok [id: new]." };
302
+ },
303
+ };
304
+ // namespacesEnabled + a server default OUTSIDE the scoped token's allow-list,
305
+ // so the fail-closed-against-default case is exercisable.
306
+ const service = makeBaseService({
307
+ fallbackLlmRef: llm,
308
+ memoryDir,
309
+ configRef: parseConfig({
310
+ memoryDir,
311
+ namespacesEnabled: true,
312
+ defaultNamespace: "default",
313
+ chat: { ...DEFAULT_CHAT_CONFIG, enabled: true },
314
+ }),
315
+ });
316
+ const server = new EngramMcpServer(service, { chatVisible: true, principal: "alice" });
317
+
318
+ // (1) DISALLOWED explicit namespace: token scoped to ["team-a"], but the new
319
+ // session would be created under scope.namespace "team-b" (not in the
320
+ // allow-list) → rejected BEFORE processChatMessage (LLM never reached).
321
+ const deniedExplicit = await tokenCapabilityStore.run(
322
+ { version: 1, ops: ["chat_message"], namespaces: ["team-a"] },
323
+ async () =>
324
+ readToolOutcome(
325
+ await server.handleRequest(
326
+ makeCallRequest("engram.memory_chat", { message: "hi" }),
327
+ { namespaceOverride: "team-b" },
328
+ ),
329
+ ),
330
+ );
331
+ assert.equal(deniedExplicit.isError, true, "a scoped token must not start a new chat in a disallowed namespace");
332
+ assert.match(deniedExplicit.text, /not permitted to access namespace/i);
333
+ assert.equal(dispatchCount, 0, "the denied new chat must not dispatch the LLM");
334
+
335
+ // (2) ALLOWED explicit namespace: same token, scope.namespace "team-a" (IN
336
+ // the allow-list) → gate passes, chat runs, returns a reply + session id.
337
+ const allowedExplicit = await tokenCapabilityStore.run(
338
+ { version: 1, ops: ["chat_message"], namespaces: ["team-a"] },
339
+ async () =>
340
+ readToolOutcome(
341
+ await server.handleRequest(
342
+ makeCallRequest("engram.memory_chat", { message: "hi" }),
343
+ { namespaceOverride: "team-a" },
344
+ ),
345
+ ),
346
+ );
347
+ assert.equal(allowedExplicit.isError, false, "a scoped token may start a new chat in an allowed namespace");
348
+ const allowedParsed = JSON.parse(allowedExplicit.text) as Record<string, unknown>;
349
+ assert.ok(typeof allowedParsed.reply === "string", "the allowed new chat returns a reply");
350
+ assert.ok(typeof allowedParsed.chatSessionId === "string", "the allowed new chat returns a chatSessionId");
351
+ assert.equal(dispatchCount, 1, "only the allowed new chat dispatched the LLM");
352
+
353
+ // (3) FAIL CLOSED against the server default: same token scoped to ["team-a"],
354
+ // NEW chat with NO scope.namespace → effective namespace is the server
355
+ // default ("default"), not in ["team-a"] → rejected. Mirrors the HTTP r7
356
+ // new-session gate (omitting the param cannot bypass it).
357
+ const deniedDefault = await tokenCapabilityStore.run(
358
+ { version: 1, ops: ["chat_message"], namespaces: ["team-a"] },
359
+ async () =>
360
+ readToolOutcome(await server.handleRequest(makeCallRequest("engram.memory_chat", { message: "hi" }))),
361
+ );
362
+ assert.equal(deniedDefault.isError, true, "omitting scope.namespace must fail closed against the server default");
363
+ assert.match(deniedDefault.text, /server default namespace is not permitted/i);
364
+ assert.equal(dispatchCount, 1, "the denied-against-default new chat must not dispatch the LLM");
365
+
366
+ await rm(memoryDir, { recursive: true, force: true });
367
+ });
@@ -11,6 +11,7 @@ import { mkdir, readFile, appendFile, readdir, stat, unlink, open } from "node:f
11
11
  import { constants as fsConstants } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { randomUUID } from "node:crypto";
14
+ import { enforceNamespaceAllowList, tokenCapabilityStore } from "../access-token-capabilities.js";
14
15
 
15
16
  import type { ChatSessionState, ChatTranscriptEntry } from "./chat-types.js";
16
17
 
@@ -211,6 +212,39 @@ export async function loadChatSession(
211
212
  };
212
213
  }
213
214
 
215
+ /**
216
+ * Structural shape {@link enforceChatSessionNamespace} reads from a service —
217
+ * `memoryDir` to locate the session file and `configRef.defaultNamespace` to
218
+ * resolve the effective namespace. Kept structural so the chat module need not
219
+ * depend on the full {@link EngramAccessService} type.
220
+ */
221
+ export interface ChatSessionNamespaceHost {
222
+ memoryDir: string;
223
+ configRef?: { defaultNamespace?: string } | undefined;
224
+ }
225
+
226
+ /**
227
+ * The shared effective-namespace chokepoint for a resumed chat session
228
+ * (issue #1850 round 6). A chat session is an id-loaded record whose stored
229
+ * namespace bypassed the per-token allow-list; BOTH the HTTP dispatch
230
+ * (`/engram/v1/chat/message`, `/engram/v1/chat/events/:id`) and the MCP
231
+ * dispatch (`memory_chat` → processChatMessage) call this ONE function so
232
+ * neither surface can dodge it. Loads the session and runs
233
+ * `enforceNamespaceAllowList` (which maps undefined → the server default).
234
+ * FAIL CLOSED: a scoped token whose allow-list does not cover the session's
235
+ * effective namespace is rejected; no-op for unrestricted/legacy tokens. A
236
+ * missing session is left to the downstream handler (404 /
237
+ * chat_session_not_found) — only FOUND sessions carry a namespace to gate.
238
+ */
239
+ export async function enforceChatSessionNamespace(
240
+ service: ChatSessionNamespaceHost,
241
+ chatSessionId: string,
242
+ ): Promise<void> {
243
+ const session = await loadChatSession(service.memoryDir, chatSessionId);
244
+ if (!session) return;
245
+ enforceNamespaceAllowList(tokenCapabilityStore.getStore(), session.namespace, service.configRef?.defaultNamespace);
246
+ }
247
+
214
248
  /**
215
249
  * Append a transcript entry to the session file (atomic append, rule 54).
216
250
  *