From 9ec5b81d634455fae7ede43266e619b38f786c76 Mon Sep 17 00:00:00 2001 From: Jack Brown Date: Mon, 13 Apr 2026 21:04:28 -0700 Subject: [PATCH 1/2] fix redactSecrets stack overflow on circular references Use a WeakMap to track visited objects and map them to their redacted results. This prevents infinite recursion and ensures secrets are redacted even through circular reference paths. --- packages/runtimeuse/src/utils.test.ts | 10 ++++++++++ packages/runtimeuse/src/utils.ts | 21 +++++++++++++++------ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/runtimeuse/src/utils.test.ts b/packages/runtimeuse/src/utils.test.ts index 31f0d7c..298ed32 100644 --- a/packages/runtimeuse/src/utils.test.ts +++ b/packages/runtimeuse/src/utils.test.ts @@ -116,5 +116,15 @@ describe("redactSecrets", () => { redactSecrets("my-secret-key", ["secret", "my-secret-key"]), ).toBe("my-[REDACTED]-key"); }); + + it("handles circular references without stack overflow", () => { + const obj: Record = { key: "SECRET" }; + obj.self = obj; + const result = redactSecrets(obj, ["SECRET"]) as Record; + expect(result.key).toBe("[REDACTED]"); + // Circular ref points to the redacted result, not the original + expect(result.self).toBe(result); + expect((result.self as Record).key).toBe("[REDACTED]"); + }); }); }); diff --git a/packages/runtimeuse/src/utils.ts b/packages/runtimeuse/src/utils.ts index 8958f29..37b15d8 100644 --- a/packages/runtimeuse/src/utils.ts +++ b/packages/runtimeuse/src/utils.ts @@ -6,7 +6,7 @@ export async function sleep(ms: number) { * Recursively redact secret values from an arbitrary data structure. * Any string that contains a secret will have that secret replaced with [REDACTED]. */ -export function redactSecrets(value: T, secrets: string[]): T { +export function redactSecrets(value: T, secrets: string[], seen?: WeakMap): T { if (secrets.length === 0) return value; if (typeof value === "string") { @@ -19,14 +19,23 @@ export function redactSecrets(value: T, secrets: string[]): T { return redacted as T; } - if (Array.isArray(value)) { - return value.map((item) => redactSecrets(item, secrets)) as T; - } - if (value !== null && typeof value === "object") { + if (!seen) seen = new WeakMap(); + if (seen.has(value as object)) return seen.get(value as object) as T; + + if (Array.isArray(value)) { + const result: unknown[] = []; + seen.set(value as object, result); + for (const item of value) { + result.push(redactSecrets(item, secrets, seen)); + } + return result as T; + } + const result: Record = {}; + seen.set(value as object, result); for (const [key, val] of Object.entries(value)) { - result[key] = redactSecrets(val, secrets); + result[key] = redactSecrets(val, secrets, seen); } return result as T; } From b5409afcdd41699e7790316be42998e62c9088be Mon Sep 17 00:00:00 2001 From: Jack Brown Date: Mon, 13 Apr 2026 21:10:37 -0700 Subject: [PATCH 2/2] hide WeakMap cycle-tracking behind private helper Move recursive logic into a closure so the public signature stays as the documented two-parameter form. --- packages/runtimeuse/src/utils.ts | 53 +++++++++++++++++--------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/runtimeuse/src/utils.ts b/packages/runtimeuse/src/utils.ts index 37b15d8..eb18e4f 100644 --- a/packages/runtimeuse/src/utils.ts +++ b/packages/runtimeuse/src/utils.ts @@ -6,39 +6,44 @@ export async function sleep(ms: number) { * Recursively redact secret values from an arbitrary data structure. * Any string that contains a secret will have that secret replaced with [REDACTED]. */ -export function redactSecrets(value: T, secrets: string[], seen?: WeakMap): T { +export function redactSecrets(value: T, secrets: string[]): T { if (secrets.length === 0) return value; - if (typeof value === "string") { - let redacted: string = value; - for (const secret of secrets) { - if (secret && redacted.includes(secret)) { - redacted = redacted.replaceAll(secret, "[REDACTED]"); + const seen = new WeakMap(); + + function redact(val: U): U { + if (typeof val === "string") { + let redacted: string = val; + for (const secret of secrets) { + if (secret && redacted.includes(secret)) { + redacted = redacted.replaceAll(secret, "[REDACTED]"); + } } + return redacted as U; } - return redacted as T; - } - if (value !== null && typeof value === "object") { - if (!seen) seen = new WeakMap(); - if (seen.has(value as object)) return seen.get(value as object) as T; + if (val !== null && typeof val === "object") { + if (seen.has(val as object)) return seen.get(val as object) as U; - if (Array.isArray(value)) { - const result: unknown[] = []; - seen.set(value as object, result); - for (const item of value) { - result.push(redactSecrets(item, secrets, seen)); + if (Array.isArray(val)) { + const result: unknown[] = []; + seen.set(val as object, result); + for (const item of val) { + result.push(redact(item)); + } + return result as U; } - return result as T; - } - const result: Record = {}; - seen.set(value as object, result); - for (const [key, val] of Object.entries(value)) { - result[key] = redactSecrets(val, secrets, seen); + const result: Record = {}; + seen.set(val as object, result); + for (const [key, v] of Object.entries(val)) { + result[key] = redact(v); + } + return result as U; } - return result as T; + + return val; } - return value; + return redact(value); }