From 3d481c1e69346b8feace7ef4f927c372f5468089 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Wed, 15 Jul 2026 19:03:17 +0200 Subject: [PATCH 1/3] feat(redaction): add event-level redactEvent hook Add a redactEvent option to AgentCatOptions that receives the full Event object before publishing, complementing the existing string-level redactSensitiveInformation hook. Consumers can inspect event metadata (resourceName, eventType, parameters, response, etc.) to make context-aware redaction decisions, return a modified event, or return null to drop the event entirely. - New RedactEventFunction type, exported from the package root - Hook runs before string-level redaction, so it sees raw values; string redaction, sanitization, and truncation still run on its output - System-managed fields (id, sessionId, projectId, eventType, timestamp) are restored if the hook alters them - Hook errors drop the event and log, matching existing redaction semantics - Attached centrally in publishEvent, which also brings custom events published via publishCustomEvent on tracked servers under the hook --- README.md | 20 +++ src/index.ts | 26 +++ src/modules/eventQueue.ts | 27 ++- src/modules/redaction.ts | 56 +++++- src/tests/eventQueue.test.ts | 116 +++++++++++++ src/tests/redaction.test.ts | 321 ++++++++++++++++++++++++++++++++++- src/types.ts | 6 + 7 files changed, 565 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4d1ac09..f3e2070 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,26 @@ agentcat.track(mcpServer, "proj_0000000", { }); ``` +For redaction decisions that need more context than a single string — such as which tool was called or what type of event is being published — use the event-level `redactEvent` hook. It receives the full event object and returns a modified event, or `null` to drop the event entirely. It may be sync or async, and can be combined with `redactSensitiveInformation`. + +```ts +agentcat.track(mcpServer, "proj_0000000", { + redactEvent: (event) => { + // Drop events from tools that handle secrets entirely + if (event.resourceName === "get_credentials") { + return null; + } + // Strip response payloads from a specific tool + if (event.resourceName === "export_report") { + return { ...event, response: undefined }; + } + return event; + }, +}); +``` + +When both hooks are configured, `redactEvent` runs first and sees the raw, unredacted values; `redactSensitiveInformation` then runs on its output as a final string-level scrub. The system-managed fields `id`, `sessionId`, `projectId`, `eventType`, and `timestamp` cannot be changed by the hook, and if the hook throws, the event is dropped. The hook also applies to `publishCustomEvent` when called with a tracked server. + ### Existing Platform Support AgentCat seamlessly integrates with your existing observability stack, providing automatic logging and tracing without the tedious setup typically required. Export telemetry data to multiple platforms simultaneously: diff --git a/src/index.ts b/src/index.ts index 6e64dbc..e3d2039 100644 --- a/src/index.ts +++ b/src/index.ts @@ -49,6 +49,7 @@ import { initDiagnostics } from "./modules/diagnostics.js"; * @param options.customContextDescription - Custom description for the injected context parameter. Only applies when enableToolCallContext is true. Use this to provide domain-specific guidance to LLMs about what context they should provide. * @param options.identify - Async function to identify users and attach custom data to their sessions. * @param options.redactSensitiveInformation - Function to redact sensitive data before sending to AgentCat. + * @param options.redactEvent - Event-level redaction hook invoked with the full event (inspect `resourceName`, `eventType`, `parameters`, `response`, etc.) before it is published. Return a modified event, or null to drop the event entirely. May be sync or async. Runs before `redactSensitiveInformation`, so it sees raw, unredacted values; the string-level hook, sanitization, and truncation still run on its output. The system-managed fields `id`, `sessionId`, `projectId`, `eventType`, and `timestamp` cannot be changed (`id` is assigned after redaction and is empty at hook time). If the hook throws, the event is dropped and the error is logged to `~/agentcat.log`. * @param options.eventTags - Callback invoked on every auto-captured event (tool calls, tool lists, initialize) to attach string key-value tags. Tags are intended to be indexed and queryable in the AgentCat dashboard — use them for structured metadata you'll want to filter or group by (e.g., trace IDs, environments, regions). Tags are validated client-side: keys must be ≤32 chars matching `[a-zA-Z0-9$_.:\- ]`, values must be strings ≤200 chars with no newlines, max 50 entries per event. Invalid entries are silently dropped with a warning logged to `~/agentcat.log`. If the callback throws or returns null, tags are omitted. Receives the same `(request, extra)` arguments as `identify`. * @param options.eventProperties - Callback invoked on every auto-captured event to attach flexible JSON metadata (device info, feature flags, nested context). No constraints beyond standard JSON types. If the callback throws or returns null, properties are omitted. Receives the same `(request, extra)` arguments as `identify`. * @param options.apiBaseUrl - Custom API base URL for sending events. Falls back to the `AGENTCAT_API_URL` environment variable if not set (then legacy `MCPCAT_API_URL`), then to the default `https://api.agentcat.com`. @@ -116,6 +117,24 @@ import { initDiagnostics } from "./modules/diagnostics.js"; * * @example * ```typescript + * // With event-level redaction + * agentcat.track(mcpServer, "proj_abc123xyz", { + * redactEvent: (event) => { + * // Drop events from tools that handle secrets entirely + * if (event.resourceName === "get_credentials") { + * return null; + * } + * // Strip response payloads from a specific tool + * if (event.resourceName === "export_report") { + * return { ...event, response: undefined }; + * } + * return event; + * } + * }); + * ``` + * + * @example + * ```typescript * // With event tags and properties * agentcat.track(mcpServer, "proj_abc123xyz", { * eventTags: async (request, extra) => ({ @@ -231,6 +250,7 @@ function track( customContextDescription: options.customContextDescription, identify: options.identify, redactSensitiveInformation: options.redactSensitiveInformation, + redactEvent: options.redactEvent, eventTags: options.eventTags, eventProperties: options.eventProperties, }, @@ -286,6 +306,11 @@ function track( * * @returns Promise that resolves when the event is queued for publishing * + * @remarks + * When a tracked server is passed, the `redactEvent` hook configured via `track()` + * is applied to the custom event before it is published. Events published with a + * bare session ID string bypass redaction, since no tracked configuration exists. + * * @example * ```typescript * // With a tracked server @@ -415,6 +440,7 @@ export type { AgentCatData, UserIdentity, RedactFunction, + RedactEventFunction, ExporterConfig, Exporter, CustomEventData, diff --git a/src/modules/eventQueue.ts b/src/modules/eventQueue.ts index 4f8a3c4..be5f0cb 100644 --- a/src/modules/eventQueue.ts +++ b/src/modules/eventQueue.ts @@ -8,7 +8,7 @@ import { Event, UnredactedEvent, MCPServerLike } from "../types.js"; import { writeToLog } from "./logging.js"; import { getServerTrackingData } from "./internal.js"; import { getSessionInfo } from "./session.js"; -import { redactEvent } from "./redaction.js"; +import { applyEventRedaction, redactEvent } from "./redaction.js"; import { sanitizeEvent } from "./sanitization.js"; import { truncateEvent } from "./truncation.js"; import KSUID from "../thirdparty/ksuid/index.js"; @@ -60,6 +60,28 @@ class EventQueue { const event = this.queue.shift(); if (!event) continue; + if (event.eventRedactionFn) { + const eventRedactionFn = event.eventRedactionFn; + event.eventRedactionFn = undefined; + try { + const result = await applyEventRedaction(event, eventRedactionFn); + if (!result) { + writeToLog("Event dropped by redactEvent hook"); + continue; + } + // Rebuild in place (the pipeline mutates the same object) without + // resurrecting fields the hook deleted + const redactionFn = event.redactionFn; + for (const key of Object.keys(event)) { + delete event[key as keyof UnredactedEvent]; + } + Object.assign(event, result, { redactionFn }); + } catch (error) { + writeToLog(`Failed to redact event (event-level hook): ${error}`); + continue; + } + } + if (event.redactionFn) { try { const redactedEvent = await redactEvent(event, event.redactionFn); @@ -292,8 +314,9 @@ export function publishEvent( isError: eventInput.isError, error: eventInput.error, - // Preserve redaction function + // Preserve redaction functions redactionFn: eventInput.redactionFn, + eventRedactionFn: eventInput.eventRedactionFn ?? data.options.redactEvent, // Customer-defined metadata tags: eventInput.tags, diff --git a/src/modules/redaction.ts b/src/modules/redaction.ts index 4beadfc..12df008 100644 --- a/src/modules/redaction.ts +++ b/src/modules/redaction.ts @@ -1,4 +1,9 @@ -import { Event, RedactFunction, UnredactedEvent } from "../types.js"; +import { + Event, + RedactEventFunction, + RedactFunction, + UnredactedEvent, +} from "../types.js"; /** * Set of field names that should be protected from redaction. @@ -109,3 +114,52 @@ export async function redactEvent( ): Promise { return redactStringsInObject(event, redactFn, "", false) as Promise; } + +/** + * Set of system-managed fields that are restored after the event-level + * redaction hook runs. These are required for ingestion and session/project + * attribution, so consumer changes to them are ignored. + */ +const RESTORED_FIELDS = [ + "id", + "sessionId", + "projectId", + "eventType", + "timestamp", +] as const; + +/** + * Applies the customer's event-level redaction hook to an event. + * The hook receives the full event (without internal function fields) and may + * return a modified event, or null/undefined to drop the event entirely. + * + * @param event - The event to run the hook on + * @param eventRedactFn - The customer's event-level redaction hook + * @returns The redacted event, or null if the event should be dropped + */ +export async function applyEventRedaction( + event: UnredactedEvent, + eventRedactFn: RedactEventFunction, +): Promise { + const { redactionFn: _r, eventRedactionFn: _e, ...hookInput } = event; + const result = await eventRedactFn(hookInput as Event); + + if (result === null || result === undefined) { + return null; + } + + const redactedEvent: UnredactedEvent = { ...result }; + delete redactedEvent.redactionFn; + delete redactedEvent.eventRedactionFn; + + // System-managed fields are not consumer-settable + for (const field of RESTORED_FIELDS) { + if (event[field] === undefined) { + delete redactedEvent[field]; + } else { + (redactedEvent as any)[field] = event[field]; + } + } + + return redactedEvent; +} diff --git a/src/tests/eventQueue.test.ts b/src/tests/eventQueue.test.ts index 8a89146..7e136bc 100644 --- a/src/tests/eventQueue.test.ts +++ b/src/tests/eventQueue.test.ts @@ -304,6 +304,122 @@ describe("EventQueue", () => { }); }); + describe("event-level redaction hook (redactEvent)", () => { + beforeEach(() => { + // Restore the prototype add() (a prior test calls destroy(), which + // permanently replaces it on the singleton) and point the queue at the + // mocked API client so sends resolve deterministically. + delete (eventQueue as any).add; + (eventQueue as any).apiClient = mockApiClient; + }); + + it("should apply the redactEvent option from tracking data to published events", async () => { + const hook = vi.fn((event: any) => ({ + ...event, + parameters: { text: "[EVENT-REDACTED]" }, + })); + (getServerTrackingData as any).mockReturnValue({ + projectId: "test-project", + sessionId: "test-session", + options: { enableTracing: true, redactEvent: hook }, + }); + + publishEvent({} as MCPServerLike, { + sessionId: "test-session", + eventType: "mcp:tools/call", + resourceName: "do_thing", + parameters: { text: "raw sensitive value" }, + timestamp: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(hook).toHaveBeenCalledTimes(1); + expect(hook.mock.calls[0][0].resourceName).toBe("do_thing"); + expect(hook.mock.calls[0][0].parameters).toEqual({ + text: "raw sensitive value", + }); + expect(mockPublishEvent).toHaveBeenCalledTimes(1); + const sent = mockPublishEvent.mock.calls[0][0].publishEventRequest; + expect(sent.parameters).toEqual({ text: "[EVENT-REDACTED]" }); + }); + + it("should drop the event when the hook returns null", async () => { + (getServerTrackingData as any).mockReturnValue({ + projectId: "test-project", + sessionId: "test-session", + options: { enableTracing: true, redactEvent: () => null }, + }); + + publishEvent({} as MCPServerLike, { + sessionId: "test-session", + eventType: "mcp:tools/call", + timestamp: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockPublishEvent).not.toHaveBeenCalled(); + expect(writeToLog).toHaveBeenCalledWith( + "Event dropped by redactEvent hook", + ); + }); + + it("should drop the event when the hook throws", async () => { + (getServerTrackingData as any).mockReturnValue({ + projectId: "test-project", + sessionId: "test-session", + options: { + enableTracing: true, + redactEvent: () => { + throw new Error("hook exploded"); + }, + }, + }); + + publishEvent({} as MCPServerLike, { + sessionId: "test-session", + eventType: "mcp:tools/call", + timestamp: new Date(), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(mockPublishEvent).not.toHaveBeenCalled(); + expect(writeToLog).toHaveBeenCalledWith( + expect.stringContaining("Failed to redact event (event-level hook)"), + ); + }); + + it("should run the event hook before string redaction and compose both", async () => { + const seenByEventHook: string[] = []; + (getServerTrackingData as any).mockReturnValue({ + projectId: "test-project", + sessionId: "test-session", + options: { + enableTracing: true, + redactEvent: (event: any) => { + seenByEventHook.push(event.userIntent); + return { ...event, userIntent: `${event.userIntent} (reviewed)` }; + }, + }, + }); + + publishEvent({} as MCPServerLike, { + sessionId: "test-session", + eventType: "mcp:tools/call", + userIntent: "raw secret", + timestamp: new Date(), + redactionFn: async (text: string) => + text.replace("secret", "[REDACTED]"), + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Event hook saw the raw value; string redaction ran on its output + expect(seenByEventHook).toEqual(["raw secret"]); + expect(mockPublishEvent).toHaveBeenCalledTimes(1); + const sent = mockPublishEvent.mock.calls[0][0].publishEventRequest; + expect(sent.userIntent).toBe("raw [REDACTED] (reviewed)"); + }); + }); + describe("Process lifecycle handling", () => { it("should handle SIGINT signal", () => { // Test that signal handlers are registered diff --git a/src/tests/redaction.test.ts b/src/tests/redaction.test.ts index 836502d..af6de2c 100644 --- a/src/tests/redaction.test.ts +++ b/src/tests/redaction.test.ts @@ -1,12 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { redactEvent } from "../modules/redaction.js"; -import { UnredactedEvent, RedactFunction } from "../types.js"; +import { redactEvent, applyEventRedaction } from "../modules/redaction.js"; +import { + Event, + UnredactedEvent, + RedactFunction, + RedactEventFunction, +} from "../types.js"; import { setupTestServerAndClient, resetTodos, } from "./test-utils/client-server-factory.js"; -import { track } from "../index.js"; -import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types"; +import { track, publishCustomEvent } from "../index.js"; +import { + CallToolResultSchema, + ListToolsResultSchema, +} from "@modelcontextprotocol/sdk/types"; import { EventCapture } from "./test-utils.js"; import { PublishEventRequestEventTypeEnum } from "agentcat-api"; @@ -386,6 +394,135 @@ describe("redactEvent", () => { }); }); +describe("applyEventRedaction", () => { + const baseEvent = (): UnredactedEvent => ({ + id: "evt_original", + sessionId: "ses_123", + projectId: "proj_789", + eventType: "mcp:tools/call", + timestamp: new Date("2024-01-01"), + resourceName: "add_todo", + parameters: { text: "sensitive text" }, + response: { content: "sensitive response" }, + userIntent: "sensitive intent", + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should apply a synchronous hook", async () => { + const hook: RedactEventFunction = (event) => ({ + ...event, + parameters: { text: "[SCRUBBED]" }, + }); + + const result = await applyEventRedaction(baseEvent(), hook); + + expect(result).not.toBeNull(); + expect(result?.parameters).toEqual({ text: "[SCRUBBED]" }); + expect(result?.response).toEqual({ content: "sensitive response" }); + }); + + it("should apply an asynchronous hook", async () => { + const hook: RedactEventFunction = async (event) => ({ + ...event, + userIntent: "[SCRUBBED]", + }); + + const result = await applyEventRedaction(baseEvent(), hook); + + expect(result?.userIntent).toBe("[SCRUBBED]"); + }); + + it("should return null when the hook returns null", async () => { + const hook: RedactEventFunction = () => null; + + const result = await applyEventRedaction(baseEvent(), hook); + + expect(result).toBeNull(); + }); + + it("should return null when the hook returns undefined", async () => { + const hook = (() => undefined) as unknown as RedactEventFunction; + + const result = await applyEventRedaction(baseEvent(), hook); + + expect(result).toBeNull(); + }); + + it("should honor field deletions instead of resurrecting them", async () => { + const hook: RedactEventFunction = (event) => { + const copy = { ...event }; + delete copy.response; + delete copy.userIntent; + return copy; + }; + + const result = await applyEventRedaction(baseEvent(), hook); + + expect(result).not.toBeNull(); + expect(result).not.toHaveProperty("response"); + expect(result).not.toHaveProperty("userIntent"); + expect(result?.parameters).toEqual({ text: "sensitive text" }); + }); + + it("should restore system-managed fields if the hook changes or removes them", async () => { + const hook: RedactEventFunction = (event) => { + const copy = { ...event }; + copy.id = "evt_forged"; + copy.sessionId = "ses_forged"; + copy.projectId = "proj_forged"; + copy.eventType = "forged:event"; + delete copy.timestamp; + return copy; + }; + + const result = await applyEventRedaction(baseEvent(), hook); + + expect(result?.id).toBe("evt_original"); + expect(result?.sessionId).toBe("ses_123"); + expect(result?.projectId).toBe("proj_789"); + expect(result?.eventType).toBe("mcp:tools/call"); + expect(result?.timestamp).toEqual(new Date("2024-01-01")); + }); + + it("should never expose internal function fields to the hook nor keep them on the result", async () => { + let sawEvent: any; + const hook: RedactEventFunction = (event) => { + sawEvent = event; + return { + ...event, + redactionFn: async (text: string) => text, + eventRedactionFn: () => null, + } as unknown as Event; + }; + + const input: UnredactedEvent = { + ...baseEvent(), + redactionFn: async (text: string) => text, + eventRedactionFn: hook, + }; + + const result = await applyEventRedaction(input, hook); + + expect(sawEvent).not.toHaveProperty("redactionFn"); + expect(sawEvent).not.toHaveProperty("eventRedactionFn"); + expect(result).not.toHaveProperty("redactionFn"); + expect(result).not.toHaveProperty("eventRedactionFn"); + }); + + it("should propagate hook errors to the caller", async () => { + const hook: RedactEventFunction = () => { + throw new Error("Hook failure"); + }; + + await expect(applyEventRedaction(baseEvent(), hook)).rejects.toThrow( + "Hook failure", + ); + }); +}); + describe("redactEvent integration tests", () => { let server: any; let client: any; @@ -645,3 +782,179 @@ describe("redactEvent integration tests", () => { await eventCapture.stop(); }); }); + +describe("redactEvent hook integration tests", () => { + let server: any; + let client: any; + let cleanup: () => Promise; + + beforeEach(async () => { + resetTodos(); + const setup = await setupTestServerAndClient(); + server = setup.server; + client = setup.client; + cleanup = setup.cleanup; + }); + + afterEach(async () => { + await cleanup(); + }); + + const callAddTodo = async (text: string) => { + await client.request( + { + method: "tools/call", + params: { + name: "add_todo", + arguments: { text, context: "Adding a todo item" }, + }, + }, + CallToolResultSchema, + ); + }; + + it("should let the hook inspect event metadata and replace event fields", async () => { + const eventCapture = new EventCapture(); + await eventCapture.start(); + + const seenResourceNames: (string | undefined)[] = []; + track(server, "test-project", { + enableTracing: true, + redactEvent: (event) => { + seenResourceNames.push(event.resourceName); + if (event.resourceName === "add_todo") { + return { ...event, parameters: { replaced: true } }; + } + return event; + }, + }); + + await callAddTodo("Buy milk"); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const events = eventCapture.getEvents(); + const toolCallEvent = events.find( + (e) => e.eventType === PublishEventRequestEventTypeEnum.mcpToolsCall, + ); + + expect(seenResourceNames).toContain("add_todo"); + expect(toolCallEvent).toBeDefined(); + expect(toolCallEvent?.parameters).toEqual({ replaced: true }); + + // System-managed fields are intact + expect(toolCallEvent?.sessionId).toMatch(/^ses_/); + expect(toolCallEvent?.projectId).toBe("test-project"); + expect(toolCallEvent?.eventType).toBe( + PublishEventRequestEventTypeEnum.mcpToolsCall, + ); + + await eventCapture.stop(); + }); + + it("should drop events when the hook returns null, while other events still publish", async () => { + // Capture at the sendEvent boundary — EventCapture hooks add(), which + // runs before the queue applies the event-level hook, so drops are only + // observable at send time. + const eventQueueModule = await import("../modules/eventQueue.js"); + const eq = eventQueueModule.eventQueue as any; + const originalSendEvent = eq.sendEvent; + const sentEvents: Event[] = []; + eq.sendEvent = async (event: Event) => { + sentEvents.push(event); + }; + + try { + track(server, "test-project", { + enableTracing: true, + redactEvent: (event) => { + if ( + event.eventType === PublishEventRequestEventTypeEnum.mcpToolsCall + ) { + return null; + } + return event; + }, + }); + + await callAddTodo("Buy milk"); + await client.request({ method: "tools/list" }, ListToolsResultSchema); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const toolCallEvents = sentEvents.filter( + (e) => e.eventType === PublishEventRequestEventTypeEnum.mcpToolsCall, + ); + + expect(toolCallEvents).toHaveLength(0); + expect(sentEvents.length).toBeGreaterThan(0); // other event types still arrive + } finally { + eq.sendEvent = originalSendEvent; + } + }); + + it("should run the event hook on raw values before string redaction", async () => { + const eventCapture = new EventCapture(); + await eventCapture.start(); + + const rawTexts: string[] = []; + track(server, "test-project", { + enableTracing: true, + redactEvent: (event) => { + const text = (event.parameters as any)?.request?.params?.arguments + ?.text; + if (typeof text === "string") { + rawTexts.push(text); + } + return event; + }, + redactSensitiveInformation: async (text) => + text.includes("secret") ? "[REDACTED-SECRET]" : text, + }); + + await callAddTodo("this contains a secret value"); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Event hook saw the raw, unredacted text + expect(rawTexts).toContain("this contains a secret value"); + + // String redaction still ran afterwards on the published event + const events = eventCapture.getEvents(); + const toolCallEvent = events.find( + (e) => e.eventType === PublishEventRequestEventTypeEnum.mcpToolsCall, + ); + const params = toolCallEvent?.parameters as any; + expect(params.request.params.arguments.text).toBe("[REDACTED-SECRET]"); + + await eventCapture.stop(); + }); + + it("should apply the hook to custom events published on a tracked server", async () => { + const eventCapture = new EventCapture(); + await eventCapture.start(); + + track(server, "test-project", { + enableTracing: true, + redactEvent: (event) => { + if (event.eventType === "agentcat:custom") { + return { ...event, response: { scrubbed: true } }; + } + return event; + }, + }); + + await publishCustomEvent(server, "test-project", { + resourceName: "custom_action", + response: { secret: "raw value" }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const events = eventCapture.getEvents(); + const customEvent = events.find( + (e) => (e.eventType as string) === "agentcat:custom", + ); + + expect(customEvent).toBeDefined(); + expect(customEvent?.response).toEqual({ scrubbed: true }); + + await eventCapture.stop(); + }); +}); diff --git a/src/types.ts b/src/types.ts index 9fd29b6..c91eab2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ export interface AgentCatOptions { extra?: CompatibleRequestHandlerExtra, ) => Promise; redactSensitiveInformation?: RedactFunction; + redactEvent?: RedactEventFunction; exporters?: Record; apiBaseUrl?: string; disableDiagnostics?: boolean; @@ -44,6 +45,10 @@ export type RegisteredTool = { export type RedactFunction = (text: string) => Promise; +export type RedactEventFunction = ( + event: Event, +) => Event | null | Promise; + export interface ExporterConfig { type: string; [key: string]: any; @@ -105,6 +110,7 @@ export interface Event { export interface UnredactedEvent extends Partial { redactionFn?: RedactFunction; // Optional redaction function for sensitive data + eventRedactionFn?: RedactEventFunction; // Optional whole-event redaction hook } // Use our own minimal interface for what we actually need From cc695caf9e934759d3abcc81d4bcd63ff3e4ff90 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Wed, 15 Jul 2026 23:31:53 +0200 Subject: [PATCH 2/3] refactor(redaction): move in-place event rewrite into applyEventRedaction The queue's process() previously handled the mechanics of applying the redactEvent hook result: clearing the event object, reassigning fields, and re-attaching the string-level redaction function. Consolidate all of that into applyEventRedaction, which already owns the rest of the hook contract (carrier-field stripping, system-managed field restoration). The helper now rewrites the event in place and returns whether the event was kept, so the queue reduces to a single call plus its existing drop/log handling. No behavior change. --- src/modules/eventQueue.ts | 10 +---- src/modules/redaction.ts | 27 ++++++++---- src/tests/redaction.test.ts | 87 +++++++++++++++++++++++-------------- 3 files changed, 74 insertions(+), 50 deletions(-) diff --git a/src/modules/eventQueue.ts b/src/modules/eventQueue.ts index be5f0cb..2eac09c 100644 --- a/src/modules/eventQueue.ts +++ b/src/modules/eventQueue.ts @@ -64,18 +64,10 @@ class EventQueue { const eventRedactionFn = event.eventRedactionFn; event.eventRedactionFn = undefined; try { - const result = await applyEventRedaction(event, eventRedactionFn); - if (!result) { + if (!(await applyEventRedaction(event, eventRedactionFn))) { writeToLog("Event dropped by redactEvent hook"); continue; } - // Rebuild in place (the pipeline mutates the same object) without - // resurrecting fields the hook deleted - const redactionFn = event.redactionFn; - for (const key of Object.keys(event)) { - delete event[key as keyof UnredactedEvent]; - } - Object.assign(event, result, { redactionFn }); } catch (error) { writeToLog(`Failed to redact event (event-level hook): ${error}`); continue; diff --git a/src/modules/redaction.ts b/src/modules/redaction.ts index 12df008..9bc7776 100644 --- a/src/modules/redaction.ts +++ b/src/modules/redaction.ts @@ -129,27 +129,32 @@ const RESTORED_FIELDS = [ ] as const; /** - * Applies the customer's event-level redaction hook to an event. + * Applies the customer's event-level redaction hook to an event, in place. * The hook receives the full event (without internal function fields) and may * return a modified event, or null/undefined to drop the event entirely. * - * @param event - The event to run the hook on + * The event object is rewritten rather than replaced: the queue pipeline and + * its observers hold references to the same object across processing steps, + * and clearing before assigning ensures fields the hook deleted stay deleted. + * System-managed fields are restored from the original, and the string-level + * `redactionFn` is preserved so it still runs afterwards. + * + * @param event - The event to run the hook on; mutated with the hook's result * @param eventRedactFn - The customer's event-level redaction hook - * @returns The redacted event, or null if the event should be dropped + * @returns True if the event was kept, false if the hook dropped it */ export async function applyEventRedaction( event: UnredactedEvent, eventRedactFn: RedactEventFunction, -): Promise { - const { redactionFn: _r, eventRedactionFn: _e, ...hookInput } = event; +): Promise { + const { redactionFn, eventRedactionFn: _e, ...hookInput } = event; const result = await eventRedactFn(hookInput as Event); if (result === null || result === undefined) { - return null; + return false; } - const redactedEvent: UnredactedEvent = { ...result }; - delete redactedEvent.redactionFn; + const redactedEvent: UnredactedEvent = { ...result, redactionFn }; delete redactedEvent.eventRedactionFn; // System-managed fields are not consumer-settable @@ -161,5 +166,9 @@ export async function applyEventRedaction( } } - return redactedEvent; + for (const key of Object.keys(event)) { + delete event[key as keyof UnredactedEvent]; + } + Object.assign(event, redactedEvent); + return true; } diff --git a/src/tests/redaction.test.ts b/src/tests/redaction.test.ts index af6de2c..6a0f88f 100644 --- a/src/tests/redaction.test.ts +++ b/src/tests/redaction.test.ts @@ -411,44 +411,49 @@ describe("applyEventRedaction", () => { vi.clearAllMocks(); }); - it("should apply a synchronous hook", async () => { + it("should apply a synchronous hook in place", async () => { const hook: RedactEventFunction = (event) => ({ ...event, parameters: { text: "[SCRUBBED]" }, }); - const result = await applyEventRedaction(baseEvent(), hook); + const event = baseEvent(); + const kept = await applyEventRedaction(event, hook); - expect(result).not.toBeNull(); - expect(result?.parameters).toEqual({ text: "[SCRUBBED]" }); - expect(result?.response).toEqual({ content: "sensitive response" }); + expect(kept).toBe(true); + expect(event.parameters).toEqual({ text: "[SCRUBBED]" }); + expect(event.response).toEqual({ content: "sensitive response" }); }); - it("should apply an asynchronous hook", async () => { + it("should apply an asynchronous hook in place", async () => { const hook: RedactEventFunction = async (event) => ({ ...event, userIntent: "[SCRUBBED]", }); - const result = await applyEventRedaction(baseEvent(), hook); + const event = baseEvent(); + const kept = await applyEventRedaction(event, hook); - expect(result?.userIntent).toBe("[SCRUBBED]"); + expect(kept).toBe(true); + expect(event.userIntent).toBe("[SCRUBBED]"); }); - it("should return null when the hook returns null", async () => { + it("should report a drop and leave the event untouched when the hook returns null", async () => { const hook: RedactEventFunction = () => null; - const result = await applyEventRedaction(baseEvent(), hook); + const event = baseEvent(); + const kept = await applyEventRedaction(event, hook); - expect(result).toBeNull(); + expect(kept).toBe(false); + expect(event).toEqual(baseEvent()); }); - it("should return null when the hook returns undefined", async () => { + it("should report a drop when the hook returns undefined", async () => { const hook = (() => undefined) as unknown as RedactEventFunction; - const result = await applyEventRedaction(baseEvent(), hook); + const kept = await applyEventRedaction(baseEvent(), hook); - expect(result).toBeNull(); + expect(kept).toBe(false); }); it("should honor field deletions instead of resurrecting them", async () => { @@ -459,12 +464,13 @@ describe("applyEventRedaction", () => { return copy; }; - const result = await applyEventRedaction(baseEvent(), hook); + const event = baseEvent(); + const kept = await applyEventRedaction(event, hook); - expect(result).not.toBeNull(); - expect(result).not.toHaveProperty("response"); - expect(result).not.toHaveProperty("userIntent"); - expect(result?.parameters).toEqual({ text: "sensitive text" }); + expect(kept).toBe(true); + expect(event).not.toHaveProperty("response"); + expect(event).not.toHaveProperty("userIntent"); + expect(event.parameters).toEqual({ text: "sensitive text" }); }); it("should restore system-managed fields if the hook changes or removes them", async () => { @@ -478,38 +484,55 @@ describe("applyEventRedaction", () => { return copy; }; - const result = await applyEventRedaction(baseEvent(), hook); + const event = baseEvent(); + await applyEventRedaction(event, hook); - expect(result?.id).toBe("evt_original"); - expect(result?.sessionId).toBe("ses_123"); - expect(result?.projectId).toBe("proj_789"); - expect(result?.eventType).toBe("mcp:tools/call"); - expect(result?.timestamp).toEqual(new Date("2024-01-01")); + expect(event.id).toBe("evt_original"); + expect(event.sessionId).toBe("ses_123"); + expect(event.projectId).toBe("proj_789"); + expect(event.eventType).toBe("mcp:tools/call"); + expect(event.timestamp).toEqual(new Date("2024-01-01")); }); - it("should never expose internal function fields to the hook nor keep them on the result", async () => { + it("should hide internal function fields from the hook but preserve the string-redaction fn", async () => { let sawEvent: any; const hook: RedactEventFunction = (event) => { sawEvent = event; return { ...event, - redactionFn: async (text: string) => text, eventRedactionFn: () => null, } as unknown as Event; }; - const input: UnredactedEvent = { + const stringRedactFn = async (text: string) => text; + const event: UnredactedEvent = { ...baseEvent(), - redactionFn: async (text: string) => text, + redactionFn: stringRedactFn, eventRedactionFn: hook, }; - const result = await applyEventRedaction(input, hook); + const kept = await applyEventRedaction(event, hook); + expect(kept).toBe(true); expect(sawEvent).not.toHaveProperty("redactionFn"); expect(sawEvent).not.toHaveProperty("eventRedactionFn"); - expect(result).not.toHaveProperty("redactionFn"); - expect(result).not.toHaveProperty("eventRedactionFn"); + // The string-level hook must survive so it still runs afterwards + expect(event.redactionFn).toBe(stringRedactFn); + // The event-level hook must not, even if the hook smuggles one back + expect(event.eventRedactionFn).toBeUndefined(); + }); + + it("should keep the same object identity so pipeline observers see the changes", async () => { + const hook: RedactEventFunction = (event) => ({ + ...event, + parameters: { text: "[SCRUBBED]" }, + }); + + const event = baseEvent(); + const observer = event; // e.g. EventCapture holds the reference from add() + await applyEventRedaction(event, hook); + + expect(observer.parameters).toEqual({ text: "[SCRUBBED]" }); }); it("should propagate hook errors to the caller", async () => { From 50a1bac31184d72b564cc37ade506f3316c6d424 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Thu, 16 Jul 2026 17:45:12 +0200 Subject: [PATCH 3/3] chore(release): bump version to 1.0.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 935e592..2dffa9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentcat", - "version": "1.0.0", + "version": "1.0.1", "description": "Analytics tool for MCP (Model Context Protocol) servers and AI agents - tracks tool usage patterns and provides insights", "type": "module", "main": "dist/index.js",