From 877b28cd793a0e4fc52c9ea9503d788bb41bf618 Mon Sep 17 00:00:00 2001 From: Mathews-Tom Date: Sun, 21 Jun 2026 05:40:15 +0530 Subject: [PATCH] Preserve replacements after restart --- .../coding-agent/src/secrets/obfuscator.ts | 19 ++++++++++++----- .../test/secrets-obfuscator.test.ts | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index 37778e0c7..6b5d92ec3 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -22,15 +22,23 @@ export interface SecretEntry { // ═══════════════════════════════════════════════════════════════════════════ const REPLACEMENT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +const REPLACEMENT_SENTINEL = "ZZ"; + +function isGeneratedDeterministicReplacement(value: string): boolean { + return value.length === 1 ? value === REPLACEMENT_SENTINEL[0] : value.startsWith(REPLACEMENT_SENTINEL); +} /** Generate a deterministic same-length replacement string from a secret value. */ function generateDeterministicReplacement(secret: string): string { - // Simple hash: use Bun.hash for speed, seed from the secret bytes + if (secret.length === 0) return ""; + if (isGeneratedDeterministicReplacement(secret)) return secret; + // Prefix generated chunks with a fixed sentinel so a restarted process can + // recognize already-redacted default replacements next to reversible + // placeholders and keep outbound history idempotent. const hash = BigInt(Bun.hash(secret)); - const chars: string[] = []; + const chars = secret.length === 1 ? ["Z"] : ["Z", "Z"]; let h = hash; - for (let i = 0; i < secret.length; i++) { - // Mix the hash for each character position + for (let i = chars.length; i < secret.length; i++) { h = h ^ (BigInt(i + 1) * 0x9e3779b97f4a7c15n); const idx = Number((h < 0n ? -h : h) % BigInt(REPLACEMENT_CHARS.length)); chars.push(REPLACEMENT_CHARS[idx]); @@ -325,7 +333,8 @@ export class SecretObfuscator { match.preserveInputPlaceholders && entry.replacement === undefined && match.inputPlaceholderOutsideChunkCount === 1 && - this.#generatedReplaceChunks.has(match.inputPlaceholderOutside) + (this.#generatedReplaceChunks.has(match.inputPlaceholderOutside) || + isGeneratedDeterministicReplacement(match.inputPlaceholderOutside)) ) { continue; } diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 2f6251e28..fca9dad1d 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -637,6 +637,27 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obf.obfuscate(out)).toBe(out); }); + it("keeps default replace regex output idempotent after restart", () => { + const key = "P".repeat(43); + const first = new SecretObfuscator( + [ + { type: "plain", content: "SECRET" }, + { type: "regex", mode: "replace", content: "[A-Za-z0-9]+" }, + ], + key, + ); + const persisted = first.obfuscate("SECRETX1"); + const restarted = new SecretObfuscator( + [ + { type: "plain", content: "SECRET" }, + { type: "regex", mode: "replace", content: "[A-Za-z0-9]+" }, + ], + key, + ); + + expect(restarted.obfuscate(persisted)).toBe(persisted); + }); + it("ignores regex matches that fall entirely inside known placeholders", () => { const obfuscator = new SecretObfuscator([ { type: "plain", content: "abc" },