From 2924495bfdf01ee2a35ee299bfb6d8bb3d5ece47 Mon Sep 17 00:00:00 2001 From: luk Date: Sun, 15 Feb 2026 16:50:01 +0000 Subject: [PATCH] feat(secrets): add secret obfuscation with regex flags support --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/agent-loop.ts | 4 +- packages/agent/src/agent.ts | 9 + packages/agent/src/types.ts | 6 + packages/coding-agent/CHANGELOG.md | 6 + .../src/config/settings-schema.ts | 9 + packages/coding-agent/src/sdk.ts | 24 +- packages/coding-agent/src/secrets/index.ts | 114 ++++++++ .../coding-agent/src/secrets/obfuscator.ts | 265 ++++++++++++++++++ packages/coding-agent/src/secrets/regex.ts | 9 + .../coding-agent/src/session/agent-session.ts | 10 +- .../test/secrets-obfuscator.test.ts | 67 +++++ 12 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/src/secrets/index.ts create mode 100644 packages/coding-agent/src/secrets/obfuscator.ts create mode 100644 packages/coding-agent/src/secrets/regex.ts create mode 100644 packages/coding-agent/test/secrets-obfuscator.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7a129b84e..017031633 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `transformToolCallArguments` option to `AgentOptions` and `AgentLoopConfig` for transforming tool call arguments before execution (e.g. secret deobfuscation) + ## [12.2.0] - 2026-02-13 ### Added diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 2f9df5e92..f5b16eb76 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -198,6 +198,7 @@ async function runLoop( config.getSteeringMessages, config.getToolContext, config.interruptMode, + config.transformToolCallArguments, ); toolResults.push(...toolExecution.toolResults); steeringAfterTools = toolExecution.steeringMessages ?? null; @@ -371,6 +372,7 @@ async function executeToolCalls( getSteeringMessages?: AgentLoopConfig["getSteeringMessages"], getToolContext?: AgentLoopConfig["getToolContext"], interruptMode: AgentLoopConfig["interruptMode"] = "immediate", + transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"], ): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> { type ToolCallContent = Extract; const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall"); @@ -448,7 +450,7 @@ async function executeToolCalls( : undefined; result = await tool.execute( toolCall.id, - validatedArgs, + transformToolCallArguments ? transformToolCallArguments(validatedArgs, toolCall.name) : validatedArgs, tool.nonAbortable ? undefined : toolSignal, partialResult => { if (interruptState.triggered) return; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 27b649b28..12418cd49 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -123,6 +123,12 @@ export interface AgentOptions { */ getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined; + /** + * Optional transform applied to tool call arguments before execution. + * Use for deobfuscating secrets or rewriting arguments. + */ + transformToolCallArguments?: (args: Record, toolName: string) => Record; + /** * Cursor exec handlers for local tool execution. */ @@ -178,6 +184,7 @@ export class Agent { #resolveRunningPrompt?: () => void; #kimiApiFormat?: "openai" | "anthropic"; #preferWebsockets?: boolean; + #transformToolCallArguments?: (args: Record, toolName: string) => Record; /** Buffered Cursor tool results with text length at time of call (for correct ordering) */ #cursorToolResultBuffer: CursorToolResultEntry[] = []; @@ -204,6 +211,7 @@ export class Agent { this.#cursorOnToolResult = opts.cursorOnToolResult; this.#kimiApiFormat = opts.kimiApiFormat; this.#preferWebsockets = opts.preferWebsockets; + this.#transformToolCallArguments = opts.transformToolCallArguments; } /** @@ -626,6 +634,7 @@ export class Agent { getToolContext: this.#getToolContext, cursorExecHandlers: this.#cursorExecHandlers, cursorOnToolResult, + transformToolCallArguments: this.#transformToolCallArguments, getSteeringMessages: async () => { if (skipInitialSteeringPoll) { skipInitialSteeringPoll = false; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index da532eaec..81e3e9978 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -111,6 +111,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * Use for late-bound UI or session state access. */ getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined; + + /** + * Optional transform applied to tool call arguments before execution. + * Use for deobfuscating secrets or rewriting arguments. + */ + transformToolCallArguments?: (args: Record, toolName: string) => Record; } export interface ToolCallContext { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f406c28a1..d0f6b9a78 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- Added secret obfuscation: env vars matching secret patterns and `secrets.json` entries are replaced with placeholders before sending to LLM providers, deobfuscated in tool call arguments +- Added `secrets.enabled` setting to toggle secret obfuscation +- Added full regex literal support for `secrets.json` entries (`"/pattern/flags"` syntax with escaped `/` handling, automatic `g` flag enforcement) + ## [12.5.1] - 2026-02-15 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 3218b58be..125544ffb 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -300,6 +300,15 @@ export const SETTINGS_SCHEMA = { disabledExtensions: { type: "array", default: [] as string[] }, modelRoles: { type: "record", default: {} as Record }, + // ───────────────────────────────────────────────────────────────────────── + // Secrets settings + // ───────────────────────────────────────────────────────────────────────── + "secrets.enabled": { + type: "boolean", + default: true, + ui: { tab: "config", label: "Hide secrets", description: "Obfuscate secrets before sending to AI providers" }, + }, + // ───────────────────────────────────────────────────────────────────────── // Compaction settings // ───────────────────────────────────────────────────────────────────────── diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 96fea686b..4101415c5 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -47,6 +47,7 @@ import { import { disposeAllKernelSessions } from "./ipy/executor"; import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp"; import { buildMemoryToolDeveloperInstructions, startMemoryStartupTask } from "./memories"; +import { collectEnvSecrets, loadSecrets, obfuscateMessages, SecretObfuscator } from "./secrets"; import { AgentSession } from "./session/agent-session"; import { AuthStorage } from "./session/auth-storage"; import { convertToLlm } from "./session/messages"; @@ -1091,6 +1092,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }); }; + // Load and create secret obfuscator if secrets are enabled + let obfuscator: SecretObfuscator | undefined; + if (settings.get("secrets.enabled")) { + const fileEntries = await loadSecrets(cwd, agentDir); + const envEntries = collectEnvSecrets(); + const allEntries = [...envEntries, ...fileEntries]; + if (allEntries.length > 0) { + obfuscator = new SecretObfuscator(allEntries); + } + time("loadSecrets"); + } + + // Final convertToLlm: chain block-images filter with secret obfuscation + const convertToLlmFinal = (messages: AgentMessage[]): Message[] => { + const converted = convertToLlmWithBlockImages(messages); + if (!obfuscator?.hasSecrets()) return converted; + return obfuscateMessages(obfuscator, converted); + }; + const setToolUIContext = (uiContext: ExtensionUIContext, hasUI: boolean) => { toolContextStore.setUIContext(uiContext, hasUI); }; @@ -1110,7 +1130,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingLevel, tools: initialTools, }, - convertToLlm: convertToLlmWithBlockImages, + convertToLlm: convertToLlmFinal, sessionId: sessionManager.getSessionId(), transformContext: extensionRunner ? async messages => { @@ -1135,6 +1155,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return key; }, cursorExecHandlers, + transformToolCallArguments: obfuscator?.hasSecrets() ? args => obfuscator!.deobfuscateObject(args) : undefined, }); cursorEventEmitter = event => agent.emitExternalEvent(event); debugStartup("sdk:createAgent"); @@ -1171,6 +1192,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} rebuildSystemPrompt, ttsrManager, forceCopilotAgentInitiator, + obfuscator, }); debugStartup("sdk:createAgentSession"); time("createAgentSession"); diff --git a/packages/coding-agent/src/secrets/index.ts b/packages/coding-agent/src/secrets/index.ts new file mode 100644 index 000000000..30e18efb6 --- /dev/null +++ b/packages/coding-agent/src/secrets/index.ts @@ -0,0 +1,114 @@ +import * as path from "node:path"; +import { isEnoent, logger } from "@oh-my-pi/pi-utils"; +import type { SecretEntry } from "./obfuscator"; +import { compileSecretRegex } from "./regex"; + +export { obfuscateMessages, type SecretEntry, SecretObfuscator } from "./obfuscator"; + +/** + * Load secrets from project-local and global secrets.json files. + * Project-local entries override global entries with matching content. + */ +export async function loadSecrets(cwd: string, agentDir: string): Promise { + const projectPath = path.join(cwd, ".omp", "secrets.json"); + const globalPath = path.join(agentDir, "secrets.json"); + + const globalEntries = await loadSecretsFile(globalPath); + const projectEntries = await loadSecretsFile(projectPath); + + if (globalEntries.length === 0) return projectEntries; + if (projectEntries.length === 0) return globalEntries; + + // Merge: project overrides global by content match + const projectContents = new Set(projectEntries.map(e => e.content)); + const merged = [...globalEntries.filter(e => !projectContents.has(e.content)), ...projectEntries]; + return merged; +} + +/** Minimum env var value length to consider as a secret. */ +const MIN_ENV_VALUE_LENGTH = 8; + +/** Env var name patterns that indicate secret values. */ +const SECRET_ENV_PATTERNS = /(?:KEY|SECRET|TOKEN|PASSWORD|PASS|AUTH|CREDENTIAL|PRIVATE|OAUTH)(?:_|$)/i; + +/** Collect environment variable values that look like secrets. */ +export function collectEnvSecrets(): SecretEntry[] { + const entries: SecretEntry[] = []; + const seen = new Set(); + for (const [name, value] of Object.entries(process.env)) { + if (!value || value.length < MIN_ENV_VALUE_LENGTH) continue; + if (!SECRET_ENV_PATTERNS.test(name)) continue; + if (seen.has(value)) continue; + seen.add(value); + entries.push({ type: "plain", content: value, mode: "obfuscate" }); + } + return entries; +} + +async function loadSecretsFile(filePath: string): Promise { + try { + const raw = await Bun.file(filePath).json(); + if (!Array.isArray(raw)) { + logger.warn("secrets.json must be a JSON array", { path: filePath }); + return []; + } + const entries: SecretEntry[] = []; + for (let i = 0; i < raw.length; i++) { + const entry = raw[i]; + if (!validateEntry(entry, filePath, i)) continue; + entries.push({ + type: entry.type, + content: entry.content, + mode: entry.mode ?? "obfuscate", + replacement: entry.replacement, + flags: entry.flags, + }); + } + return entries; + } catch (err) { + if (isEnoent(err)) return []; + logger.warn("Failed to load secrets.json", { path: filePath, error: String(err) }); + return []; + } +} + +function validateEntry(entry: unknown, filePath: string, index: number): entry is SecretEntry { + if (entry === null || typeof entry !== "object") { + logger.warn(`secrets.json[${index}]: entry must be an object`, { path: filePath }); + return false; + } + const e = entry as Record; + if (e.type !== "plain" && e.type !== "regex") { + logger.warn(`secrets.json[${index}]: type must be "plain" or "regex"`, { path: filePath }); + return false; + } + if (typeof e.content !== "string" || e.content.length === 0) { + logger.warn(`secrets.json[${index}]: content must be a non-empty string`, { path: filePath }); + return false; + } + if (e.mode !== undefined && e.mode !== "obfuscate" && e.mode !== "replace") { + logger.warn(`secrets.json[${index}]: mode must be "obfuscate" or "replace"`, { path: filePath }); + return false; + } + if (e.replacement !== undefined && typeof e.replacement !== "string") { + logger.warn(`secrets.json[${index}]: replacement must be a string`, { path: filePath }); + return false; + } + if (e.flags !== undefined && typeof e.flags !== "string") { + logger.warn(`secrets.json[${index}]: flags must be a string`, { path: filePath }); + return false; + } + if (e.type === "regex") { + try { + compileSecretRegex(e.content as string, e.flags as string | undefined); + } catch (error) { + logger.warn(`secrets.json[${index}]: invalid regex pattern`, { + path: filePath, + pattern: e.content, + error: String(error), + }); + return false; + } + } + return true; +} diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts new file mode 100644 index 000000000..05c4a5d3c --- /dev/null +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -0,0 +1,265 @@ +import type { Message, TextContent } from "@oh-my-pi/pi-ai"; +import { compileSecretRegex } from "./regex"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════════ + +export interface SecretEntry { + type: "plain" | "regex"; + content: string; + mode?: "obfuscate" | "replace"; + replacement?: string; + flags?: string; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Deterministic replacement generation +// ═══════════════════════════════════════════════════════════════════════════ + +const REPLACEMENT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + +/** Generate a deterministic same-length replacement string from a secret value. */ +function generateDeterministicReplacement(secret: string): string { + // Simple hash: use Bun.hash for speed, seed from the secret bytes + const hash = BigInt(Bun.hash(secret)); + const chars: string[] = []; + let h = hash; + for (let i = 0; i < secret.length; i++) { + // Mix the hash for each character position + 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(""); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Placeholder format +// ═══════════════════════════════════════════════════════════════════════════ + +const PLACEHOLDER_PREFIX = "<<$env:S"; +const PLACEHOLDER_SUFFIX = ">>"; + +/** Build an obfuscation placeholder for secret index N, padded to match the secret length. */ +function buildPlaceholder(index: number, secretLength: number): string { + // Minimum: <<$env:SN>> = 11 chars for single-digit index + const bare = `${PLACEHOLDER_PREFIX}${index}${PLACEHOLDER_SUFFIX}`; + if (secretLength <= bare.length) { + return bare; + } + // Pad with '.' between index and >> + const paddingNeeded = secretLength - bare.length; + const padding = ".".repeat(paddingNeeded); + return `${PLACEHOLDER_PREFIX}${index}=${padding}${PLACEHOLDER_SUFFIX}`; +} + +/** Regex to match obfuscation placeholders: <<$env:S=??>> */ +const PLACEHOLDER_RE = /<<\$env:S(\d+)(?:=[.]*)?>>(?!>)/g; + +// ═══════════════════════════════════════════════════════════════════════════ +// SecretObfuscator +// ═══════════════════════════════════════════════════════════════════════════ + +export class SecretObfuscator { + /** Plain secrets: secret → index (known at construction) */ + #plainMappings = new Map(); + + /** Regex entries (patterns compiled at construction) */ + #regexEntries: Array<{ regex: RegExp; mode: "obfuscate" | "replace"; replacement?: string }> = []; + + /** All obfuscate-mode mappings: index → { secret, placeholder } */ + #obfuscateMappings = new Map(); + + /** Replace-mode plain mappings: secret → replacement */ + #replaceMappings = new Map(); + + /** Reverse lookup for deobfuscation: placeholder → secret */ + #deobfuscateMap = new Map(); + + /** Next available index for regex match discoveries */ + #nextIndex: number; + + /** Whether any secrets were configured */ + #hasAny: boolean; + + constructor(entries: SecretEntry[]) { + let index = 0; + for (const entry of entries) { + const mode = entry.mode ?? "obfuscate"; + + if (entry.type === "plain") { + if (mode === "obfuscate") { + const placeholder = buildPlaceholder(index, entry.content.length); + this.#plainMappings.set(entry.content, index); + this.#obfuscateMappings.set(index, { secret: entry.content, placeholder }); + this.#deobfuscateMap.set(placeholder, entry.content); + index++; + } else { + // replace mode + const replacement = entry.replacement ?? generateDeterministicReplacement(entry.content); + this.#replaceMappings.set(entry.content, replacement); + } + } else { + // regex type — compiled here, matches discovered during obfuscate() + try { + const regex = compileSecretRegex(entry.content, entry.flags); + this.#regexEntries.push({ regex, mode, replacement: entry.replacement }); + } catch { + // Invalid regex — skip silently (validation happens at load time) + } + } + } + + this.#nextIndex = index; + this.#hasAny = entries.length > 0; + } + + hasSecrets(): boolean { + return this.#hasAny; + } + + /** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */ + obfuscate(text: string): string { + if (!this.#hasAny) return text; + let result = text; + + // 1. Process replace-mode plain secrets + for (const [secret, replacement] of this.#replaceMappings) { + result = replaceAll(result, secret, replacement); + } + + // 2. Process obfuscate-mode plain secrets + for (const [secret, index] of this.#plainMappings) { + const mapping = this.#obfuscateMappings.get(index)!; + result = replaceAll(result, secret, mapping.placeholder); + } + + // 3. Process regex entries — discover new matches + for (const entry of this.#regexEntries) { + entry.regex.lastIndex = 0; + const matches = new Set(); + for (;;) { + const match = entry.regex.exec(result); + if (match === null) break; + matches.add(match[0]); + } + + for (const matchValue of matches) { + if (entry.mode === "replace") { + const replacement = entry.replacement ?? generateDeterministicReplacement(matchValue); + result = replaceAll(result, matchValue, replacement); + } else { + // obfuscate mode — get or create stable index + let index = this.#findObfuscateIndex(matchValue); + if (index === undefined) { + index = this.#nextIndex++; + const placeholder = buildPlaceholder(index, matchValue.length); + this.#obfuscateMappings.set(index, { secret: matchValue, placeholder }); + this.#deobfuscateMap.set(placeholder, matchValue); + } + const mapping = this.#obfuscateMappings.get(index)!; + result = replaceAll(result, matchValue, mapping.placeholder); + } + } + } + + return result; + } + + /** Deobfuscate obfuscate-mode placeholders back to original secrets. Replace-mode is NOT reversed. */ + deobfuscate(text: string): string { + if (!this.#hasAny) return text; + return text.replace(PLACEHOLDER_RE, match => { + return this.#deobfuscateMap.get(match) ?? match; + }); + } + + /** Deep-walk an object, deobfuscating all string values. */ + deobfuscateObject(obj: T): T { + if (!this.#hasAny) return obj; + return deepWalkStrings(obj, s => this.deobfuscate(s)); + } + + /** Find the obfuscate index for a known secret value. */ + #findObfuscateIndex(secret: string): number | undefined { + // Check plain mappings first + const plainIndex = this.#plainMappings.get(secret); + if (plainIndex !== undefined) return plainIndex; + + // Check regex-discovered mappings + for (const [index, mapping] of this.#obfuscateMappings) { + if (mapping.secret === secret) return index; + } + return undefined; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Message obfuscation (outbound to LLM) +// ═══════════════════════════════════════════════════════════════════════════ + +/** Obfuscate all text content in LLM messages (for outbound interception). */ +export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] { + return messages.map(msg => { + if (!Array.isArray(msg.content)) return msg; + + let changed = false; + const content = msg.content.map(block => { + if (block.type === "text") { + const obfuscated = obfuscator.obfuscate(block.text); + if (obfuscated !== block.text) { + changed = true; + return { ...block, text: obfuscated } as TextContent; + } + } + return block; + }); + + return changed ? ({ ...msg, content } as typeof msg) : msg; + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════ + +/** Replace all occurrences of `search` in `text` with `replacement`. */ +function replaceAll(text: string, search: string, replacement: string): string { + if (search.length === 0) return text; + let result = text; + let idx = result.indexOf(search); + while (idx !== -1) { + result = result.slice(0, idx) + replacement + result.slice(idx + search.length); + idx = result.indexOf(search, idx + replacement.length); + } + return result; +} + +/** Deep-walk an object, transforming all string values. */ +function deepWalkStrings(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") { + let changed = false; + const result: Record = {}; + for (const key of Object.keys(obj)) { + const value = (obj as Record)[key]; + const transformed = deepWalkStrings(value, transform); + if (transformed !== value) changed = true; + result[key] = transformed; + } + return (changed ? result : obj) as T; + } + return obj; +} diff --git a/packages/coding-agent/src/secrets/regex.ts b/packages/coding-agent/src/secrets/regex.ts new file mode 100644 index 000000000..e1e7a455c --- /dev/null +++ b/packages/coding-agent/src/secrets/regex.ts @@ -0,0 +1,9 @@ +/** Add global flag while preserving user-provided flags. */ +function enforceGlobalFlag(flags: string): string { + return flags.includes("g") ? flags : `${flags}g`; +} + +/** Compile a secret regex entry with global scanning enabled by default. */ +export function compileSecretRegex(pattern: string, flags?: string): RegExp { + return new RegExp(pattern, enforceGlobalFlag(flags ?? "")); +} diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 28a92c5f3..086ec58a2 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -72,6 +72,7 @@ import type { PlanModeState } from "../plan-mode/state"; import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" }; import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" }; import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" }; +import type { SecretObfuscator } from "../secrets/obfuscator"; import { closeAllConnections } from "../ssh/connection-manager"; import { unmountAll } from "../ssh/sshfs-mount"; import { outputMeta } from "../tools/output-meta"; @@ -155,6 +156,8 @@ export interface AgentSessionConfig { ttsrManager?: TtsrManager; /** Force X-Initiator: agent for GitHub Copilot model selections in this session. */ forceCopilotAgentInitiator?: boolean; + /** Secret obfuscator for deobfuscating streaming edit content */ + obfuscator?: SecretObfuscator; } /** Options for AgentSession.prompt() */ @@ -340,6 +343,7 @@ export class AgentSession { #streamingEditCheckedLineCounts = new Map(); #streamingEditFileCache = new Map(); #promptInFlight = false; + #obfuscator: SecretObfuscator | undefined; #promptGeneration = 0; #providerSessionState = new Map(); @@ -361,6 +365,7 @@ export class AgentSession { this.#baseSystemPrompt = this.agent.state.systemPrompt; this.#ttsrManager = config.ttsrManager; this.#forceCopilotAgentInitiator = config.forceCopilotAgentInitiator ?? false; + this.#obfuscator = config.obfuscator; this.agent.providerSessionState = this.#providerSessionState; // Always subscribe to agent events for internal handling @@ -721,7 +726,10 @@ export class AgentSession { const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1); if (diffForCheck.trim().length === 0) return; - const normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, "")); + let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, "")); + if (!normalizedDiff) return; + // Deobfuscate the diff so removed lines match real file content + if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff); if (!normalizedDiff) return; const lines = normalizedDiff.split("\n"); const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-")); diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts new file mode 100644 index 000000000..925e78e34 --- /dev/null +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for secrets regex parsing, compilation, and obfuscation. + */ + +import { describe, expect, it } from "bun:test"; +import { SecretObfuscator } from "../src/secrets/obfuscator"; +import { compileSecretRegex } from "../src/secrets/regex"; + +describe("compileSecretRegex", () => { + it("compiles pattern with explicit flags and enforces global scanning", () => { + const regex = compileSecretRegex("api[_-]?key\\s*=\\s*\\w+", "gi"); + expect(regex.source).toBe("api[_-]?key\\s*=\\s*\\w+"); + expect(regex.flags).toBe("gi"); + }); + + it("adds global flag when not provided", () => { + const regex = compileSecretRegex("api[_-]?key\\s*=\\s*\\w+", "i"); + expect(regex.source).toBe("api[_-]?key\\s*=\\s*\\w+"); + expect(regex.flags).toBe("gi"); + }); + + it("defaults to global flag when no flags provided", () => { + const regex = compileSecretRegex("api[_-]?key\\s*=\\s*\\w+"); + expect(regex.source).toBe("api[_-]?key\\s*=\\s*\\w+"); + expect(regex.flags).toBe("g"); + }); + + it("rejects invalid regex pattern", () => { + expect(() => compileSecretRegex("(")).toThrow(); + }); + it("rejects invalid regex flags", () => { + expect(() => compileSecretRegex("x", "zz")).toThrow(); + }); +}); + +describe("SecretObfuscator regex behavior", () => { + it("obfuscates and deobfuscates regex matches with flags", () => { + const obfuscator = new SecretObfuscator([{ type: "regex", content: "api[_-]?key\\s*=\\s*\\w+", flags: "i" }]); + const original = "API_KEY=abc and api-key=def"; + const obfuscated = obfuscator.obfuscate(original); + expect(obfuscated).not.toEqual(original); + expect(obfuscator.deobfuscate(obfuscated)).toEqual(original); + }); + + it("supports bare regex patterns without explicit flags", () => { + const obfuscator = new SecretObfuscator([{ type: "regex", content: "api[_-]?key\\s*=\\s*\\w+" }]); + const text = "api_key=abc and API_KEY=def"; + const obfuscated = obfuscator.obfuscate(text); + expect(obfuscated).not.toEqual(text); + expect(obfuscator.deobfuscate(obfuscated)).toEqual(text); + }); + it("deobfuscates placeholders through object payloads", () => { + const obfuscator = new SecretObfuscator([{ type: "regex", content: "api[_-]?key\\s*=\\s*\\w+", flags: "i" }]); + const original = { + cmd: "API_KEY=abc and api-key=def", + status: "ok", + }; + const obfuscated = { + cmd: obfuscator.obfuscate(original.cmd), + status: original.status, + }; + expect(obfuscator.deobfuscateObject(obfuscated)).toEqual({ + cmd: original.cmd, + status: original.status, + }); + }); +});