flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

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

205 lines7,309 bytesCodeBlame
/**
 * The audit log, as the site shows and exports it: who may see what, the
 * filters a page's address carries, and the CSV and JSON it downloads.
 * Pure, so it can be tested; the server side is in audit.server.ts.
 */

import type {
  ActorKind,
  AuditEntry,
  AuditOutcome,
  AuditQuery,
  AuditVisibility,
  Role,
} from "@g1t/contracts";

/**
 * How much of a workspace's log a viewer sees: an owner everything; a
 * member what was done to the workspace's projects, and what they did or
 * had done for them; anyone else nothing.
 */
export function visibilityFor(role: Role | null, username: string): AuditVisibility | null {
  if (role === "owner") return { kind: "all" };
  if (role === "member") return { kind: "projects", username };
  return null;
}

/**
 * The earliest time a workspace's log can be read from, given how many
 * days its plan keeps (30, or a year on Team): the later of what was asked
 * for and the start of the window.
 */
export function retainedSince(since: string | null | undefined, days: number, now = Date.now()): string {
  const start = new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
  return since && since > start ? since : start;
}

/** The filters a page's address can carry. */
export type AuditFilters = {
  actor: string;
  agent: string;
  action: string;
  project: string;
  outcome: "" | AuditOutcome;
  kind: "" | ActorKind;
  run: string;
  /** `YYYY-MM-DD`, inclusive. */
  from: string;
  /** `YYYY-MM-DD`, inclusive. */
  to: string;
  before: string;
};

const OUTCOMES: AuditOutcome[] = ["allowed", "denied"];
const KINDS: ActorKind[] = ["person", "agent", "workspace"];
const DAY = /^\d{4}-\d{2}-\d{2}$/;

function clean(value: string | null, max = 120): string {
  return (value ?? "").trim().slice(0, max);
}

export function parseFilters(params: URLSearchParams): AuditFilters {
  const outcome = clean(params.get("outcome"));
  const kind = clean(params.get("kind"));
  const day = (name: string) => {
    const value = clean(params.get(name));
    return DAY.test(value) ? value : "";
  };
  return {
    actor: clean(params.get("actor")),
    agent: clean(params.get("agent")),
    action: clean(params.get("action")),
    project: clean(params.get("project")),
    outcome: OUTCOMES.includes(outcome as AuditOutcome) ? (outcome as AuditOutcome) : "",
    kind: KINDS.includes(kind as ActorKind) ? (kind as ActorKind) : "",
    run: clean(params.get("run")),
    from: day("from"),
    to: day("to"),
    before: clean(params.get("before")),
  };
}

/** The day after `day`, for an inclusive end. */
function nextDay(day: string): string {
  const date = new Date(`${day}T00:00:00Z`);
  date.setUTCDate(date.getUTCDate() + 1);
  return date.toISOString().slice(0, 10);
}

/** What to ask the log for. `project` is a project's name in `workspace`, or `owner/name`. */
export function toQuery(
  workspace: string,
  visibility: AuditVisibility,
  filters: AuditFilters,
  limit: number,
): AuditQuery {
  const project = filters.project.includes("/") ? filters.project : filters.project ? `${workspace}/${filters.project}` : null;
  return {
    workspace,
    visibility,
    actor: filters.actor || null,
    agent: filters.agent || null,
    action: filters.action || null,
    repo: project,
    outcome: filters.outcome || null,
    actorKind: filters.kind || null,
    runIds: filters.run ? [filters.run] : [],
    since: filters.from ? `${filters.from}T00:00:00.000Z` : null,
    until: filters.to ? `${nextDay(filters.to)}T00:00:00.000Z` : null,
    before: filters.before || null,
    limit,
  };
}

/** The address of the same view with `changes` applied, for links. */
export function filterHref(base: string, filters: AuditFilters, changes: Partial<AuditFilters> = {}): string {
  const merged = { ...filters, ...changes };
  const params = new URLSearchParams();
  for (const [key, value] of Object.entries(merged)) {
    if (value) params.set(key, value);
  }
  const search = params.toString();
  return search ? `${base}?${search}` : base;
}

/** Who acted, as people read it: "g1t-agent on behalf of syntaqx". */
export function actorLabel(entry: Pick<AuditEntry, "actor" | "agent" | "onBehalfOf">): string {
  return entry.onBehalfOf ? `${entry.agent ?? entry.actor} on behalf of ${entry.onBehalfOf}` : entry.actor;
}

/** What it was done to: `acme/rocket#12`, with a ref or path when there is one. */
export function targetLabel(entry: Pick<AuditEntry, "workspace" | "repo" | "number" | "gitRef" | "path">): string {
  const where = entry.repo ? `${entry.repo}${entry.number != null ? `#${entry.number}` : ""}` : entry.workspace;
  const what = [entry.gitRef, entry.path].filter(Boolean).join(" ");
  return what ? `${where} ${what}` : where;
}

/** An operation's name as a phrase: `create_issue` is "create issue". */
export function actionLabel(action: string): string {
  if (action === "git.push") return "git push";
  if (action === "git.fetch") return "git fetch";
  return action.replaceAll("_", " ");
}

export const CSV_COLUMNS = [
  "id",
  "time",
  "workspace",
  "actorKind",
  "actor",
  "agent",
  "onBehalfOf",
  "runId",
  "runKind",
  "credentialId",
  "action",
  "surface",
  "repo",
  "number",
  "gitRef",
  "path",
  "outcome",
  "rule",
  "result",
  "message",
  "requestId",
] as const satisfies readonly (keyof AuditEntry)[];

function csvCell(value: unknown): string {
  if (value == null) return "";
  let text = String(value);
  // A spreadsheet runs a cell that starts like a formula; keep it text.
  if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
  return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}

/** RFC 4180 CSV, a header row and one row per entry. */
export function toCsv(entries: AuditEntry[]): string {
  const rows = [CSV_COLUMNS.join(",")];
  for (const entry of entries) rows.push(CSV_COLUMNS.map((column) => csvCell(entry[column])).join(","));
  return `${rows.join("\r\n")}\r\n`;
}

/** The download's file name: `acme-audit-2026-10-04.csv`. */
export function exportName(workspace: string, format: "csv" | "json", now: Date): string {
  return `${workspace}-audit-${now.toISOString().slice(0, 10)}.${format}`;
}

/** Plain words for the rule that decided an entry. */
export function ruleLabel(rule: string): string {
  if (rule === "never") return "never allowed for agents";
  if (rule === "scope:operation") return "not in the run's scope";
  if (rule === "scope:repository") return "outside the run's repository";
  if (rule === "scope:pull") return "outside the run's pull request";
  if (rule === "on-behalf-of:membership") return "the person it works for is not a member";
  if (rule === "git:push") return "no push grant";
  if (rule === "git:read") return "no read grant";
  if (rule === "git:ref") return "branch not granted";
  if (rule === "git:not-a-run") return "tools token used with git";
  if (rule === "service" || rule === "repository") return "refused by the repository's rules";
  if (rule === "person") return "the person's own access";
  if (rule === "workspace-token") return "the workspace token's access";
  const run = /^run:([a-z]+)\/(runner|tools)(?::(push|read))?$/.exec(rule);
  if (run) return `${run[1]} run ${run[2] === "tools" ? "tools" : "runner"}${run[3] ? ` (${run[3]})` : ""}`;
  return rule;
}