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 API | 1 | /** |
| 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 | ||
| 7 | import 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 | */ | |
| 21 | export 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 put | 27 | /** |
| 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 | */ | |
| 32 | export 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 API | 37 | /** The filters a page's address can carry. */ |
| 38 | export 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 | ||
| 53 | const OUTCOMES: AuditOutcome[] = ["allowed", "denied"]; | |
| 54 | const KINDS: ActorKind[] = ["person", "agent", "workspace"]; | |
| 55 | const DAY = /^\d{4}-\d{2}-\d{2}$/; | |
| 56 | ||
| 57 | function clean(value: string | null, max = 120): string { | |
| 58 | return (value ?? "").trim().slice(0, max); | |
| 59 | } | |
| 60 | ||
| 61 | export 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. */ | |
| 83 | function 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`. */ | |
| 90 | export 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. */ | |
| 115 | export 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". */ | |
| 126 | export 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. */ | |
| 131 | export 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". */ | |
| 138 | export 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 | ||
| 144 | export 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 | ||
| 168 | function 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. */ | |
| 177 | export 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`. */ | |
| 184 | export 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. */ | |
| 189 | export 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 | } |