@opengeni/db 0.12.0 → 0.12.6

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.
@@ -1,4 +1,11 @@
1
- import { boundSessionEventPayload } from "@opengeni/contracts";
1
+ import {
2
+ boundSessionEventPayload,
3
+ isCredentialHeaderName,
4
+ isSensitiveFieldName,
5
+ redactSensitiveKey,
6
+ redactSensitiveText,
7
+ type SecretForRedaction,
8
+ } from "@opengeni/contracts";
2
9
 
3
10
  /**
4
11
  * Last line of defense against a session event crashing a whole turn.
@@ -18,21 +25,6 @@ import { boundSessionEventPayload } from "@opengeni/contracts";
18
25
 
19
26
  const REPLACEMENT = "�";
20
27
  const REDACTED = "[redacted]";
21
- const SENSITIVE_FIELD_NAMES = new Set([
22
- "authorization",
23
- "headers",
24
- "accesstoken",
25
- "refreshtoken",
26
- "idtoken",
27
- "token",
28
- "apikey",
29
- "secret",
30
- "clientsecret",
31
- "credential",
32
- "credentialencrypted",
33
- "encryptedpkceverifier",
34
- "codeverifier",
35
- ]);
36
28
 
37
29
  /**
38
30
  * Strip NUL and repair invalid/lone UTF-16 surrogates in a single string.
@@ -94,6 +86,8 @@ export type SanitizeEventPayloadOptions = {
94
86
  * this from a producer-controlled payload field.
95
87
  */
96
88
  fullEvidence?: unknown;
89
+ /** Exact runtime credential provenance to remove from keys and values. */
90
+ knownSecrets?: readonly SecretForRedaction[];
97
91
  };
98
92
 
99
93
  export function sanitizeEventPayload<T>(payload: T, options: SanitizeEventPayloadOptions = {}): T {
@@ -106,6 +100,7 @@ export function sanitizeEventPayload<T>(payload: T, options: SanitizeEventPayloa
106
100
  });
107
101
  return sanitizeEventPayloadDeep(
108
102
  bounded === payload ? removeProducerTruncationMetadata(bounded) : bounded,
103
+ options.knownSecrets ?? [],
109
104
  );
110
105
  }
111
106
 
@@ -126,46 +121,60 @@ function removeProducerTruncationMetadata<T>(payload: T): T {
126
121
  return cleaned as T;
127
122
  }
128
123
 
129
- function sanitizeEventPayloadDeep<T>(payload: T): T {
124
+ function sanitizeEventPayloadDeep<T>(
125
+ payload: T,
126
+ knownSecrets: readonly SecretForRedaction[] = [],
127
+ ): T {
130
128
  if (typeof payload === "string") {
131
- return sanitizeEventString(payload) as unknown as T;
129
+ return sanitizeEventString(redactSensitiveText(payload, knownSecrets)) as unknown as T;
132
130
  }
133
131
  if (Array.isArray(payload)) {
134
- return payload.map((item) => sanitizeEventPayloadDeep(item)) as unknown as T;
132
+ return payload.map((item) => sanitizeEventPayloadDeep(item, knownSecrets)) as unknown as T;
135
133
  }
136
134
  if (payload instanceof Date) {
137
135
  return safeDateIso(payload) as unknown as T;
138
136
  }
139
137
  if (payload && typeof payload === "object") {
140
- const entries = Object.entries(payload as Record<string, unknown>).map(
141
- ([key, value]) =>
142
- [sanitizeEventString(key), sanitizeSensitiveEventField(key, value)] as const,
143
- );
138
+ const usedKeys = new Set<string>();
139
+ const entries = Object.entries(payload as Record<string, unknown>).map(([key, value]) => {
140
+ const safeKey = nextUniqueKey(
141
+ sanitizeEventString(redactSensitiveKey(key, knownSecrets)),
142
+ usedKeys,
143
+ );
144
+ return [safeKey, sanitizeSensitiveEventField(key, value, knownSecrets)] as const;
145
+ });
144
146
  return Object.fromEntries(entries) as unknown as T;
145
147
  }
146
148
  return payload;
147
149
  }
148
150
 
149
151
  /**
150
- * Make model-facing conversation data safe for Postgres without redacting it.
152
+ * Make model-facing conversation data safe for Postgres and secret-redacted.
151
153
  *
152
- * Session events are an audit/UI projection and deliberately redact fields such
153
- * as `token` and `authorization`. Conversation history is the replay source for
154
- * the model, so applying event redaction there silently changes tool arguments
155
- * and can make recovery diverge from the call that actually ran. This walker
156
- * performs only the database-safety repair (NUL removal and UTF-16 repair).
154
+ * Conversation history is the replay source for the model, so this boundary
155
+ * retains protocol structure and non-sensitive diagnostics while removing
156
+ * credential-bearing fields and text before durable storage. It also performs
157
+ * the database-safety repair (NUL removal and UTF-16 repair).
157
158
  */
158
- export function sanitizeModelPayload<T>(payload: T): T {
159
- return sanitizeModelPayloadDeep(payload, new WeakSet<object>(), 0);
159
+ export function sanitizeModelPayload<T>(
160
+ payload: T,
161
+ knownSecrets: readonly SecretForRedaction[] = [],
162
+ ): T {
163
+ return sanitizeModelPayloadDeep(payload, new WeakSet<object>(), 0, knownSecrets);
160
164
  }
161
165
 
162
166
  const MODEL_PAYLOAD_SANITIZE_MAX_DEPTH = 64;
163
167
  const MODEL_PAYLOAD_CYCLE_MARKER = "[OpenGeni omitted cyclic model payload]";
164
168
  const MODEL_PAYLOAD_DEPTH_MARKER = "[OpenGeni omitted model payload beyond database-safety depth]";
165
169
 
166
- function sanitizeModelPayloadDeep<T>(payload: T, seen: WeakSet<object>, depth: number): T {
170
+ function sanitizeModelPayloadDeep<T>(
171
+ payload: T,
172
+ seen: WeakSet<object>,
173
+ depth: number,
174
+ knownSecrets: readonly SecretForRedaction[] = [],
175
+ ): T {
167
176
  if (typeof payload === "string") {
168
- return sanitizeEventString(payload) as unknown as T;
177
+ return sanitizeEventString(redactSensitiveText(payload, knownSecrets)) as unknown as T;
169
178
  }
170
179
  if (!payload || typeof payload !== "object") return payload;
171
180
  if (payload instanceof Date) {
@@ -178,13 +187,25 @@ function sanitizeModelPayloadDeep<T>(payload: T, seen: WeakSet<object>, depth: n
178
187
  seen.add(payload);
179
188
  try {
180
189
  if (Array.isArray(payload)) {
181
- return payload.map((item) => sanitizeModelPayloadDeep(item, seen, depth + 1)) as unknown as T;
190
+ return payload.map((item) =>
191
+ sanitizeModelPayloadDeep(item, seen, depth + 1, knownSecrets),
192
+ ) as unknown as T;
182
193
  }
194
+ const usedKeys = new Set<string>();
183
195
  return Object.fromEntries(
184
- Object.entries(payload as Record<string, unknown>).map(([key, value]) => [
185
- sanitizeEventString(key),
186
- sanitizeModelPayloadDeep(value, seen, depth + 1),
187
- ]),
196
+ Object.entries(payload as Record<string, unknown>).map(([key, value]) => {
197
+ const safeKey = nextUniqueKey(
198
+ sanitizeEventString(redactSensitiveKey(key, knownSecrets)),
199
+ usedKeys,
200
+ );
201
+ if (isSensitiveFieldName(key)) {
202
+ return [safeKey, REDACTED] as const;
203
+ }
204
+ if (normalizeFieldName(key) === "headers") {
205
+ return [safeKey, sanitizeModelHeaders(value, seen, depth + 1, knownSecrets)] as const;
206
+ }
207
+ return [safeKey, sanitizeModelPayloadDeep(value, seen, depth + 1, knownSecrets)] as const;
208
+ }),
188
209
  ) as unknown as T;
189
210
  } finally {
190
211
  seen.delete(payload);
@@ -200,30 +221,98 @@ function safeDateIso(value: Date): string | null {
200
221
  }
201
222
  }
202
223
 
203
- function sanitizeSensitiveEventField(key: string, value: unknown): unknown {
224
+ function sanitizeSensitiveEventField(
225
+ key: string,
226
+ value: unknown,
227
+ knownSecrets: readonly SecretForRedaction[],
228
+ ): unknown {
204
229
  if (key === "mcpServers") {
205
- return sanitizeSessionMcpServerList(value);
230
+ return sanitizeSessionMcpServerList(value, knownSecrets);
206
231
  }
207
232
  if (key === "mcpCredentialUpdates") {
208
- return sanitizeMcpCredentialUpdateList(value);
233
+ return sanitizeMcpCredentialUpdateList(value, knownSecrets);
234
+ }
235
+ if (normalizeFieldName(key) === "headers") {
236
+ return sanitizeEventHeaders(value, knownSecrets);
209
237
  }
210
- if (SENSITIVE_FIELD_NAMES.has(normalizeFieldName(key))) {
238
+ if (isSensitiveFieldName(key)) {
211
239
  return REDACTED;
212
240
  }
213
- return sanitizeEventPayloadDeep(value);
241
+ return sanitizeEventPayloadDeep(value, knownSecrets);
214
242
  }
215
243
 
216
- function sanitizeSessionMcpServerList(value: unknown): unknown {
244
+ function sanitizeEventHeaders(
245
+ value: unknown,
246
+ knownSecrets: readonly SecretForRedaction[],
247
+ ): unknown {
248
+ if (!isPlainObject(value)) {
249
+ return sanitizeEventPayloadDeep(value, knownSecrets);
250
+ }
251
+ const usedKeys = new Set<string>();
252
+ return Object.fromEntries(
253
+ Object.entries(value).map(([key, child]) => {
254
+ const safeKey = nextUniqueKey(
255
+ sanitizeEventString(redactSensitiveKey(key, knownSecrets)),
256
+ usedKeys,
257
+ );
258
+ return [
259
+ safeKey,
260
+ isCredentialHeaderName(key) ? REDACTED : sanitizeEventPayloadDeep(child, knownSecrets),
261
+ ];
262
+ }),
263
+ );
264
+ }
265
+
266
+ function sanitizeModelHeaders(
267
+ value: unknown,
268
+ seen: WeakSet<object>,
269
+ depth: number,
270
+ knownSecrets: readonly SecretForRedaction[],
271
+ ): unknown {
272
+ if (!isPlainObject(value)) {
273
+ return sanitizeModelPayloadDeep(value, seen, depth, knownSecrets);
274
+ }
275
+ if (depth >= MODEL_PAYLOAD_SANITIZE_MAX_DEPTH) {
276
+ return MODEL_PAYLOAD_DEPTH_MARKER;
277
+ }
278
+ if (seen.has(value)) return MODEL_PAYLOAD_CYCLE_MARKER;
279
+ seen.add(value);
280
+ try {
281
+ const usedKeys = new Set<string>();
282
+ return Object.fromEntries(
283
+ Object.entries(value).map(([key, child]) => {
284
+ const safeKey = nextUniqueKey(
285
+ sanitizeEventString(redactSensitiveKey(key, knownSecrets)),
286
+ usedKeys,
287
+ );
288
+ return [
289
+ safeKey,
290
+ isCredentialHeaderName(key)
291
+ ? REDACTED
292
+ : sanitizeModelPayloadDeep(child, seen, depth + 1, knownSecrets),
293
+ ];
294
+ }),
295
+ );
296
+ } finally {
297
+ seen.delete(value);
298
+ }
299
+ }
300
+
301
+ function sanitizeSessionMcpServerList(
302
+ value: unknown,
303
+ knownSecrets: readonly SecretForRedaction[],
304
+ ): unknown {
217
305
  if (!Array.isArray(value)) {
218
- return sanitizeEventPayloadDeep(value);
306
+ return sanitizeEventPayloadDeep(value, knownSecrets);
219
307
  }
220
308
  return value.map((item) => {
221
309
  if (!isPlainObject(item)) {
222
- return sanitizeEventPayloadDeep(item);
310
+ return sanitizeEventPayloadDeep(item, knownSecrets);
223
311
  }
224
312
  const { headers, headersEncrypted, ...rest } = item;
225
- const cleaned = sanitizeEventPayloadDeep(rest) as Record<string, unknown>;
226
- const headerNames = safeHeaderNames(headers) ?? safeHeaderNames(headersEncrypted);
313
+ const cleaned = sanitizeEventPayloadDeep(rest, knownSecrets) as Record<string, unknown>;
314
+ const headerNames =
315
+ safeHeaderNames(headers, knownSecrets) ?? safeHeaderNames(headersEncrypted, knownSecrets);
227
316
  if (headerNames) {
228
317
  cleaned.headerNames = headerNames;
229
318
  }
@@ -231,17 +320,21 @@ function sanitizeSessionMcpServerList(value: unknown): unknown {
231
320
  });
232
321
  }
233
322
 
234
- function sanitizeMcpCredentialUpdateList(value: unknown): unknown {
323
+ function sanitizeMcpCredentialUpdateList(
324
+ value: unknown,
325
+ knownSecrets: readonly SecretForRedaction[],
326
+ ): unknown {
235
327
  if (!Array.isArray(value)) {
236
- return sanitizeEventPayloadDeep(value);
328
+ return sanitizeEventPayloadDeep(value, knownSecrets);
237
329
  }
238
330
  return value.map((item) => {
239
331
  if (!isPlainObject(item)) {
240
- return sanitizeEventPayloadDeep(item);
332
+ return sanitizeEventPayloadDeep(item, knownSecrets);
241
333
  }
242
334
  const { headers, headersEncrypted, ...rest } = item;
243
- const cleaned = sanitizeEventPayloadDeep(rest) as Record<string, unknown>;
244
- const headerNames = safeHeaderNames(headers) ?? safeHeaderNames(headersEncrypted);
335
+ const cleaned = sanitizeEventPayloadDeep(rest, knownSecrets) as Record<string, unknown>;
336
+ const headerNames =
337
+ safeHeaderNames(headers, knownSecrets) ?? safeHeaderNames(headersEncrypted, knownSecrets);
245
338
  if (headerNames) {
246
339
  cleaned.headerNames = headerNames;
247
340
  }
@@ -249,11 +342,30 @@ function sanitizeMcpCredentialUpdateList(value: unknown): unknown {
249
342
  });
250
343
  }
251
344
 
252
- function safeHeaderNames(value: unknown): string[] | null {
345
+ function safeHeaderNames(
346
+ value: unknown,
347
+ knownSecrets: readonly SecretForRedaction[],
348
+ ): string[] | null {
253
349
  if (!isPlainObject(value)) {
254
350
  return null;
255
351
  }
256
- return Object.keys(value).map(sanitizeEventString).sort();
352
+ const usedKeys = new Set<string>();
353
+ return Object.keys(value)
354
+ .map((key) =>
355
+ nextUniqueKey(sanitizeEventString(redactSensitiveKey(key, knownSecrets)), usedKeys),
356
+ )
357
+ .sort();
358
+ }
359
+
360
+ function nextUniqueKey(base: string, usedKeys: Set<string>): string {
361
+ let candidate = base;
362
+ let suffix = 2;
363
+ while (usedKeys.has(candidate)) {
364
+ candidate = `${base}#${suffix}`;
365
+ suffix += 1;
366
+ }
367
+ usedKeys.add(candidate);
368
+ return candidate;
257
369
  }
258
370
 
259
371
  function isPlainObject(value: unknown): value is Record<string, unknown> {