fix: redact trajectory exports consistently (#89354)

* fix trajectory export redaction

* fix trajectory export top-level redaction

* fix trajectory export key redaction

* fix trajectory export structural key redaction
This commit is contained in:
Pavan Kumar Gondhi
2026-06-02 15:41:44 +05:30
committed by GitHub
parent 2664f59519
commit 19fb9f1299
2 changed files with 250 additions and 20 deletions
+166
View File
@@ -272,6 +272,172 @@ describe("exportTrajectoryBundle", () => {
);
});
it("redacts broad secret patterns from every exported bundle file", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl");
const outputDir = path.join(tmpDir, "bundle");
const rawSecrets = [
"sk-exported-session-secret",
"ghp_123456789012345678901234",
"xoxb-1234567890-abcdefghijkl",
"ya29.exported-access-token-with-enough-length",
"ADMIN_PASSWORD=plain-text-password",
"sk-top-level-export-secret",
];
const header = {
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-04-01T05:46:39.000Z",
cwd: tmpDir,
};
const userEntry = {
type: "message",
id: "entry-user",
parentId: null,
timestamp: "2026-04-01T05:46:40.000Z",
message: userMessage(`user pasted ${rawSecrets[0]} keep-visible-marker`),
};
const assistantEntry = {
type: "message",
id: "entry-assistant",
parentId: "entry-user",
timestamp: "2026-04-01T05:46:41.000Z",
message: assistantMessage([
{
type: "toolCall",
id: "call_1",
name: "read",
arguments: {
[rawSecrets[5]]: "secret-looking tool argument key",
command: `curl -H 'Authorization: Bearer ${rawSecrets[1]}'`,
},
},
]),
};
const compactionEntry = {
type: "compaction",
id: "entry-compaction",
parentId: "entry-assistant",
timestamp: "2026-04-01T05:46:42.000Z",
summary: `compaction summary saw ${rawSecrets[2]}`,
firstKeptEntryId: "entry-assistant",
tokensBefore: 1024,
details: { note: rawSecrets[3] },
};
const branchSummaryEntry = {
type: "branch_summary",
id: "entry-branch-summary",
parentId: "entry-compaction",
timestamp: "2026-04-01T05:46:43.000Z",
fromId: "entry-assistant",
summary: `branch summary saw ${rawSecrets[4]}`,
details: { token: rawSecrets[0] },
};
fs.writeFileSync(
sessionFile,
`${[header, userEntry, assistantEntry, compactionEntry, branchSummaryEntry]
.map((entry) => JSON.stringify(entry))
.join("\n")}\n`,
"utf8",
);
fs.writeFileSync(
runtimeFile,
[
{
traceSchema: "openclaw-trajectory",
schemaVersion: 1,
traceId: "session-1",
source: "runtime",
type: "context.compiled",
ts: "2026-04-22T08:00:00.000Z",
seq: 1,
sourceSeq: 1,
sessionId: "session-1",
apiKey: rawSecrets[5],
data: {
systemPrompt: `system includes ${rawSecrets[1]}`,
tools: [{ name: "danger", description: `tool mentions ${rawSecrets[2]}` }],
},
},
{
traceSchema: "openclaw-trajectory",
schemaVersion: 1,
traceId: "session-1",
source: "runtime",
type: "trace.metadata",
ts: "2026-04-22T08:00:01.000Z",
seq: 2,
sourceSeq: 2,
sessionId: "session-1",
data: {
harness: { type: "openclaw", token: rawSecrets[3] },
metadata: {
[`https://example.test/callback?token=${rawSecrets[1]}`]:
"secret-looking metadata key",
},
prompting: {
skillsPrompt: `skills ${rawSecrets[4]}`,
userPromptPrefixText: `prefix ${rawSecrets[0]}`,
},
},
},
{
traceSchema: "openclaw-trajectory",
schemaVersion: 1,
traceId: "session-1",
source: "runtime",
type: "prompt.submitted",
ts: "2026-04-22T08:00:02.000Z",
seq: 3,
sourceSeq: 3,
sessionId: "session-1",
data: { prompt: `submitted ${rawSecrets[1]}` },
},
{
traceSchema: "openclaw-trajectory",
schemaVersion: 1,
traceId: "session-1",
source: "runtime",
type: "trace.artifacts",
ts: "2026-04-22T08:00:03.000Z",
seq: 4,
sourceSeq: 4,
sessionId: "session-1",
runId: rawSecrets[5],
data: {
assistantTexts: [`assistant ${rawSecrets[2]}`],
finalPromptText: `final ${rawSecrets[3]}`,
},
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
"utf8",
);
const bundle = await exportTrajectoryBundle({
outputDir,
sessionFile,
sessionId: "session-1",
sessionKey: rawSecrets[5],
workspaceDir: tmpDir,
runtimeFile,
});
const exportedBundleText = fs
.readdirSync(outputDir)
.map((file) => fs.readFileSync(path.join(outputDir, file), "utf8"))
.join("\n");
expect(exportedBundleText).toContain("keep-visible-marker");
for (const secret of rawSecrets) {
expect(exportedBundleText).not.toContain(secret);
}
expect(JSON.stringify(bundle.events)).not.toContain(rawSecrets[5]);
expect(JSON.stringify(bundle.manifest)).not.toContain(rawSecrets[5]);
});
it("rejects oversized runtime trajectory files", async () => {
const tmpDir = makeTempDir();
const sessionFile = path.join(tmpDir, "session.jsonl");
+84 -20
View File
@@ -18,6 +18,7 @@ import {
redactSupportString,
type SupportRedactionContext,
} from "../logging/diagnostic-support-redaction.js";
import { redactSecrets, redactToolPayloadText } from "../logging/redact.js";
import { safeJsonStringify } from "../utils/safe-json.js";
import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, safeTrajectorySessionFileName } from "./paths.js";
import { isRegularNonSymlinkFile, resolveTrajectoryRuntimeFile } from "./runtime-file.js";
@@ -389,6 +390,10 @@ function extractAssistantToolCalls(
});
}
function sanitizeTrajectoryExportValue<T>(value: T): T {
return redactSecrets(sanitizeDiagnosticPayload(value)) as T;
}
function buildTranscriptEvents(params: {
entries: SessionEntry[];
sessionId: string;
@@ -525,6 +530,15 @@ function trajectoryJsonlFile(
return jsonlSupportBundleFile(pathName, lines);
}
function redactTrajectoryBundleFileContent(
file: DiagnosticSupportBundleFile,
): DiagnosticSupportBundleFile {
return {
...file,
content: redactToolPayloadText(file.content),
};
}
function buildTrajectoryExportRedaction(params: {
workspaceDir: string;
}): TrajectoryExportRedaction {
@@ -585,19 +599,55 @@ function redactLocalPathValues(value: unknown, redaction: TrajectoryExportRedact
return next;
}
function uniqueRedactedObjectKey(key: string, usedKeys: Set<string>): string {
if (!usedKeys.has(key)) {
usedKeys.add(key);
return key;
}
let index = 2;
while (usedKeys.has(`${key}#${index}`)) {
index += 1;
}
const unique = `${key}#${index}`;
usedKeys.add(unique);
return unique;
}
function redactTrajectoryExportObjectKeys(
value: unknown,
redaction: TrajectoryExportRedaction,
): unknown {
if (Array.isArray(value)) {
return value.map((entry) => redactTrajectoryExportObjectKeys(entry, redaction));
}
if (!value || typeof value !== "object") {
return value;
}
const usedKeys = new Set<string>();
const next: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
const redactedKey = redactToolPayloadText(maybeRedactPathString(key, redaction));
next[uniqueRedactedObjectKey(redactedKey, usedKeys)] = redactTrajectoryExportObjectKeys(
entry,
redaction,
);
}
return next;
}
function redactTrajectoryExportValue(
value: unknown,
redaction: TrajectoryExportRedaction,
): unknown {
const redactedValue = sanitizeTrajectoryExportValue(redactLocalPathValues(value, redaction));
return redactTrajectoryExportObjectKeys(redactedValue, redaction);
}
function redactEventForExport(
event: TrajectoryEvent,
redaction: TrajectoryExportRedaction,
): TrajectoryEvent {
return {
...event,
workspaceDir: event.workspaceDir
? maybeRedactPathString(event.workspaceDir, redaction)
: undefined,
data: event.data
? (redactLocalPathValues(event.data, redaction) as Record<string, unknown>)
: undefined,
};
return redactTrajectoryExportValue(event, redaction) as TrajectoryEvent;
}
function resolveRuntimeContext(runtimeEvents: TrajectoryEvent[]): RuntimeTrajectoryContext {
@@ -967,19 +1017,25 @@ export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams
});
if (metadataCapture) {
files.push(
jsonSupportBundleFile("metadata.json", redactLocalPathValues(metadataCapture, redaction)),
jsonSupportBundleFile(
"metadata.json",
redactTrajectoryExportValue(metadataCapture, redaction),
),
);
supplementalFiles.push("metadata.json");
}
if (artifactsCapture) {
files.push(
jsonSupportBundleFile("artifacts.json", redactLocalPathValues(artifactsCapture, redaction)),
jsonSupportBundleFile(
"artifacts.json",
redactTrajectoryExportValue(artifactsCapture, redaction),
),
);
supplementalFiles.push("artifacts.json");
}
if (promptsCapture) {
files.push(
jsonSupportBundleFile("prompts.json", redactLocalPathValues(promptsCapture, redaction)),
jsonSupportBundleFile("prompts.json", redactTrajectoryExportValue(promptsCapture, redaction)),
);
supplementalFiles.push("prompts.json");
}
@@ -991,12 +1047,12 @@ export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams
files.push(
jsonSupportBundleFile(
"session-branch.json",
redactLocalPathValues(
sanitizeDiagnosticPayload({
redactTrajectoryExportValue(
{
header,
leafId,
entries: branchEntries,
}),
},
redaction,
),
),
@@ -1005,7 +1061,7 @@ export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams
files.push(
textSupportBundleFile(
"system-prompt.txt",
redactLocalPathValues(bundleRuntimeContext.systemPrompt, redaction) as string,
redactTrajectoryExportValue(bundleRuntimeContext.systemPrompt, redaction) as string,
),
);
}
@@ -1013,21 +1069,29 @@ export async function exportTrajectoryBundle(params: BuildTrajectoryBundleParams
files.push(
jsonSupportBundleFile(
"tools.json",
redactLocalPathValues(bundleRuntimeContext.tools, redaction),
redactTrajectoryExportValue(bundleRuntimeContext.tools, redaction),
),
);
}
const contents: DiagnosticSupportBundleContent[] = [...supportBundleContents(files)];
const redactedFiles = files.map(redactTrajectoryBundleFileContent);
const contents: DiagnosticSupportBundleContent[] = [...supportBundleContents(redactedFiles)];
manifest.contents = contents;
const redactedManifest = redactTrajectoryExportValue(
manifest,
redaction,
) as TrajectoryBundleManifest;
const manifestFile = redactTrajectoryBundleFileContent(
jsonSupportBundleFile("manifest.json", redactedManifest),
);
await writeSupportBundleDirectory({
outputDir: params.outputDir,
files: [jsonSupportBundleFile("manifest.json", manifest), ...files],
files: [manifestFile, ...redactedFiles],
});
return {
manifest,
manifest: redactedManifest,
outputDir: params.outputDir,
events,
header,