Files
openclaw/src/agents/transcript-redact.ts
T
Peter Steinberger edecdbd05e refactor(config): config-surface reduction tranche 3 — product consolidations (review request) (#111527)
* refactor(config): consolidate media model lists

* refactor(config): unify memory configuration

* refactor(config): consolidate TTS ownership

* refactor(config): move typing policy to agents

* refactor(config): retire product-level config surfaces

* refactor(config): share scoped tool policy type

* chore(config): refresh generated baselines

* fix(config): honor agent typing overrides

* fix(config): migrate sibling config consumers

* refactor(infra): keep base64url decoder private

* fix(config): strip invalid legacy TTS values

* chore(config): refresh rebased baseline hash

* fix(doctor): route legacy messages.tts.realtime voice to talk during tts move

* refactor(config): polish final layout names

* refactor(config): freeze retired tuning defaults

* feat(config): add fast mode default symmetry

* refactor(config): key agent entries by id

* docs(config): update final layout reference

* test(config): cover final layout migrations

* chore(config): refresh final layout baselines

* fix(config): align final layout runtime readers

* fix(config): align remaining readers

* fix(config): stabilize final layout migrations

* fix(config): finalize config projection proof

* fix(config): address final layout review

* docs(release): preserve historical config names

* fix(config): complete keyed agent migration

* fix(config): close final migration gaps

* fix(config): finish full-branch review

* fix(config): complete runtime secret detection

* fix(config): close final review findings

* fix(config): finish canonical docs and heartbeat migration

* fix(config): integrate latest main after rebase

* refactor(env): isolate test-only controls

* refactor(env): isolate build and development controls

* refactor(env): collapse process identity indirection

* refactor(env): remove duplicate config and temp aliases

* docs(env): define the operator-facing allowlist

* ci(env): ratchet production variable count

* fix(env): remove stale provider helper import

* fix(env): make ratchet sorting explicit

* test(env): keep test seam in dead-code audit

* test(env): cover ratchet growth and boundary; document surface budgets

* docs(config): document tier-eval consolidations

* docs(config): clarify speech preference ownership

* test(memory): align retired tuning fixtures

* refactor(memory): freeze engine heuristics

* refactor(config): apply tier-eval tranche

* refactor(tts): move persona shaping to providers

* refactor(compaction): move prompt policy to providers

* test(config): align hookified prompt fixtures

* chore(deadcode): classify test-only exports

* chore(github): remove unused spawn helper

* chore(deadcode): classify queue diagnostics

* chore(deadcode): remove unused lane snapshot export

* chore(plugin-sdk): ratchet consolidated surface

* fix(config): integrate latest main after rebase
2026-07-21 20:28:43 -07:00

643 lines
21 KiB
TypeScript

/**
* Agent transcript redaction helpers.
*
* Applies logging redaction rules to persisted messages while preserving unchanged object identity.
*/
import { findNormalizedProviderValue } from "@openclaw/model-catalog-core/provider-id";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readLoggingConfig } from "../logging/config.js";
import {
getDefaultRedactPatterns,
redactSensitiveFieldValue,
redactSensitiveText,
} from "../logging/redact.js";
import type { ProviderEndpointClass } from "./provider-attribution.js";
import { resolveProviderEndpoint } from "./provider-attribution.js";
import type { AgentMessage } from "./runtime/index.js";
import {
sanitizeTranscriptImageDataUrlField,
sanitizeTranscriptImageRecord,
shouldPreserveNestedTranscriptImageDataUrlFields,
shouldPreserveTranscriptImagePayload,
} from "./transcript-redact-images.js";
function resolveTranscriptRedactPatterns(patterns?: string[]) {
return patterns && patterns.length > 0 ? [...patterns, ...getDefaultRedactPatterns()] : undefined;
}
function redactTranscriptOptions(cfg?: OpenClawConfig) {
const configuredLogging = readLoggingConfig();
const patterns = resolveTranscriptRedactPatterns(
cfg?.logging?.redactPatterns ?? configuredLogging?.redactPatterns,
);
if (patterns === undefined) {
return undefined;
}
return {
mode: "tools" as const,
...(patterns !== undefined ? { patterns } : {}),
};
}
function isTranscriptRedactionDisabled(cfg?: OpenClawConfig): boolean {
void cfg;
return false;
}
function redactTranscriptText(value: string, cfg?: OpenClawConfig): string {
return redactSensitiveText(value, redactTranscriptOptions(cfg));
}
function redactTranscriptStructuredFieldValue(
key: string,
value: string,
cfg?: OpenClawConfig,
): string {
// Preserve pagination state only in transcripts; value-pattern and global log redaction remain.
return /^(?:next[_-]?)?page[_-]?token$|^page[_-]?cursor$/i.test(key)
? redactTranscriptText(value, cfg)
: redactSensitiveFieldValue(key, value, redactTranscriptOptions(cfg));
}
function isPlainTranscriptObject(value: object): value is Record<string, unknown> {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
type TranscriptValueLocation =
| "root"
| "assistant-content-array"
| "assistant-content-block"
| "nested";
type TranscriptAssistantRoute = {
api?: string;
endpointClass?: ProviderEndpointClass;
model?: string;
provider?: string;
};
const OPENAI_RESPONSES_APIS = new Set([
"openai-responses",
"azure-openai-responses",
"openai-chatgpt-responses",
"openclaw-openai-responses-transport",
"openclaw-openai-chatgpt-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 GOOGLE_THOUGHT_SIGNATURE_RE =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
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);
}
const isOpenAICompletionsRoute = (route?: TranscriptAssistantRoute) =>
OPENAI_COMPLETIONS_APIS.has(route?.api ?? "");
function isGoogleOpenAICompletionsRoute(route: TranscriptAssistantRoute | undefined): boolean {
return (
isOpenAICompletionsRoute(route) &&
(route?.provider === "google" ||
route?.endpointClass === "google-generative-ai" ||
route?.endpointClass === "google-vertex")
);
}
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 isStructurallyValidOpaqueReplayToken(value: string): boolean {
return (
value.length > 0 &&
value === value.trim() &&
OPAQUE_REPLAY_TOKEN_RE.test(value) &&
!value.includes("\u2026")
);
}
function isCredentialSafeOpaqueReplayToken(value: string): boolean {
if (!isStructurallyValidOpaqueReplayToken(value)) {
return false;
}
// OpenAI encrypted reasoning is commonly Fernet-shaped and intentionally
// matches the generic gAAAA secret detector. Custom routes retain the
// credential-sensitive gate because their opaque fields are not attributable
// to a known provider contract.
return value.startsWith("gAAAA") || redactSensitiveText(value, { mode: "tools" }) === value;
}
function isGoogleThoughtSignature(value: string): boolean {
return (
value.length > 0 &&
value === value.trim() &&
!value.includes("\u2026") &&
GOOGLE_THOUGHT_SIGNATURE_RE.test(value)
);
}
function resolveTranscriptAssistantRoute(
source: Record<string, unknown>,
cfg: OpenClawConfig | undefined,
): TranscriptAssistantRoute {
const api = typeof source.api === "string" ? source.api : undefined;
const model = typeof source.model === "string" ? source.model : undefined;
const provider = typeof source.provider === "string" ? source.provider : undefined;
const providerConfig = provider
? findNormalizedProviderValue(cfg?.models?.providers, provider)
: undefined;
const modelConfig = model
? providerConfig?.models?.find((candidate) => candidate.id === model)
: undefined;
const baseUrl = modelConfig?.baseUrl ?? providerConfig?.baseUrl;
const endpointClass = baseUrl ? resolveProviderEndpoint(baseUrl).endpointClass : undefined;
return {
...(api ? { api } : {}),
...(endpointClass ? { endpointClass } : {}),
...(model ? { model } : {}),
...(provider ? { provider } : {}),
};
}
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<string, unknown> | 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<string, unknown>,
key: string,
item: unknown,
location: TranscriptValueLocation,
route: TranscriptAssistantRoute | undefined,
): boolean {
if (location !== "assistant-content-block" || typeof item !== "string") {
return false;
}
const type = source.type;
const isAnthropicSlot =
(type === "thinking" && (key === "thinkingSignature" || key === "signature")) ||
(type === "redacted_thinking" &&
(key === "data" || key === "signature" || key === "thinkingSignature"));
if (isAnthropicReasoningRoute(route) && isAnthropicSlot) {
return isStructurallyValidOpaqueReplayToken(item);
}
const isGoogleSlot =
(type === "text" && key === "textSignature") ||
(type === "thinking" && (key === "thinkingSignature" || key === "thought_signature")) ||
(type === "toolCall" && key === "thoughtSignature");
if (isGoogleReasoningRoute(route) && isGoogleSlot) {
return isGoogleThoughtSignature(item);
}
if (isGoogleOpenAICompletionsRoute(route) && type === "toolCall" && key === "thoughtSignature") {
// The OpenAI-compatible transport captures provider-owned opaque signatures
// such as SIG-OPAQUE-ABC==; native Google routes require standard base64.
return isStructurallyValidOpaqueReplayToken(item);
}
if (!isCustomProviderRoute(route) || !isCredentialSafeOpaqueReplayToken(item)) {
return false;
}
return (
(type === "text" && key === "textSignature") ||
(type === "thinking" &&
(key === "thinkingSignature" || key === "signature" || key === "thought_signature")) ||
(type === "redacted_thinking" &&
(key === "data" || key === "signature" || key === "thinkingSignature")) ||
(type === "toolCall" && key === "thoughtSignature")
);
}
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");
const isValidEncryptedContent = isOpenAIResponsesRoute(route)
? isStructurallyValidOpaqueReplayToken
: isCredentialSafeOpaqueReplayToken;
if (
encryptedContent !== undefined &&
encryptedContent !== null &&
(typeof encryptedContent !== "string" || !isValidEncryptedContent(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,
route: TranscriptAssistantRoute | undefined,
): string | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return undefined;
}
const isValidEncryptedData = isOpenAICompletionsRoute(route)
? isStructurallyValidOpaqueReplayToken
: isCredentialSafeOpaqueReplayToken;
if (
!parsed ||
typeof parsed !== "object" ||
!isPlainTranscriptObject(parsed) ||
parsed.type !== "reasoning.encrypted" ||
typeof parsed.data !== "string" ||
!isValidEncryptedData(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<object> = new WeakSet<object>(),
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 = sanitizeTranscriptImageRecord(value);
const source = sanitizedImageRecord ?? value;
const currentAssistantRoute =
location === "root" && source.role === "assistant"
? resolveTranscriptAssistantRoute(source, cfg)
: assistantRoute;
let next: Record<string, unknown> | 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" &&
// These transports use the same validated v1 phase signature for pre-tool commentary;
// stripping it would resurface narration after reload or session resume.
(isOpenAIResponsesRoute(currentAssistantRoute) ||
isOpenAICompletionsRoute(currentAssistantRoute) ||
isAnthropicReasoningRoute(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,
currentAssistantRoute,
);
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 = sanitizeTranscriptImageDataUrlField({
source,
key,
value: item,
preserveImageDataUrlFields,
});
if (sanitizedDataUrl !== undefined) {
if (sanitizedDataUrl !== item) {
next ??= { ...source };
next[key] = sanitizedDataUrl;
}
continue;
}
}
if (shouldPreserveTranscriptImagePayload(source, key, item, preserveImageDataUrlFields)) {
continue;
}
const redacted = redactTranscriptStructuredValue(
item,
cfg,
key,
seen,
preserveImageDataUrlFields || shouldPreserveNestedTranscriptImageDataUrlFields(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<object>(),
false,
"root",
) as AgentMessage;
}