From 3f7b00a8d03ac8d2c8b633d68a3a98bb26ee24ce Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:10:37 -0500 Subject: [PATCH] fix: preserve public identifiers in feedback redaction (#16168) 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 rate agent comments and documents to capture feedback. > - Feedback bundles remove credentials and personal information before export. > - The feedback scanner treats ordinary dotted text as JWTs and authentication terms as bearer tokens. > - This pull request checks encoded JWT headers and preserves known authentication terms. > - The benefit is readable feedback context with credential masking kept in place. ## Linked Issues or Issue Description Refs #12309 for the related dotted-identifier false-positive report. This PR fixes the separate feedback scanner. It does not close every case in that report. Related PR: #14318 adds fine-grained GitHub token coverage. It addresses a separate credential form and is not duplicated here. **What happened?** Feedback exports replace `api.openai.com`, dotted filenames, and public schema IDs with a JWT placeholder. They also replace the words after `bearer` in phrases such as “bearer tokens.” Five-part encrypted JWTs are only partly masked. **Expected behavior** Keep public identifiers and authentication terms readable. Mask complete credential tokens. Count only text replacements in the redaction summary. **Steps to reproduce** 1. Pass `api.openai.com` or `Use bearer tokens.` to `sanitizeFeedbackText`. 2. Check that the original text is returned and the redaction counts stay empty. 3. Pass a signed JWT or an encrypted JWT to the same function. 4. Check that the full token is replaced and one JWT replacement is counted. **Paperclip version or commit** Reproduced in the feedback scanner on master at `5e8e992df2b3d71f82091b7bbf29fd482854d9f5`. **Deployment mode** Built from source. The defect is in the shared feedback export service and does not depend on deployment mode. ## What Changed - Use the existing JWT header predicate in the feedback scanner. - Match complete three-part and five-part tokens, including direct-encryption tokens with an empty key segment. Accept short token segments in the predicate. - Preserve the closed set of authentication terms already used by the general scanner, including terms inside closing prose delimiters. - Count only replacements that change the text. - Add tests for readable context, credential masking, nested values, and redaction counts. - Document feedback text redaction. ## Verification - Focused redaction and feedback service tests: 89 tests pass across five suites. - `pnpm --filter @paperclipai/adapter-utils typecheck` passes. Full local typecheck, tests, and build were attempted. The kernel killed compiler and test processes at the sandbox memory limit of 4 GB. Serial retries and a capped Node heap still could not complete the server compiler. CI runs the full repository checks. - `git diff --check` passes. - Latest commit `0eb8d3fb4257bb79e67fab615a8299ef69230ad8`: **55 checks passed**, including full typecheck, tests, build, and E2E; two optional Storybook checks were skipped. [CI evidence](https://github.com/paperclipai/paperclip/actions/runs/38407932875). Both Greptile findings have regression tests and fixes. [Greptile gives this commit 5/5](https://github.com/paperclipai/paperclip/pull/16168#issuecomment-6146917423), with both review threads resolved. ## Risks - This changes the feedback export matching rules. Public dotted text no longer triggers credential masking by shape alone. - Explicit credential assignments, provider keys, structured secret fields, email addresses, and phone numbers remain masked. Tests cover these cases. - Existing feedback snapshots are not rewritten. No schema change or migration is needed. - The encoded-header check remains a heuristic. It is not a full JWT validator. ## Model Used OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The runtime identifies the model as GPT-6. It does not expose the exact provider deployment ID or context window size. The earlier patch and this PR preparation were AI-assisted. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- docs/feedback-voting.md | 8 ++ .../src/command-redaction.test.ts | 31 ++++++ .../adapter-utils/src/command-redaction.ts | 7 +- .../src/__tests__/feedback-redaction.test.ts | 97 +++++++++++++++++++ server/src/services/feedback-redaction.ts | 21 ++-- 5 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 server/src/__tests__/feedback-redaction.test.ts diff --git a/docs/feedback-voting.md b/docs/feedback-voting.md index b9d40db7ad..5dea89ea12 100644 --- a/docs/feedback-voting.md +++ b/docs/feedback-voting.md @@ -110,6 +110,14 @@ feedback-export-20260331T120000Z.zip Exports are full by default. `traces/` keeps the Paperclip envelope, while `full-traces/` contains the richer per-trace bundle plus any recoverable adapter-native files. +Feedback text redaction preserves hostnames, dotted filenames, public schema IDs, +and authentication terms such as “bearer tokens.” Dotted text must have an encoded +JWT header to be masked as a token. Complete three-part signed tokens and +five-part encrypted tokens are masked, including tokens with short segments +and direct-encryption tokens with an empty encrypted-key segment. +Explicit credentials, secret fields, email addresses, and phone numbers remain +masked. The redaction summary counts text that was actually replaced. + ```bash # Custom server and output directory npx paperclipai feedback export --api-base http://127.0.0.1:3000 --company-id --out ./my-export diff --git a/packages/adapter-utils/src/command-redaction.test.ts b/packages/adapter-utils/src/command-redaction.test.ts index 4af35d09df..321866bf7c 100644 --- a/packages/adapter-utils/src/command-redaction.test.ts +++ b/packages/adapter-utils/src/command-redaction.test.ts @@ -11,10 +11,41 @@ describe("explicit diagnostic credential forms", () => { }); import { REDACTED_COMMAND_TEXT_VALUE, + looksLikeCredentialJwt, redactDiagnosticText, redactCommandText, } from "./command-redaction.js"; +describe("looksLikeCredentialJwt", () => { + it("recognizes encoded headers even with short payloads and signatures", () => { + const header = Buffer.from('{"alg":"HS256"}').toString("base64url"); + expect(looksLikeCredentialJwt(`${header}.e30.c2ln`)).toBe(true); + }); + + it("recognizes direct-encryption JWTs with an empty encrypted-key segment", () => { + const header = Buffer.from('{"alg":"dir","enc":"A256GCM"}').toString("base64url"); + expect(looksLikeCredentialJwt(`${header}..abcdefghijk.abcdefghijkl.abcdefghijk`)).toBe(true); + }); + + it.each([ + "eyJ..signature", + "eyJ.key..ciphertext.tag", + "eyJ.key.iv..tag", + "eyJ.key.iv.ciphertext.", + ])("rejects empty segments outside the JWE encrypted key: %s", (input) => { + expect(looksLikeCredentialJwt(input)).toBe(false); + }); + + it.each([ + "api.openai.com", + "agents.staging.example.com", + "deployment.credentials.example.md", + "paperclip.native-model-envelope.v3", + ])("rejects public dotted identifiers: %s", (input) => { + expect(looksLikeCredentialJwt(input)).toBe(false); + }); +}); + describe("redactDiagnosticText", () => { it("preserves credential metadata and dotted identifiers", () => { for (const text of [ diff --git a/packages/adapter-utils/src/command-redaction.ts b/packages/adapter-utils/src/command-redaction.ts index 465255caf4..46eeb6f504 100644 --- a/packages/adapter-utils/src/command-redaction.ts +++ b/packages/adapter-utils/src/command-redaction.ts @@ -36,7 +36,12 @@ const COMMAND_JWT_RE = /** Recognize encoded JSON headers, without treating dotted identifiers as tokens. */ export function looksLikeCredentialJwt(value: string): boolean { const segments = value.split("."); - if (![3, 5].includes(segments.length) || segments.some((part) => !/^[A-Za-z0-9_-]{8,}$/.test(part))) return false; + if (![3, 5].includes(segments.length)) return false; + if (segments.some((part, index) => { + // Compact JWE direct encryption has no encrypted-key value. + if (segments.length === 5 && index === 1 && part === "") return false; + return !/^[A-Za-z0-9_-]+$/.test(part); + })) return false; if (segments[0].startsWith("eyJ")) return true; try { const header = JSON.parse(atob(segments[0].replace(/-/g, "+").replace(/_/g, "/"))); diff --git a/server/src/__tests__/feedback-redaction.test.ts b/server/src/__tests__/feedback-redaction.test.ts new file mode 100644 index 0000000000..868cbb399a --- /dev/null +++ b/server/src/__tests__/feedback-redaction.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + createFeedbackRedactionState, + finalizeFeedbackRedactionSummary, + sanitizeFeedbackText, + sanitizeFeedbackValue, +} from "../services/feedback-redaction.js"; + +describe("feedback redaction", () => { + it.each([ + "https://agents.staging.example.com/team/chats/assistant", + "api.openai.com and deployment.credentials.example.md", + "paperclip.native-model-envelope.v3 and executor.coreTools.integrations.list", + "Use bearer tokens for authentication and bearer authentication for requests.", + "Document bearer authorization, bearer credentials, bearer schemes, and bearer flows.", + "Use (bearer tokens). Preserve [bearer authentication] and {bearer credentials}.", + ])("preserves non-secret chat context: %s", (input) => { + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(input, state, "body", 10_000)).toBe(input); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual([]); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({}); + }); + + it("redacts JWTs without counting neighboring hostnames as credentials", () => { + const header = Buffer.from('{"alg":"HS256","typ":"JWT"}').toString("base64url"); + const jwt = `${header}.abcdefghijk.abcdefghijkl`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(`Visit api.openai.com; token ${jwt}`, state, "body", 10_000)) + .toBe("Visit api.openai.com; token [REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual(["body"]); + }); + + it("still redacts a JWT with a short payload and signature", () => { + const header = Buffer.from('{"alg":"HS256"}').toString("base64url"); + const state = createFeedbackRedactionState(); + const jwt = `${header}.e30.c2ln`; + + expect(sanitizeFeedbackText(jwt, state, "body", 10_000)).toBe("[REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + }); + + it("redacts encrypted JWTs", () => { + const header = Buffer.from('{"alg":"RSA-OAEP","enc":"A256GCM"}').toString("base64url"); + const jwt = `${header}.abcdefghijk.abcdefghijkl.abcdefghijk.abcdefghijkl`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(jwt, state, "body", 10_000)).toBe("[REDACTED_JWT]"); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + }); + + it("redacts direct-encryption JWTs with an empty encrypted-key segment", () => { + const header = Buffer.from('{"alg":"dir","enc":"A256GCM"}').toString("base64url"); + const jwt = `${header}..abcdefghijk.abcdefghijkl.abcdefghijk`; + const state = createFeedbackRedactionState(); + + expect(sanitizeFeedbackText(`Token ${jwt}; visit api.openai.com.`, state, "body", 10_000)) + .toBe("Token [REDACTED_JWT]; visit api.openai.com."); + expect(finalizeFeedbackRedactionSummary(state).counts).toEqual({ jwt: 1 }); + expect(finalizeFeedbackRedactionSummary(state).redactedFields).toEqual(["body"]); + }); + + it("still redacts explicit credentials and personal information", () => { + const state = createFeedbackRedactionState(); + const input = [ + "api_key=opaque-value", + "Bearer abcdefghijklmnop", + "ghp_abcdefghijklmnopqrstuv", + "sk-abcdefghijklmnopqrstuv", + "postgresql://admin:password@example.com/db", + "person@example.com", + "+1 (415) 555-1234", + ].join("\n"); + const output = sanitizeFeedbackText(input, state, "body", 10_000); + + for (const value of input.split("\n")) expect(output).not.toContain(value); + expect(output).toContain("Bearer [REDACTED_TOKEN]"); + expect(output).toContain("[REDACTED_EMAIL]"); + expect(output).toContain("[REDACTED_PHONE]"); + }); + + it("preserves nested metadata while masking structured secrets", () => { + const state = createFeedbackRedactionState(); + const input = { + body: "Visit api.openai.com and use bearer tokens.", + tokenPolicy: "least privilege", + nested: [{ apiKey: "opaque-value", path: "deployment.credentials.example.md" }], + }; + + expect(sanitizeFeedbackValue(input, state, "bundle", 10_000)).toEqual({ + ...input, + nested: [{ apiKey: "***REDACTED***", path: "deployment.credentials.example.md" }], + }); + }); +}); diff --git a/server/src/services/feedback-redaction.ts b/server/src/services/feedback-redaction.ts index 7c0ed097e2..e57341407d 100644 --- a/server/src/services/feedback-redaction.ts +++ b/server/src/services/feedback-redaction.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { looksLikeCredentialJwt } from "@paperclipai/adapter-utils/command-redaction"; import { redactCurrentUserText } from "../log-redaction.js"; import { sanitizeRecord } from "../redaction.js"; @@ -34,7 +35,7 @@ const FREE_TEXT_PATTERNS: RedactionPattern[] = [ }, { kind: "bearer_token", - regex: /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, + regex: /\bBearer\s+(?!(?:tokens?|authentication|authorization|credentials?|schemes?|flows?)[.,;:!?]?(?:\s|[)\]}]|$))[A-Za-z0-9._~+/-]+=*/gi, replacement: "Bearer [REDACTED_TOKEN]", }, { @@ -49,8 +50,8 @@ const FREE_TEXT_PATTERNS: RedactionPattern[] = [ }, { kind: "jwt", - regex: /\b[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?\b/g, - replacement: "[REDACTED_JWT]", + regex: /\b[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b/g, + replacement: (match) => looksLikeCredentialJwt(match) ? "[REDACTED_JWT]" : match, }, { kind: "dsn", @@ -84,12 +85,14 @@ function recordField(state: FeedbackRedactionState, fieldPath: string) { } function applyPattern(input: string, pattern: RedactionPattern) { - const matches = Array.from(input.matchAll(pattern.regex)).length; - if (matches === 0) { - pattern.regex.lastIndex = 0; - return { output: input, matches: 0 }; - } - const output = input.replace(pattern.regex, pattern.replacement as never); + let matches = 0; + const output = input.replace(pattern.regex, (match: string, ...args: string[]) => { + const replacement = typeof pattern.replacement === "function" + ? pattern.replacement(match, ...args) + : pattern.replacement; + if (replacement !== match) matches += 1; + return replacement; + }); pattern.regex.lastIndex = 0; return { output, matches }; }