117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
/**
|
|
* Sensitive word obfuscation for Claude Code requests.
|
|
*
|
|
* Obfuscates configurable words in user messages to prevent detection
|
|
* by upstream content filters. Uses zero-width characters to break
|
|
* pattern matching while preserving readability.
|
|
*/
|
|
|
|
// Unicode zero-width joiner inserted between characters
|
|
const ZWJ = "\u200d";
|
|
|
|
const DEFAULT_SENSITIVE_WORDS = [
|
|
"opencode",
|
|
"open-code",
|
|
"cline",
|
|
"roo-cline",
|
|
"roo_cline",
|
|
"cursor",
|
|
"windsurf",
|
|
"aider",
|
|
"continue.dev",
|
|
"copilot",
|
|
"avante",
|
|
"codecompanion",
|
|
];
|
|
|
|
let sensitiveWords = [...DEFAULT_SENSITIVE_WORDS];
|
|
|
|
export function setSensitiveWords(words: string[]): void {
|
|
sensitiveWords = words.length > 0 ? words : [...DEFAULT_SENSITIVE_WORDS];
|
|
}
|
|
|
|
export function getSensitiveWords(): string[] {
|
|
return [...sensitiveWords];
|
|
}
|
|
|
|
function obfuscateWord(word: string): string {
|
|
if (word.length <= 1) return word;
|
|
// Insert ZWJ after first character
|
|
return word[0] + ZWJ + word.slice(1);
|
|
}
|
|
|
|
// Per-word regex cache — obfuscateSensitiveWords recompiles one RegExp per word on every
|
|
// request body otherwise. Bounded by distinct configured words; global regexes are safe to
|
|
// reuse because String.replace resets lastIndex.
|
|
const _obfuscationRegexCache = new Map<string, RegExp>();
|
|
function getObfuscationRegex(word: string): RegExp {
|
|
let regex = _obfuscationRegexCache.get(word);
|
|
if (!regex) {
|
|
if (_obfuscationRegexCache.size > 2000) _obfuscationRegexCache.clear();
|
|
regex = new RegExp(escapeRegex(word), "gi");
|
|
_obfuscationRegexCache.set(word, regex);
|
|
}
|
|
return regex;
|
|
}
|
|
|
|
export function obfuscateSensitiveWords(text: string): string {
|
|
if (!text || sensitiveWords.length === 0) return text;
|
|
|
|
let result = text;
|
|
for (const word of sensitiveWords) {
|
|
if (!word) continue;
|
|
// Case-insensitive replacement (cached: see getObfuscationRegex)
|
|
const regex = getObfuscationRegex(word);
|
|
result = result.replace(regex, (match) => obfuscateWord(match));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function obfuscateInBody(body: Record<string, unknown>): void {
|
|
// System prompt (Claude format: string or array of blocks)
|
|
if (typeof body.system === "string") {
|
|
body.system = obfuscateSensitiveWords(body.system);
|
|
} else if (Array.isArray(body.system)) {
|
|
for (const block of body.system as Array<Record<string, unknown>>) {
|
|
if (typeof block.text === "string") {
|
|
block.text = obfuscateSensitiveWords(block.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Messages (all roles, not just user — system/assistant may also contain sensitive words)
|
|
const messages = body.messages as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(messages)) {
|
|
for (const msg of messages) {
|
|
const content = msg.content;
|
|
if (typeof content === "string") {
|
|
msg.content = obfuscateSensitiveWords(content);
|
|
} else if (Array.isArray(content)) {
|
|
for (const block of content as Array<Record<string, unknown>>) {
|
|
if (typeof block.text === "string") {
|
|
block.text = obfuscateSensitiveWords(block.text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tool descriptions (may contain URLs or names like "opencode")
|
|
const tools = body.tools as Array<Record<string, unknown>> | undefined;
|
|
if (Array.isArray(tools)) {
|
|
for (const tool of tools) {
|
|
if (typeof tool.description === "string") {
|
|
tool.description = obfuscateSensitiveWords(tool.description);
|
|
}
|
|
const fn = tool.function as Record<string, unknown> | undefined;
|
|
if (fn && typeof fn.description === "string") {
|
|
fn.description = obfuscateSensitiveWords(fn.description);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function escapeRegex(str: string): string {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|