feat(activity): add two-tier all-actors audit feed (#10831)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators need one activity feed for human, agent, plugin, and
system changes
> - The existing audit endpoint returns only rows that have agent
attribution
> - The full audit view also requires a dedicated permission
> - This pull request adds an explicit all-actors scope with basic and
privileged access tiers
> - The benefit is that company members can inspect the shared activity
history while sensitive attribution and export controls stay protected

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The company audit activity endpoint and the board audit route.

**Subsystem affected**

Server REST API and board UI routing/API contracts.

**Current behavior**

The agent-action audit endpoint excludes activity without an agent ID.
It also rejects company members who do not have the full audit
permission.

**Proposed behavior**

Callers can opt into `actorScope=all`. A company member receives all
actor kinds with sensitive attribution fields removed. A permitted board
user receives complete rows and can use attribution filters. The default
scope and CSV permission remain unchanged.

**Reason and benefit**

The board needs one chronological activity source for user, agent,
plugin, and system actions. A two-tier response keeps the feed useful
without widening access to detailed attribution or export capabilities.

**Breaking changes**

None. The endpoint keeps the existing agent-only scope and permission
behavior by default.

## What Changed

- Added `actorScope=all` to the unified audit query and included
activity from every actor type.
- Added a company-readable basic tier that removes run,
responsible-user, agent, and details attribution.
- Kept attribution filters and CSV export behind
`audit:view_agent_actions`.
- Added route and integration coverage for basic readers, permitted
readers, pagination, filter denial, and all actor kinds.
- Added the missing unprefixed `/audit` redirect and company route
classification.

## Verification

- `pnpm exec vitest run server/src/__tests__/activity-routes.test.ts
server/src/__tests__/agent-action-audit-routes.test.ts
ui/src/lib/company-routes.test.ts --reporter=verbose` (35 tests passed)
- `pnpm -r typecheck`
- `pnpm test:run`
- `pnpm build`

## Risks

- The all-actors query can return more rows than the legacy agent-only
query. Cursor pagination and existing limits bound each request.
- The basic tier intentionally exposes action and actor-kind context. It
removes detailed run, agent, responsible-user, and details attribution.
- The legacy endpoint behavior remains the default, which reduces
compatibility risk.

> The roadmap marks activity log and action attribution as shipped. This
change improves that existing capability and does not introduce a
separate workflow system.

## Model Used

- OpenAI Codex, `gpt-5.6-sol`, 114K context, agentic reasoning with tool
use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-04 23:07:38 -05:00 committed by GitHub
parent f0ed524ffa
commit 68ddd6a7a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 601 additions and 104 deletions

View File

@ -21,6 +21,11 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
canUser: vi.fn(),
}));
const mockAgentActionAuditService = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("../services/activity.js", () => ({
@ -37,6 +42,10 @@ vi.mock("../services/index.js", () => ({
heartbeatService: () => mockHeartbeatService,
}));
vi.mock("../services/agent-action-audit.js", () => ({
agentActionAuditService: () => mockAgentActionAuditService,
}));
async function createApp(
actor: Record<string, unknown> = {
type: "board",
@ -98,12 +107,103 @@ describe.sequential("activity routes", () => {
for (const mock of Object.values(mockHeartbeatService)) mock.mockReset();
for (const mock of Object.values(mockIssueService)) mock.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.canUser.mockReset();
mockAgentActionAuditService.list.mockReset();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockAccessService.canUser.mockResolvedValue(false);
});
it("returns redacted all-actors rows to a basic company reader", async () => {
mockAgentActionAuditService.list.mockResolvedValue({
items: [{
id: "activity-1",
companyId: "company-1",
actorType: "plugin",
actorId: "plugin-1",
action: "plugin.synced",
entityType: "company",
entityId: "company-1",
agentId: "agent-1",
runId: "run-1",
responsibleUserId: "user-2",
details: { privateAttribution: true },
createdAt: "2026-08-04T00:00:00.000Z",
entity: { issue: null, comment: null, document: null },
}],
nextCursor: null,
});
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/audit/agent-actions?actorScope=all");
expect(res.status).toBe(200);
expect(mockAgentActionAuditService.list).toHaveBeenCalledWith({
companyId: "company-1",
actorScope: "all",
limit: 50,
});
expect(res.body.items[0]).toMatchObject({
actorType: "plugin",
actorId: "plugin-1",
action: "plugin.synced",
entityType: "company",
entityId: "company-1",
createdAt: "2026-08-04T00:00:00.000Z",
agentId: null,
runId: null,
responsibleUserId: null,
details: null,
});
expect(res.body.accessTier).toBe("basic");
});
it("rejects attribution filters for a basic all-actors reader", async () => {
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/audit/agent-actions?actorScope=all&runId=00000000-0000-4000-8000-000000000001");
expect(res.status).toBe(403);
expect(res.body.error).toContain("audit:view_agent_actions");
expect(mockAgentActionAuditService.list).not.toHaveBeenCalled();
});
it("keeps attribution for a permitted all-actors reader", async () => {
mockAccessService.canUser.mockResolvedValue(true);
mockAgentActionAuditService.list.mockResolvedValue({
items: [{
id: "activity-1",
agentId: "agent-1",
runId: "run-1",
responsibleUserId: "user-2",
details: { attribution: true },
}],
nextCursor: null,
});
const app = await createApp();
const res = await request(app)
.get("/api/companies/company-1/audit/agent-actions?actorScope=all&actorType=system");
expect(res.status).toBe(200);
expect(mockAgentActionAuditService.list).toHaveBeenCalledWith({
companyId: "company-1",
actorScope: "all",
actorType: "system",
limit: 50,
});
expect(res.body.items[0]).toMatchObject({
agentId: "agent-1",
runId: "run-1",
responsibleUserId: "user-2",
details: { attribution: true },
});
expect(res.body.accessTier).toBe("full");
});
it("limits company activity lists by default", async () => {

View File

@ -115,6 +115,42 @@ describePostgres("agent action audit routes", () => {
return { company, agent, otherAgent, issue, comment, issueDocument, run };
}
async function seedActorOnlyRows(companyId: string) {
return db.insert(activityLog).values([
{
companyId,
actorType: "user",
actorId: "board-user",
action: "company.updated",
entityType: "company",
entityId: companyId,
responsibleUserId: "sensitive-responsible-user",
details: { changed: "name" },
createdAt: new Date("2026-07-17T00:00:06.000Z"),
},
{
companyId,
actorType: "system",
actorId: "scheduler",
action: "heartbeat.scheduled",
entityType: "company",
entityId: companyId,
details: { schedule: "private-schedule" },
createdAt: new Date("2026-07-17T00:00:05.000Z"),
},
{
companyId,
actorType: "plugin",
actorId: "example-plugin",
action: "plugin.synced",
entityType: "company",
entityId: companyId,
details: { pluginConfig: "private-config" },
createdAt: new Date("2026-07-17T00:00:04.000Z"),
},
]).returning();
}
it("denies agents and board users without the audit permission", async () => {
const { company, agent } = await seed();
const agentResponse = await request(await createApp(db, {
@ -122,13 +158,146 @@ describePostgres("agent action audit routes", () => {
})).get(`/api/companies/${company.id}/audit/agent-actions`);
expect(agentResponse.status).toBe(403);
const agentAllActorsResponse = 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?actorScope=all`);
expect(agentAllActorsResponse.status, JSON.stringify(agentAllActorsResponse.body)).toBe(200);
expect(agentAllActorsResponse.body.items).toHaveLength(3);
expect(agentAllActorsResponse.body.items.every((item: { responsibleUserId: string | null }) => (
item.responsibleUserId === null
))).toBe(true);
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");
const invalidBoardResponse = await request(await createApp(db, {
type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false,
})).get(`/api/companies/${company.id}/audit/agent-actions?limit=invalid`);
expect(invalidBoardResponse.status).toBe(403);
expect(invalidBoardResponse.body.error).toContain("audit:view_agent_actions");
}, 30_000);
it("lets a company member read basic all-actor rows without attribution", async () => {
const { company } = await seed();
await seedActorOnlyRows(company.id);
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: "reader",
status: "active",
membershipRole: "viewer",
});
const app = await createApp(db, {
type: "board",
userId: "reader",
companyIds: [company.id],
source: "session",
isInstanceAdmin: false,
});
const items: Array<Record<string, unknown>> = [];
let cursor: string | null = null;
do {
const cursorQuery = cursor ? `&cursor=${encodeURIComponent(cursor)}` : "";
const response = await request(app)
.get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all&limit=2${cursorQuery}`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
items.push(...response.body.items);
expect(response.body.accessTier).toBe("basic");
cursor = response.body.nextCursor;
} while (cursor);
expect(items).toHaveLength(6);
expect(new Set(items.map((item) => item.actorType))).toEqual(
new Set(["agent", "user", "system", "plugin"]),
);
for (const item of items) {
expect(item).toMatchObject({
agentId: null,
runId: null,
responsibleUserId: null,
details: null,
});
expect(item).toEqual(expect.objectContaining({
actorType: expect.any(String),
actorId: expect.any(String),
action: expect.any(String),
entityType: expect.any(String),
entityId: expect.any(String),
createdAt: expect.any(String),
}));
}
});
it("rejects the full filter set for a basic all-actors reader", async () => {
const { company, agent, issue, run } = await seed();
await db.insert(companyMemberships).values({
companyId: company.id,
principalType: "user",
principalId: "reader",
status: "active",
membershipRole: "viewer",
});
const app = await createApp(db, {
type: "board",
userId: "reader",
companyIds: [company.id],
source: "session",
isInstanceAdmin: false,
});
const filters = [
`agentId=${agent.id}`,
"responsibleUserId=legacy-user",
`runId=${run.id}`,
"entityType=issue",
`entityId=${issue.id}`,
"action=issue.",
"actorType=agent",
"from=2026-07-17T00%3A00%3A00.000Z",
"to=2026-07-18T00%3A00%3A00.000Z",
];
for (const filter of filters) {
const response = await request(app).get(
`/api/companies/${company.id}/audit/agent-actions?actorScope=all&${filter}`,
);
expect(response.status, filter).toBe(403);
expect(response.body.error).toContain("audit:view_agent_actions");
}
});
it("keeps the default scope unchanged and gives permitted readers the full all-actors view", async () => {
const { company } = await seed();
const actorOnlyRows = await seedActorOnlyRows(company.id);
const app = await createApp(db, {
type: "board",
userId: "local-board",
companyIds: [company.id],
source: "local_implicit",
isInstanceAdmin: false,
});
const defaultResponse = await request(app)
.get(`/api/companies/${company.id}/audit/agent-actions`);
expect(defaultResponse.status, JSON.stringify(defaultResponse.body)).toBe(200);
expect(defaultResponse.body.items).toHaveLength(3);
expect(defaultResponse.body.items.map((item: { id: string }) => item.id)).not.toContain(actorOnlyRows[0]!.id);
const allResponse = await request(app)
.get(`/api/companies/${company.id}/audit/agent-actions?actorScope=all`);
expect(allResponse.status, JSON.stringify(allResponse.body)).toBe(200);
expect(defaultResponse.body.accessTier).toBe("full");
expect(allResponse.body.accessTier).toBe("full");
expect(allResponse.body.items).toHaveLength(6);
expect(allResponse.body.items.find((item: { id: string }) => item.id === actorOnlyRows[0]!.id)).toMatchObject({
responsibleUserId: "sensitive-responsible-user",
details: { changed: "name" },
});
});
it("returns a client error for invalid audit query parameters", async () => {
const { company } = await seed();
const response = await request(await createApp(db, {
@ -137,6 +306,12 @@ describePostgres("agent action audit routes", () => {
expect(response.status).toBe(400);
expect(response.body.error).toBe("Invalid agent action audit query");
const scopeResponse = 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?actorScope=unknown`);
expect(scopeResponse.status).toBe(400);
expect(scopeResponse.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`);

View File

@ -99,7 +99,10 @@ const createActivitySchema = z.object({
details: z.record(z.unknown()).optional().nullable(),
});
const agentActionAuditActorScopeSchema = z.enum(["agents", "all"]);
const agentActionAuditQuerySchema = z.object({
actorScope: agentActionAuditActorScopeSchema.default("agents"),
agentId: z.string().uuid().optional(),
responsibleUserId: z.string().min(1).optional(),
runId: z.string().uuid().optional(),
@ -121,14 +124,54 @@ export function activityRoutes(db: Db) {
const issueSvc = issueService(db);
const agentAudit = agentActionAuditService(db);
async function hasAgentAuditPermission(req: import("express").Request, companyId: string) {
if (req.actor.type !== "board") return false;
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
return Boolean(
req.actor.userId
&& await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions"),
);
}
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;
if (await hasAgentAuditPermission(req, companyId)) return;
throw forbidden("Missing permission: audit:view_agent_actions");
}
function hasAttributionFilters(query: z.infer<typeof agentActionAuditQuerySchema>) {
return query.agentId !== undefined
|| query.responsibleUserId !== undefined
|| query.runId !== undefined
|| query.entityType !== undefined
|| query.entityId !== undefined
|| query.action !== undefined
|| query.actorType !== undefined
|| query.from !== undefined
|| query.to !== undefined;
}
function stripAuditAttribution<T extends {
items: Array<{
agentId: string | null;
runId: string | null;
responsibleUserId: string | null;
details: unknown;
}>;
}>(result: T): T {
return {
...result,
items: result.items.map((item) => ({
...item,
agentId: null,
runId: null,
responsibleUserId: null,
details: null,
})),
};
}
async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
const decision = await access.decide({
actor: req.actor,
@ -194,12 +237,38 @@ export function activityRoutes(db: Db) {
router.get("/companies/:companyId/audit/agent-actions", async (req, res) => {
const companyId = req.params.companyId as string;
await assertAgentAuditPermission(req, companyId);
const parsedActorScope = agentActionAuditActorScopeSchema.safeParse(req.query.actorScope ?? "agents");
if (!parsedActorScope.success) {
throw badRequest("Invalid agent action audit query", parsedActorScope.error.issues);
}
if (parsedActorScope.data === "agents") {
// Keep the legacy authorization-before-validation behavior for callers
// that omit the new flag.
await assertAgentAuditPermission(req, companyId);
} else {
assertCompanyAccess(req, companyId);
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
}
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 }));
if (parsedQuery.data.actorScope === "agents") {
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
res.json({ ...result, accessTier: "full" });
return;
}
const canViewAttribution = await hasAgentAuditPermission(req, companyId);
if (!canViewAttribution && hasAttributionFilters(parsedQuery.data)) {
throw forbidden("Audit filters require permission: audit:view_agent_actions");
}
const result = await agentAudit.list({ companyId, ...parsedQuery.data });
res.json({
...(canViewAttribution ? result : stripAuditAttribution(result)),
accessTier: canViewAttribution ? "full" : "basic",
});
});
router.get("/companies/:companyId/audit/agent-actions.csv", async (req, res) => {
@ -238,6 +307,7 @@ export function activityRoutes(db: Db) {
rowCount: rows.length,
truncated: rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS && Boolean(cursor),
filters: {
actorScope: filters.actorScope,
agentId: filters.agentId ?? null,
responsibleUserId: filters.responsibleUserId ?? null,
runId: filters.runId ?? null,

View File

@ -8,6 +8,7 @@ import { visibleIssueCondition } from "./issue-visibility.js";
export interface AgentActionAuditFilters {
companyId: string;
actorScope?: "agents" | "all";
agentId?: string;
responsibleUserId?: string;
runId?: string;
@ -53,7 +54,10 @@ export function agentActionAuditService(db: Db) {
const cursor = decodeCursor(filters.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)];
const conditions = [eq(activityLog.companyId, filters.companyId)];
// Preserve the historical agent-audit query unless the caller opts into
// the unified all-actors feed explicitly.
if (filters.actorScope !== "all") conditions.push(isNotNull(activityLog.agentId));
if (filters.agentId) conditions.push(eq(activityLog.agentId, filters.agentId));
if (filters.responsibleUserId) conditions.push(or(
eq(activityLog.responsibleUserId, filters.responsibleUserId),

View File

@ -6,10 +6,11 @@ import { api } from "./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.
* The default agent scope is gated server-side by the
* `audit:view_agent_actions` board permission. The explicit all-actors scope
* also supports a company-read basic tier with attribution stripped. CSV
* export streams from the permission-gated sibling `.csv` endpoint, which
* logs the export action itself.
*/
/** Render-ready entity snippets attached to each row at read time (no N+1). */
@ -39,10 +40,14 @@ export interface AuditActionRecord {
export interface AuditActionsResponse {
items: AuditActionRecord[];
nextCursor: string | null;
/** Controls whether attribution filters and CSV export are available. */
accessTier: "basic" | "full";
}
/** Server-side filters for the audit feed. All optional. */
export interface AuditActionFilters {
/** Defaults to `agents`; `all` opts into the unified all-actors feed. */
actorScope?: "agents" | "all";
agentId?: string | null;
responsibleUserId?: string | null;
runId?: string | null;
@ -60,6 +65,7 @@ export interface AuditActionFilters {
function buildAuditQuery(filters: AuditActionFilters): URLSearchParams {
const search = new URLSearchParams();
if (filters.actorScope) search.set("actorScope", filters.actorScope);
if (filters.agentId) search.set("agentId", filters.agentId);
if (filters.responsibleUserId) search.set("responsibleUserId", filters.responsibleUserId);
if (filters.runId) search.set("runId", filters.runId);
@ -76,9 +82,9 @@ function buildAuditQuery(filters: AuditActionFilters): URLSearchParams {
export const auditApi = {
/**
* Cursor-paginated agent-action feed. Returns `{ items, nextCursor }`.
* Throws `ApiError` with status 403 when the caller lacks
* `audit:view_agent_actions`.
* Cursor-paginated activity/audit feed. The default agent scope throws a
* 403 without `audit:view_agent_actions`; `actorScope: "all"` returns a
* redacted basic tier to ordinary company readers.
*/
listAgentActions: (companyId: string, filters: AuditActionFilters = {}) => {
const search = buildAuditQuery(filters);

View File

@ -56,6 +56,7 @@ export const queryKeys = {
agentActions: (
companyId: string,
filters: {
actorScope?: "agents" | "all" | null;
agentId?: string | null;
responsibleUserId?: string | null;
runId?: string | null;
@ -70,6 +71,7 @@ export const queryKeys = {
"audit",
companyId,
"agent-actions",
filters.actorScope ?? "agents",
filters.agentId ?? "__all",
filters.responsibleUserId ?? "__all",
filters.runId ?? "__all",

View File

@ -91,7 +91,7 @@ describe("AuditFeed", () => {
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null });
listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null, accessTier: "full" });
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 } }],
@ -116,6 +116,7 @@ describe("AuditFeed", () => {
);
});
await flushReact();
return client;
}
function clickButton(text: string) {
@ -129,6 +130,10 @@ describe("AuditFeed", () => {
it("renders the humanized sentence, the task link, the excerpt, and the on-behalf chip", async () => {
await render();
expect(listAgentActionsMock).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ actorScope: "all" }),
);
expect(container.textContent).toContain("Fable");
expect(container.textContent).toContain("commented on");
const taskLink = container.querySelector('a[href="/issues/PAP-1"]');
@ -151,11 +156,104 @@ describe("AuditFeed", () => {
expect(container.textContent).not.toContain("Recorded by Paperclip");
});
it("hides attribution filters and export for a basic all-actors reader", async () => {
listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null, accessTier: "basic" });
await render();
expect(container.textContent).toContain("commented on");
expect(container.textContent).not.toContain("All agents");
expect(container.textContent).not.toContain("All responsible users");
expect(container.textContent).not.toContain("Export CSV");
});
it("clears privileged filters and recovers the basic feed after an access downgrade", async () => {
let permissionRevoked = false;
listAgentActionsMock.mockImplementation((_companyId: string, filters: { from?: string }) => {
if (permissionRevoked && filters.from) {
return Promise.reject(
new ApiError("Missing permission: audit:view_agent_actions", 403, { error: "Missing permission" }),
);
}
return Promise.resolve({
items: [record()],
nextCursor: null,
accessTier: permissionRevoked ? "basic" : "full",
});
});
await render();
const fromDate = container.querySelector<HTMLInputElement>('input[aria-label="From date"]');
expect(fromDate).toBeTruthy();
const setInputValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
expect(setInputValue).toBeTruthy();
await act(async () => {
permissionRevoked = true;
setInputValue!.call(fromDate, "2026-08-01");
fromDate!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
expect(listAgentActionsMock.mock.calls.some(([, filters]) => filters.from)).toBe(true);
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
expect.objectContaining({ actorScope: "all", from: undefined }),
);
expect(container.textContent).toContain("commented on");
expect(container.textContent).not.toContain("Paperclip Enterprise view");
expect(container.textContent).not.toContain("All agents");
expect(container.textContent).not.toContain("Export CSV");
});
it("drops cached privileged pages when pagination observes an access downgrade", async () => {
let permissionRevoked = false;
listAgentActionsMock.mockImplementation((_companyId: string, filters: { cursor?: string }) => {
if (filters.cursor === "cursor-2") {
return Promise.resolve({
items: [record({
id: "evt-2",
agentId: null,
runId: null,
responsibleUserId: null,
details: null,
})],
nextCursor: null,
accessTier: "basic",
});
}
if (permissionRevoked) {
return Promise.resolve({
items: [record({ agentId: null, runId: null, responsibleUserId: null, details: null })],
nextCursor: null,
accessTier: "basic",
});
}
return Promise.resolve({ items: [record()], nextCursor: "cursor-2", accessTier: "full" });
});
await render();
expect(container.textContent).toContain("on behalf of Dotta");
expect(container.textContent).toContain("Export CSV");
permissionRevoked = true;
await clickButton("Load more");
await flushReact();
expect(listAgentActionsMock.mock.calls.at(-1)?.[1]).toEqual(
expect.objectContaining({ actorScope: "all", cursor: undefined }),
);
expect(container.textContent).toContain("commented on");
expect(container.textContent).not.toContain("on behalf of Dotta");
expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).toBeFalsy();
expect(container.textContent).not.toContain("All agents");
expect(container.textContent).not.toContain("Export CSV");
});
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");
expect(filters).toEqual(expect.objectContaining({ actorScope: "agents", agentId: "agent-1" }));
// No "All agents" option means the agent filter is hidden on the per-agent tab.
expect(container.textContent).not.toContain("All agents");
});
@ -171,9 +269,9 @@ describe("AuditFeed", () => {
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({ id: "evt-2", entity: { issue: { id: "i2", identifier: "PAP-2", title: "Second" }, comment: null, document: null } })], nextCursor: null, accessTier: "full" });
}
return Promise.resolve({ items: [record()], nextCursor: "cursor-2" });
return Promise.resolve({ items: [record()], nextCursor: "cursor-2", accessTier: "full" });
});
await render();
@ -196,7 +294,10 @@ describe("AuditFeed", () => {
await clickButton("Export CSV");
await flushReact();
expect(exportCsvMock).toHaveBeenCalledWith("company-1", expect.any(Object));
expect(exportCsvMock).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({ actorScope: "all" }),
);
expect(createUrl).toHaveBeenCalled();
expect(revokeUrl).not.toHaveBeenCalled();
const deferredRevoke = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 5_000)?.[0];

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Download, ScrollText, ShieldAlert } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
@ -256,6 +256,10 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
);
const filters: AuditActionFilters = {
// The company feed is the shared all-actors view. The per-agent tab keeps
// the legacy privileged scope because it always carries an attribution
// filter and must not silently downgrade to the basic tier.
actorScope: lockedAgentId ? "agents" : "all",
agentId: lockedAgentId ?? (agent === ALL ? undefined : agent),
responsibleUserId: responsibleUser === ALL ? undefined : responsibleUser,
action: actionDomain === ALL ? undefined : actionDomain,
@ -275,6 +279,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
const feed = useInfiniteQuery({
queryKey: queryKeys.audit.agentActions(companyId, {
actorScope: filters.actorScope,
agentId: filters.agentId,
responsibleUserId: filters.responsibleUserId,
action: filters.action,
@ -295,6 +300,33 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
);
const permissionDenied = feed.error instanceof ApiError && feed.error.status === 403;
const hasBasicPage = feed.data?.pages.some((page) => page.accessTier === "basic") ?? false;
const hasFullPage = feed.data?.pages.some((page) => page.accessTier === "full") ?? false;
// Access may be revoked between cursor requests. Treat the least-privileged
// page as authoritative until every cached page has been fetched again.
const accessTier = hasBasicPage ? "basic" : feed.data?.pages[0]?.accessTier;
const hasMixedAccessTiers = hasBasicPage && hasFullPage;
const canUseAdvancedControls = lockedAgentId
? true
: accessTier === "full";
const recoveringFromAccessDowngrade = Boolean(
!lockedAgentId
&& ((permissionDenied && hasActiveFilters) || hasMixedAccessTiers),
);
useEffect(() => {
if (!lockedAgentId && (accessTier === "basic" || recoveringFromAccessDowngrade)) {
setAgent(ALL);
setResponsibleUser(ALL);
setActionDomain(ALL);
setEntityType(ALL);
setDateFrom("");
setDateTo("");
}
if (hasMixedAccessTiers) {
void feed.refetch();
}
}, [accessTier, feed, hasMixedAccessTiers, lockedAgentId, recoveringFromAccessDowngrade]);
const clearFilters = () => {
setAgent(ALL);
@ -309,6 +341,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
setExporting(true);
try {
const blob = await auditApi.exportAgentActionsCsv(companyId, {
actorScope: filters.actorScope,
agentId: filters.agentId,
responsibleUserId: filters.responsibleUserId,
action: filters.action,
@ -338,7 +371,7 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
}
};
if (permissionDenied) {
if (permissionDenied && !recoveringFromAccessDowngrade) {
return <AuditUpsell />;
}
@ -349,100 +382,108 @@ export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedPro
<div>
<h1 className="text-lg font-semibold text-foreground">Audit</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
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.
Everything your company did, newest first each line is one recorded action. Full
audit access also shows responsible-person and run attribution.
</p>
</div>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
{!lockedAgentId ? (
<Select value={agent} onValueChange={setAgent}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Agent" />
{canUseAdvancedControls ? (
<div className="flex flex-wrap items-center gap-2">
{!lockedAgentId ? (
<Select value={agent} onValueChange={setAgent}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Agent" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All agents</SelectItem>
{(agents.data ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Select value={responsibleUser} onValueChange={setResponsibleUser}>
<SelectTrigger className="w-44">
<SelectValue placeholder="Responsible user" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All agents</SelectItem>
{(agents.data ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
<SelectItem value={ALL}>All responsible users</SelectItem>
{(userDirectory.data?.users ?? []).map((u) => (
<SelectItem key={u.principalId} value={u.principalId}>
{u.user?.name ?? u.user?.email ?? u.principalId.slice(0, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Select value={responsibleUser} onValueChange={setResponsibleUser}>
<SelectTrigger className="w-44">
<SelectValue placeholder="Responsible user" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All responsible users</SelectItem>
{(userDirectory.data?.users ?? []).map((u) => (
<SelectItem key={u.principalId} value={u.principalId}>
{u.user?.name ?? u.user?.email ?? u.principalId.slice(0, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={actionDomain} onValueChange={setActionDomain}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
{ACTION_DOMAINS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Entity" />
</SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
aria-label="From date"
value={dateFrom}
max={dateTo || undefined}
onChange={(e) => setDateFrom(e.target.value)}
className="w-36"
/>
<Input
type="date"
aria-label="To date"
value={dateTo}
min={dateFrom || undefined}
onChange={(e) => setDateTo(e.target.value)}
className="w-36"
/>
{hasActiveFilters ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear filters
<Select value={actionDomain} onValueChange={setActionDomain}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Action" />
</SelectTrigger>
<SelectContent>
{ACTION_DOMAINS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Entity" />
</SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="date"
aria-label="From date"
value={dateFrom}
max={dateTo || undefined}
onChange={(e) => setDateFrom(e.target.value)}
className="w-36"
/>
<Input
type="date"
aria-label="To date"
value={dateTo}
min={dateFrom || undefined}
onChange={(e) => setDateTo(e.target.value)}
className="w-36"
/>
{hasActiveFilters ? (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Clear filters
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={handleExport}
disabled={exporting || feed.isLoading || items.length === 0}
>
<Download className="mr-1.5 h-4 w-4" />
{exporting ? "Exporting…" : "Export CSV"}
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={handleExport}
disabled={exporting || feed.isLoading || items.length === 0}
>
<Download className="mr-1.5 h-4 w-4" />
{exporting ? "Exporting…" : "Export CSV"}
</Button>
</div>
</div>
) : null}
{feed.isLoading ? (
{recoveringFromAccessDowngrade ? (
<Card>
<CardContent className="py-14 text-center text-sm text-muted-foreground">
Refreshing audit access
</CardContent>
</Card>
) : feed.isLoading ? (
<Card>
<CardContent className="py-14 text-center text-sm text-muted-foreground">Loading</CardContent>
</Card>

View File

@ -6,10 +6,8 @@ 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`).
* Company-level audit page. All company readers receive the redacted shared
* feed; attribution filters and export remain permission-gated server-side.
*/
export function CompanyAudit() {
const { selectedCompanyId } = useCompany();