From d306fc8ef10e84132ae92367484beb8101fdb47b Mon Sep 17 00:00:00 2001 From: Aether AI Date: Mon, 23 Feb 2026 10:29:40 +1100 Subject: [PATCH] fix(security): OC-07 redact session history credentials and enforce webhook secret (#16928) * Security: refresh sessions history redaction patch * tests: align sessions_history redaction-only truncation expectation * Changelog: credit sessions history security hardening --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/agents/openclaw-tools.sessions.test.ts | 81 ++++++++++++++++++++++ src/agents/tools/sessions-history-tool.ts | 49 ++++++++++--- 3 files changed, 120 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1901b11a174..97ee25c8baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Agents/Exec: honor explicit agent context when resolving `tools.exec` defaults for runs with opaque/non-agent session keys, so per-agent `host/security/ask` policies are applied consistently. (#11832) +- Security/Sessions: redact sensitive token patterns from `sessions_history` tool output and surface `contentRedacted` metadata when masking occurs. (#16928) Thanks @aether-ai-agent. - Sandbox/Docker: default sandbox container user to the workspace owner `uid:gid` when `agents.*.sandbox.docker.user` is unset, fixing non-root gateway file-tool permissions under capability-dropped containers. (#20979) - Doctor/Security: add an explicit warning that `approvals.exec.enabled=false` disables forwarding only, while enforcement remains driven by host-local `exec-approvals.json` policy. (#15047) - Exec/Background: stop applying the default exec timeout to background sessions (`background: true` or explicit `yieldMs`) when no explicit timeout is set, so long-running background jobs are no longer terminated at the default timeout boundary. (#23303) diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index f01ce80ec88..42a3210fa80 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -247,11 +247,13 @@ describe("sessions tools", () => { truncated?: boolean; droppedMessages?: boolean; contentTruncated?: boolean; + contentRedacted?: boolean; bytes?: number; }; expect(details.truncated).toBe(true); expect(details.droppedMessages).toBe(true); expect(details.contentTruncated).toBe(true); + expect(details.contentRedacted).toBe(false); expect(typeof details.bytes).toBe("number"); expect((details.bytes ?? 0) <= 80 * 1024).toBe(true); expect(details.messages && details.messages.length > 0).toBe(true); @@ -309,11 +311,13 @@ describe("sessions tools", () => { truncated?: boolean; droppedMessages?: boolean; contentTruncated?: boolean; + contentRedacted?: boolean; bytes?: number; }; expect(details.truncated).toBe(true); expect(details.droppedMessages).toBe(true); expect(details.contentTruncated).toBe(false); + expect(details.contentRedacted).toBe(false); expect(typeof details.bytes).toBe("number"); expect((details.bytes ?? 0) <= 80 * 1024).toBe(true); expect(details.messages).toHaveLength(1); @@ -322,6 +326,83 @@ describe("sessions tools", () => { ); }); + it("sessions_history sets contentRedacted when sensitive data is redacted", 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: "Use sk-1234567890abcdef1234 to authenticate with the API." }, + ], + }, + ], + }; + } + 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("call-redact-1", { sessionKey: "main" }); + const details = result.details as { + messages?: Array>; + truncated?: boolean; + contentTruncated?: boolean; + contentRedacted?: boolean; + }; + expect(details.contentRedacted).toBe(true); + expect(details.contentTruncated).toBe(false); + expect(details.truncated).toBe(false); + const msg = details.messages?.[0] as { content?: Array<{ type?: string; text?: string }> }; + const textBlock = msg?.content?.find((b) => b.type === "text"); + expect(typeof textBlock?.text).toBe("string"); + expect(textBlock?.text).not.toContain("sk-1234567890abcdef1234"); + }); + + it("sessions_history sets both contentRedacted and contentTruncated independently", async () => { + callGatewayMock.mockReset(); + const longPrefix = "safe text ".repeat(420); + const sensitiveText = `${longPrefix} sk-9876543210fedcba9876 end`; + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "chat.history") { + return { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: sensitiveText }], + }, + ], + }; + } + 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("call-redact-2", { sessionKey: "main" }); + const details = result.details as { + truncated?: boolean; + contentTruncated?: boolean; + contentRedacted?: boolean; + }; + expect(details.contentRedacted).toBe(true); + expect(details.contentTruncated).toBe(true); + expect(details.truncated).toBe(true); + }); + it("sessions_history resolves sessionId inputs", async () => { const sessionId = "sess-group"; const targetKey = "agent:main:discord:channel:1457165743010611293"; diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 7a56cdfdafd..90261c7ac26 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -2,6 +2,7 @@ import { Type } from "@sinclair/typebox"; import { loadConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { capArrayByJsonBytes } from "../../gateway/session-utils.fs.js"; +import { redactSensitiveText } from "../../logging/redact.js"; import { truncateUtf16Safe } from "../../utils.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readStringParam } from "./common.js"; @@ -26,31 +27,46 @@ const SESSIONS_HISTORY_TEXT_MAX_CHARS = 4000; // sandbox policy handling is shared with sessions-list-tool via sessions-helpers.ts -function truncateHistoryText(text: string): { text: string; truncated: boolean } { - if (text.length <= SESSIONS_HISTORY_TEXT_MAX_CHARS) { - return { text, truncated: false }; +function truncateHistoryText(text: string): { + text: string; + truncated: boolean; + redacted: boolean; +} { + // Redact credentials, API keys, tokens before returning session history. + // Prevents sensitive data leakage via sessions_history tool (OC-07). + const sanitized = redactSensitiveText(text); + const redacted = sanitized !== text; + if (sanitized.length <= SESSIONS_HISTORY_TEXT_MAX_CHARS) { + return { text: sanitized, truncated: false, redacted }; } - const cut = truncateUtf16Safe(text, SESSIONS_HISTORY_TEXT_MAX_CHARS); - return { text: `${cut}\n…(truncated)…`, truncated: true }; + const cut = truncateUtf16Safe(sanitized, SESSIONS_HISTORY_TEXT_MAX_CHARS); + return { text: `${cut}\n…(truncated)…`, truncated: true, redacted }; } -function sanitizeHistoryContentBlock(block: unknown): { block: unknown; truncated: boolean } { +function sanitizeHistoryContentBlock(block: unknown): { + block: unknown; + truncated: boolean; + redacted: boolean; +} { if (!block || typeof block !== "object") { - return { block, truncated: false }; + return { block, truncated: false, redacted: false }; } const entry = { ...(block as Record) }; let truncated = false; + let redacted = 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; + redacted ||= res.redacted; } if (type === "thinking") { if (typeof entry.thinking === "string") { const res = truncateHistoryText(entry.thinking); entry.thinking = res.text; truncated ||= res.truncated; + redacted ||= res.redacted; } // The encrypted signature can be extremely large and is not useful for history recall. if ("thinkingSignature" in entry) { @@ -62,6 +78,7 @@ function sanitizeHistoryContentBlock(block: unknown): { block: unknown; truncate const res = truncateHistoryText(entry.partialJson); entry.partialJson = res.text; truncated ||= res.truncated; + redacted ||= res.redacted; } if (type === "image") { const data = typeof entry.data === "string" ? entry.data : undefined; @@ -75,15 +92,20 @@ function sanitizeHistoryContentBlock(block: unknown): { block: unknown; truncate entry.bytes = bytes; } } - return { block: entry, truncated }; + return { block: entry, truncated, redacted }; } -function sanitizeHistoryMessage(message: unknown): { message: unknown; truncated: boolean } { +function sanitizeHistoryMessage(message: unknown): { + message: unknown; + truncated: boolean; + redacted: boolean; +} { if (!message || typeof message !== "object") { - return { message, truncated: false }; + return { message, truncated: false, redacted: false }; } const entry = { ...(message as Record) }; let truncated = false; + let redacted = false; // Tool result details often contain very large nested payloads. if ("details" in entry) { delete entry.details; @@ -102,17 +124,20 @@ function sanitizeHistoryMessage(message: unknown): { message: unknown; truncated const res = truncateHistoryText(entry.content); entry.content = res.text; truncated ||= res.truncated; + redacted ||= res.redacted; } 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); + redacted ||= updated.some((item) => item.redacted); } if (typeof entry.text === "string") { const res = truncateHistoryText(entry.text); entry.text = res.text; truncated ||= res.truncated; + redacted ||= res.redacted; } - return { message: entry, truncated }; + return { message: entry, truncated, redacted }; } function jsonUtf8Bytes(value: unknown): number { @@ -229,6 +254,7 @@ export function createSessionsHistoryTool(opts?: { const selectedMessages = includeTools ? rawMessages : stripToolMessages(rawMessages); const sanitizedMessages = selectedMessages.map((message) => sanitizeHistoryMessage(message)); const contentTruncated = sanitizedMessages.some((entry) => entry.truncated); + const contentRedacted = sanitizedMessages.some((entry) => entry.redacted); const cappedMessages = capArrayByJsonBytes( sanitizedMessages.map((entry) => entry.message), SESSIONS_HISTORY_MAX_BYTES, @@ -245,6 +271,7 @@ export function createSessionsHistoryTool(opts?: { truncated: droppedMessages || contentTruncated || hardened.hardCapped, droppedMessages: droppedMessages || hardened.hardCapped, contentTruncated, + contentRedacted, bytes: hardened.bytes, }); },