Default PII and secrets detection to scan only user-controlled roles (user, tool, function, mcp) instead of every message role. Infer per-span roles in the Codex and Anthropic extractors so harness-injected context (environment_context, system-reminder, Codex AGENTS.md) and system/assistant content stay unscanned.
Restrict the dashboard preview to scanned roles after masking.
- IP_ADDRESS
- VAT_CODE # EU VAT number (any member state; requires country prefix)
- # Which message roles to scan for PII (optional)
- # By default, all roles are scanned. Set this to scan only user-controlled content:
- # - user: User messages (primary source of PII)
- # - tool: Tool/function call results (may contain user data)
- # - function: Legacy function results (OpenAI)
- # scan_roles:
- # - user
- # - tool
- # - function
-
# Secrets Detection settings (Secrets Shield)
# Detects private keys, API keys, tokens and other secret credentials in requests
secrets_detection:
# Log detected secret types (never logs raw secret content)
log_detected_types: true
- # Which message roles to scan for secrets (optional)
- # By default, all roles are scanned. Set this to scan only user-controlled content:
- # - user: User messages (primary source of secrets)
- # - tool: Tool/function call results (may contain secrets)
- # - function: Legacy function results (OpenAI)
- # scan_roles:
- # - user
- # - tool
- # - function
-
# Logging settings
logging:
# SQLite database for request logs
# Log retention in days (0 = keep forever)
retention_days: 30
- # Log masked content for dashboard preview (default: true)
- # Shows what was actually sent to provider with PII and secrets replaced by placeholders
- # Disable if you don't want any content stored, even masked
+ # Store request text in the dashboard after masking (default: true)
+ # Disable if you don't want request text stored, even after masking.
+ # Only scanned roles (scan_roles) are stored, after PII and secrets are masked.
log_masked_content: true
# Dashboard settings
| `show_markers` | `false` | Add visual markers around unmasked values |
| `marker_text` | `[protected]` | Marker text if enabled |
| `allowlist` | `[]` | Text patterns that are never masked; set `regex: true` for regex patterns |
-| `denylist` | `[]` | Text patterns that are always masked with the configured `type`; set `regex: true` for regex patterns |
+| `denylist` | `[]` | Text patterns masked with the configured `type`; set `regex: true` for regex patterns |
## Response Headers
|--------|---------|-------------|
| `database` | `./data/pasteguard.db` | SQLite database path |
| `retention_days` | `30` | Days to keep logs. `0` = forever |
-| `log_masked_content` | `true` | Log masked version for dashboard |
+| `log_masked_content` | `true` | Store request text in the dashboard after masking |
## Database
## Content Logging
-### Masked Content (default)
+### Masked Content
-Logs the masked version for dashboard preview:
+Stores a dashboard preview for text that PasteGuard scanned:
```yaml
logging:
log_masked_content: true
```
-Shows what was actually sent upstream with PII and secrets replaced by placeholders.
+This is the default. It stores only roles in `scan_roles`, after PII and secrets are replaced
+with placeholders. System prompts, developer prompts, assistant messages, and agent context do not
+appear in the preview.
### No Content
log_masked_content: false
```
-Only metadata (timestamps, models, PII detected) is logged.
+Only metadata is logged.
## Security
-- Raw request/response content is **never** logged — only the masked version, and only when `log_masked_content` is enabled
-- With `secrets_detection.action: route_local`, content is not logged at all when secrets are detected, since secrets stay unmasked for the local provider
-- Only secret types are logged if `log_detected_types: true`
-- Masked content shows placeholders like `[[EMAIL_ADDRESS_1]]` and `[[API_KEY_SK_1]]`, not real values
+- The dashboard preview stores only scanned spans (`scan_roles`) after masking; other roles never appear.
+- With `secrets_detection.action: route_local`, content is not logged when secrets are detected.
+- Secret logs store secret types only, never the raw secret value.
+- Placeholders look like `[[EMAIL_ADDRESS_1]]` or `[[API_KEY_SK_1]]`.
## Scan Roles
-By default, all message roles are scanned. To scan only user-controlled content:
+By default, PasteGuard scans user messages and tool results. It skips system, developer,
+and assistant text. Set `scan_roles` to replace that default:
```yaml
pii_detection:
- user
- tool
- function
+ - mcp
```
-| Role | Description |
-|------|-------------|
-| `user` | User messages (primary source of PII) |
+| Scan label | Description |
+|------------|-------------|
+| `user` | User messages |
| `assistant` | Assistant responses |
| `system` | System prompts |
-| `tool` | Tool/function call results |
-| `function` | Legacy function results (OpenAI) |
+| `developer` | Developer prompts |
+| `tool` | Tool results, including file reads and shell output |
+| `function` | Legacy OpenAI function results |
+| `mcp` | PasteGuard internal label for MCP tool items, such as Codex `mcp_tool_call` output |
-This reduces detector calls for large system prompts and avoids false positives on app-controlled content.
+If `scan_roles` is set, PasteGuard scans exactly those roles.
## Scan Roles
-By default, all message roles are scanned. To scan only user-controlled content:
+By default, PasteGuard scans user messages and tool results. It skips system, developer,
+and assistant text. Set `scan_roles` to replace that default:
```yaml
secrets_detection:
- user
- tool
- function
+ - mcp
```
-| Role | Description |
-|------|-------------|
-| `user` | User messages (primary source of secrets) |
+| Scan label | Description |
+|------------|-------------|
+| `user` | User messages |
| `assistant` | Assistant responses |
| `system` | System prompts |
-| `tool` | Tool/function call results |
-| `function` | Legacy function results (OpenAI) |
+| `developer` | Developer prompts |
+| `tool` | Tool results, including file reads and shell output |
+| `function` | Legacy OpenAI function results |
+| `mcp` | PasteGuard internal label for MCP tool items, such as Codex `mcp_tool_call` output |
+
+If `scan_roles` is set, PasteGuard scans exactly those roles.
## Performance
}
});
+ test("defaults PII and secrets scan roles to input-controlled content", () => {
+ const path = writeConfig(`
+mode: mask
+providers:
+ openai: {}
+ anthropic: {}
+pii_detection:
+ detector_url: http://localhost:5002
+`);
+
+ try {
+ const config = loadConfig(path);
+
+ expect(config.pii_detection.scan_roles).toEqual(["user", "tool", "function", "mcp"]);
+ expect(config.secrets_detection.scan_roles).toEqual(["user", "tool", "function", "mcp"]);
+ } finally {
+ cleanupConfig(path);
+ }
+ });
+
+ test("falls back to default scan roles when configured empty", () => {
+ const path = writeConfig(`
+mode: mask
+providers:
+ openai: {}
+ anthropic: {}
+pii_detection:
+ detector_url: http://localhost:5002
+ scan_roles: []
+secrets_detection:
+ scan_roles: []
+`);
+
+ try {
+ const config = loadConfig(path);
+
+ expect(config.pii_detection.scan_roles).toEqual(["user", "tool", "function", "mcp"]);
+ expect(config.secrets_detection.scan_roles).toEqual(["user", "tool", "function", "mcp"]);
+ } finally {
+ cleanupConfig(path);
+ }
+ });
+
+ test("rejects unknown scan roles", () => {
+ const path = writeConfig(`
+mode: mask
+providers:
+ openai: {}
+ anthropic: {}
+pii_detection:
+ detector_url: http://localhost:5002
+ scan_roles:
+ - users
+`);
+
+ try {
+ expect(() => loadConfig(path)).toThrow("Invalid configuration");
+ } finally {
+ cleanupConfig(path);
+ }
+ });
+
test("accepts masking allowlist and denylist patterns", () => {
const path = writeConfig(`
mode: mask
.pipe(z.array(PhoneRegionSchema))
.default([]);
+const KNOWN_SCAN_ROLES = ["user", "tool", "function", "mcp", "system", "developer", "assistant"];
+const DEFAULT_SCAN_ROLES = ["user", "tool", "function", "mcp"];
+const scanRolesField = z
+ .array(
+ z.string().refine((role) => KNOWN_SCAN_ROLES.includes(role), {
+ message: `Unknown scan role (allowed: ${KNOWN_SCAN_ROLES.join(", ")})`,
+ }),
+ )
+ .default([...DEFAULT_SCAN_ROLES])
+ .transform((roles) => (roles.length > 0 ? roles : [...DEFAULT_SCAN_ROLES]));
+
const PIIDetectionSchema = z.object({
enabled: z.boolean().default(true),
detector_url: z.string().url(),
"IP_ADDRESS",
"VAT_CODE",
]),
- scan_roles: z.array(z.string()).optional(),
+ scan_roles: scanRolesField,
});
const ServerSchema = z.object({
entities: z.array(z.enum(SecretEntityTypes)).default([...SecretEntityTypes]),
max_scan_chars: z.coerce.number().int().min(0).default(200000),
log_detected_types: z.boolean().default(true),
- scan_roles: z.array(z.string()).optional(),
+ scan_roles: scanRolesField,
});
const ConfigSchema = z
return "";
}
-/**
- * Extract text from content (string or block array)
- */
-export function extractAnthropicTextContent(content: string | ContentBlock[] | undefined): string {
- if (!content) return "";
- if (typeof content === "string") return content;
- if (Array.isArray(content)) {
- return content.map(extractBlockText).filter(Boolean).join("\n");
- }
- return "";
-}
-
-/**
- * Extract text from system prompt (for logging/debugging)
- */
-export function extractSystemText(system: string | ContentBlock[] | undefined): string {
- if (!system) return "";
- if (typeof system === "string") return system;
- return extractAnthropicTextContent(system);
-}
-
/**
* Anthropic request extractor
*
--- /dev/null
+import { describe, expect, test } from "bun:test";
+import { type CodexResponsesRequest, codexExtractor } from "./codex";
+
+describe("Codex Text Extractor", () => {
+ test("infers roles for instructions, messages, tools, and MCP items", () => {
+ const request: CodexResponsesRequest = {
+ model: "gpt-5.5",
+ instructions: "System Jane jane.system@example.com",
+ input: [
+ {
+ type: "message",
+ role: "assistant",
+ content: [{ type: "output_text", text: "Assistant Alice alice.assistant@example.com" }],
+ },
+ {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_text", text: "User Bob bob.user@example.com" }],
+ },
+ {
+ type: "message",
+ role: "user",
+ content: [
+ {
+ type: "input_text",
+ text: "# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nSystem context\n</INSTRUCTIONS>",
+ },
+ {
+ type: "input_text",
+ text: "<environment_context>\n<cwd>/repo</cwd>\n</environment_context>",
+ },
+ {
+ type: "input_text",
+ text: "<system-reminder>Internal reminder</system-reminder>",
+ },
+ ],
+ },
+ {
+ type: "function_call_output",
+ output_text: "DATABASE_URL=postgres://admin:secret@db.example.com/app",
+ },
+ {
+ type: "mcp_tool_call",
+ output: "MCP result for Charlie charlie@example.com",
+ },
+ {
+ type: "local_shell_call_output",
+ output: "Shell output for Dana dana@example.com",
+ },
+ ],
+ };
+
+ expect(
+ codexExtractor.extractTexts(request).map((span) => ({
+ path: span.path,
+ role: span.role,
+ text: span.text,
+ })),
+ ).toEqual([
+ {
+ path: "instructions",
+ role: "system",
+ text: "System Jane jane.system@example.com",
+ },
+ {
+ path: "input[0].content[0].text",
+ role: "assistant",
+ text: "Assistant Alice alice.assistant@example.com",
+ },
+ {
+ path: "input[1].content[0].text",
+ role: "user",
+ text: "User Bob bob.user@example.com",
+ },
+ {
+ path: "input[2].content[0].text",
+ role: "user",
+ text: "# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nSystem context\n</INSTRUCTIONS>",
+ },
+ {
+ path: "input[2].content[1].text",
+ role: "user",
+ text: "<environment_context>\n<cwd>/repo</cwd>\n</environment_context>",
+ },
+ {
+ path: "input[2].content[2].text",
+ role: "user",
+ text: "<system-reminder>Internal reminder</system-reminder>",
+ },
+ {
+ path: "input[3].output_text",
+ role: "tool",
+ text: "DATABASE_URL=postgres://admin:secret@db.example.com/app",
+ },
+ {
+ path: "input[4].output",
+ role: "mcp",
+ text: "MCP result for Charlie charlie@example.com",
+ },
+ {
+ path: "input[5].output",
+ role: "tool",
+ text: "Shell output for Dana dana@example.com",
+ },
+ ]);
+ });
+});
"input",
"input_text",
"instructions",
+ "output",
"output_text",
+ "stderr",
+ "stdout",
"text",
]);
interface LocatedString {
path: Array<string | number>;
value: string;
+ role: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
-function collectText(value: unknown, path: Array<string | number> = []): LocatedString[] {
+function roleForText(path: Array<string | number>, inheritedRole?: string): string {
+ if (path.includes("instructions")) return "system";
+ return inheritedRole ?? "user";
+}
+
+function inferRole(value: Record<string, unknown>, inheritedRole?: string): string | undefined {
+ if (typeof value.role === "string") return value.role;
+ if (value.type === "function_call_output" || value.type === "local_shell_call_output")
+ return "tool";
+ if (typeof value.type === "string" && value.type.startsWith("mcp_")) return "mcp";
+ return inheritedRole;
+}
+
+function collectText(
+ value: unknown,
+ path: Array<string | number> = [],
+ inheritedRole?: string,
+): LocatedString[] {
if (typeof value === "string") {
const key = path[path.length - 1];
if (typeof key === "string" && TEXT_KEYS.has(key)) {
- return [{ path, value }];
+ return [{ path, value, role: roleForText(path, inheritedRole) }];
}
return [];
}
if (Array.isArray(value)) {
- return value.flatMap((item, index) => collectText(item, [...path, index]));
+ return value.flatMap((item, index) => collectText(item, [...path, index], inheritedRole));
}
if (isRecord(value)) {
- return Object.entries(value).flatMap(([key, item]) => collectText(item, [...path, key]));
+ const role = inferRole(value, inheritedRole);
+ return Object.entries(value).flatMap(([key, item]) => collectText(item, [...path, key], role));
}
return [];
path: pathToString(item.path),
messageIndex: index,
partIndex: 0,
- role: item.path.includes("instructions") ? "system" : "user",
+ role: item.role,
}));
},
--- /dev/null
+import { describe, expect, test } from "bun:test";
+import { resolveOverlaps, type Span } from "./conflict-resolver";
+import { createPlaceholderContext } from "./context";
+import { maskSpans } from "./service";
+import type { TextSpan } from "./types";
+
+describe("maskSpans", () => {
+ test("preserves nested part indexes", () => {
+ const text = "DATABASE_URL=postgres://user:pass@db.example.com/app";
+ const spans: TextSpan[] = [
+ {
+ text,
+ path: "messages[0].content[0].content[0].text",
+ messageIndex: 0,
+ partIndex: 0,
+ nestedPartIndex: 0,
+ role: "tool",
+ },
+ ];
+ const items: Span[][] = [[{ start: 13, end: text.length }]];
+ const result = maskSpans(
+ spans,
+ items,
+ () => "CONNECTION_STRING",
+ (type, context) => {
+ context.counters[type] = (context.counters[type] ?? 0) + 1;
+ return `[[${type}_${context.counters[type]}]]`;
+ },
+ resolveOverlaps,
+ createPlaceholderContext(),
+ );
+
+ expect(result.maskedSpans[0]).toEqual({
+ path: "messages[0].content[0].content[0].text",
+ maskedText: "DATABASE_URL=[[CONNECTION_STRING_1]]",
+ messageIndex: 0,
+ partIndex: 0,
+ nestedPartIndex: 0,
+ });
+ });
+});
maskedText: span.text,
messageIndex: span.messageIndex,
partIndex: span.partIndex,
+ nestedPartIndex: span.nestedPartIndex,
});
continue;
}
maskedText,
messageIndex: span.messageIndex,
partIndex: span.partIndex,
+ nestedPartIndex: span.nestedPartIndex,
});
}
import { afterEach, describe, expect, mock, test } from "bun:test";
import { getConfig } from "../config";
import { openaiExtractor } from "../masking/extractors/openai";
+import type { RequestExtractor, TextSpan } from "../masking/types";
import type { OpenAIMessage, OpenAIRequest } from "../providers/openai/types";
import {
filterAllowlistedEntities,
return { model: "gpt-4", messages };
}
+const spanExtractor: RequestExtractor<TextSpan[], unknown> = {
+ extractTexts: (request) => request,
+ applyMasked: (request) => request,
+ unmaskResponse: (response) => response,
+};
+
describe("PIIDetector", () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("analyzeRequest", () => {
- test("scans all message roles", async () => {
- mockDetector({
+ test("scans input roles by default and skips system/developer/assistant", async () => {
+ const analyzeRequests = mockDetector({
"system-pii": [{ entity_type: "PERSON", start: 0, end: 10, score: 0.9 }],
"user-pii": [{ entity_type: "EMAIL_ADDRESS", start: 0, end: 8, score: 0.9 }],
+ "tool-pii": [{ entity_type: "IP_ADDRESS", start: 0, end: 8, score: 0.9 }],
+ "function-pii": [{ entity_type: "VAT_CODE", start: 0, end: 12, score: 0.9 }],
"assistant-pii": [{ entity_type: "PHONE_NUMBER", start: 0, end: 13, score: 0.9 }],
});
const detector = new PIIDetector();
const request = createRequest([
{ role: "system", content: "system-pii here" },
+ { role: "developer", content: "developer-pii here" },
{ role: "user", content: "user-pii here" },
{ role: "assistant", content: "assistant-pii here" },
+ { role: "tool", content: "tool-pii here" },
+ { role: "function", content: "function-pii here" },
]);
const result = await detector.analyzeRequest(request, openaiExtractor);
expect(result.hasPII).toBe(true);
- expect(result.spanEntities).toHaveLength(3);
- expect(result.spanEntities[0]).toHaveLength(1);
- expect(result.spanEntities[1]).toHaveLength(1);
+ expect(result.spanEntities).toHaveLength(6);
+ expect(result.spanEntities[0]).toHaveLength(0);
+ expect(result.spanEntities[1]).toHaveLength(0);
expect(result.spanEntities[2]).toHaveLength(1);
+ expect(result.spanEntities[3]).toHaveLength(0);
+ expect(result.spanEntities[4]).toHaveLength(1);
+ expect(result.spanEntities[5]).toHaveLength(1);
+ expect(analyzeRequests).toEqual([
+ expect.objectContaining({ text: "user-pii here" }),
+ expect.objectContaining({ text: "tool-pii here" }),
+ expect.objectContaining({ text: "function-pii here" }),
+ ]);
});
- test("detects PII in system message when user message has none", async () => {
- mockDetector({
+ test("ignores PII in system message when user message has none", async () => {
+ const analyzeRequests = mockDetector({
"John Doe": [{ entity_type: "PERSON", start: 18, end: 26, score: 0.95 }],
});
const result = await detector.analyzeRequest(request, openaiExtractor);
+ expect(result.hasPII).toBe(false);
+ expect(result.spanEntities[0]).toHaveLength(0);
+ expect(result.spanEntities[1]).toHaveLength(0);
+ expect(analyzeRequests).toEqual([
+ expect.objectContaining({ text: "Extract the data into JSON" }),
+ ]);
+ });
+
+ test("scans mcp spans by default", async () => {
+ const analyzeRequests = mockDetector({
+ "mcp-pii": [{ entity_type: "EMAIL_ADDRESS", start: 0, end: 7, score: 0.9 }],
+ });
+
+ const detector = new PIIDetector();
+ const spans: TextSpan[] = [
+ {
+ text: "mcp-pii here",
+ path: "input[0].output_text",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "mcp",
+ },
+ ];
+
+ const result = await detector.analyzeRequest(spans, spanExtractor);
+
expect(result.hasPII).toBe(true);
expect(result.spanEntities[0]).toHaveLength(1);
- expect(result.spanEntities[0][0].entity_type).toBe("PERSON");
+ expect(analyzeRequests).toEqual([expect.objectContaining({ text: "mcp-pii here" })]);
+ });
+
+ test("honors explicit scan_roles override", async () => {
+ const config = getConfig();
+ const previousScanRoles = config.pii_detection.scan_roles;
+ config.pii_detection.scan_roles = ["system", "assistant"];
+ mockDetector({
+ "system-pii": [{ entity_type: "PERSON", start: 0, end: 10, score: 0.9 }],
+ "assistant-pii": [{ entity_type: "PHONE_NUMBER", start: 0, end: 13, score: 0.9 }],
+ "user-pii": [{ entity_type: "EMAIL_ADDRESS", start: 0, end: 8, score: 0.9 }],
+ });
+
+ try {
+ const detector = new PIIDetector();
+ const request = createRequest([
+ { role: "system", content: "system-pii here" },
+ { role: "user", content: "user-pii here" },
+ { role: "assistant", content: "assistant-pii here" },
+ ]);
+
+ const result = await detector.analyzeRequest(request, openaiExtractor);
+
+ expect(result.hasPII).toBe(true);
+ expect(result.spanEntities[0]).toHaveLength(1);
+ expect(result.spanEntities[1]).toHaveLength(0);
+ expect(result.spanEntities[2]).toHaveLength(1);
+ } finally {
+ config.pii_detection.scan_roles = previousScanRoles;
+ }
+ });
+
+ test("does not apply denylist to roles outside scan_roles", async () => {
+ const config = getConfig();
+ const previousDenylist = config.masking.denylist;
+ config.masking.denylist = [{ pattern: "ProjectX", type: "PROJECT_NAME", regex: false }];
+ mockDetector({});
+
+ try {
+ const detector = new PIIDetector();
+ const request = createRequest([
+ { role: "system", content: "Launch ProjectX" },
+ { role: "user", content: "No sensitive data" },
+ ]);
+
+ const result = await detector.analyzeRequest(request, openaiExtractor);
+
+ expect(result.hasPII).toBe(false);
+ expect(result.spanEntities[0]).toHaveLength(0);
+ } finally {
+ config.masking.denylist = previousDenylist;
+ }
});
test("detects PII in earlier user message", async () => {
const spans = extractor.extractTexts(request);
// Detect PII for each span independently
- const scanRoles = config.pii_detection.scan_roles
- ? new Set(config.pii_detection.scan_roles)
- : null;
+ const scanRoles = new Set(config.pii_detection.scan_roles);
const allowlist = config.masking.allowlist;
const denylist = config.masking.denylist;
const spanEntities: PIIEntity[][] = await Promise.all(
spans.map(async (span) => {
if (!span.text) return [];
- const denylistedEntities = findDenylistedEntities(span.text, denylist, knownPlaceholders);
- if (scanRoles && span.role && !scanRoles.has(span.role)) {
- return mergeDenylistEntities([], denylistedEntities);
+ if (!span.role || !scanRoles.has(span.role)) {
+ return [];
}
+ const denylistedEntities = findDenylistedEntities(span.text, denylist, knownPlaceholders);
const detectedEntities = config.pii_detection.enabled
? await this.detectPII(span.text)
: [];
import { Hono } from "hono";
import { getConfig } from "../config";
import type { PlaceholderContext } from "../masking/context";
-import {
- anthropicExtractor,
- extractAnthropicTextContent,
- extractSystemText,
-} from "../masking/extractors/anthropic";
+import { anthropicExtractor } from "../masking/extractors/anthropic";
import { unmaskResponse as unmaskPIIResponse } from "../pii/mask";
import { callAnthropic } from "../providers/anthropic/client";
import { createAnthropicUnmaskingStream } from "../providers/anthropic/stream-transformer";
} from "../providers/anthropic/types";
import { callLocalAnthropic } from "../providers/local";
import { unmaskSecretsResponse } from "../secrets/mask";
+import { formatMaskedSpansForLog, logScanRoles } from "../services/log-content";
import { logRequest } from "../services/logger";
import { detectPII, maskPII, type PIIDetectResult } from "../services/pii";
import {
// --- Helpers ---
-function formatRequestForLog(request: AnthropicRequest): string {
- const parts: string[] = [];
-
- if (request.system) {
- const systemText = extractSystemText(request.system);
- if (systemText) parts.push(`[system] ${systemText}`);
- }
-
- for (const msg of request.messages) {
- const text = extractAnthropicTextContent(msg.content);
- const isMultimodal = Array.isArray(msg.content);
- parts.push(`[${msg.role}${isMultimodal ? " multimodal" : ""}] ${text}`);
- }
-
- return parts.join("\n");
+function formatRequestForLog(request: AnthropicRequest): string | undefined {
+ const config = getConfig();
+ return formatMaskedSpansForLog(
+ anthropicExtractor.extractTexts(request),
+ logScanRoles({
+ piiRoles: config.pii_detection.scan_roles,
+ piiActive: config.pii_detection.enabled || config.masking.denylist.length > 0,
+ secretRoles: config.secrets_detection.scan_roles,
+ secretsActive: config.secrets_detection.enabled,
+ }),
+ );
}
// --- Response handlers ---
entities: config.secrets_detection.entities,
max_scan_chars: config.secrets_detection.max_scan_chars,
log_detected_types: false,
+ scan_roles: config.secrets_detection.scan_roles,
};
const secretsResult = detectSecrets(maskedText, secretsConfig);
);
});
+ test("masks and logs Codex MCP output fields", async () => {
+ const calls: CapturedRequest[] = [];
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
+ const request = input instanceof Request ? input : new Request(input, init);
+ calls.push({
+ url: request.url,
+ method: request.method,
+ headers: new Headers(request.headers),
+ body: await request.clone().text(),
+ });
+ return Promise.resolve(
+ Response.json({
+ output: [{ type: "message", content: [{ type: "output_text", text: "ok" }] }],
+ }),
+ );
+ }) as typeof fetch;
+
+ const res = await app.request("/codex/responses", {
+ method: "POST",
+ body: JSON.stringify({
+ model: "gpt-5.5",
+ input: [
+ {
+ type: "message",
+ role: "user",
+ content: [
+ {
+ type: "input_text",
+ text: "Find ANSWER_CODE in the file output.",
+ },
+ ],
+ },
+ {
+ type: "mcp_tool_call",
+ output: "mcp output\nDATABASE_URL=postgres://mcp:secret@db.example.com/app",
+ },
+ ],
+ }),
+ headers: {
+ Authorization: "Bearer chatgpt-token",
+ "Content-Type": "application/json",
+ },
+ });
+
+ expect(res.status).toBe(200);
+ expect(calls).toHaveLength(1);
+
+ const forwarded = JSON.parse(calls[0].body) as {
+ input: Array<{ output?: string }>;
+ };
+ expect(forwarded.input[1].output).toContain("[[CONNECTION_STRING_1]]");
+ expect(forwarded.input[1].output).not.toContain("postgres://mcp:secret");
+
+ const logCalls = mockLogRequest.mock.calls as unknown as Array<
+ [{ maskedContent?: string }, string | null]
+ >;
+ const logData = logCalls.at(-1)?.[0];
+ expect(logData?.maskedContent).toContain("[mcp result]");
+ expect(logData?.maskedContent).toContain("[[CONNECTION_STRING_1]]");
+ expect(logData?.maskedContent).not.toContain("postgres://");
+ });
+
test("blocks sensitive Codex requests in route mode instead of forwarding them", async () => {
config.mode = "route";
mockAnalyzeRequest.mockResolvedValueOnce({
unmaskSecretsResponse,
unmaskSecretsStreamChunk,
} from "../secrets/mask";
+import { formatMaskedSpansForLog, logScanRoles } from "../services/log-content";
import { logRequest } from "../services/logger";
import { detectPII, maskPII, type PIIDetectResult } from "../services/pii";
import {
}
function formatCodexForLog(request: CodexResponsesRequest): string | undefined {
- const spans = codexExtractor.extractTexts(request).filter((span) => span.role !== "system");
- if (spans.length === 0) return undefined;
-
- return spans
- .map((span) => `[${span.role || "unknown"} ${span.path}] ${span.text}`)
- .join("\n")
- .slice(0, 20000);
+ const config = getConfig();
+ const scanRoles = logScanRoles({
+ piiRoles: config.pii_detection.scan_roles,
+ piiActive: config.pii_detection.enabled || config.masking.denylist.length > 0,
+ secretRoles: config.secrets_detection.scan_roles,
+ secretsActive: config.secrets_detection.enabled,
+ });
+ return formatMaskedSpansForLog(codexExtractor.extractTexts(request), scanRoles);
}
function respondBlocked(
import { callOpenAI, getOpenAIInfo, type ProviderResult } from "../providers/openai/client";
import { createUnmaskingStream } from "../providers/openai/stream-transformer";
import {
- type OpenAIMessage,
type OpenAIRequest,
OpenAIRequestSchema,
type OpenAIResponse,
} from "../providers/openai/types";
import { unmaskSecretsResponse } from "../secrets/mask";
+import { formatMaskedSpansForLog, logScanRoles } from "../services/log-content";
import { logRequest } from "../services/logger";
import { detectPII, maskPII, type PIIDetectResult } from "../services/pii";
import {
type SecretsProcessResult,
secretPlaceholders,
} from "../services/secrets";
-import { extractTextContent } from "../utils/content";
import {
createLogData,
errorFormats,
// --- Helpers ---
-function formatMessagesForLog(messages: OpenAIMessage[]): string {
- return messages
- .map((m) => {
- const text = extractTextContent(m.content);
- const isMultimodal = Array.isArray(m.content);
- return `[${m.role}${isMultimodal ? " multimodal" : ""}] ${text}`;
- })
- .join("\n");
+function formatRequestForLog(request: OpenAIRequest): string | undefined {
+ const config = getConfig();
+ return formatMaskedSpansForLog(
+ openaiExtractor.extractTexts(request),
+ logScanRoles({
+ piiRoles: config.pii_detection.scan_roles,
+ piiActive: config.pii_detection.enabled || config.masking.denylist.length > 0,
+ secretRoles: config.secrets_detection.scan_roles,
+ secretsActive: config.secrets_detection.enabled,
+ }),
+ );
}
// --- Response handlers ---
const { request, piiResult, piiMaskingContext, secretsResult, startTime, authHeader } = opts;
const maskedContent =
- piiResult.hasPII || secretsResult.masked ? formatMessagesForLog(request.messages) : undefined;
+ piiResult.hasPII || secretsResult.masked ? formatRequestForLog(request) : undefined;
setResponseHeaders(
c,
}
const maskedContent =
- piiResult.hasPII || secretsResult.masked ? formatMessagesForLog(request.messages) : undefined;
+ piiResult.hasPII || secretsResult.masked ? formatRequestForLog(request) : undefined;
setResponseHeaders(
c,
import { describe, expect, test } from "bun:test";
import type { SecretsDetectionConfig } from "../config";
-import { detectSecrets } from "./detect";
+import type { TextSpan } from "../masking/types";
+import { detectSecrets, detectSecretsInSpans } from "./detect";
const defaultConfig: SecretsDetectionConfig = {
enabled: true,
entities: ["OPENSSH_PRIVATE_KEY", "PEM_PRIVATE_KEY"],
max_scan_chars: 200000,
log_detected_types: true,
+ scan_roles: ["user", "tool", "function", "mcp"],
};
const opensshKey = `-----BEGIN OPENSSH PRIVATE KEY-----
});
});
+describe("detectSecretsInSpans", () => {
+ const apiKeyConfig: SecretsDetectionConfig = {
+ ...defaultConfig,
+ entities: ["API_KEY_SK"],
+ };
+
+ test("scans input roles by default and skips system/developer/assistant", () => {
+ const spans: TextSpan[] = [
+ {
+ text: `system ${openaiApiKey}`,
+ path: "messages[0].content",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "system",
+ },
+ {
+ text: `developer ${openaiApiKey}`,
+ path: "messages[1].content",
+ messageIndex: 1,
+ partIndex: 0,
+ role: "developer",
+ },
+ {
+ text: `user ${openaiApiKey}`,
+ path: "messages[2].content",
+ messageIndex: 2,
+ partIndex: 0,
+ role: "user",
+ },
+ {
+ text: `assistant ${openaiApiKey}`,
+ path: "messages[3].content",
+ messageIndex: 3,
+ partIndex: 0,
+ role: "assistant",
+ },
+ {
+ text: `tool ${openaiApiKey}`,
+ path: "messages[4].content",
+ messageIndex: 4,
+ partIndex: 0,
+ role: "tool",
+ },
+ {
+ text: `function ${openaiApiKey}`,
+ path: "messages[5].content",
+ messageIndex: 5,
+ partIndex: 0,
+ role: "function",
+ },
+ {
+ text: `mcp ${openaiApiKey}`,
+ path: "input[6].output_text",
+ messageIndex: 6,
+ partIndex: 0,
+ role: "mcp",
+ },
+ ];
+
+ const result = detectSecretsInSpans(spans, apiKeyConfig);
+
+ expect(result.detected).toBe(true);
+ expect(result.spanLocations).toBeDefined();
+ expect(result.spanLocations?.map((locations) => locations.length)).toEqual([
+ 0, 0, 1, 0, 1, 1, 1,
+ ]);
+ expect(result.matches).toEqual([{ type: "API_KEY_SK", count: 4 }]);
+ });
+
+ test("honors explicit scan_roles override", () => {
+ const config: SecretsDetectionConfig = {
+ ...apiKeyConfig,
+ scan_roles: ["system", "assistant"],
+ };
+ const spans: TextSpan[] = [
+ {
+ text: `system ${openaiApiKey}`,
+ path: "messages[0].content",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "system",
+ },
+ {
+ text: `user ${openaiApiKey}`,
+ path: "messages[1].content",
+ messageIndex: 1,
+ partIndex: 0,
+ role: "user",
+ },
+ {
+ text: `assistant ${openaiApiKey}`,
+ path: "messages[2].content",
+ messageIndex: 2,
+ partIndex: 0,
+ role: "assistant",
+ },
+ ];
+
+ const result = detectSecretsInSpans(spans, config);
+
+ expect(result.detected).toBe(true);
+ expect(result.spanLocations?.map((locations) => locations.length)).toEqual([1, 0, 1]);
+ expect(result.matches).toEqual([{ type: "API_KEY_SK", count: 2 }]);
+ });
+});
+
// Test data for secret types
const openaiApiKey = "sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx";
const anthropicApiKey =
return detectSecretsInSpans(spans, config);
}
-function detectSecretsInSpans(
+export function detectSecretsInSpans(
spans: TextSpan[],
config: SecretsDetectionConfig,
): MessageSecretsResult {
}
// Detect secrets in each span
- const scanRoles = config.scan_roles ? new Set(config.scan_roles) : null;
+ const scanRoles = new Set(config.scan_roles);
const matchCounts = new Map<string, number>();
const spanLocations: SecretLocation[][] = spans.map((span) => {
- if (scanRoles && span.role && !scanRoles.has(span.role)) {
+ if (!span.role || !scanRoles.has(span.role)) {
return [];
}
const result = detectSecrets(span.text, config);
import { describe, expect, test } from "bun:test";
-import { shouldLogMaskedContent } from "./log-content";
+import { formatMaskedSpansForLog, logScanRoles, shouldLogMaskedContent } from "./log-content";
describe("shouldLogMaskedContent", () => {
- // With action "mask", maskedContent has both PII and secrets replaced by
- // placeholders, e.g. "My key is [[API_KEY_SK_1]] and email [[EMAIL_ADDRESS_1]]".
- // Storing it is safe even when secrets were detected (issue #91).
const maskedWithSecret = "My key is [[API_KEY_SK_1]] and email [[EMAIL_ADDRESS_1]]";
const maskedPiiOnly = "Email [[EMAIL_ADDRESS_1]]";
});
test("does not log when secrets were detected but not masked (route_local)", () => {
- // Route mode with action "route_local" leaves secrets raw for the trusted
- // local provider, so the content may contain actual secret material.
expect(
shouldLogMaskedContent({
maskedContent: "My key is sk-live-actual-secret and email [[EMAIL_ADDRESS_1]]",
).toBe(false);
});
});
+
+describe("formatMaskedSpansForLog", () => {
+ const spans = [
+ {
+ text: "System Jane jane.system@example.com",
+ path: "system",
+ messageIndex: -1,
+ partIndex: 0,
+ role: "system",
+ },
+ {
+ text: "User [[PERSON_1]] [[EMAIL_ADDRESS_1]]",
+ path: "messages[0].content",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "user",
+ },
+ {
+ text: "Assistant Alice alice.assistant@example.com",
+ path: "messages[1].content",
+ messageIndex: 1,
+ partIndex: 0,
+ role: "assistant",
+ },
+ {
+ text: "DATABASE_URL=[[CONNECTION_STRING_1]]",
+ path: "messages[2].content[0].content",
+ messageIndex: 2,
+ partIndex: 0,
+ role: "tool",
+ },
+ {
+ text: "Function result [[EMAIL_ADDRESS_2]]",
+ path: "messages[3].content",
+ messageIndex: 3,
+ partIndex: 0,
+ role: "function",
+ },
+ {
+ text: "MCP result [[CONNECTION_STRING_2]]",
+ path: "input[0].output",
+ messageIndex: 4,
+ partIndex: 0,
+ role: "mcp",
+ },
+ {
+ text: "<system-reminder>\nPrivate project memory [[EMAIL_ADDRESS_3]]\n</system-reminder>",
+ path: "messages[4].content[0].text",
+ messageIndex: 5,
+ partIndex: 0,
+ role: "user",
+ },
+ {
+ text: "# AGENTS.md instructions for /repo\n\n<INSTRUCTIONS>\nJane jane.agent@example.com\n</INSTRUCTIONS>",
+ path: "input[0].content[0].text",
+ messageIndex: 0,
+ partIndex: 0,
+ role: "user",
+ },
+ {
+ text: "<environment_context>\n<cwd>/repo</cwd>\n<current_date>2026-06-23</current_date>\n</environment_context>",
+ path: "input[1].content[0].text",
+ messageIndex: 1,
+ partIndex: 0,
+ role: "user",
+ },
+ ];
+
+ test("includes only configured roles by default", () => {
+ const result = formatMaskedSpansForLog(spans, ["user", "tool", "function", "mcp"]);
+
+ expect(result).toContain("[user prompt] User [[PERSON_1]] [[EMAIL_ADDRESS_1]]");
+ expect(result).toContain("[tool result] DATABASE_URL=[[CONNECTION_STRING_1]]");
+ expect(result).toContain("[function result] Function result [[EMAIL_ADDRESS_2]]");
+ expect(result).toContain("[mcp result] MCP result [[CONNECTION_STRING_2]]");
+ expect(result).not.toContain("messages[0].content");
+ expect(result).not.toContain("messages[2].content[0].content");
+ expect(result).not.toContain("jane.system@example.com");
+ expect(result).not.toContain("alice.assistant@example.com");
+ expect(result).toContain("Private project memory");
+ expect(result).toContain("jane.agent@example.com");
+ expect(result).toContain("environment_context");
+ });
+});
+
+describe("logScanRoles", () => {
+ test("intersects roles when both detectors are active", () => {
+ expect(
+ logScanRoles({
+ piiRoles: ["user", "tool"],
+ piiActive: true,
+ secretRoles: ["user"],
+ secretsActive: true,
+ }),
+ ).toEqual(["user"]);
+ });
+
+ test("uses the active detector's roles when only one is active", () => {
+ expect(
+ logScanRoles({
+ piiRoles: ["user"],
+ piiActive: false,
+ secretRoles: ["tool"],
+ secretsActive: true,
+ }),
+ ).toEqual(["tool"]);
+ });
+
+ test("returns no roles when neither detector is active", () => {
+ expect(
+ logScanRoles({
+ piiRoles: ["user"],
+ piiActive: false,
+ secretRoles: ["tool"],
+ secretsActive: false,
+ }),
+ ).toEqual([]);
+ });
+
+ test("drops spans scanned by only one detector from the preview", () => {
+ const roles = logScanRoles({
+ piiRoles: ["user"],
+ piiActive: true,
+ secretRoles: ["tool"],
+ secretsActive: true,
+ });
+ const result = formatMaskedSpansForLog(
+ [{ text: "tool secret here", path: "p", messageIndex: 0, partIndex: 0, role: "tool" }],
+ roles,
+ );
+ expect(result).toBeUndefined();
+ });
+});
+import type { TextSpan } from "../masking/types";
+
export interface LogContentDecision {
maskedContent?: string;
logMaskedContent: boolean;
secretsMasked?: boolean;
}
-/**
- * Decide whether masked content should be persisted to the request log.
- *
- * When secrets_detection.action is "mask" (the default), maskedContent has both
- * PII and secrets replaced by placeholders (e.g. "[[API_KEY_SK_1]]",
- * "[[EMAIL_ADDRESS_1]]") by the time it reaches the logger, so it is safe to
- * store even when secrets were detected — gating follows log_masked_content.
- *
- * The exception is route mode with action "route_local": secrets are detected
- * but intentionally left unmasked for the trusted local provider, so the
- * content may contain raw secret material and must never be persisted.
- */
export function shouldLogMaskedContent(decision: LogContentDecision): boolean {
const { maskedContent, logMaskedContent, secretsDetected, secretsMasked } = decision;
if (!maskedContent || !logMaskedContent) return false;
- // Detected but unmasked secrets (action: route_local) are still raw in the content
if (secretsDetected && !secretsMasked) return false;
return true;
}
+
+export function formatMaskedSpansForLog(
+ spans: TextSpan[],
+ scanRoles: readonly string[],
+): string | undefined {
+ const allowedRoles = new Set(scanRoles);
+ const lines = spans
+ .filter((span) => span.text && span.role && allowedRoles.has(span.role))
+ .map((span) => `[${labelSpan(span)}] ${span.text}`);
+
+ return lines.length > 0 ? lines.join("\n").slice(0, 20000) : undefined;
+}
+
+export function logScanRoles(opts: {
+ piiRoles: readonly string[];
+ piiActive: boolean;
+ secretRoles: readonly string[];
+ secretsActive: boolean;
+}): string[] {
+ const active: string[][] = [];
+ if (opts.piiActive) active.push([...opts.piiRoles]);
+ if (opts.secretsActive) active.push([...opts.secretRoles]);
+ if (active.length === 0) return [];
+ const [first, ...rest] = active;
+ return [...new Set(first)].filter((role) => rest.every((roles) => roles.includes(role)));
+}
+
+function labelSpan(span: TextSpan): string {
+ if (span.role === "tool") return "tool result";
+ if (span.role === "function") return "function result";
+ if (span.role === "mcp") return "mcp result";
+ if (span.role === "user") return "user prompt";
+ return span.role ?? "unknown";
+}
+++ /dev/null
-import { describe, expect, test } from "bun:test";
-import { extractTextContent, type OpenAIContentPart } from "./content";
-
-describe("extractTextContent", () => {
- test("returns empty string for null", () => {
- expect(extractTextContent(null)).toBe("");
- });
-
- test("returns empty string for undefined", () => {
- expect(extractTextContent(undefined)).toBe("");
- });
-
- test("returns string content unchanged", () => {
- expect(extractTextContent("Hello world")).toBe("Hello world");
- });
-
- test("extracts text from single text part", () => {
- const content: OpenAIContentPart[] = [{ type: "text", text: "What's in this image?" }];
- expect(extractTextContent(content)).toBe("What's in this image?");
- });
-
- test("extracts and joins multiple text parts", () => {
- const content: OpenAIContentPart[] = [
- { type: "text", text: "First part" },
- { type: "text", text: "Second part" },
- ];
- expect(extractTextContent(content)).toBe("First part\nSecond part");
- });
-
- test("skips image_url parts", () => {
- const content: OpenAIContentPart[] = [
- { type: "text", text: "Look at this" },
- { type: "image_url", image_url: { url: "https://example.com/image.jpg" } },
- { type: "text", text: "What is it?" },
- ];
- expect(extractTextContent(content)).toBe("Look at this\nWhat is it?");
- });
-
- test("returns empty string for array with no text parts", () => {
- const content: OpenAIContentPart[] = [
- { type: "image_url", image_url: { url: "https://example.com/image.jpg" } },
- ];
- expect(extractTextContent(content)).toBe("");
- });
-
- test("handles empty array", () => {
- expect(extractTextContent([])).toBe("");
- });
-});
-/**
- * Message content utilities
- */
-
import type { OpenAIContentPart, OpenAIMessageContent } from "../providers/openai/types";
export type { OpenAIContentPart, OpenAIMessageContent };
-
-/**
- * Extracts text content from a message (handles string and array content)
- */
-export function extractTextContent(content: OpenAIMessageContent | undefined): string {
- if (!content) {
- return "";
- }
-
- if (typeof content === "string") {
- return content;
- }
-
- if (Array.isArray(content)) {
- return content
- .filter((part) => part.type === "text" && typeof part.text === "string")
- .map((part) => part.text!)
- .join("\n");
- }
-
- return "";
-}