/** * Agent transcript redaction helpers. * * Applies logging redaction rules to persisted messages while preserving unchanged object identity. */ import { sanitizeInlineImageBase64, sanitizeInlineImageDataUrlForStorage, } from "@openclaw/media-core/inline-image-data-url"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { readLoggingConfig } from "../logging/config.js"; import { getDefaultRedactPatterns, redactSensitiveFieldValue, redactSensitiveText, } from "../logging/redact.js"; import type { AgentMessage } from "./runtime/index.js"; function resolveTranscriptRedactPatterns(patterns?: string[]) { return patterns && patterns.length > 0 ? [...patterns, ...getDefaultRedactPatterns()] : undefined; } function redactTranscriptOptions(cfg?: OpenClawConfig) { const configuredLogging = readLoggingConfig(); const mode = cfg?.logging?.redactSensitive ?? configuredLogging?.redactSensitive; const patterns = resolveTranscriptRedactPatterns( cfg?.logging?.redactPatterns ?? configuredLogging?.redactPatterns, ); if (mode === undefined && patterns === undefined) { return undefined; } return { ...(mode !== undefined ? { mode } : {}), ...(patterns !== undefined ? { patterns } : {}), }; } function isTranscriptRedactionDisabled(cfg?: OpenClawConfig): boolean { return (cfg?.logging?.redactSensitive ?? readLoggingConfig()?.redactSensitive) === "off"; } function redactTranscriptText(value: string, cfg?: OpenClawConfig): string { if (cfg?.logging?.redactSensitive === "off") { return value; } return redactSensitiveText(value, redactTranscriptOptions(cfg)); } function redactTranscriptStructuredFieldValue( key: string, value: string, cfg?: OpenClawConfig, ): string { if (cfg?.logging?.redactSensitive === "off") { return value; } return redactSensitiveFieldValue(key, value, redactTranscriptOptions(cfg)); } function isPlainTranscriptObject(value: object): value is Record { const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } function isImageMimeType(value: unknown): value is string { return typeof value === "string" && /^image\//iu.test(value.trim()); } function normalizeImageMimeType(value: unknown): string | undefined { return isImageMimeType(value) ? value.trim().toLowerCase() : undefined; } function imageMimeTypeForRecord(value: Record): string | undefined { return ( normalizeImageMimeType(value.mimeType) ?? normalizeImageMimeType(value.mediaType) ?? normalizeImageMimeType(value.media_type) ); } function imageMimeTypeFieldsForRecord(value: Record): string[] { return ["mimeType", "mediaType", "media_type"].filter((key) => isImageMimeType(value[key])); } function sanitizeOpaqueImageBase64( base64: string, mimeType: string | undefined, ): { mimeType: string; base64: string } | undefined { return mimeType ? sanitizeInlineImageBase64({ mimeType, base64 }) : undefined; } function isValidOpaqueImageBase64(base64: string, mimeType: string | undefined): boolean { return sanitizeOpaqueImageBase64(base64, mimeType) !== undefined; } function isTranscriptImageContentBlock(value: Record): boolean { return ( value.type === "image" && typeof value.data === "string" && isValidOpaqueImageBase64(value.data, imageMimeTypeForRecord(value)) ); } function isImageBase64SourceBlock(value: Record): boolean { return ( value.type === "base64" && typeof value.data === "string" && isValidOpaqueImageBase64(value.data, imageMimeTypeForRecord(value)) ); } function sanitizeImageRecord(source: Record): Record | undefined { const isImageBlock = source.type === "image"; const isBase64SourceBlock = source.type === "base64"; if ((!isImageBlock && !isBase64SourceBlock) || typeof source.data !== "string") { return undefined; } const mimeTypeFields = imageMimeTypeFieldsForRecord(source); if (mimeTypeFields.length === 0) { return undefined; } const sanitized = sanitizeOpaqueImageBase64(source.data, imageMimeTypeForRecord(source)); if (!sanitized) { return undefined; } const hasCanonicalMimeTypes = mimeTypeFields.every((key) => source[key] === sanitized.mimeType); if (source.data === sanitized.base64 && hasCanonicalMimeTypes) { return source; } const next: Record = { ...source, data: sanitized.base64 }; for (const field of mimeTypeFields) { next[field] = sanitized.mimeType; } return next; } function startsWithDataUrl(value: string): boolean { return value.slice(0, "data:".length).toLowerCase() === "data:"; } function sanitizeImageDataUrlField( source: Record, key: string, value: string, ): string | undefined { if (!startsWithDataUrl(value)) { return undefined; } const isImageDataUrlField = (source.type === "input_image" && key === "image_url") || ((source.type === "image" || source.type === "image_url") && key === "url") || (source.type === "image" && (key === "source" || key === "data")); return isImageDataUrlField ? sanitizeInlineImageDataUrlForStorage(value) : undefined; } function shouldPreserveOpaqueImagePayload( source: Record, key: string, item: unknown, preserveImageDataUrlFields: boolean, ): boolean { if (typeof item !== "string") { return false; } if ( key === "data" && (isTranscriptImageContentBlock(source) || isImageBase64SourceBlock(source)) ) { return true; } if (preserveImageDataUrlFields && key === "url") { return startsWithDataUrl(item) && sanitizeInlineImageDataUrlForStorage(item) !== undefined; } return sanitizeImageDataUrlField(source, key, item) !== undefined; } function shouldPreserveNestedImageDataUrlFields( source: Record, key: string, ): boolean { return ( key === "image_url" && (source.type === "image_url" || source.type === "input_image" || source.type === "image") ); } type TranscriptValueLocation = | "root" | "assistant-content-array" | "assistant-content-block" | "nested"; type TranscriptAssistantRoute = { api?: string; model?: string; provider?: string; }; const OPENAI_RESPONSES_APIS = new Set([ "openai-responses", "azure-openai-responses", "openai-chatgpt-responses", "openclaw-openai-responses-transport", "openclaw-azure-openai-responses-transport", ]); const GOOGLE_REASONING_APIS = new Set([ "google-generative-ai", "google-vertex", "google-gemini-cli", "openclaw-google-generative-ai-transport", ]); const ANTHROPIC_REASONING_APIS = new Set([ "anthropic-messages", "bedrock-converse-stream", "openclaw-anthropic-messages-transport", ]); const OPENAI_COMPLETIONS_APIS = new Set([ "openai-completions", "openclaw-openai-completions-transport", ]); const OPAQUE_REPLAY_TOKEN_RE = /^[A-Za-z0-9+/_-]+={0,2}$/; const OPENAI_REPLAY_CONTEXT_HASH_RE = /^[a-f0-9]{16}$/; function isOpenAIResponsesRoute(route: TranscriptAssistantRoute | undefined): boolean { return typeof route?.api === "string" && OPENAI_RESPONSES_APIS.has(route.api); } function isGoogleReasoningRoute(route: TranscriptAssistantRoute | undefined): boolean { return typeof route?.api === "string" && GOOGLE_REASONING_APIS.has(route.api); } function isAnthropicReasoningRoute(route: TranscriptAssistantRoute | undefined): boolean { return typeof route?.api === "string" && ANTHROPIC_REASONING_APIS.has(route.api); } function isOpenAICompletionsRoute(route: TranscriptAssistantRoute | undefined): boolean { return typeof route?.api === "string" && OPENAI_COMPLETIONS_APIS.has(route.api); } function isCustomProviderRoute(route: TranscriptAssistantRoute | undefined): boolean { return ( Boolean(route?.api && route.model && route.provider) && route?.api !== "mistral-conversations" && !isOpenAIResponsesRoute(route) && !isGoogleReasoningRoute(route) && !isAnthropicReasoningRoute(route) && !isOpenAICompletionsRoute(route) ); } function isGitHubCopilotResponsesRoute(route: TranscriptAssistantRoute | undefined): boolean { return ( (route?.api === "openai-responses" || route?.api === "openclaw-openai-responses-transport") && route.provider === "github-copilot" ); } function isOpaqueReplayToken(value: string): boolean { if ( value.length === 0 || value !== value.trim() || !OPAQUE_REPLAY_TOKEN_RE.test(value) || value.includes("\u2026") ) { return false; } // OpenAI encrypted reasoning is commonly Fernet-shaped and intentionally // matches the generic gAAAA secret detector. Other known credential forms // must never gain a transcript-redaction bypass. return value.startsWith("gAAAA") || redactSensitiveText(value, { mode: "tools" }) === value; } function isSafeReplayIdentifier(value: string, maxLength = 512): boolean { return ( value.length > 0 && value.length <= maxLength && value === value.trim() && /^[A-Za-z0-9+/_:.=-]+$/.test(value) && redactSensitiveText(value, { mode: "tools" }) === value ); } function isOpenAIResponseItemId( value: string, route: TranscriptAssistantRoute | undefined, ): boolean { return isSafeReplayIdentifier(value, isGitHubCopilotResponsesRoute(route) ? 64 : 512); } function isOpenAITextSignature( value: string, route: TranscriptAssistantRoute | undefined, ): boolean { if (value.startsWith("{")) { try { const parsed = JSON.parse(value) as unknown; if (!parsed || typeof parsed !== "object" || !isPlainTranscriptObject(parsed)) { return false; } if (!Object.keys(parsed).every((key) => key === "v" || key === "id" || key === "phase")) { return false; } const id = typeof parsed.id === "string" && isOpenAIResponseItemId(parsed.id, route) ? parsed.id : undefined; const phase = parsed.phase === "commentary" || parsed.phase === "final_answer" ? parsed.phase : undefined; if (parsed.id !== undefined && id === undefined) { return false; } return parsed.v === 1 && (id !== undefined || phase !== undefined); } catch { return false; } } return isOpenAIResponseItemId(value, route); } const OPENAI_REASONING_REPLAY_METADATA_KEYS = new Set([ "v", "source", "provider", "api", "model", "baseUrlHash", "sessionHash", "authProfileHash", ]); const OPENAI_REASONING_REPLAY_METADATA_KEY = "__openclaw_replay"; function sanitizeOpenAIReasoningReplayMetadata( value: unknown, route: TranscriptAssistantRoute | undefined, ): Record | undefined { if ( !value || typeof value !== "object" || !isPlainTranscriptObject(value) || !route?.api || !route.model || !route.provider ) { return undefined; } if ( value.v !== 1 || value.source !== "openai-responses" || value.provider !== route?.provider || value.api !== route.api || value.model !== route.model || (value.baseUrlHash !== undefined && (typeof value.baseUrlHash !== "string" || !OPENAI_REPLAY_CONTEXT_HASH_RE.test(value.baseUrlHash))) || (value.sessionHash !== undefined && (typeof value.sessionHash !== "string" || !OPENAI_REPLAY_CONTEXT_HASH_RE.test(value.sessionHash))) || (value.authProfileHash !== undefined && (typeof value.authProfileHash !== "string" || !OPENAI_REPLAY_CONTEXT_HASH_RE.test(value.authProfileHash))) ) { return undefined; } if (Object.keys(value).every((key) => OPENAI_REASONING_REPLAY_METADATA_KEYS.has(key))) { return value; } return { v: 1, source: "openai-responses", provider: value.provider, api: value.api, model: value.model, ...(value.baseUrlHash !== undefined ? { baseUrlHash: value.baseUrlHash } : {}), ...(value.sessionHash !== undefined ? { sessionHash: value.sessionHash } : {}), ...(value.authProfileHash !== undefined ? { authProfileHash: value.authProfileHash } : {}), }; } function shouldPreserveOpaqueProviderPayload( source: Record, key: string, item: unknown, location: TranscriptValueLocation, route: TranscriptAssistantRoute | undefined, ): boolean { if ( location !== "assistant-content-block" || typeof item !== "string" || !isOpaqueReplayToken(item) ) { return false; } const type = source.type; const customRoute = isCustomProviderRoute(route); return ( (type === "text" && key === "textSignature" && (isGoogleReasoningRoute(route) || customRoute)) || (type === "thinking" && ((key === "thinkingSignature" && (isAnthropicReasoningRoute(route) || isGoogleReasoningRoute(route) || customRoute)) || (key === "signature" && (isAnthropicReasoningRoute(route) || customRoute)) || (key === "thought_signature" && (isGoogleReasoningRoute(route) || customRoute)))) || (type === "redacted_thinking" && (isAnthropicReasoningRoute(route) || customRoute) && (key === "data" || key === "signature" || key === "thinkingSignature")) || (type === "toolCall" && key === "thoughtSignature" && (isGoogleReasoningRoute(route) || isOpenAICompletionsRoute(route) || customRoute)) ); } function sanitizeOpenAIReasoningSignature( value: string, route: TranscriptAssistantRoute | undefined, ): string | undefined { let parsed: unknown; try { parsed = JSON.parse(value); } catch { return undefined; } if ( !parsed || typeof parsed !== "object" || !isPlainTranscriptObject(parsed) || parsed.type !== "reasoning" || (parsed.summary !== undefined && !Array.isArray(parsed.summary)) ) { return undefined; } const encryptedContent = parsed.encrypted_content; const hasEncryptedContent = Object.hasOwn(parsed, "encrypted_content"); if ( encryptedContent !== undefined && encryptedContent !== null && (typeof encryptedContent !== "string" || !isOpaqueReplayToken(encryptedContent)) ) { return undefined; } if ( parsed.id !== undefined && (typeof parsed.id !== "string" || !isOpenAIResponseItemId(parsed.id, route)) ) { return undefined; } if ( parsed.status !== undefined && parsed.status !== "in_progress" && parsed.status !== "completed" && parsed.status !== "incomplete" ) { return undefined; } if (!hasEncryptedContent && typeof parsed.id !== "string") { return undefined; } const replayMetadata = sanitizeOpenAIReasoningReplayMetadata( parsed[OPENAI_REASONING_REPLAY_METADATA_KEY], route, ); return JSON.stringify({ ...(typeof parsed.id === "string" ? { id: parsed.id } : {}), type: "reasoning", summary: [], ...(parsed.status !== undefined ? { status: parsed.status } : {}), ...(hasEncryptedContent ? { encrypted_content: encryptedContent } : {}), ...(replayMetadata ? { [OPENAI_REASONING_REPLAY_METADATA_KEY]: replayMetadata } : {}), }); } function sanitizeOpenAICompletionsToolSignature(value: string): string | undefined { let parsed: unknown; try { parsed = JSON.parse(value); } catch { return undefined; } if ( !parsed || typeof parsed !== "object" || !isPlainTranscriptObject(parsed) || parsed.type !== "reasoning.encrypted" || typeof parsed.data !== "string" || !isOpaqueReplayToken(parsed.data) || (parsed.id !== undefined && parsed.id !== null && (typeof parsed.id !== "string" || !isSafeReplayIdentifier(parsed.id))) || (parsed.format !== undefined && parsed.format !== null && (typeof parsed.format !== "string" || parsed.format.length > 64 || !/^[a-z0-9.-]+$/.test(parsed.format))) || (parsed.index !== undefined && (!Number.isSafeInteger(parsed.index) || (parsed.index as number) < 0)) ) { return undefined; } return JSON.stringify({ type: "reasoning.encrypted", data: parsed.data, ...(parsed.id !== undefined ? { id: parsed.id } : {}), ...(parsed.format !== undefined ? { format: parsed.format } : {}), ...(parsed.index !== undefined ? { index: parsed.index } : {}), }); } function redactTranscriptStructuredValue( value: unknown, cfg?: OpenClawConfig, fieldKey?: string, seen: WeakSet = new WeakSet(), preserveImageDataUrlFields = false, location: TranscriptValueLocation = "nested", assistantRoute?: TranscriptAssistantRoute, ): unknown { if (typeof value === "string") { if (fieldKey) { return redactTranscriptStructuredFieldValue(fieldKey, value, cfg); } return redactTranscriptText(value, cfg); } if (Array.isArray(value)) { if (seen.has(value)) { return "[Circular]"; } seen.add(value); let changed = false; const redacted = value.map((item) => { const next = redactTranscriptStructuredValue( item, cfg, fieldKey, seen, preserveImageDataUrlFields, location === "assistant-content-array" ? "assistant-content-block" : "nested", assistantRoute, ); changed ||= next !== item; return next; }); seen.delete(value); return changed ? redacted : value; } if (!value || typeof value !== "object") { return value; } if (seen.has(value)) { // Avoid recursive transcript payloads from escaping redaction or crashing // persistence; circular refs serialize as a stable marker. return "[Circular]"; } if (!isPlainTranscriptObject(value)) { // Non-plain instances can carry runtime state; leave them untouched instead // of cloning unexpected prototypes into transcripts. return value; } seen.add(value); const sanitizedImageRecord = sanitizeImageRecord(value); const source = sanitizedImageRecord ?? value; const currentAssistantRoute = location === "root" && source.role === "assistant" ? { ...(typeof source.api === "string" ? { api: source.api } : {}), ...(typeof source.model === "string" ? { model: source.model } : {}), ...(typeof source.provider === "string" ? { provider: source.provider } : {}), } : assistantRoute; let next: Record | null = null; if (source !== value) { next = { ...source }; } for (const [key, item] of Object.entries(source)) { if ( location === "assistant-content-block" && (isOpenAIResponsesRoute(currentAssistantRoute) || isCustomProviderRoute(currentAssistantRoute)) && source.type === "thinking" && key === "openclawReasoningReplay" ) { const sanitizedMetadata = sanitizeOpenAIReasoningReplayMetadata(item, currentAssistantRoute); if (sanitizedMetadata !== undefined) { if (sanitizedMetadata !== item) { next ??= { ...source }; next[key] = sanitizedMetadata; } continue; } } if ( location === "assistant-content-block" && (isOpenAIResponsesRoute(currentAssistantRoute) || isCustomProviderRoute(currentAssistantRoute)) && source.type === "thinking" && key === "thinkingSignature" && typeof item === "string" ) { const sanitizedSignature = sanitizeOpenAIReasoningSignature(item, currentAssistantRoute); if (sanitizedSignature !== undefined) { if (sanitizedSignature !== item) { next ??= { ...source }; next[key] = sanitizedSignature; } continue; } } if ( location === "assistant-content-block" && (isOpenAIResponsesRoute(currentAssistantRoute) || isCustomProviderRoute(currentAssistantRoute)) && source.type === "text" && key === "textSignature" && typeof item === "string" && isOpenAITextSignature(item, currentAssistantRoute) ) { continue; } if ( location === "assistant-content-block" && (isOpenAICompletionsRoute(currentAssistantRoute) || isCustomProviderRoute(currentAssistantRoute)) && source.type === "toolCall" && key === "thoughtSignature" && typeof item === "string" ) { const sanitizedSignature = sanitizeOpenAICompletionsToolSignature(item); if (sanitizedSignature !== undefined) { if (sanitizedSignature !== item) { next ??= { ...source }; next[key] = sanitizedSignature; } continue; } } // Provider-signed/encrypted bytes must remain exact or replayed tool turns fail. if (shouldPreserveOpaqueProviderPayload(source, key, item, location, currentAssistantRoute)) { continue; } if (typeof item === "string") { const sanitizedDataUrl = preserveImageDataUrlFields && key === "url" ? startsWithDataUrl(item) ? sanitizeInlineImageDataUrlForStorage(item) : undefined : sanitizeImageDataUrlField(source, key, item); if (sanitizedDataUrl !== undefined) { if (sanitizedDataUrl !== item) { next ??= { ...source }; next[key] = sanitizedDataUrl; } continue; } } if (shouldPreserveOpaqueImagePayload(source, key, item, preserveImageDataUrlFields)) { continue; } const redacted = redactTranscriptStructuredValue( item, cfg, key, seen, preserveImageDataUrlFields || shouldPreserveNestedImageDataUrlFields(source, key), location === "root" && source.role === "assistant" && key === "content" && Array.isArray(item) ? "assistant-content-array" : "nested", currentAssistantRoute, ); if (redacted === item) { continue; } next ??= { ...source }; next[key] = redacted; } seen.delete(value); return next ?? value; } /** Return a redacted transcript message according to logging config. */ export function redactTranscriptMessage(message: AgentMessage, cfg?: OpenClawConfig): AgentMessage { if (isTranscriptRedactionDisabled(cfg)) { return message; } return redactTranscriptStructuredValue( message, cfg, undefined, new WeakSet(), false, "root", ) as AgentMessage; }