Ctrl K
ComunidadGuías…

server/admin-activity.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Who changed what on this server: one NDJSON file per UTC month under <data>/admin-activity, 0600, pruned by the decision
// log's retention window. Values are redacted (credential-named keys and every headers/env value become "[hidden]") and
// nothing is sent anywhere. Fire-and-forget: a full disk must not undo a setting that already took effect.
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { appendFile, mkdir } from "node:fs/promises";
import { basename, join } from "node:path";

import { actorLabel, boundRetentionDays, pruneMonthFiles, type DecisionActor, type DecisionRow } from "./decision-log.ts";
import { redactSecrets } from "./redact.ts";
import { csvCell, monthKey } from "./usage-ledger.ts";

export const ADMIN_ACTIVITY_CATEGORIES = ["config", "people", "session", "webhook", "bot-api", "mcp", "engine", "bot", "budget", "visibility"] as const;
export type AdminActivityCategory = typeof ADMIN_ACTIVITY_CATEGORIES[number];

export type AdminActor = DecisionActor | { kind: "cli" };

export interface AdminActionRow {
  at: string;
  category: AdminActivityCategory;
  /** `<thing>.<verb>`: config.update, people.update, bot.create, bot.update,
   * bot.delete, visibility.update, webhook.create, session.revoke, … */
  action: string;
  target?: { kind: string; id?: string; name?: string };
  /** The setting paths or fields that changed. Never their secret values. */
  changed?: string[];
  /** Redacted values of the changed paths, keyed by path. */
  before?: Record<string, unknown>;
  after?: Record<string, unknown>;
  actor: AdminActor;
}

const DIR = "admin-activity";
const MONTH_FILE = /^(\d{4})-(\d{2})\.ndjson$/;
const PRUNE_EVERY_MS = 60 * 60_000;
const writeQueues = new Map<string, Promise<void>>();
const lastPrune = new Map<string, number>();

export function adminActivityFileFor(dataDir: string, at: Date): string {
  return join(dataDir, DIR, `${monthKey(at)}.ndjson`);
}

async function writeRow(dataDir: string, row: AdminActionRow): Promise<void> {
  const at = new Date(row.at);
  await mkdir(join(dataDir, DIR), { recursive: true, mode: 0o700 });
  await appendFile(adminActivityFileFor(dataDir, at), JSON.stringify(row) + "\n", { mode: 0o600 });
  if (at.getTime() - (lastPrune.get(dataDir) ?? 0) >= PRUNE_EVERY_MS) {
    lastPrune.set(dataDir, at.getTime());
    await pruneMonthFiles(join(dataDir, DIR), boundRetentionDays(), at);
  }
}

/** Append one row. Its values are redacted here, at the moment of writing. */
export function appendAdminAction(dataDir: string, row: Omit<AdminActionRow, "at"> & { at?: string }): void {
  const record: AdminActionRow = {
    ...row,
    at: row.at ?? new Date().toISOString(),
    ...(row.before ? { before: auditValues(row.before) } : {}),
    ...(row.after ? { after: auditValues(row.after) } : {}),
  };
  const previous = writeQueues.get(dataDir) ?? Promise.resolve();
  const queued = previous.then(() => writeRow(dataDir, record)).catch(() => {
  });
  writeQueues.set(dataDir, queued);
  void queued.finally(() => {
    if (writeQueues.get(dataDir) === queued) writeQueues.delete(dataDir);
  });
}

/** Never throws; writing prunes too, and the server runs this on a timer for quiet days. */
export function pruneAdminActivity(dataDir: string, days = boundRetentionDays(), now = new Date()): Promise<string[]> {
  return pruneMonthFiles(join(dataDir, DIR), days, now);
}

export async function flushAdminActivity(dataDir: string): Promise<void> {
  await writeQueues.get(dataDir);
}


/** Name parts that mark a credential (case-insensitive substring), plus `key` alone or as a suffix (apiKey, xai_key). */
const SECRET_PARTS = ["token", "secret", "password", "passwd", "passphrase", "credential", "authorization", "cookie", "bearer", "apikey", "api_key"];
/** Maps whose every value may be a credential: keep the names, hide values. */
const OPAQUE_MAP = /^(?:headers|env|environment)$/i;
const MAX_VALUE_CHARS = 2_000;
const HIDDEN = "[hidden]";

function isSecretName(name: string): boolean {
  const lower = name.toLowerCase();
  // Counts, not credentials: maxTokens, contextTokens.
  if (lower.endsWith("tokens")) return false;
  if (SECRET_PARTS.some((part) => lower.includes(part))) return true;
  return /(?:^|[_.-])keys?$/.test(lower) || /[a-z]Keys?$/.test(name);
}

/** A command-line flag or URL parameter that names a credential:
 * `--api-key`, `--token=…`, `?key=`, `&access_token=`, `?sig=`. */
function isSecretParameter(name: string): boolean {
  // `-k` is the usual short form of --key (curl-style CLIs and many MCP servers).
  if (name === "-k" || name === "-K") return true;
  const bare = name.replace(/^-+/, "");
  return isSecretName(bare) || /^(?:auth|sig|signature|pass|pwd|code)$/i.test(bare);
}

/** A run of 16+ mixed letters and digits (Zapier's `/s/<key>/sse`, a Slack hook's last part); a slug or a UUID is not. */
function keyLikeSegment(segment: string): boolean {
  return (segment.match(/[A-Za-z0-9]{16,}/g) ?? []).some((run) => /\d/.test(run) && /[A-Za-z]/.test(run));
}

/** Credentials before the host (`user:pass@`, `TOKEN@`), key-like path segments, and credential-named query or fragment values. */
function maskUrlSecrets(text: string): string {
  return text.replace(/\b([a-z][\w+.-]*:\/\/)([^\s/?#]*)([^\s?#]*)([^\s]*)/gi, (_all, scheme: string, authority: string, path: string, rest: string) => {
    const at = authority.lastIndexOf("@");
    const host = at >= 0 ? `${HIDDEN}@${authority.slice(at + 1)}` : authority;
    const maskedPath = path.split("/").map((segment) => (keyLikeSegment(segment) ? HIDDEN : segment)).join("/");
    const maskedRest = rest.replace(/([?&;#])([^=&#\s]+)=([^&#\s]+)/g, (all, separator: string, name: string) => (isSecretParameter(name) ? `${separator}${name}=${HIDDEN}` : all));
    return `${scheme}${host}${maskedPath}${maskedRest}`;
  });
}

/** An argument list: the value after a credential flag, or after its `=`. */
function maskArgs(items: readonly unknown[]): unknown[] {
  return items.map((item, index) => {
    const previous = items[index - 1];
    if (typeof item !== "string") return item;
    if (typeof previous === "string" && /^--?[\w-]+$/.test(previous) && isSecretParameter(previous)) return HIDDEN;
    const inline = /^(--?[\w-]+)=(.+)$/.exec(item);
    return inline && isSecretParameter(inline[1]!) ? `${inline[1]}=${HIDDEN}` : item;
  });
}

function scrub(value: unknown, depth: number, hideAll: boolean): unknown {
  if (value === undefined || value === null || value === "") return value;
  if (hideAll && typeof value !== "object") return HIDDEN;
  if (typeof value === "string") return redactSecrets(maskUrlSecrets(value));
  if (typeof value !== "object") return value;
  if (depth > 6) return "[…]";
  if (Array.isArray(value)) return maskArgs(value.slice(0, 100)).map((item) => scrub(item, depth + 1, hideAll));
  const out: Record<string, unknown> = {};
  for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
    if (isSecretName(key) && child !== undefined && child !== null && child !== "") out[key] = HIDDEN;
    else out[key] = scrub(child, depth + 1, hideAll || OPAQUE_MAP.test(key));
  }
  return out;
}

/** A path's last segment decides too (`anthropic.key` is a key); a value too large for an audit row is summarised. */
export function auditValues(values: Record<string, unknown>): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [path, value] of Object.entries(values)) {
    const segments = path.split(".");
    const last = segments.at(-1) ?? path;
    const opaque = segments.slice(0, -1).some((segment) => OPAQUE_MAP.test(segment));
    const scrubbed = (isSecretName(last) || opaque) && value !== undefined && value !== null && value !== ""
      ? HIDDEN
      : scrub(value, 0, OPAQUE_MAP.test(last));
    const text = JSON.stringify(scrubbed);
    out[path] = text !== undefined && text.length > MAX_VALUE_CHARS ? "[large value]" : scrubbed ?? null;
  }
  return out;
}


const isPlainObject = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null && !Array.isArray(value);

function stable(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
  if (isPlainObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
  return JSON.stringify(value) ?? "undefined";
}

export function changedPaths(before: unknown, after: unknown, depth = 3, prefix = ""): string[] {
  if (stable(before) === stable(after)) return [];
  if (before === undefined && isPlainObject(after)) before = {};
  if (after === undefined && isPlainObject(before)) after = {};
  if (depth > 0 && isPlainObject(before) && isPlainObject(after)) {
    const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
    return keys.flatMap((key) => changedPaths(before[key], after[key], depth - 1, prefix ? `${prefix}.${key}` : key));
  }
  return [prefix || "(all)"];
}

export function valueAt(source: unknown, path: string): unknown {
  let value: unknown = source;
  for (const segment of path.split(".")) {
    if (!isPlainObject(value)) return undefined;
    value = value[segment];
  }
  return value;
}

const pick = (source: unknown, paths: readonly string[]) =>
  Object.fromEntries(paths.map((path) => [path, valueAt(source, path) ?? null]));

const CONFIG_CATEGORY: Record<string, AdminActivityCategory> = {
  signIn: "people",
  budgets: "budget",
  billing: "budget",
  mcpServers: "mcp",
  instances: "engine",
  anthropic: "engine",
  openaiCompat: "engine",
  xai: "engine",
  opencodeGo: "engine",
  defaultModelSelection: "engine",
};

export function configChangeRows(before: unknown, after: unknown): Array<Omit<AdminActionRow, "at" | "actor">> {
  const paths = changedPaths(isPlainObject(before) ? before : {}, isPlainObject(after) ? after : {});
  const byCategory = new Map<AdminActivityCategory, string[]>();
  for (const path of paths) {
    const category = CONFIG_CATEGORY[path.split(".")[0]!] ?? "config";
    byCategory.set(category, [...(byCategory.get(category) ?? []), path]);
  }
  return [...byCategory].map(([category, changed]) => ({
    category,
    action: `${category}.update`,
    target: { kind: "settings" },
    changed,
    before: pick(before, changed),
    after: pick(after, changed),
  }));
}

/** What a bot may do, where, and who may see it; display fields (colour, unread, pins) are not audited. */
export const BOT_AUDIT_FIELDS = [
  "approvalMode", "autoApprove", "alwaysAllow", "computer", "cloudBackend", "autoStartVps", "vpsAlias",
  "cwd", "composio", "browser", "browserProfile", "mcpServers", "chiefOfStaff", "managedSections", "section",
  "parkDirectMessages", "hidden", "modelSelection", "helperModel", "visibility",
] as const;

/** A Team Cloud's bots are everyone's: who renamed one, recoloured it or changed its instructions is recorded too. */
export const TEAM_BOT_AUDIT_FIELDS = [...BOT_AUDIT_FIELDS, "name", "title", "description", "soul", "color"] as const;

export function botAuditSnapshot(bot: Record<string, unknown> | null | undefined, fields: readonly string[] = BOT_AUDIT_FIELDS): Record<string, unknown> | null {
  if (!bot) return null;
  // "everyone" and no field are one audience.
  const present = (field: string) => bot[field] !== undefined && !(field === "visibility" && bot[field] === "everyone");
  return Object.fromEntries(fields.filter(present).map((field) => [field, field === "soul" ? textDigest(bot[field]) : structuredClone(bot[field])]));
}

/** Instructions may hold anything a person typed: the log keeps only that they changed. */
function textDigest(value: unknown): { length: number; sha256: string } {
  const text = String(value);
  return { length: text.length, sha256: createHash("sha256").update(text).digest("hex").slice(0, 12) };
}

export function botChangeRows(
  target: { id: string; name?: string },
  before: Record<string, unknown> | null,
  after: Record<string, unknown> | null,
  only?: readonly string[],
  fields: readonly string[] = BOT_AUDIT_FIELDS,
): Array<Omit<AdminActionRow, "at" | "actor">> {
  if (!before || !after) return [];
  const changed = fields.filter((field) => (!only || only.includes(field)) && stable(before[field]) !== stable(after[field]));
  const rows: Array<Omit<AdminActionRow, "at" | "actor">> = [];
  const bot = { kind: "bot", id: target.id, ...(target.name ? { name: target.name } : {}) };
  if (changed.includes("visibility")) {
    const audience = (value: unknown) => value ?? "everyone";
    rows.push({
      category: "visibility", action: "visibility.update", target: bot, changed: ["visibility"],
      before: { visibility: audience(before.visibility) }, after: { visibility: audience(after.visibility) },
    });
  }
  const rest = changed.filter((field) => field !== "visibility");
  if (rest.length) rows.push({ category: "bot", action: "bot.update", target: bot, changed: rest, before: pick(before, rest), after: pick(after, rest) });
  return rows;
}


export interface SignInLists {
  admins: readonly string[];
  members: readonly string[];
}

/** An email sign-in list that lets more than one person in: any member, a
 * second admin, or a whole @domain. */
export function sharedSignIn(lists: SignInLists): boolean {
  return lists.members.length > 0 || lists.admins.length > 1 || [...lists.admins, ...lists.members].some((entry) => entry.trim().startsWith("@"));
}

export function signInListsOf(config: unknown): SignInLists {
  const signIn = isPlainObject(config) && isPlainObject(config.signIn) ? config.signIn : {};
  const list = (value: unknown) => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []);
  return { admins: list(signIn.admins), members: list(signIn.members) };
}


const isRow = (value: unknown): value is AdminActionRow =>
  isPlainObject(value) &&
  typeof value.at === "string" &&
  typeof value.category === "string" &&
  typeof value.action === "string" &&
  isPlainObject(value.actor);

function readRows(path: string): AdminActionRow[] {
  let text: string;
  try {
    text = readFileSync(path, "utf8");
  } catch {
    return [];
  }
  const rows: AdminActionRow[] = [];
  for (const line of text.split("\n")) {
    if (!line) continue;
    try {
      const value: unknown = JSON.parse(line);
      if (isRow(value)) rows.push(value);
    } catch {
    }
  }
  return rows;
}

export function readAdminActivityRange(dataDir: string, range: { from: Date; to: Date }): AdminActionRow[] {
  let months: string[] = [];
  try {
    months = readdirSync(join(dataDir, DIR)).filter((name) => MONTH_FILE.test(name)).sort();
  } catch {
    return [];
  }
  const first = monthKey(range.from);
  const last = monthKey(range.to);
  const from = range.from.getTime();
  const to = range.to.getTime();
  const rows: AdminActionRow[] = [];
  for (const name of months) {
    const month = basename(name, ".ndjson");
    if (month < first || month > last) continue;
    for (const row of readRows(join(dataDir, DIR, name))) {
      const at = Date.parse(row.at);
      if (at >= from && at <= to) rows.push(row);
    }
  }
  return rows;
}


export function adminActorLabel(actor: AdminActor | undefined): string {
  if (actor?.kind === "cli") return "Command line";
  return actorLabel(actor as DecisionActor | undefined);
}

/** `all`: admin actions and every card a person answered. `approvals`: the
 * answers alone. `decisions`: every decision row, automatic ones included.
 * A category: that kind of admin action. */
export type ActivityWhat = "all" | "approvals" | "decisions" | AdminActivityCategory;

export function parseActivityWhat(value: string | null): ActivityWhat | null {
  if (!value) return "all";
  if (value === "all" || value === "approvals" || value === "decisions") return value;
  return (ADMIN_ACTIVITY_CATEGORIES as readonly string[]).includes(value) ? (value as AdminActivityCategory) : null;
}

export type ActivityEntry =
  | {
      type: "approval";
      at: string;
      who: string;
      what: DecisionRow["decision"];
      source: DecisionRow["source"];
      bot?: string;
      tool?: string;
      summary?: string;
      summaryCode?: string;
      summaryParams?: DecisionRow["summaryParams"];
      threadId: string;
      requestId?: string;
    }
  | {
      type: "admin";
      at: string;
      who: string;
      what: AdminActivityCategory;
      action: string;
      target?: AdminActionRow["target"];
      changed?: string[];
      before?: Record<string, unknown>;
      after?: Record<string, unknown>;
    };

export function activityEntries(
  decisions: readonly DecisionRow[],
  actions: readonly AdminActionRow[],
  filter: { what: ActivityWhat; who?: string },
): ActivityEntry[] {
  const who = filter.who?.trim().toLowerCase() ?? "";
  const entries: ActivityEntry[] = [];
  const wantDecision = (row: DecisionRow) =>
    filter.what === "decisions" || ((filter.what === "all" || filter.what === "approvals") && row.source === "user");
  for (const row of decisions) {
    if (!wantDecision(row)) continue;
    const clean = redactSecrets(row) as DecisionRow;
    entries.push({
      type: "approval",
      at: clean.at,
      who: clean.actor ? actorLabel(clean.actor) : "",
      what: clean.decision,
      source: clean.source,
      ...(clean.botName || clean.botId ? { bot: clean.botName || clean.botId } : {}),
      ...(clean.tool ? { tool: clean.tool } : {}),
      ...(clean.summary ? { summary: clean.summary, summaryCode: clean.summaryCode, summaryParams: clean.summaryParams } : {}),
      threadId: clean.threadId,
      ...(clean.requestId ? { requestId: clean.requestId } : {}),
    });
  }
  if (filter.what !== "approvals" && filter.what !== "decisions") {
    for (const row of actions) {
      if (filter.what !== "all" && row.category !== filter.what) continue;
      entries.push({
        type: "admin",
        at: row.at,
        who: adminActorLabel(row.actor),
        what: row.category,
        action: row.action,
        ...(row.target ? { target: row.target } : {}),
        ...(row.changed ? { changed: row.changed } : {}),
        ...(row.before ? { before: row.before } : {}),
        ...(row.after ? { after: row.after } : {}),
      });
    }
  }
  const matched = who ? entries.filter((entry) => entry.who.toLowerCase().includes(who)) : entries;
  return matched.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
}

/** Cells are formula-neutralised; values were redacted when the rows were written. */
export function activityCsv(entries: readonly ActivityEntry[]): string {
  const header = ["time", "type", "who", "what", "action", "target", "changed", "before", "after", "bot", "tool", "summary", "thread"];
  const lines = [header.join(",")];
  for (const entry of entries) {
    const cells = entry.type === "approval"
      ? [entry.at, "approval", entry.who, entry.what, entry.source, "", "", "", "", entry.bot ?? "", entry.tool ?? "", entry.summary ?? "", entry.threadId]
      : [
          entry.at, "admin", entry.who, entry.what, entry.action,
          entry.target ? entry.target.name ?? entry.target.id ?? entry.target.kind : "",
          (entry.changed ?? []).join(" "),
          entry.before ? JSON.stringify(entry.before) : "",
          entry.after ? JSON.stringify(entry.after) : "",
          "", "", "", "",
        ];
    lines.push(cells.map(csvCell).join(","));
  }
  return lines.join("\n") + "\n";
}