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