Files
PaperClipAI/ui/src/api/audit.ts
T
71231dfa38 feat(audit): agent audit UI — company page + per-agent tab (#9744)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Operators need an audit record of agent actions across tasks,
comments, documents, approvals, and runs
> - The permission-gated audit read API provides that record, but
operators cannot inspect it in the product
> - A readable UI must preserve company boundaries, server-side
permission decisions, and redaction
> - Audit exports must also be safe to open in spreadsheet software and
must record the export itself
> - This pull request adds company and per-agent audit views plus a
guarded CSV export
> - The benefit is a searchable, filterable, and reviewable agent action
history with direct links back to work

## Linked Issues or Issue Description

**Feature.** This change adds the frontend and CSV export for the agent
action audit log.

Refs #9731 and #9735.

- Problem: agent actions are recorded, but operators have no readable
product surface to inspect or export them.
- Solution: add a company audit page and a per-agent Audit tab that use
the permission-gated audit API.
- Alternative: build a separate plugin-only surface. This was rejected
because the existing permission model already supports a unified,
server-authoritative view.

This pull request targets the audit epic branch, which contains the
merged #9735 audit API.

## What Changed

- Added a company Audit page and sidebar entry.
- Added a per-agent Audit tab with a fixed agent filter.
- Added filters for agent, responsible user, action domain, entity type,
and date range.
- Added task and run links, responsible-user context, cursor pagination,
and readable action text.
- Added a permission-denied Enterprise card for callers without
`audit:view_agent_actions`.
- Added a CSV export that is permission-gated, capped, self-audited,
CSV-escaped, and protected against spreadsheet formula injection.
- Preserved the merged audit API cursor validation, redaction, and
sub-millisecond pagination behavior.

## Verification

- `pnpm exec vitest run ui/src/pages/audit/AuditFeed.test.tsx` — 6
passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/agent-action-audit-routes.test.ts` — 8 passed with
embedded PostgreSQL.
- `pnpm -r typecheck` — passed across all workspaces.
- `pnpm build` — passed across all workspaces.
- `pnpm test:run` — all completed shards passed except one
environment-sensitive CLI assertion caused by injected static AWS
credential variables; the exact test passes 8/8 with those variables
unset.
- Manual Chromium QA exercised the populated feed, active filters,
permission-denied card, per-agent tab, and CSV export.

## Screenshots and Manual QA

- [All audit states exercised in
Chromium](https://github.com/paperclipai/paperclip/pull/9744#issuecomment-4998997001)
- [Detailed browser report and per-agent tab root
cause](https://github.com/paperclipai/paperclip/pull/9744#issuecomment-4998771061)

The per-agent redirect defect found during QA is fixed in this branch.

## Risks

Low to moderate risk. The UI and export route are additive and use the
existing company-scoped permission gate. The main risks are large
exports and spreadsheet interpretation. The export is capped at 10,000
rows, records truncation accurately, and prefixes formula-like cells as
text. There are no schema changes or migrations.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 4.8, 1M context, extended thinking, tool use,
and code execution produced the original implementation.
- OpenAI Codex, GPT-5 (deployment ID and context window not exposed),
reasoning, tool use, code execution, browser-test orchestration, and
GitHub review tooling repaired and verified the pull request.

## 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)
- [ ] 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>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 19:09:13 -07:00

113 lines
4.0 KiB
TypeScript

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<string, unknown> | 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<AuditActionsResponse>(
`/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<AuditActionFilters, "cursor" | "limit"> = {},
): Promise<Blob> => {
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();
},
};