Files
PaperClipAI/server/src/__tests__/redaction.test.ts
T
DottaandPaperclip 9786f6df56 fix(runner): preserve credential content in document saves (#14937)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Runner sends authorized tool calls to the control plane.
> - Agents use these calls to save plans and instruction files.
> - The Runner used diagnostic secret detection to reject execution
arguments.
> - Ordinary credential-related prose could reject a document save
before persistence.
> - This pull request forwards the original arguments and leaves
credential policy to the provider harness.
> - The benefit is reliable saves with useful diagnostic records.

## Linked Issues or Issue Description

Related foundation: Refs #12415 and #14430. No duplicate save-policy fix
was found.

**What happened?**

A `write_document` call failed before the server saved its plan. The
Runner reported `semantic tool input contains credential material;
refusing to execute altered arguments`. The detector also masked
ordinary phrases such as `secret manager` and `credential handling` in
diagnostics. Both TypeScript dispatchers had equivalent execution gates.
One dispatcher also rewrote structured approval and question payloads
before execution.

**Expected behavior**

Paperclip forwards authorized arguments unchanged. The provider harness
decides credential-content policy. Log and audit redaction does not
reject or rewrite save input.

**Steps to reproduce**

1. Send an authorized `write_document` call with a plan that discusses
credential handling.
2. Include an intentional credential value in the body to exercise
harness-owned policy.
3. The old Runner rejects the call. With this change, the document
service stores the exact body.
4. Diagnostic records still mask explicit credential values. Qualified
credential fields, short bearer values, opaque diagnostic pairs, and
valid encoded JSON token headers have regression coverage.

**Paperclip version or commit**

Reproduced at `c46e41e81c03cd3c8b64cf993615b604d7fe8c62`. The branch is
based on current `master`.

**Deployment mode**

Server deployment with the native Paperclip Runner. Local regression
tests use the real document service and an embedded test database.

## What Changed

- Remove credential-content vetoes from Rust admission and both
TypeScript semantic dispatchers.
- Preserve original structured approval and question arguments during
execution.
- Keep transport bounds, schema checks, authorization, idempotency, and
audit masking.
- Require explicit credential syntax or recognized formats for
diagnostic masking. Preserve ordinary prose, metadata, and dotted
identifiers.
- Test exact document persistence, replay, nested argument identities,
and masked audit copies.
- Remove obsolete retry guidance and document harness-owned credential
policy.

## Verification

- `cargo test --manifest-path
packages/paperclip-runner/runner/Cargo.toml --locked -p
paperclip-runner-core --lib --test acpx_event_payload --test
acpx_provider_state --test acpx_provider_turns`: 355 tests passed.
- Focused server and adapter tests: 189 tests passed after rebase. These
include the real document save and the complete tool-gateway suite.
- Semantic dispatcher and conformance tests: 34 tests passed.
- Diagnostic redaction and MCP tests: 46 tests passed, including all six
review examples.
- `pnpm -r typecheck` and `pnpm build` passed on the repaired branch.
- The broad local root suite was interrupted after database fixture
setup failures. The focused database suites passed. CI runs the complete
configured test lanes.

## Risks

- Authorized tool arguments can intentionally contain credentials. The
harness must enforce its content policy.
- Diagnostic detection is narrower. Explicit assignments, credential
fields, and recognized credential formats remain masked.
- The change does not add a database migration or change company
authorization.

## Model Used

- OpenAI GPT-6 through Codex. The session exposes the GPT-6 model
family; its exact runtime model identifier and context window size are
not exposed. Used reasoning, 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
- [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>
2026-10-02 14:19:41 -05:00

832 lines
28 KiB
TypeScript

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("explicit diagnostic credential forms", () => {
it("masks qualified credential fields while preserving metadata", () => {
expect(redactEventPayload({ authorizationHeader: "abcdefghijkl", apiKeyProduction: "sensitivevalue", credentialHandling: "harness", tokenPolicy: "least privilege" }))
.toEqual({ authorizationHeader: "***REDACTED***", apiKeyProduction: "***REDACTED***", credentialHandling: "harness", tokenPolicy: "least privilege" });
});
it("masks standalone bearer values and whitespace-prefixed JSON token headers", () => {
const jwt = `${Buffer.from(' {"alg":"HS256","typ":"JWT"}').toString("base64url")}.abcdefghijk.abcdefghijkl`;
expect(redactSensitiveText("provider said Bearer abcdefghijkl")).not.toContain("abcdefghijkl");
expect(redactEventPayload({ diagnostic: jwt })?.diagnostic).toBe("***REDACTED***");
expect(redactSensitiveText("Use bearer authentication and bearer tokens.")).toBe("Use bearer authentication and bearer tokens.");
});
});
import {
PRP_V1_EVENT_TYPES,
PRP_V2_EVENT_TYPES,
REDACTED_EVENT_VALUE,
redactAgentAdapterConfig,
redactEventPayload,
redactSensitiveText,
sanitizeRecord,
} from "../redaction.js";
describe("redaction", () => {
it("preserves credential-related prose, metadata, and dotted filenames", () => {
const input = {
body: "Keep the private key in a secret manager. Document credential handling and token permissions. Use bearer tokens for authentication. Prefer bearer authentication.",
tokenPolicy: "least privilege",
secretStorage: "vault",
credentialHandling: "harness",
authorizationRequired: true,
path: "deployment.credentials.example.md",
};
expect(sanitizeRecord(input)).toEqual(input);
expect(redactSensitiveText(input.body)).toBe(input.body);
expect(sanitizeRecord({ credentials: { provider: "opaque-value" }, passwordValue: "opaque-value", authorization_code: "opaque-value" }))
.toEqual({ credentials: REDACTED_EVENT_VALUE, passwordValue: REDACTED_EVENT_VALUE, authorization_code: REDACTED_EVENT_VALUE });
expect(redactSensitiveText("--token-budget 4000 SECRET_STORAGE=vault")).toBe("--token-budget 4000 SECRET_STORAGE=vault");
});
it("keeps the discriminator allowlist in exact PRP v1 schema parity", () => {
const schema = JSON.parse(
readFileSync(
new URL(
"../../../packages/paperclip-runner/protocol/schemas/event.schema.json",
import.meta.url,
),
"utf8",
),
) as { properties: { eventType: { enum: string[] } } };
expect([...PRP_V1_EVENT_TYPES]).toEqual(schema.properties.eventType.enum);
});
it("keeps the v2 additions in parity with the canonical v2 schema", () => {
const v1 = JSON.parse(
readFileSync(
new URL(
"../../../packages/paperclip-runner/protocol/schemas/event.schema.json",
import.meta.url,
),
"utf8",
),
) as { properties: { eventType: { enum: string[] } } };
const v2 = JSON.parse(
readFileSync(
new URL(
"../../../packages/paperclip-runner/protocol/schemas/event-v2.schema.json",
import.meta.url,
),
"utf8",
),
) as { properties: { eventType: { enum: string[] } } };
const v1Types = new Set(v1.properties.eventType.enum);
expect([...PRP_V2_EVENT_TYPES]).toEqual(
v2.properties.eventType.enum.filter((eventType) => !v1Types.has(eventType)),
);
});
it("preserves every discriminator in the cross-language replay stream", () => {
const fixture = JSON.parse(
readFileSync(
new URL(
"../../../packages/paperclip-runner/protocol/fixtures/replay/duplicate-event.json",
import.meta.url,
),
"utf8",
),
) as { events: Array<Record<string, unknown>> };
for (const event of fixture.events) {
const sanitized = redactEventPayload({ prpEvent: event });
const envelope = sanitized?.prpEvent as Record<string, unknown>;
expect(envelope.eventType).toBe(event.eventType);
expect(envelope.sourceEventId).toBe(event.sourceEventId);
expect(envelope.payload).toEqual(event.payload);
}
});
it("preserves current PRP v2 session discriminators", () => {
const schema = JSON.parse(readFileSync(
new URL("../../../packages/paperclip-runner/protocol/schemas/event-v2.schema.json", import.meta.url),
"utf8",
)) as { properties: { eventType: { enum: string[] } } };
for (const eventType of schema.properties.eventType.enum) {
const event = {
schema: "paperclip.prp.event.v2",
schemaVersion: 2,
eventType,
payload: { safe: true },
};
const sanitized = redactEventPayload({ prpEvent: event });
expect(sanitized?.prpEvent).toEqual(event);
}
});
it("preserves diagnostic PRP discriminators while redacting their credential payloads", () => {
const unknown = redactEventPayload({
prpEvent: {
schema: "paperclip.prp.event.v2",
schemaVersion: 2,
eventType: "session.not-a-real.event",
},
});
expect((unknown?.prpEvent as Record<string, unknown>).eventType).toBe(
"session.not-a-real.event",
);
const mismatched = redactEventPayload({
prpEvent: {
schema: "paperclip.prp.event.v1",
schemaVersion: 2,
eventType: "session.capabilities.updated",
},
});
expect((mismatched?.prpEvent as Record<string, unknown>).eventType).toBe(
"session.capabilities.updated",
);
const secretPayload = redactEventPayload({
prpEvent: {
schema: "paperclip.prp.event.v2",
schemaVersion: 2,
eventType: "session.capabilities.updated",
payload: { authorization: "Bearer secret-value" },
},
});
expect(
((secretPayload?.prpEvent as Record<string, unknown>).payload as Record<string, unknown>)
.authorization,
).toBe(REDACTED_EVENT_VALUE);
});
it("redacts sensitive keys and nested secret values", () => {
const input = {
apiKey: "abc123",
nested: {
AUTH_TOKEN: "token-value",
safe: "ok",
},
env: {
OPENAI_API_KEY: "sk-openai",
OPENAI_API_KEY_REF: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
version: "aaa.bbb.ccc",
projectionAllowlistKey: "aaa.bbb.ccc",
token: "must-not-survive-reference-shape",
},
USER_API_KEY_REF: {
type: "user_secret_ref",
key: "OPENAI_API_KEY",
password: "must-not-survive-user-reference-shape",
},
OPENAI_API_KEY_PLAIN: {
type: "plain",
value: "sk-plain",
},
PAPERCLIP_API_URL: "http://localhost:3100",
},
};
const result = sanitizeRecord(input);
expect(result.apiKey).toBe(REDACTED_EVENT_VALUE);
expect(result.nested).toEqual({
AUTH_TOKEN: REDACTED_EVENT_VALUE,
safe: "ok",
});
expect(result.env).toEqual({
OPENAI_API_KEY: REDACTED_EVENT_VALUE,
OPENAI_API_KEY_REF: {
type: "secret_ref",
secretId: "11111111-1111-1111-1111-111111111111",
},
USER_API_KEY_REF: {
type: "user_secret_ref",
key: "OPENAI_API_KEY",
},
OPENAI_API_KEY_PLAIN: {
type: "plain",
value: REDACTED_EVENT_VALUE,
},
PAPERCLIP_API_URL: "http://localhost:3100",
});
});
it("redacts jwt-looking values even when key name is not sensitive", () => {
const jwt =
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const input = {
session: jwt,
opaque: "aaa.bbb.ccc",
normal: "plain",
};
const result = sanitizeRecord(input);
expect(result.session).toBe(REDACTED_EVENT_VALUE);
expect(result.opaque).toBe("aaa.bbb.ccc");
expect(result.normal).toBe("plain");
});
it("preserves Paperclip protocol schema identifiers", () => {
expect(
sanitizeRecord({
schema: "paperclip.question_set.v1",
nested: {
schema: "paperclip.question_response.v1",
runtimeSchema: "paperclip.runtime_request.v2",
arbitraryProviderValue: "paperclip.question_set.v1",
},
}),
).toEqual({
schema: "paperclip.question_set.v1",
nested: {
schema: "paperclip.question_response.v1",
runtimeSchema: "paperclip.runtime_request.v2",
arbitraryProviderValue: "paperclip.question_set.v1",
},
});
});
it("preserves native execution v5 and model envelope v3 schema identifiers", () => {
const input = {
schema: "paperclip.native-execution-input.v5",
nested: {
runtimeSchema: "paperclip.native-model-envelope.v3",
providerValue: "paperclip.native-execution-input.v5",
},
};
expect(sanitizeRecord(input)).toEqual({
schema: input.schema,
nested: {
runtimeSchema: input.nested.runtimeSchema,
providerValue: input.nested.providerValue,
},
});
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v4", runtimeSchema: "paperclip.native-model-envelope.v2" })).toEqual({
schema: "paperclip.native-execution-input.v4",
runtimeSchema: "paperclip.native-model-envelope.v2",
});
});
it("preserves only known PRP v1 event discriminators inside validated envelopes", () => {
const payload = {
prpEvent: {
schema: "paperclip.prp.event.v1",
schemaVersion: 1,
eventType: "tool.execution.started",
payload: {
eventType: "run.result.accepted",
credential: "aaa.bbb.ccc",
},
},
unrelated: {
eventType: "workspace.file.referenced",
},
};
const sanitized = redactEventPayload(payload);
expect(sanitized).toEqual({
prpEvent: {
schema: "paperclip.prp.event.v1",
schemaVersion: 1,
eventType: "tool.execution.started",
payload: {
eventType: "run.result.accepted",
credential: REDACTED_EVENT_VALUE,
},
},
unrelated: {
eventType: "workspace.file.referenced",
},
});
expect(redactEventPayload(sanitized)).toEqual(sanitized);
});
it("preserves unknown dotted event values instead of treating them as JWTs", () => {
expect(
redactEventPayload({
schema: "paperclip.prp.event.v1",
schemaVersion: 1,
eventType: "attacker.supplied.token",
})?.eventType,
).toBe("attacker.supplied.token");
});
it("preserves dotted schema identifiers without interpreting their authority", () => {
expect(
redactEventPayload({
schema: "paperclip.attacker.control.v1",
runtimeSchema: "paperclip.attacker.runtime.v1",
schemaVersion: 1,
eventType: "tool.execution.started",
}),
).toEqual({
schema: "paperclip.attacker.control.v1",
runtimeSchema: "paperclip.attacker.runtime.v1",
schemaVersion: 1,
eventType: "tool.execution.started",
});
});
it("preserves native run span identities and ordinary hostnames", () => {
const spanNames = [
"environment.workspace.realize",
"native.coordinator.claim",
"runner.transport.selected",
"runner.prp.authenticate",
"runner.prp.route.register",
"runner.transport.connect",
"runner.session.bootstrap",
"runner.turn.submit",
"runner.session.startup",
"provider.turn.queue",
"question_response.to_run_created",
"native.session.execute",
"native.result.finalize",
"task.run.measured",
];
for (const span of spanNames) {
const input = {
schema: "paperclip.run-performance-span.v1",
span,
parentSpan: "native.session.execute",
providerHostname: "api.openai.com",
};
const sanitized = redactEventPayload(input);
expect(sanitized).toMatchObject({
schema: input.schema,
span,
parentSpan: "native.session.execute",
providerHostname: "api.openai.com",
});
expect(redactEventPayload(sanitized)).toEqual(sanitized);
}
expect(
redactEventPayload({
schema: "paperclip.run-performance-span.v1",
span: "api.openai.com",
})?.span,
).toBe("api.openai.com");
expect(
redactEventPayload({
schema: "paperclip.run-performance-span.v1",
span: "runner.example.com",
})?.span,
).toBe("runner.example.com");
});
it("redacts payload objects while preserving null", () => {
expect(redactEventPayload(null)).toBeNull();
expect(redactEventPayload({ password: "hunter2", safe: "value" })).toEqual({
password: REDACTED_EVENT_VALUE,
safe: "value",
});
});
it("redacts credentials in ordinary nested diagnostic strings", () => {
const sanitized = redactEventPayload({
message: "request failed with Authorization: Basic dXNlcjpwYXNz",
diagnostic: {
reason: "upstream returned Bearer live-provider-token",
details: [
"safe context",
"proxyAuthorization Basic nested-proxy-secret",
"aaa.bbb.ccc",
["Bearer nested-array-secret"],
],
},
});
expect(sanitized).toEqual({
message: `request failed with Authorization: ${REDACTED_EVENT_VALUE}`,
diagnostic: {
reason: `upstream returned Bearer ${REDACTED_EVENT_VALUE}`,
details: [
"safe context",
`proxyAuthorization ${REDACTED_EVENT_VALUE}`,
"aaa.bbb.ccc",
[`Bearer ${REDACTED_EVENT_VALUE}`],
],
},
});
expect(redactEventPayload(sanitized)).toEqual(sanitized);
});
it("preserves authorization decision reasons in audit payloads", () => {
expect(
redactEventPayload({
authorizationReason: "allow_scoped_agent_write",
authorization: "Bearer secret",
surface: "issue.comment.create",
}),
).toEqual({
authorizationReason: "allow_scoped_agent_write",
authorization: REDACTED_EVENT_VALUE,
surface: "issue.comment.create",
});
});
/**
* A removal receipt (PAP-17119) has to show what it revoked, so a fixed set of
* count keys is exempt from the secret-key guard — but only while the value is
* a number. The second half of this test is the point: the same key carrying
* anything else is still blanked, so the exemption cannot be used to smuggle
* material out under a familiar name.
*/
it("keeps numeric removal-receipt counts but still redacts non-numeric values on the same keys", () => {
expect(
sanitizeRecord({
secretsRevoked: 2,
secretsRetainedShared: 0,
credentialRefsCleared: 3,
secretBindingsRemoved: 3,
tokenIssuanceHashesCleared: 1,
gatewayTokensRevoked: 0,
appProfile: "deleted",
}),
).toEqual({
secretsRevoked: 2,
secretsRetainedShared: 0,
credentialRefsCleared: 3,
secretBindingsRemoved: 3,
tokenIssuanceHashesCleared: 1,
gatewayTokensRevoked: 0,
appProfile: "deleted",
});
expect(
sanitizeRecord({
secretsRevoked: "pasted-api-key-value",
secretBindingsRemoved: { name: "tool_app.abc.headers_authorization" },
tokenIssuanceHashesCleared: Number.NaN,
gatewayTokensRevoked: ["pcgw_live_token"],
}),
).toEqual({
secretsRevoked: REDACTED_EVENT_VALUE,
secretBindingsRemoved: REDACTED_EVENT_VALUE,
tokenIssuanceHashesCleared: REDACTED_EVENT_VALUE,
gatewayTokensRevoked: REDACTED_EVENT_VALUE,
});
});
it("redacts common secret shapes from unstructured text", () => {
const jwt =
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const githubToken = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";
const input = [
"Authorization: Bearer live-bearer-token-value",
`payload {"apiKey":"json-secret-value"}`,
`paperclip {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`,
`escaped {\\"apiKey\\":\\"escaped-json-secret\\"}`,
`export PAPERCLIP_API_KEY='paperclip-shell-secret'`,
`GITHUB_TOKEN=${githubToken}`,
`session=${jwt}`,
].join("\n");
const result = redactSensitiveText(input);
expect(result).toContain(REDACTED_EVENT_VALUE);
expect(result).not.toContain("live-bearer-token-value");
expect(result).not.toContain("json-secret-value");
expect(result).not.toContain("paperclip-json-secret");
expect(result).not.toContain("escaped-json-secret");
expect(result).not.toContain("paperclip-shell-secret");
expect(result).not.toContain(githubToken);
expect(result).not.toContain(jwt);
});
it("redacts authorization variants and standalone bearer credentials from diagnostic text", () => {
const input = [
"Authorization: Basic dXNlcjpwYXNz",
"Authorization Basic uncolonized-secret",
"proxyAuthorization Basic compound-secret",
'Authorization Bearer abc"embedded-tail',
'Authorization "Bearer quoted-secret"',
'request failed with Bearer standalone"embedded-tail',
].join("\n");
const result = redactSensitiveText(input);
expect(result).toContain(`Authorization: ${REDACTED_EVENT_VALUE}`);
expect(result).toContain(`Authorization ${REDACTED_EVENT_VALUE}`);
expect(result).toContain(`proxyAuthorization ${REDACTED_EVENT_VALUE}`);
expect(result).toContain(`Authorization "${REDACTED_EVENT_VALUE}"`);
expect(result).toContain(`Bearer ${REDACTED_EVENT_VALUE}`);
expect(result).not.toContain("dXNlcjpwYXNz");
expect(result).not.toContain("uncolonized-secret");
expect(result).not.toContain("compound-secret");
expect(result).not.toContain("embedded-tail");
expect(result).not.toContain("quoted-secret");
expect(result).not.toContain("standalone-secret");
expect(redactSensitiveText(result)).toBe(result);
});
it("redacts raw and escaped quote-delimited authorization values without leaking nested content", () => {
const cases = [
{
input: 'prefix Authorization: Bearer "two word secret" suffix',
expected: `prefix Authorization: ${REDACTED_EVENT_VALUE} suffix`,
},
{
input: "prefix Authorization: Basic 'two word secret' suffix",
expected: `prefix Authorization: ${REDACTED_EVENT_VALUE} suffix`,
},
{
input: String.raw`prefix {\"Authorization\":\"Basic dXNlcjpwYXNz\"} suffix`,
expected: String.raw`prefix {\"Authorization\":\"***REDACTED***\"} suffix`,
},
{
input: String.raw`prefix Authorization: \"Bearer \\\"nested two word secret\\\"\" suffix`,
expected: String.raw`prefix Authorization: \"***REDACTED***\" suffix`,
},
];
for (const { input, expected } of cases) {
const result = redactSensitiveText(input);
expect(result).toBe(expected);
expect(result).not.toContain("two word secret");
expect(result).not.toContain("dXNlcjpwYXNz");
expect(redactSensitiveText(result)).toBe(result);
}
});
it("redacts raw and serialized standalone bearer values without leaking quoted content", () => {
const cases = [
{
input: 'prefix Bearer "two word secret" suffix',
expected: `prefix Bearer "${REDACTED_EVENT_VALUE}" suffix`,
},
{
input: "prefix Bearer 'two word secret' suffix",
expected: `prefix Bearer '${REDACTED_EVENT_VALUE}' suffix`,
},
{
input: String.raw`prefix Bearer \"two word secret\" suffix`,
expected: String.raw`prefix Bearer \"***REDACTED***\" suffix`,
},
{
input: String.raw`prefix Bearer \"outer \\\"nested secret\\\" value\" suffix`,
expected: String.raw`prefix Bearer \"***REDACTED***\" suffix`,
},
];
for (const { input, expected } of cases) {
const result = redactSensitiveText(input);
expect(result).toBe(expected);
expect(result).not.toContain("two word secret");
expect(result).not.toContain("nested secret");
expect(redactSensitiveText(result)).toBe(result);
}
});
it("redacts token tails attached to raw and escaped closing credential quotes", () => {
const cases = [
{
input: 'Authorization: Basic "abc"defg retry',
expected: `Authorization: ${REDACTED_EVENT_VALUE}`,
},
{
input: String.raw`Authorization: Basic \"abc\"defg retry`,
expected: `Authorization: ${REDACTED_EVENT_VALUE}`,
},
{
input: 'Bearer "abc"defg retry',
expected: `Bearer "${REDACTED_EVENT_VALUE}"`,
},
{
input: String.raw`Bearer \"abc\"defg retry`,
expected: String.raw`Bearer \"***REDACTED***\"`,
},
{
input: 'Authorization: Basic "abc"defg retry\nsafe context',
expected: `Authorization: ${REDACTED_EVENT_VALUE}`,
},
{
input:
String.raw`Authorization: Basic \"abc\"defg retry` + "\nsafe context",
expected: `Authorization: ${REDACTED_EVENT_VALUE}`,
},
{
input: 'authorization="Bearer abc\ndef" status=401',
expected: `authorization="${REDACTED_EVENT_VALUE}" status=401`,
},
{
input: String.raw`authorization=\"Bearer abc
def\" status=401`,
expected: String.raw`authorization=\"***REDACTED***\" status=401`,
},
{
input: 'authorization="Bearer abc"\ndef" status=401',
expected: `authorization="${REDACTED_EVENT_VALUE}" status=401`,
},
{
input: String.raw`authorization=\"Bearer abc\"
def\" status=401`,
expected: String.raw`authorization=\"***REDACTED***\" status=401`,
},
{
input: 'authorization="Bearer abc"\ndef status=401',
expected: `authorization="${REDACTED_EVENT_VALUE}"`,
},
{
input: String.raw`authorization=\"Bearer abc\"
def status=401`,
expected: String.raw`authorization=\"***REDACTED***\"`,
},
{
input:
'authorization="Bearer first-line"\nrequest failed with Bearer standalone"embedded-tail',
expected: `authorization="${REDACTED_EVENT_VALUE}"\nrequest failed with Bearer ${REDACTED_EVENT_VALUE}`,
},
{
input: String.raw`authorization=\"Bearer first-line\"
request failed with Bearer standalone\"embedded-tail`,
expected: String.raw`authorization=\"***REDACTED***\"
request failed with Bearer ***REDACTED***`,
},
{
input: String.raw`{\"authorization\":\"Bearer a\"b c\"} suffix`,
expected: String.raw`{\"authorization\":\"***REDACTED***\"} suffix`,
},
{
input: `{"authorization":"Bearer a"b c"} suffix`,
expected: `{"authorization":"${REDACTED_EVENT_VALUE}"} suffix`,
},
{
input: String.raw`{\"authorization\":\"Bearer a\" b c\"} suffix`,
expected: String.raw`{\"authorization\":\"***REDACTED***\"} suffix`,
},
{
input: `{"authorization":"Bearer a" b c"} suffix`,
expected: `{"authorization":"${REDACTED_EVENT_VALUE}"} suffix`,
},
];
for (const { input, expected } of cases) {
const result = redactSensitiveText(input);
expect(result).toBe(expected);
expect(result).not.toContain("abc");
expect(result).not.toContain("defg");
expect(result).not.toContain('a"b c');
expect(result).not.toContain('a" b c');
expect(redactSensitiveText(result)).toBe(result);
}
const sanitized = redactEventPayload({
message: 'Authorization: Basic "abc"defg retry',
malformedProviderText: '{"authorization":"Bearer a" b c"} suffix',
authorization: 'Bearer a"b c"',
nested: {
diagnostics: [
String.raw`Bearer \"abc\"defg retry`,
String.raw`{\"authorization\":\"Bearer a\"b c\"} suffix`,
String.raw`authorization=\"Bearer first-line
second-line\" status=401`,
String.raw`authorization=\"Bearer first-line\"
second-line\" status=401`,
],
},
});
expect(sanitized).toEqual({
message: `Authorization: ${REDACTED_EVENT_VALUE}`,
malformedProviderText: `{"authorization":"${REDACTED_EVENT_VALUE}"} suffix`,
authorization: REDACTED_EVENT_VALUE,
nested: {
diagnostics: [
String.raw`Bearer \"***REDACTED***\"`,
String.raw`{\"authorization\":\"***REDACTED***\"} suffix`,
String.raw`authorization=\"***REDACTED***\" status=401`,
String.raw`authorization=\"***REDACTED***\" status=401`,
],
},
});
expect(redactEventPayload(sanitized)).toEqual(sanitized);
});
it("redacts inline secrets from command metadata without hiding safe command text", () => {
const input = {
command:
"custom-acp --token ghp_example_secret env OPENAI_API_KEY=sk-live-example custom-acp",
commandArgs: [
"--safe",
"ok",
"--token",
"ghp_arg_secret",
"--api-key=sk-inline-example",
],
env: {
PAPERCLIP_RESOLVED_COMMAND:
"env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret",
SAFE_VALUE: "visible",
},
};
const result = redactEventPayload(input);
expect(result?.command).toBe(
`custom-acp --token ${REDACTED_EVENT_VALUE} env OPENAI_API_KEY=${REDACTED_EVENT_VALUE} custom-acp`,
);
expect(result?.commandArgs).toEqual([
"--safe",
"ok",
"--token",
REDACTED_EVENT_VALUE,
`--api-key=${REDACTED_EVENT_VALUE}`,
]);
expect(result?.env).toEqual({
PAPERCLIP_RESOLVED_COMMAND: `env OPENAI_API_KEY=${REDACTED_EVENT_VALUE} custom-acp --token ${REDACTED_EVENT_VALUE}`,
SAFE_VALUE: "visible",
});
});
it("redacts non-string command args after secret flags", () => {
const result = redactEventPayload({
commandArgs: ["--api-key", { nested: "secret-value" }, "safe-next"],
});
expect(result?.commandArgs).toEqual([
"--api-key",
REDACTED_EVENT_VALUE,
"safe-next",
]);
});
it("does not treat bare args payloads as command args", () => {
const result = redactEventPayload({
args: ["--api-key", "not-a-command-secret"],
argv: ["--api-key", "command-secret"],
});
expect(result?.args).toEqual(["--api-key", "not-a-command-secret"]);
expect(result?.argv).toEqual(["--api-key", REDACTED_EVENT_VALUE]);
});
it("redacts every plaintext agent env binding while preserving secret references", () => {
const plaintextValue = "adapter-env-value-must-not-leak";
const result = redactAgentAdapterConfig({
command: "pnpm agent:run",
env: {
EXISTING_VALUE: plaintextValue,
NEW_VALUE: { type: "plain", value: plaintextValue },
SECRET_REFERENCE: {
type: "secret_ref",
secretId: "55555555-5555-4555-8555-555555555555",
version: "latest",
},
USER_SECRET_REFERENCE: {
type: "user_secret_ref",
key: "GITHUB_TOKEN",
},
},
});
expect(result).toEqual({
command: "pnpm agent:run",
env: {
EXISTING_VALUE: { type: "plain", value: REDACTED_EVENT_VALUE },
NEW_VALUE: { type: "plain", value: REDACTED_EVENT_VALUE },
SECRET_REFERENCE: {
type: "secret_ref",
secretId: "55555555-5555-4555-8555-555555555555",
version: "latest",
},
USER_SECRET_REFERENCE: {
type: "user_secret_ref",
key: "GITHUB_TOKEN",
},
},
});
expect(JSON.stringify(result)).not.toContain(plaintextValue);
});
it("redacts non-env adapter keys while leaving env binding shapes intact", () => {
const result = redactAgentAdapterConfig({
command: "pnpm agent:run",
apiKey: "adapter-level-secret",
env: {
API_KEY: "env-level-secret",
AUTH_TOKEN: { type: "plain", value: "another-env-secret" },
},
});
// Non-env keys still go through the shared payload sanitizer.
expect(result.apiKey).toBe(REDACTED_EVENT_VALUE);
expect(result.command).toBe("pnpm agent:run");
// Env bindings keep their binding shape rather than collapsing to a bare
// sentinel string, which is what a second sanitizer pass would produce for
// these sensitive-looking key names.
expect(result.env).toEqual({
API_KEY: { type: "plain", value: REDACTED_EVENT_VALUE },
AUTH_TOKEN: { type: "plain", value: REDACTED_EVENT_VALUE },
});
});
it("redacts adapter configs that have no env block", () => {
expect(redactAgentAdapterConfig({ command: "pnpm agent:run", apiKey: "secret" })).toEqual({
command: "pnpm agent:run",
apiKey: REDACTED_EVENT_VALUE,
});
});
});