From: Stefan Gasser Date: Tue, 23 Jun 2026 18:52:25 +0000 (+0200) Subject: Limit masking scans to input roles (#115) X-Git-Tag: v0.7.0~2 X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=43bb64f1c136f1256c4dadc4e3508d74fe96220f;p=sgasser-llm-shield.git Limit masking scans to input roles (#115) 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. --- diff --git a/config.example.yaml b/config.example.yaml index cd1698f..4798242 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -88,16 +88,6 @@ pii_detection: - 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: @@ -132,16 +122,6 @@ 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 @@ -150,9 +130,9 @@ logging: # 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 diff --git a/docs/concepts/mask-mode.mdx b/docs/concepts/mask-mode.mdx index b1b4e1f..66ce4e4 100644 --- a/docs/concepts/mask-mode.mdx +++ b/docs/concepts/mask-mode.mdx @@ -63,7 +63,7 @@ masking: | `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 diff --git a/docs/configuration/logging.mdx b/docs/configuration/logging.mdx index bcef0c2..dfae808 100644 --- a/docs/configuration/logging.mdx +++ b/docs/configuration/logging.mdx @@ -16,7 +16,7 @@ logging: |--------|---------|-------------| | `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 @@ -47,16 +47,18 @@ logging: ## 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 @@ -67,11 +69,11 @@ logging: 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]]`. diff --git a/docs/configuration/pii-detection.mdx b/docs/configuration/pii-detection.mdx index b3ee39a..0717bb7 100644 --- a/docs/configuration/pii-detection.mdx +++ b/docs/configuration/pii-detection.mdx @@ -114,7 +114,8 @@ Patterns are matched literally by default. Set `regex: true` for JavaScript rege ## 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: @@ -122,14 +123,17 @@ 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. diff --git a/docs/configuration/secrets-detection.mdx b/docs/configuration/secrets-detection.mdx index 98ed6ca..ef3debe 100644 --- a/docs/configuration/secrets-detection.mdx +++ b/docs/configuration/secrets-detection.mdx @@ -107,7 +107,8 @@ secrets_detection: ## 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: @@ -115,15 +116,20 @@ 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 diff --git a/src/config.test.ts b/src/config.test.ts index b55d857..64f16c1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -86,6 +86,68 @@ pii_detection: } }); + 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 diff --git a/src/config.ts b/src/config.ts index a999e57..8534a8c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -116,6 +116,17 @@ const PhoneRegionsSchema = z .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(), @@ -133,7 +144,7 @@ const PIIDetectionSchema = z.object({ "IP_ADDRESS", "VAT_CODE", ]), - scan_roles: z.array(z.string()).optional(), + scan_roles: scanRolesField, }); const ServerSchema = z.object({ @@ -178,7 +189,7 @@ const SecretsDetectionSchema = 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 diff --git a/src/masking/extractors/anthropic.ts b/src/masking/extractors/anthropic.ts index a472bc1..3f9d6b2 100644 --- a/src/masking/extractors/anthropic.ts +++ b/src/masking/extractors/anthropic.ts @@ -49,27 +49,6 @@ function extractBlockText(block: ContentBlock): string { 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 * diff --git a/src/masking/extractors/codex.test.ts b/src/masking/extractors/codex.test.ts new file mode 100644 index 0000000..7918ad4 --- /dev/null +++ b/src/masking/extractors/codex.test.ts @@ -0,0 +1,107 @@ +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\nSystem context\n", + }, + { + type: "input_text", + text: "\n/repo\n", + }, + { + type: "input_text", + text: "Internal 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\nSystem context\n", + }, + { + path: "input[2].content[1].text", + role: "user", + text: "\n/repo\n", + }, + { + path: "input[2].content[2].text", + role: "user", + text: "Internal 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", + }, + ]); + }); +}); diff --git a/src/masking/extractors/codex.ts b/src/masking/extractors/codex.ts index c6798f7..be9aad2 100644 --- a/src/masking/extractors/codex.ts +++ b/src/masking/extractors/codex.ts @@ -18,34 +18,56 @@ const TEXT_KEYS = new Set([ "input", "input_text", "instructions", + "output", "output_text", + "stderr", + "stdout", "text", ]); interface LocatedString { path: Array; value: string; + role: string; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function collectText(value: unknown, path: Array = []): LocatedString[] { +function roleForText(path: Array, inheritedRole?: string): string { + if (path.includes("instructions")) return "system"; + return inheritedRole ?? "user"; +} + +function inferRole(value: Record, 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 = [], + 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 []; @@ -95,7 +117,7 @@ export const codexExtractor: RequestExtractor { + 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, + }); + }); +}); diff --git a/src/masking/service.ts b/src/masking/service.ts index 3ee35a8..76df74b 100644 --- a/src/masking/service.ts +++ b/src/masking/service.ts @@ -67,6 +67,7 @@ export function maskSpans( maskedText: span.text, messageIndex: span.messageIndex, partIndex: span.partIndex, + nestedPartIndex: span.nestedPartIndex, }); continue; } @@ -85,6 +86,7 @@ export function maskSpans( maskedText, messageIndex: span.messageIndex, partIndex: span.partIndex, + nestedPartIndex: span.nestedPartIndex, }); } diff --git a/src/pii/detect.test.ts b/src/pii/detect.test.ts index 9ff5aeb..5b18425 100644 --- a/src/pii/detect.test.ts +++ b/src/pii/detect.test.ts @@ -1,6 +1,7 @@ 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, @@ -56,37 +57,56 @@ function createRequest(messages: OpenAIMessage[]): OpenAIRequest { return { model: "gpt-4", messages }; } +const spanExtractor: RequestExtractor = { + 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 }], }); @@ -98,9 +118,86 @@ describe("PIIDetector", () => { 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 () => { diff --git a/src/pii/detect.ts b/src/pii/detect.ts index f79e177..d1ebeaa 100644 --- a/src/pii/detect.ts +++ b/src/pii/detect.ts @@ -221,21 +221,19 @@ export class PIIDetector { 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) : []; diff --git a/src/routes/anthropic.ts b/src/routes/anthropic.ts index 6e0876f..6aa45d7 100644 --- a/src/routes/anthropic.ts +++ b/src/routes/anthropic.ts @@ -3,11 +3,7 @@ import type { Context } from "hono"; 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"; @@ -18,6 +14,7 @@ import { } 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 { @@ -194,21 +191,17 @@ interface LocalOptions { // --- 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 --- diff --git a/src/routes/api.ts b/src/routes/api.ts index f13bd43..7df4291 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -130,6 +130,7 @@ apiRoutes.post("/mask", async (c) => { 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); diff --git a/src/routes/codex.test.ts b/src/routes/codex.test.ts index 070973a..6d8e31f 100644 --- a/src/routes/codex.test.ts +++ b/src/routes/codex.test.ts @@ -146,6 +146,68 @@ describe("Codex proxy", () => { ); }); + 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({ diff --git a/src/routes/codex.ts b/src/routes/codex.ts index 771bfdd..4366cbf 100644 --- a/src/routes/codex.ts +++ b/src/routes/codex.ts @@ -21,6 +21,7 @@ import { 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 { @@ -171,13 +172,14 @@ function getForwardHeaders(c: Context): Record { } 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( diff --git a/src/routes/openai.ts b/src/routes/openai.ts index 2e64441..42f3c5d 100644 --- a/src/routes/openai.ts +++ b/src/routes/openai.ts @@ -10,12 +10,12 @@ import { callLocal } from "../providers/local"; 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 { @@ -23,7 +23,6 @@ import { type SecretsProcessResult, secretPlaceholders, } from "../services/secrets"; -import { extractTextContent } from "../utils/content"; import { createLogData, errorFormats, @@ -151,14 +150,17 @@ interface LocalOptions { // --- 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 --- @@ -224,7 +226,7 @@ async function sendToOpenAI(c: Context, originalRequest: OpenAIRequest, opts: Op 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, @@ -293,7 +295,7 @@ async function sendToLocal(c: Context, originalRequest: OpenAIRequest, opts: Loc } const maskedContent = - piiResult.hasPII || secretsResult.masked ? formatMessagesForLog(request.messages) : undefined; + piiResult.hasPII || secretsResult.masked ? formatRequestForLog(request) : undefined; setResponseHeaders( c, diff --git a/src/secrets/detect.test.ts b/src/secrets/detect.test.ts index bfb68a7..a1f52bf 100644 --- a/src/secrets/detect.test.ts +++ b/src/secrets/detect.test.ts @@ -1,6 +1,7 @@ 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, @@ -8,6 +9,7 @@ const defaultConfig: SecretsDetectionConfig = { 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----- @@ -180,6 +182,112 @@ describe("detectSecrets", () => { }); }); +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 = diff --git a/src/secrets/detect.ts b/src/secrets/detect.ts index 295d155..18b9c91 100644 --- a/src/secrets/detect.ts +++ b/src/secrets/detect.ts @@ -64,7 +64,7 @@ export function detectSecretsInRequest( return detectSecretsInSpans(spans, config); } -function detectSecretsInSpans( +export function detectSecretsInSpans( spans: TextSpan[], config: SecretsDetectionConfig, ): MessageSecretsResult { @@ -77,11 +77,11 @@ function detectSecretsInSpans( } // 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(); 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); diff --git a/src/services/log-content.test.ts b/src/services/log-content.test.ts index 433e79e..ef037f4 100644 --- a/src/services/log-content.test.ts +++ b/src/services/log-content.test.ts @@ -1,10 +1,7 @@ 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]]"; @@ -47,8 +44,6 @@ describe("shouldLogMaskedContent", () => { }); 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]]", @@ -68,3 +63,136 @@ describe("shouldLogMaskedContent", () => { ).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: "\nPrivate project memory [[EMAIL_ADDRESS_3]]\n", + path: "messages[4].content[0].text", + messageIndex: 5, + partIndex: 0, + role: "user", + }, + { + text: "# AGENTS.md instructions for /repo\n\n\nJane jane.agent@example.com\n", + path: "input[0].content[0].text", + messageIndex: 0, + partIndex: 0, + role: "user", + }, + { + text: "\n/repo\n2026-06-23\n", + 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(); + }); +}); diff --git a/src/services/log-content.ts b/src/services/log-content.ts index 94f9301..cbb5bc3 100644 --- a/src/services/log-content.ts +++ b/src/services/log-content.ts @@ -1,3 +1,5 @@ +import type { TextSpan } from "../masking/types"; + export interface LogContentDecision { maskedContent?: string; logMaskedContent: boolean; @@ -5,22 +7,43 @@ export interface LogContentDecision { 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"; +} diff --git a/src/utils/content.test.ts b/src/utils/content.test.ts deleted file mode 100644 index 2ce3af5..0000000 --- a/src/utils/content.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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(""); - }); -}); diff --git a/src/utils/content.ts b/src/utils/content.ts index 86a41d3..2933b78 100644 --- a/src/utils/content.ts +++ b/src/utils/content.ts @@ -1,29 +1,3 @@ -/** - * 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 ""; -}