diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 42c002ef80..a065aa3f8d 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -934,6 +934,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 0000000000..4cbe95e14f --- /dev/null +++ b/server/src/__tests__/agent-action-audit-routes.test.ts @@ -0,0 +1,352 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq, sql } from "drizzle-orm"; +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("returns a client error for invalid audit query parameters", async () => { + const { company } = await seed(); + const response = await request(await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions?limit=invalid`); + expect(response.status).toBe(400); + expect(response.body.error).toBe("Invalid agent action audit query"); + + const cursorResponse = await request(await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions?cursor=invalid`); + expect(cursorResponse.status).toBe(400); + expect(cursorResponse.body.error).toBe("Invalid audit cursor"); + + const nonUuidCursor = Buffer.from(JSON.stringify({ + createdAt: "2026-07-17T00:00:00.000000Z", + id: "not-a-uuid", + }), "utf8").toString("base64url"); + const nonUuidCursorResponse = await request(await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions?cursor=${encodeURIComponent(nonUuidCursor)}`); + expect(nonUuidCursorResponse.status).toBe(400); + expect(nonUuidCursorResponse.body.error).toBe("Invalid audit cursor"); + }); + + it("preserves sub-millisecond cursor precision across pages", async () => { + const { company, agent } = await seed(); + await db.delete(activityLog); + const newerId = randomUUID(); + const olderId = randomUUID(); + await db.execute(sql` + insert into activity_log ( + id, company_id, actor_type, actor_id, action, entity_type, entity_id, agent_id, created_at + ) values + (${newerId}::uuid, ${company.id}::uuid, 'agent', ${agent.id}, 'audit.precision', 'company', ${company.id}, ${agent.id}::uuid, '2026-07-17T00:00:00.001900Z'::timestamptz), + (${olderId}::uuid, ${company.id}::uuid, 'agent', ${agent.id}, 'audit.precision', 'company', ${company.id}, ${agent.id}::uuid, '2026-07-17T00:00:00.001100Z'::timestamptz) + `); + + 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?action=audit.precision&limit=1`); + expect(first.status, JSON.stringify(first.body)).toBe(200); + expect(first.body.items.map((item: { id: string }) => item.id)).toEqual([newerId]); + expect(first.body.nextCursor).toEqual(expect.any(String)); + + const second = await request(app).get( + `/api/companies/${company.id}/audit/agent-actions?action=audit.precision&limit=1&cursor=${encodeURIComponent(first.body.nextCursor)}`, + ); + expect(second.status, JSON.stringify(second.body)).toBe(200); + expect(second.body.items.map((item: { id: string }) => item.id)).toEqual([olderId]); + expect(second.body.nextCursor).toBeNull(); + }); + + 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("does not enrich hidden issues or mismatched entity types", async () => { + const { company, agent, comment, run } = await seed(); + const hiddenIssue = await db.insert(issues).values({ + companyId: company.id, + identifier: `${company.issuePrefix}-HIDDEN`, + title: "Hidden audit target", + status: "todo", + priority: "medium", + hiddenAt: new Date(), + }).returning().then((rows) => rows[0]!); + const hiddenComment = await db.insert(issueComments).values({ + companyId: company.id, + issueId: hiddenIssue.id, + authorAgentId: agent.id, + body: "Hidden audit comment", + }).returning().then((rows) => rows[0]!); + const [hiddenActivity, hiddenDocumentActivity, mismatchedActivity] = await db.insert(activityLog).values([ + { + companyId: company.id, + actorType: "agent", + actorId: agent.id, + action: "issue.comment.created", + entityType: "issue", + entityId: hiddenIssue.id, + agentId: agent.id, + runId: run.id, + details: { + commentId: hiddenComment.id, + bodySnippet: hiddenComment.body, + identifier: hiddenIssue.identifier, + issueTitle: hiddenIssue.title, + }, + }, + { + companyId: company.id, + actorType: "agent", + actorId: agent.id, + action: "issue.document_updated", + entityType: "issue", + entityId: hiddenIssue.id, + agentId: agent.id, + runId: run.id, + details: { + documentId: randomUUID(), + key: "plan", + title: "Hidden document title", + }, + }, + { + companyId: company.id, + actorType: "agent", + actorId: agent.id, + action: "company.updated", + entityType: "company", + entityId: comment.id, + agentId: agent.id, + runId: run.id, + }, + ]).returning(); + + const response = await request(await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + })).get(`/api/companies/${company.id}/audit/agent-actions`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.items.find((item: { id: string }) => item.id === hiddenActivity.id)?.entity).toEqual({ + issue: null, + comment: null, + document: null, + }); + expect(response.body.items.find((item: { id: string }) => item.id === hiddenActivity.id)?.details).toBeNull(); + expect(response.body.items.find((item: { id: string }) => item.id === hiddenDocumentActivity.id)?.details).toBeNull(); + expect(response.body.items.find((item: { id: string }) => item.id === mismatchedActivity.id)?.entity).toEqual({ + issue: null, + comment: null, + document: null, + }); + }); + + it("exports the audit feed as CSV and logs the export action", async () => { + const { company, issue, comment } = await seed(); + await db.update(issues).set({ title: "=2+2" }).where(eq(issues.id, issue.id)); + await db.update(issueComments).set({ body: "@SUM(A1)" }).where(eq(issueComments.id, comment.id)); + const app = await createApp(db, { + type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false, + }); + const response = await request(app).get(`/api/companies/${company.id}/audit/agent-actions.csv`); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.headers["content-type"]).toContain("text/csv"); + expect(response.headers["content-disposition"]).toContain(`agent-audit-${company.id}.csv`); + + const lines = response.text.trim().split("\r\n"); + expect(lines[0]).toBe( + "createdAt,action,actorType,actorId,agentId,runId,responsibleUserId,entityType,entityId,issueIdentifier,issueTitle,commentExcerpt,documentKey", + ); + // Three seeded activity rows → three CSV data rows (the export reads before it logs itself). + expect(lines).toHaveLength(4); + // User-controlled cells are preserved as text rather than executable formulas. + expect(response.text).toContain("'=2+2"); + expect(response.text).toContain("'@SUM(A1)"); + + // The export is itself recorded as an auditable action. + const logged = (await db.select().from(activityLog)).filter((row) => row.action === "audit.exported"); + expect(logged).toHaveLength(1); + expect(logged[0]!.entityType).toBe("company"); + expect(logged[0]!.entityId).toBe(company.id); + expect(logged[0]!.details).toMatchObject({ format: "csv", rowCount: 3, truncated: false }); + }); + + it("denies CSV export without the audit permission and logs nothing", async () => { + const { company } = await seed(); + 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.csv`); + expect(response.status).toBe(403); + expect(response.body.error).toContain("audit:view_agent_actions"); + + const logged = (await db.select().from(activityLog)).filter((row) => row.action === "audit.exported"); + expect(logged).toHaveLength(0); + }); + + 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 7fe6894cbf..225161e3d0 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -7,6 +7,87 @@ 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 { badRequest, forbidden } from "../errors.js"; +import { agentActionAuditService } from "../services/agent-action-audit.js"; +import { logActivity } from "../services/activity-log.js"; + +/** Max rows a single CSV export will stream (guards against runaway exports). */ +const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000; +const AUDIT_CSV_PAGE_SIZE = 200; +const CSV_FORMULA_CHARS = /^[=+\-@\t\r]/; + +const AUDIT_CSV_COLUMNS = [ + "createdAt", + "action", + "actorType", + "actorId", + "agentId", + "runId", + "responsibleUserId", + "entityType", + "entityId", + "issueIdentifier", + "issueTitle", + "commentExcerpt", + "documentKey", +] as const; + +function csvCell(value: unknown): string { + if (value === null || value === undefined) return ""; + const str = value instanceof Date ? value.toISOString() : String(value); + // Prevent spreadsheet applications from interpreting user-controlled cells + // as formulas when an operator opens the export. + const safe = CSV_FORMULA_CHARS.test(str) ? `'${str}` : str; + // Quote if the value contains a delimiter, quote, or newline; escape quotes by doubling. + return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe; +} + +function readNested(value: unknown, ...keys: string[]): string | null { + let cursor: unknown = value; + for (const key of keys) { + if (!cursor || typeof cursor !== "object") return null; + cursor = (cursor as Record)[key]; + } + return typeof cursor === "string" ? cursor : null; +} + +type AuditCsvRow = { + createdAt: Date | string; + action: string; + actorType: string | null; + actorId: string | null; + agentId: string | null; + runId: string | null; + responsibleUserId: string | null; + entityType: string; + entityId: string; + // Enrichment snippet is redacted server-side into a plain record, so read it + // defensively rather than assuming a fixed shape. + entity: unknown; +}; + +function auditRowsToCsv(rows: AuditCsvRow[]): string { + const lines = [AUDIT_CSV_COLUMNS.join(",")]; + for (const row of rows) { + lines.push([ + csvCell(row.createdAt), + csvCell(row.action), + csvCell(row.actorType), + csvCell(row.actorId), + csvCell(row.agentId), + csvCell(row.runId), + csvCell(row.responsibleUserId), + csvCell(row.entityType), + csvCell(row.entityId), + csvCell(readNested(row.entity, "issue", "identifier")), + csvCell(readNested(row.entity, "issue", "title")), + csvCell(readNested(row.entity, "comment", "excerpt")), + csvCell(readNested(row.entity, "document", "key")), + ].join(",")); + } + // Trailing newline keeps POSIX tools + spreadsheet importers happy. + return `${lines.join("\r\n")}\r\n`; +} const createActivitySchema = z.object({ actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"), @@ -18,12 +99,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 +192,70 @@ 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 parsedQuery = agentActionAuditQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + throw badRequest("Invalid agent action audit query", parsedQuery.error.issues); + } + res.json(await agentAudit.list({ companyId, ...parsedQuery.data })); + }); + + router.get("/companies/:companyId/audit/agent-actions.csv", async (req, res) => { + const companyId = req.params.companyId as string; + await assertAgentAuditPermission(req, companyId); + const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + throw badRequest("Invalid agent action audit query", parsedQuery.error.issues); + } + // Drive our own pagination for the export; a client-supplied cursor/limit + // would silently truncate the export, so ignore them. + const { cursor: _cursor, limit: _limit, ...filters } = parsedQuery.data; + const rows: Awaited>["items"] = []; + let cursor: string | undefined; + do { + const page = await agentAudit.list({ companyId, ...filters, cursor, limit: AUDIT_CSV_PAGE_SIZE }); + for (const item of page.items) { + if (rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS) break; + rows.push(item); + } + cursor = page.nextCursor ?? undefined; + } while (cursor && rows.length < AUDIT_CSV_EXPORT_MAX_ROWS); + + // The export is itself an auditable act (training-data export precedent): + // record who exported what filter set and how many rows left the system. + const actorUserId = req.actor.type === "board" ? req.actor.userId ?? null : null; + await logActivity(db, { + companyId, + actorType: actorUserId ? "user" : "system", + actorId: actorUserId ?? "local-board", + action: "audit.exported", + entityType: "company", + entityId: companyId, + details: { + format: "csv", + rowCount: rows.length, + truncated: rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS && Boolean(cursor), + filters: { + agentId: filters.agentId ?? null, + responsibleUserId: filters.responsibleUserId ?? null, + runId: filters.runId ?? null, + entityType: filters.entityType ?? null, + entityId: filters.entityId ?? null, + action: filters.action ?? null, + actorType: filters.actorType ?? null, + from: filters.from ? filters.from.toISOString() : null, + to: filters.to ? filters.to.toISOString() : null, + }, + }, + }); + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="agent-audit-${companyId}.csv"`); + res.send(auditRowsToCsv(rows)); + }); + router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index ebfd82fd5e..c0ed4e0367 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3123,6 +3123,62 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/audit/agent-actions", + tags: ["activity"], + summary: "List agent action audit entries", + request: { + params: z.object({ companyId: z.string() }), + query: 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.string().datetime().optional(), + to: z.string().datetime().optional(), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/audit/agent-actions.csv", + tags: ["activity"], + summary: "Export agent action audit entries as CSV", + request: { + params: z.object({ companyId: z.string() }), + query: 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.string().datetime().optional(), + to: z.string().datetime().optional(), + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + }), + }, + responses: { + 200: { + description: "Agent action audit export", + content: { "text/csv": { schema: z.string() } }, + }, + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + }, +}); + registry.registerPath({ method: "post", path: "/api/companies/{companyId}/activity", diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index fb27951a5c..981d7ce153 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -65,6 +65,20 @@ export interface LogActivityInput { details?: Record | null; } +export async function createActivityDetailsRedactor(db: Db) { + const currentUserRedactionOptions = { + enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs, + }; + return (details: Record | null) => ( + details ? redactCurrentUserValue(sanitizeRecord(details), currentUserRedactionOptions) : null + ); +} + +export async function redactActivityDetails(db: Db, details: Record | null) { + if (!details) return null; + return (await createActivityDetailsRedactor(db))(details); +} + function readNonEmptyString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } @@ -125,13 +139,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 0000000000..864b48dd74 --- /dev/null +++ b/server/src/services/agent-action-audit.ts @@ -0,0 +1,193 @@ +import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm"; +import { z } from "zod"; +import type { Db } from "@paperclipai/db"; +import { activityLog, heartbeatRuns, issueComments, issueDocuments, issues } from "@paperclipai/db"; +import { createActivityDetailsRedactor } from "./activity-log.js"; +import { badRequest } from "../errors.js"; +import { visibleIssueCondition } from "./issue-visibility.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 }; + +const cursorValueSchema = z.object({ + createdAt: z.string().datetime({ offset: true }), + id: z.string().uuid(), +}); + +function decodeCursor(cursor: string | undefined): CursorValue | null { + if (!cursor) return null; + try { + const parsed = cursorValueSchema.safeParse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"))); + return parsed.success ? parsed.data : null; + } 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 badRequest("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(or( + eq(activityLog.responsibleUserId, filters.responsibleUserId), + and( + isNull(activityLog.responsibleUserId), + eq(heartbeatRuns.responsibleUserId, 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) { + conditions.push(or( + sql`${activityLog.createdAt} < ${cursor.createdAt}::timestamptz`, + and( + sql`${activityLog.createdAt} = ${cursor.createdAt}::timestamptz`, + 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, + cursorCreatedAt: sql`to_char(${activityLog.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`.as("cursor_created_at"), + }).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 commentEntityIds = [...new Set(page + .filter((row) => row.entityType === "issue_comment") + .map((row) => row.entityId))]; + const issueEntityIds = [...new Set(page + .filter((row) => row.entityType === "issue") + .map((row) => row.entityId))]; + const documentEntityIds = [...new Set(page + .filter((row) => row.entityType === "issue_document") + .map((row) => row.entityId))]; + const commentRows = commentEntityIds.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), + visibleIssueCondition(), + )).where(and( + eq(issueComments.companyId, filters.companyId), + inArray(issueComments.id, commentEntityIds), + )); + const issueRows = issueEntityIds.length === 0 ? [] : await db.select({ + id: issues.id, identifier: issues.identifier, title: issues.title, + }).from(issues).where(and( + eq(issues.companyId, filters.companyId), + visibleIssueCondition(), + inArray(issues.id, issueEntityIds), + )); + const documentRows = documentEntityIds.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), + visibleIssueCondition(), + )).where(and( + eq(issueDocuments.companyId, filters.companyId), + or( + inArray(issueDocuments.id, documentEntityIds), + inArray(issueDocuments.documentId, documentEntityIds), + ), + )); + + 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 redactDetails = await createActivityDetailsRedactor(db); + const items = page.map((row) => { + const comment = row.entityType === "issue_comment" ? comments.get(row.entityId) : undefined; + const issue = row.entityType === "issue" ? issueMap.get(row.entityId) : undefined; + const document = row.entityType === "issue_document" ? documents.get(row.entityId) : undefined; + 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; + const isIssueDerived = row.entityType === "issue" + || row.entityType === "issue_comment" + || row.entityType === "issue_document"; + return { + id: row.id, + companyId: row.companyId, + actorType: row.actorType, + actorId: row.actorId, + action: row.action, + entityType: row.entityType, + entityId: row.entityId, + agentId: row.agentId, + runId: row.runId, + responsibleUserId: row.responsibleUserId, + createdAt: row.createdAt, + details: isIssueDerived && !issueSnippet ? null : redactDetails(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.cursorCreatedAt, id: last.id }) + : null, + }; + }, + }; +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 981a0ebada..bf61c83db9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -40,6 +40,7 @@ import { Approvals } from "./pages/Approvals"; import { ApprovalDetail } from "./pages/ApprovalDetail"; import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; +import { CompanyAudit } from "./pages/audit/CompanyAudit"; import { Inbox } from "./pages/Inbox"; import { WhatNeedsMe } from "./pages/WhatNeedsMe"; import { TrainingInspector, TrainingLibrary } from "./pages/Training"; @@ -258,6 +259,7 @@ function boardRoutes() { } /> } /> } /> + } /> {/* Conference Room Chat surfaces (PAP-136/PAP-137): routes stay registered but redirect to the company home while the experimental flag is off. The board-level `artifacts` mount below is the new diff --git a/ui/src/api/audit.ts b/ui/src/api/audit.ts new file mode 100644 index 0000000000..4a80440284 --- /dev/null +++ b/ui/src/api/audit.ts @@ -0,0 +1,112 @@ +import { api } from "./client"; + +/** + * Agent audit API client. + * + * Consumes the unified read API shipped by Phase 2c + * (`server/src/routes/activity.ts` → `services/agent-action-audit.ts`): + * GET /companies/:companyId/audit/agent-actions + * Gated server-side by the `audit:view_agent_actions` board permission — the + * client renders an upsell/permission-denied state when the request 403s + * (see `ui/src/pages/CompanyAudit.tsx`). CSV export streams from the sibling + * `.csv` endpoint, which logs the export action itself. + */ + +/** Render-ready entity snippets attached to each row at read time (no N+1). */ +export interface AuditEntitySnippet { + issue: { id: string; identifier: string | null; title: string | null } | null; + comment: { id: string; excerpt: string } | null; + document: { id: string; key: string } | null; +} + +/** One enriched `activity_log` row from the agent-action audit feed. */ +export interface AuditActionRecord { + id: string; + companyId: string; + actorType: "agent" | "user" | "system" | "plugin" | null; + actorId: string | null; + action: string; + entityType: string; + entityId: string; + agentId: string | null; + runId: string | null; + responsibleUserId: string | null; + details: Record | null; + createdAt: string; + entity: AuditEntitySnippet; +} + +export interface AuditActionsResponse { + items: AuditActionRecord[]; + nextCursor: string | null; +} + +/** Server-side filters for the audit feed. All optional. */ +export interface AuditActionFilters { + agentId?: string | null; + responsibleUserId?: string | null; + runId?: string | null; + entityType?: string | null; + entityId?: string | null; + /** Action-domain prefix, e.g. `issue.` or `issue.comment_added`. */ + action?: string | null; + /** ISO-8601 with offset. */ + from?: string | null; + to?: string | null; + actorType?: "agent" | "user" | "system" | "plugin" | null; + cursor?: string | null; + limit?: number; +} + +function buildAuditQuery(filters: AuditActionFilters): URLSearchParams { + const search = new URLSearchParams(); + if (filters.agentId) search.set("agentId", filters.agentId); + if (filters.responsibleUserId) search.set("responsibleUserId", filters.responsibleUserId); + if (filters.runId) search.set("runId", filters.runId); + if (filters.entityType) search.set("entityType", filters.entityType); + if (filters.entityId) search.set("entityId", filters.entityId); + if (filters.action) search.set("action", filters.action); + if (filters.from) search.set("from", filters.from); + if (filters.to) search.set("to", filters.to); + if (filters.actorType) search.set("actorType", filters.actorType); + if (filters.cursor) search.set("cursor", filters.cursor); + if (filters.limit != null) search.set("limit", String(filters.limit)); + return search; +} + +export const auditApi = { + /** + * Cursor-paginated agent-action feed. Returns `{ items, nextCursor }`. + * Throws `ApiError` with status 403 when the caller lacks + * `audit:view_agent_actions`. + */ + listAgentActions: (companyId: string, filters: AuditActionFilters = {}) => { + const search = buildAuditQuery(filters); + const qs = search.toString(); + return api.get( + `/companies/${companyId}/audit/agent-actions${qs ? `?${qs}` : ""}`, + ); + }, + + /** + * Fetch the filtered feed as a CSV blob. The server logs an `audit.exported` + * activity row for the export itself (training-data export precedent). + */ + exportAgentActionsCsv: async ( + companyId: string, + filters: Omit = {}, + ): Promise => { + const search = buildAuditQuery(filters); + const qs = search.toString(); + const res = await fetch( + `/api/companies/${companyId}/audit/agent-actions.csv${qs ? `?${qs}` : ""}`, + { credentials: "include", headers: { Accept: "text/csv" } }, + ); + if (!res.ok) { + const body = await res.json().catch(() => null); + const message = (body as { error?: string } | null)?.error ?? `Export failed: ${res.status}`; + throw new Error(message); + } + return res.blob(); + }, +}; diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index c71bb5a71e..910211d3cf 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import { AppWindow, MessagesSquare, GanttChartSquare, + ScrollText, LayoutGrid, } from "lucide-react"; import { useState } from "react"; @@ -283,6 +284,7 @@ export function Sidebar() { + diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index f0fee3dbda..97545ec48a 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -82,6 +82,7 @@ const ACTIVITY_ROW_VERBS: Record = { "company.archived": "archived", "company.reactivated": "reactivated", "company.budget_updated": "updated budget for", + "audit.exported": "exported the agent audit log for", }; const ISSUE_ACTIVITY_LABELS: Record = { diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 149ce2c6b9..326e33fffa 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -52,6 +52,34 @@ export const queryKeys = { filters.search ?? "", ] as const, }, + audit: { + agentActions: ( + companyId: string, + filters: { + agentId?: string | null; + responsibleUserId?: string | null; + runId?: string | null; + entityType?: string | null; + action?: string | null; + from?: string | null; + to?: string | null; + actorType?: string | null; + }, + ) => + [ + "audit", + companyId, + "agent-actions", + filters.agentId ?? "__all", + filters.responsibleUserId ?? "__all", + filters.runId ?? "__all", + filters.entityType ?? "__all", + filters.action ?? "__all", + filters.actorType ?? "__all", + filters.from ?? "", + filters.to ?? "", + ] as const, + }, smokeLab: { services: (companyId: string) => ["smoke-lab", companyId, "services"] as const, runs: (companyId: string) => ["smoke-lab", companyId, "runs"] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 1cee14ad96..53cc43fdc9 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -42,6 +42,7 @@ import { EntityRow } from "../components/EntityRow"; import { MembershipAction } from "../components/MembershipAction"; import { StarToggle } from "../components/StarToggle"; import { Identity } from "../components/Identity"; +import { AuditFeed } from "./audit/AuditFeed"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentActionButtons } from "../components/AgentActionButtons"; import { InlineBanner } from "../components/InlineBanner"; @@ -271,7 +272,7 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh container.scrollTo({ top: container.scrollHeight, behavior }); } -type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "tools" | "runs" | "budget"; +type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "tools" | "runs" | "audit" | "budget"; function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "instructions" || value === "prompts") return "instructions"; @@ -279,6 +280,7 @@ function parseAgentDetailView(value: string | null): AgentDetailView { if (value === "skills") return "skills"; if (value === "tools") return "tools"; if (value === "budget") return "budget"; + if (value === "audit") return "audit"; if (value === "runs") return value; return "dashboard"; } @@ -898,8 +900,10 @@ export function AgentDetail() { ? "tools" : activeView === "runs" ? "runs" - : activeView === "budget" - ? "budget" + : activeView === "audit" + ? "audit" + : activeView === "budget" + ? "budget" : "dashboard"; if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true }); @@ -1251,6 +1255,7 @@ export function AgentDetail() { { value: "configuration", label: "Configuration" }, { value: "tools", label: "Tools" }, { value: "runs", label: "Runs" }, + { value: "audit", label: "Audit" }, { value: "budget", label: "Budget" }, ]} value={activeView} @@ -1383,6 +1388,10 @@ export function AgentDetail() { /> )} + {activeView === "audit" && resolvedCompanyId ? ( + + ) : null} + {activeView === "budget" && resolvedCompanyId ? (
vi.fn()); +const exportCsvMock = vi.hoisted(() => vi.fn()); +const listAgentsMock = vi.hoisted(() => vi.fn()); +const listUserDirectoryMock = vi.hoisted(() => vi.fn()); +const pushToastMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/audit", () => ({ + auditApi: { + listAgentActions: (companyId: string, filters: unknown) => listAgentActionsMock(companyId, filters), + exportAgentActionsCsv: (companyId: string, filters: unknown) => exportCsvMock(companyId, filters), + }, +})); + +vi.mock("@/api/agents", () => ({ + agentsApi: { list: (companyId: string) => listAgentsMock(companyId) }, +})); + +vi.mock("@/api/access", () => ({ + accessApi: { listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId) }, +})); + +vi.mock("@/context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: pushToastMock }), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => ( + + {children} + + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +async function flushReact() { + for (let i = 0; i < 3; i += 1) { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); + } +} + +function record(overrides: Record = {}) { + return { + id: "evt-1", + companyId: "company-1", + actorType: "agent", + actorId: "agent-1", + action: "issue.comment_added", + entityType: "issue", + entityId: "issue-1", + agentId: "agent-1", + runId: "run-1", + responsibleUserId: "user-1", + details: { commentId: "c1" }, + createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + entity: { + issue: { id: "issue-1", identifier: "PAP-1", title: "Ship the audit UI" }, + comment: { id: "c1", excerpt: "Looks good to me" }, + document: null, + }, + ...overrides, + }; +} + +describe("AuditFeed", () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null }); + listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "Fable", icon: null }]); + listUserDirectoryMock.mockResolvedValue({ + users: [{ principalId: "user-1", status: "active", user: { id: "user-1", name: "Dotta", email: null, image: null } }], + }); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + async function render(props: { companyId?: string; lockedAgentId?: string } = {}) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + } + + function clickButton(text: string) { + const btn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes(text)); + expect(btn, `button "${text}"`).toBeTruthy(); + return act(async () => { + btn!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } + + it("renders the humanized sentence, the task link, the excerpt, and the on-behalf chip", async () => { + await render(); + + expect(container.textContent).toContain("Fable"); + expect(container.textContent).toContain("commented on"); + const taskLink = container.querySelector('a[href="/issues/PAP-1"]'); + expect(taskLink?.textContent).toContain("PAP-1"); + expect(container.textContent).toContain("Looks good to me"); + expect(container.textContent).toContain("on behalf of Dotta"); + expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).toBeTruthy(); + expect(container.textContent).toContain("Recorded by Paperclip"); + }); + + it("shows the permission-denied upsell when the feed 403s", async () => { + listAgentActionsMock.mockRejectedValue( + new ApiError("Missing permission: audit:view_agent_actions", 403, { error: "Missing permission" }), + ); + await render(); + + expect(container.textContent).toContain("Paperclip Enterprise view"); + expect(container.textContent).toContain("audit:view_agent_actions"); + // The feed chrome (filters, footer) is not rendered in the denied state. + expect(container.textContent).not.toContain("Recorded by Paperclip"); + }); + + it("hides the agent filter and pins the query when lockedAgentId is set", async () => { + await render({ lockedAgentId: "agent-1" }); + + const [, filters] = listAgentActionsMock.mock.calls[0]; + expect((filters as { agentId?: string }).agentId).toBe("agent-1"); + // No "All agents" option means the agent filter is hidden on the per-agent tab. + expect(container.textContent).not.toContain("All agents"); + }); + + it("only offers action domains present in the agent-action feed", async () => { + await render(); + + await clickButton("All actions"); + expect(document.body.textContent).toContain("Tasks"); + expect(document.body.textContent).not.toContain("Audit exports"); + }); + + it("loads more when a cursor is returned", async () => { + listAgentActionsMock.mockImplementation((_companyId: string, filters: { cursor?: string }) => { + if (filters.cursor === "cursor-2") { + return Promise.resolve({ items: [record({ id: "evt-2", entity: { issue: { id: "i2", identifier: "PAP-2", title: "Second" }, comment: null, document: null } })], nextCursor: null }); + } + return Promise.resolve({ items: [record()], nextCursor: "cursor-2" }); + }); + await render(); + + expect(container.querySelector('a[href="/issues/PAP-2"]')).toBeFalsy(); + await clickButton("Load more"); + await flushReact(); + expect(container.querySelector('a[href="/issues/PAP-2"]')).toBeTruthy(); + }); + + it("exports CSV and toasts on success", async () => { + exportCsvMock.mockResolvedValue(new Blob(["csv"], { type: "text/csv" })); + // jsdom lacks URL.createObjectURL; stub it for the download path. + const createUrl = vi.fn(() => "blob:mock"); + const revokeUrl = vi.fn(); + (URL as unknown as { createObjectURL: unknown }).createObjectURL = createUrl; + (URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeUrl; + await render(); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + + await clickButton("Export CSV"); + await flushReact(); + + expect(exportCsvMock).toHaveBeenCalledWith("company-1", expect.any(Object)); + expect(createUrl).toHaveBeenCalled(); + expect(revokeUrl).not.toHaveBeenCalled(); + const deferredRevoke = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 5_000)?.[0]; + expect(deferredRevoke).toEqual(expect.any(Function)); + deferredRevoke!(); + expect(revokeUrl).toHaveBeenCalledWith("blob:mock"); + expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "success" })); + }); +}); diff --git a/ui/src/pages/audit/AuditFeed.tsx b/ui/src/pages/audit/AuditFeed.tsx new file mode 100644 index 0000000000..e128e7b49a --- /dev/null +++ b/ui/src/pages/audit/AuditFeed.tsx @@ -0,0 +1,516 @@ +import { useMemo, useState } from "react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Download, ScrollText, ShieldAlert } from "lucide-react"; +import type { Agent } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Identity } from "@/components/Identity"; +import { AgentIcon } from "@/components/AgentIconPicker"; +import { cn, relativeTime } from "@/lib/utils"; +import { queryKeys } from "@/lib/queryKeys"; +import { formatActivityVerb } from "@/lib/activity-format"; +import { buildCompanyUserProfileMap, type CompanyUserProfile } from "@/lib/company-members"; +import { auditApi, type AuditActionRecord, type AuditActionFilters } from "@/api/audit"; +import { agentsApi } from "@/api/agents"; +import { accessApi } from "@/api/access"; +import { ApiError } from "@/api/client"; +import { useToastActions } from "@/context/ToastContext"; + +const PAGE_SIZE = 50; +const ALL = "__all"; + +/** Action-domain prefixes offered in the filter (server does a prefix match). */ +const ACTION_DOMAINS: { value: string; label: string }[] = [ + { value: ALL, label: "All actions" }, + { value: "issue.", label: "Tasks" }, + { value: "agent.", label: "Agents" }, + { value: "heartbeat.", label: "Runs" }, + { value: "approval.", label: "Approvals" }, + { value: "project.", label: "Projects" }, + { value: "goal.", label: "Goals" }, + { value: "tool_gateway.", label: "Tools" }, + { value: "cost.", label: "Costs" }, + { value: "company.", label: "Company" }, +]; + +/** Entity types offered in the filter (server does an exact match). */ +const ENTITY_TYPES: { value: string; label: string }[] = [ + { value: ALL, label: "All entities" }, + { value: "issue", label: "Task" }, + { value: "agent", label: "Agent" }, + { value: "project", label: "Project" }, + { value: "goal", label: "Goal" }, + { value: "company", label: "Company" }, +]; + +export interface AuditFeedProps { + companyId: string; + /** + * When set, the feed is pinned to a single agent (per-agent Audit tab) — the + * agent filter is hidden and every query/export carries this agentId. + */ + lockedAgentId?: string; + /** Hide the section header/description (the AgentDetail tab supplies its own chrome). */ + hideHeader?: boolean; +} + +function toStartIso(value: string): string | undefined { + if (!value) return undefined; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function toEndIso(value: string): string | undefined { + if (!value) return undefined; + const date = new Date(`${value}T23:59:59.999Z`); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +/** Actor avatar + name — agents render their icon glyph, humans their avatar. */ +function AuditActor({ + record, + agentMap, + userProfileMap, +}: { + record: AuditActionRecord; + agentMap: Map; + userProfileMap: Map; +}) { + const agent = record.agentId ? agentMap.get(record.agentId) : null; + if (agent) { + return ( + + + + + {agent.name} + + ); + } + if (record.actorType === "user" && record.actorId) { + const profile = userProfileMap.get(record.actorId); + return ( + + ); + } + const label = record.actorType === "plugin" ? "Plugin" : "System"; + return ; +} + +/** + * The clickable entity node inside the humanized sentence. The verb from + * `formatActivityVerb` already encodes the relationship ("commented on", + * "created document for", …) and expects the issue reference to follow it, so + * this renders the task link (or a document/plain fallback) — never a phrase + * that would duplicate the verb. + */ +function AuditEntityNode({ record }: { record: AuditActionRecord }) { + const { issue, document } = record.entity; + const issueRef = issue?.identifier ?? issue?.id ?? null; + + if (issueRef) { + return ( + + {issue?.identifier ? `${issue.identifier}${issue.title ? ` · ${issue.title}` : ""}` : "the task"} + + ); + } + if (document) { + return {document.key}; + } + // Non-linkable entities (company, agent, goal, …) — show a plain descriptor. + return {record.entityType}; +} + +function AuditRow({ + record, + agentMap, + userProfileMap, +}: { + record: AuditActionRecord; + agentMap: Map; + userProfileMap: Map; +}) { + const verb = formatActivityVerb(record.action, record.details, { agentMap, userProfileMap }); + const responsible = record.responsibleUserId ? userProfileMap.get(record.responsibleUserId) : null; + // Suppress the "on behalf of" chip when the human actor *is* the responsible user. + const showOnBehalf = Boolean( + record.responsibleUserId + && !(record.actorType === "user" && record.actorId === record.responsibleUserId), + ); + const responsibleLabel = responsible?.label ?? (record.responsibleUserId ? "a user" : null); + const excerpt = record.entity.comment?.excerpt?.trim(); + // Show the document key only when it isn't already the linked entity node. + const documentKey = record.entity.issue && record.entity.document ? record.entity.document.key : null; + + return ( +
  • +
    +
    +
    + + {verb} + +
    + {excerpt ? ( +

    + “{excerpt}” +

    + ) : null} + {documentKey ? ( +

    + Document {documentKey} +

    + ) : null} +
    + {showOnBehalf && responsibleLabel ? ( + + on behalf of {responsibleLabel} + + ) : null} + {record.runId && record.agentId ? ( + + View run + + ) : null} + {record.action} +
    +
    + +
    +
  • + ); +} + +/** The permission-denied / upsell state shown when the caller lacks the grant. */ +function AuditUpsell() { + return ( + + + +
    +

    Agent audit is a Paperclip Enterprise view

    +

    + The agent audit log gives you a searchable, exportable record of everything your agents + did — every comment, task change, approval, and run — with the responsible person for + each action. Ask an administrator to grant you the{" "} + audit:view_agent_actions{" "} + permission to view it. +

    +
    +
    +
    + ); +} + +export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedProps) { + const { pushToast } = useToastActions(); + const [agent, setAgent] = useState(ALL); + const [responsibleUser, setResponsibleUser] = useState(ALL); + const [actionDomain, setActionDomain] = useState(ALL); + const [entityType, setEntityType] = useState(ALL); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [exporting, setExporting] = useState(false); + + const agents = useQuery({ + queryKey: queryKeys.agents.list(companyId), + queryFn: () => agentsApi.list(companyId), + }); + const userDirectory = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(companyId), + queryFn: () => accessApi.listUserDirectory(companyId), + retry: false, + }); + + const agentMap = useMemo( + () => new Map((agents.data ?? []).map((a) => [a.id, a])), + [agents.data], + ); + const userProfileMap = useMemo( + () => buildCompanyUserProfileMap(userDirectory.data?.users), + [userDirectory.data], + ); + + const filters: AuditActionFilters = { + agentId: lockedAgentId ?? (agent === ALL ? undefined : agent), + responsibleUserId: responsibleUser === ALL ? undefined : responsibleUser, + action: actionDomain === ALL ? undefined : actionDomain, + entityType: entityType === ALL ? undefined : entityType, + from: toStartIso(dateFrom), + to: toEndIso(dateTo), + }; + + const hasActiveFilters = Boolean( + (!lockedAgentId && agent !== ALL) + || responsibleUser !== ALL + || actionDomain !== ALL + || entityType !== ALL + || dateFrom + || dateTo, + ); + + const feed = useInfiniteQuery({ + queryKey: queryKeys.audit.agentActions(companyId, { + agentId: filters.agentId, + responsibleUserId: filters.responsibleUserId, + action: filters.action, + entityType: filters.entityType, + from: filters.from, + to: filters.to, + }), + queryFn: ({ pageParam }) => + auditApi.listAgentActions(companyId, { ...filters, limit: PAGE_SIZE, cursor: pageParam ?? undefined }), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + retry: (count, error) => !(error instanceof ApiError && error.status === 403) && count < 2, + }); + + const items = useMemo( + () => feed.data?.pages.flatMap((page) => page.items) ?? [], + [feed.data], + ); + + const permissionDenied = feed.error instanceof ApiError && feed.error.status === 403; + + const clearFilters = () => { + setAgent(ALL); + setResponsibleUser(ALL); + setActionDomain(ALL); + setEntityType(ALL); + setDateFrom(""); + setDateTo(""); + }; + + const handleExport = async () => { + setExporting(true); + try { + const blob = await auditApi.exportAgentActionsCsv(companyId, { + agentId: filters.agentId, + responsibleUserId: filters.responsibleUserId, + action: filters.action, + entityType: filters.entityType, + from: filters.from, + to: filters.to, + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `agent-audit-${companyId}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + // Browsers may read blob URLs lazily after click(), so keep the URL alive + // long enough for the download to start. + window.setTimeout(() => URL.revokeObjectURL(url), 5_000); + pushToast({ title: "Audit exported", body: "Your CSV download has started.", tone: "success" }); + } catch (error) { + pushToast({ + title: "Export failed", + body: error instanceof Error ? error.message : "Could not export the audit log.", + tone: "error", + }); + } finally { + setExporting(false); + } + }; + + if (permissionDenied) { + return ; + } + + return ( +
    + {!hideHeader ? ( +
    +
    +

    Audit

    +

    + Everything your agents did, newest first — each line is one recorded action, with the + person responsible for it. Click through to the task or run for the full context. +

    +
    +
    + ) : null} + +
    + {!lockedAgentId ? ( + + ) : null} + + + + setDateFrom(e.target.value)} + className="w-36" + /> + setDateTo(e.target.value)} + className="w-36" + /> + {hasActiveFilters ? ( + + ) : null} + +
    + + {feed.isLoading ? ( + + Loading… + + ) : feed.error ? ( + + +

    + {feed.error instanceof Error ? feed.error.message : "Failed to load the audit log."} +

    + +
    +
    + ) : items.length === 0 ? ( + + + +
    +

    + {hasActiveFilters ? "No actions match these filters" : "Nothing here yet"} +

    +

    + {hasActiveFilters + ? "Try a wider date range or different filters." + : "As soon as your agents start doing things, their actions show up here."} +

    +
    + {hasActiveFilters ? ( + + ) : null} +
    +
    + ) : ( + + +
      + {items.map((record) => ( + + ))} +
    +
    +
    + )} + + {feed.hasNextPage ? ( +
    + +
    + ) : null} + +

    + Recorded by Paperclip — entries can't be edited. Sensitive values are never stored. +

    +
    + ); +} diff --git a/ui/src/pages/audit/CompanyAudit.tsx b/ui/src/pages/audit/CompanyAudit.tsx new file mode 100644 index 0000000000..06ae873496 --- /dev/null +++ b/ui/src/pages/audit/CompanyAudit.tsx @@ -0,0 +1,27 @@ +import { useEffect } from "react"; +import { ShieldCheck } from "lucide-react"; +import { useCompany } from "../../context/CompanyContext"; +import { useBreadcrumbs } from "../../context/BreadcrumbContext"; +import { EmptyState } from "../../components/EmptyState"; +import { AuditFeed } from "./AuditFeed"; + +/** + * Company-level agent audit page — a permission-gated + * rich view in the unified codebase, matching the `tools:view_audit` precedent. + * The feed itself renders the upsell/permission-denied state when the caller + * lacks `audit:view_agent_actions` (server-authoritative, see `AuditFeed`). + */ +export function CompanyAudit() { + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + + useEffect(() => { + setBreadcrumbs([{ label: "Audit" }]); + }, [setBreadcrumbs]); + + if (!selectedCompanyId) { + return ; + } + + return ; +}