diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c90bf2d3f..f42443ab9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,16 @@ # Changelog ## [Unreleased] +### Changed + +- Refined secret obfuscation to only target message roles and fields containing operator secrets +- Restricted obfuscator to ignore secrets and regex matches shorter than 8 characters +- Optimized obfuscation to skip static system prompts and tool schemas in provider contexts +- Ensured image data bytes are never modified to prevent corrupted data URL payloads ### Fixed +- Fixed secret obfuscation corrupting Codex image reads (and other provider requests) with `Invalid 'input[N].content[].image_url'. Expected a base64-encoded data URL ... but got an invalid base64-encoded value`. The obfuscator deep-walked every string in the outbound request — including inline image base64 and opaque provider replay/signature fields — so a configured secret that happened to be a substring of the base64 (or of an ordinary word like `response`) injected `#HASH#` placeholders mid-payload. Obfuscation is now opt-in and fully typed: only user messages, tool-result messages, and user-attributed developer messages (`@file` mentions) are redacted; system prompts and tool schemas pass through untouched; image bytes and signature/encrypted-reasoning fields are never rewritten; and tool-call arguments are the only JSON walked. Configured plain secrets and regex matches shorter than 8 characters are now ignored to stop false matches on short words. - Fixed RPC/ACP startup clobbering explicit caller/project/global configuration for `task.isolation.{mode,merge,commits}`, `task.eager`, `task.batch`, `task.maxConcurrency`, `task.maxRecursionDepth`, `task.disabledAgents`, `task.agentModelOverrides`, `memory.backend`, `memories.enabled`, `advisor.{enabled,subagents,syncBacklog,immuneTurns}`, plus the RPC-only `async.{enabled,maxJobs}` and `bash.autoBackground.{enabled,thresholdMs}`. `applyDefaultSettingOverrides` re-asserted the schema default as a runtime override after settings load, regressing the `isConfigured()` guard added for #2598 and ignoring every explicit value the embedder, project, `--config` overlay, or global config had set. The guard is restored, so the host default now only fills holes ([#3207](https://github.com/can1357/oh-my-pi/issues/3207)). ## [16.1.11] - 2026-06-21 @@ -12282,4 +12289,4 @@ Initial public release. ## [0.7.6] - 2025-11-13 -Previous releases did not maintain a changelog. +Previous releases did not maintain a changelog. \ No newline at end of file diff --git a/packages/coding-agent/src/export/share.ts b/packages/coding-agent/src/export/share.ts index 5d8e8fd66..9dd077752 100644 --- a/packages/coding-agent/src/export/share.ts +++ b/packages/coding-agent/src/export/share.ts @@ -19,13 +19,16 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentState } from "@oh-my-pi/pi-agent-core"; +import type { AgentMessage, AgentState } from "@oh-my-pi/pi-agent-core"; +import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { $which, logger } from "@oh-my-pi/pi-utils"; import { DEFAULT_SHARE_URL } from "@oh-my-pi/pi-wire"; import { $ } from "bun"; -import type { SecretObfuscator } from "../secrets/obfuscator"; +import { obfuscateToolArguments, type SecretObfuscator } from "../secrets/obfuscator"; +import type { SessionEntry, SessionHeader } from "../session/session-entries"; import type { SessionManager } from "../session/session-manager"; -import { buildSessionData, type SessionData } from "./html"; +import type { OutputMeta } from "../tools/output-meta"; +import { buildSessionData, type SessionData, type SubSession } from "./html"; export { DEFAULT_SHARE_URL }; @@ -53,10 +56,15 @@ export interface ShareSessionOptions { /** Agent state for system prompt + tool descriptions in the snapshot. */ state?: AgentState; /** - * Redacts the snapshot before sealing: deep-walks every string (entries, - * header, system prompt, tool descriptions) through the obfuscator, so - * secrets that landed in persisted entries (tool outputs reading .env, - * etc.) never leave the machine. Pass undefined to skip. + * Redacts the snapshot before sealing via a typed, per-field walk over the + * session (header title/cwd, system prompt, tool descriptions, entry summaries, + * labels, and message text — including tool-result output and `@file` mentions), + * so secrets that landed in persisted entries (tool outputs reading .env, etc.) + * never leave the machine. Inline image bytes are preserved (size-trimmed + * separately); opaque provider-replay blobs (`providerPayload`, + * `redactedThinking`, `compaction.preserveData`) and untyped extension payloads + * (`details`/`data`/`outputSchema`) are dropped rather than walked. Pass + * undefined to skip redaction entirely. */ obfuscator?: SecretObfuscator; } @@ -75,7 +83,189 @@ export interface ShareSessionResult { /** Build the snapshot that gets sealed and uploaded, redacted when an obfuscator is provided. */ export function buildShareSnapshot(sm: SessionManager, options?: ShareSessionOptions): SessionData { const data = buildSessionData(sm, options?.state); - return options?.obfuscator?.hasSecrets() ? options.obfuscator.obfuscateObject(data) : data; + return options?.obfuscator?.hasSecrets() ? redactSessionDataForShare(options.obfuscator, data) : data; +} + +/** + * Redact secrets from a share snapshot. A share blob leaves the machine, so + * every text-bearing field is rewritten through the obfuscator. The walk is + * typed end-to-end (no generic object traversal): inline image bytes are left + * intact (size-trimmed later by {@link stripImagePayloads}) and opaque, + * untyped payloads we cannot redact field-by-field (`compaction.preserveData`, + * extension `details`/`data`, `mode_change.data`, structured output schemas) + * are dropped so they cannot leak. + */ +function redactShareHeader(o: SecretObfuscator, header: SessionHeader | null): SessionHeader | null { + if (!header) return header; + return { + ...header, + title: header.title === undefined ? undefined : o.obfuscate(header.title), + cwd: o.obfuscate(header.cwd), + }; +} + +function redactSessionDataForShare(o: SecretObfuscator, data: SessionData): SessionData { + return { + ...data, + header: redactShareHeader(o, data.header), + systemPrompt: data.systemPrompt === undefined ? undefined : o.obfuscate(data.systemPrompt), + tools: data.tools?.map(tool => ({ ...tool, description: o.obfuscate(tool.description) })), + entries: data.entries.map(entry => redactShareEntry(o, entry)), + subSessions: data.subSessions + ? Object.fromEntries( + Object.entries(data.subSessions).map(([key, sub]) => [key, redactShareSubSession(o, sub)]), + ) + : data.subSessions, + }; +} + +function redactShareSubSession(o: SecretObfuscator, sub: SubSession): SubSession { + return { + ...sub, + header: redactShareHeader(o, sub.header), + entries: sub.entries.map(entry => redactShareEntry(o, entry)), + }; +} + +function redactShareEntry(o: SecretObfuscator, entry: SessionEntry): SessionEntry { + switch (entry.type) { + case "message": + return { ...entry, message: redactShareMessage(o, entry.message) }; + case "compaction": + return { + ...entry, + summary: o.obfuscate(entry.summary), + shortSummary: entry.shortSummary === undefined ? undefined : o.obfuscate(entry.shortSummary), + details: undefined, + preserveData: undefined, + }; + case "branch_summary": + return { ...entry, summary: o.obfuscate(entry.summary), details: undefined }; + case "custom_message": + return { ...entry, content: redactShareContent(o, entry.content), details: undefined }; + case "custom": + return { ...entry, data: undefined }; + case "mode_change": + return { ...entry, data: undefined }; + case "session_init": + return { + ...entry, + systemPrompt: o.obfuscate(entry.systemPrompt), + task: o.obfuscate(entry.task), + outputSchema: undefined, + }; + case "label": + return { ...entry, label: entry.label === undefined ? undefined : o.obfuscate(entry.label) }; + default: + return entry; + } +} + +function redactShareContent( + o: SecretObfuscator, + content: string | (TextContent | ImageContent)[], +): string | (TextContent | ImageContent)[] { + if (typeof content === "string") return o.obfuscate(content); + return content.map(block => (block.type === "text" ? { ...block, text: o.obfuscate(block.text) } : block)); +} + +/** Redact freeform strings in tool output metadata (source path/URL, diagnostics); numeric truncation info is preserved. */ +function redactShareOutputMeta(o: SecretObfuscator, meta: OutputMeta | undefined): OutputMeta | undefined { + if (!meta) return meta; + return { + ...meta, + source: meta.source ? { ...meta.source, value: o.obfuscate(meta.source.value) } : meta.source, + diagnostics: meta.diagnostics + ? { + summary: o.obfuscate(meta.diagnostics.summary), + messages: meta.diagnostics.messages.map(message => o.obfuscate(message)), + } + : meta.diagnostics, + }; +} + +function redactShareMessage(o: SecretObfuscator, message: AgentMessage): AgentMessage { + switch (message.role) { + case "user": + case "developer": + return { + ...message, + providerPayload: undefined, + content: redactShareContent(o, message.content), + } as AgentMessage; + case "custom": + case "hookMessage": + return { ...message, details: undefined, content: redactShareContent(o, message.content) } as AgentMessage; + case "toolResult": + return { + ...message, + details: undefined, + content: redactShareContent(o, message.content) as (TextContent | ImageContent)[], + }; + case "assistant": + // Drop opaque provider-replay state (encrypted reasoning / native history) the viewer + // never reads and we cannot redact field-by-field: `providerPayload` and any + // `redactedThinking` blocks. + return { + ...message, + providerPayload: undefined, + errorMessage: message.errorMessage === undefined ? undefined : o.obfuscate(message.errorMessage), + content: message.content.flatMap((block): AssistantMessage["content"] => { + if (block.type === "redactedThinking") return []; + if (block.type === "text") return [{ ...block, text: o.obfuscate(block.text) }]; + if (block.type === "thinking") return [{ ...block, thinking: o.obfuscate(block.thinking) }]; + if (block.type === "toolCall") { + return [ + { + ...block, + arguments: obfuscateToolArguments(o, block.arguments), + intent: block.intent === undefined ? undefined : o.obfuscate(block.intent), + rawBlock: block.rawBlock === undefined ? undefined : o.obfuscate(block.rawBlock), + }, + ]; + } + return [block]; + }), + }; + case "bashExecution": + return { + ...message, + command: o.obfuscate(message.command), + output: o.obfuscate(message.output), + meta: redactShareOutputMeta(o, message.meta), + }; + case "pythonExecution": + return { + ...message, + code: o.obfuscate(message.code), + output: o.obfuscate(message.output), + meta: redactShareOutputMeta(o, message.meta), + }; + case "branchSummary": + return { ...message, summary: o.obfuscate(message.summary) }; + case "compactionSummary": + return { + ...message, + providerPayload: undefined, + summary: o.obfuscate(message.summary), + shortSummary: message.shortSummary === undefined ? undefined : o.obfuscate(message.shortSummary), + blocks: + message.blocks === undefined + ? undefined + : (redactShareContent(o, message.blocks) as (TextContent | ImageContent)[]), + }; + case "fileMention": + return { + ...message, + files: message.files.map(file => ({ + ...file, + path: o.obfuscate(file.path), + content: o.obfuscate(file.content), + })), + }; + default: + return message; + } } /** Share the session; tries a secret gist first, then the share server. */ diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index a2640858a..a89b020eb 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -97,6 +97,7 @@ import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry"; import { collectEnvSecrets, deobfuscateSessionContext, + deobfuscateToolArguments, loadSecrets, obfuscateMessages, obfuscateProviderContext, @@ -2535,7 +2536,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} result = { ...result, timeout: Math.min(result.timeout, maxTimeout) }; } if (obfuscator?.hasSecrets()) { - result = obfuscator.deobfuscateObject(result); + result = deobfuscateToolArguments(obfuscator, result); } return result; }, diff --git a/packages/coding-agent/src/secrets/index.ts b/packages/coding-agent/src/secrets/index.ts index 550557658..dec5be368 100644 --- a/packages/coding-agent/src/secrets/index.ts +++ b/packages/coding-agent/src/secrets/index.ts @@ -6,9 +6,9 @@ import { compileSecretRegex } from "./regex"; export { deobfuscateSessionContext, + deobfuscateToolArguments, obfuscateMessages, obfuscateProviderContext, - obfuscateProviderTools, type SecretEntry, SecretObfuscator, } from "./obfuscator"; diff --git a/packages/coding-agent/src/secrets/obfuscator.ts b/packages/coding-agent/src/secrets/obfuscator.ts index f34c3b7b1..d271b40bb 100644 --- a/packages/coding-agent/src/secrets/obfuscator.ts +++ b/packages/coding-agent/src/secrets/obfuscator.ts @@ -1,5 +1,5 @@ -import type { Context, Message, Tool } from "@oh-my-pi/pi-ai"; -import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { AssistantMessage, Context, ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai"; import type { SessionContext } from "../session/session-context"; import { compileSecretRegex } from "./regex"; @@ -15,6 +15,9 @@ export interface SecretEntry { flags?: string; } +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue | undefined }; +export type JsonRecord = { [key: string]: JsonValue | undefined }; + // ═══════════════════════════════════════════════════════════════════════════ // Deterministic replacement generation // ═══════════════════════════════════════════════════════════════════════════ @@ -85,26 +88,34 @@ export class SecretObfuscator { constructor(entries: SecretEntry[]) { let index = 0; + let hasRealSec = false; for (const entry of entries) { const mode = entry.mode ?? "obfuscate"; if (entry.type === "plain") { if (mode === "obfuscate") { + if (entry.content.length < 8) { + // Tone down short plain secret obfuscation to avoid false matches on small words like "esp" + continue; + } const placeholder = buildPlaceholder(index); this.#plainMappings.set(entry.content, index); this.#obfuscateMappings.set(index, { secret: entry.content, placeholder }); this.#deobfuscateMap.set(placeholder, entry.content); index++; + hasRealSec = true; } else { // replace mode const replacement = entry.replacement ?? generateDeterministicReplacement(entry.content); this.#replaceMappings.set(entry.content, replacement); + hasRealSec = true; } } 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 }); + hasRealSec = true; } catch { // Invalid regex — skip silently (validation happens at load time) } @@ -112,7 +123,7 @@ export class SecretObfuscator { } this.#nextIndex = index; - this.#hasAny = entries.length > 0; + this.#hasAny = hasRealSec; } hasSecrets(): boolean { @@ -154,6 +165,10 @@ export class SecretObfuscator { const replacement = entry.replacement ?? generateDeterministicReplacement(matchValue); result = replaceAll(result, matchValue, replacement); } else { + if (matchValue.length < 8) { + // Tone down short regex match obfuscation to avoid false matches on small words/fragments + continue; + } // obfuscate mode — get or create stable index let index = this.#findObfuscateIndex(matchValue); if (index === undefined) { @@ -174,30 +189,13 @@ export class SecretObfuscator { /** Deobfuscate obfuscate-mode placeholders back to original secrets. Replace-mode is NOT reversed. */ deobfuscate(text: string): string { if (!this.#hasAny || !text.includes("#")) return text; - return text.replace(PLACEHOLDER_RE, match => { - return this.#deobfuscateMap.get(match) ?? match; - }); + return text.replace(PLACEHOLDER_RE, match => 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)); - } - - /** 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 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; } @@ -205,47 +203,200 @@ export class SecretObfuscator { } } +// ═══════════════════════════════════════════════════════════════════════════ +// Display restore (inbound, persisted/provider → local display) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Restore secret placeholders for local display. Only message kinds the model + * itself authored from obfuscated context carry placeholders — assistant + * content and the LLM-written branch/compaction summaries. User, developer, and + * tool-result messages are persisted with their literal text, so a literal + * `#ABCD#` the operator typed must survive untouched; those roles are never + * walked. + */ export function deobfuscateSessionContext( sessionContext: SessionContext, obfuscator: SecretObfuscator | undefined, ): SessionContext { if (!obfuscator?.hasSecrets()) return sessionContext; - const messages = obfuscator.deobfuscateObject(sessionContext.messages); + const messages = deobfuscateAgentMessages(obfuscator, sessionContext.messages); return messages === sessionContext.messages ? sessionContext : { ...sessionContext, messages }; } -// ═══════════════════════════════════════════════════════════════════════════ -// Message obfuscation (outbound to LLM) -// ═══════════════════════════════════════════════════════════════════════════ - -/** Obfuscate all string content in LLM messages (for outbound interception). */ -export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] { - return obfuscator.obfuscateObject(messages); +export function deobfuscateAgentMessages(obfuscator: SecretObfuscator, messages: AgentMessage[]): AgentMessage[] { + let changed = false; + const result = messages.map((message): AgentMessage => { + switch (message.role) { + case "assistant": { + const content = deobfuscateAssistantContent(obfuscator, message.content); + if (content === message.content) return message; + changed = true; + return { ...message, content }; + } + case "branchSummary": { + const summary = obfuscator.deobfuscate(message.summary); + if (summary === message.summary) return message; + changed = true; + return { ...message, summary }; + } + case "compactionSummary": { + const summary = obfuscator.deobfuscate(message.summary); + const shortSummary = + message.shortSummary === undefined ? undefined : obfuscator.deobfuscate(message.shortSummary); + const blocks = message.blocks === undefined ? undefined : deobfuscateTextBlocks(obfuscator, message.blocks); + if (summary === message.summary && shortSummary === message.shortSummary && blocks === message.blocks) { + return message; + } + changed = true; + return { ...message, summary, shortSummary, blocks }; + } + default: + return message; + } + }); + return changed ? result : messages; } -/** Obfuscate provider request context without walking live tool schema instances. */ +/** + * Restore placeholders in assistant content: visible text, thinking text, and + * tool-call arguments/intent/rawBlock. Signatures and redacted-thinking bytes + * are opaque provider-replay data and pass through byte-identical. + */ +export function deobfuscateAssistantContent( + obfuscator: SecretObfuscator, + content: AssistantMessage["content"], +): AssistantMessage["content"] { + if (!obfuscator.hasSecrets()) return content; + let changed = false; + const result = content.map((block): AssistantMessage["content"][number] => { + if (block.type === "text") { + const text = obfuscator.deobfuscate(block.text); + if (text === block.text) return block; + changed = true; + return { ...block, text }; + } + if (block.type === "thinking") { + const thinking = obfuscator.deobfuscate(block.thinking); + if (thinking === block.thinking) return block; + changed = true; + return { ...block, thinking }; + } + if (block.type === "toolCall") { + const args = deobfuscateToolArguments(obfuscator, block.arguments); + const intent = block.intent === undefined ? undefined : obfuscator.deobfuscate(block.intent); + const rawBlock = block.rawBlock === undefined ? undefined : obfuscator.deobfuscate(block.rawBlock); + if (args === block.arguments && intent === block.intent && rawBlock === block.rawBlock) return block; + changed = true; + return { ...block, arguments: args, intent, rawBlock }; + } + return block; + }); + return changed ? result : content; +} + +/** + * Restore placeholders inside a tool call's arguments. Arguments are arbitrary + * model-authored JSON, so tool-call arguments are the ONLY place a recursive + * JSON walk runs. + */ +export function deobfuscateToolArguments( + obfuscator: SecretObfuscator, + args: Record, +): Record { + if (!obfuscator.hasSecrets()) return args; + return mapJsonStrings(args as JsonValue, s => obfuscator.deobfuscate(s)) as Record; +} + +/** Redact secrets inside a tool call's arguments (same JSON-walk exception as {@link deobfuscateToolArguments}). */ +export function obfuscateToolArguments( + obfuscator: SecretObfuscator, + args: Record, +): Record { + if (!obfuscator.hasSecrets()) return args; + return mapJsonStrings(args as JsonValue, s => obfuscator.obfuscate(s)) as Record; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Outbound obfuscation (local → provider) +// ═══════════════════════════════════════════════════════════════════════════ + +type UserFacingMessage = Extract; + +/** Obfuscate `text` blocks of a content array; image and other blocks pass through. */ +function obfuscateTextBlocks( + obfuscator: SecretObfuscator, + content: (TextContent | ImageContent)[], +): (TextContent | ImageContent)[] { + let changed = false; + const result = content.map((block): TextContent | ImageContent => { + if (block.type !== "text") return block; + const text = obfuscator.obfuscate(block.text); + if (text === block.text) return block; + changed = true; + return { ...block, text }; + }); + return changed ? result : content; +} + +/** Restore placeholders in `text` blocks of a content array; image and other blocks pass through. */ +function deobfuscateTextBlocks( + obfuscator: SecretObfuscator, + content: (TextContent | ImageContent)[], +): (TextContent | ImageContent)[] { + let changed = false; + const result = content.map((block): TextContent | ImageContent => { + if (block.type !== "text") return block; + const text = obfuscator.deobfuscate(block.text); + if (text === block.text) return block; + changed = true; + return { ...block, text }; + }); + return changed ? result : content; +} + +/** + * Redact secrets from outbound messages. Opt-in by origin: only user messages, + * tool results, and user-authored developer messages (e.g. `@file` mentions) + * can carry operator secrets. System prompts, tool schemas, and assistant + * output are author-controlled or model-generated and pass through untouched. + * Within a targeted message only `text` blocks are rewritten — inline image + * bytes are never walked. + */ +export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Message[]): Message[] { + if (!obfuscator.hasSecrets()) return messages; + let changed = false; + const result = messages.map((message): Message => { + if ( + message.role !== "user" && + message.role !== "toolResult" && + !(message.role === "developer" && message.attribution === "user") + ) { + return message; + } + const target = message as UserFacingMessage; + if (typeof target.content === "string") { + const content = obfuscator.obfuscate(target.content); + if (content === target.content) return message; + changed = true; + return { ...target, content } as Message; + } + const content = obfuscateTextBlocks(obfuscator, target.content); + if (content === target.content) return message; + changed = true; + return { ...target, content } as Message; + }); + return changed ? result : messages; +} + +/** + * Redact outbound provider context. Only conversation messages are rewritten; + * the static system prompt and tool schemas pass through unchanged. + */ 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, - })); + const messages = obfuscateMessages(obfuscator, context.messages); + return messages === context.messages ? context : { ...context, messages }; } // ═══════════════════════════════════════════════════════════════════════════ @@ -264,35 +415,33 @@ function replaceAll(text: string, search: string, replacement: string): string { 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)) { +/** + * Map every string in arbitrary JSON. Used ONLY for tool-call arguments, whose + * shape is model-authored and not known ahead of time. No other caller may walk + * untyped data: every message/content path is handled by a typed transformer. + */ +function mapJsonStrings(value: JsonValue, fn: (s: string) => string): JsonValue { + if (typeof value === "string") return fn(value); + if (Array.isArray(value)) { let changed = false; - const result = obj.map(item => { - const transformed = deepWalkStrings(item, transform); - if (transformed !== item) changed = true; - return transformed; + const out = value.map(item => { + const next = mapJsonStrings(item, fn); + if (next !== item) changed = true; + return next; }); - return (changed ? result : obj) as unknown as T; + return changed ? out : value; } - if (obj !== null && typeof obj === "object" && isPlainRecord(obj)) { + if (value !== null && typeof value === "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; + const out: JsonRecord = {}; + for (const key of Object.keys(value)) { + const item = value[key]; + if (item === undefined) continue; + const next = mapJsonStrings(item, fn); + if (next !== item) changed = true; + out[key] = next; } - return (changed ? result : obj) as T; + return changed ? out : value; } - return obj; -} - -function isPlainRecord(obj: object): obj is Record { - const prototype = Object.getPrototypeOf(obj); - return prototype === Object.prototype || prototype === null; + return value; } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a6b91853c..85c1c7e30 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -234,9 +234,10 @@ import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { t import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" }; import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" }; import { + deobfuscateAssistantContent, deobfuscateSessionContext, + obfuscateMessages, obfuscateProviderContext, - obfuscateProviderTools, type SecretObfuscator, } from "../secrets/obfuscator"; import { invalidateHostMetadata } from "../ssh/connection-manager"; @@ -2500,7 +2501,7 @@ export class AgentSession { const obfuscator = this.#obfuscator; if (obfuscator && event.type === "message_end" && event.message.role === "assistant") { const message = event.message; - const deobfuscatedContent = obfuscator.deobfuscateObject(message.content); + const deobfuscatedContent = deobfuscateAssistantContent(obfuscator, message.content); if (deobfuscatedContent !== message.content) { displayEvent = { ...event, message: { ...message, content: deobfuscatedContent } }; } @@ -5232,28 +5233,17 @@ export class AgentSession { return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator); } - #obfuscateForProvider(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, - }; + if (!this.#obfuscator?.hasSecrets() || !preparation.previousSummary) return preparation; + // `previousPreserveData` is opaque provider-replay state (e.g. OpenAI remote-compaction + // `encrypted_content`); rewriting it would corrupt replay, so only the plaintext summary + // is redacted. + return { ...preparation, previousSummary: this.#obfuscator.obfuscate(preparation.previousSummary) }; } #deobfuscateFromProvider(text: string): string { @@ -5270,7 +5260,8 @@ export class AgentSession { } #convertToLlmForSideRequest(messages: AgentMessage[]): Message[] { - return this.#obfuscateForProvider(convertToLlm(messages)); + const converted = convertToLlm(messages); + return this.#obfuscator?.hasSecrets() ? obfuscateMessages(this.#obfuscator, converted) : converted; } /** Convert session messages using the same pre-LLM pipeline as the active session. */ @@ -7865,8 +7856,8 @@ export class AgentSession { compactionAbortController.signal, { promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt), - extraContext: this.#obfuscateForProvider(compactionPrep.hookContext), - remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")), + extraContext: compactionPrep.hookContext, + remoteInstructions: this.#baseSystemPrompt.join("\n\n"), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), }, ); @@ -8060,11 +8051,10 @@ export class AgentSession { model, this.#modelRegistry.resolver(model, this.sessionId), { - systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt), - tools: obfuscateProviderTools( - this.#obfuscator, - this.#pruneToolDescriptions ? stripToolDescriptions(this.agent.state.tools) : this.agent.state.tools, - ), + systemPrompt: this.#baseSystemPrompt, + tools: this.#pruneToolDescriptions + ? stripToolDescriptions(this.agent.state.tools) + : this.agent.state.tools, customInstructions: this.#obfuscateTextForProvider(customInstructions), convertToLlm: messages => this.#convertToLlmForSideRequest(messages), initiatorOverride: "agent", @@ -9752,8 +9742,8 @@ export class AgentSession { autoCompactionSignal, { promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt), - extraContext: this.#obfuscateForProvider(compactionPrep.hookContext), - remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")), + extraContext: compactionPrep.hookContext, + remoteInstructions: this.#baseSystemPrompt.join("\n\n"), metadata: this.agent.metadataForProvider(candidate.provider), initiatorOverride: "agent", convertToLlm: messages => this.#convertToLlmForSideRequest(messages), @@ -11379,7 +11369,7 @@ export class AgentSession { } if (event.type === "done") { assistantMessage = this.#obfuscator?.hasSecrets() - ? { ...event.message, content: this.#obfuscator.deobfuscateObject(event.message.content) } + ? { ...event.message, content: deobfuscateAssistantContent(this.#obfuscator, event.message.content) } : event.message; break; } diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 85d85eb6d..5194c38d1 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -264,7 +264,7 @@ describe("AgentSession handoff", () => { expect(result?.document).not.toContain(placeholder); }); - it("obfuscates previous compaction summary and preserve data before forwarding to compact()", async () => { + it("obfuscates the previous compaction summary but preserves opaque replay data", async () => { session.settings.set("compaction.strategy", "context-full"); const placeholder = obfuscator.obfuscate(HANDOFF_SECRET); const entries = sessionManager.getBranch(); @@ -302,9 +302,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); + // Opaque provider-replay state (encrypted_content / replacementHistory) must pass through + // byte-identical — rewriting it would corrupt OpenAI remote-compaction replay. + expect(call[0].previousPreserveData).toBe(fixedPreparation.previousPreserveData); }); 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 2273773db..96ba0e79e 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -396,7 +396,7 @@ describe("AgentSession message pipeline", () => { expect(capturedOptions?.openrouterVariant).toBe("nitro"); }); - it("obfuscates the system prompt and messages on ephemeral side-channel requests", async () => { + it("obfuscates user messages on ephemeral side-channel requests", async () => { const api = "test-ephemeral-secret-redaction"; const secret = "EPHEMERAL_SECRET_TOKEN_12345"; let capturedContext: Context | undefined; @@ -427,7 +427,7 @@ describe("AgentSession message pipeline", () => { agent: new Agent({ initialState: { model, - systemPrompt: [`system prompt with ${secret}`], + systemPrompt: ["system prompt"], messages: [], tools: [], }, @@ -443,6 +443,7 @@ describe("AgentSession message pipeline", () => { expect(result.replyText).toBe("Answer"); expect(capturedContext).toBeDefined(); + // The secret entered only via the user prompt, which the opt-in obfuscator redacts. expect(JSON.stringify(capturedContext)).not.toContain(secret); }); @@ -516,10 +517,12 @@ describe("AgentSession message pipeline", () => { await agent.prompt("Main Question?"); await session.runEphemeralTurn({ promptText: `Side Question ${secret}?` }); + // The static prefix (system prompt + tools) is left untouched, so it stays byte-identical + // between the main turn and the side turn and the prompt cache prefix survives. expect(JSON.stringify(mainContext?.systemPrompt)).toBe(JSON.stringify(sideContext?.systemPrompt)); expect(JSON.stringify(mainContext?.tools)).toBe(JSON.stringify(sideContext?.tools)); - expect(JSON.stringify(sideContext?.systemPrompt)).not.toContain(secret); - expect(JSON.stringify(sideContext?.tools)).not.toContain(secret); + // The side turn's user prompt secret is redacted from the outbound messages. + expect(JSON.stringify(sideContext?.messages)).not.toContain(secret); }); }); diff --git a/packages/coding-agent/test/agent-session-silent-abort.test.ts b/packages/coding-agent/test/agent-session-silent-abort.test.ts index 3fd6ed9bb..50319609f 100644 --- a/packages/coding-agent/test/agent-session-silent-abort.test.ts +++ b/packages/coding-agent/test/agent-session-silent-abort.test.ts @@ -157,7 +157,7 @@ describe("AgentSession silent-abort marker stamping", () => { }); it("A4: marker is stamped on event.message BEFORE the obfuscator's displayEvent copy", async () => { - // Build a real obfuscator with a `plain` secret so `deobfuscateObject(content)` + // Build a real obfuscator with a `plain` secret so `deobfuscateAssistantContent(content)` // returns a NEW content array — that's the only path that triggers the // `displayEvent = { ...event, message: { ...message, content } }` spread copy // in `#handleAgentEvent`. The marker must be stamped BEFORE that spread so diff --git a/packages/coding-agent/test/secrets-obfuscator.test.ts b/packages/coding-agent/test/secrets-obfuscator.test.ts index 89a8d7c19..5b84f6548 100644 --- a/packages/coding-agent/test/secrets-obfuscator.test.ts +++ b/packages/coding-agent/test/secrets-obfuscator.test.ts @@ -3,8 +3,11 @@ */ import { describe, expect, it } from "bun:test"; -import type { Context, Message } from "@oh-my-pi/pi-ai"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { AssistantMessage, Context, Message } from "@oh-my-pi/pi-ai"; import { + deobfuscateAgentMessages, + deobfuscateToolArguments, obfuscateMessages, obfuscateProviderContext, SecretObfuscator, @@ -49,48 +52,36 @@ describe("SecretObfuscator regex behavior", () => { expect(obfuscated).not.toEqual(text); expect(obfuscator.deobfuscate(obfuscated)).toEqual(text); }); - it("deobfuscates placeholders through object payloads", () => { + it("deobfuscates placeholders through tool-call arguments", () => { 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 original = { cmd: "API_KEY=abc and api-key=def", status: "ok", nested: { note: "API_KEY=zzz" } }; const obfuscated = { cmd: obfuscator.obfuscate(original.cmd), status: original.status, + nested: { note: obfuscator.obfuscate(original.nested.note) }, }; - expect(obfuscator.deobfuscateObject(obfuscated)).toEqual({ - cmd: original.cmd, - status: original.status, - }); + expect(JSON.stringify(obfuscated)).not.toContain("API_KEY=abc"); + expect(deobfuscateToolArguments(obfuscator, obfuscated)).toEqual(original); }); - it("obfuscates nested provider request payloads", () => { + it("obfuscates conversation messages but leaves the system prompt untouched", () => { const secret = "SUPER_SECRET_TOKEN_12345"; const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); - const payload = { + const context: Context = { systemPrompt: [`workspace contains ${secret}`], - messages: [], - tools: [ - { - name: "handoff", - description: `preserve ${secret}`, - parameters: { - type: "object", - properties: { note: { type: "string", description: `write ${secret}` } }, - }, - }, - ], + messages: [{ role: "user", content: `use ${secret}`, timestamp: 1 }], }; - const obfuscated = obfuscateProviderContext(obfuscator, payload); - const serialized = JSON.stringify(obfuscated); + const obfuscated = obfuscateProviderContext(obfuscator, context); - expect(serialized).not.toContain(secret); - expect(obfuscator.deobfuscateObject(obfuscated).tools?.[0]?.description).toEqual(payload.tools[0]?.description); + // Conversation messages are redacted (and round-trip back to the secret)... + expect(JSON.stringify(obfuscated.messages)).not.toContain(secret); + expect(obfuscator.deobfuscate(JSON.stringify(obfuscated.messages))).toContain(secret); + // ...but the author-controlled system prompt passes through by reference. + expect(obfuscated.systemPrompt).toBe(context.systemPrompt); }); - it("redacts arktype tool schemas without cloning the live schema instance", () => { + it("leaves tool schemas untouched in provider context (no clone, no redaction)", () => { const secret = "SUPER_SECRET_TOKEN_12345"; const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); const parameters = type({ @@ -109,48 +100,109 @@ describe("SecretObfuscator regex behavior", () => { 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); + expect(obfuscated.tools).toBe(context.tools); + expect(obfuscated.tools?.[0]?.parameters).toBe(parameters); }); - it("obfuscates system reminders and assistant tool calls in messages", () => { + it("redacts only user, tool-result, and user-attributed developer 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 }, + const userMsg: Message = { role: "user", content: `user says ${secret}`, timestamp: 1 }; + const systemDeveloperMsg: Message = { role: "developer", content: `system reminder ${secret}`, timestamp: 1 }; + const fileMentionMsg: Message = { + role: "developer", + content: `${secret}`, + attribution: "user", + timestamp: 1, + }; + const assistantMsg: Message = { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call_1", + name: "handoff", + arguments: { note: secret }, + intent: `handoff ${secret}`, }, - stopReason: "toolUse", - timestamp: 1, + ], + 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 toolResultMsg: Message = { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [{ type: "text", text: `tool output ${secret}` }], + isError: false, + timestamp: 1, + }; - const obfuscated = obfuscateMessages(obfuscator, messages); + const obfuscated = obfuscateMessages(obfuscator, [ + userMsg, + systemDeveloperMsg, + fileMentionMsg, + assistantMsg, + toolResultMsg, + ]); - expect(JSON.stringify(obfuscated)).not.toContain(secret); - expect(obfuscator.deobfuscateObject(obfuscated)).toEqual(messages); + // User, user-attributed developer, and tool results are redacted. + expect(JSON.stringify(obfuscated[0])).not.toContain(secret); + expect(JSON.stringify(obfuscated[2])).not.toContain(secret); + expect(JSON.stringify(obfuscated[4])).not.toContain(secret); + // System developer reminders and assistant output pass through untouched (same reference). + expect(obfuscated[1]).toBe(systemDeveloperMsg); + expect(obfuscated[3]).toBe(assistantMsg); + }); + + it("never rewrites inline image bytes", () => { + const secret = "SUPER_SECRET_TOKEN_12345"; + const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); + // A base64 payload that literally contains the secret substring must survive byte-identical; + // rewriting it would corrupt the data URL (the Codex "invalid base64" failure). + const imageData = `iVBORw0KGgo${secret}AAAASUVORK5CYII=`; + const message: Message = { + role: "toolResult", + toolCallId: "call_1", + toolName: "read", + content: [ + { type: "text", text: `read ${secret}` }, + { type: "image", data: imageData, mimeType: "image/png" }, + ], + isError: false, + timestamp: 1, + }; + + const [obfuscated] = obfuscateMessages(obfuscator, [message]) as [typeof message]; + const blocks = obfuscated.content; + const image = blocks[1]; + const text = blocks[0]; + // Image bytes untouched... + expect(image.type === "image" && image.data).toBe(imageData); + // ...while the adjacent text is redacted. + expect(text.type === "text" && text.text.includes(secret)).toBe(false); + }); + + it("ignores configured plain secrets shorter than 8 characters", () => { + const obfuscator = new SecretObfuscator([{ type: "plain", content: "esp" }]); + expect(obfuscator.hasSecrets()).toBe(false); + expect(obfuscator.obfuscate("the response despite whitespace")).toBe("the response despite whitespace"); + }); + + it("ignores regex matches shorter than 8 characters", () => { + const obfuscator = new SecretObfuscator([{ type: "regex", content: "esp" }]); + expect(obfuscator.obfuscate("the response despite whitespace")).toBe("the response despite whitespace"); }); }); @@ -180,17 +232,17 @@ describe("SecretObfuscator cross-turn cache stability", () => { it("keeps earlier message placeholders stable when a later message reveals a new regex secret", () => { const obfuscator = new SecretObfuscator([{ type: "regex", content: "tok_[a-z0-9]+" }]); - const early: Message[] = [{ role: "user", content: "first uses tok_aaa", timestamp: 1 }]; + const early: Message[] = [{ role: "user", content: "first uses tok_aaaa", timestamp: 1 }]; // Turn N: only the early message exists; tok_aaa mints a fresh placeholder. const earlyTurnN = JSON.stringify(obfuscateMessages(obfuscator, early)); - expect(earlyTurnN).not.toContain("tok_aaa"); + expect(earlyTurnN).not.toContain("tok_aaaa"); // A later turn reveals a brand-new secret. Lazy regex discovery assigns it a fresh // index — this MUST NOT shift the placeholder already minted for tok_aaa. - const later: Message[] = [{ role: "user", content: "later uses tok_bbb", timestamp: 2 }]; + const later: Message[] = [{ role: "user", content: "later uses tok_bbbb", timestamp: 2 }]; const laterOut = JSON.stringify(obfuscateMessages(obfuscator, later)); - expect(laterOut).not.toContain("tok_bbb"); + expect(laterOut).not.toContain("tok_bbbb"); // Re-obfuscate the early message after the new discovery: identical bytes → the // already-cached prefix for the early message stays valid. @@ -198,3 +250,95 @@ describe("SecretObfuscator cross-turn cache stability", () => { expect(earlyTurnNPlus1).toEqual(earlyTurnN); }); }); + +describe("deobfuscateAgentMessages (display restore)", () => { + it("restores assistant content and model-generated summaries, leaving raw user text untouched", () => { + const secret = "DISPLAY_SECRET_TOKEN_123"; + const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); + const placeholder = obfuscator.obfuscate(secret); + expect(placeholder).not.toBe(secret); + + const userMsg: AgentMessage = { role: "user", content: `literal ${placeholder} token`, timestamp: 1 }; + const assistantMsg: AgentMessage = { + role: "assistant", + content: [ + { type: "text", text: `answer ${placeholder}` }, + { type: "thinking", thinking: `reason ${placeholder}` }, + { + type: "toolCall", + id: "call_1", + name: "read", + arguments: { path: `path ${placeholder}` }, + intent: `intent ${placeholder}`, + }, + ], + 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: 2, + }; + const branchSummary: AgentMessage = { + role: "branchSummary", + summary: `branch ${placeholder}`, + fromId: "x", + timestamp: 3, + }; + const compactionSummary: AgentMessage = { + role: "compactionSummary", + summary: `compact ${placeholder}`, + shortSummary: `short ${placeholder}`, + tokensBefore: 0, + timestamp: 4, + }; + + const restored = deobfuscateAgentMessages(obfuscator, [userMsg, assistantMsg, branchSummary, compactionSummary]); + + // Assistant text, thinking, and tool-call args/intent are restored to the real secret. + const restoredAssistant = restored[1] as AssistantMessage; + const assistantJson = JSON.stringify(restoredAssistant.content); + expect(assistantJson).toContain(secret); + expect(assistantJson).not.toContain(placeholder); + // Model-generated summaries are restored. + expect((restored[2] as { summary: string }).summary).toBe(`branch ${secret}`); + expect((restored[3] as { summary: string; shortSummary?: string }).summary).toBe(`compact ${secret}`); + expect((restored[3] as { summary: string; shortSummary?: string }).shortSummary).toBe(`short ${secret}`); + // The user message is persisted raw and never walked: a literal placeholder-shaped token + // survives byte-identical (same reference) rather than being turned into the secret. + expect(restored[0]).toBe(userMsg); + }); + + it("restores compactionSummary block text while leaving snapcompact image bytes intact", () => { + const secret = "BLOCKS_SECRET_TOKEN_456"; + const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); + const placeholder = obfuscator.obfuscate(secret); + const imageData = `frame${secret}bytes==`; + const message: AgentMessage = { + role: "compactionSummary", + summary: `summary ${placeholder}`, + tokensBefore: 0, + blocks: [ + { type: "text", text: `archived ${placeholder}` }, + { type: "image", data: imageData, mimeType: "image/png" }, + ], + timestamp: 1, + }; + + const [restored] = deobfuscateAgentMessages(obfuscator, [message]) as [typeof message]; + const blocks = restored.blocks ?? []; + const text = blocks[0]; + const image = blocks[1]; + // Archived text is restored to the real secret... + expect(text.type === "text" && text.text).toBe(`archived ${secret}`); + // ...while the snapcompact image bytes pass through untouched. + expect(image.type === "image" && image.data).toBe(imageData); + }); +}); diff --git a/packages/coding-agent/test/share.test.ts b/packages/coding-agent/test/share.test.ts index a72b343ed..f43d6812d 100644 --- a/packages/coding-agent/test/share.test.ts +++ b/packages/coding-agent/test/share.test.ts @@ -121,6 +121,128 @@ describe("buildShareSnapshot", () => { const plain = buildShareSnapshot(sm, {}); expect(JSON.stringify(plain)).toContain("hunter2-XYZZY"); }); + + test("redacts header cwd, bookmark labels, and file-mention paths", () => { + const secret = "shareleak-ABCDE"; + const ts = "2026-06-12T00:00:00.000Z"; + const entries: SessionEntry[] = [ + { + type: "label", + id: "l1", + parentId: null, + timestamp: ts, + targetId: "e1", + label: `bookmark ${secret}`, + } as SessionEntry, + { + type: "message", + id: "e1", + parentId: null, + timestamp: ts, + message: { + role: "fileMention", + files: [{ path: `/home/${secret}/.env`, content: `KEY=${secret}` }], + timestamp: 1, + }, + } as unknown as SessionEntry, + ]; + const header = { type: "session", version: 3, id: "t", timestamp: ts, cwd: `/home/${secret}/proj` }; + const sm = { + getHeader: () => header, + getEntries: () => entries, + getLeafId: () => "e1", + } as unknown as SessionManager; + const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); + + const snapshot = buildShareSnapshot(sm, { obfuscator }); + const flat = JSON.stringify(snapshot); + + // cwd, label, file path, and file content are all redacted... + expect(flat).not.toContain(secret); + // ...while surrounding structure (the path shape) survives. + expect(flat).toContain("/.env"); + // Source entries keep the real values; redaction is share-only. + expect(JSON.stringify(entries)).toContain(secret); + }); + + test("redacts assistant tool calls / error messages and bash meta, and drops provider replay payloads", () => { + const secret = "asst-secret-ABCDE"; + const replaySentinel = "REPLAY_BLOB_SENTINEL_XYZ"; + const ts = "2026-06-12T00:00:00.000Z"; + const usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + const entries: SessionEntry[] = [ + { + type: "message", + id: "a1", + parentId: null, + timestamp: ts, + message: { + role: "assistant", + content: [ + { type: "text", text: `answer ${secret}` }, + { + type: "toolCall", + id: "c1", + name: "read", + arguments: { path: `/x/${secret}` }, + intent: `intent ${secret}`, + rawBlock: `raw ${secret}`, + }, + ], + api: "test", + provider: "test", + model: "test", + usage, + stopReason: "toolUse", + errorMessage: `boom ${secret}`, + providerPayload: { type: "openaiResponsesHistory", items: [{ note: replaySentinel }] }, + timestamp: 1, + }, + } as unknown as SessionEntry, + { + type: "message", + id: "b1", + parentId: "a1", + timestamp: ts, + message: { + role: "bashExecution", + command: `echo ${secret}`, + output: `out ${secret}`, + exitCode: 0, + cancelled: false, + truncated: false, + meta: { + source: { type: "path", value: `/home/${secret}/log` }, + diagnostics: { summary: `diag ${secret}`, messages: [`msg ${secret}`] }, + }, + timestamp: 2, + }, + } as unknown as SessionEntry, + ]; + const sm = { + getHeader: () => sessionData([], "x").header, + getEntries: () => entries, + getLeafId: () => "b1", + } as unknown as SessionManager; + const obfuscator = new SecretObfuscator([{ type: "plain", content: secret }]); + + const flat = JSON.stringify(buildShareSnapshot(sm, { obfuscator })); + + // Every freeform occurrence (text, tool-call args/intent/rawBlock, errorMessage, bash output + meta) is redacted. + expect(flat).not.toContain(secret); + // Opaque provider-replay payload is dropped wholesale — the sentinel is NOT a configured secret, + // so its absence proves the subtree was removed rather than merely obfuscated. + expect(flat).not.toContain(replaySentinel); + // Source entries keep the real values; redaction is share-only. + expect(JSON.stringify(entries)).toContain(secret); + }); }); describe("normalizeShareServerUrl", () => {