fix: preserve public identifiers in feedback redaction (#16168)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-13 09:10:37 -05:00
1 parent 561e956266
commit 3f7b00a8d0
5 files changed
+154 -10

No files matched your search

+8
View File
@@ -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 <company-id> --out ./my-export
@@ -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 [
@@ -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, "/")));
@@ -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" }],
});
});
});
+12 -9
View File
@@ -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 };
}