refactor(coding-agent): split secret obfuscator into domain modules
- Separated deterministic replacement generation, placeholder derivation, placeholder-range scanning and message-tree transforms out of the 2647-line module; obfuscator.ts now holds the types and SecretObfuscator. - ephemeralPlaceholderKey stays a single instance and both global regexes stay beside the code that resets their lastIndex, so placeholder stability and the security argument in the moved comments are preserved verbatim. - Repointed every importer at the real modules rather than leaving a re-export shim; the public ./secrets barrel exports the same 15 names as before.
This commit is contained in:
@@ -5,7 +5,8 @@ import * as AIError from "@oh-my-pi/pi-ai/error";
|
||||
import { raceWithSignal } from "@oh-my-pi/pi-ai/utils/abort";
|
||||
import { type CursorExecResolvedCarrier, kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
|
||||
import { obfuscateToolArguments } from "../secrets/message-transform";
|
||||
import type { SecretObfuscator } from "../secrets/obfuscator";
|
||||
import {
|
||||
formatExecutionSourcePreview,
|
||||
formatSessionHistoryMarkdown,
|
||||
|
||||
@@ -24,7 +24,8 @@ import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-a
|
||||
import { $which, logger } from "@oh-my-pi/pi-utils";
|
||||
import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire";
|
||||
import { $ } from "bun";
|
||||
import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator";
|
||||
import { obfuscateToolArguments } from "../secrets/message-transform";
|
||||
import type { SecretObfuscator } from "../secrets/obfuscator";
|
||||
import { type SessionEntry, type SessionHeader, TITLE_CHANGE_ENTRY_TYPE } from "../session/session-entries";
|
||||
import type { SessionManager } from "../session/session-manager";
|
||||
import type { OutputMeta } from "../tools/output-meta";
|
||||
|
||||
@@ -4,14 +4,10 @@ import * as path from "node:path";
|
||||
import { SENSITIVE_TOKEN_RE } from "@oh-my-pi/pi-ai/providers/transform-messages";
|
||||
import { getSecretPlaceholderKeyPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import {
|
||||
regexHasUnresolvableShortMatchFallback,
|
||||
type SecretEntry,
|
||||
SecretObfuscator,
|
||||
sanitizeSecretFriendlyName,
|
||||
secretEntriesNeedPlaceholderKey,
|
||||
} from "./obfuscator";
|
||||
import { type SecretEntry, SecretObfuscator } from "./obfuscator";
|
||||
import { sanitizeSecretFriendlyName, secretEntriesNeedPlaceholderKey } from "./placeholder";
|
||||
import { compileSecretRegex } from "./regex";
|
||||
import { regexHasUnresolvableShortMatchFallback } from "./replacement";
|
||||
|
||||
const PLACEHOLDER_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||
const cachedPlaceholderKeys = new Map<string, string>();
|
||||
@@ -158,11 +154,9 @@ export {
|
||||
deobfuscateToolArguments,
|
||||
obfuscateMessages,
|
||||
obfuscateProviderContext,
|
||||
type SecretEntry,
|
||||
SecretObfuscator,
|
||||
secretEntriesNeedPlaceholderKey,
|
||||
secretEntryNeedsPlaceholderKey,
|
||||
} from "./obfuscator";
|
||||
} from "./message-transform";
|
||||
export { type SecretEntry, SecretObfuscator } from "./obfuscator";
|
||||
export { secretEntriesNeedPlaceholderKey, secretEntryNeedsPlaceholderKey } from "./placeholder";
|
||||
|
||||
/**
|
||||
* Load secrets from project-local and global secrets.yml files.
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import type { AssistantMessage, Context, ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
|
||||
import type { SessionContext } from "../session/session-context";
|
||||
import type { JsonValue, SecretObfuscator } from "./obfuscator";
|
||||
import { collectJsonRegexSecretValues, mapJsonStrings } from "./placeholder-scan";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Display restore (inbound, persisted/provider → local display)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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 operator-authored
|
||||
* placeholder-shaped text must survive untouched; those roles are never walked.
|
||||
*/
|
||||
export function deobfuscateSessionContext(
|
||||
sessionContext: SessionContext,
|
||||
obfuscator: SecretObfuscator | undefined,
|
||||
): SessionContext {
|
||||
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[]): 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);
|
||||
if (content === message.content) return message;
|
||||
changed = true;
|
||||
return { ...message, content };
|
||||
}
|
||||
case "branchSummary": {
|
||||
const summary = deob(message.summary);
|
||||
if (summary === message.summary) return message;
|
||||
changed = true;
|
||||
return { ...message, summary };
|
||||
}
|
||||
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);
|
||||
if (summary === message.summary && shortSummary === message.shortSummary && blocks === message.blocks) {
|
||||
return message;
|
||||
}
|
||||
changed = true;
|
||||
return { ...message, summary, shortSummary, blocks };
|
||||
}
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
});
|
||||
return changed ? result : messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore placeholders in assistant content: visible text and tool-call
|
||||
* arguments/intent/rawBlock. Thinking and signatures are opaque
|
||||
* provider-replay/hidden-reasoning data and pass through byte-identical.
|
||||
*/
|
||||
export function deobfuscateAssistantContent(
|
||||
obfuscator: SecretObfuscator,
|
||||
content: AssistantMessage["content"],
|
||||
): AssistantMessage["content"] {
|
||||
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") {
|
||||
const text = deob(block.text);
|
||||
if (text === block.text) return block;
|
||||
changed = true;
|
||||
return { ...block, text };
|
||||
}
|
||||
|
||||
if (block.type === "toolCall") {
|
||||
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;
|
||||
changed = true;
|
||||
return { ...block, arguments: args, intent, rawBlock };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
return changed ? result : content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore placeholders inside a tool call's arguments. Arguments are arbitrary
|
||||
* model-authored JSON, so tool-call arguments are the ONLY place a recursive
|
||||
* JSON walk runs.
|
||||
*/
|
||||
export function deobfuscateToolArguments(
|
||||
obfuscator: SecretObfuscator,
|
||||
args: Record<string, unknown>,
|
||||
): 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}). */
|
||||
export function obfuscateToolArguments(
|
||||
obfuscator: SecretObfuscator,
|
||||
args: Record<string, unknown>,
|
||||
sharedRegexSecretValues?: ReadonlySet<string>,
|
||||
): Record<string, unknown> {
|
||||
if (!obfuscator.hasSecrets()) return args;
|
||||
const regexSecretValues = sharedRegexSecretValues ?? collectJsonRegexSecretValues(obfuscator, args as JsonValue);
|
||||
return mapJsonStrings(args as JsonValue, s => obfuscator.obfuscate(s, regexSecretValues)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Outbound obfuscation (local → provider)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type UserFacingMessage = Extract<Message, { role: "user" | "developer" | "toolResult" }>;
|
||||
|
||||
/** Obfuscate `text` blocks of a content array; image and other blocks pass through. */
|
||||
function obfuscateTextBlocks(
|
||||
obfuscator: SecretObfuscator,
|
||||
content: (TextContent | ImageContent)[],
|
||||
sharedRegexSecretValues?: ReadonlySet<string>,
|
||||
): (TextContent | ImageContent)[] {
|
||||
let changed = false;
|
||||
const result = content.map((block): TextContent | ImageContent => {
|
||||
if (block.type !== "text") return block;
|
||||
const text = obfuscator.obfuscate(block.text, sharedRegexSecretValues);
|
||||
if (text === block.text) return block;
|
||||
changed = true;
|
||||
return { ...block, text };
|
||||
});
|
||||
return changed ? result : content;
|
||||
}
|
||||
|
||||
/** Restore placeholders in `text` blocks of a content array; image and other blocks pass through. */
|
||||
function deobfuscateTextBlocks(
|
||||
obfuscator: SecretObfuscator,
|
||||
content: (TextContent | ImageContent)[],
|
||||
): (TextContent | ImageContent)[] {
|
||||
let changed = false;
|
||||
const result = content.map((block): TextContent | ImageContent => {
|
||||
if (block.type !== "text") return block;
|
||||
const text = obfuscator.deobfuscate(block.text);
|
||||
if (text === block.text) return block;
|
||||
changed = true;
|
||||
return { ...block, text };
|
||||
});
|
||||
return changed ? result : content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-obfuscate assistant content before it returns to a provider after session
|
||||
* restoration, removing friendly prefixes made unsafe by this batch. A changed
|
||||
* thinking block loses its byte-bound replay signature.
|
||||
*/
|
||||
function obfuscateAssistantContentForReplay(
|
||||
obfuscator: SecretObfuscator,
|
||||
content: AssistantMessage["content"],
|
||||
sharedRegexSecretValues: ReadonlySet<string>,
|
||||
): AssistantMessage["content"] {
|
||||
const obfuscate = (text: string): string =>
|
||||
obfuscator.stripUnsafeFriendlyPlaceholderPrefixes(
|
||||
obfuscator.obfuscate(text, sharedRegexSecretValues),
|
||||
sharedRegexSecretValues,
|
||||
);
|
||||
let changed = false;
|
||||
const result = content.map((block): AssistantMessage["content"][number] => {
|
||||
if (block.type === "text") {
|
||||
const text = obfuscate(block.text);
|
||||
if (text === block.text) return block;
|
||||
changed = true;
|
||||
return { ...block, text };
|
||||
}
|
||||
if (block.type === "thinking") {
|
||||
const thinking = obfuscate(block.thinking);
|
||||
if (thinking === block.thinking) return block;
|
||||
changed = true;
|
||||
return { ...block, thinking, thinkingSignature: undefined };
|
||||
}
|
||||
if (block.type === "toolCall") {
|
||||
const args = mapJsonStrings(block.arguments as JsonValue, obfuscate) as Record<string, unknown>;
|
||||
const intent = block.intent === undefined ? undefined : obfuscate(block.intent);
|
||||
const rawBlock = block.rawBlock === undefined ? undefined : obfuscate(block.rawBlock);
|
||||
if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block;
|
||||
changed = true;
|
||||
return { ...block, arguments: args, intent, rawBlock };
|
||||
}
|
||||
return block;
|
||||
});
|
||||
return changed ? result : content;
|
||||
}
|
||||
|
||||
function collectMessageRegexSecretValues(obfuscator: SecretObfuscator, messages: Message[]): Set<string> {
|
||||
const values = new Set<string>();
|
||||
const addText = (text: string | undefined): void => {
|
||||
if (text === undefined) return;
|
||||
for (const value of obfuscator.collectRegexSecretValuesForObfuscation(text)) {
|
||||
values.add(value);
|
||||
}
|
||||
};
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant") {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") addText(block.text);
|
||||
else if (block.type === "thinking") addText(block.thinking);
|
||||
else if (block.type === "toolCall") {
|
||||
for (const value of collectJsonRegexSecretValues(obfuscator, block.arguments as JsonValue)) {
|
||||
values.add(value);
|
||||
}
|
||||
addText(block.intent);
|
||||
addText(block.rawBlock);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
message.role !== "user" &&
|
||||
message.role !== "toolResult" &&
|
||||
!(message.role === "developer" && message.attribution === "user")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const target = message as UserFacingMessage;
|
||||
if (typeof target.content === "string") {
|
||||
addText(target.content);
|
||||
continue;
|
||||
}
|
||||
for (const block of target.content) {
|
||||
if (block.type === "text") addText(block.text);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact secrets from outbound messages. User messages, tool results, and
|
||||
* user-authored developer messages (e.g. `@file` mentions) are obfuscated.
|
||||
* Assistant replay content is re-obfuscated too, because session restoration
|
||||
* expands keyed placeholders locally before the next provider request. Inline
|
||||
* image bytes are never walked.
|
||||
*/
|
||||
export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] {
|
||||
if (!obfuscator.hasSecrets()) return messages;
|
||||
const sharedRegexSecretValues = collectMessageRegexSecretValues(obfuscator, messages);
|
||||
let changed = false;
|
||||
const result = messages.map((message): Message => {
|
||||
if (
|
||||
message.role !== "user" &&
|
||||
message.role !== "toolResult" &&
|
||||
!(message.role === "developer" && message.attribution === "user")
|
||||
) {
|
||||
if (message.role !== "assistant") return message;
|
||||
const content = obfuscateAssistantContentForReplay(obfuscator, message.content, sharedRegexSecretValues);
|
||||
if (content === message.content) return message;
|
||||
changed = true;
|
||||
return { ...message, content };
|
||||
}
|
||||
const target = message as UserFacingMessage;
|
||||
if (typeof target.content === "string") {
|
||||
const content = obfuscator.obfuscate(target.content, sharedRegexSecretValues);
|
||||
if (content === target.content) return message;
|
||||
changed = true;
|
||||
return { ...target, content } as Message;
|
||||
}
|
||||
const content = obfuscateTextBlocks(obfuscator, target.content, sharedRegexSecretValues);
|
||||
if (content === target.content) return message;
|
||||
changed = true;
|
||||
return { ...target, content } as Message;
|
||||
});
|
||||
return changed ? result : messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact outbound provider context. Only conversation messages are rewritten;
|
||||
* the static system prompt and tool schemas pass through unchanged.
|
||||
*/
|
||||
export function obfuscateProviderContext(obfuscator: SecretObfuscator | undefined, context: Context): Context {
|
||||
if (!obfuscator?.hasSecrets()) return context;
|
||||
const messages = obfuscateMessages(obfuscator, context.messages);
|
||||
return messages === context.messages ? context : { ...context, messages };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,506 @@
|
||||
import type { JsonRecord, JsonValue, SecretObfuscator } from "./obfuscator";
|
||||
import {
|
||||
lookupFriendlyPlaceholderAlias,
|
||||
PLACEHOLDER_RE,
|
||||
type RegexScanSegment,
|
||||
type ReplaceRegexScan,
|
||||
resumePlaceholderScanAfterRejectedCandidate,
|
||||
} from "./placeholder";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Like the untracked walk, but threads a parallel `origin` tag string through:
|
||||
// preserved placeholder spans keep their existing origin tag (so a
|
||||
// same-call-fresh "F" placeholder is never relabeled prior-call "I", and vice
|
||||
// versa), while `transform`'s output — always freshly generated or redacted
|
||||
// content in both callers below — is tagged "I" (it must not be re-matched as
|
||||
// though it arrived in the input, mirroring plain-secret replacement tagging).
|
||||
export function transformOutsidePlaceholdersTracked(
|
||||
text: string,
|
||||
origin: string,
|
||||
shouldSkipPlaceholder: (placeholder: string) => boolean,
|
||||
transform: (chunk: string) => string,
|
||||
preservePlaceholder?: (placeholder: string) => string,
|
||||
): { text: string; origin: string } {
|
||||
PLACEHOLDER_RE.lastIndex = 0;
|
||||
let result = "";
|
||||
let resultOrigin = "";
|
||||
let pendingIndex = 0;
|
||||
for (;;) {
|
||||
const match = PLACEHOLDER_RE.exec(text);
|
||||
if (match === null) break;
|
||||
if (!shouldSkipPlaceholder(match[0])) {
|
||||
resumePlaceholderScanAfterRejectedCandidate(match);
|
||||
continue;
|
||||
}
|
||||
const transformed = transform(text.slice(pendingIndex, match.index));
|
||||
result += transformed;
|
||||
resultOrigin += "I".repeat(transformed.length);
|
||||
const preserved = preservePlaceholder ? preservePlaceholder(match[0]) : match[0];
|
||||
result += preserved;
|
||||
resultOrigin += origin.slice(match.index, match.index + match[0].length);
|
||||
pendingIndex = match.index + match[0].length;
|
||||
}
|
||||
const trailing = transform(text.slice(pendingIndex));
|
||||
result += trailing;
|
||||
resultOrigin += "I".repeat(trailing.length);
|
||||
return { text: result, origin: resultOrigin };
|
||||
}
|
||||
|
||||
export function trailingOutsidePreservedPlaceholderChunk(
|
||||
text: string,
|
||||
shouldPreservePlaceholder: (placeholder: string) => boolean,
|
||||
): string {
|
||||
PLACEHOLDER_RE.lastIndex = 0;
|
||||
let pendingIndex = 0;
|
||||
let sawPlaceholder = false;
|
||||
for (;;) {
|
||||
const match = PLACEHOLDER_RE.exec(text);
|
||||
if (match === null) break;
|
||||
if (!shouldPreservePlaceholder(match[0])) {
|
||||
resumePlaceholderScanAfterRejectedCandidate(match);
|
||||
continue;
|
||||
}
|
||||
sawPlaceholder = true;
|
||||
pendingIndex = match.index + match[0].length;
|
||||
}
|
||||
return sawPlaceholder ? text.slice(pendingIndex) : "";
|
||||
}
|
||||
|
||||
export function buildReplaceRegexScan(
|
||||
text: string,
|
||||
ranges: ReadonlyArray<{ start: number; end: number }>,
|
||||
deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
|
||||
): ReplaceRegexScan {
|
||||
let scanText = "";
|
||||
let cursor = 0;
|
||||
const segments: RegexScanSegment[] = [];
|
||||
const appendSegment = (
|
||||
value: string,
|
||||
textStart: number,
|
||||
textEnd: number,
|
||||
generatedPlaceholder: boolean,
|
||||
recursive: boolean,
|
||||
) => {
|
||||
if (value.length === 0) return;
|
||||
const scanStart = scanText.length;
|
||||
scanText += value;
|
||||
segments.push({
|
||||
scanStart,
|
||||
scanEnd: scanStart + value.length,
|
||||
textStart,
|
||||
textEnd,
|
||||
generatedPlaceholder,
|
||||
recursive,
|
||||
});
|
||||
};
|
||||
|
||||
for (const range of ranges) {
|
||||
appendSegment(text.slice(cursor, range.start), cursor, range.start, false, false);
|
||||
const placeholder = text.slice(range.start, range.end);
|
||||
const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
|
||||
appendSegment(mapping?.secret ?? placeholder, range.start, range.end, true, mapping?.recursive ?? false);
|
||||
cursor = range.end;
|
||||
}
|
||||
appendSegment(text.slice(cursor), cursor, text.length, false, false);
|
||||
|
||||
return { text: scanText, segments };
|
||||
}
|
||||
|
||||
export function mapReplaceRegexMatch(
|
||||
segments: ReadonlyArray<RegexScanSegment>,
|
||||
scanStart: number,
|
||||
scanEnd: number,
|
||||
): {
|
||||
start: number;
|
||||
end: number;
|
||||
recursive: boolean;
|
||||
preserveGeneratedPlaceholders: boolean;
|
||||
partialPlaceholderCut: boolean;
|
||||
cutResumeIndex: number;
|
||||
firstPlaceholderScanStart: number;
|
||||
} {
|
||||
const startSegment = findScanSegment(segments, scanStart);
|
||||
const endSegment = findScanSegment(segments, scanEnd - 1);
|
||||
const start = startSegment.generatedPlaceholder
|
||||
? startSegment.textStart
|
||||
: startSegment.textStart + (scanStart - startSegment.scanStart);
|
||||
const end = endSegment.generatedPlaceholder
|
||||
? endSegment.textEnd
|
||||
: endSegment.textStart + (scanEnd - endSegment.scanStart);
|
||||
// A match boundary that falls strictly inside a generated placeholder's
|
||||
// expanded value cuts the underlying secret: the snap above pulls the span out
|
||||
// to the whole `#…#` token, so the obfuscate path can leave it alone instead of
|
||||
// consuming a partial placeholder expansion.
|
||||
const partialPlaceholderCut =
|
||||
(startSegment.generatedPlaceholder && scanStart > startSegment.scanStart) ||
|
||||
(endSegment.generatedPlaceholder && scanEnd < endSegment.scanEnd);
|
||||
let recursive = false;
|
||||
let preserveGeneratedPlaceholders = false;
|
||||
// When the match straddles a placeholder, resume scanning just past the last
|
||||
// overlapping placeholder so trailing wholly-outside content (e.g. an 8-char
|
||||
// run after the secret) still gets matched instead of being consumed by the
|
||||
// straddling span. `firstPlaceholderScanStart` marks where the leading
|
||||
// wholly-outside prefix ends, so a prefix that independently matches can be
|
||||
// redacted on its own rather than skipped along with the cut span.
|
||||
let cutResumeIndex = scanStart;
|
||||
let firstPlaceholderScanStart = -1;
|
||||
for (const segment of segments) {
|
||||
if (segment.scanStart >= scanEnd || segment.scanEnd <= scanStart) continue;
|
||||
recursive ||= segment.recursive;
|
||||
preserveGeneratedPlaceholders ||= segment.generatedPlaceholder;
|
||||
if (segment.generatedPlaceholder) {
|
||||
if (firstPlaceholderScanStart === -1) firstPlaceholderScanStart = segment.scanStart;
|
||||
if (segment.scanEnd > cutResumeIndex) cutResumeIndex = segment.scanEnd;
|
||||
}
|
||||
}
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
recursive,
|
||||
preserveGeneratedPlaceholders,
|
||||
partialPlaceholderCut,
|
||||
cutResumeIndex,
|
||||
firstPlaceholderScanStart,
|
||||
};
|
||||
}
|
||||
|
||||
function findScanSegment(segments: ReadonlyArray<RegexScanSegment>, scanIndex: number): RegexScanSegment {
|
||||
for (const segment of segments) {
|
||||
if (scanIndex >= segment.scanStart && scanIndex < segment.scanEnd) return segment;
|
||||
}
|
||||
throw new Error("regex match did not map to source text");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend a scan-space resume position past a consecutive run of generated
|
||||
* placeholder segments starting exactly at it, with no raw gap in between. A
|
||||
* cut-resolution resume point that happens to land precisely on the START of
|
||||
* ANOTHER placeholder must not stop there and hand it to a fresh `regex.exec`
|
||||
* attempt — the same content, scanned as an opaque adjacent placeholder run,
|
||||
* must resolve identically whether the run's LEADING member is still raw text
|
||||
* (this call is about to placeholder it) or is ALREADY a placeholder from a
|
||||
* prior call or an earlier pass of this same call. Without this, a bounded
|
||||
* regex whose reach spans two adjacent secrets plus trailing spillover bytes
|
||||
* (e.g. `[A-Z]{9}` over `ABCDEFGH` + `SECRETUV` + `A`) resolves the leading
|
||||
* secret as its own independent redaction on the FIRST obfuscate() call (a
|
||||
* genuinely raw prefix gets its own match, then the discard for the rest
|
||||
* resumes right after it), but on a LATER call — once that prefix is itself a
|
||||
* placeholder — the very first match attempt starts already inside the
|
||||
* placeholder run, cannot be prefix-narrowed at all, and its discard resume
|
||||
* point lands mid-run instead of past it, exposing a shorter tail (`SECRETUV`
|
||||
* + `A`) to a clean, un-cut match the first call never attempted. Chaining the
|
||||
* resume point through every immediately-adjacent placeholder makes both
|
||||
* calls land on the exact same next scan position.
|
||||
*/
|
||||
export function extendPastAdjacentPlaceholders(segments: ReadonlyArray<RegexScanSegment>, index: number): number {
|
||||
let cursor = index;
|
||||
for (;;) {
|
||||
const segment = segments.find(candidate => candidate.scanStart === cursor && candidate.generatedPlaceholder);
|
||||
if (!segment) return cursor;
|
||||
cursor = segment.scanEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a fixed custom replacement across a matched span while preserving any
|
||||
// inner generated placeholders. Usually the replacement is the user's single
|
||||
// redaction marker for the whole match, so emit it for the first non-empty
|
||||
// surrounding chunk and drop later chunks. But bounded regexes can cut through
|
||||
// an already-emitted marker on the trailing side (`X#…#RED` from
|
||||
// `XSECRETUVREDACTED`), where dropping the later prefix would leave raw bytes
|
||||
// (`ACTED`) to be consumed on the next pass. Promote later chunks that are a
|
||||
// prefix of the replacement to the FULL marker so the first pass is already a
|
||||
// fixed point. The reversible placeholder stays intact in its relative
|
||||
// position.
|
||||
export function redactWithFixedReplacementOutsidePlaceholders(
|
||||
text: string,
|
||||
origin: string,
|
||||
replacement: string,
|
||||
shouldPreservePlaceholder: (placeholder: string) => boolean,
|
||||
): { text: string; origin: string } {
|
||||
let emitted = false;
|
||||
return transformOutsidePlaceholdersTracked(
|
||||
text,
|
||||
origin,
|
||||
shouldPreservePlaceholder,
|
||||
chunk => {
|
||||
if (chunk.length === 0) return "";
|
||||
if (!emitted) {
|
||||
emitted = true;
|
||||
return replacement;
|
||||
}
|
||||
return replacement.startsWith(chunk) ? replacement : "";
|
||||
},
|
||||
placeholder => placeholder,
|
||||
);
|
||||
}
|
||||
|
||||
export function deobfuscateGeneratedPlaceholderRanges(
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ReadonlyArray<{ start: number; end: number }>,
|
||||
deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
|
||||
): { text: string; recursive: boolean } {
|
||||
let result = "";
|
||||
let cursor = start;
|
||||
let recursive = false;
|
||||
for (const range of ranges) {
|
||||
if (range.end <= start || range.start >= end) continue;
|
||||
const overlapStart = Math.max(range.start, start);
|
||||
const overlapEnd = Math.min(range.end, end);
|
||||
result += text.slice(cursor, overlapStart);
|
||||
const placeholder = text.slice(overlapStart, overlapEnd);
|
||||
const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
|
||||
result += mapping?.secret ?? placeholder;
|
||||
recursive ||= mapping?.recursive ?? false;
|
||||
cursor = overlapEnd;
|
||||
}
|
||||
result += text.slice(cursor, end);
|
||||
return { text: result, recursive };
|
||||
}
|
||||
|
||||
// Concatenate ONLY the deobfuscated placeholder ranges within [start, end),
|
||||
// dropping the bytes that lie outside them. Used to test whether a regex match
|
||||
// that straddles a prior-call placeholder would still match on the placeholder's
|
||||
// own (expanded) secret value alone — i.e. the surrounding raw bytes are greedy
|
||||
// spillover the match does not need, rather than content the match depends on.
|
||||
export function placeholderInnerText(
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ReadonlyArray<{ start: number; end: number }>,
|
||||
deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
|
||||
): string {
|
||||
let result = "";
|
||||
for (const range of ranges) {
|
||||
if (range.end <= start || range.start >= end) continue;
|
||||
const overlapStart = Math.max(range.start, start);
|
||||
const overlapEnd = Math.min(range.end, end);
|
||||
const placeholder = text.slice(overlapStart, overlapEnd);
|
||||
const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
|
||||
result += mapping?.secret ?? placeholder;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Concatenate the bytes of [start, end) that lie OUTSIDE the given (ascending,
|
||||
// non-overlapping) placeholder ranges. Used to test whether a regex match that
|
||||
// straddles a prior-call placeholder would still match on its surrounding bytes
|
||||
// alone — i.e. those bytes are genuinely new content to redact rather than a
|
||||
// match that only exists because the deobfuscated placeholder bridges them.
|
||||
export function textOutsidePlaceholderRanges(
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ReadonlyArray<{ start: number; end: number }>,
|
||||
): string {
|
||||
let result = "";
|
||||
let cursor = start;
|
||||
for (const range of ranges) {
|
||||
if (range.end <= start || range.start >= end) continue;
|
||||
const overlapStart = Math.max(range.start, start);
|
||||
const overlapEnd = Math.min(range.end, end);
|
||||
result += text.slice(cursor, overlapStart);
|
||||
cursor = overlapEnd;
|
||||
}
|
||||
result += text.slice(cursor, end);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Like `textOutsidePlaceholderRanges`, but tests each outside chunk against
|
||||
// `regex` in its REAL context instead of on an isolated slice — tried in BOTH
|
||||
// the literal `#…#` placeholder-token text AND the EXPANDED scan context
|
||||
// (placeholder resolved to its secret value), since either can be the reason a
|
||||
// chunk independently requires redaction:
|
||||
// - Literal-token context matters when the placeholder TOKEN's own non-word
|
||||
// boundary is what completes a boundary-sensitive pattern, e.g. a prefix
|
||||
// "ABCDEFGH" next to a placeholder token matches `\b[A-Z]{8}\b` because the
|
||||
// token's leading `#` is a non-word byte — but that boundary disappears
|
||||
// once the placeholder expands into more `[A-Z]` bytes with no separator.
|
||||
// - Expanded scan context matters when a lookbehind/lookahead only resolves
|
||||
// once the neighboring placeholder is expanded, e.g. a prior plain
|
||||
// placeholder for `ABCDEFGH` next to raw `SECRET`, matched by
|
||||
// `(?<=ABCDEFGH)SECRET`: the literal placeholder token before `SECRET`
|
||||
// never satisfies the lookbehind, so literal-context alone wrongly reports
|
||||
// no independent match.
|
||||
// A match only counts when it lies ENTIRELY within one outside chunk (in
|
||||
// whichever context it was tested); a match that reaches into the
|
||||
// placeholder itself is not evidence the outside chunk independently
|
||||
// requires redaction.
|
||||
export function outsidePlaceholderRangesAnyIndependentlyMatch(
|
||||
text: string,
|
||||
scanText: string,
|
||||
segments: ReadonlyArray<RegexScanSegment>,
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ReadonlyArray<{ start: number; end: number }>,
|
||||
regex: RegExp,
|
||||
): boolean {
|
||||
// A text-space outside chunk lies entirely within one non-placeholder scan
|
||||
// segment (placeholder ranges are exactly the gaps between such segments),
|
||||
// so its scan-space span is a fixed offset from its text-space span.
|
||||
const toScanSpace = (chunkStart: number, chunkEnd: number): [number, number] | undefined => {
|
||||
for (const segment of segments) {
|
||||
if (segment.generatedPlaceholder || segment.textStart > chunkStart || segment.textEnd < chunkEnd) continue;
|
||||
const offset = segment.scanStart - segment.textStart;
|
||||
return [chunkStart + offset, chunkEnd + offset];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const chunkIndependentlyMatches = (chunkStart: number, chunkEnd: number): boolean => {
|
||||
if (chunkMatchesInSourceContext(text, chunkStart, chunkEnd, regex)) return true;
|
||||
const scanSpan = toScanSpace(chunkStart, chunkEnd);
|
||||
return scanSpan !== undefined && chunkMatchesInSourceContext(scanText, scanSpan[0], scanSpan[1], regex);
|
||||
};
|
||||
let cursor = start;
|
||||
for (const range of ranges) {
|
||||
if (range.end <= start || range.start >= end) continue;
|
||||
const overlapStart = Math.max(range.start, start);
|
||||
const overlapEnd = Math.min(range.end, end);
|
||||
if (cursor < overlapStart && chunkIndependentlyMatches(cursor, overlapStart)) return true;
|
||||
cursor = overlapEnd;
|
||||
}
|
||||
return cursor < end && chunkIndependentlyMatches(cursor, end);
|
||||
}
|
||||
|
||||
// Whether `regex` (global) has a match fully contained in [chunkStart, chunkEnd)
|
||||
// when run against the full `text` — so lookbehind/lookahead see the actual
|
||||
// surrounding bytes rather than an isolated slice's edges.
|
||||
function chunkMatchesInSourceContext(text: string, chunkStart: number, chunkEnd: number, regex: RegExp): boolean {
|
||||
regex.lastIndex = chunkStart;
|
||||
for (;;) {
|
||||
const found = regex.exec(text);
|
||||
if (found === null || found.index >= chunkEnd) return false;
|
||||
const matchEnd = found.index + found[0].length;
|
||||
if (matchEnd <= chunkEnd) return true;
|
||||
regex.lastIndex = found[0].length === 0 ? found.index + 1 : matchEnd;
|
||||
}
|
||||
}
|
||||
|
||||
export function firstOutsidePlaceholderRange(
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ReadonlyArray<{ start: number; end: number }>,
|
||||
): { start: number; end: number } | undefined {
|
||||
let cursor = start;
|
||||
for (const range of ranges) {
|
||||
if (range.end <= start || range.start >= end) continue;
|
||||
const overlapStart = Math.max(range.start, start);
|
||||
const overlapEnd = Math.min(range.end, end);
|
||||
if (cursor < overlapStart) return { start: cursor, end: overlapStart };
|
||||
cursor = overlapEnd;
|
||||
}
|
||||
return cursor < end ? { start: cursor, end } : undefined;
|
||||
}
|
||||
|
||||
export function countOutsidePlaceholderRanges(
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ReadonlyArray<{ start: number; end: number }>,
|
||||
): number {
|
||||
let count = 0;
|
||||
let cursor = start;
|
||||
for (const range of ranges) {
|
||||
if (range.end <= start || range.start >= end) continue;
|
||||
const overlapStart = Math.max(range.start, start);
|
||||
const overlapEnd = Math.min(range.end, end);
|
||||
if (cursor < overlapStart) count++;
|
||||
cursor = overlapEnd;
|
||||
}
|
||||
if (cursor < end) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
export function replaceRange(text: string, start: number, end: number, replacement: string): string {
|
||||
return text.slice(0, start) + replacement + text.slice(end);
|
||||
}
|
||||
|
||||
/** Deep-walk an object, transforming all string values. */
|
||||
export function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
|
||||
if (typeof obj === "string") {
|
||||
return transform(obj) as unknown as T;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
let changed = false;
|
||||
const result = obj.map(item => {
|
||||
const transformed = deepWalkStrings(item, transform);
|
||||
if (transformed !== item) changed = true;
|
||||
return transformed;
|
||||
});
|
||||
return (changed ? result : obj) as unknown as T;
|
||||
}
|
||||
if (obj !== null && typeof obj === "object" && isPlainRecord(obj)) {
|
||||
let changed = false;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = (obj as Record<string, unknown>)[key];
|
||||
const transformed = deepWalkStrings(value, transform);
|
||||
if (transformed !== value) changed = true;
|
||||
result[key] = transformed;
|
||||
}
|
||||
return (changed ? result : obj) as T;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function isPlainRecord(obj: object): obj is Record<string, unknown> {
|
||||
const prototype = Object.getPrototypeOf(obj);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
export function collectJsonRegexSecretValues(obfuscator: SecretObfuscator, value: JsonValue): Set<string> {
|
||||
const values = new Set<string>();
|
||||
const collect = (item: JsonValue): void => {
|
||||
if (typeof item === "string") {
|
||||
for (const secretValue of obfuscator.collectRegexSecretValuesForObfuscation(item)) {
|
||||
values.add(secretValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(item)) {
|
||||
for (const child of item) collect(child);
|
||||
return;
|
||||
}
|
||||
if (item !== null && typeof item === "object") {
|
||||
for (const child of Object.values(item)) {
|
||||
if (child !== undefined) collect(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(value);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map every string in arbitrary JSON. Used ONLY for tool-call arguments, whose
|
||||
* shape is model-authored and not known ahead of time. No other caller may walk
|
||||
* untyped data: every message/content path is handled by a typed transformer.
|
||||
*/
|
||||
export function mapJsonStrings(value: JsonValue, fn: (s: string) => string): JsonValue {
|
||||
if (typeof value === "string") return fn(value);
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const out = value.map(item => {
|
||||
const next = mapJsonStrings(item, fn);
|
||||
if (next !== item) changed = true;
|
||||
return next;
|
||||
});
|
||||
return changed ? out : value;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
let changed = false;
|
||||
const out: JsonRecord = {};
|
||||
for (const key of Object.keys(value)) {
|
||||
const item = value[key];
|
||||
if (item === undefined) continue;
|
||||
const next = mapJsonStrings(item, fn);
|
||||
if (next !== item) changed = true;
|
||||
out[key] = next;
|
||||
}
|
||||
return changed ? out : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import type { SecretEntry } from "./obfuscator";
|
||||
import { ensureDistinctReplacement, generateDeterministicReplacement, REPLACEMENT_CHARS } from "./replacement";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Placeholder format
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const HASH_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
// Base length is sized for ~62 bits of entropy (64 bits of a keyed digest
|
||||
// rendered as 12 base36 chars) so unrelated secrets do not collide on a shared
|
||||
// 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;
|
||||
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.
|
||||
export const MIN_OBFUSCATE_SECRET_LEN = 8;
|
||||
|
||||
// Per-process fallback key used when a caller does not supply a persisted
|
||||
// per-install key. It is random (never shipped in source), so model-visible
|
||||
// placeholders cannot be reversed by dictionary-hashing candidate secrets; it
|
||||
// only forgoes cross-session token stability, which the persisted key provides.
|
||||
let ephemeralPlaceholderKey: string | undefined;
|
||||
export function defaultPlaceholderKey(): string {
|
||||
ephemeralPlaceholderKey ??= crypto.randomBytes(32).toString("base64url");
|
||||
return ephemeralPlaceholderKey;
|
||||
}
|
||||
|
||||
type PlaceholderCaseHint = "U" | "L" | "C" | "M";
|
||||
|
||||
/** Normalize a friendly name into the model-visible placeholder prefix. */
|
||||
export function sanitizeSecretFriendlyName(name: string): string | undefined {
|
||||
const sanitized = name
|
||||
.replace(/[^A-Za-z0-9]/g, "")
|
||||
.toUpperCase()
|
||||
.slice(0, MAX_FRIENDLY_NAME_LEN);
|
||||
return sanitized.length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a secret value into the same alnum-only, uppercased shape a
|
||||
* friendly-name label or placeholder prefix is sanitized into, so comparing a
|
||||
* raw (possibly lowercase/punctuated) secret value against already-sanitized,
|
||||
* model-visible text does not miss a case- or separator-only variant. Unlike
|
||||
* `sanitizeSecretFriendlyName` this never truncates and never signals "empty"
|
||||
* via `undefined` — callers already guard on `.length > 0` before comparing.
|
||||
*/
|
||||
export function sanitizeForCollisionCheck(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
// A label leaks a secret either by containing the whole normalized secret or,
|
||||
// once it reaches the public display cap, by being the secret's visible prefix.
|
||||
// Shorter names like "TOKEN" can still be intentional generic labels.
|
||||
export function sanitizedLabelCollidesWithSecret(sanitizedLabel: string, sanitizedSecret: string): boolean {
|
||||
if (sanitizedSecret.length === 0) return false;
|
||||
if (sanitizedLabel.includes(sanitizedSecret)) return true;
|
||||
return sanitizedLabel.length >= MAX_FRIENDLY_NAME_LEN && sanitizedSecret.startsWith(sanitizedLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an entry needs the persisted placeholder key: either because it can
|
||||
* produce a reversible (keyed) obfuscate-mode placeholder, or because a default
|
||||
* (no custom `replacement`) replace-mode regex can reach
|
||||
* `#generateRegexReplacement`'s key-derived idempotent fallback marker (see
|
||||
* `#generateReplacement`) when every same-length candidate re-matches a
|
||||
* pathological match-everything config (e.g. `[\s\S]{8}`). That fallback depends
|
||||
* on the persisted per-install key — not just length — to stay a fixed point
|
||||
* across a process restart; without a persisted key, a fresh install falls back
|
||||
* to a process-random key (`defaultPlaceholderKey()`), so the fallback marker
|
||||
* would churn across restarts even though the algorithm itself is stable. A
|
||||
* regex WITH a custom `replacement` never reaches that fallback (it always emits
|
||||
* the literal configured string), and a plain replace secret's replacement is
|
||||
* pure content-hash (`#generateSecretReplacement`), so neither needs the key.
|
||||
* Short plain obfuscate entries are toned down (never placeheld), so they must
|
||||
* NOT force key creation: otherwise a `secret-placeholder.key` file is written
|
||||
* and persisted for a config that ends up with no active secrets, leaving the
|
||||
* key readable via a tool and reusable for later placeholders.
|
||||
*/
|
||||
export function secretEntryNeedsPlaceholderKey(entry: SecretEntry): boolean {
|
||||
if ((entry.mode ?? "obfuscate") === "obfuscate") {
|
||||
if (entry.type === "regex") return true;
|
||||
return entry.content.length >= MIN_OBFUSCATE_SECRET_LEN;
|
||||
}
|
||||
return entry.type === "regex" && entry.replacement === undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a plain replace-mode replacement string can contribute a fragment that
|
||||
* helps the replace phase reconstruct an obfuscate `content`. During obfuscate()'s
|
||||
* replace phase the output is a tiling of passthrough bytes (adversary-controlled
|
||||
* provider text) and whole replacement outputs; any contiguous occurrence of
|
||||
* `content` in that output is covered by interior replacement tiles (each a
|
||||
* substring of `content`) bordered by passthrough at the ends, where the border
|
||||
* tile may be a suffix of a replacement (forming `content`'s prefix) or a prefix
|
||||
* of a replacement (forming `content`'s suffix). An EMPTY replacement deletes its
|
||||
* trigger entirely, joining the passthrough on both sides; with adversary-chosen
|
||||
* surrounding bytes that can form any non-empty `content` across the deleted gap.
|
||||
* So a replacement can help iff it is empty, is a substring of `content`,
|
||||
* contains `content`, or shares such a border overlap.
|
||||
*/
|
||||
function replacementCanFormContent(replacement: string, content: string): boolean {
|
||||
if (replacement.length === 0) return content.length > 0;
|
||||
if (content.includes(replacement) || replacement.includes(content)) return true;
|
||||
const maxOverlap = Math.min(replacement.length, content.length);
|
||||
for (let k = 1; k <= maxOverlap; k++) {
|
||||
// A suffix of the replacement forms the prefix of the content (left border),
|
||||
// or a prefix of the replacement forms the suffix of the content (right border).
|
||||
if (content.startsWith(replacement.slice(replacement.length - k)) || content.endsWith(replacement.slice(0, k))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a SET of entries needs the persisted placeholder key. `obfuscate()`
|
||||
* applies plain replace-mode mappings before the plain-obfuscate pass, so a plain
|
||||
* obfuscate entry only emits a reversible (keyed) placeholder when its content can
|
||||
* still appear AFTER the replace phase. When no obfuscate entry can ever produce a
|
||||
* placeholder, the persisted key must NOT be required/created — otherwise an
|
||||
* effectively replace-only secret set still writes `secret-placeholder.key` and
|
||||
* fails startup when the agent config dir is unwritable.
|
||||
*
|
||||
* The decision models the replace phase as the obfuscator actually runs it:
|
||||
* replace mappings are content-keyed (later duplicate wins) and applied in
|
||||
* descending content-length order; for a fresh probe (no prior placeholders) that
|
||||
* phase is plain sequential substring replacement. A plain obfuscate entry needs
|
||||
* the key when its content survives that simulated phase (direct typing) OR when
|
||||
* any effective replacement can form the content via tiling — a substring,
|
||||
* wholesale superstring, or prefix/suffix border that joins with surrounding
|
||||
* passthrough bytes (see `replacementCanFormContent`). This covers direct
|
||||
* shadowing (`SECRET -> safe`), reintroduction, duplicate ordering, transitive
|
||||
* chains, and context-joined fragments uniformly. Default (omitted) replacements
|
||||
* are deterministic, length-preserving, and distinct, so a same-content shadow
|
||||
* with no other interacting replacement stays key-free.
|
||||
* Replacement outputs are themselves rewritten by every later (shorter-content)
|
||||
* replacement before the plain-obfuscate pass sees them, so a fragment that a
|
||||
* subsequent replacement erases (`AA -> SEC` then `S -> X` turns every `SEC` into
|
||||
* `XEC`) no longer forces the key. Surrounding bytes stay modeled as arbitrary
|
||||
* passthrough, so testing the surviving fragment only drops false positives and
|
||||
* never under-approximates a real key need.
|
||||
*/
|
||||
export function secretEntriesNeedPlaceholderKey(entries: SecretEntry[]): boolean {
|
||||
const replaceMap = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "plain" || (entry.mode ?? "obfuscate") !== "replace") continue;
|
||||
replaceMap.set(
|
||||
entry.content,
|
||||
entry.replacement ?? ensureDistinctReplacement(generateDeterministicReplacement(entry.content), entry.content),
|
||||
);
|
||||
}
|
||||
const replacePhase = [...replaceMap].sort((a, b) => b[0].length - a[0].length);
|
||||
// Apply the replace phase from `start` onward. The phase runs in descending
|
||||
// content-length order, so a replacement output emitted at index i is rewritten
|
||||
// only by the later (shorter-content) replacements at i+1…; `start` 0 models a
|
||||
// value typed directly into the input.
|
||||
const applyReplacePhaseFrom = (text: string, start: number): string => {
|
||||
let result = text;
|
||||
for (let i = start; i < replacePhase.length; i++) {
|
||||
result = result.split(replacePhase[i][0]).join(replacePhase[i][1]);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return entries.some(entry => {
|
||||
if (!secretEntryNeedsPlaceholderKey(entry)) return false;
|
||||
// Regex obfuscate entries match dynamically; conservatively require the key.
|
||||
if (entry.type !== "plain") return true;
|
||||
const content = entry.content;
|
||||
if (applyReplacePhaseFrom(content, 0).includes(content)) return true;
|
||||
// Test each replacement output in the form it SURVIVES the rest of the phase,
|
||||
// so a fragment a later replacement erases no longer forces the key. The
|
||||
// content it tiles into must also survive those later replacements: if a
|
||||
// shorter-content replacement rewrites the surrounding passthrough bytes
|
||||
// (e.g. `AA -> SEC` forms `SEC`+`RET12`, then `R -> X` turns the freshly
|
||||
// formed `SECRET12` into `SECXET12`), the content can never reach the
|
||||
// obfuscate pass, so the key is not needed. Requiring content stability only
|
||||
// drops such false positives — a formation that genuinely survives is still
|
||||
// caught at the replacement index that produces it.
|
||||
return replacePhase.some(
|
||||
([, replacement], i) =>
|
||||
applyReplacePhaseFrom(content, i + 1) === content &&
|
||||
replacementCanFormContent(applyReplacePhaseFrom(replacement, i + 1), content),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Derive the model-visible base from a KEYED digest of the secret. xxHash is
|
||||
// fast and unkeyed, so a fixed-seed content hash of a low-entropy secret could
|
||||
// be dictionaried from the transcript; HMAC-SHA256 under a private per-install
|
||||
// key cannot, since the attacker lacks the key.
|
||||
export function buildHashBase(key: string, value: string): string {
|
||||
const digest = new Bun.CryptoHasher("sha256", key).update(value).digest();
|
||||
let v = 0n;
|
||||
for (let i = 0; i < 8; i++) v = (v << 8n) | BigInt(digest[i]);
|
||||
const radix = BigInt(HASH_CHARS.length);
|
||||
let tag = "";
|
||||
for (let i = 0; i < HASH_LEN; i++) {
|
||||
tag += HASH_CHARS[Number(v % radix)];
|
||||
v /= radix;
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
// Build a deterministic, key-derived run of REPLACEMENT_CHARS of the given
|
||||
// length. Used to redact a per-chunk replace remainder to a marker that depends
|
||||
// only on the per-install key and the remainder length, so a fresh obfuscator
|
||||
// reproduces the identical marker (idempotent redaction across restarts) while
|
||||
// the run stays unpredictable without the key (raw sentinel-shaped bytes cannot
|
||||
// equal the marker, so they are still redacted rather than passed through).
|
||||
export function buildKeyedReplacementRun(key: string, length: number): string {
|
||||
if (length <= 0) return "";
|
||||
const radix = REPLACEMENT_CHARS.length;
|
||||
let out = "";
|
||||
for (let block = 0; out.length < length; block++) {
|
||||
const digest = new Bun.CryptoHasher("sha256", key).update(`replace-chunk\0${length}\0${block}`).digest();
|
||||
for (let i = 0; i < digest.length && out.length < length; i++) {
|
||||
out += REPLACEMENT_CHARS[digest[i] % radix];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function inferCaseHint(secret: string): PlaceholderCaseHint | undefined {
|
||||
let hasCased = false;
|
||||
let hasUpper = false;
|
||||
let hasLower = false;
|
||||
let capitalized = true;
|
||||
let seenFirstCased = false;
|
||||
|
||||
for (let i = 0; i < secret.length; i++) {
|
||||
const code = secret.charCodeAt(i);
|
||||
const isUpper = code >= 65 && code <= 90;
|
||||
const isLower = code >= 97 && code <= 122;
|
||||
if (!isUpper && !isLower) continue;
|
||||
|
||||
hasCased = true;
|
||||
if (isUpper) {
|
||||
hasUpper = true;
|
||||
if (seenFirstCased) capitalized = false;
|
||||
} else {
|
||||
hasLower = true;
|
||||
if (!seenFirstCased) capitalized = false;
|
||||
}
|
||||
seenFirstCased = true;
|
||||
}
|
||||
|
||||
if (!hasCased) return undefined;
|
||||
if (hasUpper && !hasLower) return "U";
|
||||
if (hasLower && !hasUpper) return "L";
|
||||
if (capitalized) return "C";
|
||||
return "M";
|
||||
}
|
||||
|
||||
export function buildPlaceholder(hint: PlaceholderCaseHint | undefined, base: string, friendlyName?: string): string {
|
||||
const prefix = friendlyName ? `${friendlyName}_` : "";
|
||||
return hint ? `$$${prefix}${base}:${hint}$$` : `$$${prefix}${base}$$`;
|
||||
}
|
||||
|
||||
/** Regex matching `$$HASH$$`, `$$HASH:U$$`, and `$$FRIENDLY_HASH(:hint)$$` placeholders. */
|
||||
export const PLACEHOLDER_RE = /\$\$(?:[A-Z0-9]+_)?[A-Z0-9]{4,}(?::[ULCM])?\$\$/g;
|
||||
|
||||
export 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 - 2;
|
||||
}
|
||||
|
||||
export function placeholderWithoutFriendlyName(placeholder: string): string | undefined {
|
||||
const match = /^\$\$[A-Z0-9]+_([A-Z0-9]{4,}(?::[ULCM])?)\$\$$/.exec(placeholder);
|
||||
return match ? `$$${match[1]}$$` : undefined;
|
||||
}
|
||||
|
||||
export function lookupFriendlyPlaceholderAlias(
|
||||
deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
|
||||
placeholder: string,
|
||||
): { secret: string; recursive: boolean } | undefined {
|
||||
const direct = deobfuscateMap.get(placeholder);
|
||||
if (direct !== undefined) return direct;
|
||||
const unprefixed = placeholderWithoutFriendlyName(placeholder);
|
||||
return unprefixed !== undefined ? deobfuscateMap.get(unprefixed) : undefined;
|
||||
}
|
||||
|
||||
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 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;
|
||||
return text.slice(0, pendingPlaceholderStart.index);
|
||||
}
|
||||
|
||||
export interface RegexScanSegment {
|
||||
scanStart: number;
|
||||
scanEnd: number;
|
||||
textStart: number;
|
||||
textEnd: number;
|
||||
generatedPlaceholder: boolean;
|
||||
recursive: boolean;
|
||||
}
|
||||
|
||||
export interface ReplaceRegexScan {
|
||||
text: string;
|
||||
segments: RegexScanSegment[];
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Deterministic replacement generation
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const REPLACEMENT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const NONMATCHING_REPLACEMENT_CHARS = `${REPLACEMENT_CHARS}!#$%&()*+,-./:;<=>?@[]^_{|}~`;
|
||||
// Whitespace bytes used to build last-resort redactions for a default replace
|
||||
// regex that matches every non-whitespace candidate (e.g. `\S{n}`). Only
|
||||
// `space`/`tab` are used — never a line terminator — so a `.`-style
|
||||
// match-everything regex (which matches space and tab but not `\n`) still
|
||||
// exhausts to the sentinel instead of redacting to a newline run.
|
||||
const WHITESPACE_REPLACEMENT_CHARS = " \t";
|
||||
|
||||
/** Generate a deterministic same-length replacement string from a secret value. */
|
||||
export function generateDeterministicReplacement(secret: string): string {
|
||||
if (secret.length === 0) return "";
|
||||
// Prefix generated chunks with a fixed `ZZ` so re-redacting an already-emitted
|
||||
// 1–2 char chunk is a fixed point (the deterministic replacement of a <=2-char
|
||||
// value is itself `Z`/`ZZ`), keeping short default-replacement remainders next
|
||||
// to a reversible placeholder stable across an obfuscator restart.
|
||||
const hash = BigInt(Bun.hash(secret));
|
||||
const chars = secret.length === 1 ? ["Z"] : ["Z", "Z"];
|
||||
let h = hash;
|
||||
for (let i = chars.length; i < secret.length; i++) {
|
||||
h = h ^ (BigInt(i + 1) * 0x9e3779b97f4a7c15n);
|
||||
const idx = Number((h < 0n ? -h : h) % BigInt(REPLACEMENT_CHARS.length));
|
||||
chars.push(REPLACEMENT_CHARS[idx]);
|
||||
}
|
||||
return chars.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a length-preserving deterministic replacement to differ from the secret
|
||||
* it stands in for. `generateDeterministicReplacement` seeds its first 1–2 chars
|
||||
* with the `Z`/`ZZ` sentinel, so a whole configured value that is exactly `Z` or
|
||||
* `ZZ` (or an astronomically unlikely longer hash collision) would otherwise be
|
||||
* emitted unchanged and ship the raw secret to the provider. Flip the first char
|
||||
* to a fixed different glyph: same length, still deterministic, guaranteed != the
|
||||
* secret. Only safe for a whole CONFIGURED value (a plain secret matches its own
|
||||
* literal, so the perturbed output is no longer matched and stays a fixed point);
|
||||
* per-chunk remainders must keep the sentinel to remain idempotent across restart.
|
||||
*/
|
||||
export function ensureDistinctReplacement(replacement: string, secret: string): string {
|
||||
if (replacement.length === 0 || replacement !== secret) return replacement;
|
||||
const alt = replacement[0] === REPLACEMENT_CHARS[0] ? REPLACEMENT_CHARS[1] : REPLACEMENT_CHARS[0];
|
||||
return alt + replacement.slice(1);
|
||||
}
|
||||
|
||||
// How far left of the matched span the re-match scan begins looking for a match
|
||||
// that overlaps the candidate. This bounds ONLY the match-start search position,
|
||||
// never the lookbehind/lookahead context: the probe below substitutes the
|
||||
// candidate into the FULL text, so a regex's lookbehind/lookahead assertions
|
||||
// always evaluate against complete context regardless of width. The single
|
||||
// re-match this misses is one that begins more than this many bytes before the
|
||||
// span and extends into it (a single match longer than the window) — that only
|
||||
// churns the chosen redaction marker between candidates, never back to the raw
|
||||
// matched value, so it cannot leak a secret.
|
||||
const REGEX_REMATCH_BACKSCAN = 512;
|
||||
|
||||
export interface RegexMatchContext {
|
||||
/** Full text the match was found in (positions are offsets into it). */
|
||||
text: string;
|
||||
/** Start/end of the matched span being replaced. */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `candidate`, substituted for the matched span in its surrounding text,
|
||||
* is re-matched by `regex` at its own position. A replace-mode regex that depends
|
||||
* on context (lookbehind/lookahead/`\b`) can match a candidate that does NOT match
|
||||
* in isolation: e.g. `(?<=api=)[AZ]` never matches a bare `A`, but `api=A` does, so
|
||||
* a candidate `A` chosen by an isolation test is re-redacted on the next obfuscate()
|
||||
* pass and can oscillate back to the raw matched value. The probe substitutes the
|
||||
* candidate into the FULL text — not a truncated window — so a wide lookbehind or
|
||||
* lookahead (e.g. `(?<=A{600})`) still evaluates against the context that makes it
|
||||
* match. Truncating that context dropped the assertion's reach and falsely
|
||||
* accepted an oscillating, leaky candidate. The scan starts a bounded distance
|
||||
* left of the span and stops once a match begins at/after the span's end (matches
|
||||
* arrive in order), keeping per-candidate cost independent of total text length.
|
||||
*/
|
||||
export function regexRematchesInContext(candidate: string, regex: RegExp, ctx: RegexMatchContext): boolean {
|
||||
const probe = ctx.text.slice(0, ctx.start) + candidate + ctx.text.slice(ctx.end);
|
||||
const spanStart = ctx.start;
|
||||
const spanEnd = spanStart + candidate.length;
|
||||
regex.lastIndex = Math.max(0, spanStart - REGEX_REMATCH_BACKSCAN);
|
||||
for (let m = regex.exec(probe); m !== null; m = regex.exec(probe)) {
|
||||
const matchStart = m.index;
|
||||
const matchEnd = m.index + m[0].length;
|
||||
// Matches arrive in increasing position; once one starts at or past the
|
||||
// span's end it cannot cover the candidate, and neither can any later one.
|
||||
if (matchStart >= spanEnd) break;
|
||||
// A match overlapping the candidate's own bytes means those bytes get
|
||||
// re-redacted on a later pass — not a fixed point.
|
||||
if (matchEnd > spanStart) return true;
|
||||
// Zero-width matches do not advance lastIndex; step past to avoid a loop.
|
||||
if (m[0].length === 0) regex.lastIndex++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search same-length replacements for one the regex does NOT match, so a default
|
||||
* regex secret whose deterministic replacement collides with its own value (the
|
||||
* `Z`/`ZZ` sentinel, or an astronomical hash collision) is still redacted to a
|
||||
* STABLE nonmatching value instead of shipping the raw secret. A nonmatching
|
||||
* candidate is a fixed point under re-obfuscation — the regex never re-matches it,
|
||||
* so it cannot re-leak on a later pass. The search stays bounded to O(length *
|
||||
* alphabet) regardless of value length: first exhaust every single-position
|
||||
* substitution against a deterministic baseline (`AAAA…`, then `!AAA…`, `A!AA…`,
|
||||
* …) so any regex that only needs one out-of-class byte — regardless of position —
|
||||
* is found in a handful of probes rather than enumerating every combination (which
|
||||
* for a 3-byte match-everything config, e.g. `[\s\S]{3}`, would otherwise run
|
||||
* 90**3 = 729000 candidates through the regex on every single match, stalling
|
||||
* provider requests). Candidates are enumerated deterministically over a stable
|
||||
* ASCII alphabet: alphanumerics first (usually enough), then punctuation fallback
|
||||
* bytes when the regex covers every alphanumeric candidate. When the regex still
|
||||
* matches around a lone perturbed byte (for example `[A-Za-z0-9].*` matching the
|
||||
* unperturbed tail), full-width same-byte candidates (`!!!!!`, `_____`, …) are
|
||||
* tried next. When the regex covers every non-whitespace candidate (e.g. `\S{n}`),
|
||||
* whitespace markers (a full space/tab run, then a single whitespace byte among
|
||||
* non-whitespace filler) are tried as a last resort. A genuine match-everything
|
||||
* regex (`.`/`[\s\S]`, which also matches space and tab) still exhausts this bounded
|
||||
* sweep and returns undefined, letting the caller keep its own fixed-point fallback
|
||||
* — bounded search can in principle miss an escape that depends jointly on
|
||||
* multiple positions in a way no single-position swap reaches, but no realistic
|
||||
* secret-redaction regex (character classes, literal matches, anchored/bounded
|
||||
* repeats) has that shape.
|
||||
*/
|
||||
export function findNonMatchingReplacement(
|
||||
value: string,
|
||||
regex: RegExp,
|
||||
context: RegexMatchContext,
|
||||
): string | undefined {
|
||||
const len = value.length;
|
||||
if (len === 0) return undefined;
|
||||
// Exhaust every single-position substitution against the deterministic baseline
|
||||
// first (covers the common case cheaply), then fall back to full-width same-byte
|
||||
// candidates for a regex that only rejects a lone perturbed byte in context.
|
||||
const baseline = NONMATCHING_REPLACEMENT_CHARS[0].repeat(len);
|
||||
for (let position = 0; position < len; position++) {
|
||||
for (const ch of NONMATCHING_REPLACEMENT_CHARS) {
|
||||
const candidate = `${baseline.slice(0, position)}${ch}${baseline.slice(position + 1)}`;
|
||||
if (candidate === value) continue;
|
||||
if (!regexRematchesInContext(candidate, regex, context)) return candidate;
|
||||
}
|
||||
}
|
||||
// If the regex can still match around a lone punctuation byte (for example
|
||||
// `[A-Za-z0-9].*` matching the `AAAA` tail of `!AAAA`), try full-width
|
||||
// same-byte fallbacks like `!!!!!`, `_____`, etc. before giving up.
|
||||
for (const ch of NONMATCHING_REPLACEMENT_CHARS) {
|
||||
const candidate = ch.repeat(len);
|
||||
if (candidate === value) continue;
|
||||
if (!regexRematchesInContext(candidate, regex, context)) return candidate;
|
||||
}
|
||||
return findWhitespaceFallbackReplacement(value, regex, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort fallback for a default replace regex that matches every
|
||||
* non-whitespace candidate. Builds same-length whitespace markers the regex
|
||||
* cannot match: first a full space/tab run (handles `\S`-class patterns), then a
|
||||
* single whitespace byte among non-whitespace filler (` AAAA`, `A AAA`, …). The
|
||||
* mixed marker defeats regexes that ALSO match all-space/all-tab runs, e.g.
|
||||
* `(?:\S{n}| {n}|\t{n})`, because the lone whitespace byte breaks every
|
||||
* fixed-length run. A genuine match-everything regex (`.`/`[\s\S]`) matches the
|
||||
* filler and the whitespace alike, so this still returns undefined there, keeping
|
||||
* the caller's sentinel as the sole fixed point.
|
||||
*/
|
||||
function findWhitespaceFallbackReplacement(
|
||||
value: string,
|
||||
regex: RegExp,
|
||||
context: RegexMatchContext,
|
||||
): string | undefined {
|
||||
const len = value.length;
|
||||
const filler = NONMATCHING_REPLACEMENT_CHARS[0];
|
||||
for (const ws of WHITESPACE_REPLACEMENT_CHARS) {
|
||||
const full = ws.repeat(len);
|
||||
if (full !== value) {
|
||||
if (!regexRematchesInContext(full, regex, context)) return full;
|
||||
}
|
||||
for (let pos = 0; pos < len; pos++) {
|
||||
const candidate = `${filler.repeat(pos)}${ws}${filler.repeat(len - pos - 1)}`;
|
||||
if (candidate === value) continue;
|
||||
if (!regexRematchesInContext(candidate, regex, context)) return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a default (no custom `replacement`) replace-mode regex can never
|
||||
* safely redact a 1-2 char match: `findNonMatchingReplacement`'s bounded
|
||||
* search — the same search `#generateRegexReplacement` runs at match time —
|
||||
* finds no candidate the regex fails to re-match. This holds independent of
|
||||
* any actual per-install key: the search already exhausts every character in
|
||||
* `REPLACEMENT_CHARS` (the alphabet `buildKeyedReplacementRun` draws its
|
||||
* fallback marker from) plus punctuation and whitespace, so if none of those
|
||||
* escape the regex, no key-derived marker drawn from the same alphabet can
|
||||
* either — the marker is guaranteed to re-match too, making every such match
|
||||
* unresolvable: the fallback could only ever emit the raw matched text
|
||||
* unchanged. Probed with a value (`"\0".repeat(length)`) the bounded search
|
||||
* never treats as a real candidate, so the result depends only on the
|
||||
* regex's own matching behavior, not on this specific probe.
|
||||
*/
|
||||
export function regexHasUnresolvableShortMatchFallback(regex: RegExp): boolean {
|
||||
return ([1, 2] as const).some(length => {
|
||||
const probe = "\u0000".repeat(length);
|
||||
const savedLastIndex = regex.lastIndex;
|
||||
try {
|
||||
return findNonMatchingReplacement(probe, regex, { text: probe, start: 0, end: length }) === undefined;
|
||||
} finally {
|
||||
regex.lastIndex = savedLastIndex;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -174,8 +174,8 @@ import {
|
||||
deobfuscateSessionContext,
|
||||
deobfuscateToolArguments,
|
||||
obfuscateProviderContext,
|
||||
type SecretObfuscator,
|
||||
} from "../secrets/obfuscator";
|
||||
} from "../secrets/message-transform";
|
||||
import type { SecretObfuscator } from "../secrets/obfuscator";
|
||||
import {
|
||||
AUTO_THINKING,
|
||||
type ConfiguredThinkingLevel,
|
||||
|
||||
@@ -14,7 +14,8 @@ import { logger, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import type { ModelRegistry } from "../config/model-registry";
|
||||
import type { Settings } from "../config/settings";
|
||||
import type { ExtensionRunner, SessionBeforeSwitchResult } from "../extensibility/extensions";
|
||||
import { obfuscateProviderContext, type SecretObfuscator } from "../secrets/obfuscator";
|
||||
import { obfuscateProviderContext } from "../secrets/message-transform";
|
||||
import type { SecretObfuscator } from "../secrets/obfuscator";
|
||||
import type { HandoffResult, SessionHandoffOptions } from "./agent-session-types";
|
||||
import type { BashSessionTransition } from "./bash-runner";
|
||||
import type { SessionContext } from "./session-context";
|
||||
|
||||
@@ -10,12 +10,9 @@ import { formatModelString } from "../config/model-resolver";
|
||||
import type { Settings } from "../config/settings";
|
||||
import { validateProviderMaxInFlightRequests } from "../config/settings";
|
||||
import type { LocalProtocolOptions } from "../internal-urls";
|
||||
import {
|
||||
deobfuscateSessionContext,
|
||||
obfuscateMessages,
|
||||
type SecretObfuscator,
|
||||
stripPendingSecretPlaceholderSuffix,
|
||||
} from "../secrets/obfuscator";
|
||||
import { deobfuscateSessionContext, obfuscateMessages } from "../secrets/message-transform";
|
||||
import type { SecretObfuscator } from "../secrets/obfuscator";
|
||||
import { stripPendingSecretPlaceholderSuffix } from "../secrets/placeholder";
|
||||
import { normalizeModelContextImages } from "../utils/image-loading";
|
||||
import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallback";
|
||||
import { type CustomMessage, convertToLlm } from "./messages";
|
||||
|
||||
@@ -23,13 +23,14 @@ import {
|
||||
obfuscateMessages,
|
||||
obfuscateProviderContext,
|
||||
obfuscateToolArguments,
|
||||
type SecretEntry,
|
||||
SecretObfuscator,
|
||||
} from "@oh-my-pi/pi-coding-agent/secrets/message-transform";
|
||||
import { type SecretEntry, SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator";
|
||||
import {
|
||||
sanitizeSecretFriendlyName,
|
||||
secretEntriesNeedPlaceholderKey,
|
||||
secretEntryNeedsPlaceholderKey,
|
||||
stripPendingSecretPlaceholderSuffix,
|
||||
} from "@oh-my-pi/pi-coding-agent/secrets/obfuscator";
|
||||
} from "@oh-my-pi/pi-coding-agent/secrets/placeholder";
|
||||
import { compileSecretRegex } from "@oh-my-pi/pi-coding-agent/secrets/regex";
|
||||
import { getActiveProfile, getAgentDir, setProfile } from "@oh-my-pi/pi-utils/dirs";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user