Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/src/lib/audit/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,22 @@ export function auditEventsQueryOptions(search = "") {
},
});
}

/**
* The CSV download behind the Audit page's Export button.
*
* Carries the page's current filter (`search`, e.g. `?eventType=...`) into
* `GET /api/admin/audit-events?format=csv`, so the file and the table agree. A `format`
* already on the search is replaced rather than doubled, and paging params are dropped:
* the export walks every matching page server-side up to its cap.
*/
export function auditExportUrl(search = ""): string {
const params = new URLSearchParams(
search.startsWith("?") ? search.slice(1) : search,
);
params.delete("cursor");
params.delete("limit");
params.set("format", "csv");
const query = params.toString();
return `/api/admin/audit-events${query ? `?${query}` : "?format=csv"}`;
}
75 changes: 69 additions & 6 deletions app/src/routes/_authed/admin/audit.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconRefresh } from "@tabler/icons-react";
import { IconDownload, IconRefresh } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
Expand All @@ -15,8 +15,9 @@ import {
outcomeOf,
REFUSED_EVENT_TYPES,
} from "@/lib/audit/outcome";
import { auditEventsQueryOptions } from "@/lib/audit/queries";
import { auditEventsQueryOptions, auditExportUrl } from "@/lib/audit/queries";
import { silenceOf } from "@/lib/audit/silence";
import { client } from "@/lib/client";

/**
* Read surface for policy, computer, component, MCP, and credential audit events.
Expand Down Expand Up @@ -66,6 +67,46 @@ function AuditPage() {
const events = useQuery(auditEventsQueryOptions(search));
const rows = (events.data?.events ?? []) as AuditEvent[];
const nameFor = useBotNames();
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [exportCapped, setExportCapped] = useState<number | null>(null);

/*
* The same filtered query as the table, downloaded as a file. The server walks every
* matching page up to its cap, so this stays a single request no matter how many pages
* the filter matches; the filename carries the day it was pulled.
*/
async function exportCsv() {
if (exporting) return;
setExporting(true);
setExportError(null);
setExportCapped(null);
try {
const response = await client(auditExportUrl(search), {
fallback: "Could not export audit events",
});
// A file that stopped early must say so, or it reads as the whole trail.
const capped = Number(response.headers.get("x-audit-export-truncated"));
if (Number.isInteger(capped) && capped > 0) setExportCapped(capped);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `audit-events-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
} catch (error) {
setExportError(
error instanceof Error
? error.message
: "Could not export audit events",
);
} finally {
setExporting(false);
}
}

return (
/*
Expand All @@ -76,10 +117,21 @@ function AuditPage() {
*/
<PageShell
action={
<Button onClick={() => events.refetch()} size="sm" variant="ghost">
<IconRefresh />
Refresh
</Button>
<div className="flex items-center gap-2">
<Button
disabled={exporting}
onClick={exportCsv}
size="sm"
variant="outline"
>
<IconDownload />
{exporting ? "Exporting…" : "Export CSV"}
</Button>
<Button onClick={() => events.refetch()} size="sm" variant="ghost">
<IconRefresh />
Refresh
</Button>
</div>
}
description="Every action a Bot took, and every one this deployment's policy refused."
title="Audit"
Expand All @@ -101,6 +153,17 @@ function AuditPage() {
))}
</div>

{exportError ? (
<p className="mt-4 text-destructive text-sm" role="alert">
{exportError}
</p>
) : null}
{exportCapped ? (
<p className="mt-4 text-muted-foreground text-sm" role="status">
The file holds the newest {exportCapped.toLocaleString()} events,
not all of them. Narrow the filter or the dates to export the rest.
</p>
) : null}
{events.isPending ? null : events.isError ? (
<p className="mt-4 text-destructive text-sm" role="alert">
The audit trail could not be loaded.
Expand Down
63 changes: 63 additions & 0 deletions app/tests/audit-export-truncated.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from "@tanstack/react-router";
import { cleanup, fireEvent, render } from "@testing-library/react";
import { agentKeys } from "@/lib/agents/queries";
import { auditKeys } from "@/lib/audit/queries";
import { Route as AuditRoute } from "@/routes/_authed/admin/audit";

beforeAll(() => GlobalRegistrator.register());

const realFetch = globalThis.fetch;
afterEach(() => {
cleanup();
globalThis.fetch = realFetch;
});

afterAll(() => GlobalRegistrator.unregister());

async function exportWith(headers: Record<string, string>) {
globalThis.fetch = (async () =>
new Response("id\n", {
headers: { "content-type": "text/csv", ...headers },
})) as unknown as typeof fetch;
URL.createObjectURL = () => "blob:audit";
URL.revokeObjectURL = () => {};
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Number.POSITIVE_INFINITY },
},
});
queryClient.setQueryData([...auditKeys.all, ""], { events: [] });
queryClient.setQueryData(agentKeys.list(false), []);
const router = createRouter({
routeTree: createRootRoute({ component: AuditRoute.options.component }),
history: createMemoryHistory({ initialEntries: ["/"] }),
});
const view = render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
fireEvent.click(await view.findByText("Export CSV"));
await view.findByText("Export CSV");
return view;
}

// A file that stopped at the cap reads as the whole trail unless the page says otherwise.
test("an export that stopped at the cap says how much the file holds", async () => {
const view = await exportWith({ "x-audit-export-truncated": "5000" });
const notice = await view.findByRole("status");
expect(notice.textContent).toContain("newest 5,000 events");
});

test("a whole export says nothing extra", async () => {
const view = await exportWith({});
expect(view.queryByRole("status")).toBeNull();
});
34 changes: 34 additions & 0 deletions app/tests/audit-export-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import { auditExportUrl } from "../src/lib/audit/queries";

/**
* The download behind Audit's Export button.
*
* The file must carry the page's current filter, or the download and the table disagree
* about what was reviewed. Paging belongs to the JSON list and is dropped: the server
* walks every matching page up to its cap.
*/

describe("auditExportUrl", () => {
test("no filter exports the whole trail", () => {
expect(auditExportUrl()).toBe("/api/admin/audit-events?format=csv");
expect(auditExportUrl("")).toBe("/api/admin/audit-events?format=csv");
});

test("the current filter travels into the download", () => {
expect(auditExportUrl("?eventType=computer.action_allowed")).toBe(
"/api/admin/audit-events?eventType=computer.action_allowed&format=csv",
);
});

test("paging is dropped and a stray format is replaced, not doubled", () => {
expect(
auditExportUrl("?eventType=computer.action_allowed&limit=50&cursor=abc"),
).toBe(
"/api/admin/audit-events?eventType=computer.action_allowed&format=csv",
);
expect(auditExportUrl("?format=json")).toBe(
"/api/admin/audit-events?format=csv",
);
});
});
12 changes: 12 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ Computer actions carry the initiator too. A run with no browser attached (a rout
a group turn) is given the computer tools server-side, with the Bot and the person bound before the
model supplies any arguments, and the gateway writes that run's initiator on every decision.

### Taking the trail out

The Audit screen's **Export CSV** downloads the current filter as
`GET /api/admin/audit-events?format=csv`: the same filtered query as the table, walked page by
page up to 5,000 rows and returned as RFC 4180 CSV with the redacted payload as one JSON column.
An export that hits the cap holds the newest 5,000 and answers with `X-Audit-Export-Truncated`
(the row count), and the screen says so, so a reviewer narrows `from`/`to` or a filter instead of
citing a partial trail. A cell that starts with `=`, `+`, `-` or `@` is prefixed with `'`, so a
name or URL somebody chose opens in a spreadsheet as text, never as a formula. Every export is
itself recorded as `audit.exported`, with who took it, how many rows, and under which filter. An
unknown `format` is a 400, like any other malformed audit query.

## Human control and secrets

Handovers are audited as control events:
Expand Down
55 changes: 49 additions & 6 deletions server/src/admin/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
parseEgressRules,
} from "../computer/policy-network";
import { mcpServers } from "../db/schema";
import { parsePageLimit } from "../paging";
import { otelConfigured } from "../telemetry/otel";
import {
CAPABILITIES,
Expand All @@ -29,6 +30,40 @@ import {
import { ACTION_RECORD_RETENTION_DAYS, enterpriseControls } from "./controls";
import type { EnterpriseSettings } from "./settings-store";

/**
* Strict readers for the two paged enterprise GETs, matching the shared `parsePageLimit`
* rule every other paged list follows: a run of digits is clamped into range, anything
* else is a 400 naming the parameter rather than a silently coerced page.
*
* `GET /models/usage?days=` used `Number(raw) || 30`, so `?days=abc`, `?days=12abc`
* and `?days=` all quietly returned the 30-day view, and `?days=3.9` averaged a
* fractional window. `GET /actions?limit=` did the same with the 100-row default.
* Exported pure so the 400 paths are unit-tested without standing up the controls.
*/
export const ENTERPRISE_DAYS_ERROR =
'Query parameter "days" must be a positive integer.';

export function parseEnterpriseDays(
raw: string | null,
): { ok: true; days: number } | { ok: false; error: string } {
if (raw === null || raw.trim() === "") return { ok: true, days: 30 };
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed))
return { ok: false, error: ENTERPRISE_DAYS_ERROR };
return {
ok: true,
days: Math.min(Math.max(Number.parseInt(trimmed, 10), 1), 365),
};
}

export function parseEnterpriseActionsLimit(
raw: string | null,
): { ok: true; limit: number } | { ok: false; error: string } {
const parsed = parsePageLimit(raw, 500);
if (!parsed.ok) return parsed;
return { ok: true, limit: parsed.limit ?? 100 };
}

const capabilityInput = z.strictObject({
scopeKind: z.enum(["organization", "role", "group"]),
scopeId: z.string().trim().max(200).default(""),
Expand Down Expand Up @@ -326,10 +361,11 @@ export function createEnterpriseAdminRoutes(
routes.get("/models/usage", async (context) => {
const controls = enterpriseControls();
if (!controls) return unavailable(context);
const days = Math.min(
Math.max(Number(context.req.query("days") ?? 30) || 30, 1),
365,
);
const parsedDays = parseEnterpriseDays(context.req.query("days") ?? null);
if (!parsedDays.ok) {
return context.json({ error: parsedDays.error }, 400);
}
const days = parsedDays.days;
const summary = await controls.store.modelUsageSummary(days);
const recent = await controls.store.recentModelUsage(50);
const emails = await controls.store.usersById([
Expand All @@ -348,8 +384,15 @@ export function createEnterpriseAdminRoutes(
routes.get("/actions", async (context) => {
const controls = enterpriseControls();
if (!controls) return unavailable(context);
const limit = Number(context.req.query("limit") ?? 100) || 100;
return context.json({ actions: await controls.store.actions(limit) });
const parsedLimit = parseEnterpriseActionsLimit(
context.req.query("limit") ?? null,
);
if (!parsedLimit.ok) {
return context.json({ error: parsedLimit.error }, 400);
}
return context.json({
actions: await controls.store.actions(parsedLimit.limit),
});
});

routes.post("/people/:userId/terminate-computers", async (context) => {
Expand Down
Loading
Loading