mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 21:00:44 +00:00
fix(agents): redact Control UI tool payload secrets (#72319)
Fixes #72283. - Redacts Control UI tool start args, partial/final result payloads, derived exec output, and patch summaries before event emission. - Forces tool/UI payload redaction to include built-in patterns plus configured custom `logging.redactPatterns`. - Covers object, details-only, primitive string, and top-level array tool-result shapes. Tests: - `pnpm test src/agents/pi-embedded-subscribe.tools.test.ts src/agents/pi-embedded-subscribe.handlers.tools.test.ts` - `pnpm check:changed` Co-authored-by: volcano303 <75143900+volcano303@users.noreply.github.com> Co-authored-by: Val Alexander <bunsthedev@gmail.com>
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev.
|
||||||
- CLI/model probes: fail local `infer model run` probes when the provider returns no text output, so unreachable local providers and empty completions no longer look like successful smoke tests. Refs #73023. Thanks @pavelyortho-cyber.
|
- CLI/model probes: fail local `infer model run` probes when the provider returns no text output, so unreachable local providers and empty completions no longer look like successful smoke tests. Refs #73023. Thanks @pavelyortho-cyber.
|
||||||
- CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020.
|
- CLI/Ollama: run local `infer model run` through the lean provider completion path and skip global model discovery for one-shot local probes, so Ollama smoke tests no longer pay full chat-agent/tool startup cost or hang before the native `/api/chat` request. Fixes #72851. Thanks @TotalRes2020.
|
||||||
- Doctor/gateway services: ignore launchd/systemd companion services that only reference the gateway as a dependency, suppress inactive Linux extra-service warnings, and avoid rewriting a running systemd gateway command/entrypoint during doctor repair. Carries forward #39118. Thanks @therk.
|
- Doctor/gateway services: ignore launchd/systemd companion services that only reference the gateway as a dependency, suppress inactive Linux extra-service warnings, and avoid rewriting a running systemd gateway command/entrypoint during doctor repair. Carries forward #39118. Thanks @therk.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
import type { AgentEvent } from "@mariozechner/pi-agent-core";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
onAgentEvent as registerAgentEventListener,
|
||||||
|
resetAgentEventsForTest,
|
||||||
|
} from "../infra/agent-events.js";
|
||||||
import type { MessagingToolSend } from "./pi-embedded-messaging.types.js";
|
import type { MessagingToolSend } from "./pi-embedded-messaging.types.js";
|
||||||
import {
|
import {
|
||||||
handleToolExecutionEnd,
|
handleToolExecutionEnd,
|
||||||
@@ -959,3 +963,147 @@ describe("messaging tool media URL tracking", () => {
|
|||||||
expect(ctx.state.pendingMessagingMediaUrls.has("tool-m3")).toBe(false);
|
expect(ctx.state.pendingMessagingMediaUrls.has("tool-m3")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("control UI credential redaction (issue #72283)", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
resetAgentEventsForTest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets in args before emitting the tool start event", async () => {
|
||||||
|
const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
|
||||||
|
registerAgentEventListener((evt) => {
|
||||||
|
events.push(evt as never);
|
||||||
|
});
|
||||||
|
const { ctx } = createTestContext();
|
||||||
|
|
||||||
|
await handleToolExecutionStart(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_start",
|
||||||
|
toolName: "gateway",
|
||||||
|
toolCallId: "tool-secret-args",
|
||||||
|
args: {
|
||||||
|
action: "config.apply",
|
||||||
|
raw: 'apiKey: "sk-1234567890abcdefXYZ"',
|
||||||
|
headers: { Authorization: "Bearer abcdef0123456789QWERTY=" },
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const startEvent = events.find(
|
||||||
|
(evt) => evt.stream === "tool" && (evt.data as { phase?: string })?.phase === "start",
|
||||||
|
);
|
||||||
|
expect(startEvent).toBeDefined();
|
||||||
|
const emittedArgs = (startEvent?.data as { args?: Record<string, unknown> })?.args ?? {};
|
||||||
|
const serialized = JSON.stringify(emittedArgs);
|
||||||
|
expect(serialized).not.toContain("sk-1234567890abcdefXYZ");
|
||||||
|
expect(serialized).not.toContain("abcdef0123456789QWERTY=");
|
||||||
|
expect(serialized).toContain("config.apply");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets in exec aggregated stdout before emitting command_output", async () => {
|
||||||
|
const { ctx, onAgentEvent } = createTestContext();
|
||||||
|
|
||||||
|
await handleToolExecutionStart(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_start",
|
||||||
|
toolName: "exec",
|
||||||
|
toolCallId: "tool-exec-secret",
|
||||||
|
args: { command: "cat ~/.openclaw/openclaw.json" },
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleToolExecutionEnd(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_end",
|
||||||
|
toolName: "exec",
|
||||||
|
toolCallId: "tool-exec-secret",
|
||||||
|
isError: false,
|
||||||
|
result: {
|
||||||
|
details: {
|
||||||
|
status: "completed",
|
||||||
|
aggregated:
|
||||||
|
'OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789\napiKey: "ghp_abcdefghij1234567890"',
|
||||||
|
exitCode: 0,
|
||||||
|
durationMs: 12,
|
||||||
|
cwd: "/tmp/work",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const commandOutputCalls = onAgentEvent.mock.calls
|
||||||
|
.map((call) => call[0])
|
||||||
|
.filter((arg: unknown) => (arg as { stream?: string })?.stream === "command_output");
|
||||||
|
expect(commandOutputCalls.length).toBeGreaterThan(0);
|
||||||
|
const lastOutput = commandOutputCalls.at(-1) as { data?: { output?: string } } | undefined;
|
||||||
|
expect(lastOutput?.data?.output).toBeDefined();
|
||||||
|
expect(lastOutput?.data?.output).not.toContain("sk-or-v1-abcdef0123456789");
|
||||||
|
expect(lastOutput?.data?.output).not.toContain("ghp_abcdefghij1234567890");
|
||||||
|
expect(lastOutput?.data?.output).toContain("OPENROUTER_API_KEY=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts details-only results before emitting the tool result event", async () => {
|
||||||
|
const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
|
||||||
|
registerAgentEventListener((evt) => {
|
||||||
|
events.push(evt as never);
|
||||||
|
});
|
||||||
|
const { ctx } = createTestContext();
|
||||||
|
|
||||||
|
await handleToolExecutionEnd(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_end",
|
||||||
|
toolName: "gateway",
|
||||||
|
toolCallId: "tool-details-secret",
|
||||||
|
isError: false,
|
||||||
|
result: {
|
||||||
|
details: {
|
||||||
|
config: { apiKey: "sk-1234567890abcdefXYZ", model: "gpt-4" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultEvent = events.find(
|
||||||
|
(evt) => evt.stream === "tool" && (evt.data as { phase?: string })?.phase === "result",
|
||||||
|
);
|
||||||
|
expect(resultEvent).toBeDefined();
|
||||||
|
const serialized = JSON.stringify(resultEvent?.data?.result);
|
||||||
|
expect(serialized).not.toContain("sk-1234567890abcdefXYZ");
|
||||||
|
expect(serialized).toContain("gpt-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts primitive string results before emitting the tool result event", async () => {
|
||||||
|
const events: Array<{ stream?: string; data?: Record<string, unknown> }> = [];
|
||||||
|
registerAgentEventListener((evt) => {
|
||||||
|
events.push(evt as never);
|
||||||
|
});
|
||||||
|
const { ctx } = createTestContext();
|
||||||
|
|
||||||
|
await handleToolExecutionEnd(
|
||||||
|
ctx as never,
|
||||||
|
{
|
||||||
|
type: "tool_execution_end",
|
||||||
|
toolName: "gateway",
|
||||||
|
toolCallId: "tool-string-secret",
|
||||||
|
isError: false,
|
||||||
|
result: "OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789",
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultEvent = events.find(
|
||||||
|
(evt) => evt.stream === "tool" && (evt.data as { phase?: string })?.phase === "result",
|
||||||
|
);
|
||||||
|
expect(resultEvent).toBeDefined();
|
||||||
|
const emittedResult = resultEvent?.data?.result;
|
||||||
|
expect(typeof emittedResult).toBe("string");
|
||||||
|
if (typeof emittedResult !== "string") {
|
||||||
|
throw new Error("expected string result");
|
||||||
|
}
|
||||||
|
expect(emittedResult).not.toContain("sk-or-v1-abcdef0123456789");
|
||||||
|
expect(emittedResult).toContain("OPENROUTER_API_KEY=");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
filterToolResultMediaUrls,
|
filterToolResultMediaUrls,
|
||||||
isToolResultError,
|
isToolResultError,
|
||||||
isToolResultTimedOut,
|
isToolResultTimedOut,
|
||||||
|
sanitizeToolArgs,
|
||||||
sanitizeToolResult,
|
sanitizeToolResult,
|
||||||
} from "./pi-embedded-subscribe.tools.js";
|
} from "./pi-embedded-subscribe.tools.js";
|
||||||
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
||||||
@@ -634,7 +635,7 @@ export function handleToolExecutionStart(
|
|||||||
phase: "start",
|
phase: "start",
|
||||||
name: toolName,
|
name: toolName,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
args: args as Record<string, unknown>,
|
args: sanitizeToolArgs(args) as Record<string, unknown>,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const itemData: AgentItemEventData = {
|
const itemData: AgentItemEventData = {
|
||||||
@@ -945,7 +946,8 @@ export async function handleToolExecutionEnd(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isExecToolName(toolName)) {
|
if (isExecToolName(toolName)) {
|
||||||
const execDetails = readExecToolDetails(result);
|
// Use sanitizedResult so `aggregated` is redacted before reaching command_output.
|
||||||
|
const execDetails = readExecToolDetails(sanitizedResult);
|
||||||
const commandItemId = buildCommandItemId(toolCallId);
|
const commandItemId = buildCommandItemId(toolCallId);
|
||||||
if (
|
if (
|
||||||
execDetails?.status === "approval-pending" ||
|
execDetails?.status === "approval-pending" ||
|
||||||
@@ -1083,7 +1085,7 @@ export async function handleToolExecutionEnd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPatchToolName(toolName)) {
|
if (isPatchToolName(toolName)) {
|
||||||
const patchSummary = readApplyPatchSummary(result);
|
const patchSummary = readApplyPatchSummary(sanitizedResult);
|
||||||
const patchItemId = buildPatchItemId(toolCallId);
|
const patchItemId = buildPatchItemId(toolCallId);
|
||||||
const summaryText = patchSummary ? buildPatchSummaryText(patchSummary) : undefined;
|
const summaryText = patchSummary ? buildPatchSummaryText(patchSummary) : undefined;
|
||||||
emitTrackedItemEvent(ctx, {
|
emitTrackedItemEvent(ctx, {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { extractToolErrorMessage } from "./pi-embedded-subscribe.tools.js";
|
import * as loggingConfigModule from "../logging/config.js";
|
||||||
|
import {
|
||||||
|
extractToolErrorMessage,
|
||||||
|
sanitizeToolArgs,
|
||||||
|
sanitizeToolResult,
|
||||||
|
} from "./pi-embedded-subscribe.tools.js";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe("extractToolErrorMessage", () => {
|
describe("extractToolErrorMessage", () => {
|
||||||
it("ignores non-error status values", () => {
|
it("ignores non-error status values", () => {
|
||||||
@@ -34,3 +43,178 @@ describe("extractToolErrorMessage", () => {
|
|||||||
).toBe("SYSTEM_RUN_DENIED: approval required");
|
).toBe("SYSTEM_RUN_DENIED: approval required");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getTextContent(result: unknown, index = 0): string {
|
||||||
|
const record = result as { content: Array<{ text: string }> };
|
||||||
|
return record.content[index].text;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sanitizeToolResult", () => {
|
||||||
|
it("redacts JSON-style apiKey fields in text content blocks", () => {
|
||||||
|
const result = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: '{"apiKey":"sk-1234567890abcdef","model":"gpt-4"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = getTextContent(sanitizeToolResult(result));
|
||||||
|
expect(text).not.toContain("sk-1234567890abcdef");
|
||||||
|
expect(text).toContain("model");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts ENV-style credential assignments", () => {
|
||||||
|
const result = {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789\nMODEL=gpt-4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const text = getTextContent(sanitizeToolResult(result));
|
||||||
|
expect(text).not.toContain("sk-or-v1-abcdef0123456789");
|
||||||
|
expect(text).toContain("MODEL=gpt-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts Bearer authorization tokens", () => {
|
||||||
|
const result = {
|
||||||
|
content: [{ type: "text", text: "Authorization: Bearer abcdef0123456789QWERTY=" }],
|
||||||
|
};
|
||||||
|
const text = getTextContent(sanitizeToolResult(result));
|
||||||
|
expect(text).not.toContain("abcdef0123456789QWERTY=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves image content stripping behavior", () => {
|
||||||
|
const result = {
|
||||||
|
content: [{ type: "image", data: "base64imagedata", mimeType: "image/png" }],
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeToolResult(result) as {
|
||||||
|
content: Array<{ data?: string; bytes?: number; omitted?: boolean }>;
|
||||||
|
};
|
||||||
|
expect(sanitized.content[0].data).toBeUndefined();
|
||||||
|
expect(sanitized.content[0].omitted).toBe(true);
|
||||||
|
expect(sanitized.content[0].bytes).toBe("base64imagedata".length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets inside result.details (e.g. exec aggregated stdout)", () => {
|
||||||
|
const result = {
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
details: {
|
||||||
|
status: "completed",
|
||||||
|
aggregated:
|
||||||
|
'OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789\napiKey: "ghp_abcdefghij1234567890"',
|
||||||
|
exitCode: 0,
|
||||||
|
cwd: "/tmp/work",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeToolResult(result) as {
|
||||||
|
details: { status: string; aggregated: string; exitCode: number; cwd: string };
|
||||||
|
};
|
||||||
|
expect(sanitized.details.aggregated).not.toContain("sk-or-v1-abcdef0123456789");
|
||||||
|
expect(sanitized.details.aggregated).not.toContain("ghp_abcdefghij1234567890");
|
||||||
|
expect(sanitized.details.status).toBe("completed");
|
||||||
|
expect(sanitized.details.exitCode).toBe(0);
|
||||||
|
expect(sanitized.details.cwd).toBe("/tmp/work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets at the top level outside content/details", () => {
|
||||||
|
const result = {
|
||||||
|
output: "OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789",
|
||||||
|
metadata: {
|
||||||
|
token: "ghp_abcdefghij1234567890ABCDEF",
|
||||||
|
nested: { auth: "Bearer abcdef0123456789QWERTY=" },
|
||||||
|
},
|
||||||
|
summary: "ok",
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeToolResult(result) as {
|
||||||
|
output: string;
|
||||||
|
metadata: { token: string; nested: { auth: string } };
|
||||||
|
summary: string;
|
||||||
|
};
|
||||||
|
expect(sanitized.output).not.toContain("sk-or-v1-abcdef0123456789");
|
||||||
|
expect(sanitized.metadata.token).not.toContain("ghp_abcdefghij1234567890ABCDEF");
|
||||||
|
expect(sanitized.metadata.nested.auth).not.toContain("abcdef0123456789QWERTY=");
|
||||||
|
expect(sanitized.summary).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts a details-only result with no content array", () => {
|
||||||
|
const result = {
|
||||||
|
details: {
|
||||||
|
config: { apiKey: "sk-1234567890abcdefXYZ", model: "gpt-4" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeToolResult(result) as {
|
||||||
|
details: { config: { apiKey: string; model: string } };
|
||||||
|
};
|
||||||
|
expect(sanitized.details.config.apiKey).not.toContain("sk-1234567890abcdefXYZ");
|
||||||
|
expect(sanitized.details.config.model).toBe("gpt-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts primitive string results", () => {
|
||||||
|
const sanitized = sanitizeToolResult("OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789") as string;
|
||||||
|
|
||||||
|
expect(sanitized).not.toContain("sk-or-v1-abcdef0123456789");
|
||||||
|
expect(sanitized).toContain("OPENROUTER_API_KEY=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves top-level arrays while redacting nested strings", () => {
|
||||||
|
const sanitized = sanitizeToolResult([
|
||||||
|
{ output: "Authorization: Bearer abcdef0123456789QWERTY=" },
|
||||||
|
"apiKey=sk-1234567890abcdefXYZ",
|
||||||
|
]) as Array<{ output: string } | string>;
|
||||||
|
|
||||||
|
expect(Array.isArray(sanitized)).toBe(true);
|
||||||
|
expect(JSON.stringify(sanitized)).not.toContain("abcdef0123456789QWERTY=");
|
||||||
|
expect(JSON.stringify(sanitized)).not.toContain("sk-1234567890abcdefXYZ");
|
||||||
|
expect((sanitized[0] as { output: string }).output).toContain("Authorization: Bearer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies configured redact patterns to Control UI tool payloads", () => {
|
||||||
|
vi.spyOn(loggingConfigModule, "readLoggingConfig").mockReturnValue({
|
||||||
|
redactSensitive: "off",
|
||||||
|
redactPatterns: [String.raw`\bcustom-secret-[A-Za-z0-9]+\b`],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
content: [{ type: "text", text: "value custom-secret-abc123" }],
|
||||||
|
};
|
||||||
|
const text = getTextContent(sanitizeToolResult(result));
|
||||||
|
|
||||||
|
expect(text).not.toContain("custom-secret-abc123");
|
||||||
|
expect(text).toContain("custom…c123");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeToolArgs", () => {
|
||||||
|
it("redacts string-valued credentials nested anywhere in args", () => {
|
||||||
|
const args = {
|
||||||
|
apiKey: "sk-1234567890abcdefXYZ",
|
||||||
|
headers: { Authorization: "Bearer abcdef0123456789QWERTY=" },
|
||||||
|
command: "OPENROUTER_API_KEY=sk-or-v1-abcdef0123456789 ./run.sh",
|
||||||
|
flags: ["--api-key", "sk-1234567890abcdefXYZ"],
|
||||||
|
};
|
||||||
|
const sanitized = sanitizeToolArgs(args) as {
|
||||||
|
apiKey: string;
|
||||||
|
headers: { Authorization: string };
|
||||||
|
command: string;
|
||||||
|
flags: string[];
|
||||||
|
};
|
||||||
|
expect(sanitized.apiKey).not.toContain("sk-1234567890abcdefXYZ");
|
||||||
|
expect(sanitized.headers.Authorization).not.toContain("abcdef0123456789QWERTY=");
|
||||||
|
expect(sanitized.command).not.toContain("sk-or-v1-abcdef0123456789");
|
||||||
|
expect(sanitized.flags.join(" ")).not.toContain("sk-1234567890abcdefXYZ");
|
||||||
|
expect(sanitized.flags[0]).toBe("--api-key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through null/undefined and non-string primitives unchanged", () => {
|
||||||
|
expect(sanitizeToolArgs(undefined)).toBeUndefined();
|
||||||
|
expect(sanitizeToolArgs(null)).toBeNull();
|
||||||
|
expect(sanitizeToolArgs(42)).toBe(42);
|
||||||
|
expect(sanitizeToolArgs({ count: 3, file_path: "/tmp/x.txt" })).toEqual({
|
||||||
|
count: 3,
|
||||||
|
file_path: "/tmp/x.txt",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
|
||||||
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
|
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
|
||||||
|
import { redactToolPayloadText } from "../logging/redact.js";
|
||||||
import { splitMediaFromOutput } from "../media/parse.js";
|
import { splitMediaFromOutput } from "../media/parse.js";
|
||||||
import { pluginRegistrationContractRegistry } from "../plugins/contracts/registry.js";
|
import { pluginRegistrationContractRegistry } from "../plugins/contracts/registry.js";
|
||||||
import {
|
import {
|
||||||
@@ -114,34 +115,84 @@ function isHostDenialToolText(text: string): boolean {
|
|||||||
return normalized.toLowerCase().includes("approval cannot safely bind");
|
return normalized.toLowerCase().includes("approval cannot safely bind");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redactStringsDeep(value: unknown, seen = new WeakSet<object>()): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return redactToolPayloadText(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return "[Circular]";
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
return value.map((item) => redactStringsDeep(item, seen));
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
if (seen.has(value)) {
|
||||||
|
return "[Circular]";
|
||||||
|
}
|
||||||
|
seen.add(value);
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[key] = redactStringsDeep(child, seen);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeToolArgs(args: unknown): unknown {
|
||||||
|
return redactStringsDeep(args);
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeToolResult(result: unknown): unknown {
|
export function sanitizeToolResult(result: unknown): unknown {
|
||||||
|
if (typeof result === "string") {
|
||||||
|
return redactToolPayloadText(result);
|
||||||
|
}
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
return redactStringsDeep(result);
|
||||||
|
}
|
||||||
if (!result || typeof result !== "object") {
|
if (!result || typeof result !== "object") {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
const record = result as Record<string, unknown>;
|
const record = result as Record<string, unknown>;
|
||||||
const content = Array.isArray(record.content) ? record.content : null;
|
// Strip image data first so the deep redaction pass doesn't waste work
|
||||||
if (!content) {
|
// scanning base64 payloads (and so we capture the original byte counts).
|
||||||
return record;
|
const preCleaned: Record<string, unknown> = { ...record };
|
||||||
|
const originalContent = Array.isArray(record.content) ? record.content : null;
|
||||||
|
if (originalContent) {
|
||||||
|
preCleaned.content = originalContent.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const entry = item as Record<string, unknown>;
|
||||||
|
if (readStringValue(entry.type) === "image") {
|
||||||
|
const data = readStringValue(entry.data);
|
||||||
|
const bytes = data ? data.length : undefined;
|
||||||
|
const cleaned = { ...entry };
|
||||||
|
delete cleaned.data;
|
||||||
|
return Object.assign({}, cleaned, { bytes, omitted: true });
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const sanitized = content.map((item) => {
|
// Deep-redact the entire result so any top-level or nested string is
|
||||||
if (!item || typeof item !== "object") {
|
// protected, not just `details` and text content blocks.
|
||||||
return item;
|
const baseline = redactStringsDeep(preCleaned) as Record<string, unknown>;
|
||||||
}
|
const out: Record<string, unknown> = { ...baseline };
|
||||||
const entry = item as Record<string, unknown>;
|
const content = Array.isArray(baseline.content) ? baseline.content : null;
|
||||||
const type = readStringValue(entry.type);
|
if (content) {
|
||||||
if (type === "text" && typeof entry.text === "string") {
|
out.content = content.map((item) => {
|
||||||
return Object.assign({}, entry, { text: truncateToolText(entry.text) });
|
if (!item || typeof item !== "object") {
|
||||||
}
|
return item;
|
||||||
if (type === "image") {
|
}
|
||||||
const data = readStringValue(entry.data);
|
const entry = item as Record<string, unknown>;
|
||||||
const bytes = data ? data.length : undefined;
|
if (readStringValue(entry.type) === "text" && typeof entry.text === "string") {
|
||||||
const cleaned = { ...entry };
|
return Object.assign({}, entry, { text: truncateToolText(entry.text) });
|
||||||
delete cleaned.data;
|
}
|
||||||
return Object.assign({}, cleaned, { bytes, omitted: true });
|
return entry;
|
||||||
}
|
});
|
||||||
return entry;
|
}
|
||||||
});
|
return out;
|
||||||
return { ...record, content: sanitized };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractToolResultText(result: unknown): string | undefined {
|
export function extractToolResultText(result: unknown): string | undefined {
|
||||||
|
|||||||
@@ -165,6 +165,22 @@ export function redactToolDetail(detail: string): string {
|
|||||||
return redactSensitiveText(detail, resolved);
|
return redactSensitiveText(detail, resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forces tools-mode regardless of `logging.redactSensitive` (which governs log
|
||||||
|
// output, not UI surfaces), and merges user `logging.redactPatterns` with the
|
||||||
|
// built-in defaults so both apply.
|
||||||
|
export function redactToolPayloadText(text: string): string {
|
||||||
|
if (!text) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
const cfg = readLoggingConfig();
|
||||||
|
const userPatterns = cfg?.redactPatterns;
|
||||||
|
const patterns =
|
||||||
|
userPatterns && userPatterns.length > 0
|
||||||
|
? [...userPatterns, ...DEFAULT_REDACT_PATTERNS]
|
||||||
|
: undefined;
|
||||||
|
return redactSensitiveText(text, { mode: "tools", patterns });
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultRedactPatterns(): string[] {
|
export function getDefaultRedactPatterns(): string[] {
|
||||||
return [...DEFAULT_REDACT_PATTERNS];
|
return [...DEFAULT_REDACT_PATTERNS];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user