diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9a043b9060b..0fbc3aa4e6b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -918,6 +918,7 @@ export const PERMISSION_KEYS = [ "tools:manage_connections", "tools:manage_profiles", "tools:view_audit", + "audit:view_agent_actions", "tools:use", "tools:manage_runtime", "inbox:manage", diff --git a/server/src/__tests__/agent-action-audit-routes.test.ts b/server/src/__tests__/agent-action-audit-routes.test.ts new file mode 100644 index 00000000000..ac1a53b4540 --- /dev/null +++ b/server/src/__tests__/agent-action-audit-routes.test.ts @@ -0,0 +1,177 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + companies, + companyMemberships, + createDb, + documents, + heartbeatRuns, + issueComments, + issueDocuments, + issues, + principalPermissionGrants, +} from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; +type Db = ReturnType; + +async function createApp(db: Db, actor: Express.Request["actor"]) { + const { activityRoutes } = await import("../routes/activity.js"); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", activityRoutes(db)); + app.use((error: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error.status ?? 500).json({ error: error.message ?? "Internal server error" }); + }); + return app; +} + +describePostgres("agent action audit routes", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-action-audit-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(issueComments); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => tempDb?.cleanup()); + + async function seed() { + const company = await db.insert(companies).values({ + name: "Audit Company", + issuePrefix: `AU${randomUUID().slice(0, 6).toUpperCase()}`, + }).returning().then((rows) => rows[0]!); + const [agent, otherAgent] = await db.insert(agents).values([1, 2].map((index) => ({ + companyId: company.id, + name: `Audit Agent ${index}`, + role: "engineer", + status: "active" as const, + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + }))).returning(); + const issue = await db.insert(issues).values({ + companyId: company.id, + identifier: `${company.issuePrefix}-1`, + title: "Audit target", + status: "todo", + priority: "medium", + }).returning().then((rows) => rows[0]!); + const comment = await db.insert(issueComments).values({ + companyId: company.id, + issueId: issue.id, + authorAgentId: agent.id, + body: "A useful comment excerpt for the audit feed.", + }).returning().then((rows) => rows[0]!); + const document = await db.insert(documents).values({ + companyId: company.id, + title: "Plan", + latestBody: "Plan body", + createdByAgentId: agent.id, + updatedByAgentId: agent.id, + }).returning().then((rows) => rows[0]!); + const issueDocument = await db.insert(issueDocuments).values({ + companyId: company.id, + issueId: issue.id, + documentId: document.id, + key: "plan", + }).returning().then((rows) => rows[0]!); + const run = await db.insert(heartbeatRuns).values({ + companyId: company.id, + agentId: agent.id, + responsibleUserId: "legacy-user", + }).returning().then((rows) => rows[0]!); + const base = new Date("2026-07-17T00:00:00.000Z"); + await db.insert(activityLog).values([ + { companyId: company.id, actorType: "agent", actorId: agent.id, action: "issue.comment.created", entityType: "issue_comment", entityId: comment.id, agentId: agent.id, runId: run.id, responsibleUserId: null, createdAt: new Date(base.getTime() + 3000) }, + { companyId: company.id, actorType: "system", actorId: "system", action: "issue.document.updated", entityType: "issue_document", entityId: issueDocument.id, agentId: agent.id, runId: run.id, responsibleUserId: "direct-user", createdAt: new Date(base.getTime() + 2000) }, + { companyId: company.id, actorType: "agent", actorId: otherAgent.id, action: "issue.updated", entityType: "issue", entityId: issue.id, agentId: otherAgent.id, responsibleUserId: "other-user", createdAt: new Date(base.getTime() + 1000) }, + ]); + return { company, agent, otherAgent, issue, comment, issueDocument, run }; + } + + it("denies agents and board users without the audit permission", async () => { + const { company, agent } = await seed(); + const agentResponse = await request(await createApp(db, { + type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt", + })).get(`/api/companies/${company.id}/audit/agent-actions`); + expect(agentResponse.status).toBe(403); + + const boardResponse = await request(await createApp(db, { + type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions`); + expect(boardResponse.status).toBe(403); + expect(boardResponse.body.error).toContain("audit:view_agent_actions"); + }); + + it("paginates, filters, enriches entities, and falls back to the run responsible user", async () => { + const { company, agent, otherAgent, issue, comment, issueDocument, run } = await seed(); + const app = await createApp(db, { type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false }); + const first = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?limit=1`); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body.items).toHaveLength(1); + expect(first.body.items[0].responsibleUserId).toBe("legacy-user"); + expect(first.body.items[0].entity.comment).toEqual({ id: comment.id, excerpt: "A useful comment excerpt for the audit feed." }); + expect(first.body.items[0].entity.issue).toMatchObject({ id: issue.id, identifier: issue.identifier, title: issue.title }); + expect(first.body.nextCursor).toEqual(expect.any(String)); + + const second = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?limit=1&cursor=${encodeURIComponent(first.body.nextCursor)}`); + expect(second.body.items[0].entity.document).toEqual({ id: expect.any(String), key: "plan" }); + + const cases = [ + [`agentId=${agent.id}`, 2], + ["responsibleUserId=legacy-user", 1], + [`runId=${run.id}`, 2], + ["entityType=issue_document", 1], + [`entityId=${issueDocument.id}`, 1], + ["action=issue.comment", 1], + ["actorType=system", 1], + ["from=2026-07-17T00%3A00%3A01.500Z&to=2026-07-17T00%3A00%3A02.500Z", 1], + [`agentId=${otherAgent.id}`, 1], + ] as const; + for (const [query, count] of cases) { + const response = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?${query}`); + expect(response.status, `${query}: ${JSON.stringify(response.body)}`).toBe(200); + expect(response.body.items, query).toHaveLength(count); + } + }); + + it("allows a signed-in board user with the explicit permission", async () => { + const { company } = await seed(); + await db.insert(companyMemberships).values({ + companyId: company.id, principalType: "user", principalId: "reader", status: "active", membershipRole: "viewer", + }); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, principalType: "user", principalId: "reader", permissionKey: "audit:view_agent_actions", scope: null, grantedByUserId: null, + }); + const response = await request(await createApp(db, { + type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.items).toHaveLength(3); + }); +}); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 7fe6894cbf8..88b1f0928c1 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -7,6 +7,8 @@ import { activityService, normalizeActivityLimit } from "../services/activity.js import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js"; import { accessService, heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; +import { forbidden } from "../errors.js"; +import { agentActionAuditService } from "../services/agent-action-audit.js"; const createActivitySchema = z.object({ actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"), @@ -18,12 +20,35 @@ const createActivitySchema = z.object({ details: z.record(z.unknown()).optional().nullable(), }); +const agentActionAuditQuerySchema = z.object({ + agentId: z.string().uuid().optional(), + responsibleUserId: z.string().min(1).optional(), + runId: z.string().uuid().optional(), + entityType: z.string().min(1).optional(), + entityId: z.string().min(1).optional(), + action: z.string().min(1).optional(), + actorType: z.enum(["agent", "user", "system", "plugin"]).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), +}); + export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); const access = accessService(db); const heartbeat = heartbeatService(db); const issueSvc = issueService(db); + const agentAudit = agentActionAuditService(db); + + async function assertAgentAuditPermission(req: import("express").Request, companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + if (req.actor.userId && await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions")) return; + throw forbidden("Missing permission: audit:view_agent_actions"); + } async function assertCompanyScopeReadAllowed(req: Parameters[0], res: any, companyId: string) { const decision = await access.decide({ @@ -88,6 +113,21 @@ export function activityRoutes(db: Db) { res.json(result); }); + router.get("/companies/:companyId/audit/agent-actions", async (req, res) => { + const companyId = req.params.companyId as string; + await assertAgentAuditPermission(req, companyId); + const query = agentActionAuditQuerySchema.parse(req.query); + try { + res.json(await agentAudit.list({ companyId, ...query })); + } catch (error) { + if (error instanceof Error && error.message === "Invalid audit cursor") { + res.status(400).json({ error: error.message }); + return; + } + throw error; + } + }); + router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index fb27951a5c8..eb821f23011 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -65,6 +65,14 @@ export interface LogActivityInput { details?: Record | null; } +export async function redactActivityDetails(db: Db, details: Record | null) { + if (!details) return null; + const currentUserRedactionOptions = { + enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs, + }; + return redactCurrentUserValue(sanitizeRecord(details), currentUserRedactionOptions); +} + function readNonEmptyString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -125,13 +133,7 @@ export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActi } export async function logActivity(db: Db, input: LogActivityInput) { - const currentUserRedactionOptions = { - enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs, - }; - const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null; - const redactedDetails = sanitizedDetails - ? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions) - : null; + const redactedDetails = await redactActivityDetails(db, input.details ?? null); const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input); await db.insert(activityLog).values({ companyId: input.companyId, diff --git a/server/src/services/agent-action-audit.ts b/server/src/services/agent-action-audit.ts new file mode 100644 index 00000000000..312b84a8687 --- /dev/null +++ b/server/src/services/agent-action-audit.ts @@ -0,0 +1,139 @@ +import { and, desc, eq, gte, inArray, isNotNull, lt, lte, or, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { activityLog, heartbeatRuns, issueComments, issueDocuments, issues } from "@paperclipai/db"; +import { redactActivityDetails } from "./activity-log.js"; + +export interface AgentActionAuditFilters { + companyId: string; + agentId?: string; + responsibleUserId?: string; + runId?: string; + entityType?: string; + entityId?: string; + action?: string; + actorType?: "agent" | "user" | "system" | "plugin"; + from?: Date; + to?: Date; + cursor?: string; + limit: number; +} + +type CursorValue = { createdAt: string; id: string }; + +function decodeCursor(cursor: string | undefined): CursorValue | null { + if (!cursor) return null; + try { + const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as CursorValue; + const createdAt = new Date(value.createdAt); + if (!value.id || Number.isNaN(createdAt.getTime())) return null; + return { createdAt: createdAt.toISOString(), id: value.id }; + } catch { + return null; + } +} + +function encodeCursor(value: CursorValue) { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function excerpt(value: string, maxLength = 280) { + const normalized = value.replace(/\s+/g, " ").trim(); + return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 1)}…`; +} + +export function agentActionAuditService(db: Db) { + return { + list: async (filters: AgentActionAuditFilters) => { + const cursor = decodeCursor(filters.cursor); + if (filters.cursor && !cursor) throw new Error("Invalid audit cursor"); + const effectiveResponsibleUserId = sql`coalesce(${activityLog.responsibleUserId}, ${heartbeatRuns.responsibleUserId})`; + const conditions = [eq(activityLog.companyId, filters.companyId), isNotNull(activityLog.agentId)]; + if (filters.agentId) conditions.push(eq(activityLog.agentId, filters.agentId)); + if (filters.responsibleUserId) conditions.push(eq(effectiveResponsibleUserId, filters.responsibleUserId)); + if (filters.runId) conditions.push(eq(activityLog.runId, filters.runId)); + if (filters.entityType) conditions.push(eq(activityLog.entityType, filters.entityType)); + if (filters.entityId) conditions.push(eq(activityLog.entityId, filters.entityId)); + if (filters.action) conditions.push(sql`starts_with(${activityLog.action}, ${filters.action})`); + if (filters.actorType) conditions.push(eq(activityLog.actorType, filters.actorType)); + if (filters.from) conditions.push(gte(activityLog.createdAt, filters.from)); + if (filters.to) conditions.push(lte(activityLog.createdAt, filters.to)); + if (cursor) { + const cursorDate = new Date(cursor.createdAt); + conditions.push(or( + lt(activityLog.createdAt, cursorDate), + and(eq(activityLog.createdAt, cursorDate), lt(activityLog.id, cursor.id)), + )!); + } + + const rows = await db.select({ + id: activityLog.id, + companyId: activityLog.companyId, + actorType: activityLog.actorType, + actorId: activityLog.actorId, + action: activityLog.action, + entityType: activityLog.entityType, + entityId: activityLog.entityId, + agentId: activityLog.agentId, + runId: activityLog.runId, + responsibleUserId: effectiveResponsibleUserId, + details: activityLog.details, + createdAt: activityLog.createdAt, + }).from(activityLog).leftJoin(heartbeatRuns, and( + eq(heartbeatRuns.companyId, activityLog.companyId), + eq(heartbeatRuns.id, activityLog.runId), + )).where(and(...conditions)).orderBy(desc(activityLog.createdAt), desc(activityLog.id)).limit(filters.limit + 1); + + const page = rows.slice(0, filters.limit); + const entityIds = [...new Set(page.map((row) => row.entityId))]; + const commentRows = entityIds.length === 0 ? [] : await db.select({ + id: issueComments.id, body: issueComments.body, issueId: issues.id, identifier: issues.identifier, title: issues.title, + }).from(issueComments).innerJoin(issues, and(eq(issues.id, issueComments.issueId), eq(issues.companyId, filters.companyId))) + .where(and(eq(issueComments.companyId, filters.companyId), inArray(issueComments.id, entityIds))); + const issueRows = entityIds.length === 0 ? [] : await db.select({ + id: issues.id, identifier: issues.identifier, title: issues.title, + }).from(issues).where(and(eq(issues.companyId, filters.companyId), inArray(issues.id, entityIds))); + const documentRows = entityIds.length === 0 ? [] : await db.select({ + id: issueDocuments.id, documentId: issueDocuments.documentId, key: issueDocuments.key, + issueId: issues.id, identifier: issues.identifier, title: issues.title, + }).from(issueDocuments).innerJoin(issues, and(eq(issues.id, issueDocuments.issueId), eq(issues.companyId, filters.companyId))) + .where(and(eq(issueDocuments.companyId, filters.companyId), or( + inArray(issueDocuments.id, entityIds), inArray(issueDocuments.documentId, entityIds), + ))); + + const comments = new Map(commentRows.map((row) => [row.id, row])); + const issueMap = new Map(issueRows.map((row) => [row.id, row])); + const documents = new Map(); + for (const row of documentRows) { + documents.set(row.id, row); + documents.set(row.documentId, row); + } + + const items = await Promise.all(page.map(async (row) => { + const comment = comments.get(row.entityId); + const issue = issueMap.get(row.entityId); + const document = documents.get(row.entityId); + const issueSnippet = comment + ? { id: comment.issueId, identifier: comment.identifier, title: comment.title } + : document + ? { id: document.issueId, identifier: document.identifier, title: document.title } + : issue ? { id: issue.id, identifier: issue.identifier, title: issue.title } : null; + return { + ...row, + details: await redactActivityDetails(db, row.details), + entity: { + issue: issueSnippet, + comment: comment ? { id: comment.id, excerpt: excerpt(comment.body) } : null, + document: document ? { id: document.documentId, key: document.key } : null, + }, + }; + })); + const last = page.at(-1); + return { + items, + nextCursor: rows.length > filters.limit && last + ? encodeCursor({ createdAt: last.createdAt.toISOString(), id: last.id }) + : null, + }; + }, + }; +}