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:
@@ -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
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user