diff --git a/packages/memory-host-sdk/package.json b/packages/memory-host-sdk/package.json index d44507f5396b..ab6591d4e697 100644 --- a/packages/memory-host-sdk/package.json +++ b/packages/memory-host-sdk/package.json @@ -3,6 +3,9 @@ "version": "0.0.0-private", "private": true, "type": "module", + "dependencies": { + "@openclaw/normalization-core": "workspace:*" + }, "exports": { "./runtime": "./src/runtime.ts", "./runtime-core": "./src/runtime-core.ts", diff --git a/packages/memory-host-sdk/src/host/error-utils.test.ts b/packages/memory-host-sdk/src/host/error-utils.test.ts new file mode 100644 index 000000000000..1e29450b3343 --- /dev/null +++ b/packages/memory-host-sdk/src/host/error-utils.test.ts @@ -0,0 +1,23 @@ +// Memory Host SDK tests cover error formatting and secret redaction. +import { describe, expect, it } from "vitest"; +import { formatErrorMessage } from "./error-utils.js"; + +const TOKEN_CASES = [ + ["leading split", "abcde😀xxxxxxxxwxyz", "abcde...wxyz"], + ["trailing split", "abcdefghijklm😀xyz", "abcdef...xyz"], + ["intact leading pair", "abcd😀xxxxxxxxwxyz", "abcd😀...wxyz"], + ["intact trailing pair", "abcdefghijklmn😀xy", "abcdef...😀xy"], +] as const; + +describe("formatErrorMessage", () => { + it.each(TOKEN_CASES)("masks tokens with a UTF-16-safe %s", (_label, token, masked) => { + expect(formatErrorMessage(`TOKEN=${token}`)).toBe(`TOKEN=${masked}`); + }); + + it("replaces the captured value literally when key and value repeat", () => { + expect(formatErrorMessage("LONG_LONG_LONG_TOKEN=LONG_LONG_LONG_TOKEN")).toBe( + "LONG_LONG_LONG_TOKEN=LONG_L...OKEN", + ); + expect(formatErrorMessage("TOKEN=$&abcdxxxxxxxxwxyz")).toBe("TOKEN=$&abcd...wxyz"); + }); +}); diff --git a/packages/memory-host-sdk/src/host/error-utils.ts b/packages/memory-host-sdk/src/host/error-utils.ts index 2914517e146c..ff8b92171b99 100644 --- a/packages/memory-host-sdk/src/host/error-utils.ts +++ b/packages/memory-host-sdk/src/host/error-utils.ts @@ -45,7 +45,14 @@ function redactMatch(match: string, groups: string[]): string { } const token = groups.findLast((value) => typeof value === "string" && value.length > 0) ?? match; const masked = maskToken(token); - return token === match ? masked : match.replace(token, masked); + if (token === match) { + return masked; + } + const tokenOffset = match.lastIndexOf(token); + if (tokenOffset < 0) { + return "***"; + } + return `${match.slice(0, tokenOffset)}${masked}${match.slice(tokenOffset + token.length)}`; } function redactSensitiveText(text: string): string { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc52a8043a2d..cdf5a6dcd7ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1953,7 +1953,11 @@ importers: packages/media-understanding-common: {} - packages/memory-host-sdk: {} + packages/memory-host-sdk: + dependencies: + '@openclaw/normalization-core': + specifier: workspace:* + version: link:../normalization-core packages/model-catalog-core: {} diff --git a/ui/src/lib/browser-redact.test.ts b/ui/src/lib/browser-redact.test.ts index b198780bb1cd..e822c72492aa 100644 --- a/ui/src/lib/browser-redact.test.ts +++ b/ui/src/lib/browser-redact.test.ts @@ -55,4 +55,36 @@ describe("browser tool detail redaction", () => { "OPENAI_API_KEY=sk-123...cdef", ); }); + + it.each([ + ["leading split", "abcde😀xxxxxxxxwxyz", "abcde...wxyz"], + ["trailing split", "abcdefghijklm😀xyz", "abcdef...xyz"], + ["intact leading pair", "abcd😀xxxxxxxxwxyz", "abcd😀...wxyz"], + ["intact trailing pair", "abcdefghijklmn😀xy", "abcdef...😀xy"], + ])("masks tool payload tokens with a UTF-16-safe %s", (_label, token, masked) => { + expect(redactToolPayloadText(`{"token":"${token}"}`)).toBe(`{"token":"${masked}"}`); + }); + + it("does not trust mask-shaped input as already redacted", () => { + expect(redactToolPayloadText("TOKEN=abcde...wxyz")).toBe("TOKEN=abcde....wxyz"); + }); + + it("redacts replacement-template text literally without changing surrounding text", () => { + const markerLike = "\u{e000}0\u{e001}"; + expect(redactToolPayloadText(`${markerLike} TOKEN=$\`abcdxxxxxxxxwxyz`)).toBe( + `${markerLike} TOKEN=$\`abcd...wxyz`, + ); + }); + + it("redacts the captured value when key and value repeat", () => { + expect(redactToolPayloadText("LONG_LONG_LONG_TOKEN=LONG_LONG_LONG_TOKEN")).toBe( + "LONG_LONG_LONG_TOKEN=LONG_L...OKEN", + ); + }); + + it("prefers an outer credential match over a nested one", () => { + expect(redactToolPayloadText('cookie: "TOKEN=abcdefghijklmno&abcdefghij"')).toBe( + 'cookie: "TOKEN=...ghij"', + ); + }); }); diff --git a/ui/src/lib/browser-redact.ts b/ui/src/lib/browser-redact.ts index 4d166662fae1..612e10528117 100644 --- a/ui/src/lib/browser-redact.ts +++ b/ui/src/lib/browser-redact.ts @@ -1,4 +1,6 @@ // Browser-safe redaction for tool details rendered by the Control UI. +import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; + const PAYMENT_CREDENTIAL_KEYS = "card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|securityCode|payment[-_]?credential|paymentCredential|shared[-_]?payment[-_]?token|sharedPaymentToken"; @@ -52,7 +54,9 @@ function redactToken(value: string): string { if (value.length <= 10) { return "***"; } - return `${value.slice(0, 6)}...${value.slice(-4)}`; + // A 6/4-shaped hint is intentionally idempotent: every non-separator + // character is already inside the visible prefix or suffix budget. + return `${sliceUtf16Safe(value, 0, 6)}...${sliceUtf16Safe(value, -4)}`; } function redactPemBlock(block: string): string { @@ -63,18 +67,28 @@ function redactPemBlock(block: string): string { return `${lines[0]}\n...redacted...\n${lines[lines.length - 1]}`; } +function redactMatch(match: string, groups: Array): string { + if (match.includes("PRIVATE KEY-----")) { + return redactPemBlock(match); + } + const token = groups.findLast((group) => typeof group === "string" && group.length > 0) ?? match; + const masked = redactToken(token); + if (token === match) { + return masked; + } + const tokenOffset = match.lastIndexOf(token); + if (tokenOffset < 0) { + return "***"; + } + return `${match.slice(0, tokenOffset)}${masked}${match.slice(tokenOffset + token.length)}`; +} + export function redactToolDetail(detail: string): string { let redacted = detail; for (const pattern of SECRET_DETAIL_PATTERNS) { - redacted = redacted.replace(pattern, (...args: string[]) => { - const match = args[0] ?? ""; - if (match.includes("PRIVATE KEY-----")) { - return redactPemBlock(match); - } - const groups = args.slice(1, -2); - const token = groups.findLast((group) => typeof group === "string" && group.length > 0); - return token ? match.replace(token, redactToken(token)) : "***"; - }); + redacted = redacted.replace(pattern, (...args: string[]) => + redactMatch(args[0] ?? "", args.slice(1, -2)), + ); } return redacted; }