diff --git a/src/shared/sensitive-redactor.ts b/src/shared/sensitive-redactor.ts index 8d2bd40..b1ea506 100644 --- a/src/shared/sensitive-redactor.ts +++ b/src/shared/sensitive-redactor.ts @@ -449,3 +449,25 @@ export function redactMessage(msg: ParsedMessage): ParsedMessage { // toolName is typically safe (e.g. "Bash", "Read") — pass through unchanged }; } + +/** + * Counts how many messages contain at least one sensitive match. + * Checks both content and toolInput fields. + */ +export function countSensitiveMessages(messages: ParsedMessage[]): number { + let count = 0; + for (const msg of messages) { + const contentResult = redactSensitiveContent(msg.content); + if (contentResult.redactionCount > 0) { + count++; + continue; + } + if (msg.toolInput) { + const inputResult = redactSensitiveContent(msg.toolInput); + if (inputResult.redactionCount > 0) { + count++; + } + } + } + return count; +} diff --git a/tests/unit/sensitive-redactor.test.ts b/tests/unit/sensitive-redactor.test.ts index 4bc4c1d..fd20739 100644 --- a/tests/unit/sensitive-redactor.test.ts +++ b/tests/unit/sensitive-redactor.test.ts @@ -3,6 +3,7 @@ import { redactSensitiveContent, redactMessage, redactString, + countSensitiveMessages, } from "../../src/shared/sensitive-redactor.js"; import type { ParsedMessage } from "../../src/shared/types.js"; @@ -563,4 +564,78 @@ describe("sensitive-redactor", () => { expect(redacted.toolName).toBeUndefined(); }); }); + + describe("countSensitiveMessages", () => { + it("returns 0 for empty array", () => { + const result = countSensitiveMessages([]); + expect(result).toBe(0); + }); + + it("returns 0 when no messages contain sensitive content", () => { + const messages: ParsedMessage[] = [ + { + uuid: "m1", + category: "assistant_text", + content: "Hello, how can I help?", + toolName: undefined, + toolInput: undefined, + timestamp: "2025-10-15T10:00:00Z", + rawIndex: 0, + }, + ]; + expect(countSensitiveMessages(messages)).toBe(0); + }); + + it("counts messages with sensitive content", () => { + const ghToken = "ghp_" + "a".repeat(36); + const messages: ParsedMessage[] = [ + { + uuid: "m1", + category: "assistant_text", + content: "Here is your token: " + ghToken, + toolName: undefined, + toolInput: undefined, + timestamp: "2025-10-15T10:00:00Z", + rawIndex: 0, + }, + { + uuid: "m2", + category: "assistant_text", + content: "No secrets here", + toolName: undefined, + toolInput: undefined, + timestamp: "2025-10-15T10:01:00Z", + rawIndex: 1, + }, + { + uuid: "m3", + category: "tool_call", + content: "Running command", + toolName: "Bash", + toolInput: "export SECRET_KEY=abcdefghijklmnopqrstuvwxyz", + timestamp: "2025-10-15T10:02:00Z", + rawIndex: 2, + }, + ]; + // m1 has a GitHub token in content, m3 has a secret in toolInput + expect(countSensitiveMessages(messages)).toBe(2); + }); + + it("counts a message only once even if it has multiple sensitive items", () => { + const ghToken = "ghp_" + "a".repeat(36); + const messages: ParsedMessage[] = [ + { + uuid: "m1", + category: "assistant_text", + content: ghToken + " and also AKIAIOSFODNN7EXAMPLE", + toolName: undefined, + toolInput: undefined, + timestamp: "2025-10-15T10:00:00Z", + rawIndex: 0, + }, + ]; + // One message with two secrets still counts as 1 message + expect(countSensitiveMessages(messages)).toBe(1); + }); + }); });