]> git.99rst.org Git - sgasser-llm-shield.git/commitdiff
Limit masking scans to input roles (#115)
authorStefan Gasser <redacted>
Tue, 23 Jun 2026 18:52:25 +0000 (20:52 +0200)
committerGitHub <redacted>
Tue, 23 Jun 2026 18:52:25 +0000 (20:52 +0200)
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.

25 files changed:
config.example.yaml
docs/concepts/mask-mode.mdx
docs/configuration/logging.mdx
docs/configuration/pii-detection.mdx
docs/configuration/secrets-detection.mdx
src/config.test.ts
src/config.ts
src/masking/extractors/anthropic.ts
src/masking/extractors/codex.test.ts [new file with mode: 0644]
src/masking/extractors/codex.ts
src/masking/service.test.ts [new file with mode: 0644]
src/masking/service.ts
src/pii/detect.test.ts
src/pii/detect.ts
src/routes/anthropic.ts
src/routes/api.ts
src/routes/codex.test.ts
src/routes/codex.ts
src/routes/openai.ts
src/secrets/detect.test.ts
src/secrets/detect.ts
src/services/log-content.test.ts
src/services/log-content.ts
src/utils/content.test.ts [deleted file]
src/utils/content.ts

index cd1698ffde1f60f3be8e44a1c85d1de2bc11ca15..47982420b7f69c5f07a9480ce1a042605d483727 100644 (file)
@@ -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
index b1b4e1f5181b35bdd052af0f209d6cc38af6a475..66ce4e4ae09843c41226e6300c39f58e6e94a4e0 100644 (file)
@@ -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
 
index bcef0c28a8f4e3efbaf7ba4b4dcddca6cde29883..dfae8086c0bce812d4dd5c8a5494c9d7eb17d133 100644 (file)
@@ -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]]`.
index b3ee39ae75e806edcaaba6095a824447c3eb90e6..0717bb757f0ad67a67644b205336f9c1f77d6315 100644 (file)
@@ -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.
index 98ed6ca524230f22e50c58d2aed8a0db7c6677a1..ef3debec2fab52033ac24c8228c8c9ce35413874 100644 (file)
@@ -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
 
index b55d857a1edc2c4bad8218804b8dccaddaca7d5a..64f16c1aa9e02324049ea59319b484a0bfa361ba 100644 (file)
@@ -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
index a999e5748e3eb3c7a791319e7330ab67bd5b8c1e..8534a8c3b3f5309d7f427fe4bec484be850718d6 100644 (file)
@@ -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
index a472bc1bcaa205654d9fe3b99436cace09ce06e8..3f9d6b2f4da260fe421f5b40fa5568418c45d4b0 100644 (file)
@@ -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 (file)
index 0000000..7918ad4
--- /dev/null
@@ -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<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",
+      },
+    ]);
+  });
+});
index c6798f77126b15d2079b1263ee44bc2413185dbd..be9aad29145427928eecb0b78fd2ffc03b82cc80 100644 (file)
@@ -18,34 +18,56 @@ const TEXT_KEYS = new Set([
   "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 [];
@@ -95,7 +117,7 @@ export const codexExtractor: RequestExtractor<CodexResponsesRequest, CodexRespon
       path: pathToString(item.path),
       messageIndex: index,
       partIndex: 0,
-      role: item.path.includes("instructions") ? "system" : "user",
+      role: item.role,
     }));
   },
 
diff --git a/src/masking/service.test.ts b/src/masking/service.test.ts
new file mode 100644 (file)
index 0000000..e095657
--- /dev/null
@@ -0,0 +1,41 @@
+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,
+    });
+  });
+});
index 3ee35a844c49aeced6e0cea95f2b19ba56c9f162..76df74b258ec33cf10c1d7e1453b8e07ea3163b0 100644 (file)
@@ -67,6 +67,7 @@ export function maskSpans<T extends Span>(
         maskedText: span.text,
         messageIndex: span.messageIndex,
         partIndex: span.partIndex,
+        nestedPartIndex: span.nestedPartIndex,
       });
       continue;
     }
@@ -85,6 +86,7 @@ export function maskSpans<T extends Span>(
       maskedText,
       messageIndex: span.messageIndex,
       partIndex: span.partIndex,
+      nestedPartIndex: span.nestedPartIndex,
     });
   }
 
index 9ff5aebb4e13730de3c1eef3d0aceef05ec7fd27..5b18425dd2c5513c34a4d80b01bf097d577a5533 100644 (file)
@@ -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<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 }],
       });
 
@@ -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 () => {
index f79e177b1359329b6bd6fa23f2eaecec791e1a26..d1ebeaa5d93b67e84ee1615fbb3e7d78ffc1eea4 100644 (file)
@@ -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)
           : [];
index 6e0876ff46d6186154eb003e9e7d0e2156fdf957..6aa45d7759612e895afce6c2780eff1039a8cab5 100644 (file)
@@ -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 ---
index f13bd435126b8e2ac0642b8f0ed6f97415c7f588..7df4291499a90ae4da7cac21ec75c979121d42a8 100644 (file)
@@ -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);
index 070973ac992245caa4c4d7a9b238617368a4627e..6d8e31fb82fda7c39ffcd5ecd29065af4774a710 100644 (file)
@@ -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({
index 771bfddad93b4aeae9f7be611a0fddd3997e056d..4366cbf92558c82fc6dd4a74a581ccb88baf4c0d 100644 (file)
@@ -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<string, string> {
 }
 
 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(
index 2e644418b4d9c269117a04dcbbff9695f5390596..42f3c5d6be01ed06397c97a1d87b88af13076965 100644 (file)
@@ -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,
index bfb68a7730b3a8ffbb60fec75ef43e30a9a3f83c..a1f52bf58a05542648a07ae4041f3286435df284 100644 (file)
@@ -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 =
index 295d15524b8f068a80c71611767fd9a53d6f44c1..18b9c9154571137293a58e6f197f149bcfb4cb85 100644 (file)
@@ -64,7 +64,7 @@ export function detectSecretsInRequest<TRequest, TResponse>(
   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<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);
index 433e79e4660cced11141a6c6e6c387741c0ceff7..ef037f42d31fbad982159f1e672732706c9d60a6 100644 (file)
@@ -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: "<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();
+  });
+});
index 94f93013ba6ce694e0dcf2aa7b4aca9c9259eb17..cbb5bc387081e3e27ad81681e9ba3e18ba626cdb 100644 (file)
@@ -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 (file)
index 2ce3af5..0000000
+++ /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("");
-  });
-});
index 86a41d3c69034f06b0611171c900b5c7e7b88920..2933b7845876a8fceaa05e33bfc3a7d76ffcffcb 100644 (file)
@@ -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 "";
-}
git clone https://git.99rst.org/PROJECT