Redact current user from run logs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
66
server/src/__tests__/log-redaction.test.ts
Normal file
66
server/src/__tests__/log-redaction.test.ts
Normal 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
118
server/src/log-redaction.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user