Skip to content

g1t/apps/web/app/components/audit.tsx

189 lines7,817 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1/**
2 * The audit log's entries, as the workspace's Audit log page, an agent
3 * run's page and a pull request's Agent panel show them.
4 */
5
6import { ShieldAlert, ShieldCheck } from "lucide-react";
7import { useEffect } from "react";
8import { Link, useFetcher } from "react-router";
9
10import type { AuditEntry } from "@g1t/contracts";
11
12import { actionLabel, actorLabel, ruleLabel, targetLabel } from "../lib/audit";
13import { Avatar, TimeAgo } from "./ui";
Loading: nothing pops in, nothing shifts, and leaving a page shows it14import { Loading, SkeletonRows } from "./ui/skeleton";
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API15
16function clock(at: string): string {
17 return new Date(at).toISOString().slice(11, 19);
18}
19
20/** Allowed or denied, and by which rule. */
21export function OutcomeMark({ entry }: { entry: AuditEntry }) {
22 const denied = entry.outcome === "denied";
23 return (
24 <span
25 title={`${entry.outcome}: ${ruleLabel(entry.rule)} (${entry.rule})`}
26 className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs ${
27 denied ? "bg-danger/10 text-danger ring-1 ring-danger/30" : "bg-raised text-muted ring-1 ring-line"
28 }`}
29 >
30 {denied ? <ShieldAlert size={11} /> : <ShieldCheck size={11} />}
31 {entry.outcome}
32 </span>
33 );
34}
35
36/** Who acted, with an agent shown as working for someone. */
37export function ActorLine({ entry }: { entry: AuditEntry }) {
38 return (
39 <span className="flex min-w-0 items-center gap-2">
40 <Avatar name={entry.agent ?? entry.actor} size={18} square={entry.actorKind === "workspace"} />
41 <span className="truncate">
42 {entry.onBehalfOf ? (
43 <>
44 <span className="font-medium">{entry.agent ?? entry.actor}</span>
45 <span className="text-muted"> on behalf of </span>
46 <span className="font-medium">{entry.onBehalfOf}</span>
47 </>
48 ) : (
49 <span className="font-medium">{entry.actor}</span>
50 )}
51 </span>
52 </span>
53 );
54}
55
56/** The workspace's log, one row an entry, newest first. */
57export function AuditTable({ entries, base }: { entries: AuditEntry[]; base: string }) {
58 return (
59 <ol className="divide-y divide-line rounded-xl border border-line bg-surface">
60 {entries.map((entry) => (
61 <li key={entry.id} className="grid gap-x-4 gap-y-1 px-4 py-3 text-sm sm:grid-cols-[9rem_1fr_auto]">
62 <span className="text-xs leading-5 text-faint">
63 <TimeAgo at={entry.time} />
64 </span>
65 <div className="min-w-0">
66 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
67 <ActorLine entry={entry} />
68 <span className="font-mono text-xs text-fg/85">{actionLabel(entry.action)}</span>
69 <span className="truncate font-mono text-xs text-muted">{targetLabel(entry)}</span>
70 </div>
71 <p className="mt-1 flex flex-wrap gap-x-3 text-xs text-faint">
72 <span title={entry.rule}>{ruleLabel(entry.rule)}</span>
73 <span>{entry.surface.toUpperCase()}</span>
74 {entry.result && entry.result !== "ok" && <span>result: {entry.result}</span>}
75 {entry.runId && entry.repo && (
76 <Link to={`/${entry.repo}/agents/runs/${entry.runId}`} className="hover:text-fg">
77 {entry.runKind ?? "agent"} run
78 </Link>
79 )}
80 {entry.runId && (
81 <Link to={`${base}?run=${encodeURIComponent(entry.runId)}`} className="hover:text-fg">
82 everything this run did
83 </Link>
84 )}
85 {entry.credentialId && (
86 <span className="font-mono" title="Credential">
87 {entry.credentialId}
88 </span>
89 )}
90 <span className="font-mono" title="Request id">
91 {entry.requestId}
92 </span>
93 </p>
94 {entry.outcome === "denied" && entry.message && <p className="mt-1 text-xs text-danger">{entry.message}</p>}
95 </div>
96 <span className="sm:text-right">
97 <OutcomeMark entry={entry} />
98 </span>
99 </li>
100 ))}
101 </ol>
102 );
103}
104
105/**
106 * What an agent's run did, oldest first: every call it made and every git
107 * request, allowed or refused. `entries` come from the run's audit log.
108 */
109export function WhatItDid({ entries, compact = false }: { entries: AuditEntry[]; compact?: boolean }) {
110 if (entries.length === 0) {
111 return (
112 <p className="mt-3 text-sm text-muted">
113 Nothing recorded yet. Every call this run makes with its credentials, and every clone and push, is listed here.
114 </p>
115 );
116 }
117 const who = entries.find((entry) => entry.onBehalfOf);
118 const denied = entries.filter((entry) => entry.outcome === "denied").length;
119 const shown = compact ? entries.slice(-8) : entries;
120 return (
121 <div className="mt-3">
122 <p className="text-xs text-muted">
123 {who ? actorLabel(who) : entries[0].actor} · {entries.length} {entries.length === 1 ? "action" : "actions"}
124 {denied > 0 && <span className="text-danger"> · {denied} refused</span>}
125 {compact && entries.length > shown.length && ` · the latest ${shown.length}`}
126 </p>
127 <ol className="mt-2 divide-y divide-line rounded-xl border border-line bg-surface">
128 {shown.map((entry) => (
129 <li key={entry.id} className="flex items-start gap-3 px-4 py-2 text-sm">
130 <time dateTime={entry.time} className="shrink-0 font-mono text-xs leading-5 text-faint" suppressHydrationWarning>
131 {clock(entry.time)}
132 </time>
133 <div className="min-w-0 grow">
134 <p className="flex min-w-0 flex-wrap items-center gap-x-2 font-mono text-xs leading-5">
135 <span className="text-fg/85">{actionLabel(entry.action)}</span>
136 <span className="truncate text-muted">{targetLabel(entry)}</span>
137 </p>
138 {entry.outcome === "denied" && (
139 <p className="text-xs text-danger">
140 {entry.message ?? "Refused."} <span className="text-faint">({ruleLabel(entry.rule)})</span>
141 </p>
142 )}
143 </div>
144 <OutcomeMark entry={entry} />
145 </li>
146 ))}
147 </ol>
148 </div>
149 );
150}
151
152/**
153 * What the runs on a pull request did, fetched from the project's
154 * `audit.json`, for the Agent panel. Shown to members only.
155 */
156export function RunAudit({ owner, repo, runIds, live }: { owner: string; repo: string; runIds: string[]; live: boolean }) {
157 const fetcher = useFetcher<{ entries: AuditEntry[] }>();
158 const search = new URLSearchParams(runIds.map((id) => ["run", id])).toString();
159 const url = `/${owner}/${repo}/audit.json?${search}`;
160 const { load } = fetcher;
161 useEffect(() => {
162 if (runIds.length > 0) load(url);
163 }, [load, url, runIds.length]);
164 useEffect(() => {
165 if (!live) return;
166 const timer = setInterval(() => {
167 if (document.visibilityState === "visible") load(url);
168 }, 8000);
169 return () => clearInterval(timer);
170 }, [live, load, url]);
171 const entries = fetcher.data?.entries;
Loading: nothing pops in, nothing shifts, and leaving a page shows it172 if (runIds.length === 0) return null;
173 // There from the start, so the panel does not grow when the log arrives.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API174 return (
175 <details className="mt-3 group">
176 <summary className="cursor-pointer text-xs text-muted hover:text-fg">What it did</summary>
Loading: nothing pops in, nothing shifts, and leaving a page shows it177 {entries ? (
178 <WhatItDid entries={entries} compact />
179 ) : (
180 <Loading className="mt-3 overflow-hidden rounded-xl border border-line bg-surface">
181 <SkeletonRows rows={3} rowClassName="h-9 px-4" />
182 </Loading>
183 )}
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API184 <Link to={`/${owner}/-/audit?project=${encodeURIComponent(repo)}&kind=agent`} className="mt-2 inline-block text-xs text-muted hover:text-fg">
185 Open the audit log
186 </Link>
187 </details>
188 );
189}