@remnic/core 9.3.648 → 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 (54) hide show
  1. package/dist/access-cli.js +4 -4
  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-TWVRDGTX.js → chunk-23RYLGYA.js} +185 -55
  11. package/dist/chunk-23RYLGYA.js.map +1 -0
  12. package/dist/{chunk-CNRZ6WJU.js → chunk-3IJEQWQX.js} +4 -4
  13. package/dist/{chunk-XUGQQPGO.js → chunk-AGRPGAKR.js} +12 -1
  14. package/dist/chunk-AGRPGAKR.js.map +1 -0
  15. package/dist/{chunk-6GIKAUTN.js → chunk-MMJANTJX.js} +33 -2
  16. package/dist/{chunk-6GIKAUTN.js.map → chunk-MMJANTJX.js.map} +1 -1
  17. package/dist/{chunk-6BNFVP7Y.js → chunk-RZOBQ23O.js} +2 -2
  18. package/dist/{chunk-AEIZEAP7.js → chunk-TUMH6EDV.js} +12 -15
  19. package/dist/chunk-TUMH6EDV.js.map +1 -0
  20. package/dist/{chunk-FUXV6HSO.js → chunk-TVOPSKOK.js} +3 -3
  21. package/dist/{chunk-5ETA6OAS.js → chunk-YAFSTKTH.js} +608 -80
  22. package/dist/chunk-YAFSTKTH.js.map +1 -0
  23. package/dist/{cli-DrL2Nv4j.d.ts → cli-BG4ybtJr.d.ts} +2 -2
  24. package/dist/cli.d.ts +3 -3
  25. package/dist/cli.js +7 -7
  26. package/dist/explicit-capture.d.ts +1 -1
  27. package/dist/index.d.ts +4 -4
  28. package/dist/index.js +8 -8
  29. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  30. package/dist/{orchestrator-DEQW9j0Z.d.ts → orchestrator-CX-oqwJq.d.ts} +58 -0
  31. package/dist/orchestrator.d.ts +1 -1
  32. package/dist/orchestrator.js +3 -3
  33. package/dist/resume-bundles.js +2 -2
  34. package/dist/transcript.d.ts +18 -1
  35. package/dist/transcript.js +5 -3
  36. package/package.json +1 -1
  37. package/src/access-service-lcm-forgery.test.ts +410 -0
  38. package/src/access-service-observe-lcm-parity.test.ts +1397 -0
  39. package/src/access-service-observe-scope.test.ts +599 -0
  40. package/src/access-service-raw-excerpt-read-gate.test.ts +443 -0
  41. package/src/access-service.ts +1270 -113
  42. package/src/cli.ts +10 -12
  43. package/src/coding/coding-namespace.test.ts +44 -0
  44. package/src/coding/coding-namespace.ts +163 -0
  45. package/src/orchestrator.ts +335 -77
  46. package/src/transcript-day-range.test.ts +101 -0
  47. package/src/transcript.ts +26 -0
  48. package/dist/chunk-5ETA6OAS.js.map +0 -1
  49. package/dist/chunk-AEIZEAP7.js.map +0 -1
  50. package/dist/chunk-TWVRDGTX.js.map +0 -1
  51. package/dist/chunk-XUGQQPGO.js.map +0 -1
  52. /package/dist/{chunk-CNRZ6WJU.js.map → chunk-3IJEQWQX.js.map} +0 -0
  53. /package/dist/{chunk-6BNFVP7Y.js.map → chunk-RZOBQ23O.js.map} +0 -0
  54. /package/dist/{chunk-FUXV6HSO.js.map → chunk-TVOPSKOK.js.map} +0 -0
@@ -0,0 +1,410 @@
1
+ /**
2
+ * #1495 / #1505 P1 (SECURITY): LCM session_id forgery across namespaces.
3
+ *
4
+ * `observe` archives each turn under the LCM `session_id`
5
+ * `lcmSessionKeyForNamespace(effectiveNamespace, sessionKey)`. The LCM archive
6
+ * (SQLite) filters by `session_id` with an EXACT-equality match
7
+ * (`session_id = ?`) and `sessionPrefix` with a LIKE prefix match
8
+ * (`session_id LIKE '<prefix>%'`) — it is keyed by the STRING, NOT physically
9
+ * partitioned by namespace.
10
+ *
11
+ * BEFORE this fix the overlay encoding was `${namespace}:${sessionKey}` and the
12
+ * default-store path returned the RAW `sessionKey` unchanged. That encoding is
13
+ * NOT injective with caller-controlled raw session keys: a caller authorized to
14
+ * read ONLY the `default` store could choose
15
+ * sessionKey = "<victim-overlay-ns>:<victim-session>" (exact-match vector)
16
+ * sessionPrefix = "<victim-overlay-ns>:" (LIKE-prefix vector)
17
+ * which the default-store read path passed through unchanged, producing a
18
+ * `session_id` / prefix that EXACTLY matched the rows the victim archived under
19
+ * its overlay — a CROSS-TENANT READ LEAK.
20
+ *
21
+ * This suite drives a probe whose `searchContextFull` enforces the SAME match
22
+ * semantics as the real SQLite archive (exact `session_id`, LIKE `prefix%`)
23
+ * over a shared row store seeded by the victim's `observe`. It asserts the
24
+ * attacker (authorized for `default` only) retrieves NONE of the victim's
25
+ * overlay rows via `lcmSearch` — both the exact-`session_id` and the
26
+ * `sessionPrefix` forgery vectors — while the legitimate same-principal owner
27
+ * still reads its own rows.
28
+ */
29
+ import assert from "node:assert/strict";
30
+ import test from "node:test";
31
+
32
+ import { EngramAccessService } from "./access-service.js";
33
+ import { Orchestrator } from "./orchestrator.js";
34
+ import type { EngramAccessObserveRequest } from "./access-service.js";
35
+ import type { CodingContext, PluginConfig } from "./types.js";
36
+
37
+ interface ArchiveRow {
38
+ session_id: string;
39
+ content: string;
40
+ turn_index: number;
41
+ }
42
+
43
+ interface ForgeryProbe {
44
+ orch: Orchestrator;
45
+ contexts: Map<string, CodingContext>;
46
+ rows: ArchiveRow[];
47
+ searchSessionIds: Array<string | undefined>;
48
+ searchSessionPrefixes: Array<string | undefined>;
49
+ }
50
+
51
+ /**
52
+ * Build a probe whose LCM engine writes/reads a SHARED row store with the SAME
53
+ * match semantics as the production SQLite archive:
54
+ * - `searchContextFull(query, limit, sessionId, sessionPrefix)`:
55
+ * * sessionId present ⇒ rows where `row.session_id === sessionId`
56
+ * * sessionPrefix present ⇒ rows where `row.session_id.startsWith(prefix)`
57
+ * * neither ⇒ ALL rows (archive-wide scan)
58
+ * - `enqueueObserveMessages(sessionId, messages)`: append one row per message
59
+ * under that exact `session_id`.
60
+ */
61
+ function makeForgeryProbe(overrides: Partial<PluginConfig> = {}): ForgeryProbe {
62
+ const contexts = new Map<string, CodingContext>();
63
+ const rows: ArchiveRow[] = [];
64
+ const searchSessionIds: Array<string | undefined> = [];
65
+ const searchSessionPrefixes: Array<string | undefined> = [];
66
+
67
+ const config = {
68
+ namespacesEnabled: true,
69
+ defaultNamespace: "default",
70
+ sharedNamespace: "shared",
71
+ namespacePolicies: [],
72
+ defaultRecallNamespaces: ["self", "shared"],
73
+ codingMode: { projectScope: true },
74
+ memoryDir: "/synthetic/remnic-lcm-forgery",
75
+ objectiveStateMemoryEnabled: false,
76
+ objectiveStateSnapshotWritesEnabled: false,
77
+ principalFromSessionKeyMode: "prefix",
78
+ principalFromSessionKeyRules: [],
79
+ recallCrossNamespaceBudgetEnabled: false,
80
+ recallCrossNamespaceBudgetWindowMs: 60_000,
81
+ recallCrossNamespaceBudgetSoftLimit: 10,
82
+ recallCrossNamespaceBudgetHardLimit: 30,
83
+ ...overrides,
84
+ } as unknown as PluginConfig;
85
+
86
+ const orch = {
87
+ config,
88
+ getCodingContextForSession: (sk: string | undefined) =>
89
+ (sk ? contexts.get(sk) : null) ?? null,
90
+ setCodingContextForSession: (sk: string, ctx: CodingContext | null) => {
91
+ if (ctx === null) contexts.delete(sk);
92
+ else contexts.set(sk, ctx);
93
+ },
94
+ applyCodingNamespaceOverlay: (sk: string | undefined, base: string) =>
95
+ Orchestrator.prototype.applyCodingNamespaceOverlay.call(orch, sk, base),
96
+ resolvePrincipal: (sk?: string) =>
97
+ Orchestrator.prototype.resolvePrincipal.call(orch, sk),
98
+ resolveSelfNamespace: (sk?: string) =>
99
+ Orchestrator.prototype.resolveSelfNamespace.call(orch, sk),
100
+ lcmEngine: {
101
+ enabled: true,
102
+ enqueueObserveMessages: (
103
+ sessionId: string,
104
+ messages: Array<{ role: string; content: string }>,
105
+ ) => {
106
+ for (let i = 0; i < messages.length; i += 1) {
107
+ rows.push({
108
+ session_id: sessionId,
109
+ content: messages[i]!.content,
110
+ turn_index: i,
111
+ });
112
+ }
113
+ },
114
+ waitForSessionObserveIdle: async (_sessionKey: string) => {},
115
+ preCompactionFlush: async (_sessionKey: string) => {},
116
+ recordCompaction: async () => {},
117
+ // Mirror the real SQLite archive match semantics (archive.ts):
118
+ // session_id = ? (exact)
119
+ // session_id LIKE prefix% (prefix)
120
+ searchContextFull: async (
121
+ _query: string,
122
+ limit: number,
123
+ sessionId?: string,
124
+ sessionPrefix?: string,
125
+ ) => {
126
+ searchSessionIds.push(sessionId);
127
+ searchSessionPrefixes.push(sessionPrefix);
128
+ let matched = rows;
129
+ if (typeof sessionId === "string") {
130
+ matched = matched.filter((r) => r.session_id === sessionId);
131
+ } else if (typeof sessionPrefix === "string") {
132
+ matched = matched.filter((r) => r.session_id.startsWith(sessionPrefix));
133
+ }
134
+ return matched.slice(0, limit).map((r) => ({
135
+ id: r.turn_index,
136
+ turn_index: r.turn_index,
137
+ role: "assistant",
138
+ content: r.content,
139
+ session_id: r.session_id,
140
+ score: 1,
141
+ }));
142
+ },
143
+ },
144
+ ingestReplayBatch: async () => {},
145
+ } as unknown as Orchestrator;
146
+
147
+ return { orch, contexts, rows, searchSessionIds, searchSessionPrefixes };
148
+ }
149
+
150
+ function observeRequest(
151
+ overrides: Partial<EngramAccessObserveRequest>,
152
+ ): EngramAccessObserveRequest {
153
+ return {
154
+ sessionKey: "victim",
155
+ skipExtraction: true,
156
+ messages: [
157
+ { role: "user", content: "what is the secret deploy key?" },
158
+ { role: "assistant", content: "VICTIM SECRET: the deploy key is hunter2" },
159
+ ],
160
+ ...overrides,
161
+ } as EngramAccessObserveRequest;
162
+ }
163
+
164
+ const VICTIM_SECRET = "VICTIM SECRET: the deploy key is hunter2";
165
+
166
+ /**
167
+ * Two principals share the deployment:
168
+ * - alice: CAN read+write her self namespace (so she gets a project overlay).
169
+ * - mallory: a caller authorized for the `default` store ONLY (no self policy
170
+ * entry ⇒ resolves to principal `default`, reads `default`).
171
+ * `default` is readable so mallory passes the implicit-LCM read gate and the
172
+ * read collapses to the default store (raw key) — exactly the path the forgery
173
+ * abuses.
174
+ */
175
+ function twoTenantConfig(): Partial<PluginConfig> {
176
+ return {
177
+ namespacePolicies: [
178
+ { name: "alice", readPrincipals: ["alice"], writePrincipals: ["alice"] },
179
+ // `default` readable by anyone (the store mallory is authorized for).
180
+ { name: "default", readPrincipals: ["*"], writePrincipals: ["*"] },
181
+ ],
182
+ principalFromSessionKeyMode: "prefix",
183
+ principalFromSessionKeyRules: [{ match: "alice:", principal: "alice" }],
184
+ defaultRecallNamespaces: ["self", "shared"],
185
+ } as Partial<PluginConfig>;
186
+ }
187
+
188
+ test("#1495 P1 FORGERY BLOCKED: a default-only caller cannot read another tenant's overlay LCM rows via a forged exact session_id", async () => {
189
+ const probe = makeForgeryProbe(twoTenantConfig());
190
+ const service = new EngramAccessService(probe.orch);
191
+
192
+ // VICTIM (alice) archives a project-scoped turn under her overlay namespace.
193
+ const victimRes = await service.observe(
194
+ observeRequest({
195
+ sessionKey: "alice:s1",
196
+ projectTag: "Blend/Supply",
197
+ }),
198
+ );
199
+ const victimWriteKey = probe.rows[0]!.session_id;
200
+ const victimOverlayNs = victimRes.effectiveNamespace;
201
+ assert.ok(
202
+ victimOverlayNs && victimOverlayNs.startsWith("alice-"),
203
+ `victim must archive under her overlay namespace, got ${String(victimOverlayNs)}`,
204
+ );
205
+ // Sanity: the victim's own rows are reachable by an exact match on her write key.
206
+ assert.ok(
207
+ probe.rows.some((r) => r.session_id === victimWriteKey),
208
+ "victim rows must be present in the shared archive",
209
+ );
210
+
211
+ // ATTACKER (mallory, default-only) forges a raw sessionKey EQUAL to the victim's
212
+ // overlay write key string. Under the OLD encoding the default-store read path
213
+ // returned this raw key unchanged, producing an exact session_id match on the
214
+ // victim's rows.
215
+ const forgedSessionKey = victimWriteKey; // e.g. "alice-project-...:alice:s1"
216
+ const res = await service.lcmSearch({
217
+ query: "secret deploy key",
218
+ sessionKey: forgedSessionKey,
219
+ authenticatedPrincipal: "default",
220
+ });
221
+
222
+ const leaked = res.results.some((r) => r.content.includes(VICTIM_SECRET));
223
+ assert.equal(
224
+ leaked,
225
+ false,
226
+ `cross-tenant LEAK: default-only caller read the victim's overlay rows via a forged session_id; results=${JSON.stringify(
227
+ res.results,
228
+ )}`,
229
+ );
230
+ assert.equal(res.count, 0, "forged exact session_id must return NO victim rows");
231
+ });
232
+
233
+ test("#1495 P1 FORGERY BLOCKED: a default-only caller cannot read another tenant's overlay LCM rows via a forged sessionPrefix (LIKE vector)", async () => {
234
+ const probe = makeForgeryProbe(twoTenantConfig());
235
+ const service = new EngramAccessService(probe.orch);
236
+
237
+ const victimRes = await service.observe(
238
+ observeRequest({ sessionKey: "alice:s1", projectTag: "Blend/Supply" }),
239
+ );
240
+ const victimOverlayNs = victimRes.effectiveNamespace!;
241
+ assert.ok(victimOverlayNs.startsWith("alice-"));
242
+
243
+ // ATTACKER forges a sessionPrefix that, under the old encoding, LIKE-matched all
244
+ // of the victim's overlay rows: "<victim-overlay-ns>:". With NO sessionKey the
245
+ // exact `session_id` filter is absent and the `sessionPrefix` LIKE applies; the
246
+ // default-store read path passed the prefix through unchanged. (Supplying a
247
+ // sessionKey would make the archive use the exact `session_id` filter instead,
248
+ // so the prefix-only form is the true LIKE vector.)
249
+ const res = await service.lcmSearch({
250
+ query: "secret deploy key",
251
+ sessionPrefix: `${victimOverlayNs}:`,
252
+ authenticatedPrincipal: "default",
253
+ });
254
+
255
+ const leaked = res.results.some((r) => r.content.includes(VICTIM_SECRET));
256
+ assert.equal(
257
+ leaked,
258
+ false,
259
+ `cross-tenant LEAK via sessionPrefix LIKE: default-only caller matched the victim's overlay rows; results=${JSON.stringify(
260
+ res.results,
261
+ )}`,
262
+ );
263
+ });
264
+
265
+ test("#1495 P1 LEGITIMATE ACCESS PRESERVED: the victim still reads its OWN overlay rows in the same session", async () => {
266
+ const probe = makeForgeryProbe(twoTenantConfig());
267
+ const service = new EngramAccessService(probe.orch);
268
+
269
+ // alice archives under her overlay AND binds the coding context to her session.
270
+ await service.observe(
271
+ observeRequest({ sessionKey: "alice:s1", projectTag: "Blend/Supply" }),
272
+ );
273
+
274
+ // A same-session lcmSearch by alice (no explicit namespace) must reach her rows:
275
+ // the read resolves the SAME overlay key the write used.
276
+ const res = await service.lcmSearch({
277
+ query: "secret deploy key",
278
+ sessionKey: "alice:s1",
279
+ authenticatedPrincipal: "alice",
280
+ });
281
+
282
+ assert.ok(
283
+ res.results.some((r) => r.content.includes(VICTIM_SECRET)),
284
+ `legitimate same-principal same-session read must still return alice's own rows; results=${JSON.stringify(
285
+ res.results,
286
+ )}`,
287
+ );
288
+ });
289
+
290
+ test("#1495 P1 LEGITIMATE ACCESS PRESERVED: a session key that legitimately contains ':' still reads its own rows (single store, no overlay)", async () => {
291
+ // Single-user / no-overlay deployment: the raw sessionKey is used verbatim as
292
+ // the LCM key, including embedded ':'. The owner must still read its own rows.
293
+ const probe = makeForgeryProbe({
294
+ namespacesEnabled: false,
295
+ } as Partial<PluginConfig>);
296
+ const service = new EngramAccessService(probe.orch);
297
+
298
+ await service.observe(
299
+ observeRequest({ sessionKey: "agent:proj:sess-1", skipExtraction: true }),
300
+ );
301
+ // No overlay ⇒ raw key archived verbatim.
302
+ assert.equal(probe.rows[0]!.session_id, "agent:proj:sess-1");
303
+
304
+ const res = await service.lcmSearch({
305
+ query: "secret deploy key",
306
+ sessionKey: "agent:proj:sess-1",
307
+ });
308
+ assert.ok(
309
+ res.results.some((r) => r.content.includes(VICTIM_SECRET)),
310
+ `single-store owner with a ':'-bearing session key must read its own rows; results=${JSON.stringify(
311
+ res.results,
312
+ )}`,
313
+ );
314
+ });
315
+
316
+ // ──────────────────────────────────────────────────────────────────────────
317
+ // #1505 codex P1 (round 2): a SESSIONLESS + prefixless `lcmSearch` issues
318
+ // `searchContextFull(query, limit, undefined, undefined)` — an archive-wide FTS
319
+ // scan over EVERY `session_id`, including sentinel-framed overlay rows. The
320
+ // archive is keyed by `session_id`, NOT partitioned by namespace, so an
321
+ // unscoped scan cannot be constrained to the caller's authorized namespace.
322
+ // When namespaces are ENABLED it must therefore be SUPPRESSED regardless of an
323
+ // explicit `namespace` or default-readability; only single-store (namespaces
324
+ // disabled) keeps the legitimate archive-wide scan.
325
+ // ──────────────────────────────────────────────────────────────────────────
326
+
327
+ test("#1505 codex P1 r2 FORGERY BLOCKED: explicit-namespace + sessionless lcmSearch does NOT archive-scan another tenant's overlay rows", async () => {
328
+ const probe = makeForgeryProbe(twoTenantConfig());
329
+ const service = new EngramAccessService(probe.orch);
330
+
331
+ // VICTIM archives overlay rows.
332
+ const victimRes = await service.observe(
333
+ observeRequest({ sessionKey: "alice:s1", projectTag: "Blend/Supply" }),
334
+ );
335
+ assert.ok(victimRes.effectiveNamespace!.startsWith("alice-"));
336
+
337
+ // ATTACKER reads an explicit namespace they ARE authorized for (`default`),
338
+ // with NO sessionKey/sessionPrefix. The underlying search would be an
339
+ // archive-wide scan (no session_id filter), exposing alice's overlay rows.
340
+ const res = await service.lcmSearch({
341
+ query: "secret deploy key",
342
+ namespace: "default",
343
+ authenticatedPrincipal: "default",
344
+ });
345
+
346
+ assert.equal(
347
+ res.results.some((r) => r.content.includes(VICTIM_SECRET)),
348
+ false,
349
+ `cross-tenant LEAK via explicit-namespace archive scan; results=${JSON.stringify(res.results)}`,
350
+ );
351
+ assert.equal(res.count, 0, "explicit-namespace sessionless lcmSearch must NOT archive-scan");
352
+ assert.equal(
353
+ probe.searchSessionIds.length,
354
+ 0,
355
+ "searchContextFull must NOT run an unscoped archive scan when namespaces are enabled",
356
+ );
357
+ });
358
+
359
+ test("#1505 codex P1 r2 FORGERY BLOCKED: default-readable implicit + sessionless lcmSearch does NOT archive-scan overlay rows", async () => {
360
+ const probe = makeForgeryProbe(twoTenantConfig());
361
+ const service = new EngramAccessService(probe.orch);
362
+
363
+ const victimRes = await service.observe(
364
+ observeRequest({ sessionKey: "alice:s1", projectTag: "Blend/Supply" }),
365
+ );
366
+ assert.ok(victimRes.effectiveNamespace!.startsWith("alice-"));
367
+
368
+ // ATTACKER (default-readable) with NO sessionKey/namespace/sessionPrefix. The
369
+ // pre-fix guard allowed this through to an archive-wide scan.
370
+ const res = await service.lcmSearch({
371
+ query: "secret deploy key",
372
+ authenticatedPrincipal: "default",
373
+ });
374
+
375
+ assert.equal(
376
+ res.results.some((r) => r.content.includes(VICTIM_SECRET)),
377
+ false,
378
+ `cross-tenant LEAK via default-readable archive scan; results=${JSON.stringify(res.results)}`,
379
+ );
380
+ assert.equal(
381
+ probe.searchSessionIds.length,
382
+ 0,
383
+ "searchContextFull must NOT run an unscoped archive scan when namespaces are enabled",
384
+ );
385
+ });
386
+
387
+ test("#1505 codex P1 r2 regression: single-store (namespaces disabled) + sessionless lcmSearch STILL archive-scans (byte-for-byte prior behavior)", async () => {
388
+ const probe = makeForgeryProbe({
389
+ namespacesEnabled: false,
390
+ } as Partial<PluginConfig>);
391
+ const service = new EngramAccessService(probe.orch);
392
+
393
+ // Single-store: one shared archive owned by the caller. Seed a row, then a
394
+ // sessionless search must still scan it.
395
+ await service.observe(
396
+ observeRequest({ sessionKey: "owner-sess", skipExtraction: true }),
397
+ );
398
+ const res = await service.lcmSearch({ query: "secret deploy key" });
399
+
400
+ assert.equal(
401
+ probe.searchSessionIds.length,
402
+ 1,
403
+ "namespaces disabled ⇒ sessionless archive scan still runs",
404
+ );
405
+ assert.equal(probe.searchSessionIds[0], undefined, "archive-wide scan passes no session_id filter");
406
+ assert.ok(
407
+ res.results.some((r) => r.content.includes(VICTIM_SECRET)),
408
+ "single-store owner still reads its archive",
409
+ );
410
+ });