flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/lib/audit.ts

205 lines7,309 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, as the site shows and exports it: who may see what, the
3 * filters a page's address carries, and the CSV and JSON it downloads.
4 * Pure, so it can be tested; the server side is in audit.server.ts.
5 */
6
7import type {
8 ActorKind,
9 AuditEntry,
10 AuditOutcome,
11 AuditQuery,
12 AuditVisibility,
13 Role,
14} from "@g1t/contracts";
15
16/**
17 * How much of a workspace's log a viewer sees: an owner everything; a
18 * member what was done to the workspace's projects, and what they did or
19 * had done for them; anyone else nothing.
20 */
21export function visibilityFor(role: Role | null, username: string): AuditVisibility | null {
22 if (role === "owner") return { kind: "all" };
23 if (role === "member") return { kind: "projects", username };
24 return null;
25}
26
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put27/**
28 * The earliest time a workspace's log can be read from, given how many
29 * days its plan keeps (30, or a year on Team): the later of what was asked
30 * for and the start of the window.
31 */
32export function retainedSince(since: string | null | undefined, days: number, now = Date.now()): string {
33 const start = new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
34 return since && since > start ? since : start;
35}
36
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API37/** The filters a page's address can carry. */
38export type AuditFilters = {
39 actor: string;
40 agent: string;
41 action: string;
42 project: string;
43 outcome: "" | AuditOutcome;
44 kind: "" | ActorKind;
45 run: string;
46 /** `YYYY-MM-DD`, inclusive. */
47 from: string;
48 /** `YYYY-MM-DD`, inclusive. */
49 to: string;
50 before: string;
51};
52
53const OUTCOMES: AuditOutcome[] = ["allowed", "denied"];
54const KINDS: ActorKind[] = ["person", "agent", "workspace"];
55const DAY = /^\d{4}-\d{2}-\d{2}$/;
56
57function clean(value: string | null, max = 120): string {
58 return (value ?? "").trim().slice(0, max);
59}
60
61export function parseFilters(params: URLSearchParams): AuditFilters {
62 const outcome = clean(params.get("outcome"));
63 const kind = clean(params.get("kind"));
64 const day = (name: string) => {
65 const value = clean(params.get(name));
66 return DAY.test(value) ? value : "";
67 };
68 return {
69 actor: clean(params.get("actor")),
70 agent: clean(params.get("agent")),
71 action: clean(params.get("action")),
72 project: clean(params.get("project")),
73 outcome: OUTCOMES.includes(outcome as AuditOutcome) ? (outcome as AuditOutcome) : "",
74 kind: KINDS.includes(kind as ActorKind) ? (kind as ActorKind) : "",
75 run: clean(params.get("run")),
76 from: day("from"),
77 to: day("to"),
78 before: clean(params.get("before")),
79 };
80}
81
82/** The day after `day`, for an inclusive end. */
83function nextDay(day: string): string {
84 const date = new Date(`${day}T00:00:00Z`);
85 date.setUTCDate(date.getUTCDate() + 1);
86 return date.toISOString().slice(0, 10);
87}
88
89/** What to ask the log for. `project` is a project's name in `workspace`, or `owner/name`. */
90export function toQuery(
91 workspace: string,
92 visibility: AuditVisibility,
93 filters: AuditFilters,
94 limit: number,
95): AuditQuery {
96 const project = filters.project.includes("/") ? filters.project : filters.project ? `${workspace}/${filters.project}` : null;
97 return {
98 workspace,
99 visibility,
100 actor: filters.actor || null,
101 agent: filters.agent || null,
102 action: filters.action || null,
103 repo: project,
104 outcome: filters.outcome || null,
105 actorKind: filters.kind || null,
106 runIds: filters.run ? [filters.run] : [],
107 since: filters.from ? `${filters.from}T00:00:00.000Z` : null,
108 until: filters.to ? `${nextDay(filters.to)}T00:00:00.000Z` : null,
109 before: filters.before || null,
110 limit,
111 };
112}
113
114/** The address of the same view with `changes` applied, for links. */
115export function filterHref(base: string, filters: AuditFilters, changes: Partial<AuditFilters> = {}): string {
116 const merged = { ...filters, ...changes };
117 const params = new URLSearchParams();
118 for (const [key, value] of Object.entries(merged)) {
119 if (value) params.set(key, value);
120 }
121 const search = params.toString();
122 return search ? `${base}?${search}` : base;
123}
124
125/** Who acted, as people read it: "g1t-agent on behalf of syntaqx". */
126export function actorLabel(entry: Pick<AuditEntry, "actor" | "agent" | "onBehalfOf">): string {
127 return entry.onBehalfOf ? `${entry.agent ?? entry.actor} on behalf of ${entry.onBehalfOf}` : entry.actor;
128}
129
130/** What it was done to: `acme/rocket#12`, with a ref or path when there is one. */
131export function targetLabel(entry: Pick<AuditEntry, "workspace" | "repo" | "number" | "gitRef" | "path">): string {
132 const where = entry.repo ? `${entry.repo}${entry.number != null ? `#${entry.number}` : ""}` : entry.workspace;
133 const what = [entry.gitRef, entry.path].filter(Boolean).join(" ");
134 return what ? `${where} ${what}` : where;
135}
136
137/** An operation's name as a phrase: `create_issue` is "create issue". */
138export function actionLabel(action: string): string {
139 if (action === "git.push") return "git push";
140 if (action === "git.fetch") return "git fetch";
141 return action.replaceAll("_", " ");
142}
143
144export const CSV_COLUMNS = [
145 "id",
146 "time",
147 "workspace",
148 "actorKind",
149 "actor",
150 "agent",
151 "onBehalfOf",
152 "runId",
153 "runKind",
154 "credentialId",
155 "action",
156 "surface",
157 "repo",
158 "number",
159 "gitRef",
160 "path",
161 "outcome",
162 "rule",
163 "result",
164 "message",
165 "requestId",
166] as const satisfies readonly (keyof AuditEntry)[];
167
168function csvCell(value: unknown): string {
169 if (value == null) return "";
170 let text = String(value);
171 // A spreadsheet runs a cell that starts like a formula; keep it text.
172 if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
173 return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
174}
175
176/** RFC 4180 CSV, a header row and one row per entry. */
177export function toCsv(entries: AuditEntry[]): string {
178 const rows = [CSV_COLUMNS.join(",")];
179 for (const entry of entries) rows.push(CSV_COLUMNS.map((column) => csvCell(entry[column])).join(","));
180 return `${rows.join("\r\n")}\r\n`;
181}
182
183/** The download's file name: `acme-audit-2026-10-04.csv`. */
184export function exportName(workspace: string, format: "csv" | "json", now: Date): string {
185 return `${workspace}-audit-${now.toISOString().slice(0, 10)}.${format}`;
186}
187
188/** Plain words for the rule that decided an entry. */
189export function ruleLabel(rule: string): string {
190 if (rule === "never") return "never allowed for agents";
191 if (rule === "scope:operation") return "not in the run's scope";
192 if (rule === "scope:repository") return "outside the run's repository";
193 if (rule === "scope:pull") return "outside the run's pull request";
194 if (rule === "on-behalf-of:membership") return "the person it works for is not a member";
195 if (rule === "git:push") return "no push grant";
196 if (rule === "git:read") return "no read grant";
197 if (rule === "git:ref") return "branch not granted";
198 if (rule === "git:not-a-run") return "tools token used with git";
199 if (rule === "service" || rule === "repository") return "refused by the repository's rules";
200 if (rule === "person") return "the person's own access";
201 if (rule === "workspace-token") return "the workspace token's access";
202 const run = /^run:([a-z]+)\/(runner|tools)(?::(push|read))?$/.exec(rule);
203 if (run) return `${run[1]} run ${run[2] === "tools" ? "tools" : "runner"}${run[3] ? ` (${run[3]})` : ""}`;
204 return rule;
205}