From 7a93cc62156837192f50e9d23aa5b41e109342ad Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:52:19 -0600 Subject: [PATCH] fix(server): redact invitation tokens from agent run logs (#20360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People join a company through an invitation link. The link carries a bearer token in its URL path. > - An agent can create an invitation in chat and give the person the join link. > - The server writes each agent run to a run log file (`.ndjson`). This file is a plain diagnostic copy. Local agents run as the same OS user, so they can read it. > - #20343 removed invitation tokens from HTTP logs. The run log still kept the full join link and the raw token. > - This pull request removes invitation tokens from run-log chunks when the server writes them. > - The benefit is that a live join link does not stay in a file that other local agents can read. The person still gets the working link in the chat reply. ## Linked Issues or Issue Description Refs #20343 (same token policy for HTTP logs). **What happened?** An agent created an invitation in chat and replied with a `/invite/` join link. The agent run log file under `data/run-logs/` kept the full token. The token was in the agent message and in the tool result. **Expected behavior** The run log keeps the route and drops the token: `/invite/***REDACTED***`. A raw `pcp_invite_...` token also becomes `***REDACTED***`. The chat reply that the person reads keeps the working join link. **Steps to reproduce** 1. Check out master. 2. Run `server/src/services/heartbeat/run-log.test.ts` from this PR. 3. The "run-log invitation token privacy" tests fail. The token stays in both the legacy text chunk and the canonical native envelope. **Paperclip version or commit** Master at the merge base of this branch. **Deployment mode** Source server, local run logs (`local_file`). The same functions also format object-store run logs. ## What Changed - Add `redactInvitationTokens()` in `server/src/middleware/invitation-token-redaction.ts`, next to the HTTP log redactor from #20343. It removes the token from `/invite/` segments and from raw `pcp_invite_` tokens inside free text and JSON text. The character classes stop at JSON, Markdown and URL delimiters, so the record structure stays valid. `redact-sensitive.ts` stays byte-identical, because the chat eval pins its exact bytes before it accepts a real invitation link. - Apply it in `compactRunLogChunk()` and `redactCanonicalNativeRunLogChunk()`. These are the two functions that format every agent run-log chunk before the store writes it. The run excerpt columns use the same chunk, so they are also redacted. - `canonicalNativeRunLogRedactionForms()` also yields the earlier, unredacted projection. A run log written by an earlier release still verifies after an upgrade. - `restoreCompactedEvent()` accepts a truncated record that the earlier compactor wrote with the token. Cold recovery of such an old log still works. - Add a test that proves the chat reply comes from database run events, not from the run log. The reply keeps the full join link, and the presentation path never reads the run log. ## Verification - From `server`: `node ../node_modules/vitest/vitest.mjs run --config vitest.config.ts src/services/heartbeat/run-log.test.ts src/__tests__/invitation-token-redaction.test.ts src/__tests__/redact-sensitive.test.ts src/__tests__/heartbeat-run-log.test.ts src/__tests__/logger-invitation-redaction.test.ts src/services/native-runtime/native-run-log-watermark.test.ts src/services/native-runtime/native-run-log-recovery.test.ts src/__tests__/heartbeat-run-output.test.ts src/services/native-runtime/agent-chat-response-recovery.test.ts` passes. - Before the `run-log.ts` change, the two new redaction tests in `run-log.test.ts` failed (red), then passed (green). - `native-run-log-watermark.test.ts`: a legacy-compacted record with a token in its retained head cold-recovers. It failed with `native_log_source_coverage_unproven` before the restore fix. - `agent-chat-response-recovery.test.ts` (embedded Postgres): a final reply with a join link is saved as the exact comment with the token. No run-log read happens. - `tsc --noEmit -p server` shows no errors in the changed files. - A live chat eval (`access.invite-teammate`) run with this change confirms the run-log file has no token. The run is linked in a comment. ## Risks - Low risk. Run-log readers (run transcript view, feedback export, support inspection) now show `***REDACTED***` in place of invitation tokens. This removes a live credential from a diagnostic copy. It does not change the chat reply or the stored run events. - A path segment after `/invite/` in free text is always redacted, even if it is not a token. This loses only diagnostic detail. - A token that a provider streams across two separate chunks is not joined first. Each chunk is redacted on its own. A complete token in one chunk, such as a final message or a tool result, is removed. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../invitation-token-redaction.test.ts | 13 ++++++ .../middleware/invitation-token-redaction.ts | 18 ++++++++ server/src/services/heartbeat/run-log.test.ts | 45 +++++++++++++++++++ server/src/services/heartbeat/run-log.ts | 42 ++++++++++++----- .../agent-chat-response-recovery.test.ts | 29 +++++++++++- .../native-runtime/native-run-log-export.ts | 5 ++- .../native-run-log-watermark.test.ts | 21 +++++++++ 7 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 server/src/__tests__/invitation-token-redaction.test.ts create mode 100644 server/src/middleware/invitation-token-redaction.ts 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);