Skip to content

Commit faaa7ef

Browse files
fix(security): inline redact into appendSessionTranscriptMessage (#79645)
Merged via squash. Prepared head SHA: da91ab6 Co-authored-by: app/clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819
1 parent 5ef9207 commit faaa7ef

14 files changed

Lines changed: 1217 additions & 120 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
88

99
- Agents: escalate LLM idle watchdog timeouts through profile rotation and configured model fallback instead of leaving agent turns stuck after a silent model stream. Fixes #76877. (#80449) Thanks @jimdawdy-hub.
1010
- ACPX: stop forwarding unsupported timeout config options to Claude ACP while preserving OpenClaw's own turn timeout. (#80812) Thanks @sxxtony.
11+
- Session transcripts: redact sensitive message content in the centralized JSONL append path so CLI turns, gateway transcript injection, transcript mirrors, and guarded tool results use the same configured redaction behavior. Fixes #73565. Refs #73563. (#79645) Thanks @app/clawsweeper.
1112
- Channels/iMessage: ignore Apple link-preview plugin payload attachments when users paste URLs, keeping the URL text while avoiding phantom media context. (#79374) Thanks @homer-byte.
1213
- Telegram: detect polling stalls from `getUpdates` liveness only, so outbound API calls no longer mask dead inbound polling; log polling-cycle starts after transport rebuilds. Fixes #78473.
1314
- fix(plugins): scan installed dependency runtime code [AI]. (#81066) Thanks @pgondhi987.

extensions/telegram/src/bot-message-dispatch.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,58 @@ describe("dispatchTelegramMessage draft streaming", () => {
959959
});
960960
});
961961

962+
it("emits the redacted appended message in transcript updates", async () => {
963+
setupDraftStreams({ answerMessageId: 2001 });
964+
const context = createContext();
965+
context.ctxPayload.SessionKey = "agent:default:telegram:direct:123";
966+
loadSessionStore.mockReturnValue({
967+
"agent:default:telegram:direct:123": { sessionId: "s1" },
968+
});
969+
appendSessionTranscriptMessage.mockImplementationOnce(async ({ message }) => ({
970+
messageId: "m1",
971+
message: {
972+
...(message as Record<string, unknown>),
973+
content: [{ type: "text", text: "Final sk-abc…0xyz" }],
974+
},
975+
}));
976+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
977+
await dispatcherOptions.deliver({ text: "Final sk-abcdef1234567890xyz" }, { kind: "final" });
978+
return { queuedFinal: true };
979+
});
980+
981+
await dispatchWithContext({ context });
982+
983+
expectRecordFields(mockCallArg(emitSessionTranscriptUpdate), {
984+
sessionFile: "/tmp/session.jsonl",
985+
sessionKey: "agent:default:telegram:direct:123",
986+
messageId: "m1",
987+
message: {
988+
role: "assistant",
989+
content: [{ type: "text", text: "Final sk-abc…0xyz" }],
990+
api: "openai-responses",
991+
provider: "openclaw",
992+
model: "delivery-mirror",
993+
usage: {
994+
input: 0,
995+
output: 0,
996+
total: 0,
997+
prompt_tokens: 0,
998+
completion_tokens: 0,
999+
total_tokens: 0,
1000+
cache: {
1001+
read: 0,
1002+
write: 0,
1003+
cacheRead: 0,
1004+
cacheWrite: 0,
1005+
total: 0,
1006+
},
1007+
},
1008+
stopReason: "stop",
1009+
timestamp: expect.any(Number),
1010+
},
1011+
});
1012+
});
1013+
9621014
it("streams block and final text through the same answer message", async () => {
9631015
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
9641016
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,15 +337,15 @@ async function mirrorTelegramAssistantReplyToTranscript(params: {
337337
stopReason: "stop" as const,
338338
timestamp: Date.now(),
339339
};
340-
const { messageId } = await appendSessionTranscriptMessage({
340+
const { messageId, message: appendedMessage } = await appendSessionTranscriptMessage({
341341
transcriptPath: sessionFile,
342342
message,
343343
config: params.cfg,
344344
});
345345
emitSessionTranscriptUpdate({
346346
sessionFile,
347347
sessionKey: params.sessionKey,
348-
message,
348+
message: appendedMessage,
349349
messageId,
350350
});
351351
}

src/agents/pi-embedded-runner.guard.test.ts

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -128,23 +128,15 @@ describe("guardSessionManager integration", () => {
128128
.filter((e) => e.type === "message")
129129
.map((e) => (e as { message: AgentMessage }).message);
130130

131-
expect(messages).toEqual([
132-
{
133-
role: "assistant",
134-
content: [
135-
{ type: "thinking", thinking: "the email is peter@d***.io", thinkingSignature: "sig" },
136-
{ type: "text", text: "contact peter@d***.io" },
137-
{ type: "toolCall", id: "call_1", name: "read", arguments: { path: "/tmp/peter@dc.io" } },
138-
],
139-
stopReason: "toolUse",
140-
},
141-
{
142-
role: "toolResult",
143-
toolCallId: "call_1",
144-
toolName: "read",
145-
content: [{ type: "text", text: "peter@d***.io\n" }],
146-
isError: false,
147-
},
148-
]);
131+
const serialized = JSON.stringify(messages);
132+
133+
expect(serialized).not.toContain("the email is peter@dc.io");
134+
expect(serialized).not.toContain("contact peter@dc.io");
135+
expect(serialized).not.toContain("peter@dc.io\\n");
136+
expect(serialized).not.toContain('"/tmp/peter@dc.io"');
137+
expect(serialized).toContain('"thinking":"the email is peter@d***.io"');
138+
expect(serialized).toContain('"text":"contact peter@d***.io"');
139+
expect(serialized).toContain('"text":"peter@d***.io\\n"');
140+
expect(serialized).toContain('"/tmp/peter@d***.io"');
149141
});
150142
});

src/agents/session-tool-result-guard-wrapper.ts

Lines changed: 1 addition & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
import type { SessionManager } from "@earendil-works/pi-coding-agent";
33
import type { OpenClawConfig } from "../config/types.openclaw.js";
4-
import { redactSensitiveText } from "../logging/redact.js";
54
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
65
import {
76
applyInputProvenanceToUserMessage,
87
type InputProvenance,
98
} from "../sessions/input-provenance.js";
109
import { resolveLiveToolResultMaxChars } from "./pi-embedded-runner/tool-result-truncation.js";
1110
import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
11+
import { redactTranscriptMessage } from "./transcript-redact.js";
1212

1313
type GuardedSessionManager = SessionManager & {
1414
/** Flush any synthetic tool results for pending tool calls. Idempotent. */
@@ -17,71 +17,6 @@ type GuardedSessionManager = SessionManager & {
1717
clearPendingToolResults?: () => void;
1818
};
1919

20-
function redactTranscriptText(value: string, cfg?: OpenClawConfig): string {
21-
if (cfg?.logging?.redactSensitive === "off") {
22-
return value;
23-
}
24-
return redactSensitiveText(value, {
25-
mode: cfg?.logging?.redactSensitive,
26-
patterns: cfg?.logging?.redactPatterns,
27-
});
28-
}
29-
30-
function redactTranscriptContentBlock(block: unknown, cfg?: OpenClawConfig): unknown {
31-
if (!block || typeof block !== "object" || Array.isArray(block)) {
32-
return block;
33-
}
34-
const source = block as Record<string, unknown>;
35-
let next: Record<string, unknown> | null = null;
36-
const assign = (key: string, value: string) => {
37-
const redacted = redactTranscriptText(value, cfg);
38-
if (redacted === value) {
39-
return;
40-
}
41-
next ??= { ...source };
42-
next[key] = redacted;
43-
};
44-
45-
if (typeof source.text === "string") {
46-
assign("text", source.text);
47-
}
48-
if (typeof source.thinking === "string") {
49-
assign("thinking", source.thinking);
50-
}
51-
if (typeof source.partialJson === "string") {
52-
assign("partialJson", source.partialJson);
53-
}
54-
return next ?? block;
55-
}
56-
57-
function redactTranscriptContent(content: unknown, cfg?: OpenClawConfig): unknown {
58-
if (typeof content === "string") {
59-
return redactTranscriptText(content, cfg);
60-
}
61-
if (!Array.isArray(content)) {
62-
return content;
63-
}
64-
let changed = false;
65-
const redacted = content.map((block) => {
66-
const next = redactTranscriptContentBlock(block, cfg);
67-
changed ||= next !== block;
68-
return next;
69-
});
70-
return changed ? redacted : content;
71-
}
72-
73-
function redactTranscriptMessage(message: AgentMessage, cfg?: OpenClawConfig): AgentMessage {
74-
const source = message as unknown as Record<string, unknown>;
75-
const redactedContent = redactTranscriptContent(source.content, cfg);
76-
if (redactedContent === source.content) {
77-
return message;
78-
}
79-
return {
80-
...source,
81-
content: redactedContent,
82-
} as unknown as AgentMessage;
83-
}
84-
8520
/**
8621
* Apply the tool-result guard to a SessionManager exactly once and expose
8722
* a flush method on the instance for easy teardown handling.

src/agents/session-tool-result-guard.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SessionManager } from "@earendil-works/pi-coding-agent";
33
import { describe, expect, it } from "vitest";
44
import { installSessionToolResultGuard } from "./session-tool-result-guard.js";
55
import { castAgentMessage } from "./test-helpers/agent-message-fixtures.js";
6+
import { redactTranscriptMessage } from "./transcript-redact.js";
67

78
type AppendMessage = Parameters<SessionManager["appendMessage"]>[0];
89

@@ -452,6 +453,53 @@ describe("installSessionToolResultGuard", () => {
452453
expect(text).toBe("rewritten by hook");
453454
});
454455

456+
it("applies before_message_write redaction to tool-result details before persistence", () => {
457+
const sm = SessionManager.inMemory();
458+
installSessionToolResultGuard(sm, {
459+
beforeMessageWriteHook: ({ message }) => ({
460+
message: redactTranscriptMessage(message, { logging: { redactSensitive: "tools" } }),
461+
}),
462+
});
463+
464+
sm.appendMessage(toolCallMessage);
465+
sm.appendMessage(
466+
asAppendMessage({
467+
role: "toolResult",
468+
toolCallId: "call_1",
469+
toolName: "read",
470+
content: [{ type: "text", text: "result sk-abcdef1234567890xyz" }],
471+
details: {
472+
apiKey: "plainsecretvalue123",
473+
password: "hunter2",
474+
nested: { accessToken: ["nestedplainsecret123"] },
475+
safe: "visible",
476+
},
477+
isError: false,
478+
timestamp: Date.now(),
479+
}),
480+
);
481+
482+
const messages = getPersistedMessages(sm);
483+
const toolResult = messages.find((m) => m.role === "toolResult") as unknown as {
484+
content: Array<{ text: string }>;
485+
details: {
486+
apiKey: string;
487+
password: string;
488+
nested: { accessToken: string[] };
489+
safe: string;
490+
};
491+
};
492+
const serializedToolResult = JSON.stringify(toolResult);
493+
expect(toolResult.content[0].text).not.toContain("sk-abcdef1234567890xyz");
494+
expect(serializedToolResult).not.toContain("plainsecretvalue123");
495+
expect(serializedToolResult).not.toContain("hunter2");
496+
expect(serializedToolResult).not.toContain("nestedplainsecret123");
497+
expect(toolResult.details.apiKey).toBe("***");
498+
expect(toolResult.details.password).toBe("***");
499+
expect(toolResult.details.nested.accessToken[0]).toBe("***");
500+
expect(serializedToolResult).toContain("visible");
501+
});
502+
455503
it("applies before_message_write to synthetic tool-result flushes", () => {
456504
const sm = SessionManager.inMemory();
457505
const guard = installSessionToolResultGuard(sm, {

0 commit comments

Comments
 (0)