mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
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 <vincentkoc@ieee.org>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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";
|
||||
|
||||
@@ -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<string, unknown>) };
|
||||
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<string, unknown>) };
|
||||
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,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user