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.
This commit is contained in:
can1357
2026-07-23 16:23:08 +02:00
parent 639bac596d
commit fadcd1a650
10 changed files with 74 additions and 8 deletions
+1
View File
@@ -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
+4
View File
@@ -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
@@ -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<TApi extends Api>(
duplicateToolCallIdSuffixPrefix = "_dup",
targetCompat: Model<TApi>["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`
@@ -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",
@@ -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(() => {
@@ -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)
@@ -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[] = [
+4
View File
@@ -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
@@ -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",
},
},
@@ -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<Record<SettingPath, SettingHook<any>>> = {
"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);
}
/**