From 2394a99b1ab6d858546a75213a40bb8bde123fce Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 17:35:08 -0700 Subject: [PATCH 1/3] fix(secret-scrub): redact bare-KEY names in JSON object and text paths Provider-prefixed keys carry no token or secret marker, so the JSON classifiers need their own KEY boundary; the required prefix keeps a field named exactly key and MONKEY-style words readable. --- src/plugins/tool-result-secret-scrub.test.ts | 38 ++++++++++++++++++++ src/plugins/tool-result-secret-scrub.ts | 7 ++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/plugins/tool-result-secret-scrub.test.ts b/src/plugins/tool-result-secret-scrub.test.ts index ae601fb1e..6f02b872e 100644 --- a/src/plugins/tool-result-secret-scrub.test.ts +++ b/src/plugins/tool-result-secret-scrub.test.ts @@ -196,6 +196,44 @@ 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("toolResultSecretScrubPlugin", () => { const next = (content: ToolResult["content"], isError = false) => diff --git a/src/plugins/tool-result-secret-scrub.ts b/src/plugins/tool-result-secret-scrub.ts index 9fffe104e..e5f239e6c 100644 --- a/src/plugins/tool-result-secret-scrub.ts +++ b/src/plugins/tool-result-secret-scrub.ts @@ -22,7 +22,7 @@ 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; + /("(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization|[^"]+_key)"\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; @@ -76,8 +76,11 @@ export function scrubSecretShapedContent(text: string): string { } const JSON_SAFE_ERROR = "Tool result is not JSON-safe"; +// Bare-KEY class (OPENAI_KEY, MY_API_KEY): the underscore boundary keeps +// MONKEY-style words out, and the required prefix keeps an ordinary field +// named exactly "key" readable. const CREDENTIAL_FIELD = - /^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth)$/i; + /^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth|.+_key)$/i; /** * Returns a detached JSON-safe value without invoking input accessors or custom From 923900a1b8ec5f7234557ab156e5a138f6b3d563 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 17:37:38 -0700 Subject: [PATCH 2/3] fix(secret-scrub): redact bare-KEY names in query-parameter URLs --- src/plugins/tool-result-secret-scrub.test.ts | 28 ++++++++++++++++++++ src/plugins/tool-result-secret-scrub.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/plugins/tool-result-secret-scrub.test.ts b/src/plugins/tool-result-secret-scrub.test.ts index 6f02b872e..f1abbd2d9 100644 --- a/src/plugins/tool-result-secret-scrub.test.ts +++ b/src/plugins/tool-result-secret-scrub.test.ts @@ -234,6 +234,34 @@ describe("bare-KEY names in JSON paths", () => { }); }); +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) => diff --git a/src/plugins/tool-result-secret-scrub.ts b/src/plugins/tool-result-secret-scrub.ts index e5f239e6c..b4db0a312 100644 --- a/src/plugins/tool-result-secret-scrub.ts +++ b/src/plugins/tool-result-secret-scrub.ts @@ -19,7 +19,7 @@ const API_KEY_PATTERNS: RegExp[] = [ ]; const SECRET_QUERY_PARAMETER = - /([?&](?:api[_-]?key|access[_-]?token|token|password|secret|credential)=)(?!\[redacted: looks like a credential\])[^&#\s]+/gi; + /([?&](?:api[_-]?key|access[_-]?token|token|password|secret|credential|[^&#\s='"]*_key)=)(?!\[redacted: looks like a credential\])[^&#\s"']+/gi; const JSON_CREDENTIAL_FIELD = /("(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization|[^"]+_key)"\s*:\s*")([^"\r\n]+)(")/gi; From f589b175fd03df3ed1f4fa33c2ac951a4700591b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 16 Oct 2026 18:53:54 -0700 Subject: [PATCH 3/3] fix(secret-scrub): keep query tails and ordinary key fields --- src/plugins/tool-result-secret-scrub.test.ts | 53 ++++++++++++++++++++ src/plugins/tool-result-secret-scrub.ts | 35 +++++++++---- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/plugins/tool-result-secret-scrub.test.ts b/src/plugins/tool-result-secret-scrub.test.ts index f1abbd2d9..d117f60ee 100644 --- a/src/plugins/tool-result-secret-scrub.test.ts +++ b/src/plugins/tool-result-secret-scrub.test.ts @@ -234,6 +234,59 @@ describe("bare-KEY names in JSON paths", () => { }); }); +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 = diff --git a/src/plugins/tool-result-secret-scrub.ts b/src/plugins/tool-result-secret-scrub.ts index b4db0a312..526b27282 100644 --- a/src/plugins/tool-result-secret-scrub.ts +++ b/src/plugins/tool-result-secret-scrub.ts @@ -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|[^&#\s='"]*_key)=)(?!\[redacted: looks like a credential\])[^&#\s"']+/gi; - -const JSON_CREDENTIAL_FIELD = - /("(?:api[_-]?key|access[_-]?token|token|password|secret|credential|authorization|[^"]+_key)"\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; @@ -76,11 +89,13 @@ export function scrubSecretShapedContent(text: string): string { } const JSON_SAFE_ERROR = "Tool result is not JSON-safe"; -// Bare-KEY class (OPENAI_KEY, MY_API_KEY): the underscore boundary keeps -// MONKEY-style words out, and the required prefix keeps an ordinary field -// named exactly "key" readable. -const CREDENTIAL_FIELD = - /^(?:api[_ -]?key|access[_ -]?token|token|password|secret|credential|authorization|auth|.+_key)$/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