From 2a9010889331c1aafadfa108afdf38ead75de4ab Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 22:32:36 +0000 Subject: [PATCH 1/5] fix(coding-agent): hid secrets in provider requests Redacted configured secrets across provider-facing system prompts, tool definitions, developer reminders, and assistant tool-call payloads before LLM requests. Fixes #2146 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/sdk.ts | 8 ++- .../coding-agent/src/secrets/obfuscator.ts | 28 +++----- .../coding-agent/src/session/agent-session.ts | 35 +++++++--- .../test/secrets-obfuscator.test.ts | 65 ++++++++++++++++++- 5 files changed, 109 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee70e90fe..0d57a276c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -153,6 +153,10 @@ - Fixed MCP OAuth fallback rendering to show a short terminal hyperlink and keep the raw authorization URL on one unwrapped copy line ([#2121](https://github.com/can1357/oh-my-pi/issues/2121)). - Fixed `omp` startup blocking 25–30 s on a single unresponsive MCP server when no cached tools were available for it. `MCPManager.connectServers` used to fall through to an unbounded `Promise.allSettled` over every still-pending server without a cached tool list, so one server stuck waiting on the per-request MCP timeout (`OMP_MCP_TIMEOUT_MS`, default 30 000 ms) gated the entire UI ready signal. Pending-without-cache servers are now left in flight: their tools surface via the existing background `#onToolsChanged` → `refreshMCPTools` path the moment the connect completes, and failures continue to log through the background catch handler ([#2100](https://github.com/can1357/oh-my-pi/issues/2100)). +### Fixed + +- Fixed hide-secrets redaction so configured secrets are scrubbed from provider-facing system prompts, tool definitions, developer/system-reminder messages, and assistant tool-call arguments before model requests ([#2146](https://github.com/can1357/oh-my-pi/issues/2146)). + ## [15.10.6] - 2026-06-08 ### Added diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index de869ee2f..b46c01a61 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -9,6 +9,7 @@ import { type ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import { + type Context, type CredentialDisabledEvent, type Message, type Model, @@ -2138,6 +2139,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!obfuscator?.hasSecrets()) return converted; return obfuscateMessages(obfuscator, converted); }; + const obfuscateProviderContext = (context: Context): Context => { + if (!obfuscator?.hasSecrets()) return context; + return obfuscator.obfuscateObject(context); + }; + const transformContext = async (messages: AgentMessage[], _signal?: AbortSignal) => { const withContext = await extensionRunner.emitContext(messages); return wrapSteeringForModel(withContext); @@ -2215,7 +2221,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, obfuscateProviderContext(context), { ...streamOptions, openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant, }); diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index d14127b6d..72c7e8425 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -1,4 +1,4 @@ -import type { Message, TextContent } from "@oh-my-pi/pi-ai"; +import type { Message } from "@oh-my-pi/pi-ai"; import type { SessionContext } from "../session/session-manager"; import { compileSecretRegex } from "./regex"; @@ -184,6 +184,12 @@ export class SecretObfuscator { return deepWalkStrings(obj, s => this.deobfuscate(s)); } + /** Deep-walk an object, obfuscating all string values. */ + obfuscateObject(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 +217,9 @@ 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; - - 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; - }); + return obfuscator.obfuscateObject(messages); } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a86fce63f..7f9be3cb0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -3999,6 +3999,20 @@ export class AgentSession { return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator); } + #obfuscateForProvider(value: T): T { + if (!this.#obfuscator?.hasSecrets()) return value; + return this.#obfuscator.obfuscateObject(value); + } + + #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 { const transformedMessages = await this.#transformContext(messages, signal); @@ -6179,8 +6193,8 @@ export class AgentSession { { promptOverride: compactionPrep.hookPrompt, extraContext: compactionPrep.hookContext, - remoteInstructions: this.#baseSystemPrompt.join("\n\n"), - convertToLlm, + remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")), + convertToLlm: messages => this.#convertToLlmForSideRequest(messages), }, ); summary = result.summary; @@ -6363,15 +6377,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, + systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt), + tools: this.#obfuscateForProvider(this.agent.state.tools), customInstructions, - convertToLlm, + convertToLlm: messages => this.#convertToLlmForSideRequest(messages), initiatorOverride: "agent", metadata: this.agent.metadataForProvider(model.provider), telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId), @@ -6383,6 +6397,7 @@ export class AgentSession { }, handoffSignal, ); + const handoffText = this.#deobfuscateFromProvider(rawHandoffText); if (handoffSignal.aborted) { throw new Error("Handoff cancelled"); @@ -7347,7 +7362,7 @@ export class AgentSession { return await compact(preparation, candidate, apiKey, customInstructions, signal, { ...options, metadata: this.agent.metadataForProvider(candidate.provider), - convertToLlm, + 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() @@ -7637,10 +7652,10 @@ export class AgentSession { compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, { promptOverride: compactionPrep.hookPrompt, extraContext: compactionPrep.hookContext, - remoteInstructions: this.#baseSystemPrompt.join("\n\n"), + remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")), metadata: this.agent.metadataForProvider(candidate.provider), initiatorOverride: "agent", - convertToLlm, + convertToLlm: messages => this.#convertToLlmForSideRequest(messages), telemetry, // Honor the user's /model thinking selection on the // auto-compaction path — the most-fired compaction @@ -9514,7 +9529,7 @@ export class AgentSession { customInstructions: 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; diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index c1b0f6f74..96d07c760 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -3,7 +3,8 @@ */ import { describe, expect, it } from "bun:test"; -import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator"; +import type { Message } from "@oh-my-pi/pi-ai"; +import { obfuscateMessages, SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator"; import { compileSecretRegex } from "@oh-my-pi/pi-coding-agent/secrets/regex"; describe("compileSecretRegex", () => { @@ -58,4 +59,66 @@ describe("SecretObfuscator regex behavior", () => { status: original.status, }); }); + + it("obfuscates nested provider request payloads", () => { + const secret = "SUPER_SECRET_TOKEN_12345"; + const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); + const payload = { + systemPrompt: [`workspace contains ${secret}`], + tools: [ + { + name: "handoff", + description: `preserve ${secret}`, + parameters: { + type: "object", + properties: { note: { type: "string", description: `write ${secret}` } }, + }, + }, + ], + }; + + const obfuscated = obfuscator.obfuscateObject(payload); + const serialized = JSON.stringify(obfuscated); + + expect(serialized).not.toContain(secret); + expect(obfuscator.deobfuscateObject(obfuscated)).toEqual(payload); + }); + + it("obfuscates system reminders and assistant tool calls in messages", () => { + const secret = "SUPER_SECRET_TOKEN_12345"; + const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); + const messages: Message[] = [ + { role: "developer", content: `system reminder ${secret}`, timestamp: 1 }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "handoff", + arguments: { note: secret }, + intent: `handoff ${secret}`, + }, + ], + api: "test", + provider: "test", + model: "test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: 1, + }, + ]; + + const obfuscated = obfuscateMessages(obfuscator, messages); + + expect(JSON.stringify(obfuscated)).not.toContain(secret); + expect(obfuscator.deobfuscateObject(obfuscated)).toEqual(messages); + }); }); From aa4cd0ab2ddc824f4a6e9fda89c59e2929efbf72 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 22:43:25 +0000 Subject: [PATCH 2/5] fix(coding-agent): preserved tool schemas while redacting Converted provider-facing tool parameters to wire JSON Schema before redaction so live Zod instances are not deep-cloned into plain objects. Fixes #2146 --- packages/agent/src/compaction/compaction.ts | 5 ++- packages/coding-agent/src/sdk.ts | 8 +--- packages/coding-agent/src/secrets/index.ts | 9 ++++- .../coding-agent/src/secrets/obfuscator.ts | 35 ++++++++++++++++- .../coding-agent/src/session/agent-session.ts | 4 +- .../test/secrets-obfuscator.test.ts | 39 +++++++++++++++++-- 6 files changed, 83 insertions(+), 17 deletions(-) diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 881e34760..53d0ad464 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -12,6 +12,7 @@ import { type Message, type MessageAttribution, type Model, + type Tool, type Usage, } from "@oh-my-pi/pi-ai"; import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking"; @@ -19,7 +20,7 @@ import { countTokens } from "@oh-my-pi/pi-natives"; import { logger, prompt } from "@oh-my-pi/pi-utils"; import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry"; import { ThinkingLevel } from "../thinking"; -import type { AgentMessage, AgentTool } from "../types"; +import type { AgentMessage } from "../types"; import type { CompactionEntry, SessionEntry } from "./entries"; import { type ConvertToLlm, convertToLlm, createBranchSummaryMessage, createCustomMessage } from "./messages"; import { @@ -690,7 +691,7 @@ export interface HandoffOptions { /** Live agent system prompt — passed verbatim so providers hit the cached prefix. */ systemPrompt: string[]; /** Live agent tool list — same purpose. Forced to `toolChoice: "none"`. */ - tools?: AgentTool[]; + tools?: Tool[]; customInstructions?: string; convertToLlm?: ConvertToLlm; initiatorOverride?: MessageAttribution; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index b46c01a61..3f9855c9b 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -9,7 +9,6 @@ import { type ThinkingLevel, } from "@oh-my-pi/pi-agent-core"; import { - type Context, type CredentialDisabledEvent, type Message, type Model, @@ -100,6 +99,7 @@ import { deobfuscateSessionContext, loadSecrets, obfuscateMessages, + obfuscateProviderContext, SecretObfuscator, } from "./secrets"; import { AgentSession } from "./session/agent-session"; @@ -2139,10 +2139,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!obfuscator?.hasSecrets()) return converted; return obfuscateMessages(obfuscator, converted); }; - const obfuscateProviderContext = (context: Context): Context => { - if (!obfuscator?.hasSecrets()) return context; - return obfuscator.obfuscateObject(context); - }; const transformContext = async (messages: AgentMessage[], _signal?: AbortSignal) => { const withContext = await extensionRunner.emitContext(messages); @@ -2221,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, obfuscateProviderContext(context), { + return streamSimple(streamModel, obfuscator ? obfuscateProviderContext(obfuscator, context) : context, { ...streamOptions, openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant, }); diff --git a/packages/coding-agent/src/secrets/index.ts b/packages/coding-agent/src/secrets/index.ts index 420150c25..550557658 100644 --- a/packages/coding-agent/src/secrets/index.ts +++ b/packages/coding-agent/src/secrets/index.ts @@ -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. diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index 72c7e8425..ef0845f79 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -1,4 +1,5 @@ -import type { Message } 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"; @@ -222,6 +223,31 @@ export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Messag return obfuscator.obfuscateObject(messages); } +/** 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), + }; +} + +/** 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, + })); +} + // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════ @@ -252,7 +278,7 @@ function deepWalkStrings(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 = {}; for (const key of Object.keys(obj)) { @@ -265,3 +291,8 @@ function deepWalkStrings(obj: T, transform: (s: string) => string): T { } return obj; } + +function isPlainRecord(obj: object): obj is Record { + const prototype = Object.getPrototypeOf(obj); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 7f9be3cb0..73c7c9069 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -184,7 +184,7 @@ 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, obfuscateProviderTools, type SecretObfuscator } from "../secrets/obfuscator"; import { invalidateHostMetadata } from "../ssh/connection-manager"; import { AUTO_THINKING, @@ -6383,7 +6383,7 @@ export class AgentSession { apiKey, { systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt), - tools: this.#obfuscateForProvider(this.agent.state.tools), + tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools), customInstructions, convertToLlm: messages => this.#convertToLlmForSideRequest(messages), initiatorOverride: "agent", diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 96d07c760..48cf89e4e 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -3,9 +3,14 @@ */ import { describe, expect, it } from "bun:test"; -import type { Message } from "@oh-my-pi/pi-ai"; -import { obfuscateMessages, SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator"; +import type { Context, Message } from "@oh-my-pi/pi-ai"; +import { + obfuscateMessages, + obfuscateProviderContext, + SecretObfuscator, +} from "@oh-my-pi/pi-coding-agent/secrets/obfuscator"; import { compileSecretRegex } from "@oh-my-pi/pi-coding-agent/secrets/regex"; +import { z } from "zod"; describe("compileSecretRegex", () => { it("adds global flag when not provided", () => { @@ -65,6 +70,7 @@ describe("SecretObfuscator regex behavior", () => { const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); const payload = { systemPrompt: [`workspace contains ${secret}`], + messages: [], tools: [ { name: "handoff", @@ -77,11 +83,36 @@ describe("SecretObfuscator regex behavior", () => { ], }; - const obfuscated = obfuscator.obfuscateObject(payload); + const obfuscated = obfuscateProviderContext(obfuscator, payload); const serialized = JSON.stringify(obfuscated); expect(serialized).not.toContain(secret); - expect(obfuscator.deobfuscateObject(obfuscated)).toEqual(payload); + expect(obfuscator.deobfuscateObject(obfuscated).tools?.[0]?.description).toEqual(payload.tools[0]?.description); + }); + + it("redacts Zod tool schemas without cloning the live schema instance", () => { + const secret = "SUPER_SECRET_TOKEN_12345"; + const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); + const parameters = z.object({ + note: z.string().describe(`write ${secret}`), + }); + const context: Context = { + messages: [], + tools: [ + { + name: "extension_tool", + description: `preserve ${secret}`, + parameters, + }, + ], + }; + + const obfuscated = obfuscateProviderContext(obfuscator, context); + + expect(obfuscator.obfuscateObject(parameters)).toBe(parameters); + expect(context.tools?.[0]?.parameters).toBe(parameters); + expect(obfuscated.tools?.[0]?.parameters).not.toBe(parameters); + expect(JSON.stringify(obfuscated)).not.toContain(secret); }); it("obfuscates system reminders and assistant tool calls in messages", () => { From 5517115f1aafa402ec5af149059a1bb9fcd9a2b6 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 22:50:01 +0000 Subject: [PATCH 3/5] fix(coding-agent): hid handoff instructions Obfuscated custom instructions before handoff and related side-request provider calls, then deobfuscated generated handoff output before persistence. Fixes #2146 --- .../coding-agent/src/session/agent-session.ts | 38 ++++++++++++------- .../test/agent-session-handoff.test.ts | 23 +++++++++++ 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 73c7c9069..0380c218f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4004,6 +4004,11 @@ export class AgentSession { return this.#obfuscator.obfuscateObject(value); } + #obfuscateTextForProvider(text: string | undefined): string | undefined { + if (!text || !this.#obfuscator?.hasSecrets()) return text; + return this.#obfuscator.obfuscate(text); + } + #deobfuscateFromProvider(text: string): string { if (!this.#obfuscator?.hasSecrets()) return text; return this.#obfuscator.deobfuscate(text); @@ -6384,7 +6389,7 @@ export class AgentSession { { systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt), tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools), - customInstructions, + customInstructions: this.#obfuscateTextForProvider(customInstructions), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), initiatorOverride: "agent", metadata: this.agent.metadataForProvider(model.provider), @@ -7359,17 +7364,24 @@ export class AgentSession { if (!apiKey) continue; try { - return await compact(preparation, candidate, apiKey, 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, - }); + return await compact( + 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; @@ -9526,7 +9538,7 @@ 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: messages => this.#convertToLlmForSideRequest(messages), diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index c095c9806..41d14055e 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -8,11 +8,14 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ExtensionRunner, loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; +import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +const HANDOFF_SECRET = "HANDOFF_SECRET_TOKEN_12345"; + describe("AgentSession handoff", () => { // Immutable across the whole file: the model registry's synchronous bundled-model // load dominates per-test setup (~100ms each), and the auth store + bundled model @@ -27,6 +30,7 @@ describe("AgentSession handoff", () => { let session: AgentSession; let sessionManager: SessionManager; let events: AgentSessionEvent[]; + let obfuscator: SecretObfuscator; /** Poll `predicate` until it holds (returns as soon as the state is reached) or the * deadline elapses. Replaces blind settle sleeps for tests with a positive signal. */ @@ -74,6 +78,7 @@ describe("AgentSession handoff", () => { tempDir = TempDir.createSync("@pi-handoff-"); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); events = []; + obfuscator = new SecretObfuscator([{ type: "plain", content: HANDOFF_SECRET }]); const agent = new Agent({ initialState: { @@ -92,6 +97,7 @@ describe("AgentSession handoff", () => { "compaction.autoContinue": false, }), modelRegistry, + obfuscator, }); session.subscribe(event => { @@ -141,11 +147,28 @@ describe("AgentSession handoff", () => { expect(generateHandoffSpy).toHaveBeenCalledTimes(1); expect(result?.document).toBe(handoffText); + expect(events.filter(event => event.type === "auto_compaction_start")).toHaveLength(0); expect(events.filter(event => event.type === "auto_compaction_end")).toHaveLength(0); expect(sessionManager.getEntries().filter(entry => entry.type === "compaction")).toHaveLength(0); }); + it("obfuscates custom instructions before generating a handoff", async () => { + const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); + const generateHandoffSpy = vi + .spyOn(compactionModule, "generateHandoff") + .mockResolvedValue(`## Goal\nKeep ${placeholder}`); + + const result = await session.handoff(`preserve ${HANDOFF_SECRET}`); + + const handoffCall = generateHandoffSpy.mock.calls[0]; + if (!handoffCall) throw new Error("Expected generateHandoff call"); + expect(handoffCall[3].customInstructions).toBe(`preserve ${placeholder}`); + expect(handoffCall[3].customInstructions).not.toContain(HANDOFF_SECRET); + expect(result?.document).toContain(HANDOFF_SECRET); + expect(result?.document).not.toContain(placeholder); + }); + it("runs context maintenance before sending an oversized pending prompt", async () => { session.settings.set("compaction.thresholdTokens", 50); session.settings.set("compaction.keepRecentTokens", 1); From d39e4b0d38e39b3d344099f15849d3827d315d3c Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 8 Jun 2026 22:58:20 +0000 Subject: [PATCH 4/5] fix(coding-agent): hid previous compaction summary Obfuscated preparation.previousSummary, hook prompt/context, before forwarding to compact() so prior pi- or extension-supplied summaries do not leak verbatim secrets on subsequent compactions. Fixes #2146 --- .../coding-agent/src/session/agent-session.ts | 46 ++++++++++++------- .../test/agent-session-handoff.test.ts | 35 ++++++++++++++ 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 0380c218f..d72fceceb 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -4009,6 +4009,11 @@ export class AgentSession { return this.#obfuscator.obfuscate(text); } + #obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation { + if (!preparation.previousSummary || !this.#obfuscator?.hasSecrets()) return preparation; + return { ...preparation, previousSummary: this.#obfuscator.obfuscate(preparation.previousSummary) }; + } + #deobfuscateFromProvider(text: string): string { if (!this.#obfuscator?.hasSecrets()) return text; return this.#obfuscator.deobfuscate(text); @@ -6196,8 +6201,8 @@ export class AgentSession { customInstructions, compactionAbortController.signal, { - promptOverride: compactionPrep.hookPrompt, - extraContext: compactionPrep.hookContext, + promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt), + extraContext: this.#obfuscateForProvider(compactionPrep.hookContext), remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), }, @@ -7365,7 +7370,7 @@ export class AgentSession { try { return await compact( - preparation, + this.#obfuscatePreparationForProvider(preparation), candidate, apiKey, this.#obfuscateTextForProvider(customInstructions), @@ -7661,20 +7666,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.#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, - }); + 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) { diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 41d14055e..55382a150 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -169,6 +169,41 @@ describe("AgentSession handoff", () => { expect(result?.document).not.toContain(placeholder); }); + it("obfuscates previous compaction summary before forwarding to compact()", async () => { + const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); + const entries = sessionManager.getBranch(); + const lastEntryId = entries[entries.length - 1]?.id; + if (!lastEntryId) throw new Error("Expected a seeded entry id"); + const fixedPreparation: compactionModule.CompactionPreparation = { + firstKeptEntryId: lastEntryId, + messagesToSummarize: [{ role: "user", content: [{ type: "text", text: "old" }], timestamp: 1 }], + turnPrefixMessages: [], + recentMessages: [], + isSplitTurn: false, + tokensBefore: 100, + previousSummary: `summary ${HANDOFF_SECRET}`, + previousPreserveData: undefined, + fileOps: { read: new Set(), written: new Set(), edited: new Set() }, + settings: compactionModule.DEFAULT_COMPACTION_SETTINGS, + }; + vi.spyOn(compactionModule, "prepareCompaction").mockReturnValue(fixedPreparation); + + const compactSpy = vi.spyOn(compactionModule, "compact").mockResolvedValue({ + summary: "new summary", + shortSummary: undefined, + firstKeptEntryId: lastEntryId, + tokensBefore: 100, + details: {}, + }); + + await session.compact(); + + const call = compactSpy.mock.calls[0]; + if (!call) throw new Error("Expected compact call"); + expect(call[0].previousSummary).toBe(`summary ${placeholder}`); + expect(call[0].previousSummary).not.toContain(HANDOFF_SECRET); + }); + it("runs context maintenance before sending an oversized pending prompt", async () => { session.settings.set("compaction.thresholdTokens", 50); session.settings.set("compaction.keepRecentTokens", 1); From ec81b927fa72a60fb6b33ed033c775194d4dc9d6 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 10 Jun 2026 08:04:37 +0200 Subject: [PATCH 5/5] fix(coding-agent): redact ephemeral side-channel context and preserved remote compaction history runEphemeralTurn (IRC//btw) called streamSimple directly with the raw system prompt, bypassing the SDK-level obfuscateProviderContext wrapper; and #obfuscatePreparationForProvider skipped previousPreserveData, so a pre-fix openaiRemoteCompaction.replacementHistory could resend raw secrets on the next remote compaction. Addresses review feedback on #2147. --- .../coding-agent/src/session/agent-session.ts | 22 ++++++-- .../test/agent-session-handoff.test.ts | 11 +++- .../agent-session-message-pipeline.test.ts | 54 +++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index d72fceceb..4986a0466 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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, obfuscateProviderTools, type SecretObfuscator } from "../secrets/obfuscator"; +import { + deobfuscateSessionContext, + obfuscateProviderContext, + obfuscateProviderTools, + type SecretObfuscator, +} from "../secrets/obfuscator"; import { invalidateHostMetadata } from "../ssh/connection-manager"; import { AUTO_THINKING, @@ -4010,8 +4015,17 @@ export class AgentSession { } #obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation { - if (!preparation.previousSummary || !this.#obfuscator?.hasSecrets()) return preparation; - return { ...preparation, previousSummary: this.#obfuscator.obfuscate(preparation.previousSummary) }; + 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 { @@ -9031,7 +9045,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; diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 55382a150..b0fe1da7d 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -169,7 +169,7 @@ describe("AgentSession handoff", () => { expect(result?.document).not.toContain(placeholder); }); - it("obfuscates previous compaction summary before forwarding to compact()", async () => { + it("obfuscates previous compaction summary and preserve data before forwarding to compact()", async () => { const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const entries = sessionManager.getBranch(); const lastEntryId = entries[entries.length - 1]?.id; @@ -182,7 +182,11 @@ describe("AgentSession handoff", () => { isSplitTurn: false, tokensBefore: 100, previousSummary: `summary ${HANDOFF_SECRET}`, - previousPreserveData: undefined, + previousPreserveData: { + openaiRemoteCompaction: { + replacementHistory: [{ role: "user", content: `history ${HANDOFF_SECRET}` }], + }, + }, fileOps: { read: new Set(), written: new Set(), edited: new Set() }, settings: compactionModule.DEFAULT_COMPACTION_SETTINGS, }; @@ -202,6 +206,9 @@ describe("AgentSession handoff", () => { if (!call) throw new Error("Expected compact call"); expect(call[0].previousSummary).toBe(`summary ${placeholder}`); expect(call[0].previousSummary).not.toContain(HANDOFF_SECRET); + const preserveData = JSON.stringify(call[0].previousPreserveData); + expect(preserveData).toContain(placeholder); + expect(preserveData).not.toContain(HANDOFF_SECRET); }); it("runs context maintenance before sending an oversized pending prompt", async () => { diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index 3d54a4580..a6de2b096 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -3,6 +3,7 @@ import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; import { type Api, clearCustomApis, + type Context, type Message, type Model, type ModelSpec, @@ -13,6 +14,7 @@ import { import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { convertToLlm, wrapSteeringForModel } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -273,6 +275,58 @@ describe("AgentSession message pipeline", () => { expect(capturedOptions?.openrouterVariant).toBe("nitro"); }); + it("obfuscates the system prompt and messages on ephemeral side-channel requests", async () => { + const api = "test-ephemeral-secret-redaction"; + const secret = "EPHEMERAL_SECRET_TOKEN_12345"; + let capturedContext: Context | undefined; + registerCustomApi(api, (_model, context, _options) => { + capturedContext = context; + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + const message = createAssistantMessage("Answer"); + stream.push({ type: "text_delta", contentIndex: 0, delta: "Answer", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }); + + const model = buildModel({ + id: "side-model-secrets", + name: "Side Model Secrets", + api, + provider: "test-provider", + baseUrl: "", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 1024, + } as ModelSpec) as Model; + const session = new AgentSession({ + agent: new Agent({ + initialState: { + model, + systemPrompt: [`system prompt with ${secret}`], + messages: [], + tools: [], + }, + }), + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false }), + modelRegistry: { + getApiKey: vi.fn(async () => "key"), + } as never, + obfuscator: new SecretObfuscator([{ type: "plain", content: secret }]), + }); + sessions.push(session); + + const result = await session.runEphemeralTurn({ promptText: `question about ${secret}` }); + + expect(result.replyText).toBe("Answer"); + expect(capturedContext).toBeDefined(); + expect(JSON.stringify(capturedContext)).not.toContain(secret); + }); + it("records raw SSE diagnostics into the session buffer before request hooks", async () => { const requestOnSseEvent = vi.fn(); const session = new AgentSession({