Merge PR #6646: fix(secrets): avoid hashline placeholder collisions (@roboomp)

This commit is contained in:
can1357
2026-07-26 15:45:09 +02:00
7 changed files with 179 additions and 390 deletions
+1
View File
@@ -12,6 +12,7 @@
- Fixed transient reasonless request aborts that arrived after a tool call finished streaming ending the turn instead of entering recovery, which left edit calls and task subagents dead until the user manually resumed. The session now continues from the synthetic unexecuted tool result under the normal retry policy without replaying completed side effects ([#6668](https://github.com/can1357/oh-my-pi/issues/6668)).
- Fixed prewalk silently dropping a same-model hand-off that only lowers the thinking level: the arm/switch guard compared model identity alone and discarded the resolved `thinkingLevel`, so a legal effort-downgrade target (e.g. `prewalk: "@task"` resolving to the same model at a cheaper effort) never applied and the session paid the plan/continue nudges for nothing. Prewalk now compares `(provider, id, effective thinking level)`, applies effort-only hand-offs, and emits a notice on a genuine no-op instead of returning silently ([#6659](https://github.com/can1357/oh-my-pi/issues/6659)).
- Fixed `@czottmann/pi-automode` failing legacy extension validation because the pi-ai compatibility shim omitted `clampThinkingLevel`, then failing every classified tool call because `ctx.modelRegistry` omitted `getApiKeyAndHeaders`. ([#6648](https://github.com/can1357/oh-my-pi/issues/6648))
- 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 a configured `modelRoles.default` naming an extension-registered model (listed in `enabledModels`) silently running on a different in-scope provider's model. The startup model scope is resolved before extensions call `registerProvider()`, so the default role dropped out of scope and `buildSessionOptions` pinned `options.model` to the first scoped model — which marked the model "explicit" and suppressed the post-extension default-role re-resolution. A configured default that can't be found in the startup scope is now deferred so it re-resolves against the fully registered, still `enabledModels`-scoped catalog once extensions load ([#6694](https://github.com/can1357/oh-my-pi/issues/6694)).
- 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.
@@ -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_<hash>#") before the SIBLING
// friendly-prefixed placeholder ("$$TOKABC123_<hash>$$") 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_<hash>#" rendering of the regex-protected value
// normalized "$$TOKABC123_<hash>$$" 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" },
+36 -126
View File
@@ -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;
@@ -502,21 +486,21 @@ 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 `$$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 `#`, 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 - 2;
}
function placeholderWithoutFriendlyName(placeholder: string): string | undefined {
const match = /^#[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder);
return match ? `#${match[1]}#` : undefined;
const match = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder);
return match ? `$$${match[1]}$$` : undefined;
}
function lookupFriendlyPlaceholderAlias(
@@ -529,14 +513,12 @@ 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 = /(?:\$\$(?:[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;
@@ -582,13 +564,6 @@ export class SecretObfuscator {
* one without the key. */
#deobfuscateMap = new Map<string, { secret: string; recursive: boolean }>();
/** 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<string, { secret: string; recursive: boolean }>();
/** Exact placeholder tokens generated by this obfuscator revision (no aliases). */
#generatedPlaceholders = new Set<string>();
@@ -669,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;
@@ -680,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);
@@ -706,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>): string {
if (!this.#hasAny) return text;
@@ -904,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
@@ -929,7 +878,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_<suffix-copied-from-any-real-placeholder>#`
// token like `$$GITHUBPATABC123_<suffix-copied-from-any-real-placeholder>$$`
// 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,14 +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 match = /^#([A-Z0-9]+)_([A-Z0-9]{4,}(?::[ULCM])?)#$/.exec(placeholder);
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;
return this.#deobfuscateMap.get(`#${match[2]}#`);
return this.#deobfuscateMap.get(`$$${match[2]}$$`);
}
#deobfuscate(text: string, allowLegacy: boolean): string {
if ((!this.#hasAny && (!allowLegacy || this.#legacyDeobfuscateMap.size === 0)) || !text.includes("#"))
return text;
#deobfuscate(text: string): string {
if (!this.#hasAny || !text.includes("$$")) return text;
let result = text;
for (;;) {
let shouldContinue = false;
@@ -956,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("#")) return next;
if (next === result || !shouldContinue || !next.includes("$$")) return next;
result = next;
}
}
@@ -976,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<T>(obj: T): T {
if (!this.hasStoredSecrets()) return obj;
return deepWalkStrings(obj, s => this.deobfuscateStored(s));
}
/** Deep-walk an object, obfuscating all string values. */
obfuscateObject<T>(obj: T): T {
if (!this.#hasAny) return obj;
@@ -1390,7 +1326,7 @@ export class SecretObfuscator {
#placeholderForCurrentInput(placeholder: string): string {
const unprefixed = placeholderWithoutFriendlyName(placeholder);
if (unprefixed === undefined) return placeholder;
const match = /^#([A-Z0-9]+)_/.exec(placeholder);
const match = /^([A-Z0-9]+)_/.exec(placeholder.slice(2, -2));
if (match === null || !this.#prefixIsSecretShaped(match[1]!)) return placeholder;
return unprefixed;
}
@@ -1492,10 +1428,10 @@ 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 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;
return this.#deobfuscateMap.has(`#${match[2]}#`);
return this.#deobfuscateMap.has(`$$${match[2]}$$`);
}
// Replace `search` with `replacement` outside known generated placeholders while
@@ -1865,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 };
@@ -1912,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;
}
@@ -1937,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") {
@@ -1952,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;
@@ -1972,12 +1888,9 @@ export function deobfuscateAssistantContent(
export function deobfuscateToolArguments(
obfuscator: SecretObfuscator,
args: Record<string, unknown>,
allowLegacyAliases = false,
): Record<string, unknown> {
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<string, unknown>;
if (!obfuscator.hasSecrets()) return args;
return mapJsonStrings(args as JsonValue, s => obfuscator.deobfuscate(s)) as Record<string, unknown>;
}
/** Redact secrets inside a tool call's arguments (same JSON-walk exception as {@link deobfuscateToolArguments}). */
@@ -2018,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 };
@@ -2202,7 +2202,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;
@@ -4063,10 +4063,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<BuildSessionContextOptions, "collapseCompactedHistory" | "keepDanglingToolCalls">,
@@ -84,7 +84,6 @@ export class SessionProviderBoundary {
keepDanglingToolCalls: options?.keepDanglingToolCalls,
}),
this.#host.obfuscator,
true,
);
}
@@ -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).
@@ -15,7 +15,6 @@ import {
} from "@oh-my-pi/pi-coding-agent/secrets";
import {
deobfuscateAgentMessages,
deobfuscateSessionContext,
deobfuscateToolArguments,
obfuscateMessages,
obfuscateProviderContext,
@@ -313,7 +312,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 +334,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 +343,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 +368,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 +388,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 +398,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 +423,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 +444,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 +466,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 +491,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 +521,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 +555,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 +589,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 +602,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 +613,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 +643,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 +676,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 +687,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 +711,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 +724,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 +1322,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 [
[
@@ -1398,21 +1397,14 @@ 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#");
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 +1419,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 +1438,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 +1458,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 +1477,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 +1516,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 +1614,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 +2061,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 +2080,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 +2091,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 +2102,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 +2115,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 +2127,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 +2139,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 +2159,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 +2180,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 +2200,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 +2294,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 +2306,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 +2319,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 +2344,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 +2358,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);
@@ -2373,60 +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("honors legacy index-derived aliases only on the stored-replay path", () => {
it("leaves hash-delimited tokens inert while restoring current placeholders", () => {
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 current = obfuscator.obfuscate("legacy-secret");
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",
@@ -2441,89 +2387,20 @@ 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<Message, { role: "assistant" }>).content[0] as { text: string };
const fedTool = (fed.messages[1] as Extract<Message, { role: "toolResult" }>).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<string, unknown>;
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<AgentMessage, { role: "branchSummary" }>;
const fedCompaction = fed.messages[3] as Extract<AgentMessage, { role: "compactionSummary" }>;
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<Message, { role: "assistant" }>;
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<string, unknown>; 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<Message, { role: "assistant" }>).content[0] as {
text: string;
};
const shownTool = (shown.messages[1] as Extract<Message, { role: "toolResult" }>).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<AgentMessage, { role: "branchSummary" }>;
const shownCompaction = shown.messages[3] as Extract<AgentMessage, { role: "compactionSummary" }>;
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", () => {
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 +2414,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 +2468,13 @@ describe("SecretObfuscator friendlyName placeholders", () => {
// name-independent bare-alias suffix (`_<hash>:<hint>#`) 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 +2518,13 @@ describe("SecretObfuscator friendlyName placeholders", () => {
// Learn secretB's real placeholder, then derive the friendly-name-
// independent bare-alias suffix (`_<hash>:<hint>#`).
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 +2558,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 +2590,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 +2607,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 +2634,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 +2659,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 +2756,23 @@ 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 ");
// 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", () => {
@@ -2901,17 +2782,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 +2812,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 +2841,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 +2856,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 +2890,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 +2916,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 +2972,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 +3170,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 +3271,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 +3396,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
@@ -3673,7 +3554,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];