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:
Aether AI
2026-02-23 10:29:40 +11:00
committed by GitHub
parent 3efe63d1ad
commit d306fc8ef1
3 changed files with 120 additions and 11 deletions

View File

@@ -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,
});
},