diff --git a/server/src/__tests__/log-redaction.test.ts b/server/src/__tests__/log-redaction.test.ts new file mode 100644 index 00000000..a1da7a2e --- /dev/null +++ b/server/src/__tests__/log-redaction.test.ts @@ -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`], + }); + }); +}); diff --git a/server/src/log-redaction.ts b/server/src/log-redaction.ts new file mode 100644 index 00000000..34af3bbd --- /dev/null +++ b/server/src/log-redaction.ts @@ -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 { + 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) { + 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 = [ + 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(`(?(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 = {}; + for (const [key, entry] of Object.entries(value)) { + redacted[key] = redactCurrentUserValue(entry, opts); + } + return redacted as T; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 91a47d95..c4485d30 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3ced47fc..24580adb 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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; }, ) { + 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), }; },