From bccdc95a9b24701d06fc47043a667859273df9a3 Mon Sep 17 00:00:00 2001 From: Shailesh <75851986+gut-puncture@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:20:57 +0530 Subject: [PATCH] Cap sessions_history payloads to prevent context overflow (#10000) * Cap sessions_history payloads to prevent context overflow * fix: harden sessions_history payload caps * fix: cap sessions_history payloads to prevent context overflow (#10000) (thanks @gut-puncture) --------- Co-authored-by: Shailesh Rana Co-authored-by: George Pickett --- CHANGELOG.md | 2 + src/agents/openclaw-tools.sessions.test.ts | 122 +++++++++++++++++ src/agents/tools/sessions-history-tool.ts | 147 ++++++++++++++++++++- src/security/audit-extra.ts | 3 +- 4 files changed, 271 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a653372244..a1836ec0ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,8 @@ Docs: https://docs.openclaw.ai - Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane. - Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted. - Slack: strip `<@...>` mention tokens before command matching so `/new` and `/reset` work when prefixed with a mention. (#9971) Thanks @ironbyte-rgb. +- Agents: cap `sessions_history` tool output and strip oversized fields to prevent context overflow. (#10000) Thanks @gut-puncture. +- Security: normalize code safety finding paths in `openclaw security audit --deep` output for cross-platform consistency. (#10000) Thanks @gut-puncture. - Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier. - Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier. - Security: gate `whatsapp_login` tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier. diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index aaaf31fe32..f1a0aea89e 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -181,6 +181,128 @@ describe("sessions tools", () => { expect(withToolsDetails.messages).toHaveLength(2); }); + it("sessions_history caps oversized payloads and strips heavy fields", async () => { + callGatewayMock.mockReset(); + const oversized = Array.from({ length: 80 }, (_, idx) => ({ + role: "assistant", + content: [ + { + type: "text", + text: `${String(idx)}:${"x".repeat(5000)}`, + }, + { + type: "thinking", + thinking: "y".repeat(7000), + thinkingSignature: "sig".repeat(4000), + }, + ], + details: { + giant: "z".repeat(12000), + }, + usage: { + input: 1, + output: 1, + }, + })); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "chat.history") { + return { messages: oversized }; + } + return {}; + }); + + const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_history"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_history tool"); + } + + const result = await tool.execute("call4b", { + sessionKey: "main", + includeTools: true, + }); + const details = result.details as { + messages?: Array>; + truncated?: boolean; + droppedMessages?: boolean; + contentTruncated?: boolean; + bytes?: number; + }; + expect(details.truncated).toBe(true); + expect(details.droppedMessages).toBe(true); + expect(details.contentTruncated).toBe(true); + expect(typeof details.bytes).toBe("number"); + expect((details.bytes ?? 0) <= 80 * 1024).toBe(true); + expect(details.messages && details.messages.length > 0).toBe(true); + + const first = details.messages?.[0] as + | { + details?: unknown; + usage?: unknown; + content?: Array<{ + type?: string; + text?: string; + thinking?: string; + thinkingSignature?: string; + }>; + } + | undefined; + expect(first?.details).toBeUndefined(); + expect(first?.usage).toBeUndefined(); + const textBlock = first?.content?.find((block) => block.type === "text"); + expect(typeof textBlock?.text).toBe("string"); + expect((textBlock?.text ?? "").length <= 4015).toBe(true); + const thinkingBlock = first?.content?.find((block) => block.type === "thinking"); + expect(thinkingBlock?.thinkingSignature).toBeUndefined(); + }); + + it("sessions_history enforces a hard byte cap even when a single message is huge", async () => { + callGatewayMock.mockReset(); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "chat.history") { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "ok" }], + extra: "x".repeat(200_000), + }, + ], + }; + } + return {}; + }); + + const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_history"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing sessions_history tool"); + } + + const result = await tool.execute("call4c", { + sessionKey: "main", + includeTools: true, + }); + const details = result.details as { + messages?: Array>; + truncated?: boolean; + droppedMessages?: boolean; + contentTruncated?: boolean; + bytes?: number; + }; + expect(details.truncated).toBe(true); + expect(details.droppedMessages).toBe(true); + expect(details.contentTruncated).toBe(false); + expect(typeof details.bytes).toBe("number"); + expect((details.bytes ?? 0) <= 80 * 1024).toBe(true); + expect(details.messages).toHaveLength(1); + expect(details.messages?.[0]?.content).toContain( + "[sessions_history omitted: message too large]", + ); + }); + it("sessions_history resolves sessionId inputs", async () => { callGatewayMock.mockReset(); const sessionId = "sess-group"; diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 091d8051c8..9038e9b902 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -2,7 +2,9 @@ import { Type } from "@sinclair/typebox"; import type { AnyAgentTool } from "./common.js"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; +import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js"; import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { truncateUtf16Safe } from "../../utils.js"; import { jsonResult, readStringParam } from "./common.js"; import { createAgentToAgentPolicy, @@ -19,6 +21,131 @@ const SessionsHistoryToolSchema = Type.Object({ includeTools: Type.Optional(Type.Boolean()), }); +const SESSIONS_HISTORY_MAX_BYTES = 80 * 1024; +const SESSIONS_HISTORY_TEXT_MAX_CHARS = 4000; + +function truncateHistoryText(text: string): { text: string; truncated: boolean } { + if (text.length <= SESSIONS_HISTORY_TEXT_MAX_CHARS) { + return { text, truncated: false }; + } + const cut = truncateUtf16Safe(text, SESSIONS_HISTORY_TEXT_MAX_CHARS); + return { text: `${cut}\n…(truncated)…`, truncated: true }; +} + +function sanitizeHistoryContentBlock(block: unknown): { block: unknown; truncated: boolean } { + if (!block || typeof block !== "object") { + return { block, truncated: false }; + } + const entry = { ...(block as Record) }; + let truncated = false; + const type = typeof entry.type === "string" ? entry.type : ""; + if (typeof entry.text === "string") { + const res = truncateHistoryText(entry.text); + entry.text = res.text; + truncated ||= res.truncated; + } + if (type === "thinking") { + if (typeof entry.thinking === "string") { + const res = truncateHistoryText(entry.thinking); + entry.thinking = res.text; + truncated ||= res.truncated; + } + // The encrypted signature can be extremely large and is not useful for history recall. + if ("thinkingSignature" in entry) { + delete entry.thinkingSignature; + truncated = true; + } + } + if (typeof entry.partialJson === "string") { + const res = truncateHistoryText(entry.partialJson); + entry.partialJson = res.text; + truncated ||= res.truncated; + } + if (type === "image") { + const data = typeof entry.data === "string" ? entry.data : undefined; + const bytes = data ? data.length : undefined; + if ("data" in entry) { + delete entry.data; + truncated = true; + } + entry.omitted = true; + if (bytes !== undefined) { + entry.bytes = bytes; + } + } + return { block: entry, truncated }; +} + +function sanitizeHistoryMessage(message: unknown): { message: unknown; truncated: boolean } { + if (!message || typeof message !== "object") { + return { message, truncated: false }; + } + const entry = { ...(message as Record) }; + let truncated = false; + // Tool result details often contain very large nested payloads. + if ("details" in entry) { + delete entry.details; + truncated = true; + } + if ("usage" in entry) { + delete entry.usage; + truncated = true; + } + if ("cost" in entry) { + delete entry.cost; + truncated = true; + } + + if (typeof entry.content === "string") { + const res = truncateHistoryText(entry.content); + entry.content = res.text; + truncated ||= res.truncated; + } else if (Array.isArray(entry.content)) { + const updated = entry.content.map((block) => sanitizeHistoryContentBlock(block)); + entry.content = updated.map((item) => item.block); + truncated ||= updated.some((item) => item.truncated); + } + if (typeof entry.text === "string") { + const res = truncateHistoryText(entry.text); + entry.text = res.text; + truncated ||= res.truncated; + } + return { message: entry, truncated }; +} + +function jsonUtf8Bytes(value: unknown): number { + try { + return Buffer.byteLength(JSON.stringify(value), "utf8"); + } catch { + return Buffer.byteLength(String(value), "utf8"); + } +} + +function enforceSessionsHistoryHardCap(params: { + items: unknown[]; + bytes: number; + maxBytes: number; +}): { items: unknown[]; bytes: number; hardCapped: boolean } { + if (params.bytes <= params.maxBytes) { + return { items: params.items, bytes: params.bytes, hardCapped: false }; + } + + const last = params.items.at(-1); + const lastOnly = last ? [last] : []; + const lastBytes = jsonUtf8Bytes(lastOnly); + if (lastBytes <= params.maxBytes) { + return { items: lastOnly, bytes: lastBytes, hardCapped: true }; + } + + const placeholder = [ + { + role: "assistant", + content: "[sessions_history omitted: message too large]", + }, + ]; + return { items: placeholder, bytes: jsonUtf8Bytes(placeholder), hardCapped: true }; +} + function resolveSandboxSessionToolsVisibility(cfg: ReturnType) { return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned"; } @@ -131,10 +258,26 @@ export function createSessionsHistoryTool(opts?: { params: { sessionKey: resolvedKey, limit }, }); const rawMessages = Array.isArray(result?.messages) ? result.messages : []; - const messages = includeTools ? rawMessages : stripToolMessages(rawMessages); + const selectedMessages = includeTools ? rawMessages : stripToolMessages(rawMessages); + const sanitizedMessages = selectedMessages.map((message) => sanitizeHistoryMessage(message)); + const contentTruncated = sanitizedMessages.some((entry) => entry.truncated); + const cappedMessages = capArrayByJsonBytes( + sanitizedMessages.map((entry) => entry.message), + SESSIONS_HISTORY_MAX_BYTES, + ); + const droppedMessages = cappedMessages.items.length < selectedMessages.length; + const hardened = enforceSessionsHistoryHardCap({ + items: cappedMessages.items, + bytes: cappedMessages.bytes, + maxBytes: SESSIONS_HISTORY_MAX_BYTES, + }); return jsonResult({ sessionKey: displayKey, - messages, + messages: hardened.items, + truncated: droppedMessages || contentTruncated || hardened.hardCapped, + droppedMessages: droppedMessages || hardened.hardCapped, + contentTruncated, + bytes: hardened.bytes, }); }, }; diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 8c3b64c5df..9688374d1c 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -1123,7 +1123,8 @@ function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): relPath && relPath !== "." && !relPath.startsWith("..") ? relPath : path.basename(finding.file); - return ` - [${finding.ruleId}] ${finding.message} (${filePath}:${finding.line})`; + const normalizedPath = filePath.replaceAll("\\", "/"); + return ` - [${finding.ruleId}] ${finding.message} (${normalizedPath}:${finding.line})`; }) .join("\n"); }