fix(server): prevent audit enrichment disclosure

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-17 02:21:24 +00:00
1 parent a1237f9560
commit cd96a89250
3 files changed
+104 -24

No files matched your search

@@ -135,6 +135,12 @@ describePostgres("agent action audit routes", () => {
})).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");
});
it("paginates, filters, enriches entities, and falls back to the run responsible user", async () => {
@@ -169,6 +175,61 @@ describePostgres("agent action audit routes", () => {
}
});
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, mismatchedActivity] = await db.insert(activityLog).values([
{
companyId: company.id,
actorType: "agent",
actorId: agent.id,
action: "issue.comment.created",
entityType: "issue_comment",
entityId: hiddenComment.id,
agentId: agent.id,
runId: run.id,
},
{
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 === mismatchedActivity.id)?.entity).toEqual({
issue: null,
comment: null,
document: null,
});
});
it("allows a signed-in board user with the explicit permission", async () => {
const { company } = await seed();
await db.insert(companyMemberships).values({
+1 -9
View File
@@ -120,15 +120,7 @@ export function activityRoutes(db: Db) {
if (!parsedQuery.success) {
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
}
try {
res.json(await agentAudit.list({ companyId, ...parsedQuery.data }));
} catch (error) {
if (error instanceof Error && error.message === "Invalid audit cursor") {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
res.json(await agentAudit.list({ companyId, ...parsedQuery.data }));
});
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
+42 -15
View File
@@ -2,6 +2,8 @@ import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from
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;
@@ -45,7 +47,7 @@ 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");
if (filters.cursor && !cursor) throw badRequest("Invalid audit cursor");
const effectiveResponsibleUserId = sql<string | null>`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));
@@ -90,21 +92,46 @@ export function agentActionAuditService(db: Db) {
)).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({
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)))
.where(and(eq(issueComments.companyId, filters.companyId), inArray(issueComments.id, entityIds)));
const issueRows = entityIds.length === 0 ? [] : await db.select({
}).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), inArray(issues.id, entityIds)));
const documentRows = entityIds.length === 0 ? [] : await db.select({
}).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)))
.where(and(eq(issueDocuments.companyId, filters.companyId), or(
inArray(issueDocuments.id, entityIds), inArray(issueDocuments.documentId, entityIds),
)));
}).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]));
@@ -116,9 +143,9 @@ export function agentActionAuditService(db: Db) {
const redactDetails = await createActivityDetailsRedactor(db);
const items = page.map((row) => {
const comment = comments.get(row.entityId);
const issue = issueMap.get(row.entityId);
const document = documents.get(row.entityId);
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