From fadcd1a6508d7708a25e6fc7c107f022d5279917 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 23 Jul 2026 16:23:08 +0200 Subject: [PATCH] fix(ai): made credential-pattern redaction opt-in - Credential-shaped token redaction now requires explicit opt-in via `configureCredentialRedaction` and is disabled by default, so user-supplied credential-like strings reach providers unmodified unless the host enables redaction. - Wired the `secrets.enabled` ("Hide Secrets") setting to `configureCredentialRedaction` in the coding-agent so the pattern redaction follows the existing secret-obfuscation toggle. --- .gitignore | 1 + packages/ai/CHANGELOG.md | 4 +++ .../ai/src/providers/transform-messages.ts | 25 +++++++++++++++++-- .../test/gitlab-duo-workflow-provider.test.ts | 6 ++++- .../test/openai-codex-responses-lite.test.ts | 6 ++++- .../openai-responses-system-prompt.test.ts | 6 ++++- ...ransform-messages-redact-sensitive.test.ts | 23 +++++++++++++++-- packages/coding-agent/CHANGELOG.md | 4 +++ .../src/config/settings-schema.ts | 2 +- packages/coding-agent/src/config/settings.ts | 5 ++++ 10 files changed, 74 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index db243d087..d398f2a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ syntax.jsonl out.jsonl out.html pi-*.html +.close_issue.sh # Generated files packages/coding-agent/src/export/html/tool-views.generated.js diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index a8ab95d61..3a8a7804a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed outbound credential-pattern redaction (`[github_token_redacted]` & co.) running unconditionally: it is now opt-in via `configureCredentialRedaction` and disabled by default, so credential-shaped strings the user deliberately pastes reach the provider unmodified unless the host enables redaction. + ## [17.0.9] - 2026-07-23 ### Added diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index ecb6f0d05..4af7777d2 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -316,7 +316,25 @@ function hasPlausibleCredentialEntropy(token: string): boolean { return [/[a-z]/, /[A-Z]/, /\d/, /[_-]/].filter(pattern => pattern.test(secret)).length >= 2; } +/** + * Whether outbound credential-pattern redaction is active. Off by default; + * hosts opt in explicitly (the coding agent wires this to the + * `secrets.enabled` setting). + */ +let credentialRedactionEnabled = false; + +/** + * Toggle outbound credential-pattern redaction. When disabled (the default), + * {@link redactSensitiveCredentials} and {@link redactSensitiveInObject} are + * pass-throughs and outbound messages/system prompts leave the process + * unmodified. + */ +export function configureCredentialRedaction(enabled: boolean): void { + credentialRedactionEnabled = enabled; +} + export function redactSensitiveCredentials(text: string): string { + if (!credentialRedactionEnabled) return text; return text.replace(SENSITIVE_TOKEN_RE, match => { if (!hasPlausibleCredentialEntropy(match)) return match; const lower = match.toLowerCase(); @@ -337,6 +355,7 @@ export function redactSensitiveCredentials(text: string): string { } export function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } { + if (!credentialRedactionEnabled) return { result: val, changed: false }; if (typeof val === "string") { const redacted = redactSensitiveCredentials(val); return { result: redacted, changed: redacted !== val }; @@ -364,6 +383,7 @@ export function redactSensitiveInObject(val: unknown): { result: unknown; change } function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] { + if (!credentialRedactionEnabled) return messages; return messages.map((msg): Message => { if (msg.role === "user" || msg.role === "developer") { const userMsg = msg as UserMessage | DeveloperMessage; @@ -453,8 +473,9 @@ export function transformMessages( duplicateToolCallIdSuffixPrefix = "_dup", targetCompat: Model["compat"] = model.compat, ): Message[] { - // Redact sensitive credential-like patterns from all outbound messages - // to prevent security block errors from LLM providers (e.g. invalid_prompt). + // Redact sensitive credential-like patterns from all outbound messages when + // the host opted in via `configureCredentialRedaction` — prevents security + // block errors from LLM providers (e.g. invalid_prompt). messages = redactSensitiveCredentialsInMessages(messages); // Drop assistant `toolCall` blocks with empty/whitespace `id` or `name` diff --git a/packages/ai/test/gitlab-duo-workflow-provider.test.ts b/packages/ai/test/gitlab-duo-workflow-provider.test.ts index 52a65c1f6..edf479d8a 100644 --- a/packages/ai/test/gitlab-duo-workflow-provider.test.ts +++ b/packages/ai/test/gitlab-duo-workflow-provider.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -27,6 +27,7 @@ import { streamGitLabDuoWorkflow, traceGitLabDuoWorkflow, } from "@oh-my-pi/pi-ai/providers/gitlab-duo-workflow"; +import { configureCredentialRedaction } from "@oh-my-pi/pi-ai/providers/transform-messages"; import type { AssistantMessage, Context, @@ -42,6 +43,9 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils"; import { z } from "zod/v4"; +beforeAll(() => configureCredentialRedaction(true)); +afterAll(() => configureCredentialRedaction(false)); + const model: Model<"gitlab-duo-agent"> = buildModel({ id: "claude_sonnet_4_6_vertex", name: "Claude Sonnet 4.6 - Vertex", diff --git a/packages/ai/test/openai-codex-responses-lite.test.ts b/packages/ai/test/openai-codex-responses-lite.test.ts index b650eb481..ee6bd4bf7 100644 --- a/packages/ai/test/openai-codex-responses-lite.test.ts +++ b/packages/ai/test/openai-codex-responses-lite.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { type InputItem, type RequestBody, @@ -11,11 +11,15 @@ import { streamOpenAICodexResponses, } from "@oh-my-pi/pi-ai/providers/openai-codex-responses"; import { isOpenAIResponsesProgressEvent } from "@oh-my-pi/pi-ai/providers/openai-shared"; +import { configureCredentialRedaction } from "@oh-my-pi/pi-ai/providers/transform-messages"; import type { CodexCompactionRequestContext, Context, FetchImpl, ProviderSessionState } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import * as piUtils from "@oh-my-pi/pi-utils"; import { createCodexModel } from "./helpers"; +beforeAll(() => configureCredentialRedaction(true)); +afterAll(() => configureCredentialRedaction(false)); + const TEST_INSTALLATION_ID = "00000000-0000-4000-8000-000000000001"; beforeEach(() => { diff --git a/packages/ai/test/openai-responses-system-prompt.test.ts b/packages/ai/test/openai-responses-system-prompt.test.ts index 26103a45c..ba86199bc 100644 --- a/packages/ai/test/openai-responses-system-prompt.test.ts +++ b/packages/ai/test/openai-responses-system-prompt.test.ts @@ -1,9 +1,13 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { streamOpenAIResponses } from "@oh-my-pi/pi-ai/providers/openai-responses"; +import { configureCredentialRedaction } from "@oh-my-pi/pi-ai/providers/transform-messages"; import type { Context, FetchImpl, Model, ModelSpec } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +beforeAll(() => configureCredentialRedaction(true)); +afterAll(() => configureCredentialRedaction(false)); + // Non-reasoning model on api.openai.com (canonical path) const gpt4oMiniModel = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-responses">; // Reasoning model on api.openai.com (developer-role path) diff --git a/packages/ai/test/transform-messages-redact-sensitive.test.ts b/packages/ai/test/transform-messages-redact-sensitive.test.ts index 590bb3b8d..4b958b1b3 100644 --- a/packages/ai/test/transform-messages-redact-sensitive.test.ts +++ b/packages/ai/test/transform-messages-redact-sensitive.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "bun:test"; -import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { configureCredentialRedaction, transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages"; import type { AssistantMessage, Message, Model, ToolCall, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; @@ -18,6 +18,25 @@ function makeModel(): Model<"openai-responses"> { }); } +beforeAll(() => configureCredentialRedaction(true)); +afterAll(() => configureCredentialRedaction(false)); + +describe("transformMessages credential redaction disabled", () => { + it("passes real tokens through untouched when redaction is off", () => { + configureCredentialRedaction(false); + try { + const token = "ghp_AbCd1234EfGh5678IjKl9012MnOp3456QrSt"; + const transformed = transformMessages( + [{ role: "user", content: `Token: ${token}`, timestamp: Date.now() }], + makeModel(), + ); + expect(transformed[0]).toMatchObject({ role: "user", content: `Token: ${token}` }); + } finally { + configureCredentialRedaction(true); + } + }); +}); + describe("transformMessages redact sensitive credentials", () => { it("redacts already-masked and real tokens from outbound messages", () => { const messages: Message[] = [ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e86026dff..c23117eab 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed credential-shaped tokens (GitHub/GitLab/OpenAI/Anthropic key patterns) being redacted from outbound provider requests even with `secrets.enabled` off; the pattern redaction now follows the `secrets.enabled` ("Hide Secrets") setting like the secret obfuscator. + ## [17.0.9] - 2026-07-23 ### Added diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index d122d251c..726f184bb 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -4458,7 +4458,7 @@ export const SETTINGS_SCHEMA = { tab: "providers", group: "Privacy", label: "Hide Secrets", - description: "Obfuscate secrets before sending to AI providers", + description: "Obfuscate configured secrets and redact credential-shaped tokens before sending to AI providers", }, }, diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 9ee5b2ff5..a81d9299f 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -14,6 +14,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { configureCredentialRedaction } from "@oh-my-pi/pi-ai/providers/transform-messages"; import { configureProviderMaxInFlightRequests } from "@oh-my-pi/pi-ai/stream"; import { getAgentDbPath, @@ -1919,6 +1920,9 @@ const SETTING_HOOKS: Partial>> = { "providers.maxInFlightRequests": value => { configureProviderMaxInFlightRequests(validateProviderMaxInFlightRequests(value)); }, + "secrets.enabled": value => { + configureCredentialRedaction(value === true); + }, "hindsight.bankId": () => hindsightScopeSignal.fire(), "hindsight.bankIdPrefix": () => hindsightScopeSignal.fire(), "hindsight.scoping": () => hindsightScopeSignal.fire(), @@ -2001,6 +2005,7 @@ export function resetSettingsForTest(): void { globalInstancePromise = null; clearBoundSettingsMethods(); configureProviderMaxInFlightRequests(undefined); + configureCredentialRedaction(false); } /**