feat(secrets): add secret obfuscation with regex flags support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<AssistantMessage["content"][number], { type: "toolCall" }>;
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown>, toolName: string) => Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Cursor exec handlers for local tool execution.
|
||||
*/
|
||||
@@ -178,6 +184,7 @@ export class Agent {
|
||||
#resolveRunningPrompt?: () => void;
|
||||
#kimiApiFormat?: "openai" | "anthropic";
|
||||
#preferWebsockets?: boolean;
|
||||
#transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -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<string, unknown>, toolName: string) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolCallContext {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -300,6 +300,15 @@ export const SETTINGS_SCHEMA = {
|
||||
disabledExtensions: { type: "array", default: [] as string[] },
|
||||
modelRoles: { type: "record", default: {} as Record<string, string> },
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Secrets settings
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
"secrets.enabled": {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
ui: { tab: "config", label: "Hide secrets", description: "Obfuscate secrets before sending to AI providers" },
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Compaction settings
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<SecretEntry[]> {
|
||||
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<string>();
|
||||
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<SecretEntry[]> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@@ -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<N>=?<padding>?>> */
|
||||
const PLACEHOLDER_RE = /<<\$env:S(\d+)(?:=[.]*)?>>(?!>)/g;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SecretObfuscator
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class SecretObfuscator {
|
||||
/** Plain secrets: secret → index (known at construction) */
|
||||
#plainMappings = new Map<string, number>();
|
||||
|
||||
/** 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<number, { secret: string; placeholder: string }>();
|
||||
|
||||
/** Replace-mode plain mappings: secret → replacement */
|
||||
#replaceMappings = new Map<string, string>();
|
||||
|
||||
/** Reverse lookup for deobfuscation: placeholder → secret */
|
||||
#deobfuscateMap = new Map<string, string>();
|
||||
|
||||
/** 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<string>();
|
||||
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<T>(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<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") {
|
||||
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;
|
||||
}
|
||||
@@ -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 ?? ""));
|
||||
}
|
||||
@@ -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<string, number>();
|
||||
#streamingEditFileCache = new Map<string, string>();
|
||||
#promptInFlight = false;
|
||||
#obfuscator: SecretObfuscator | undefined;
|
||||
#promptGeneration = 0;
|
||||
#providerSessionState = new Map<string, ProviderSessionState>();
|
||||
|
||||
@@ -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("-"));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user