Redact current user from run logs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-11 17:46:23 -05:00
parent 6e7266eeb4
commit 7945e7e780
4 changed files with 214 additions and 19 deletions

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
CURRENT_USER_REDACTION_TOKEN,
redactCurrentUserText,
redactCurrentUserValue,
} from "../log-redaction.js";
describe("log redaction", () => {
it("redacts the active username inside home-directory paths", () => {
const userName = "paperclipuser";
const input = [
`cwd=/Users/${userName}/paperclip`,
`home=/home/${userName}/workspace`,
`win=C:\\Users\\${userName}\\paperclip`,
].join("\n");
const result = redactCurrentUserText(input, {
userNames: [userName],
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
});
expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`);
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`);
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`);
expect(result).not.toContain(userName);
});
it("redacts standalone username mentions without mangling larger tokens", () => {
const userName = "paperclipuser";
const result = redactCurrentUserText(
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
{
userNames: [userName],
homeDirs: [],
},
);
expect(result).toBe(
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`,
);
});
it("recursively redacts nested event payloads", () => {
const userName = "paperclipuser";
const result = redactCurrentUserValue({
cwd: `/Users/${userName}/paperclip`,
prompt: `open /Users/${userName}/paperclip/ui`,
nested: {
author: userName,
},
values: [userName, `/home/${userName}/project`],
}, {
userNames: [userName],
homeDirs: [`/Users/${userName}`, `/home/${userName}`],
});
expect(result).toEqual({
cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`,
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`,
nested: {
author: CURRENT_USER_REDACTION_TOKEN,
},
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`],
});
});
});

118
server/src/log-redaction.ts Normal file
View File

@@ -0,0 +1,118 @@
import os from "node:os";
export const CURRENT_USER_REDACTION_TOKEN = "[]";
interface CurrentUserRedactionOptions {
replacement?: string;
userNames?: string[];
homeDirs?: string[];
}
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;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function uniqueNonEmpty(values: Array<string | null | undefined>) {
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
}
function splitPathSegments(value: string) {
return value.replace(/[\\/]+$/, "").split(/[\\/]+/).filter(Boolean);
}
function replaceLastPathSegment(pathValue: string, replacement: string) {
const normalized = pathValue.replace(/[\\/]+$/, "");
const lastSeparator = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\"));
if (lastSeparator < 0) return replacement;
return `${normalized.slice(0, lastSeparator + 1)}${replacement}`;
}
function defaultUserNames() {
const candidates = [
process.env.USER,
process.env.LOGNAME,
process.env.USERNAME,
];
try {
candidates.push(os.userInfo().username);
} catch {
// Some environments do not expose userInfo; env vars are enough fallback.
}
return uniqueNonEmpty(candidates);
}
function defaultHomeDirs(userNames: string[]) {
const candidates: Array<string | null | undefined> = [
process.env.HOME,
process.env.USERPROFILE,
];
try {
candidates.push(os.homedir());
} catch {
// Ignore and fall back to env hints below.
}
for (const userName of userNames) {
candidates.push(`/Users/${userName}`);
candidates.push(`/home/${userName}`);
candidates.push(`C:\\Users\\${userName}`);
}
return uniqueNonEmpty(candidates);
}
function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) {
const userNames = uniqueNonEmpty(opts?.userNames ?? defaultUserNames());
const homeDirs = uniqueNonEmpty(opts?.homeDirs ?? defaultHomeDirs(userNames));
const replacement = opts?.replacement?.trim() || CURRENT_USER_REDACTION_TOKEN;
return { userNames, homeDirs, replacement };
}
export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) {
if (!input) return input;
const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts);
let result = input;
for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) {
const lastSegment = splitPathSegments(homeDir).pop() ?? "";
const replacementDir = userNames.includes(lastSegment)
? replaceLastPathSegment(homeDir, replacement)
: replacement;
result = result.split(homeDir).join(replacementDir);
}
for (const userName of [...userNames].sort((a, b) => b.length - a.length)) {
const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
result = result.replace(pattern, replacement);
}
return result;
}
export function redactCurrentUserValue<T>(value: T, opts?: CurrentUserRedactionOptions): T {
if (typeof value === "string") {
return redactCurrentUserText(value, opts) as T;
}
if (Array.isArray(value)) {
return value.map((entry) => redactCurrentUserValue(entry, opts)) as T;
}
if (!isPlainObject(value)) {
return value;
}
const redacted: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
redacted[key] = redactCurrentUserValue(entry, opts);
}
return redacted as T;
}

View File

@@ -31,6 +31,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
@@ -1360,7 +1361,7 @@ export function agentRoutes(db: Db) {
return;
}
assertCompanyAccess(req, run.companyId);
res.json(run);
res.json(redactCurrentUserValue(run));
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
@@ -1395,10 +1396,12 @@ export function agentRoutes(db: Db) {
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
const redactedEvents = events.map((event) => ({
...event,
payload: redactEventPayload(event.payload),
}));
const redactedEvents = events.map((event) =>
redactCurrentUserValue({
...event,
payload: redactEventPayload(event.payload),
}),
);
res.json(redactedEvents);
});
@@ -1495,7 +1498,7 @@ export function agentRoutes(db: Db) {
}
res.json({
...run,
...redactCurrentUserValue(run),
agentId: agent.id,
agentName: agent.name,
adapterType: agent.adapterType,

View File

@@ -39,6 +39,7 @@ import {
parseProjectExecutionWorkspacePolicy,
resolveExecutionWorkspaceMode,
} from "./execution-workspace-policy.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
@@ -811,6 +812,9 @@ export function heartbeatService(db: Db) {
payload?: Record<string, unknown>;
},
) {
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message;
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload;
await db.insert(heartbeatRunEvents).values({
companyId: run.companyId,
runId: run.id,
@@ -820,8 +824,8 @@ export function heartbeatService(db: Db) {
stream: event.stream,
level: event.level,
color: event.color,
message: event.message,
payload: event.payload,
message: sanitizedMessage,
payload: sanitizedPayload,
});
publishLiveEvent({
@@ -835,8 +839,8 @@ export function heartbeatService(db: Db) {
stream: event.stream ?? null,
level: event.level ?? null,
color: event.color ?? null,
message: event.message ?? null,
payload: event.payload ?? null,
message: sanitizedMessage ?? null,
payload: sanitizedPayload ?? null,
},
});
}
@@ -1335,22 +1339,23 @@ export function heartbeatService(db: Db) {
.where(eq(heartbeatRuns.id, runId));
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk);
const sanitizedChunk = redactCurrentUserText(chunk);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString();
if (handle) {
await runLogStore.append(handle, {
stream,
chunk,
chunk: sanitizedChunk,
ts,
});
}
const payloadChunk =
chunk.length > MAX_LIVE_LOG_CHUNK_BYTES
? chunk.slice(chunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
: chunk;
sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
: sanitizedChunk;
publishLiveEvent({
companyId: run.companyId,
@@ -1361,7 +1366,7 @@ export function heartbeatService(db: Db) {
ts,
stream,
chunk: payloadChunk,
truncated: payloadChunk.length !== chunk.length,
truncated: payloadChunk.length !== sanitizedChunk.length,
},
});
};
@@ -1552,7 +1557,9 @@ export function heartbeatService(db: Db) {
error:
outcome === "succeeded"
? null
: adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
: redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
),
errorCode:
outcome === "timed_out"
? "timeout"
@@ -1619,7 +1626,7 @@ export function heartbeatService(db: Db) {
}
await finalizeAgentStatus(agent.id, outcome);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown adapter failure";
const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure");
logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@@ -2405,6 +2412,7 @@ export function heartbeatService(db: Db) {
store: run.logStore,
logRef: run.logRef,
...result,
content: redactCurrentUserText(result.content),
};
},