fix: tighten signed thinking replay sanitization

This commit is contained in:
Shakker
2026-04-12 04:32:59 +01:00
committed by Shakker
parent eb501536d2
commit 3cc9d53eb3
3 changed files with 64 additions and 23 deletions

View File

@@ -418,7 +418,10 @@ export async function sanitizeSessionHistory(params: {
: sanitizedImages;
const sanitizedToolCalls = sanitizeToolCallInputs(droppedThinking, {
allowedToolNames: params.allowedToolNames,
preserveImmutableThinkingTurns: policy.validateAnthropicTurns,
allowProviderOwnedThinkingReplay:
policy.validateAnthropicTurns &&
params.provider === "anthropic" &&
params.modelApi === "anthropic-messages",
});
const repairedTools = policy.repairToolUseResultPairing
? sanitizeToolUseResultPairing(sanitizedToolCalls, {

View File

@@ -2,7 +2,10 @@ import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js";
import { validateAnthropicTurns, validateGeminiTurns } from "../../pi-embedded-helpers.js";
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
import {
isRedactedSessionsSpawnAttachment,
sanitizeToolUseResultPairing,
} from "../../session-transcript-repair.js";
import { normalizeToolName } from "../../tool-policy.js";
import type { TranscriptPolicy } from "../../transcript-policy.js";
@@ -251,14 +254,7 @@ function hasUnredactedSessionsSpawnAttachments(block: ReplayToolCallBlock): bool
continue;
}
for (const attachment of attachments) {
if (!attachment || typeof attachment !== "object") {
continue;
}
if (!Object.hasOwn(attachment, "content")) {
continue;
}
const content = (attachment as { content?: unknown }).content;
if (content !== "__OPENCLAW_REDACTED__") {
if (!isRedactedSessionsSpawnAttachment(attachment)) {
return true;
}
}
@@ -331,7 +327,7 @@ function resolveReplayToolCallName(
function sanitizeReplayToolCallInputs(
messages: AgentMessage[],
allowedToolNames?: Set<string>,
preserveImmutableThinkingTurns?: boolean,
allowProviderOwnedThinkingReplay?: boolean,
): ReplayToolCallSanitizeReport {
let changed = false;
let droppedAssistantMessages = 0;
@@ -347,7 +343,7 @@ function sanitizeReplayToolCallInputs(
continue;
}
if (
preserveImmutableThinkingTurns &&
allowProviderOwnedThinkingReplay &&
message.content.some((block) => isThinkingLikeReplayBlock(block)) &&
message.content.some((block) => isReplayToolCallBlock(block))
) {
@@ -641,7 +637,8 @@ export function wrapStreamFnSanitizeMalformedToolCalls(
const sanitized = sanitizeReplayToolCallInputs(
messages as AgentMessage[],
allowedToolNames,
transcriptPolicy?.validateAnthropicTurns === true,
transcriptPolicy?.validateAnthropicTurns === true &&
(model as { api?: unknown })?.api === "anthropic-messages",
);
if (sanitized.messages === messages) {
return baseFn(model, context, options);

View File

@@ -8,6 +8,8 @@ import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-
const TOOL_CALL_NAME_MAX_CHARS = 64;
const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_:.-]+$/;
const REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT = "__OPENCLAW_REDACTED__";
const SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS = ["name", "encoding", "mimeType"] as const;
type RawToolCallBlock = {
type?: unknown;
@@ -94,20 +96,59 @@ function redactSessionsSpawnAttachmentsArgs(value: unknown): unknown {
if (!Array.isArray(raw)) {
return value;
}
let changed = false;
const next = raw.map((item) => {
if (!item || typeof item !== "object") {
if (isRedactedSessionsSpawnAttachment(item)) {
return item;
}
const a = item as Record<string, unknown>;
if (!Object.hasOwn(a, "content")) {
return item;
}
const { content: _content, ...rest } = a;
return { ...rest, content: "__OPENCLAW_REDACTED__" };
changed = true;
return redactSessionsSpawnAttachment(item);
});
if (!changed) {
return value;
}
return { ...rec, attachments: next };
}
function redactSessionsSpawnAttachment(item: unknown): Record<string, unknown> {
const next: Record<string, unknown> = {
content: REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT,
};
if (!item || typeof item !== "object") {
return next;
}
const attachment = item as Record<string, unknown>;
for (const key of SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS) {
const value = attachment[key];
if (typeof value === "string" && value.trim().length > 0) {
next[key] = value;
}
}
return next;
}
export function isRedactedSessionsSpawnAttachment(item: unknown): boolean {
if (!item || typeof item !== "object") {
return false;
}
const attachment = item as Record<string, unknown>;
if (attachment.content !== REDACTED_SESSIONS_SPAWN_ATTACHMENT_CONTENT) {
return false;
}
for (const key of Object.keys(attachment)) {
if (key === "content") {
continue;
}
if (!(SESSIONS_SPAWN_ATTACHMENT_METADATA_KEYS as readonly string[]).includes(key)) {
return false;
}
if (typeof attachment[key] !== "string" || (attachment[key] as string).trim().length === 0) {
return false;
}
}
return true;
}
function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock {
const rawName = readStringValue(block.name);
const trimmedName = rawName?.trim();
@@ -232,7 +273,7 @@ export type ToolCallInputRepairReport = {
export type ToolCallInputRepairOptions = {
allowedToolNames?: Iterable<string>;
preserveImmutableThinkingTurns?: boolean;
allowProviderOwnedThinkingReplay?: boolean;
};
export type ErroredAssistantResultPolicy = "preserve" | "drop";
@@ -270,7 +311,7 @@ export function repairToolCallInputs(
let changed = false;
const out: AgentMessage[] = [];
const allowedToolNames = normalizeAllowedToolNames(options?.allowedToolNames);
const preserveImmutableThinkingTurns = options?.preserveImmutableThinkingTurns === true;
const allowProviderOwnedThinkingReplay = options?.allowProviderOwnedThinkingReplay === true;
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
@@ -284,7 +325,7 @@ export function repairToolCallInputs(
}
if (
preserveImmutableThinkingTurns &&
allowProviderOwnedThinkingReplay &&
msg.content.some((block) => isThinkingLikeBlock(block)) &&
countRawToolCallBlocks(msg.content) > 0
) {