Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/plugins/tool-result-secret-scrub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,125 @@ describe("scrubSecretShapedValue key handling", () => {
});
});

describe("bare-KEY names in JSON paths", () => {
test("redacts bare-KEY names in JSON objects", () => {
const out = scrubSecretShapedValue({
OPENAI_KEY: "sk-live-abc123",
nested: { MY_API_KEY: "stripe-live-xyz" },
});
expect(out).toEqual({
OPENAI_KEY: CREDENTIAL_REDACTION,
nested: { MY_API_KEY: CREDENTIAL_REDACTION },
});
});

test("redacts bare-KEY names in JSON text", () => {
const pretty = scrubSecretShapedContent('{"OPENAI_KEY": "sk-live-abc123"}');
expect(pretty).toBe(`{"OPENAI_KEY": "${CREDENTIAL_REDACTION}"}`);
const compact = scrubSecretShapedContent('{"OPENAI_KEY":"sk-live-abc123"}');
expect(compact).toBe(`{"OPENAI_KEY":"${CREDENTIAL_REDACTION}"}`);
expect(compact).not.toContain("sk-live-abc123");
});

test("leaves MONKEY-style words and ordinary key fields alone", () => {
const value = scrubSecretShapedValue({
MONKEY: "banana",
monkey: "banana",
key: "ctrl+s",
hotkey: "ctrl+s",
});
expect(value).toEqual({
MONKEY: "banana",
monkey: "banana",
key: "ctrl+s",
hotkey: "ctrl+s",
});
const text = '{"MONKEY": "banana", "key": "ctrl+s"}';
expect(scrubSecretShapedContent(text)).toBe(text);
});
});

describe("query secrets keep characters after quotes", () => {
test("does not leave a tail after a quote in password, api_key, or token", () => {
for (const name of ["password", "api_key", "token"] as const) {
for (const quote of ["'", '"'] as const) {
const text = `GET https://provider.invalid/v1?${name}=p${quote}LEAKTAIL&format=json`;
const out = scrubSecretShapedContent(text);
expect(out).toBe(
`GET https://provider.invalid/v1?${name}=${CREDENTIAL_REDACTION}&format=json`,
);
expect(out).not.toContain("LEAKTAIL");
}
}
});
});

describe("storage key names stay readable", () => {
test("keeps partition, sort, primary, cache, and prompt_cache keys", () => {
const value = {
partition_key: "tenant",
sort_key: 2,
primary_key: ["id"],
cache_key: [1, 2],
prompt_cache_key: 9,
OPENAI_KEY: "sk-live-abc123",
api_key: ["still-secret"],
};
expect(scrubSecretShapedValue(value)).toEqual({
partition_key: "tenant",
sort_key: 2,
primary_key: ["id"],
cache_key: [1, 2],
prompt_cache_key: 9,
OPENAI_KEY: CREDENTIAL_REDACTION,
api_key: CREDENTIAL_REDACTION,
});

const text =
'{"partition_key":"tenant","sort_key":2,"primary_key":["id"],"cache_key":[1],"prompt_cache_key":"sess","OPENAI_KEY":"sk-live-abc123","api_key":"still-secret"}';
const scrubbed = scrubSecretShapedContent(text);
expect(scrubbed).toContain('"partition_key":"tenant"');
expect(scrubbed).toContain('"sort_key":2');
expect(scrubbed).toContain('"primary_key":["id"]');
expect(scrubbed).toContain('"cache_key":[1]');
expect(scrubbed).toContain('"prompt_cache_key":"sess"');
expect(scrubbed).not.toContain("sk-live-abc123");
expect(scrubbed).not.toContain("still-secret");

const query =
"GET https://provider.invalid/v1?sort_key=created_at&partition_key=tenant&format=json";
expect(scrubSecretShapedContent(query)).toBe(query);
});
});

describe("bare-KEY names in query parameters", () => {
test("redacts ?OPENAI_KEY= among benign params, adjacent params survive", () => {
const text =
"GET https://provider.invalid/v1?model=test&OPENAI_KEY=live-value&format=json";
const out = scrubSecretShapedContent(text);
expect(out).toBe(
`GET https://provider.invalid/v1?model=test&OPENAI_KEY=${CREDENTIAL_REDACTION}&format=json`,
);
expect(out).not.toContain("live-value");
});

test("redacts bare-KEY query params case-insensitively with quote bounds", () => {
const out = scrubSecretShapedContent(
'{"url": "https://provider.invalid/v1?openai_key=live-value&model=test"}',
);
expect(out).toBe(
`{"url": "https://provider.invalid/v1?openai_key=${CREDENTIAL_REDACTION}&model=test"}`,
);
expect(out).not.toContain("live-value");
});

test("leaves MONKEY-style and ordinary query params alone", () => {
const text =
"GET https://provider.invalid/v1?MONKEY=banana&model=test&format=json";
expect(scrubSecretShapedContent(text)).toBe(text);
});
});

describe("toolResultSecretScrubPlugin", () => {
const next =
(content: ToolResult["content"], isError = false) =>
Expand Down
32 changes: 25 additions & 7 deletions src/plugins/tool-result-secret-scrub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,24 @@ const API_KEY_PATTERNS: RegExp[] = [
/\bBasic\s+[a-zA-Z0-9+/]{8,}={0,2}/g,
];

const SECRET_QUERY_PARAMETER =
/([?&](?:api[_-]?key|access[_-]?token|token|password|secret|credential)=)(?!\[redacted: looks like a credential\])[^&#\s]+/gi;

const JSON_CREDENTIAL_FIELD =
/("(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization)"\s*:\s*")([^"\r\n]+)(")/gi;
// A *_key name is a credential only when a secret marker is its own
// underscore-separated segment (api_key, MY_API_KEY, private_key, OPENAI_KEY).
// Storage roles such as partition_key, sort_key, primary_key, cache_key, and
// prompt_cache_key have no such segment, so they stay. Marker-free vendor keys
// are left alone on purpose: missing a rare key beats wiping database keys.
const CREDENTIAL_KEY_MARKER =
"api|secret|private|access|auth|token|password|credential|openai";
const MARKER_KEY_SUFFIX = `(?:[a-z0-9]+_)*(?:${CREDENTIAL_KEY_MARKER})_key`;

const SECRET_QUERY_PARAMETER = new RegExp(
`([?&](?:api[_-]?key|access[_-]?token|token|password|secret|credential|${MARKER_KEY_SUFFIX})=)(?!\\[redacted: looks like a credential\\])[^&#\\s]+`,
"gi",
);

const JSON_CREDENTIAL_FIELD = new RegExp(
`("(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization|${MARKER_KEY_SUFFIX})"\\s*:\\s*")([^"\\r\\n]+)(")`,
"gi",
);

// Grep/shell lines often look like path:line:KEY=value
const ENV_ASSIGNMENT = /(?:^|:)([A-Z][A-Z0-9_]+)=([^\n]+)/gm;
Expand Down Expand Up @@ -76,8 +89,13 @@ export function scrubSecretShapedContent(text: string): string {
}

const JSON_SAFE_ERROR = "Tool result is not JSON-safe";
const CREDENTIAL_FIELD =
/^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth)$/i;
// Bare-KEY class (OPENAI_KEY, MY_API_KEY). The marker segment keeps storage
// roles (partition_key, sort_key, cache_key) readable, and requiring _key
// keeps MONKEY and a field named exactly "key" readable.
const CREDENTIAL_FIELD = new RegExp(
`^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth|${MARKER_KEY_SUFFIX})$`,
"i",
);

/**
* Returns a detached JSON-safe value without invoking input accessors or custom
Expand Down
Loading