@remnic/core 9.3.649 → 9.3.650

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 (42) hide show
  1. package/dist/access-cli.js +3 -3
  2. package/dist/access-http.d.ts +2 -2
  3. package/dist/access-http.js +4 -4
  4. package/dist/access-mcp.d.ts +2 -2
  5. package/dist/access-mcp.js +3 -3
  6. package/dist/{access-service-DFXIlGvZ.d.ts → access-service-DIZRHQ7Q.d.ts} +255 -2
  7. package/dist/access-service.d.ts +2 -2
  8. package/dist/access-service.js +2 -2
  9. package/dist/bootstrap.d.ts +1 -1
  10. package/dist/{chunk-XUGVP7ZU.js → chunk-23RYLGYA.js} +184 -54
  11. package/dist/chunk-23RYLGYA.js.map +1 -0
  12. package/dist/{chunk-CNRZ6WJU.js → chunk-3IJEQWQX.js} +4 -4
  13. package/dist/{chunk-6GIKAUTN.js → chunk-MMJANTJX.js} +33 -2
  14. package/dist/{chunk-6GIKAUTN.js.map → chunk-MMJANTJX.js.map} +1 -1
  15. package/dist/{chunk-FQYFMIKG.js → chunk-TUMH6EDV.js} +4 -4
  16. package/dist/{chunk-FUXV6HSO.js → chunk-TVOPSKOK.js} +3 -3
  17. package/dist/{chunk-5ETA6OAS.js → chunk-YAFSTKTH.js} +608 -80
  18. package/dist/chunk-YAFSTKTH.js.map +1 -0
  19. package/dist/{cli-DrL2Nv4j.d.ts → cli-BG4ybtJr.d.ts} +2 -2
  20. package/dist/cli.d.ts +3 -3
  21. package/dist/cli.js +5 -5
  22. package/dist/explicit-capture.d.ts +1 -1
  23. package/dist/index.d.ts +4 -4
  24. package/dist/index.js +6 -6
  25. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  26. package/dist/{orchestrator-DEQW9j0Z.d.ts → orchestrator-CX-oqwJq.d.ts} +58 -0
  27. package/dist/orchestrator.d.ts +1 -1
  28. package/dist/orchestrator.js +2 -2
  29. package/package.json +1 -1
  30. package/src/access-service-lcm-forgery.test.ts +410 -0
  31. package/src/access-service-observe-lcm-parity.test.ts +1397 -0
  32. package/src/access-service-observe-scope.test.ts +599 -0
  33. package/src/access-service-raw-excerpt-read-gate.test.ts +443 -0
  34. package/src/access-service.ts +1270 -113
  35. package/src/coding/coding-namespace.test.ts +44 -0
  36. package/src/coding/coding-namespace.ts +163 -0
  37. package/src/orchestrator.ts +335 -77
  38. package/dist/chunk-5ETA6OAS.js.map +0 -1
  39. package/dist/chunk-XUGVP7ZU.js.map +0 -1
  40. /package/dist/{chunk-CNRZ6WJU.js.map → chunk-3IJEQWQX.js.map} +0 -0
  41. /package/dist/{chunk-FQYFMIKG.js.map → chunk-TUMH6EDV.js.map} +0 -0
  42. /package/dist/{chunk-FUXV6HSO.js.map → chunk-TVOPSKOK.js.map} +0 -0
@@ -365,3 +365,47 @@ test("resolveCodingNamespaceOverlay: read path and write path see identical name
365
365
  const writeOverlay = resolveCodingNamespaceOverlay(input[0], input[1]);
366
366
  assert.deepEqual(readOverlay, writeOverlay);
367
367
  });
368
+
369
+ test("#1505 codex P2: lcmReadSessionIdsForNamespaces preserves UNDEFINED for a sessionless read (never the literal 'default' session id)", async () => {
370
+ const { lcmReadSessionIdsForNamespaces } = await import("./coding-namespace.js");
371
+ // Sessionless ⇒ a single archive-wide read (`undefined`), so the LCM builders
372
+ // run with no exact session_id filter — NOT filtered to a session literally
373
+ // named "default" (which would silently drop explicit-cue/targeted/focused/
374
+ // response/event LCM sections for every recall that omits a session key).
375
+ assert.deepEqual(
376
+ lcmReadSessionIdsForNamespaces(["default"], undefined, "default"),
377
+ [undefined],
378
+ "sessionless ⇒ [undefined], never ['default']",
379
+ );
380
+ assert.deepEqual(
381
+ lcmReadSessionIdsForNamespaces(["acme", "default"], "", "default"),
382
+ [undefined],
383
+ "empty-string sessionKey is treated as sessionless ⇒ [undefined]",
384
+ );
385
+ });
386
+
387
+ test("#1505 codex P2: lcmReadSessionIdsForNamespaces keeps the raw sessionKey + overlay keys when a session IS present", async () => {
388
+ const { lcmReadSessionIdsForNamespaces, lcmSessionKeyForNamespace } = await import(
389
+ "./coding-namespace.js"
390
+ );
391
+ // Shape-agnostic expectation: derive each expected key through the SAME shared
392
+ // encoder production uses (#1495 P1 made the namespaced encoding sentinel-framed
393
+ // and unforgeable; rule 22: never re-hardcode the join).
394
+ const enc = (ns: string, sk: string) =>
395
+ lcmSessionKeyForNamespace(ns, sk, "default") ?? sk;
396
+ // Single-store / default namespace ⇒ raw sessionKey (byte-for-byte prior).
397
+ assert.deepEqual(
398
+ lcmReadSessionIdsForNamespaces(["default"], "sk", "default"),
399
+ ["sk"],
400
+ );
401
+ // Non-default namespaces ⇒ sentinel-framed keys, deduped, ordered. The default
402
+ // key collapses to the raw `sk`, and the namespaced key is disjoint from it.
403
+ assert.deepEqual(
404
+ lcmReadSessionIdsForNamespaces(["acme", "default", "acme"], "sk", "default"),
405
+ [enc("acme", "sk"), "sk"],
406
+ );
407
+ // Security invariant: the namespaced key cannot equal a raw default key, so a
408
+ // forged default read can never collide with the overlay key.
409
+ assert.notEqual(enc("acme", "sk"), "acme:sk");
410
+ assert.notEqual(enc("acme", "sk"), "sk");
411
+ });
@@ -418,3 +418,166 @@ export function describeCodingScope(
418
418
  disabledReason: null,
419
419
  };
420
420
  }
421
+
422
+ // ──────────────────────────────────────────────────────────────────────────
423
+ // LCM session-key namespacing (#1495)
424
+ // ──────────────────────────────────────────────────────────────────────────
425
+
426
+ /**
427
+ * Reserved structural sentinel for the namespaced LCM `session_id` encoding
428
+ * (#1495 P1). U+001F (UNIT SEPARATOR) is a C0 control character that CANNOT
429
+ * occur in a route namespace (`isSafeRouteNamespace` ⇒ `[A-Za-z0-9._-]{1,64}`,
430
+ * see routing/engine.ts) and does not occur in any legitimate session key, so
431
+ * it is an unforgeable structural marker for the namespace boundary.
432
+ *
433
+ * SECURITY — why this is unforgeable (the #1495 P1 fix):
434
+ * The LCM archive is keyed by the `session_id` STRING (exact `session_id = ?`
435
+ * and prefix `session_id LIKE '<prefix>%'`), NOT physically partitioned by
436
+ * namespace. The previous encoding `${namespace}:${sessionKey}` shared the SAME
437
+ * string space as a raw default-store key, so a caller authorized for the
438
+ * `default` store could pass a raw `sessionKey` equal to another namespace's
439
+ * encoded key (`"<overlay-ns>:<victim-session>"`) and exact-match the victim's
440
+ * rows — a cross-tenant read leak.
441
+ *
442
+ * The new encoding makes the namespaced and default key-spaces PROVABLY
443
+ * DISJOINT:
444
+ * - Overlay key = `\x1f<namespace>\x1f<sessionKey>` — always begins with
445
+ * `\x1f` followed by a NON-`\x1f` character (the namespace is non-empty and
446
+ * `\x1f`-free). The leading `\x1f<namespace>\x1f` is an unambiguous,
447
+ * injective frame: the namespace cannot contain `\x1f`, so the second `\x1f`
448
+ * terminates it without any escaping of the (raw) session key.
449
+ * - Default key = the raw `sessionKey`, UNLESS it already begins with the
450
+ * sentinel, in which case it is escaped to begin with `\x1f\x1f` (see
451
+ * `escapeDefaultLcmKey`). A default key therefore NEVER matches the overlay
452
+ * frame `\x1f<non-\x1f>…`.
453
+ * Hence no caller-controlled raw `sessionKey` (default path) can reproduce an
454
+ * overlay key, closing the forgery for BOTH the exact-`session_id` match and the
455
+ * `sessionPrefix` LIKE match (an overlay prefix `\x1f<ns>\x1f<rawPrefix>` stays a
456
+ * valid LIKE-prefix of the overlay full keys, and a default prefix can only
457
+ * LIKE-match default keys).
458
+ *
459
+ * Existing default-store rows need NO migration: legitimate session keys never
460
+ * contain `\x1f`, so `escapeDefaultLcmKey` is a no-op for them and they remain
461
+ * byte-for-byte their raw form. The namespaced encoding is NEW in this
462
+ * unreleased PR, so changing its shape costs nothing.
463
+ */
464
+ const LCM_NS_SENTINEL = "\u001f";
465
+
466
+ /**
467
+ * Make a default-store (raw) LCM key disjoint from the namespaced key-space.
468
+ *
469
+ * Namespaced overlay keys always begin with `\x1f` followed by a non-`\x1f`
470
+ * namespace character. A raw default key collides with that frame ONLY if it
471
+ * begins with `\x1f`. Legitimate session keys never contain `\x1f`, so this is a
472
+ * pure no-op for them; a forged key that begins with `\x1f` is escaped to begin
473
+ * with `\x1f\x1f`, which can never equal an overlay key (whose second character
474
+ * is a `[A-Za-z0-9._-]` namespace char, never `\x1f`).
475
+ */
476
+ function escapeDefaultLcmKey(sessionKey: string): string {
477
+ return sessionKey.startsWith(LCM_NS_SENTINEL)
478
+ ? `${LCM_NS_SENTINEL}${sessionKey}`
479
+ : sessionKey;
480
+ }
481
+
482
+ /**
483
+ * Build the LCM/structured-history `session_id` that a write-producing surface
484
+ * archives under, and that a same-session reader must search under, so reads
485
+ * and writes never drift (#1495, CLAUDE.md rule 42).
486
+ *
487
+ * The LCM archive filters strictly by the `session_id` string, so the writer's
488
+ * archival key and the reader's lookup key MUST agree byte-for-byte. The
489
+ * encoding frames the namespace with the reserved {@link LCM_NS_SENTINEL}
490
+ * (`\x1f<namespace>\x1f<sessionKey>`) whenever that namespace diverges from the
491
+ * single-store default; otherwise it passes the (escaped) raw `sessionKey` so
492
+ * single-user / no-overlay deployments keep pre-#1495 behavior exactly. The two
493
+ * key-spaces are provably disjoint, so a caller-controlled raw `sessionKey`
494
+ * cannot forge another namespace's encoded id (see the {@link LCM_NS_SENTINEL}
495
+ * doc comment for the full security rationale).
496
+ *
497
+ * `observe`, compaction flush/record, and the orchestrator recall readers all
498
+ * route through this one helper so a project-scoped (cwd/projectTag) or
499
+ * explicit-namespace session reads its own compressed-history / structured /
500
+ * targeted-fact evidence instead of missing it.
501
+ */
502
+ export function lcmSessionKeyForNamespace(
503
+ namespace: string | undefined,
504
+ sessionKey: string | undefined,
505
+ defaultNamespace: string,
506
+ ): string | undefined {
507
+ if (typeof sessionKey !== "string" || sessionKey.length === 0) return sessionKey;
508
+ if (
509
+ typeof namespace === "string" &&
510
+ namespace.length > 0 &&
511
+ namespace !== defaultNamespace
512
+ ) {
513
+ // Namespaced (overlay / explicit) key: frame the namespace with the reserved
514
+ // sentinel so the boundary is unambiguous AND unforgeable from the default
515
+ // key-space. The namespace is guaranteed `\x1f`-free by `isSafeRouteNamespace`.
516
+ return `${LCM_NS_SENTINEL}${namespace}${LCM_NS_SENTINEL}${sessionKey}`;
517
+ }
518
+ // Default store: raw sessionKey, escaped only if it would otherwise intrude on
519
+ // the namespaced key-space (no-op for every legitimate key).
520
+ return escapeDefaultLcmKey(sessionKey);
521
+ }
522
+
523
+ /**
524
+ * Map an ORDERED, read-authorized namespace set (the SAME set normal QMD/file
525
+ * recall searches) to the ordered set of LCM `session_id`s a same-session reader
526
+ * must query (#1505 thread "Include coding fallback namespaces in LCM reads").
527
+ *
528
+ * The LCM archive filters strictly by `session_id`, and `observe` archives each
529
+ * turn under `${effectiveNamespace}:${sessionKey}` for the namespace that was
530
+ * effective when it was written. A branch-scoped session that overlays
531
+ * `${base-project-*-branch-*}` only sees rows written under THAT namespace if it
532
+ * reads a single overlay key — but normal recall ALSO searches the
533
+ * `codingOverlay.readFallbacks` (project / root) namespaces, so rows archived at
534
+ * project/root scope are surfaced by QMD/file recall yet MISSED by a single-key
535
+ * LCM read. Deriving the LCM read keys from the SAME `recallNamespaces` set keeps
536
+ * the LCM read path from diverging: every namespace recall is authorized to read
537
+ * (read-auth gate already applied upstream in `recallNamespaces`) contributes one
538
+ * LCM key, ordered primary-overlay-first then fallbacks. Unreadable namespaces
539
+ * are never in `recallNamespaces`, so they are never searched here either (no
540
+ * cross-tenant read leak).
541
+ *
542
+ * Single-user / no-overlay recall passes a single-namespace set that collapses to
543
+ * the raw `sessionKey`, so the result is `[sessionKey]` — byte-for-byte the
544
+ * pre-#1505 single-key behavior.
545
+ *
546
+ * SESSIONLESS recall (`sessionKey === undefined`): returns `[undefined]` so the
547
+ * caller issues ONE archive-wide LCM read with no exact `session_id` filter —
548
+ * byte-for-byte the pre-#1505 sessionless behavior. It must NOT substitute the
549
+ * literal `"default"` session id (codex P2 "Preserve unscoped LCM searches
550
+ * without a session key"): that would filter to a session literally named
551
+ * `default`, silently dropping the explicit-cue / targeted / focused / response /
552
+ * event LCM sections for every recall that omits a session key.
553
+ *
554
+ * The result is deduped while preserving first-seen order so the caller can query
555
+ * keys in priority order and short-circuit on the first hit without re-querying an
556
+ * identical key (e.g. when two namespaces both collapse to the default store).
557
+ */
558
+ export function lcmReadSessionIdsForNamespaces(
559
+ namespaces: readonly string[],
560
+ sessionKey: string | undefined,
561
+ defaultNamespace: string,
562
+ ): Array<string | undefined> {
563
+ // Sessionless ⇒ a single archive-wide read (no `session_id` filter). NEVER the
564
+ // literal "default" session id (codex P2).
565
+ if (typeof sessionKey !== "string" || sessionKey.length === 0) {
566
+ return [undefined];
567
+ }
568
+ const out: string[] = [];
569
+ const seen = new Set<string>();
570
+ for (const namespace of namespaces) {
571
+ const key =
572
+ lcmSessionKeyForNamespace(namespace, sessionKey, defaultNamespace) ??
573
+ sessionKey;
574
+ if (!seen.has(key)) {
575
+ seen.add(key);
576
+ out.push(key);
577
+ }
578
+ }
579
+ if (out.length === 0) {
580
+ out.push(sessionKey);
581
+ }
582
+ return out;
583
+ }