From 7ca59dc6bd02569e964eda776dd0ddb085ce9f7e Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 25 Jul 2026 20:23:57 +0000 Subject: [PATCH 1/3] fix(secrets): avoided hashline placeholder collisions - Switched newly generated secret placeholders to double-dollar delimiters. - Preserved trusted stored-session restoration for legacy hash-delimited tokens. - Updated redaction regressions and changelog coverage. Fixes #6631 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/advisor/__tests__/advisor.test.ts | 20 +- .../coding-agent/src/secrets/obfuscator.ts | 63 +++-- .../coding-agent/src/session/agent-session.ts | 2 +- .../agent-session-tree-ask-reanswer.test.ts | 2 +- .../test/secrets-obfuscator.test.ts | 230 +++++++++--------- 6 files changed, 176 insertions(+), 142 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c4777f2be..30b00548b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed hide-secrets placeholders conflicting with hashline edit headers by using `$$HASH$$` delimiters for newly generated placeholders while retaining stored-session restoration for legacy `#HASH#` tokens ([#6631](https://github.com/can1357/oh-my-pi/issues/6631)). - Fixed the Docker `natives-builder` stage failing to build releases ≥ 17.1.1: the native audio stack added bindgen (miniaudio needs libclang) and a bundled-opus CMake build (needs cmake + make), none of which were installed in the slim builder image. - Fixed `omp usage` duplicating org-less legacy accounts as "no usage data" rows whenever any sibling report carried an organization (mixed pools of pre-org-capture rows and fresh org-scoped logins): an org-less account is now covered by its own org-less report, while org-attributed sibling reports still never count as its coverage. - `omp usage` revalidates the broker credential snapshot before rendering: live usage reports were previously paired with a disk-cached account list up to an hour old, so a just-completed re-login (org-less row upserted to org-scoped) rendered as a phantom duplicate until the cache expired. diff --git a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts index 32e62d4ca..7e5bc7ccf 100644 --- a/packages/coding-agent/src/advisor/__tests__/advisor.test.ts +++ b/packages/coding-agent/src/advisor/__tests__/advisor.test.ts @@ -1634,7 +1634,7 @@ describe("advisor", () => { await Promise.resolve(); expect(promptInputs).toHaveLength(1); - expect(promptInputs[0]).toContain("#TOKABC123_"); + expect(promptInputs[0]).toContain("$$TOKABC123_"); expect(promptInputs[0]).not.toContain("tok_abc123"); }); it("does not scan advisor-hidden successful tool-result bodies", async () => { @@ -1666,7 +1666,7 @@ describe("advisor", () => { await Promise.resolve(); expect(promptInputs).toHaveLength(1); - expect(promptInputs[0]).toContain("#TOKABC123_"); + expect(promptInputs[0]).toContain("$$TOKABC123_"); expect(promptInputs[0]).not.toContain("tok_abc123"); }); it("does not scan tool-call arguments hidden by the primary-argument preview", async () => { @@ -1693,7 +1693,7 @@ describe("advisor", () => { }); runtime.onTurnEnd(); await runtime.waitForCatchup(1000, 1); - expect(promptInputs[0]).toContain("#TOKABC123_"); + expect(promptInputs[0]).toContain("$$TOKABC123_"); expect(promptInputs[0]).not.toContain("tok_abc123"); }); @@ -1722,7 +1722,7 @@ describe("advisor", () => { }); runtime.onTurnEnd(); await runtime.waitForCatchup(1000, 1); - expect(promptInputs[0]).toContain("#TOKABC123_"); + expect(promptInputs[0]).toContain("$$TOKABC123_"); expect(promptInputs[0]).not.toContain("tok_abc123"); }); @@ -1761,7 +1761,7 @@ describe("advisor", () => { await Promise.resolve(); expect(promptInputs).toHaveLength(1); - expect(promptInputs[0]).toContain("#TOKABC123_"); + expect(promptInputs[0]).toContain("$$TOKABC123_"); expect(promptInputs[0]).not.toContain("tok_abc123"); }); it("does not scan execution source after the advisor preview cap", async () => { @@ -1798,7 +1798,7 @@ describe("advisor", () => { await Promise.resolve(); expect(promptInputs).toHaveLength(1); - expect(promptInputs[0]).toContain("#TOKABC123_"); + expect(promptInputs[0]).toContain("$$TOKABC123_"); expect(promptInputs[0]).not.toContain("tok_abc123"); }); @@ -1829,7 +1829,7 @@ describe("advisor", () => { await Promise.resolve(); expect(promptInputs).toHaveLength(1); - expect(promptInputs[0]).toContain("#TOKABC123_"); + expect(promptInputs[0]).toContain("$$TOKABC123_"); expect(promptInputs[0]).not.toContain("tok_abc123"); expect(obfuscate).not.toHaveBeenCalledWith("tok_abc123", expect.anything()); }); @@ -1864,7 +1864,7 @@ describe("advisor", () => { await Promise.resolve(); expect(promptInputs).toHaveLength(1); - expect(promptInputs[0]).toContain("#TOKABC123_"); + expect(promptInputs[0]).toContain("$$TOKABC123_"); expect(promptInputs[0]).not.toContain("tok_abc123"); expect(obfuscate).not.toHaveBeenCalledWith("tok_abc123", expect.anything()); }); @@ -1876,7 +1876,7 @@ describe("advisor", () => { // precomputation obfuscateMessages performs for the primary provider path // (see secrets-obfuscator.test.ts). Redacting message fields independently // would let the EARLIER user message's plain secret (OTHERSECRET) mint a - // friendly-prefixed placeholder ("#TOKABC123_#") before the SIBLING + // friendly-prefixed placeholder ("$$TOKABC123_$$") before the SIBLING // toolResult's `details.diff` field, later in the same delta, reveals the // regex-protected value that friendly name normalizes to // (tok_abc123 -> TOKABC123) — baking a normalized rendering of that @@ -2004,7 +2004,7 @@ describe("advisor", () => { // placeholder from a PRIOR thinking block: if thinking fell through // unredacted, the advisor prompt would receive both the raw secret AND, // had it been redacted without sharing the regex collision set, a - // normalized "#TOKABC123_#" rendering of the regex-protected value + // normalized "$$TOKABC123_$$" rendering of the regex-protected value // (tok_abc123) only discovered later in the same delta. const obfuscator = new SecretObfuscator([ { type: "plain", content: "OTHERSECRET", friendlyName: "TOKABC123" }, diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index f867bd1f6..4bc539613 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -502,21 +502,23 @@ function inferCaseHint(secret: string): PlaceholderCaseHint | undefined { function buildPlaceholder(hint: PlaceholderCaseHint | undefined, base: string, friendlyName?: string): string { const prefix = friendlyName ? `${friendlyName}_` : ""; - return hint ? `#${prefix}${base}:${hint}#` : `#${prefix}${base}#`; + return hint ? `$$${prefix}${base}:${hint}$$` : `$$${prefix}${base}$$`; } -/** Regex to match #HASH#, #HASH:U#, and #FRIENDLY_HASH(:hint)# placeholders. */ -const PLACEHOLDER_RE = /#(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?#/g; +/** Regex matching current `$$HASH$$` and persisted legacy `#HASH#` placeholders. */ +const PLACEHOLDER_RE = /(?:\$\$(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?\$\$|#(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?#)/g; function resumePlaceholderScanAfterRejectedCandidate(match: RegExpExecArray): void { // RegExp#exec does not find overlapping matches. Restart at the rejected - // candidate's closing `#`, which can open an immediately adjacent placeholder. - PLACEHOLDER_RE.lastIndex = match.index + match[0].length - 1; + // candidate's closing delimiter, which can open an immediately adjacent placeholder. + PLACEHOLDER_RE.lastIndex = match.index + match[0].length - (match[0].startsWith("$$") ? 2 : 1); } function placeholderWithoutFriendlyName(placeholder: string): string | undefined { - const match = /^#[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder); - return match ? `#${match[1]}#` : undefined; + const current = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder); + if (current !== null) return `$$${current[1]}$$`; + const legacy = /^#[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder); + return legacy ? `#${legacy[1]}#` : undefined; } function lookupFriendlyPlaceholderAlias( @@ -529,14 +531,13 @@ function lookupFriendlyPlaceholderAlias( return unprefixed !== undefined ? deobfuscateMap.get(unprefixed) : undefined; } -const PENDING_PLACEHOLDER_SUFFIX_RE = /#(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?$/; +const PENDING_PLACEHOLDER_SUFFIX_RE = + /(?:\${1,2}(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?|#(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?)$/; // Withhold a trailing run that could be the start of a placeholder from streamed // deltas, so a partial token is never emitted before deobfuscation can replace -// it. A lone trailing `#` is always buffered, even right after an alnum/`:` -// (e.g. `ID#`), because that `#` can open a placeholder; emitting it would -// corrupt the length-sliced live draft once the token completes. The final -// non-streamed flush re-emits any buffered tail, so nothing is lost. +// it. A lone trailing delimiter character is always buffered because it can open +// a placeholder; the final non-streamed flush re-emits it when no token follows. export function stripPendingSecretPlaceholderSuffix(text: string): string { const pendingPlaceholderStart = text.match(PENDING_PLACEHOLDER_SUFFIX_RE); if (pendingPlaceholderStart?.index === undefined) return text; @@ -929,7 +930,7 @@ export class SecretObfuscator { // placeholder minted under a renamed friendly name still deobfuscates // (see `#prefixIsSecretShaped`'s docstring for why), but unconditionally // stripping and ignoring an attacker-authored prefix would let a forged - // token like `#GITHUBPATABC123_#` + // token like `$$GITHUBPATABC123_$$` // restore to that OTHER secret's raw value with no check at all — worse // than the obfuscate-direction leak, since deobfuscation is what feeds // tool-call arguments and provider-output restoration. Refuse the @@ -939,13 +940,18 @@ export class SecretObfuscator { #lookupLiveAlias(placeholder: string): { secret: string; recursive: boolean } | undefined { const direct = this.#deobfuscateMap.get(placeholder); if (direct !== undefined) return direct; - const match = /^#([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder); + const body = placeholder.startsWith("$$") ? placeholder.slice(2, -2) : placeholder.slice(1, -1); + const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(body); if (match === null || this.#prefixIsSecretShaped(match[1]!)) return undefined; - return this.#deobfuscateMap.get(`#${match[2]}#`); + const unprefixed = placeholder.startsWith("$$") ? `$$${match[2]}$$` : `#${match[2]}#`; + return this.#deobfuscateMap.get(unprefixed); } #deobfuscate(text: string, allowLegacy: boolean): string { - if ((!this.#hasAny && (!allowLegacy || this.#legacyDeobfuscateMap.size === 0)) || !text.includes("#")) + if ( + (!this.#hasAny && (!allowLegacy || this.#legacyDeobfuscateMap.size === 0)) || + (!text.includes("$$") && !text.includes("#")) + ) return text; let result = text; for (;;) { @@ -965,7 +971,7 @@ export class SecretObfuscator { } return match; }); - if (next === result || !shouldContinue || !next.includes("#")) return next; + if (next === result || !shouldContinue || (!next.includes("$$") && !next.includes("#"))) return next; result = next; } } @@ -1390,7 +1396,8 @@ export class SecretObfuscator { #placeholderForCurrentInput(placeholder: string): string { const unprefixed = placeholderWithoutFriendlyName(placeholder); if (unprefixed === undefined) return placeholder; - const match = /^#([A-Z0-9]+)_/.exec(placeholder); + const body = placeholder.startsWith("$$") ? placeholder.slice(2, -2) : placeholder.slice(1, -1); + const match = /^([A-Z0-9]+)_/.exec(body); if (match === null || !this.#prefixIsSecretShaped(match[1]!)) return placeholder; return unprefixed; } @@ -1443,6 +1450,20 @@ export class SecretObfuscator { this.#deobfuscateMap.set(unprefixed, { secret, recursive }); } } + if (placeholder.startsWith("$$")) { + const legacy = `#${placeholder.slice(2, -2)}#`; + const existingLegacy = this.#legacyDeobfuscateMap.get(legacy); + if (existingLegacy === undefined || existingLegacy.secret === secret) { + this.#legacyDeobfuscateMap.set(legacy, { secret, recursive }); + } + if (unprefixed !== undefined) { + const legacyUnprefixed = `#${unprefixed.slice(2, -2)}#`; + const existingLegacyUnprefixed = this.#legacyDeobfuscateMap.get(legacyUnprefixed); + if (existingLegacyUnprefixed === undefined || existingLegacyUnprefixed.secret === secret) { + this.#legacyDeobfuscateMap.set(legacyUnprefixed, { secret, recursive }); + } + } + } } // Whether an alnum-only, uppercase friendly-name-shaped prefix dropped from @@ -1492,10 +1513,12 @@ export class SecretObfuscator { // deobfuscate-direction check in `#deobfuscate`. #isGeneratedPlaceholder(placeholder: string): boolean { if (this.#deobfuscateMap.has(placeholder)) return true; - const match = /^#([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder); + const body = placeholder.startsWith("$$") ? placeholder.slice(2, -2) : placeholder.slice(1, -1); + const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(body); if (match === null) return false; if (this.#prefixIsSecretShaped(match[1]!)) return false; - return this.#deobfuscateMap.has(`#${match[2]}#`); + const unprefixed = placeholder.startsWith("$$") ? `$$${match[2]}$$` : `#${match[2]}#`; + return this.#deobfuscateMap.has(unprefixed); } // Replace `search` with `replacement` outside known generated placeholders while diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 9c6e3caff..84ebd8812 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2249,7 +2249,7 @@ export class AgentSession { // Deobfuscate assistant message content for display emission — the LLM echoes back // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real // values. The original event.message stays obfuscated so the persistence path below - // writes `#HASH#` tokens to the session file; convertToLlm re-obfuscates outbound + // writes `$$HASH$$` tokens to the session file; convertToLlm re-obfuscates outbound // traffic on the next turn. Walks text, thinking, and toolCall arguments/intent. let displayEvent: AgentEvent = event; const obfuscator = this.#obfuscator; diff --git a/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts b/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts index daa43ea0e..d30645ebd 100644 --- a/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts +++ b/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts @@ -360,7 +360,7 @@ describe("AgentSession tree navigation onto an ask toolResult", () => { it("(i) deobfuscates recovered ask arguments when secret obfuscation is active", async () => { // The recovery path must mirror the live tool path's // `transformToolCallArguments`: persisted `ask` toolCall arguments may - // hold `#HASH#` placeholders in place of real secrets, and must be + // hold `$$HASH$$` placeholders in place of real secrets, and must be // deobfuscated before validation — otherwise the reopened picker shows // the raw placeholder instead of the original question text // (chatgpt-codex review on #5895). diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 50d1da114..a912ce150 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -313,7 +313,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const input = `use ${secret} now`; const obfuscated = obfuscator.obfuscate(input); - expect(obfuscated).toMatch(/#GITHUBTOKEN_[A-Z0-9]+:L#/); + expect(obfuscated).toMatch(/\$\$GITHUBTOKEN_[A-Z0-9]+:L\$\$/); expect(obfuscated).not.toContain(secret); expect(obfuscator.deobfuscate(obfuscated)).toBe(input); }); @@ -335,7 +335,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const collidingObfuscated = collidingObfuscator.obfuscate(collidingSecret); expect(collidingObfuscated).not.toMatch(/GITHUBPATABC123_/); - expect(collidingObfuscated).toMatch(/^#[A-Z0-9]+:L#$/); + expect(collidingObfuscated).toMatch(/^\$\$[A-Z0-9]+:L\$\$$/); expect(collidingObfuscator.deobfuscate(collidingObfuscated)).toBe(collidingSecret); const distinctSecret = "github_pat_xyz789"; @@ -344,7 +344,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ]); const distinctObfuscated = distinctObfuscator.obfuscate(distinctSecret); - expect(distinctObfuscated).toMatch(/^#GITHUBTOKEN_[A-Z0-9]+:L#$/); + expect(distinctObfuscated).toMatch(/^\$\$GITHUBTOKEN_[A-Z0-9]+:L\$\$$/); expect(distinctObfuscator.deobfuscate(distinctObfuscated)).toBe(distinctSecret); }); @@ -369,7 +369,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate(longSecret); expect(obfuscated).not.toMatch(/GITHUBPAT/); - expect(obfuscated).toMatch(/^#[A-Z0-9]+:L#$/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]+:L\$\$$/); expect(obfuscator.deobfuscate(obfuscated)).toBe(longSecret); }); @@ -389,7 +389,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate(longSecret); expect(obfuscated).not.toContain(leakedPrefix); - expect(obfuscated).toMatch(/^#[A-Z0-9]+:L#$/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]+:L\$\$$/); expect(obfuscator.deobfuscate(obfuscated)).toBe(longSecret); }); @@ -399,7 +399,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const input = `use ${secret} please`; const obfuscated = obfuscator.obfuscate(input); - expect(obfuscated).toMatch(/#APIKEY_[A-Z0-9]+:L#/); + expect(obfuscated).toMatch(/\$\$APIKEY_[A-Z0-9]+:L\$\$/); expect(obfuscated).not.toContain(secret); expect(obfuscator.deobfuscate(obfuscated)).toBe(input); }); @@ -424,7 +424,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate(input); expect(obfuscated).not.toMatch(/TOKABC123_/); - expect(obfuscated).toMatch(/^use #[A-Z0-9]+:L# now$/); + expect(obfuscated).toMatch(/^use \$\$[A-Z0-9]+:L\$\$ now$/); expect(obfuscator.deobfuscate(obfuscated)).toBe(input); }); @@ -445,7 +445,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate(input); expect(obfuscated).not.toMatch(/TOKABC123_/); - expect(obfuscated).toMatch(/^use #[A-Z0-9]+:L# now$/); + expect(obfuscated).toMatch(/^use \$\$[A-Z0-9]+:L\$\$ now$/); expect(obfuscator.deobfuscate(obfuscated)).toBe(input); }); @@ -467,7 +467,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obfuscated).not.toContain("TOKABC123_"); expect(obfuscated).not.toContain("zeta_secret1"); expect(obfuscated).not.toContain("tok_abc123"); - expect(obfuscated).toMatch(/^use #[A-Z0-9]{4,}(?::[ULCM])?# and #[A-Z0-9]{4,}(?::[ULCM])?# now$/); + expect(obfuscated).toMatch(/^use \$\$[A-Z0-9]{4,}(?::[ULCM])?\$\$ and \$\$[A-Z0-9]{4,}(?::[ULCM])?\$\$ now$/); expect(obfuscator.deobfuscate(obfuscated)).toBe(input); }); @@ -492,7 +492,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obfuscated).not.toContain("TOKABC123_"); expect(obfuscated).not.toContain("zeta_secret1"); expect(obfuscated).not.toContain("tok_abc123"); - expect(obfuscated).toMatch(/^use #[A-Z0-9]{4,}(?::[ULCM])?# and #[A-Z0-9]{4,}(?::[ULCM])?# now$/); + expect(obfuscated).toMatch(/^use \$\$[A-Z0-9]{4,}(?::[ULCM])?\$\$ and \$\$[A-Z0-9]{4,}(?::[ULCM])?\$\$ now$/); // Deobfuscation restores the two obfuscate-mode (regex-discovered) // placeholders to the values that were actually matched — the replace- // produced `tok_abc123` and the raw `zeta_secret1` — but the one-way @@ -522,7 +522,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obfuscated).not.toContain("TOKABC123_"); expect(obfuscated).not.toContain("zeta_secret1"); expect(obfuscated).not.toContain("tok_abc123"); - expect(obfuscated).toMatch(/^use #[A-Z0-9]{4,}(?::[ULCM])?# and #[A-Z0-9]{4,}(?::[ULCM])?# now$/); + expect(obfuscated).toMatch(/^use \$\$[A-Z0-9]{4,}(?::[ULCM])?\$\$ and \$\$[A-Z0-9]{4,}(?::[ULCM])?\$\$ now$/); // Deobfuscation restores the two obfuscate-mode (regex-discovered) // placeholders to the values that were actually matched — the // regex-replace-produced `tok_abc123` and the raw `zeta_secret1` — but @@ -556,7 +556,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obfuscated).not.toContain("TOKABC123_"); expect(obfuscated).not.toContain("OTHERSECRET"); expect(obfuscated).not.toContain("tok_abc123"); - expect(obfuscated).toMatch(/^use #[A-Z0-9]{4,}:U# and #[A-Z0-9]{4,}:L# now$/); + expect(obfuscated).toMatch(/^use \$\$[A-Z0-9]{4,}:U\$\$ and \$\$[A-Z0-9]{4,}:L\$\$ now$/); // Deobfuscation restores the plain secret's placeholder to `OTHERSECRET` // and the regex-discovered placeholder to the value actually matched // (`tok_abc123`); the one-way regex replace mapping never restores `X`. @@ -590,7 +590,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obfuscated).not.toContain("TOKABC123_"); expect(obfuscated).not.toContain("OTHERSECRET"); expect(obfuscated).not.toContain("tok_abc123"); - expect(obfuscated).toMatch(/^use #[A-Z0-9]{4,}:U# and #[A-Z0-9]{4,}:L# now$/); + expect(obfuscated).toMatch(/^use \$\$[A-Z0-9]{4,}:U\$\$ and \$\$[A-Z0-9]{4,}:L\$\$ now$/); // Deobfuscation restores the plain secret's placeholder to `OTHERSECRET` // and the regex-discovered placeholder to the value actually matched // (`tok_abc123`); the default `X` -> `Z` hop is one-way, so neither `X` @@ -603,10 +603,10 @@ describe("SecretObfuscator friendlyName placeholders", () => { { type: "plain", content: "OTHERSECRET", friendlyName: "TOKABC123" }, { type: "regex", content: "tok_[a-z0-9]+" }, ]); - const stalePlaceholder = "#TOKABC123_OLDHASH:L#"; + const stalePlaceholder = "$$TOKABC123_OLDHASH:L$$"; expect(obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(stalePlaceholder, new Set(["tok_abc123"]))).toBe( - "#OLDHASH:L#", + "$$OLDHASH:L$$", ); }); it("strips unsafe prefixes from overlapping historical placeholders", () => { @@ -614,10 +614,10 @@ describe("SecretObfuscator friendlyName placeholders", () => { { type: "plain", content: "OTHERSECRET", friendlyName: "TOKABC123" }, { type: "regex", content: "tok_[a-z0-9]+" }, ]); - const stalePlaceholders = "#FOOO#TOKABC123_OLDHASH:L#"; + const stalePlaceholders = "$$FOOO$$$$TOKABC123_OLDHASH:L$$"; expect(obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(stalePlaceholders, new Set(["tok_abc123"]))).toBe( - "#FOOO#OLDHASH:L#", + "$$FOOO$$$$OLDHASH:L$$", ); }); @@ -644,9 +644,9 @@ describe("SecretObfuscator friendlyName placeholders", () => { // Turn 1: tok_abc123 has not appeared anywhere yet, so "TOKABC123" is not // a collision — the friendly prefix is applied and persisted into history. const turn1 = obfuscator.obfuscate("use OTHERSECRET now"); - expect(turn1).toMatch(/^use #TOKABC123_[A-Z0-9]+:U# now$/); - const oldPlaceholder = turn1.match(/#TOKABC123_[A-Z0-9]+:U#/)![0]; - const bareAlias = oldPlaceholder.replace(/^#TOKABC123_/, "#"); + expect(turn1).toMatch(/^use \$\$TOKABC123_[A-Z0-9]+:U\$\$ now$/); + const oldPlaceholder = turn1.match(/\$\$TOKABC123_[A-Z0-9]+:U\$\$/)![0]; + const bareAlias = oldPlaceholder.replace(/^\$\$TOKABC123_/, "$$"); // Turn 2: the already-obfuscated turn-1 output re-enters as prior history // alongside NEW text that reveals tok_abc123 — a regex-protected value that @@ -677,7 +677,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const input = `${longSecret} ${prefixSecret}`; const obfuscated = obfuscator.obfuscate(input); - expect(obfuscated).toMatch(/^#TOKEN_[A-Z0-9]+:L# #[A-Z0-9]+:U#$/); + expect(obfuscated).toMatch(/^\$\$TOKEN_[A-Z0-9]+:L\$\$ \$\$[A-Z0-9]+:U\$\$$/); expect(obfuscated).not.toContain(longSecret); expect(obfuscator.deobfuscate(obfuscated)).toBe(input); }); @@ -688,7 +688,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate(`value ${secret}`); expect(obfuscated).not.toContain(secret); - expect(obfuscated).toMatch(/^value #[A-Z0-9]+:U#$/); + expect(obfuscated).toMatch(/^value \$\$[A-Z0-9]+:U\$\$$/); expect(obfuscator.deobfuscate(obfuscated)).toBe(`value ${secret}`); }); @@ -712,7 +712,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("api_key=abcdefghXYZ"); - expect(obfuscated).toMatch(/^#APIKEY_[A-Z0-9]+:M#$/); + expect(obfuscated).toMatch(/^\$\$APIKEY_[A-Z0-9]+:M\$\$$/); expect(obfuscated).not.toContain("abcdefgh"); expect(obfuscator.deobfuscate(obfuscated)).toBe("api_key=abcdefghXYZ"); }); @@ -725,7 +725,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("api_key=abcdefghXYZ"); - expect(obfuscated).toMatch(/^#APIKEY_[A-Z0-9]+:M#$/); + expect(obfuscated).toMatch(/^\$\$APIKEY_[A-Z0-9]+:M\$\$$/); expect(obfuscated).not.toContain("abcdefgh"); expect(obfuscator.deobfuscate(obfuscated)).toBe("api_key=abcdefghXYZ"); }); @@ -1323,7 +1323,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const key = "Q".repeat(43); const discoveryObf = new SecretObfuscator([{ type: "plain", content: "ABCDEFGH" }], key); const collidingPlaceholder = discoveryObf.obfuscate("ABCDEFGH"); - expect(collidingPlaceholder).toMatch(/^#[A-Z0-9]+(?::[ULCM])?#$/); + expect(collidingPlaceholder).toMatch(/^\$\$[A-Z0-9]+(?::[ULCM])?\$\$$/); for (const entries of [ [ @@ -1412,7 +1412,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ); const obfuscated = obfuscator.obfuscate("api_key=#XRRS#"); - expect(obfuscated).toMatch(/^#APIKEY_[A-Z0-9]+(?::[ULCM])?#$/); + expect(obfuscated).toMatch(/^\$\$APIKEY_[A-Z0-9]+(?::[ULCM])?\$\$$/); expect(obfuscator.deobfuscate(obfuscated)).toBe("api_key=#XRRS#"); }); @@ -1427,7 +1427,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("SECRETUVX1"); - expect(obfuscated).toMatch(/^#[A-Z0-9]+:U#REDACTED$/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]+:U\$\$REDACTED$/); expect(obfuscated).not.toMatch(/X1$/); expect(obfuscator.deobfuscate(obfuscated)).toBe("SECRETUVREDACTED"); }); @@ -1446,7 +1446,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { // The 8-char SECRETUVX1 redacts to one placeholder + REDACTED; assert the `X1` // suffix is gone via end-anchored structure, not substring absence — the // random keyed base can itself contain the two chars "X1". - expect(obfuscated).toMatch(/^#[A-Z0-9]+:U#REDACTED$/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]+:U\$\$REDACTED$/); expect(obfuscated).not.toMatch(/X1$/); expect(obfuscator.deobfuscate(obfuscated)).toBe("SECRETUVREDACTED"); }); @@ -1466,7 +1466,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { // it must not be duplicated on both sides of the preserved placeholder (the // bug produced `REDACTED#…#REDACTED`). Asserted by structure plus an // end-anchored guard rather than a base-collidable substring count. - expect(obfuscated).toMatch(/^REDACTED#[A-Z0-9]+:L#$/); + expect(obfuscated).toMatch(/^REDACTED\$\$[A-Z0-9]+:L\$\$$/); expect(obfuscated).not.toMatch(/REDACTED$/); expect(obfuscated).not.toContain("api_key="); expect(obfuscator.deobfuscate(obfuscated)).toBe("REDACTEDabcdefgh"); @@ -1485,7 +1485,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { "D".repeat(43), ); const replaceOnce = replace.obfuscate("SECRETUVX1"); - expect(replaceOnce).toMatch(/^#[A-Z0-9]+:U#REDACTED$/); + expect(replaceOnce).toMatch(/^\$\$[A-Z0-9]+:U\$\$REDACTED$/); expect(replace.obfuscate(replaceOnce)).toBe(replaceOnce); expect(replace.obfuscate(replace.obfuscate(replaceOnce))).toBe(replaceOnce); expect(replace.deobfuscate(replaceOnce)).toBe("SECRETUVREDACTED"); @@ -1524,7 +1524,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { "G".repeat(43), ); const token = obf.obfuscate("abcdefgh"); - expect(token).toMatch(/^#[A-Z0-9]+:L#$/); + expect(token).toMatch(/^\$\$[A-Z0-9]+:L\$\$$/); // Input carries the prior token literally AND a fresh api_key=abcdefghXYZ (raw `abc`). // The fresh occurrence must still be redacted (XYZ gone) while the prior token is @@ -1622,7 +1622,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ); const out = obf.obfuscate("XSECRETUVREDACTED"); - expect(out).toMatch(/^REDACTED#[A-Z0-9]+:U#REDACTED$/); + expect(out).toMatch(/^REDACTED\$\$[A-Z0-9]+:U\$\$REDACTED$/); expect(obf.obfuscate(out)).toBe(out); expect(obf.deobfuscate(out)).toBe("REDACTEDSECRETUVREDACTED"); }); @@ -2069,7 +2069,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ); const out = obf.obfuscate(`value=${secret}`); expect(out).not.toContain(secret); - expect(out).not.toMatch(/#[A-Z0-9]/); + expect(out).not.toMatch(/\$\$[A-Z0-9]/); // An unshadowed obfuscate entry still requires the key. expect(secretEntriesNeedPlaceholderKey([{ type: "plain", content: secret, mode: "obfuscate" }])).toBe(true); // A replace entry with DIFFERENT content does not shadow the obfuscate entry. @@ -2088,7 +2088,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ]; expect(secretEntriesNeedPlaceholderKey(reintroEntries)).toBe(true); const reintroOut = new SecretObfuscator(reintroEntries, "test-placeholder-key").obfuscate(`value=${secret}`); - expect(reintroOut).toMatch(/#[A-Z0-9]/); + expect(reintroOut).toMatch(/\$\$[A-Z0-9]/); // Among duplicate same-content replace entries the LAST one wins (the // obfuscator stores replace mappings in a content-keyed Map), so a safe earlier // duplicate must not mask a later reintroducing one: key is still required. @@ -2099,7 +2099,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ]; expect(secretEntriesNeedPlaceholderKey(dupReintroLast)).toBe(true); expect(new SecretObfuscator(dupReintroLast, "test-placeholder-key").obfuscate(`value=${secret}`)).toMatch( - /#[A-Z0-9]/, + /\$\$[A-Z0-9]/, ); // Reverse order: a later safe replacement overrides an earlier reintroducing // one, so the obfuscate entry is shadowed and no key is needed. @@ -2110,7 +2110,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ]; expect(secretEntriesNeedPlaceholderKey(dupSafeLast)).toBe(false); expect(new SecretObfuscator(dupSafeLast, "test-placeholder-key").obfuscate(`value=${secret}`)).not.toMatch( - /#[A-Z0-9]/, + /\$\$[A-Z0-9]/, ); // A transitive replace chain that rewrites a safe alias back into the secret // (`SECRET -> ALIAS`, `ALIAS -> SECRET`) reintroduces the value before the @@ -2123,7 +2123,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ]; expect(secretEntriesNeedPlaceholderKey(chainReintro)).toBe(true); expect(new SecretObfuscator(chainReintro, "test-placeholder-key").obfuscate(`value=${secret}`)).toMatch( - /#[A-Z0-9]/, + /\$\$[A-Z0-9]/, ); // A replacement fragment that joins with adjacent passthrough bytes to form an // obfuscate content (`A -> SEC`, so `ARET12` becomes `SECRET12` during the @@ -2135,7 +2135,9 @@ describe("SecretObfuscator friendlyName placeholders", () => { { type: "plain", content: "A", mode: "replace", replacement: "SEC" }, ]; expect(secretEntriesNeedPlaceholderKey(fragmentJoin)).toBe(true); - expect(new SecretObfuscator(fragmentJoin, "test-placeholder-key").obfuscate("x ARET12 y")).toMatch(/#[A-Z0-9]/); + expect(new SecretObfuscator(fragmentJoin, "test-placeholder-key").obfuscate("x ARET12 y")).toMatch( + /\$\$[A-Z0-9]/, + ); // A delete replacement also joins the passthrough bytes on both sides of the // removed token, so it can reconstruct the obfuscate content even though its // replacement output is empty. @@ -2145,7 +2147,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { { type: "plain", content: "X", mode: "replace", replacement: "" }, ]; expect(secretEntriesNeedPlaceholderKey(deleteJoin)).toBe(true); - expect(new SecretObfuscator(deleteJoin, "test-placeholder-key").obfuscate("SECRETX12")).toMatch(/#[A-Z0-9]/); + expect(new SecretObfuscator(deleteJoin, "test-placeholder-key").obfuscate("SECRETX12")).toMatch(/\$\$[A-Z0-9]/); }); it("does not require the key when a later replacement erases a content-forming fragment", () => { @@ -2165,7 +2167,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { // placeholder, confirming the config is genuinely non-placeholding. const out = new SecretObfuscator(entries, "test-placeholder-key").obfuscate("AARET12 and AART12"); expect(out).not.toContain("SECRET12"); - expect(out).not.toMatch(/#[A-Z0-9]/); + expect(out).not.toMatch(/\$\$[A-Z0-9]/); }); it("does not require the key when a later replacement erases the surrounding context bytes", () => { @@ -2186,7 +2188,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(secretEntriesNeedPlaceholderKey(entries)).toBe(false); const out = new SecretObfuscator(entries, "test-placeholder-key").obfuscate("AARET12 and SECRET12"); expect(out).not.toContain("SECRET12"); - expect(out).not.toMatch(/#[A-Z0-9]/); + expect(out).not.toMatch(/\$\$[A-Z0-9]/); }); it("redacts a raw sentinel-shaped suffix bridged into a match by a prior placeholder", () => { @@ -2206,7 +2208,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { "R".repeat(43), ); const token = obf.obfuscate("SECRETUV"); - expect(token).toMatch(/^#[A-Z0-9]+:U#$/); + expect(token).toMatch(/^\$\$[A-Z0-9]+:U\$\$$/); const out = obf.obfuscate(`${token}ZZZZ`); @@ -2300,7 +2302,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("abcdefgh"); expect(obfuscated).not.toBe("REDACTED"); - expect(obfuscated).toMatch(/^#[A-Z0-9]+:L#$/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]+:L\$\$$/); expect(obfuscator.deobfuscate(obfuscated)).toBe("abcdefgh"); }); @@ -2312,8 +2314,8 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("abcdefgh"); - expect(obfuscated).toMatch(/^#[A-Z0-9]+:L#$/); - expect(obfuscated).not.toMatch(/^#INNER_/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]+:L\$\$$/); + expect(obfuscated).not.toMatch(/^\$\$INNER_/); expect(obfuscator.deobfuscate(obfuscated)).toBe("abcdefgh"); }); @@ -2325,8 +2327,8 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("secretuvX"); - expect(obfuscated).toMatch(/^#[A-Z0-9]+:L#X$/); - expect(obfuscated).not.toMatch(/^#PARTIAL_/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]+:L\$\$X$/); + expect(obfuscated).not.toMatch(/^\$\$PARTIAL_/); expect(obfuscator.deobfuscate(obfuscated)).toBe("secretuvX"); }); @@ -2350,7 +2352,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { sharedKey, ); - expect(secondPlaceholder).toMatch(/^#OTHER_[A-Z0-9]+(?::[ULCM])?#$/); + expect(secondPlaceholder).toMatch(/^\$\$OTHER_[A-Z0-9]+(?::[ULCM])?\$\$$/); expect(obfuscator.deobfuscate(secondPlaceholder)).toBe(firstPlaceholder); }); @@ -2364,8 +2366,8 @@ describe("SecretObfuscator friendlyName placeholders", () => { { type: "plain", content: "alpha-secret" }, ]); - const firstToken = first.obfuscate("alpha-secret").match(/#[A-Z0-9]+:L#/)?.[0]; - const secondToken = second.obfuscate("alpha-secret").match(/#[A-Z0-9]+:L#/)?.[0]; + const firstToken = first.obfuscate("alpha-secret").match(/\$\$[A-Z0-9]+:L\$\$/)?.[0]; + const secondToken = second.obfuscate("alpha-secret").match(/\$\$[A-Z0-9]+:L\$\$/)?.[0]; expect(firstToken).toBeDefined(); expect(firstToken).toBe(secondToken); @@ -2383,6 +2385,15 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(obfuscator.deobfuscateStored("#NTJ5#")).toBe("#NTJ5#"); }); + it("restores hash-delimited keyed placeholders only from stored sessions", () => { + const obfuscator = new SecretObfuscator([{ type: "plain", content: "legacy-secret" }], "shared-key"); + const current = obfuscator.obfuscate("legacy-secret"); + const legacy = current.replace(/^\$\$/, "#").replace(/\$\$$/, "#"); + + expect(obfuscator.deobfuscateStored(legacy)).toBe("legacy-secret"); + expect(obfuscator.deobfuscate(legacy)).toBe(legacy); + }); + it("honors legacy index-derived aliases only on the stored-replay path", () => { const obfuscator = new SecretObfuscator([{ type: "plain", content: "legacy-secret" }]); @@ -2520,10 +2531,10 @@ describe("SecretObfuscator friendlyName placeholders", () => { it("deobfuscates placeholders after friendlyName changes", () => { const renamed = new SecretObfuscator([{ type: "plain", content: "renamed-secret", friendlyName: "new name" }]); const current = renamed.obfuscate("renamed-secret"); - const oldName = current.replace("#NEWNAME_", "#OLDNAME_"); + const oldName = current.replace("$$NEWNAME_", () => "$$OLDNAME_"); const removedName = new SecretObfuscator([{ type: "plain", content: "renamed-secret" }]); - expect(current).toMatch(/^#NEWNAME_[A-Z0-9]+:L#$/); + expect(current).toMatch(/^\$\$NEWNAME_[A-Z0-9]+:L\$\$$/); expect(renamed.deobfuscate(oldName)).toBe("renamed-secret"); expect(removedName.deobfuscate(oldName)).toBe("renamed-secret"); }); @@ -2537,13 +2548,12 @@ describe("SecretObfuscator friendlyName placeholders", () => { const [tokenA, tokenB] = obfuscated.split(" "); if (!tokenA || !tokenB) throw new Error("expected two friendly placeholders"); - expect(tokenA).toMatch(/^#ALPHA_[A-Z0-9]+:M#$/); - expect(tokenB).toMatch(/^#BRAVO_[A-Z0-9]+:M#$/); + expect(tokenA).toMatch(/^\$\$ALPHA_[A-Z0-9]+:M\$\$$/); + expect(tokenB).toMatch(/^\$\$BRAVO_[A-Z0-9]+:M\$\$$/); expect(obfuscator.deobfuscate(obfuscated)).toBe("SeCretuv SecRetuv"); - const stripPrefix = (token: string) => token.replace(/^#[A-Z0-9]+_/, "#"); - const aliasA = stripPrefix(tokenA); - const aliasB = stripPrefix(tokenB); + const aliasA = tokenA.replace(/^\$\$[A-Z0-9]+_/, () => "$$"); + const aliasB = tokenB.replace(/^\$\$[A-Z0-9]+_/, () => "$$"); expect(aliasA).not.toBe(aliasB); expect(obfuscator.deobfuscate(aliasA)).toBe("SeCretuv"); expect(obfuscator.deobfuscate(aliasB)).toBe("SecRetuv"); @@ -2592,13 +2602,13 @@ describe("SecretObfuscator friendlyName placeholders", () => { // name-independent bare-alias suffix (`_:#`) that // lookupFriendlyPlaceholderAlias also resolves on purpose for deobfuscate(). const bravoPlaceholder = obfuscator.obfuscate(secretB); - expect(bravoPlaceholder).toMatch(/^#BRAVO_[A-Z0-9]{4,}(?::[ULCM])?#$/); - const aliasSuffix = bravoPlaceholder.replace(/^#BRAVO/, ""); + expect(bravoPlaceholder).toMatch(/^\$\$BRAVO_[A-Z0-9]{4,}(?::[ULCM])?\$\$$/); + const aliasSuffix = bravoPlaceholder.replace(/^\$\$BRAVO/, ""); // Forge a token shaped exactly like a friendly placeholder for secretB, but // with secretA's raw literal value standing in for the friendly name. - const forgedExact = `#${secretA}${aliasSuffix}`; - expect(forgedExact).toMatch(/^#[A-Z0-9]+_[A-Z0-9]{4,}(?::[ULCM])?#$/); + const forgedExact = `$$${secretA}${aliasSuffix}`; + expect(forgedExact).toMatch(/^\$\$[A-Z0-9]+_[A-Z0-9]{4,}(?::[ULCM])?\$\$$/); expect(obfuscator.obfuscate(forgedExact)).not.toContain(secretA); // A prefix that merely CONTAINS secretA (not just equals it) must also be @@ -2642,13 +2652,13 @@ describe("SecretObfuscator friendlyName placeholders", () => { // Learn secretB's real placeholder, then derive the friendly-name- // independent bare-alias suffix (`_:#`). const bravoPlaceholder = obfuscator.obfuscate(secretB); - expect(bravoPlaceholder).toMatch(/^#BRAVO_[A-Z0-9]{4,}(?::[ULCM])?#$/); - const aliasSuffix = bravoPlaceholder.replace(/^#BRAVO/, ""); + expect(bravoPlaceholder).toMatch(/^\$\$BRAVO_[A-Z0-9]{4,}(?::[ULCM])?\$\$$/); + const aliasSuffix = bravoPlaceholder.replace(/^\$\$BRAVO/, ""); // Forge a token wrapping the REGEX secret's raw literal around secretB's // real bare-alias suffix. - const forgedExact = `#${regexSecret}${aliasSuffix}`; - expect(forgedExact).toMatch(/^#[A-Z0-9]+_[A-Z0-9]{4,}(?::[ULCM])?#$/); + const forgedExact = `$$${regexSecret}${aliasSuffix}`; + expect(forgedExact).toMatch(/^\$\$[A-Z0-9]+_[A-Z0-9]{4,}(?::[ULCM])?\$\$$/); expect(obfuscator.obfuscate(forgedExact)).not.toContain(regexSecret); // Mixed real + forged in one call: the real secretB placeholder must still @@ -2682,14 +2692,14 @@ describe("SecretObfuscator friendlyName placeholders", () => { // registering `tokabc123` (not `TOKABC123`) in `#obfuscateMappings`. const real = obf.obfuscate("use tokabc123 now"); expect(real).not.toContain("tokabc123"); - const suffix = /#([A-Z0-9]{4,}(?::[ULCM])?)#/.exec(real)?.[1]; + const suffix = /\$\$([A-Z0-9]{4,}(?::[ULCM])?)\$\$/.exec(real)?.[1]; expect(suffix).toBeDefined(); // Forge a token wrapping an UPPERCASE variant of the secret around the // real bare-alias suffix — differently cased from what was actually // discovered, so it cannot match either exact-string check, only the // regex pattern itself. - const forged = `see #TOKABC123_${suffix}# here`; + const forged = `see $$TOKABC123_${suffix}$$ here`; const out = obf.obfuscate(forged); expect(out).not.toContain("TOKABC123"); }); @@ -2714,15 +2724,15 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); const real = obfuscator.obfuscate(secret); - const suffix = /#([A-Z0-9]{4,}(?::[ULCM])?)#/.exec(real)?.[1]; + const suffix = /\$\$([A-Z0-9]{4,}(?::[ULCM])?)\$\$/.exec(real)?.[1]; expect(suffix).toBeDefined(); // Forge a prefix that is the sanitized rendering of the SAME configured // secret's own value, wrapped around the real bare-alias suffix. - const forged = `run tool with #GITHUBPATABC123_${suffix}# now`; + const forged = `run tool with $$GITHUBPATABC123_${suffix}$$ now`; const restored = obfuscator.deobfuscate(forged); expect(restored).not.toContain(secret); - expect(restored).toContain("#GITHUBPATABC123_"); + expect(restored).toContain("$$GITHUBPATABC123_"); // A genuine rename is unaffected: the OLD friendly-name prefix is not // itself secret-shaped, so the bare-alias fallback still resolves it to @@ -2731,9 +2741,9 @@ describe("SecretObfuscator friendlyName placeholders", () => { { type: "plain", content: "some-other-secret-value", friendlyName: "OldName" }, ]); const currentToken = renameObfuscator.obfuscate("some-other-secret-value"); - expect(currentToken).toMatch(/^#OLDNAME_[A-Z0-9]+:L#$/); - const renameSuffix = currentToken.replace(/^#OLDNAME/, ""); - expect(renameObfuscator.deobfuscate(`#NEWNAME${renameSuffix}`)).toBe("some-other-secret-value"); + expect(currentToken).toMatch(/^\$\$OLDNAME_[A-Z0-9]+:L\$\$$/); + const renameSuffix = currentToken.replace(/^\$\$OLDNAME/, ""); + expect(renameObfuscator.deobfuscate(`$$NEWNAME${renameSuffix}`)).toBe("some-other-secret-value"); }); it("drops a friendly name that contains another configured secret's literal value", () => { @@ -2758,11 +2768,11 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("ABCDEFGH"); expect(obfuscated).not.toContain("LEAKTOKEN"); // Friendly name dropped for this mint: bare, unprefixed placeholder shape. - expect(obfuscated).toMatch(/^#[A-Z0-9]{4,}(?::[ULCM])?#$/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]{4,}(?::[ULCM])?\$\$$/); // A friendly name that does NOT collide with any live secret is unaffected. const safeObfuscated = obfuscator.obfuscate("OTHERSECRETXY"); - expect(safeObfuscated).toMatch(/^#SAFE_[A-Z0-9]{4,}(?::[ULCM])?#$/); + expect(safeObfuscated).toMatch(/^\$\$SAFE_[A-Z0-9]{4,}(?::[ULCM])?\$\$$/); }); it("drops a friendly name matched by a later-declared regex secret, regardless of entries[] order", () => { @@ -2783,14 +2793,14 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("ABCDEFGH"); expect(obfuscated).not.toContain("LEAKTOKEN"); - expect(obfuscated).toMatch(/^#[A-Z0-9]{4,}(?::[ULCM])?#$/); + expect(obfuscated).toMatch(/^\$\$[A-Z0-9]{4,}(?::[ULCM])?\$\$$/); }); it("keeps a mixed-case placeholder stable when a same-normalized secret is added earlier", () => { // Session 1: only SecRet is configured; persist its mixed-case token. const before = new SecretObfuscator([{ type: "plain", content: "SecRetuv" }]); const persisted = before.obfuscate("SecRetuv"); - expect(persisted).toMatch(/^#[A-Z0-9]+:M#$/); + expect(persisted).toMatch(/^\$\$[A-Z0-9]+:M\$\$$/); // Session 2: SeCret (same normalized value, also :M) is added EARLIER. const after = new SecretObfuscator([ @@ -2880,18 +2890,18 @@ describe("SecretObfuscator friendlyName placeholders", () => { }); it("withholds pending placeholders while streaming provider text", () => { - expect(stripPendingSecretPlaceholderSuffix("before #")).toBe("before "); - expect(stripPendingSecretPlaceholderSuffix("before #AB12:")).toBe("before "); - expect(stripPendingSecretPlaceholderSuffix("before #TOKEN")).toBe("before "); - expect(stripPendingSecretPlaceholderSuffix("before #TOKEN_")).toBe("before "); - expect(stripPendingSecretPlaceholderSuffix("before #TOKEN_AB12:")).toBe("before "); - expect(stripPendingSecretPlaceholderSuffix("before #TOKEN_AB12:U")).toBe("before "); - // A lone trailing `#` is buffered even after an alnum/`:` because it can - // open a new placeholder; emitting it would corrupt the length-sliced draft. - expect(stripPendingSecretPlaceholderSuffix("before #TOKEN_AB12:U#")).toBe("before #TOKEN_AB12:U"); - expect(stripPendingSecretPlaceholderSuffix("prefix ID#")).toBe("prefix ID"); - expect(stripPendingSecretPlaceholderSuffix("count 42#")).toBe("count 42"); - expect(stripPendingSecretPlaceholderSuffix("before #TOKEN ")).toBe("before #TOKEN "); + expect(stripPendingSecretPlaceholderSuffix("before $")).toBe("before "); + expect(stripPendingSecretPlaceholderSuffix("before $$")).toBe("before "); + expect(stripPendingSecretPlaceholderSuffix("before $$AB12:")).toBe("before "); + expect(stripPendingSecretPlaceholderSuffix("before $$TOKEN")).toBe("before "); + expect(stripPendingSecretPlaceholderSuffix("before $$TOKEN_")).toBe("before "); + expect(stripPendingSecretPlaceholderSuffix("before $$TOKEN_AB12:")).toBe("before "); + expect(stripPendingSecretPlaceholderSuffix("before $$TOKEN_AB12:U")).toBe("before "); + // A trailing delimiter character is buffered because it can open an adjacent placeholder. + expect(stripPendingSecretPlaceholderSuffix("before $$TOKEN_AB12:U$$")).toBe("before $$TOKEN_AB12:U"); + expect(stripPendingSecretPlaceholderSuffix("prefix ID$")).toBe("prefix ID"); + expect(stripPendingSecretPlaceholderSuffix("count 42$")).toBe("count 42"); + expect(stripPendingSecretPlaceholderSuffix("before $$TOKEN ")).toBe("before $$TOKEN "); }); it("uses independent bases across casing variants with distinct hints", () => { @@ -2901,17 +2911,17 @@ describe("SecretObfuscator friendlyName placeholders", () => { { type: "plain", content: "Secretuv", friendlyName: "token" }, ]); const obfuscated = obfuscator.obfuscate("secretuv SECRETUV Secretuv"); - const tokens = obfuscated.match(/#TOKEN_[A-Z0-9]+:[ULCM]#/g); + const tokens = obfuscated.match(/\$\$TOKEN_[A-Z0-9]+:[ULCM]\$\$/g); if (!tokens) throw new Error("Expected case-hinted placeholders"); - const bases = tokens.map(token => /^#TOKEN_([A-Z0-9]+):/.exec(token)?.[1]); + const bases = tokens.map(token => /^\$\$TOKEN_([A-Z0-9]+):/.exec(token)?.[1]); expect(tokens).toHaveLength(3); // Distinct ASCII-case variants must NOT share a base: a shared case-folded // base would let a provider synthesize a sibling token by swapping the hint. expect(new Set(bases).size).toBe(3); - expect(tokens[0]?.endsWith(":L#")).toBe(true); - expect(tokens[1]?.endsWith(":U#")).toBe(true); - expect(tokens[2]?.endsWith(":C#")).toBe(true); + expect(tokens[0]?.endsWith(":L$$")).toBe(true); + expect(tokens[1]?.endsWith(":U$$")).toBe(true); + expect(tokens[2]?.endsWith(":C$$")).toBe(true); expect(obfuscator.deobfuscate(obfuscated)).toBe("secretuv SECRETUV Secretuv"); }); @@ -2931,18 +2941,18 @@ describe("SecretObfuscator friendlyName placeholders", () => { // Provider sees only the lowercase placeholder. const visible = obfuscator.obfuscate("abc12345"); - expect(visible).toMatch(/^#[A-Z0-9]+:L#$/); - const base = /^#([A-Z0-9]+):L#$/.exec(visible)?.[1]; + expect(visible).toMatch(/^\$\$[A-Z0-9]+:L\$\$$/); + const base = /^\$\$([A-Z0-9]+):L\$\$$/.exec(visible)?.[1]; if (!base) throw new Error("expected a lowercase placeholder base"); // The uppercase secret's real token uses an independent base. const upperReal = obfuscator.obfuscate("ABC12345"); - expect(upperReal).toMatch(/^#[A-Z0-9]+:U#$/); - expect(upperReal).not.toBe(`#${base}:U#`); + expect(upperReal).toMatch(/^\$\$[A-Z0-9]+:U\$\$$/); + expect(upperReal).not.toBe(`$$${base}:U$$`); // Live deobfuscation of the synthesized sibling token leaves it literal // instead of restoring the never-provider-visible uppercase secret. - const synthesized = `#${base}:U#`; + const synthesized = `$$${base}:U$$`; expect(obfuscator.deobfuscate(synthesized)).toBe(synthesized); expect(obfuscator.deobfuscateObject({ cmd: synthesized })).toEqual({ cmd: synthesized }); // The legitimate visible token still round-trips. @@ -2960,7 +2970,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { ]); const input = "SeCretuv SecRetuv"; const obfuscated = obfuscator.obfuscate(input); - const tokens = obfuscated.match(/#TOKEN_[A-Z0-9]+:M#/g); + const tokens = obfuscated.match(/\$\$TOKEN_[A-Z0-9]+:M\$\$/g); if (!tokens) throw new Error("Expected mixed-case placeholders"); expect(tokens).toHaveLength(2); @@ -2975,7 +2985,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { { type: "plain", content: "second-token", friendlyName: "api" }, ]); const obfuscated = obfuscator.obfuscate("first-token second-token"); - const tokens = obfuscated.match(/#API_[A-Z0-9]+:L#/g); + const tokens = obfuscated.match(/\$\$API_[A-Z0-9]+:L\$\$/g); if (!tokens) throw new Error("Expected friendly-name placeholders"); expect(tokens).toHaveLength(2); @@ -3009,7 +3019,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(entries).toHaveLength(1); expect(entries[0]?.friendlyName).toBeUndefined(); - expect(obfuscated).toMatch(/#[A-Z0-9]+:L#/); + expect(obfuscated).toMatch(/\$\$[A-Z0-9]+:L\$\$/); expect(obfuscated).not.toMatch(/_[A-Z0-9]+/); expect(obfuscator.deobfuscate(obfuscated)).toBe("invalid-friendly-secret"); } finally { @@ -3035,7 +3045,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(entries).toHaveLength(1); expect(entries[0]?.friendlyName).toBeUndefined(); - expect(obfuscated).toMatch(/#[A-Z0-9]+:L#/); + expect(obfuscated).toMatch(/\$\$[A-Z0-9]+:L\$\$/); expect(obfuscated).not.toMatch(/_[A-Z0-9]+/); expect(obfuscator.deobfuscate(obfuscated)).toBe("non-string-friendly-secret"); } finally { @@ -3091,7 +3101,7 @@ describe("SecretObfuscator friendlyName placeholders", () => { const obfuscated = obfuscator.obfuscate("use tok_abc123 now"); expect(obfuscated).not.toMatch(/TOKABC123_/); - expect(obfuscated).toMatch(/^use #[A-Z0-9]+:L# now$/); + expect(obfuscated).toMatch(/^use \$\$[A-Z0-9]+:L\$\$ now$/); } finally { await fs.rm(root, { recursive: true, force: true }); } @@ -3289,10 +3299,10 @@ describe("SecretObfuscator cross-turn cache stability", () => { const minted = obfuscateMessages(obfuscator, [ { role: "user", content: "remember OTHERSECRET for later", timestamp: 1 }, ]); - const mintedMatch = /#TOKABC123_[A-Z0-9]+(?::[ULCM])?#/.exec(userText(minted[0])); + const mintedMatch = /\$\$TOKABC123_[A-Z0-9]+(?::[ULCM])?\$\$/.exec(userText(minted[0])); if (mintedMatch === null) throw new Error("expected turn 1 to mint a friendly-prefixed placeholder"); const mintedPlaceholder = mintedMatch[0]; - const bareAlias = mintedPlaceholder.replace(/^#TOKABC123_/, "#"); + const bareAlias = mintedPlaceholder.replace(/^\$\$TOKABC123_/, "$$"); // Turn 2: assistant history persisted that exact prefixed placeholder // verbatim, wrapped in ordinary prose that carries no secret material @@ -3390,10 +3400,10 @@ describe("SecretObfuscator cross-turn cache stability", () => { const minted = obfuscateMessages(obfuscator, [ { role: "user", content: "remember OTHERSECRET for later", timestamp: 1 }, ]); - const mintedMatch = /#TOKABC123_[A-Z0-9]+(?::[ULCM])?#/.exec(userText(minted[0])); + const mintedMatch = /\$\$TOKABC123_[A-Z0-9]+(?::[ULCM])?\$\$/.exec(userText(minted[0])); if (mintedMatch === null) throw new Error("expected turn 1 to mint a friendly-prefixed placeholder"); const mintedPlaceholder = mintedMatch[0]; - const bareAlias = mintedPlaceholder.replace(/^#TOKABC123_/, "#"); + const bareAlias = mintedPlaceholder.replace(/^\$\$TOKABC123_/, "$$"); // Turn 2: assistant history persisted that exact prefixed placeholder // verbatim inside a toolCall block's arguments, intent, and rawBlock — @@ -3515,10 +3525,10 @@ describe("SecretObfuscator cross-turn cache stability", () => { const minted = obfuscateMessages(obfuscator, [ { role: "user", content: "remember OTHERSECRET for later", timestamp: 1 }, ]); - const mintedMatch = /#TOKABC123_[A-Z0-9]+(?::[ULCM])?#/.exec(userText(minted[0])); + const mintedMatch = /\$\$TOKABC123_[A-Z0-9]+(?::[ULCM])?\$\$/.exec(userText(minted[0])); if (mintedMatch === null) throw new Error("expected turn 1 to mint a friendly-prefixed placeholder"); const mintedPlaceholder = mintedMatch[0]; - const bareAlias = mintedPlaceholder.replace(/^#TOKABC123_/, "#"); + const bareAlias = mintedPlaceholder.replace(/^\$\$TOKABC123_/, "$$"); // Turn 2: assistant history persisted that exact prefixed placeholder // verbatim inside a thinking block, wrapped in ordinary raw reasoning From 431a5509cc32d5f7d658979865980897974056d0 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 25 Jul 2026 23:03:19 +0000 Subject: [PATCH 2/3] fix(secrets): removed hash placeholder fallback - Removed hash-delimited placeholder parsing and stored-session aliases. - Simplified replay and display restoration to the double-dollar format. - Replaced legacy compatibility tests with an inert-token regression. --- packages/coding-agent/CHANGELOG.md | 2 +- .../coding-agent/src/secrets/obfuscator.ts | 173 +++--------------- .../coding-agent/src/session/agent-session.ts | 5 +- .../src/session/session-provider-boundary.ts | 1 - .../test/secrets-obfuscator.test.ts | 159 ++-------------- 5 files changed, 45 insertions(+), 295 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 30b00548b..17239d0de 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed -- Fixed hide-secrets placeholders conflicting with hashline edit headers by using `$$HASH$$` delimiters for newly generated placeholders while retaining stored-session restoration for legacy `#HASH#` tokens ([#6631](https://github.com/can1357/oh-my-pi/issues/6631)). +- Fixed hide-secrets placeholders conflicting with hashline edit headers by replacing hash-delimited tokens with the unambiguous `$$HASH$$` format ([#6631](https://github.com/can1357/oh-my-pi/issues/6631)). - Fixed the Docker `natives-builder` stage failing to build releases ≥ 17.1.1: the native audio stack added bindgen (miniaudio needs libclang) and a bundled-opus CMake build (needs cmake + make), none of which were installed in the slim builder image. - Fixed `omp usage` duplicating org-less legacy accounts as "no usage data" rows whenever any sibling report carried an organization (mixed pools of pre-org-capture rows and fresh org-scoped logins): an org-less account is now covered by its own org-less report, while org-attributed sibling reports still never count as its coverage. - `omp usage` revalidates the broker credential snapshot before rendering: live usage reports were previously paired with a disk-cached account list up to an hour old, so a just-completed re-login (org-less row upserted to org-scoped) rendered as a phantom duplicate until the cache expired. diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index 4bc539613..b1e22c7fd 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -243,11 +243,6 @@ const HASH_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; // base. A collision would let a persisted placeholder deobfuscate to the wrong // secret when the configured secret set or its ordering changes across sessions. const HASH_LEN = 12; -// Pre-friendly-name sessions persisted a 4-char, index-derived token; reproduce -// that exact legacy format so old session text still deobfuscates. The legacy -// token is keyed on the entry index, not the secret value, so it leaks nothing. -const LEGACY_HASH_LEN = 4; -const LEGACY_HASH_SEED = 0x5345_4352; const MAX_FRIENDLY_NAME_LEN = 32; // Plain/regex obfuscate matches shorter than this are toned down (never placed // behind a reversible placeholder) to avoid redacting small words/fragments. @@ -458,17 +453,6 @@ function buildKeyedReplacementRun(key: string, length: number): string { return out; } -/** Build the pre-friendly-name index-derived placeholder for session resume compatibility. */ -function buildLegacyPlaceholder(index: number): string { - let v = Bun.hash.xxHash32(String(index), LEGACY_HASH_SEED); - let tag = "#"; - for (let i = 0; i < LEGACY_HASH_LEN; i++) { - tag += HASH_CHARS[v % HASH_CHARS.length]; - v = Math.floor(v / HASH_CHARS.length); - } - return `${tag}#`; -} - function inferCaseHint(secret: string): PlaceholderCaseHint | undefined { let hasCased = false; let hasUpper = false; @@ -505,20 +489,18 @@ function buildPlaceholder(hint: PlaceholderCaseHint | undefined, base: string, f return hint ? `$$${prefix}${base}:${hint}$$` : `$$${prefix}${base}$$`; } -/** Regex matching current `$$HASH$$` and persisted legacy `#HASH#` placeholders. */ -const PLACEHOLDER_RE = /(?:\$\$(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?\$\$|#(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?#)/g; +/** Regex matching `$$HASH$$`, `$$HASH:U$$`, and `$$FRIENDLY_HASH(:hint)$$` placeholders. */ +const PLACEHOLDER_RE = /\$\$(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?\$\$/g; function resumePlaceholderScanAfterRejectedCandidate(match: RegExpExecArray): void { // RegExp#exec does not find overlapping matches. Restart at the rejected // candidate's closing delimiter, which can open an immediately adjacent placeholder. - PLACEHOLDER_RE.lastIndex = match.index + match[0].length - (match[0].startsWith("$$") ? 2 : 1); + PLACEHOLDER_RE.lastIndex = match.index + match[0].length - 2; } function placeholderWithoutFriendlyName(placeholder: string): string | undefined { - const current = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder); - if (current !== null) return `$$${current[1]}$$`; - const legacy = /^#[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder); - return legacy ? `#${legacy[1]}#` : undefined; + const match = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder); + return match ? `$$${match[1]}$$` : undefined; } function lookupFriendlyPlaceholderAlias( @@ -531,8 +513,7 @@ function lookupFriendlyPlaceholderAlias( return unprefixed !== undefined ? deobfuscateMap.get(unprefixed) : undefined; } -const PENDING_PLACEHOLDER_SUFFIX_RE = - /(?:\${1,2}(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?|#(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?)$/; +const PENDING_PLACEHOLDER_SUFFIX_RE = /\${1,2}(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?$/; // Withhold a trailing run that could be the start of a placeholder from streamed // deltas, so a partial token is never emitted before deobfuscation can replace @@ -583,13 +564,6 @@ export class SecretObfuscator { * one without the key. */ #deobfuscateMap = new Map(); - /** Legacy index-derived aliases (unkeyed `#XRRS#`), honored ONLY when replaying - * stored session content. They are deterministic and trivially guessable, so - * accepting them on live provider/tool-call paths would let a prompt-injected - * model synthesize one to exfiltrate a secret; they exist solely so sessions - * persisted before keyed placeholders still deobfuscate on resume/display. */ - #legacyDeobfuscateMap = new Map(); - /** Exact placeholder tokens generated by this obfuscator revision (no aliases). */ #generatedPlaceholders = new Set(); @@ -670,7 +644,6 @@ export class SecretObfuscator { } } let index = 0; - let legacyIndex = 0; let hasRealSec = this.#regexEntries.length > 0; for (const entry of entries) { if (entry.type !== "plain") continue; @@ -681,11 +654,6 @@ export class SecretObfuscator { continue; } const placeholder = this.#createPlaceholder(entry.content, entry.friendlyName); - this.#legacyDeobfuscateMap.set(buildLegacyPlaceholder(legacyIndex), { - secret: entry.content, - recursive: false, - }); - legacyIndex++; this.#plainMappings.set(entry.content, index); this.#obfuscateMappings.set(index, { secret: entry.content, placeholder }); this.#generatedPlaceholders.add(placeholder); @@ -707,11 +675,6 @@ export class SecretObfuscator { return this.#hasAny; } - /** Whether stored-session restoration can resolve keyed or legacy placeholders. */ - hasStoredSecrets(): boolean { - return this.#hasAny || this.#legacyDeobfuscateMap.size > 0; - } - /** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */ obfuscate(text: string, sharedRegexSecretValues?: ReadonlySet): string { if (!this.#hasAny) return text; @@ -905,24 +868,9 @@ export class SecretObfuscator { return result; } - /** - * Deobfuscate keyed placeholders back to original secrets for LIVE paths - * (provider output, tool-call arguments). Replace-mode is NOT reversed, and - * legacy index-derived aliases are intentionally ignored so a prompt-injected - * model cannot synthesize one to recover a secret. - */ + /** Deobfuscate keyed placeholders for provider output, tool-call arguments, replay, and display. */ deobfuscate(text: string): string { - return this.#deobfuscate(text, false); - } - - /** - * Deobfuscate stored session content for replay/display. Identical to - * {@link deobfuscate} but additionally honors legacy index-derived aliases so - * sessions persisted before keyed placeholders still resume correctly. Use - * only for trusted on-disk session content, never for live model output. - */ - deobfuscateStored(text: string): string { - return this.#deobfuscate(text, true); + return this.#deobfuscate(text); } // Reverse-direction counterpart to `#isGeneratedPlaceholder`'s guard: the @@ -940,19 +888,14 @@ export class SecretObfuscator { #lookupLiveAlias(placeholder: string): { secret: string; recursive: boolean } | undefined { const direct = this.#deobfuscateMap.get(placeholder); if (direct !== undefined) return direct; - const body = placeholder.startsWith("$$") ? placeholder.slice(2, -2) : placeholder.slice(1, -1); + const body = placeholder.slice(2, -2); const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(body); if (match === null || this.#prefixIsSecretShaped(match[1]!)) return undefined; - const unprefixed = placeholder.startsWith("$$") ? `$$${match[2]}$$` : `#${match[2]}#`; - return this.#deobfuscateMap.get(unprefixed); + return this.#deobfuscateMap.get(`$$${match[2]}$$`); } - #deobfuscate(text: string, allowLegacy: boolean): string { - if ( - (!this.#hasAny && (!allowLegacy || this.#legacyDeobfuscateMap.size === 0)) || - (!text.includes("$$") && !text.includes("#")) - ) - return text; + #deobfuscate(text: string): string { + if (!this.#hasAny || !text.includes("$$")) return text; let result = text; for (;;) { let shouldContinue = false; @@ -962,16 +905,9 @@ export class SecretObfuscator { shouldContinue ||= mapped.recursive; return mapped.secret; } - if (allowLegacy) { - const legacy = this.#legacyDeobfuscateMap.get(match); - if (legacy !== undefined) { - shouldContinue ||= legacy.recursive; - return legacy.secret; - } - } return match; }); - if (next === result || !shouldContinue || (!next.includes("$$") && !next.includes("#"))) return next; + if (next === result || !shouldContinue || !next.includes("$$")) return next; result = next; } } @@ -982,12 +918,6 @@ export class SecretObfuscator { return deepWalkStrings(obj, s => this.deobfuscate(s)); } - /** Deep-walk stored session content, deobfuscating string values incl. legacy aliases. */ - deobfuscateStoredObject(obj: T): T { - if (!this.hasStoredSecrets()) return obj; - return deepWalkStrings(obj, s => this.deobfuscateStored(s)); - } - /** Deep-walk an object, obfuscating all string values. */ obfuscateObject(obj: T): T { if (!this.#hasAny) return obj; @@ -1396,8 +1326,7 @@ export class SecretObfuscator { #placeholderForCurrentInput(placeholder: string): string { const unprefixed = placeholderWithoutFriendlyName(placeholder); if (unprefixed === undefined) return placeholder; - const body = placeholder.startsWith("$$") ? placeholder.slice(2, -2) : placeholder.slice(1, -1); - const match = /^([A-Z0-9]+)_/.exec(body); + const match = /^([A-Z0-9]+)_/.exec(placeholder.slice(2, -2)); if (match === null || !this.#prefixIsSecretShaped(match[1]!)) return placeholder; return unprefixed; } @@ -1450,20 +1379,6 @@ export class SecretObfuscator { this.#deobfuscateMap.set(unprefixed, { secret, recursive }); } } - if (placeholder.startsWith("$$")) { - const legacy = `#${placeholder.slice(2, -2)}#`; - const existingLegacy = this.#legacyDeobfuscateMap.get(legacy); - if (existingLegacy === undefined || existingLegacy.secret === secret) { - this.#legacyDeobfuscateMap.set(legacy, { secret, recursive }); - } - if (unprefixed !== undefined) { - const legacyUnprefixed = `#${unprefixed.slice(2, -2)}#`; - const existingLegacyUnprefixed = this.#legacyDeobfuscateMap.get(legacyUnprefixed); - if (existingLegacyUnprefixed === undefined || existingLegacyUnprefixed.secret === secret) { - this.#legacyDeobfuscateMap.set(legacyUnprefixed, { secret, recursive }); - } - } - } } // Whether an alnum-only, uppercase friendly-name-shaped prefix dropped from @@ -1513,12 +1428,10 @@ export class SecretObfuscator { // deobfuscate-direction check in `#deobfuscate`. #isGeneratedPlaceholder(placeholder: string): boolean { if (this.#deobfuscateMap.has(placeholder)) return true; - const body = placeholder.startsWith("$$") ? placeholder.slice(2, -2) : placeholder.slice(1, -1); - const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(body); + const match = /^([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)$/.exec(placeholder.slice(2, -2)); if (match === null) return false; if (this.#prefixIsSecretShaped(match[1]!)) return false; - const unprefixed = placeholder.startsWith("$$") ? `$$${match[2]}$$` : `#${match[2]}#`; - return this.#deobfuscateMap.has(unprefixed); + return this.#deobfuscateMap.has(`$$${match[2]}$$`); } // Replace `search` with `replacement` outside known generated placeholders while @@ -1888,40 +1801,25 @@ export class SecretObfuscator { * Restore secret placeholders for local display. Only message kinds the model * itself authored from obfuscated context carry placeholders — assistant * content and the LLM-written branch/compaction summaries. User, developer, and - * tool-result messages are persisted with their literal text, so a literal - * `#ABCD#` the operator typed must survive untouched; those roles are never - * walked. - * - * Legacy index-derived aliases (`#XXXX#`) are unkeyed and trivially guessable, - * so a prompt-injected model can plant one in any record it influences. Every - * agent-feeding path (resume, history rewrite, branch switch) therefore restores - * keyed placeholders ONLY (`allowLegacyAliases` false), leaving legacy tokens - * inert; display-only transcripts that are never re-obfuscated opt in via - * `allowLegacyAliases`. + * tool-result messages are persisted with their literal text, so operator-authored + * placeholder-shaped text must survive untouched; those roles are never walked. */ export function deobfuscateSessionContext( sessionContext: SessionContext, obfuscator: SecretObfuscator | undefined, - allowLegacyAliases = false, ): SessionContext { - if (!obfuscator || !(allowLegacyAliases ? obfuscator.hasStoredSecrets() : obfuscator.hasSecrets())) - return sessionContext; - const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages, allowLegacyAliases); + if (!obfuscator?.hasSecrets()) return sessionContext; + const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages); return messages === sessionContext.messages ? sessionContext : { ...sessionContext, messages }; } -export function deobfuscateAgentMessages( - obfuscator: SecretObfuscator, - messages: AgentMessage[], - allowLegacyAliases = false, -): AgentMessage[] { - const deob = (text: string): string => - allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text); +export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] { + const deob = (text: string): string => obfuscator.deobfuscate(text); let changed = false; const result = messages.map((message): AgentMessage => { switch (message.role) { case "assistant": { - const content = deobfuscateAssistantContent(obfuscator, message.content, allowLegacyAliases); + const content = deobfuscateAssistantContent(obfuscator, message.content); if (content === message.content) return message; changed = true; return { ...message, content }; @@ -1935,10 +1833,7 @@ export function deobfuscateAgentMessages( case "compactionSummary": { const summary = deob(message.summary); const shortSummary = message.shortSummary === undefined ? undefined : deob(message.shortSummary); - const blocks = - message.blocks === undefined - ? undefined - : deobfuscateTextBlocks(obfuscator, message.blocks, allowLegacyAliases); + const blocks = message.blocks === undefined ? undefined : deobfuscateTextBlocks(obfuscator, message.blocks); if (summary === message.summary && shortSummary === message.shortSummary && blocks === message.blocks) { return message; } @@ -1960,11 +1855,9 @@ export function deobfuscateAgentMessages( export function deobfuscateAssistantContent( obfuscator: SecretObfuscator, content: AssistantMessage["content"], - allowLegacyAliases = false, ): AssistantMessage["content"] { - if (!(allowLegacyAliases ? obfuscator.hasStoredSecrets() : obfuscator.hasSecrets())) return content; - const deob = (text: string): string => - allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text); + if (!obfuscator.hasSecrets()) return content; + const deob = (text: string): string => obfuscator.deobfuscate(text); let changed = false; const result = content.map((block): AssistantMessage["content"][number] => { if (block.type === "text") { @@ -1975,7 +1868,7 @@ export function deobfuscateAssistantContent( } if (block.type === "toolCall") { - const args = deobfuscateToolArguments(obfuscator, block.arguments, allowLegacyAliases); + const args = deobfuscateToolArguments(obfuscator, block.arguments); const intent = block.intent === undefined ? undefined : deob(block.intent); const rawBlock = block.rawBlock === undefined ? undefined : deob(block.rawBlock); if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block; @@ -1995,12 +1888,9 @@ export function deobfuscateAssistantContent( export function deobfuscateToolArguments( obfuscator: SecretObfuscator, args: Record, - allowLegacyAliases = false, ): Record { - if (!(allowLegacyAliases ? obfuscator.hasStoredSecrets() : obfuscator.hasSecrets())) return args; - const deob = (text: string): string => - allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text); - return mapJsonStrings(args as JsonValue, deob) as Record; + if (!obfuscator.hasSecrets()) return args; + return mapJsonStrings(args as JsonValue, s => obfuscator.deobfuscate(s)) as Record; } /** Redact secrets inside a tool call's arguments (same JSON-walk exception as {@link deobfuscateToolArguments}). */ @@ -2041,14 +1931,11 @@ function obfuscateTextBlocks( function deobfuscateTextBlocks( obfuscator: SecretObfuscator, content: (TextContent | ImageContent)[], - allowLegacyAliases = false, ): (TextContent | ImageContent)[] { - const deob = (text: string): string => - allowLegacyAliases ? obfuscator.deobfuscateStored(text) : obfuscator.deobfuscate(text); let changed = false; const result = content.map((block): TextContent | ImageContent => { if (block.type !== "text") return block; - const text = deob(block.text); + const text = obfuscator.deobfuscate(block.text); if (text === block.text) return block; changed = true; return { ...block, text }; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 84ebd8812..006fe3d23 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4097,10 +4097,7 @@ export class AgentSession { * Transcript for TUI display. Full history is kept for export/resume-style * callers; live chat can collapse compacted history to keep the hot render * surface bounded. Display-only — NEVER feed the result to - * `agent.replaceMessages` or a provider. Because it is never re-obfuscated, - * it opts into legacy index-derived alias restoration so pre-keyed sessions - * still render their secrets; the agent-feeding paths - * (`buildDisplaySessionContext`) keep the keyed-only default. + * `agent.replaceMessages` or a provider. */ buildTranscriptSessionContext( options?: Pick, diff --git a/packages/coding-agent/src/session/session-provider-boundary.ts b/packages/coding-agent/src/session/session-provider-boundary.ts index cbd97a970..28191e3ef 100644 --- a/packages/coding-agent/src/session/session-provider-boundary.ts +++ b/packages/coding-agent/src/session/session-provider-boundary.ts @@ -84,7 +84,6 @@ export class SessionProviderBoundary { keepDanglingToolCalls: options?.keepDanglingToolCalls, }), this.#host.obfuscator, - true, ); } diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index a912ce150..1f5c200ad 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -15,7 +15,6 @@ import { } from "@oh-my-pi/pi-coding-agent/secrets"; import { deobfuscateAgentMessages, - deobfuscateSessionContext, deobfuscateToolArguments, obfuscateMessages, obfuscateProviderContext, @@ -1398,17 +1397,10 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(after.obfuscate(persisted)).toBe(persisted); }); - it("does not canonicalize literal placeholder aliases inside regex matches", () => { - const sharedKey = "F".repeat(43); - const plain = new SecretObfuscator([{ type: "plain", content: "legacy-secret" }], sharedKey); - expect(plain.deobfuscateStored("#XRRS#")).toBe("legacy-secret"); - + it("redacts literal hash-delimited text inside regex matches", () => { const obfuscator = new SecretObfuscator( - [ - { type: "plain", content: "legacy-secret" }, - { type: "regex", content: "api_key=\\S+", friendlyName: "api-key" }, - ], - sharedKey, + [{ type: "regex", content: "api_key=\\S+", friendlyName: "api-key" }], + "F".repeat(43), ); const obfuscated = obfuscator.obfuscate("api_key=#XRRS#"); @@ -2375,69 +2367,12 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(first.deobfuscate(firstToken ?? "")).toBe("alpha-secret"); }); - it("keeps legacy aliases aligned with formerly obfuscated secrets", () => { - const obfuscator = new SecretObfuscator([ - { type: "plain", content: "abc" }, - { type: "plain", content: "MYSECRET123" }, - ]); - - expect(obfuscator.deobfuscateStored("#XRRS#")).toBe("MYSECRET123"); - expect(obfuscator.deobfuscateStored("#NTJ5#")).toBe("#NTJ5#"); - }); - - it("restores hash-delimited keyed placeholders only from stored sessions", () => { - const obfuscator = new SecretObfuscator([{ type: "plain", content: "legacy-secret" }], "shared-key"); + it("leaves hash-delimited tokens inert while restoring current placeholders", () => { + const obfuscator = new SecretObfuscator([{ type: "plain", content: "legacy-secret" }]); const current = obfuscator.obfuscate("legacy-secret"); - const legacy = current.replace(/^\$\$/, "#").replace(/\$\$$/, "#"); - - expect(obfuscator.deobfuscateStored(legacy)).toBe("legacy-secret"); - expect(obfuscator.deobfuscate(legacy)).toBe(legacy); - }); - - it("honors legacy index-derived aliases only on the stored-replay path", () => { - const obfuscator = new SecretObfuscator([{ type: "plain", content: "legacy-secret" }]); - - // The generated token is keyed, never the legacy index token. - expect(obfuscator.obfuscate("legacy-secret")).not.toBe("#XRRS#"); - - // Stored session replay/display restores pre-keyed legacy placeholders so - // older persisted sessions still resume correctly. - expect(obfuscator.deobfuscateStored("#XRRS#")).toBe("legacy-secret"); - - // Live provider output and tool-call arguments MUST NOT honor the legacy - // alias: it is unkeyed and trivially guessable, so a prompt-injected model - // could synthesize `#XRRS#` in a bash/read argument and exfiltrate the secret. - expect(obfuscator.deobfuscate("#XRRS#")).toBe("#XRRS#"); - expect(obfuscator.deobfuscateObject({ cmd: "cat #XRRS#" })).toEqual({ cmd: "cat #XRRS#" }); - }); - - it("restores keyed placeholders but never legacy aliases on agent-feeding replay", () => { - // deobfuscateSessionContext has two kinds of consumers: agent-feeding paths - // (resume, history rewrite, branch switch) whose output is re-obfuscated and - // sent to the provider, and a display-only transcript (allowLegacyAliases). - // Legacy index-derived `#XRRS#` aliases are unkeyed and guessable, so a - // prompt-injected model can plant one in ANY record it influences — its own - // assistant output OR a tool result (bash stdout). If a feed path restored - // it, the next provider turn would re-obfuscate it into a usable keyed - // placeholder the model could weaponize in a tool argument. So feed paths - // restore keyed placeholders ONLY; legacy is restored solely for the - // never-re-sent transcript so pre-keyed sessions still render their secrets. - const obfuscator = new SecretObfuscator([{ type: "plain", content: "legacy-secret" }]); - const keyedToken = obfuscator.obfuscate("legacy-secret"); - expect(keyedToken).not.toContain("#XRRS#"); - - const assistant: Message = { + const message: AgentMessage = { role: "assistant", - content: [ - { type: "text", text: `attacker planted #XRRS# and echoed ${keyedToken}` }, - { - type: "toolCall", - id: "call-1", - name: "read", - arguments: { note: keyedToken }, - intent: `intent ${keyedToken}`, - }, - ], + content: [{ type: "text", text: `legacy #XRRS# current ${current}` }], api: "anthropic-messages", provider: "anthropic", model: "test-model", @@ -2452,80 +2387,11 @@ describe("SecretObfuscator friendlyName placeholders", () => { stopReason: "stop", timestamp: 1, }; - const toolResult: Message = { - role: "toolResult", - toolCallId: "call-1", - toolName: "bash", - content: [{ type: "text", text: "bash stdout #XRRS#" }], - isError: false, - timestamp: 2, - }; - const branchSummary: AgentMessage = { - role: "branchSummary", - summary: `branch #XRRS# and echoed ${keyedToken}`, - fromId: "branch-1", - timestamp: 3, - }; - const compactionSummary: AgentMessage = { - role: "compactionSummary", - summary: `compaction #XRRS# and echoed ${keyedToken}`, - tokensBefore: 0, - timestamp: 4, - }; - const contextMessages: AgentMessage[] = [ - assistant as AgentMessage, - toolResult as AgentMessage, - branchSummary, - compactionSummary, - ]; - const ctx = { - messages: contextMessages, - models: {}, - injectedTtsrRules: [], - selectedMCPToolNames: [], - hasPersistedMCPToolSelection: false, - mode: "none", - }; - // Agent-feeding default restores keyed placeholders authored by this - // obfuscator but leaves a prompt-injected legacy alias inert before the - // next provider turn. - const fed = deobfuscateSessionContext(ctx, obfuscator); - const fedAssistant = (fed.messages[0] as Extract).content[0] as { text: string }; - const fedTool = (fed.messages[1] as Extract).content[0] as { text: string }; - expect(fedAssistant.text).toBe("attacker planted #XRRS# and echoed legacy-secret"); - const fedCall = (fed.messages[0] as AssistantMessage).content[1] as { - arguments: Record; - intent?: string; - }; - expect(fedCall.arguments).toEqual({ note: "legacy-secret" }); - expect(fedCall.intent).toBe("intent legacy-secret"); - expect(fedTool.text).toBe("bash stdout #XRRS#"); - const fedBranch = fed.messages[2] as Extract; - const fedCompaction = fed.messages[3] as Extract; - expect(fedBranch.summary).toBe("branch #XRRS# and echoed legacy-secret"); - expect(fedCompaction.summary).toBe("compaction #XRRS# and echoed legacy-secret"); - const replayed = obfuscateMessages(obfuscator, [fed.messages[0] as Message]); - const replayedAssistant = replayed[0] as Extract; - const replayedText = replayedAssistant.content[0] as { text: string }; - expect(replayedText.text).toBe(`attacker planted #XRRS# and echoed ${keyedToken}`); - const replayedCall = replayedAssistant.content[1] as { arguments: Record; intent?: string }; - expect(replayedCall.arguments).toEqual({ note: keyedToken }); - expect(replayedCall.intent).toBe(`intent ${keyedToken}`); - - // Display-only transcript: legacy aliases ARE restored so a genuinely - // pre-keyed session renders its secrets. This output is never re-obfuscated. - const shown = deobfuscateSessionContext(ctx, obfuscator, true); - const shownAssistant = (shown.messages[0] as Extract).content[0] as { - text: string; - }; - const shownTool = (shown.messages[1] as Extract).content[0] as { text: string }; - expect(shownAssistant.text).toBe("attacker planted legacy-secret and echoed legacy-secret"); - expect(shownTool.text).toBe("bash stdout #XRRS#"); - const shownBranch = shown.messages[2] as Extract; - const shownCompaction = shown.messages[3] as Extract; - expect(shownBranch.summary).toBe("branch legacy-secret and echoed legacy-secret"); - expect(shownCompaction.summary).toBe("compaction legacy-secret and echoed legacy-secret"); + const restored = deobfuscateAgentMessages(obfuscator, [message])[0]; + if (restored?.role !== "assistant") throw new Error("expected restored assistant message"); + const text = restored.content[0]; + expect(text?.type === "text" && text.text).toBe("legacy #XRRS# current legacy-secret"); }); it("deobfuscates placeholders after friendlyName changes", () => { @@ -3683,7 +3549,8 @@ describe("deobfuscateAgentMessages (display restore)", () => { timestamp: 1, }; - const [restored] = deobfuscateAgentMessages(obfuscator, [message], true) as [typeof message]; + const restored = deobfuscateAgentMessages(obfuscator, [message])[0]; + if (restored?.role !== "compactionSummary") throw new Error("expected restored compaction summary"); const blocks = restored.blocks ?? []; const text = blocks[0]; const image = blocks[1]; From a676e3f29caf59e41dd8755caf7523ec3584ff64 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 25 Jul 2026 23:08:54 +0000 Subject: [PATCH 3/3] fix(secrets): stopped buffering single-dollar streamed text - Buffered only a lone trailing dollar or a $$-introduced body during streaming. - Let $HOME/$100-style single-dollar text stream through immediately. - Added regression coverage for the passthrough. --- packages/coding-agent/src/secrets/obfuscator.ts | 2 +- packages/coding-agent/test/secrets-obfuscator.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index b1e22c7fd..57c488e61 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -513,7 +513,7 @@ function lookupFriendlyPlaceholderAlias( return unprefixed !== undefined ? deobfuscateMap.get(unprefixed) : undefined; } -const PENDING_PLACEHOLDER_SUFFIX_RE = /\${1,2}(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?$/; +const PENDING_PLACEHOLDER_SUFFIX_RE = /(?:\$\$(?:[A-Z0-9]+_)?[A-Z0-9]*(?::[ULCM]?)?|\$)$/; // Withhold a trailing run that could be the start of a placeholder from streamed // deltas, so a partial token is never emitted before deobfuscation can replace diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 1f5c200ad..7e9b7e1da 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -2768,6 +2768,11 @@ describe("SecretObfuscator friendlyName placeholders", () => { expect(stripPendingSecretPlaceholderSuffix("prefix ID$")).toBe("prefix ID"); expect(stripPendingSecretPlaceholderSuffix("count 42$")).toBe("count 42"); expect(stripPendingSecretPlaceholderSuffix("before $$TOKEN ")).toBe("before $$TOKEN "); + // A single `$` followed by non-`$` can never open a `$$…$$` placeholder, so it + // must stream through instead of being withheld until the next boundary. + expect(stripPendingSecretPlaceholderSuffix("run $HOME")).toBe("run $HOME"); + expect(stripPendingSecretPlaceholderSuffix("pay $100")).toBe("pay $100"); + expect(stripPendingSecretPlaceholderSuffix("cost $$100")).toBe("cost "); }); it("uses independent bases across casing variants with distinct hints", () => {