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

@@ -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 };
}