diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index fe5c71be7d1..1da581bff78 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -3321,7 +3321,7 @@ rl.on("line", (line) => { expect(audit.body).toHaveProperty("nextCursor"); }); - it("filters, paginates, and enriches tool gateway audit events server-side", async () => { + it("aggregates connection activity with server-side filters, pagination, and enrichment", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const otherAgent = await createAgent(db, company.id); @@ -3340,6 +3340,25 @@ rl.on("line", (line) => { status: "active", enabled: true, }).returning(); + const [profile] = await db.insert(toolProfiles).values({ + companyId: company.id, + profileKey: `audit-${randomUUID()}`, + name: `Audit ${randomUUID()}`, + }).returning(); + const [gateway, otherGateway] = await db.insert(toolMcpGateways).values([ + { + companyId: company.id, + name: `Audit gateway ${randomUUID()}`, + slug: `audit-${randomUUID()}`, + profileId: profile!.id, + }, + { + companyId: company.id, + name: `Other gateway ${randomUUID()}`, + slug: `other-${randomUUID()}`, + profileId: profile!.id, + }, + ]).returning(); const [newerInvocation, olderInvocation, otherInvocation] = await db.insert(toolInvocations).values([ { companyId: company.id, @@ -3347,9 +3366,16 @@ rl.on("line", (line) => { actorId: agent.id, agentId: agent.id, runId: run.id, + gatewayId: gateway!.id, applicationId: application!.id, connectionId: connection!.id, toolName: "mail:send_email", + argumentsSummary: { summary: JSON.stringify({ to: "person@example.test", token: "***REDACTED***" }) }, + resultSummary: { summary: JSON.stringify({ delivered: true }) }, + policyDecision: "allow", + status: "succeeded", + startedAt: new Date(Date.now() - 1_500), + completedAt: new Date(Date.now() - 1_000), }, { companyId: company.id, @@ -3357,6 +3383,7 @@ rl.on("line", (line) => { actorId: agent.id, agentId: agent.id, runId: run.id, + gatewayId: gateway!.id, applicationId: application!.id, connectionId: connection!.id, toolName: "mail:read_email", @@ -3367,48 +3394,67 @@ rl.on("line", (line) => { actorId: otherAgent.id, agentId: otherAgent.id, runId: run.id, + gatewayId: otherGateway!.id, + applicationId: application!.id, + connectionId: connection!.id, toolName: "other:delete_everything", }, ]).returning(); const now = Date.now(); - await db.insert(activityLog).values([ + const callEvents = await db.insert(toolCallEvents).values([ { companyId: company.id, + eventType: "call_completed", actorType: "agent", actorId: agent.id, - action: "tool_gateway.call_completed", - entityType: "issue", - entityId: run.id, agentId: agent.id, runId: run.id, - details: { invocationId: newerInvocation!.id, decision: "allow", reasonCode: "tool_completed", tool: "mail:send_email", upstreamToolName: "fixture.todo.list" }, + gatewayId: gateway!.id, + applicationId: application!.id, + connectionId: connection!.id, + invocationId: newerInvocation!.id, + toolName: "mail:send_email", + decision: "allow", + reasonCode: "tool_completed", + outcome: "success", + metadata: { upstreamToolName: "fixture.todo.list" }, createdAt: new Date(now - 1_000), }, { companyId: company.id, + eventType: "call_completed", actorType: "agent", actorId: agent.id, - action: "tool_gateway.call_allowed", - entityType: "issue", - entityId: run.id, agentId: agent.id, runId: run.id, - details: { invocationId: olderInvocation!.id, decision: "allow", reasonCode: "profile_allows_tool", tool: "mail:read_email" }, + gatewayId: gateway!.id, + applicationId: application!.id, + connectionId: connection!.id, + invocationId: olderInvocation!.id, + toolName: "mail:read_email", + decision: "allow", + reasonCode: "profile_allows_tool", + outcome: "success", createdAt: new Date(now - 2_000), }, { companyId: company.id, + eventType: "call_denied", actorType: "agent", actorId: otherAgent.id, - action: "tool_gateway.call_denied", - entityType: "issue", - entityId: run.id, agentId: otherAgent.id, runId: run.id, - details: { invocationId: otherInvocation!.id, decision: "deny", reasonCode: "deny_policy_block", tool: "other:delete_everything" }, + gatewayId: otherGateway!.id, + applicationId: application!.id, + connectionId: connection!.id, + invocationId: otherInvocation!.id, + toolName: "other:delete_everything", + decision: "deny", + reasonCode: "deny_policy_block", + outcome: "denied", createdAt: new Date(now - 500), }, - ]); + ]).returning(); const app = createGatewayRouteApp(db, createTestToolGatewayService(db), { type: "board", @@ -3419,9 +3465,19 @@ rl.on("line", (line) => { isInstanceAdmin: true, }); + const allActivity = await request(app) + .get("/api/tool-gateway/audit") + .query({ companyId: company.id, window: "24h" }); + expect(allActivity.status).toBe(200); + expect(allActivity.body.events.map((event: { id: string }) => event.id)).toEqual([ + callEvents[2]!.id, + callEvents[0]!.id, + callEvents[1]!.id, + ]); + const firstPage = await request(app) .get("/api/tool-gateway/audit") - .query({ companyId: company.id, app: connection!.id, agent: agent.id, outcome: "allowed", window: "24h", limit: 1 }); + .query({ companyId: company.id, gateway: gateway!.id, app: connection!.id, agent: agent.id, outcome: "allowed", window: "24h", limit: 1 }); expect(firstPage.status).toBe(200); expect(firstPage.body.events).toEqual([ expect.objectContaining({ @@ -3433,6 +3489,14 @@ rl.on("line", (line) => { appDisplayName: "Mail", toolDisplayName: "Send Email", normalizedOutcome: "allowed", + invocation: expect.objectContaining({ + id: newerInvocation!.id, + toolName: "mail:send_email", + status: "succeeded", + policyDecision: "allow", + argumentsSummary: expect.objectContaining({ summary: expect.stringContaining("***REDACTED***") }), + resultSummary: expect.objectContaining({ summary: expect.stringContaining("delivered") }), + }), }), ]); expect(typeof firstPage.body.nextCursor).toBe("string"); @@ -3441,6 +3505,7 @@ rl.on("line", (line) => { .get("/api/tool-gateway/audit") .query({ companyId: company.id, + gateway: gateway!.id, app: connection!.id, agent: agent.id, outcome: "allowed", @@ -3451,7 +3516,7 @@ rl.on("line", (line) => { expect(secondPage.status).toBe(200); expect(secondPage.body.events).toEqual([ expect.objectContaining({ - action: "tool_gateway.call_allowed", + action: "tool_gateway.call_completed", toolDisplayName: "Read Email", }), ]); diff --git a/server/src/routes/tool-gateway.ts b/server/src/routes/tool-gateway.ts index 76a91c54a3f..7344c973f19 100644 --- a/server/src/routes/tool-gateway.ts +++ b/server/src/routes/tool-gateway.ts @@ -1,7 +1,7 @@ import { Router, type Request, type Response } from "express"; import { and, desc, eq, gte, ilike, inArray, lt, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { activityLog, agents, toolApplications, toolConnections, toolInvocations } from "@paperclipai/db"; +import { agents, toolApplications, toolCallEvents, toolConnections, toolInvocations } from "@paperclipai/db"; import { humanizeConnectionDisplayName, type PermissionKey } from "@paperclipai/shared"; import { createToolMcpGatewaySchema, @@ -13,19 +13,13 @@ import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool- import { forbidden, HttpError } from "../errors.js"; import { accessService } from "../services/index.js"; -const TOOL_GATEWAY_ACTIONS = [ - "tool_gateway.session_created", - "tool_gateway.session_revoked", - "tool_gateway.session_rejected", - "tool_gateway.discovery", - "tool_gateway.call_allowed", - "tool_gateway.call_denied", - "tool_gateway.call_completed", - "tool_gateway.call_failed", - "tool_gateway.call_deferred", - "tool_gateway.approval_requested", - "tool_gateway.runtime_mcp_delivery", -]; +const TOOL_ACTIVITY_EVENT_TYPES = [ + "call_completed", + "call_failed", + "call_denied", + "approval_requested", + "approval_resolved", +] as const; const TOOL_GATEWAY_WINDOWS: Record = { "1h": 60 * 60 * 1000, @@ -194,42 +188,53 @@ function decodeAuditCursor(value: string): { createdAt: Date; id: string } | nul } } -function normalizedAuditOutcome(action: string, details: Record | null | undefined) { - const decision = detailString(details, "decision"); - if (action === "tool_gateway.call_completed" || action === "tool_gateway.call_allowed" || decision === "allow" || decision === "approved") return "allowed"; - if (action === "tool_gateway.approval_requested" || decision === "require_approval") return "asked_first"; - if (action === "tool_gateway.call_deferred" || decision === "defer_runtime") return "waiting"; - if (action === "tool_gateway.call_failed") return "failed"; - if (action === "tool_gateway.call_denied" || decision === "deny" || decision === "rate_limited") return "blocked"; +function toolActivityAction(eventType: string): string { + return `tool_gateway.${eventType}`; +} + +function normalizedAuditOutcome( + eventType: string, + outcome: string, + decision: string | null, +) { + if (eventType === "approval_requested") return "asked_first"; + if (decision === "defer_runtime") return "waiting"; + if (eventType === "call_denied" || outcome === "denied" || decision === "deny") return "blocked"; + if (eventType === "call_failed" || ["failure", "timeout", "cancelled"].includes(outcome)) return "failed"; + if (eventType === "call_completed" || eventType === "approval_resolved" || outcome === "success" || decision === "allow") return "allowed"; return "unknown"; } function outcomeCondition(outcome: string) { if (outcome === "allowed") { return or( - inArray(activityLog.action, ["tool_gateway.call_allowed", "tool_gateway.call_completed"]), - sql`${activityLog.details}->>'decision' in ('allow', 'approved')`, + eq(toolCallEvents.eventType, "call_completed"), + and(eq(toolCallEvents.eventType, "approval_resolved"), eq(toolCallEvents.outcome, "success")), + eq(toolCallEvents.decision, "allow"), ); } if (outcome === "blocked" || outcome === "denied") { return or( - eq(activityLog.action, "tool_gateway.call_denied"), - sql`${activityLog.details}->>'decision' in ('deny', 'rate_limited')`, + eq(toolCallEvents.eventType, "call_denied"), + eq(toolCallEvents.outcome, "denied"), + eq(toolCallEvents.decision, "deny"), ); } if (outcome === "asked_first" || outcome === "approval") { - return or( - eq(activityLog.action, "tool_gateway.approval_requested"), - sql`${activityLog.details}->>'decision' = 'require_approval'`, - ); + return eq(toolCallEvents.eventType, "approval_requested"); } if (outcome === "waiting" || outcome === "deferred") { - return or( - eq(activityLog.action, "tool_gateway.call_deferred"), - sql`${activityLog.details}->>'decision' = 'defer_runtime'`, + return eq(toolCallEvents.decision, "defer_runtime"); + } + if (outcome === "failed") { + return and( + or( + eq(toolCallEvents.eventType, "call_failed"), + inArray(toolCallEvents.outcome, ["failure", "timeout", "cancelled"]), + ), + sql`${toolCallEvents.decision} is distinct from 'defer_runtime'`, ); } - if (outcome === "failed") return eq(activityLog.action, "tool_gateway.call_failed"); return null; } @@ -644,12 +649,17 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { await assertBoardPermission(req, companyId, "tools:view_audit"); const limitRaw = Number(req.query.limit ?? 100); const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(100, Math.floor(limitRaw))) : 100; + const gatewayFilter = typeof req.query.gateway === "string" ? req.query.gateway.trim() : null; const appFilter = typeof req.query.app === "string" ? req.query.app.trim() : null; const agentFilter = typeof req.query.agent === "string" ? req.query.agent.trim() : null; const outcomeFilter = typeof req.query.outcome === "string" ? req.query.outcome.trim() : null; const windowFilter = typeof req.query.window === "string" ? req.query.window.trim() : "24h"; const searchRaw = typeof req.query.search === "string" ? req.query.search.trim() : null; const cursorRaw = typeof req.query.cursor === "string" ? req.query.cursor.trim() : null; + if (gatewayFilter && !uuidPattern.test(gatewayFilter)) { + res.status(400).json({ error: "gateway must be a gateway UUID" }); + return; + } if (appFilter && !uuidPattern.test(appFilter)) { res.status(400).json({ error: "app must be an applicationId or connectionId UUID" }); return; @@ -669,29 +679,34 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { } const conditions = [ - eq(activityLog.companyId, companyId), - inArray(activityLog.action, TOOL_GATEWAY_ACTIONS), - gte(activityLog.createdAt, new Date(Date.now() - TOOL_GATEWAY_WINDOWS[windowFilter])), + eq(toolCallEvents.companyId, companyId), + inArray(toolCallEvents.eventType, TOOL_ACTIVITY_EVENT_TYPES), + gte(toolCallEvents.createdAt, new Date(Date.now() - TOOL_GATEWAY_WINDOWS[windowFilter])), ]; if (cursor) { conditions.push(or( - lt(activityLog.createdAt, cursor.createdAt), - and(eq(activityLog.createdAt, cursor.createdAt), lt(activityLog.id, cursor.id)), + lt(toolCallEvents.createdAt, cursor.createdAt), + and(eq(toolCallEvents.createdAt, cursor.createdAt), lt(toolCallEvents.id, cursor.id)), + )!); + } + if (gatewayFilter) { + conditions.push(or( + eq(toolCallEvents.gatewayId, gatewayFilter), + eq(toolInvocations.gatewayId, gatewayFilter), )!); } if (appFilter) { conditions.push(or( + eq(toolCallEvents.applicationId, appFilter), + eq(toolCallEvents.connectionId, appFilter), eq(toolInvocations.applicationId, appFilter), eq(toolInvocations.connectionId, appFilter), - sql`${activityLog.details}->>'applicationId' = ${appFilter}`, - sql`${activityLog.details}->>'connectionId' = ${appFilter}`, )!); } if (agentFilter) { conditions.push(or( - eq(activityLog.agentId, agentFilter), + eq(toolCallEvents.agentId, agentFilter), eq(toolInvocations.agentId, agentFilter), - sql`${activityLog.details}->>'agentId' = ${agentFilter}`, )!); } const outcomeWhere = outcomeFilter ? outcomeCondition(outcomeFilter) : null; @@ -699,7 +714,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { // Free-text search runs server-side: resolve the term against agent / app / // connection names first, then OR those matched IDs with direct matches on - // the action name, tool name, and reason code so paginating stays honest. + // the event type, tool name, and reason code so paginating stays honest. if (searchRaw) { const like = `%${searchRaw.replace(/[%_\\]/g, (ch) => `\\${ch}`)}%`; const [matchAgents, matchApps, matchConnections] = await Promise.all([ @@ -714,48 +729,56 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { const matchedAppIds = matchApps.map((r) => r.id); const matchedConnectionIds = matchConnections.map((r) => r.id); const searchClauses = [ - ilike(activityLog.action, like), + ilike(toolCallEvents.eventType, like), + ilike(toolCallEvents.toolName, like), + ilike(toolCallEvents.reasonCode, like), + sql`${toolCallEvents.metadata}->>'upstreamToolName' ilike ${like}`, ilike(toolInvocations.toolName, like), - sql`${activityLog.details}->>'tool' ilike ${like}`, - sql`${activityLog.details}->>'toolName' ilike ${like}`, - sql`${activityLog.details}->>'upstreamToolName' ilike ${like}`, - sql`${activityLog.details}->>'reasonCode' ilike ${like}`, ]; if (matchedAgentIds.length > 0) { - searchClauses.push(inArray(activityLog.agentId, matchedAgentIds)); + searchClauses.push(inArray(toolCallEvents.agentId, matchedAgentIds)); searchClauses.push(inArray(toolInvocations.agentId, matchedAgentIds)); - for (const id of matchedAgentIds) searchClauses.push(sql`${activityLog.details}->>'agentId' = ${id}`); } if (matchedAppIds.length > 0) { + searchClauses.push(inArray(toolCallEvents.applicationId, matchedAppIds)); searchClauses.push(inArray(toolInvocations.applicationId, matchedAppIds)); - for (const id of matchedAppIds) searchClauses.push(sql`${activityLog.details}->>'applicationId' = ${id}`); } if (matchedConnectionIds.length > 0) { + searchClauses.push(inArray(toolCallEvents.connectionId, matchedConnectionIds)); searchClauses.push(inArray(toolInvocations.connectionId, matchedConnectionIds)); - for (const id of matchedConnectionIds) searchClauses.push(sql`${activityLog.details}->>'connectionId' = ${id}`); } conditions.push(or(...searchClauses)!); } const page = await db .select({ - row: activityLog, + row: toolCallEvents, invocationId: toolInvocations.id, invocationAgentId: toolInvocations.agentId, invocationApplicationId: toolInvocations.applicationId, invocationConnectionId: toolInvocations.connectionId, invocationToolName: toolInvocations.toolName, + invocationStatus: toolInvocations.status, + invocationPolicyDecision: toolInvocations.policyDecision, + invocationApprovalState: toolInvocations.approvalState, + invocationArgumentsSummary: toolInvocations.argumentsSummary, + invocationResultSummary: toolInvocations.resultSummary, + invocationResultSizeBytes: toolInvocations.resultSizeBytes, + invocationErrorCode: toolInvocations.errorCode, + invocationErrorMessage: toolInvocations.errorMessage, + invocationStartedAt: toolInvocations.startedAt, + invocationCompletedAt: toolInvocations.completedAt, }) - .from(activityLog) + .from(toolCallEvents) .leftJoin( toolInvocations, and( eq(toolInvocations.companyId, companyId), - sql`${toolInvocations.id}::text = ${activityLog.details}->>'invocationId'`, + eq(toolInvocations.id, toolCallEvents.invocationId), ), ) .where(and(...conditions)) - .orderBy(desc(activityLog.createdAt), desc(activityLog.id)) + .orderBy(desc(toolCallEvents.createdAt), desc(toolCallEvents.id)) .limit(limit + 1); const hasMore = page.length > limit; @@ -763,15 +786,14 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { const agentIds = [...new Set(visible.flatMap((item) => [ item.row.agentId, item.invocationAgentId, - detailString(item.row.details, "agentId"), ]).filter((id): id is string => Boolean(id)))]; const applicationIds = [...new Set(visible.flatMap((item) => [ + item.row.applicationId, item.invocationApplicationId, - detailString(item.row.details, "applicationId"), ]).filter((id): id is string => Boolean(id)))]; const connectionIds = [...new Set(visible.flatMap((item) => [ + item.row.connectionId, item.invocationConnectionId, - detailString(item.row.details, "connectionId"), ]).filter((id): id is string => Boolean(id)))]; const [agentRows, applicationRows, connectionRows] = await Promise.all([ agentIds.length > 0 @@ -790,20 +812,49 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { const events = visible.map((item) => { const row = item.row; - const details = row.details ?? null; - const agentId = row.agentId ?? item.invocationAgentId ?? detailString(details, "agentId"); - const connectionId = item.invocationConnectionId ?? detailString(details, "connectionId"); + const agentId = row.agentId ?? item.invocationAgentId; + const connectionId = row.connectionId ?? item.invocationConnectionId; const connection = connectionId ? connectionsById.get(connectionId) ?? null : null; - const applicationId = item.invocationApplicationId ?? detailString(details, "applicationId") ?? connection?.applicationId ?? null; + const applicationId = row.applicationId ?? item.invocationApplicationId ?? connection?.applicationId ?? null; const application = applicationId ? applicationsById.get(applicationId) ?? null : null; - const rawToolName = item.invocationToolName ?? detailString(details, "tool") ?? detailString(details, "toolName"); + const rawToolName = row.toolName ?? item.invocationToolName; const appDisplayName = connection ? humanizeConnectionDisplayName(connection) : application ? humanizeConnectionDisplayName(application.name) : null; + const details = { + ...(row.metadata ?? {}), + invocationId: row.invocationId, + actionRequestId: row.actionRequestId, + gatewayId: row.gatewayId, + agentId, + issueId: row.issueId, + runId: row.runId, + applicationId, + connectionId, + tool: rawToolName, + toolName: rawToolName, + decision: row.decision, + matchedPolicyIds: row.matchedPolicyIds, + reasonCode: row.reasonCode, + argumentsSummary: row.argumentsSummary ?? row.requestSummary, + resultSummary: row.resultSummary, + latencyMs: row.latencyMs, + errorCode: row.errorCode, + errorMessage: row.errorMessage, + }; return { - ...row, + id: row.id, + companyId: row.companyId, + action: toolActivityAction(row.eventType), + actorType: row.actorType, + actorId: row.actorId, + entityType: "tool_connection", + entityId: connectionId, + details, + createdAt: row.createdAt, + runId: row.runId, agentId, agentDisplayName: agentId ? agentsById.get(agentId)?.name ?? "Unknown agent" : null, applicationId, @@ -812,7 +863,23 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { applicationDisplayName: application ? humanizeConnectionDisplayName(application.name) : null, connectionDisplayName: connection ? humanizeConnectionDisplayName(connection) : null, toolDisplayName: rawToolName ? humanizeConnectionDisplayName(rawToolName) : null, - normalizedOutcome: normalizedAuditOutcome(row.action, details), + normalizedOutcome: normalizedAuditOutcome(row.eventType, row.outcome, row.decision), + invocation: item.invocationId && item.invocationToolName && item.invocationStatus && item.invocationApprovalState + ? { + id: item.invocationId, + toolName: item.invocationToolName, + status: item.invocationStatus, + policyDecision: item.invocationPolicyDecision, + approvalState: item.invocationApprovalState, + argumentsSummary: item.invocationArgumentsSummary, + resultSummary: item.invocationResultSummary, + resultSizeBytes: item.invocationResultSizeBytes, + errorCode: item.invocationErrorCode, + errorMessage: item.invocationErrorMessage, + startedAt: item.invocationStartedAt, + completedAt: item.invocationCompletedAt, + } + : null, }; });