diff --git a/server/src/__tests__/invitation-token-redaction.test.ts b/server/src/__tests__/invitation-token-redaction.test.ts new file mode 100644 index 00000000000..a2fb04cc961 --- /dev/null +++ b/server/src/__tests__/invitation-token-redaction.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { redactInvitationTokens } from "../middleware/invitation-token-redaction.js"; + +describe("redactInvitationTokens", () => { + it.each([ + ["Open [Join](https://app.example.test/invite/legacy8x) now", "Open [Join](https://app.example.test/invite/[REDACTED]) now"], + ['{"token":"pcp_invite_abc-DEF_123","note":"ok"}', '{"token":"[REDACTED]","note":"ok"}'], + ["POST /api/invites/pcp_invite_abc/accept", "POST /api/invites/[REDACTED]/accept"], + ["GET /api/companies/c1/invites and /invites/i1/revoke", "GET /api/companies/c1/invites and /invites/i1/revoke"], + ])("redacts invitation tokens inside free text: %s", (input, expected) => { + expect(redactInvitationTokens(input)).toBe(expected); + }); +}); diff --git a/server/src/middleware/invitation-token-redaction.ts b/server/src/middleware/invitation-token-redaction.ts new file mode 100644 index 00000000000..7a6abb2f9de --- /dev/null +++ b/server/src/middleware/invitation-token-redaction.ts @@ -0,0 +1,18 @@ +// Invitation bearer tokens inside free text, such as agent run-log chunks: the +// token segment of a `/invite/` join link and any raw `pcp_invite_` +// token. `stripSecretBearingUrlParts` in redact-sensitive.ts covers whole URL +// values in HTTP log records; this covers the same tokens where they sit +// inside prose or JSON. Both character classes stop at JSON, Markdown, and URL +// delimiters, so the surrounding structure is kept. +// +// Kept in its own module: the chat eval pins the exact bytes of the HTTP log +// redactor (redact-sensitive.ts) before it accepts a real invitation link. +const INVITATION_LINK_TOKEN_TEXT_RE = /(\/invite\/)[A-Za-z0-9_.~%-]+/gi; +const INVITATION_TOKEN_TEXT_RE = /pcp_invite_[A-Za-z0-9_-]+/g; + +export function redactInvitationTokens(text: string, marker = "[REDACTED]"): string { + if (!/\/invite\/|pcp_invite_/i.test(text)) return text; + return text + .replace(INVITATION_LINK_TOKEN_TEXT_RE, (_match, prefix: string) => `${prefix}${marker}`) + .replace(INVITATION_TOKEN_TEXT_RE, () => marker); +} diff --git a/server/src/services/heartbeat/run-log.test.ts b/server/src/services/heartbeat/run-log.test.ts index 27430bb5283..1ecaa15b97a 100644 --- a/server/src/services/heartbeat/run-log.test.ts +++ b/server/src/services/heartbeat/run-log.test.ts @@ -3,6 +3,7 @@ import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; import { appendExcerpt, boundHeartbeatRunEventPayloadForStorage, + canonicalNativeRunLogRedactionForms, compactRunLogChunk, redactCanonicalNativeRunLogChunk, } from "./run-log.js"; @@ -212,3 +213,47 @@ describe("complete canonical native log privacy", () => { expect(() => redactCanonicalNativeRunLogChunk(chunk, options)).toThrow(); }); }); + +describe("run-log invitation token privacy", () => { + // Run logs are a plain-file diagnostic copy that every local agent (same OS + // user) can read, so a live join link must not sit there. The chat reply the + // person reads is built from database run events, not from this file. + const token = "pcp_invite_FixtureInviteToken-0123456789_abcdefghijklmnopqrstu"; + const reply = `Created the invite.\n\n**Invite link:** [Join Company](http://127.0.0.1:3100/invite/${token})`; + const toolResult = JSON.stringify({ id: "invite-1", token, inviteUrl: `/invite/${token}`, + acceptPath: `/api/invites/${token}/accept` }); + const options = { redactIdentity: (value: T) => value, currentUser: { enabled: false } }; + const envelope = (text: string) => JSON.stringify({ type: "paperclip.prp.event", event: { + schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: "item.completed", + payload: { kind: "agentMessage", channel: "final", text }, + } }); + + it("redacts join-link tokens and raw invite tokens from legacy text chunks", () => { + for (const chunk of [reply, toolResult, `${envelope(reply)}\n`]) { + const compacted = compactRunLogChunk(chunk); + expect(compacted).not.toContain(token); + expect(compacted).not.toContain("FixtureInviteToken"); + } + expect(compactRunLogChunk(reply)).toBe(reply.replace(token, "***REDACTED***")); + expect(JSON.parse(compactRunLogChunk(toolResult))).toEqual({ id: "invite-1", token: "***REDACTED***", + inviteUrl: "/invite/***REDACTED***", acceptPath: "/api/invites/***REDACTED***/accept" }); + }); + + it("redacts invite tokens from canonical native envelopes without breaking their JSON", () => { + const chunk = redactCanonicalNativeRunLogChunk(envelope(reply), options); + expect(chunk).not.toContain(token); + expect(JSON.parse(chunk).event.payload).toEqual({ kind: "agentMessage", channel: "final", + text: reply.replace(token, "***REDACTED***") }); + }); + + it("still verifies a log prefix written before invite tokens were redacted", () => { + const forms = [...canonicalNativeRunLogRedactionForms(envelope(reply), options)]; + expect(forms[0]).toBe(redactCanonicalNativeRunLogChunk(envelope(reply), options)); + expect(forms.some(form => form.includes(token))).toBe(true); + }); + + it("leaves chunks without invitation tokens unchanged", () => { + const chunk = "GET /api/companies/c1/invites returned 2 invites; see /invite for help"; + expect(compactRunLogChunk(chunk)).toBe(chunk); + }); +}); diff --git a/server/src/services/heartbeat/run-log.ts b/server/src/services/heartbeat/run-log.ts index 022c3edc7f6..e442c80dc31 100644 --- a/server/src/services/heartbeat/run-log.ts +++ b/server/src/services/heartbeat/run-log.ts @@ -1,5 +1,6 @@ import { appendWithByteCap, MAX_EXCERPT_BYTES, parseObject } from "../../adapters/utils.js"; -import { redactSensitiveText, sanitizeRecord } from "../../redaction.js"; +import { REDACTED_EVENT_VALUE, redactSensitiveText, sanitizeRecord } from "../../redaction.js"; +import { redactInvitationTokens } from "../../middleware/invitation-token-redaction.js"; import { redactCurrentUserText, redactCurrentUserValue, type CurrentUserRedactionOptions } from "../../log-redaction.js"; const MAX_PERSISTED_LOG_CHUNK_CHARS = 64 * 1024; @@ -105,11 +106,21 @@ function redactInlineBase64ImageData(chunk: string) { ); } +// The run log is a plain-file diagnostic copy that local agents running as the +// same OS user can read. Invitation join links are bearer credentials, so the +// log keeps the route and drops the token, as HTTP logs do. The reply the +// person reads is built from database run events, not from this file. +function redactRunLogInvitationTokens(text: string, enabled: boolean) { + return enabled ? redactInvitationTokens(text, REDACTED_EVENT_VALUE) : text; +} + export function compactRunLogChunk( chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS, + options: { redactInvitations?: boolean } = {}, ) { - const normalized = redactSensitiveText(redactInlineBase64ImageData(chunk)); + const normalized = redactRunLogInvitationTokens( + redactSensitiveText(redactInlineBase64ImageData(chunk)), options.redactInvitations !== false); if (normalized.length <= maxChars) return normalized; const headChars = Math.max(0, Math.floor(maxChars * 0.6)); @@ -123,6 +134,8 @@ export function compactRunLogChunk( interface CanonicalNativeLogRedaction { redactIdentity: (value: T) => T; currentUser: CurrentUserRedactionOptions; + /** False only to reproduce logs written before invite tokens were redacted. */ + redactInvitations?: boolean; } function omitCanonicalImageData(value: unknown): unknown { @@ -153,19 +166,26 @@ export function redactCanonicalNativeRunLogChunk(chunk: string, options: Canonic const projected = redactCurrentUserValue( options.redactIdentity(omitCanonicalImageData(envelope)), options.currentUser, ); - return `${JSON.stringify(sanitizeRecord(projected as Record))}\n`; + // Token characters never need JSON escaping and the marker adds none, so + // redacting the encoded record keeps it valid JSON. + return `${redactRunLogInvitationTokens(JSON.stringify(sanitizeRecord(projected as Record)), + options.redactInvitations !== false)}\n`; } /** New complete-value forms first; old text projections are computed only when * an archive needs them. An old malformed projection never repairs evidence. */ export function* canonicalNativeRunLogRedactionForms(chunk: string, options: CanonicalNativeLogRedaction) { - for (const enabled of [false, true]) - yield redactCanonicalNativeRunLogChunk(chunk, { - ...options, currentUser: { ...options.currentUser, enabled }, - }); - for (const enabled of [false, true]) - yield compactRunLogChunk(redactCurrentUserText(options.redactIdentity(chunk), { - ...options.currentUser, enabled, - }), Number.MAX_SAFE_INTEGER); + // Current invite-token redaction first; logs written by an earlier release + // kept the token and must still verify after an upgrade. + for (const redactInvitations of [true, false]) { + for (const enabled of [false, true]) + yield redactCanonicalNativeRunLogChunk(chunk, { + ...options, redactInvitations, currentUser: { ...options.currentUser, enabled }, + }); + for (const enabled of [false, true]) + yield compactRunLogChunk(redactCurrentUserText(options.redactIdentity(chunk), { + ...options.currentUser, enabled, + }), Number.MAX_SAFE_INTEGER, { redactInvitations }); + } } diff --git a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts index 2b569acced4..a451d60c30a 100644 --- a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts +++ b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts @@ -14,6 +14,16 @@ import { chatCompletionDeliveryService, existingChatCompletionReply } from "../c import { instanceSettingsService } from "../instance-settings.js"; import { issueService } from "../issues.js"; import * as nativeResponseNotifications from "../native-response-presentation-notifications.js"; +import { compactRunLogChunk } from "../heartbeat/run-log.js"; + +const runLogReads = vi.hoisted(() => [] as unknown[]); +vi.mock("../run-log-store.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getRunLogStore: () => { + const store = actual.getRunLogStore(); + return { ...store, read: (...args: Parameters) => { runLogReads.push(args[0]); return store.read(...args); } }; + } }; +}); const external = resolveExternalTestDatabaseUrl("PAPERCLIP_AGENT_CHAT_RESPONSE_TEST_DATABASE_URL"); const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport(); @@ -27,7 +37,8 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => afterAll(async () => { await db?.$client.end(); await database?.cleanup(); }); async function fixture(conversation = true, options: { succeed?: boolean; disposition?: "done" | "needs_review" | "yielded"; - beforeFinalize?: (ids: { companyId: string; issueId: string; runId: string; agentId: string }) => Promise } = {}) { + beforeFinalize?: (ids: { companyId: string; issueId: string; runId: string; agentId: string }) => Promise; + reply?: string; logRef?: string } = {}) { const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); const sessionId = randomUUID(), runnerId = randomUUID(), userId = `fixture-${randomUUID()}`, wakeId = randomUUID(); await db.insert(authUsers).values({ id: userId, name: "Conversation owner", email: `${userId}@example.test`, @@ -45,12 +56,13 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issueId, nativeSessionId: sessionId, runnerInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256, status: "running", runtimeMode: "native", responsibleUserId: userId, + ...(options.logRef ? { logStore: "local_file", logRef: options.logRef } : {}), contextSnapshot: { issueId, source: "issue.comment", wakeCommentId: wakeId, ...(conversation ? { conversationSessionGeneration: 0 } : {}) } }); await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); const port = new NativeRunCoordinatorStore(db, { companyId, issueId, runId, agentId, normalizedSessionId: sessionId, runnerSourceInstanceId: runnerId, completionContractId: contract.id, completionContractSha256: contract.canonicalSha256, completionContractRevision: "test", completionContractCriterionIds: [] }); - const reply = "The teammate is hired and ready for your next instruction."; + const reply = options.reply ?? "The teammate is hired and ready for your next instruction."; const prpEvent = { runId, normalizedSessionId: sessionId, turnId: "hire-turn", sourceEventId: "fixture-final-answer", payload: { kind: "agentMessage", channel: "final", providerPhase: "final", text: reply } }; await db.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: 170, eventType: "item.completed", payload: { prpEvent } }); @@ -605,6 +617,19 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false); expect(await comments(f.runId)).toHaveLength(1); }); + it("presents an invite join link from database run events, never from the redacted run log", async () => { + // The run log redacts invitation tokens; the person must still receive the + // working join link, so the reply must come from the stored run events. + const token = "pcp_invite_FixtureInviteToken-0123456789_abcdefghijklmnopqrstu"; + const reply = `Created the invite.\n\n**Invite link:** [Join Company](http://127.0.0.1:3100/invite/${token})`; + expect(compactRunLogChunk(reply)).not.toContain(token); + const f = await fixture(true, { reply, logRef: `${randomUUID()}/${randomUUID()}/redacted-run.ndjson` }); + runLogReads.length = 0; + const work = nativeResponsePresentationWork(db), entries = await work.load(f.input); + await expect(work.check(entries[0])).resolves.toBeNull(); + expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: reply, createdByRunId: f.runId })]); + expect(runLogReads).toEqual([]); + }); it("keeps a later user message actionable after recovering the earlier exact reply", async () => { const f = await fixture(); await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorUserId: f.userId, body: "Another instruction", diff --git a/server/src/services/native-runtime/native-run-log-export.ts b/server/src/services/native-runtime/native-run-log-export.ts index b0457764fa5..33d4c678470 100644 --- a/server/src/services/native-runtime/native-run-log-export.ts +++ b/server/src/services/native-runtime/native-run-log-export.ts @@ -143,7 +143,10 @@ function restoreCompactedEvent(chunk: string, source: unknown): unknown { const encoded = JSON.stringify(value), end = leading.length + omitted; if (end >= encoded.length || !encoded.startsWith(leading) || !tail.startsWith(encoded.slice(end))) continue; const restored = head + encoded.slice(leading.length, end) + tail; - if (compactRunLogChunk(restored) !== chunk) continue; + // Records written before invite tokens were redacted still hold the token; + // prove them with the compactor of that release. + if (![true, false].some(redactInvitations => + compactRunLogChunk(restored, undefined, { redactInvitations }) === chunk)) continue; try { const envelope = JSON.parse(restored); if (envelope.type === "paperclip.prp.event" && matchesPublishedEvent(source, envelope.event)) diff --git a/server/src/services/native-runtime/native-run-log-watermark.test.ts b/server/src/services/native-runtime/native-run-log-watermark.test.ts index 392bbb7658b..e0896fd7e79 100644 --- a/server/src/services/native-runtime/native-run-log-watermark.test.ts +++ b/server/src/services/native-runtime/native-run-log-watermark.test.ts @@ -212,6 +212,27 @@ describe.skipIf(!support.supported)("native run log source watermarks", () => { expect(run!.bytes).toBe(Buffer.byteLength(content)); }); + it("cold-recovers a legacy-compacted event that an earlier release wrote with an invite token", async () => { + const f = await fixture(), store = f.store(), handle = await store.begin(f.binding); + const token = "pcp_invite_LegacyCompactedFixtureToken-0123456789abcdef"; + const event = toolEvent(f, `join /invite/${token} ${"retained output ".repeat(5000)} raw ${token}`.slice(0, 65_536)); + await f.persist(event, 2); + // Written before invite redaction: the stored head still holds the token. + const chunk = compactRunLogChunk(formatNativeRunLogEvent(event), undefined, { redactInvitations: false }); + expect(chunk).toContain(token); + expect(chunk).toContain("[paperclip truncated run log chunk: omitted "); + await store.append(handle, { stream: "stdout", ts: "legacy", seq: 1, sourceCursor: 2, chunk }); + await store.finalize(handle); + const prefix = await readAll(store, handle); + await f.persist(f.event(2), 5); + await f.settle(handle.logRef); + const cold = f.store(); + await recoverSettledNativeRunLog(db, cold, handle.logRef, async () => ({ enabled: false })); + const content = (await cold.read(handle)).content; + expect(content.startsWith(prefix)).toBe(true); + expect(content.trim().split("\n")).toHaveLength(2); + }); + it.each([[20, "A"], [256, "界"]] as const)("retains a current %i-step %s plan through proof outage and cold recovery", async (count, character) => { const f = await fixture(), event = planEvent(f, count, character.repeat(4000)); const outage = failPublishedPrefixProof(f);