Redact home paths in transcript views

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-11 20:56:47 -05:00
parent 3b9da0ee95
commit ee3d8c1890
6 changed files with 146 additions and 44 deletions

View File

@@ -22,3 +22,9 @@ export type {
CLIAdapterModule,
CreateConfigValues,
} from "./types.js";
export {
REDACTED_HOME_PATH_USER,
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
redactTranscriptEntryPaths,
} from "./log-redaction.js";

View File

@@ -0,0 +1,81 @@
import type { TranscriptEntry } from "./types.js";
export const REDACTED_HOME_PATH_USER = "[]";
const HOME_PATH_PATTERNS = [
{
regex: /\/Users\/[^/\\\s]+/g,
replace: `/Users/${REDACTED_HOME_PATH_USER}`,
},
{
regex: /\/home\/[^/\\\s]+/g,
replace: `/home/${REDACTED_HOME_PATH_USER}`,
},
{
regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g,
replace: `$1${REDACTED_HOME_PATH_USER}`,
},
] as const;
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
export function redactHomePathUserSegments(text: string): string {
let result = text;
for (const pattern of HOME_PATH_PATTERNS) {
result = result.replace(pattern.regex, pattern.replace);
}
return result;
}
export function redactHomePathUserSegmentsInValue<T>(value: T): T {
if (typeof value === "string") {
return redactHomePathUserSegments(value) as T;
}
if (Array.isArray(value)) {
return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T;
}
if (!isPlainObject(value)) {
return value;
}
const redacted: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
redacted[key] = redactHomePathUserSegmentsInValue(entry);
}
return redacted as T;
}
export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry {
switch (entry.kind) {
case "assistant":
case "thinking":
case "user":
case "stderr":
case "system":
case "stdout":
return { ...entry, text: redactHomePathUserSegments(entry.text) };
case "tool_call":
return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) };
case "tool_result":
return { ...entry, content: redactHomePathUserSegments(entry.content) };
case "init":
return {
...entry,
model: redactHomePathUserSegments(entry.model),
sessionId: redactHomePathUserSegments(entry.sessionId),
};
case "result":
return {
...entry,
text: redactHomePathUserSegments(entry.text),
subtype: redactHomePathUserSegments(entry.subtype),
errors: entry.errors.map((error) => redactHomePathUserSegments(error)),
};
default:
return entry;
}
}

View File

@@ -1,4 +1,8 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import {
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
type TranscriptEntry,
} from "@paperclipai/adapter-utils";
function safeJsonParse(text: string): unknown {
try {
@@ -39,12 +43,12 @@ function errorText(value: unknown): string {
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "string") return redactHomePathUserSegments(value);
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2);
} catch {
return String(value);
return redactHomePathUserSegments(String(value));
}
}
@@ -57,7 +61,8 @@ function parseCommandExecutionItem(
const command = asString(item.command);
const status = asString(item.status);
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
const output = asString(item.aggregated_output).replace(/\s+$/, "");
const safeCommand = redactHomePathUserSegments(command);
const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, "");
if (phase === "started") {
return [{
@@ -67,13 +72,13 @@ function parseCommandExecutionItem(
toolUseId: id || command || "command_execution",
input: {
id,
command,
command: safeCommand,
},
}];
}
const lines: string[] = [];
if (command) lines.push(`command: ${command}`);
if (safeCommand) lines.push(`command: ${safeCommand}`);
if (status) lines.push(`status: ${status}`);
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
if (output) {
@@ -104,7 +109,7 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
.filter((change): change is Record<string, unknown> => Boolean(change))
.map((change) => {
const kind = asString(change.kind, "update");
const path = asString(change.path, "unknown");
const path = redactHomePathUserSegments(asString(change.path, "unknown"));
return `${kind} ${path}`;
});
@@ -126,13 +131,13 @@ function parseCodexItem(
if (itemType === "agent_message") {
const text = asString(item.text);
if (text) return [{ kind: "assistant", ts, text }];
if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }];
return [];
}
if (itemType === "reasoning") {
const text = asString(item.text);
if (text) return [{ kind: "thinking", ts, text }];
if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }];
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
}
@@ -148,9 +153,9 @@ function parseCodexItem(
return [{
kind: "tool_call",
ts,
name: asString(item.name, "unknown"),
name: redactHomePathUserSegments(asString(item.name, "unknown")),
toolUseId: asString(item.id),
input: item.input ?? {},
input: redactHomePathUserSegmentsInValue(item.input ?? {}),
}];
}
@@ -162,24 +167,28 @@ function parseCodexItem(
asString(item.result) ||
stringifyUnknown(item.content ?? item.output ?? item.result);
const isError = item.is_error === true || asString(item.status) === "error";
return [{ kind: "tool_result", ts, toolUseId, content, isError }];
return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }];
}
if (itemType === "error" && phase === "completed") {
const text = errorText(item.message ?? item.error ?? item);
return [{ kind: "stderr", ts, text: text || "error" }];
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }];
}
const id = asString(item.id);
const status = asString(item.status);
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }];
return [{
kind: "system",
ts,
text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`),
}];
}
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
}
const type = asString(parsed.type);
@@ -189,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{
kind: "init",
ts,
model: asString(parsed.model, "codex"),
sessionId: threadId,
model: redactHomePathUserSegments(asString(parsed.model, "codex")),
sessionId: redactHomePathUserSegments(threadId),
}];
}
@@ -212,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{
kind: "result",
ts,
text: asString(parsed.result),
text: redactHomePathUserSegments(asString(parsed.result)),
inputTokens,
outputTokens,
cachedTokens,
costUsd: asNumber(parsed.total_cost_usd),
subtype: asString(parsed.subtype),
subtype: redactHomePathUserSegments(asString(parsed.subtype)),
isError: parsed.is_error === true,
errors: Array.isArray(parsed.errors)
? parsed.errors.map(errorText).filter(Boolean)
? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean)
: [],
}];
}
@@ -234,21 +243,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
return [{
kind: "result",
ts,
text: asString(parsed.result),
text: redactHomePathUserSegments(asString(parsed.result)),
inputTokens,
outputTokens,
cachedTokens,
costUsd: asNumber(parsed.total_cost_usd),
subtype: asString(parsed.subtype, "turn.failed"),
subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")),
isError: true,
errors: message ? [message] : [],
errors: message ? [redactHomePathUserSegments(message)] : [],
}];
}
if (type === "error") {
const message = errorText(parsed.message ?? parsed.error ?? parsed);
return [{ kind: "stderr", ts, text: message || line }];
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }];
}
return [{ kind: "stdout", ts, text: line }];
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
}

View File

@@ -107,7 +107,7 @@ describe("codex_local ui stdout parser", () => {
item: {
id: "item_52",
type: "file_change",
changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }],
status: "completed",
},
}),
@@ -117,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
{
kind: "system",
ts,
text: "file changes: update /home/user/project/ui/src/pages/AgentDetail.tsx",
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
},
]);
});

View File

@@ -1,3 +1,4 @@
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
import type { TranscriptEntry, StdoutLineParser } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
@@ -26,11 +27,11 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const chunk of chunks) {
if (chunk.stream === "stderr") {
entries.push({ kind: "stderr", ts: chunk.ts, text: chunk.chunk });
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
continue;
}
if (chunk.stream === "system") {
entries.push({ kind: "system", ts: chunk.ts, text: chunk.chunk });
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
continue;
}
@@ -40,14 +41,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
appendTranscriptEntries(entries, parser(trimmed, chunk.ts));
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map(redactTranscriptEntryPaths));
}
}
const trailing = stdoutBuffer.trim();
if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendTranscriptEntries(entries, parser(trailing, ts));
appendTranscriptEntries(entries, parser(trailing, ts).map(redactTranscriptEntryPaths));
}
return entries;

View File

@@ -59,6 +59,7 @@ import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
@@ -92,11 +93,11 @@ function redactEnvValue(key: string, value: unknown): string {
}
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "string") return redactHomePathUserSegments(value);
try {
return JSON.stringify(value);
return JSON.stringify(redactHomePathUserSegmentsInValue(value));
} catch {
return String(value);
return redactHomePathUserSegments(String(value));
}
}
@@ -2023,7 +2024,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const adapterInvokePayload = useMemo(() => {
const evt = events.find((e) => e.eventType === "adapter.invoke");
return asRecord(evt?.payload ?? null);
return redactHomePathUserSegmentsInValue(asRecord(evt?.payload ?? null));
}, [events]);
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
@@ -2096,8 +2097,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string"
? adapterInvokePayload.prompt
: JSON.stringify(adapterInvokePayload.prompt, null, 2)}
? redactHomePathUserSegments(adapterInvokePayload.prompt)
: JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)}
</pre>
</div>
)}
@@ -2105,7 +2106,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(adapterInvokePayload.context, null, 2)}
{JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.context), null, 2)}
</pre>
</div>
)}
@@ -2189,14 +2190,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.error && (
<div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-700 dark:text-red-300">Error: </span>
{run.error}
{redactHomePathUserSegments(run.error)}
</div>
)}
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{run.stderrExcerpt}
{redactHomePathUserSegments(run.stderrExcerpt)}
</pre>
</div>
)}
@@ -2204,7 +2205,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{JSON.stringify(run.resultJson, null, 2)}
{JSON.stringify(redactHomePathUserSegmentsInValue(run.resultJson), null, 2)}
</pre>
</div>
)}
@@ -2212,7 +2213,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{run.stdoutExcerpt}
{redactHomePathUserSegments(run.stdoutExcerpt)}
</pre>
</div>
)}
@@ -2238,7 +2239,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{evt.stream ? `[${evt.stream}]` : ""}
</span>
<span className={cn("break-all", color)}>
{evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")}
{evt.message
? redactHomePathUserSegments(evt.message)
: evt.payload
? JSON.stringify(redactHomePathUserSegmentsInValue(evt.payload))
: ""}
</span>
</div>
);