Merge pull request #2147: fix(coding-agent): hide secrets in provider requests

This commit is contained in:
can1357
2026-06-10 08:26:58 +02:00
9 changed files with 362 additions and 61 deletions
+3 -1
View File
@@ -99,6 +99,7 @@ import {
deobfuscateSessionContext,
loadSecrets,
obfuscateMessages,
obfuscateProviderContext,
SecretObfuscator,
} from "./secrets";
import { AgentSession } from "./session/agent-session";
@@ -2138,6 +2139,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (!obfuscator?.hasSecrets()) return converted;
return obfuscateMessages(obfuscator, converted);
};
const transformContext = async (messages: AgentMessage[], _signal?: AbortSignal) => {
const withContext = await extensionRunner.emitContext(messages);
return wrapSteeringForModel(withContext);
@@ -2215,7 +2217,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const openrouterRoutingPreset = settings.get("providers.openrouterVariant");
const openrouterVariant =
openrouterRoutingPreset && openrouterRoutingPreset !== "default" ? openrouterRoutingPreset : undefined;
return streamSimple(streamModel, context, {
return streamSimple(streamModel, obfuscator ? obfuscateProviderContext(obfuscator, context) : context, {
...streamOptions,
openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
});
+8 -1
View File
@@ -4,7 +4,14 @@ import { YAML } from "bun";
import type { SecretEntry } from "./obfuscator";
import { compileSecretRegex } from "./regex";
export { deobfuscateSessionContext, obfuscateMessages, type SecretEntry, SecretObfuscator } from "./obfuscator";
export {
deobfuscateSessionContext,
obfuscateMessages,
obfuscateProviderContext,
obfuscateProviderTools,
type SecretEntry,
SecretObfuscator,
} from "./obfuscator";
/**
* Load secrets from project-local and global secrets.yml files.
+39 -18
View File
@@ -1,4 +1,5 @@
import type { Message, TextContent } from "@oh-my-pi/pi-ai";
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import type { SessionContext } from "../session/session-manager";
import { compileSecretRegex } from "./regex";
@@ -184,6 +185,12 @@ export class SecretObfuscator {
return deepWalkStrings(obj, s => this.deobfuscate(s));
}
/** Deep-walk an object, obfuscating all string values. */
obfuscateObject<T>(obj: T): T {
if (!this.#hasAny) return obj;
return deepWalkStrings(obj, s => this.obfuscate(s));
}
/** Find the obfuscate index for a known secret value. */
#findObfuscateIndex(secret: string): number | undefined {
// Check plain mappings first
@@ -211,25 +218,34 @@ export function deobfuscateSessionContext(
// Message obfuscation (outbound to LLM)
// ═══════════════════════════════════════════════════════════════════════════
/** Obfuscate all text content in LLM messages (for outbound interception). */
/** Obfuscate all string 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;
return obfuscator.obfuscateObject(messages);
}
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;
});
/** Obfuscate provider request context without walking live tool schema instances. */
export function obfuscateProviderContext(obfuscator: SecretObfuscator | undefined, context: Context): Context {
if (!obfuscator?.hasSecrets()) return context;
return {
...context,
systemPrompt: obfuscator.obfuscateObject(context.systemPrompt),
messages: obfuscator.obfuscateObject(context.messages),
tools: obfuscateProviderTools(obfuscator, context.tools),
};
}
return changed ? ({ ...msg, content } as typeof msg) : msg;
});
/** Convert tool schemas to wire JSON Schema before obfuscating provider-visible strings. */
export function obfuscateProviderTools(
obfuscator: SecretObfuscator | undefined,
tools: Tool[] | undefined,
): Tool[] | undefined {
if (!tools || !obfuscator?.hasSecrets()) return tools;
return tools.map(tool => ({
...tool,
description: obfuscator.obfuscate(tool.description),
parameters: obfuscator.obfuscateObject(toolWireSchema(tool)),
customFormat: tool.customFormat ? obfuscator.obfuscateObject(tool.customFormat) : undefined,
}));
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -262,7 +278,7 @@ function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
});
return (changed ? result : obj) as unknown as T;
}
if (obj !== null && typeof obj === "object") {
if (obj !== null && typeof obj === "object" && isPlainRecord(obj)) {
let changed = false;
const result: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
@@ -275,3 +291,8 @@ function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
}
return obj;
}
function isPlainRecord(obj: object): obj is Record<string, unknown> {
const prototype = Object.getPrototypeOf(obj);
return prototype === Object.prototype || prototype === null;
}
@@ -184,7 +184,12 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator";
import {
deobfuscateSessionContext,
obfuscateProviderContext,
obfuscateProviderTools,
type SecretObfuscator,
} from "../secrets/obfuscator";
import { invalidateHostMetadata } from "../ssh/connection-manager";
import {
AUTO_THINKING,
@@ -3999,6 +4004,39 @@ export class AgentSession {
return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
}
#obfuscateForProvider<T>(value: T): T {
if (!this.#obfuscator?.hasSecrets()) return value;
return this.#obfuscator.obfuscateObject(value);
}
#obfuscateTextForProvider(text: string | undefined): string | undefined {
if (!text || !this.#obfuscator?.hasSecrets()) return text;
return this.#obfuscator.obfuscate(text);
}
#obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation {
if (!this.#obfuscator?.hasSecrets()) return preparation;
if (!preparation.previousSummary && !preparation.previousPreserveData) return preparation;
return {
...preparation,
previousSummary: preparation.previousSummary
? this.#obfuscator.obfuscate(preparation.previousSummary)
: preparation.previousSummary,
previousPreserveData: preparation.previousPreserveData
? this.#obfuscator.obfuscateObject(preparation.previousPreserveData)
: preparation.previousPreserveData,
};
}
#deobfuscateFromProvider(text: string): string {
if (!this.#obfuscator?.hasSecrets()) return text;
return this.#obfuscator.deobfuscate(text);
}
#convertToLlmForSideRequest(messages: AgentMessage[]): Message[] {
return this.#obfuscateForProvider(convertToLlm(messages));
}
/** Convert session messages using the same pre-LLM pipeline as the active session. */
async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]> {
const transformedMessages = await this.#transformContext(messages, signal);
@@ -6185,10 +6223,10 @@ export class AgentSession {
customInstructions,
compactionAbortController.signal,
{
promptOverride: compactionPrep.hookPrompt,
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
convertToLlm,
promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
},
);
summary = result.summary;
@@ -6371,15 +6409,15 @@ export class AgentSession {
throw new Error(`No API key for ${model.provider}`);
}
const handoffText = await generateHandoff(
const rawHandoffText = await generateHandoff(
this.agent.state.messages,
model,
apiKey,
{
systemPrompt: this.#baseSystemPrompt,
tools: this.agent.state.tools,
customInstructions,
convertToLlm,
systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools),
customInstructions: this.#obfuscateTextForProvider(customInstructions),
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
initiatorOverride: "agent",
metadata: this.agent.metadataForProvider(model.provider),
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
@@ -6391,6 +6429,7 @@ export class AgentSession {
},
handoffSignal,
);
const handoffText = this.#deobfuscateFromProvider(rawHandoffText);
if (handoffSignal.aborted) {
throw new Error("Handoff cancelled");
@@ -7352,17 +7391,24 @@ export class AgentSession {
if (!apiKey) continue;
try {
return await compact(preparation, candidate, apiKey, customInstructions, signal, {
...options,
metadata: this.agent.metadataForProvider(candidate.provider),
convertToLlm,
telemetry,
// Honor the user's /model thinking selection (incl. `off`) on
// the manual `/compact` path. Clamped per-model inside compact()
// via resolveCompactionEffort so unsupported-effort models
// (xai-oauth/grok-build) don't trip requireSupportedEffort.
thinkingLevel: this.thinkingLevel,
});
return await compact(
this.#obfuscatePreparationForProvider(preparation),
candidate,
apiKey,
this.#obfuscateTextForProvider(customInstructions),
signal,
{
...options,
metadata: this.agent.metadataForProvider(candidate.provider),
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
telemetry,
// Honor the user's /model thinking selection (incl. `off`) on
// the manual `/compact` path. Clamped per-model inside compact()
// via resolveCompactionEffort so unsupported-effort models
// (xai-oauth/grok-build) don't trip requireSupportedEffort.
thinkingLevel: this.thinkingLevel,
},
);
} catch (error) {
if (!this.#isCompactionAuthFailure(error)) {
throw error;
@@ -7642,20 +7688,27 @@ export class AgentSession {
let attempt = 0;
while (true) {
try {
compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
promptOverride: compactionPrep.hookPrompt,
extraContext: compactionPrep.hookContext,
remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
metadata: this.agent.metadataForProvider(candidate.provider),
initiatorOverride: "agent",
convertToLlm,
telemetry,
// Honor the user's /model thinking selection on the
// auto-compaction path — the most-fired compaction
// site. Clamped per-model inside compact() via
// resolveCompactionEffort.
thinkingLevel: this.thinkingLevel,
});
compactResult = await compact(
this.#obfuscatePreparationForProvider(preparation),
candidate,
apiKey,
undefined,
autoCompactionSignal,
{
promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
metadata: this.agent.metadataForProvider(candidate.provider),
initiatorOverride: "agent",
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
telemetry,
// Honor the user's /model thinking selection on the
// auto-compaction path — the most-fired compaction
// site. Clamped per-model inside compact() via
// resolveCompactionEffort.
thinkingLevel: this.thinkingLevel,
},
);
break;
} catch (error) {
if (autoCompactionSignal.aborted) {
@@ -9000,7 +9053,7 @@ export class AgentSession {
let replyText = "";
let assistantMessage: AssistantMessage | undefined;
const stream = streamSimple(model, context, options);
const stream = streamSimple(model, obfuscateProviderContext(this.#obfuscator, context), options);
for await (const event of stream) {
if (event.type === "text_delta") {
replyText += event.delta;
@@ -9519,10 +9572,10 @@ export class AgentSession {
model,
apiKey,
signal: this.#branchSummaryAbortController.signal,
customInstructions: options.customInstructions,
customInstructions: this.#obfuscateTextForProvider(options.customInstructions),
reserveTokens: branchSummarySettings.reserveTokens,
metadata: this.agent.metadataForProvider(model.provider),
convertToLlm,
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
});
this.#branchSummaryAbortController = undefined;