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

View File

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