Code

/**
 * mcp/skills/checkKerberosTicket.ts — check_kerberos_ticket
 *
 * Lists active Kerberos tickets and flags ones that have expired or
 * will expire within the configured window.
 *
 * Platform strategy
 * -----------------
 * darwin  `klist` (Heimdal).  Output lists principal + valid-from/to.
 *         No tickets → non-zero exit; treat as a valid empty result.
 * win32   `klist` (built-in).  Output is ticket blocks prefixed with
 *         "#<idx>>".  We parse each block's Client, Server, EndTime.
 *
 * Never throws — parse failures resolve to { tickets: [], status: "error" }.
 */
 
import { z } from "zod";
 
import { execAsync, isDarwin, isWin32 } from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "check_kerberos_ticket",
  description:
    "Lists active Kerberos tickets on the endpoint and flags any that are " +
    "expired or expiring soon. An expired or missing TGT is the top cause " +
    "of 'VPN says authentication failed' and similar AD-login issues. " +
    "Read-only; safe to run without consent.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {
    expiryWarnMinutes: z
      .number()
      .int()
      .min(1)
      .max(1440)
      .optional()
      .describe(
        "How many minutes before expiry to flag a ticket as 'expiring soon'. " +
        "Defaults to 60 (1 hour).",
      ),
  },
  // Top-level keys proactive-trigger DSL conditions may reference.
  // See docs/proactivesupport/PROACTIVE-ARCHITECTURE.md §6.
  // `tickets` is an array; the DSL does not descend into arrays, so it
  // cannot be referenced directly from a condition. Rules that care about
  // ticket validity use `healthy` or `status` instead.
  outputKeys: ["platform", "tickets", "healthy", "status", "message"],
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface KerberosTicket {
  clientPrincipal: string;
  serverPrincipal: string;
  /** ISO 8601 timestamp; null if parsing failed. */
  endTime:         string | null;
  expired:         boolean;
  expiringSoon:    boolean;
}
 
export interface KerberosStatusResult {
  platform: "darwin" | "win32" | "other";
  tickets:  KerberosTicket[];
  /** True when at least one valid, non-expiring ticket is present. */
  healthy:  boolean;
  status:   "ok" | "expired" | "expiring" | "missing" | "error";
  message:  string;
}
 
// -- darwin parser ------------------------------------------------------------
 
function parseDarwinKlist(stdout: string, warnMs: number, now: number): KerberosTicket[] {
  // Heimdal klist sample:
  //   Credentials cache: FILE:/tmp/krb5cc_501
  //           Principal: alice@EXAMPLE.COM
  //     Issued                Expires              Principal
  //   Apr 21 10:00:00 2024  Apr 21 20:00:00 2024  krbtgt/EXAMPLE.COM@EXAMPLE.COM
  const tickets: KerberosTicket[] = [];
  const principalMatch = stdout.match(/Principal:\s*([^\n]+)/);
  const principal = principalMatch ? principalMatch[1].trim() : "(unknown)";
 
  const lines = stdout.split(/\r?\n/);
  for (const line of lines) {
    const m = line.match(
      /^\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d{4}\s+(\w{3}\s+\w{3}\s+\d+\s+\d+:\d+:\d+\s+\d{4})\s+(\S+)/,
    );
    if (!m) continue;
    const endDate = new Date(m[1].replace(/\s+/g, " ") + " GMT+0000"); // best-effort; timezone may drift
    const serverPrincipal = m[2];
    const endMs = endDate.getTime();
    const valid = !isNaN(endMs);
    tickets.push({
      clientPrincipal: principal,
      serverPrincipal,
      endTime: valid ? endDate.toISOString() : null,
      expired: valid ? endMs < now : false,
      expiringSoon: valid ? endMs - now < warnMs && endMs > now : false,
    });
  }
  return tickets;
}
 
// -- win32 parser -------------------------------------------------------------
 
function parseWin32Klist(stdout: string, warnMs: number, now: number): KerberosTicket[] {
  // Windows klist sample:
  //   #0>    Client: alice @ EXAMPLE.COM
  //          Server: krbtgt/EXAMPLE.COM @ EXAMPLE.COM
  //          KerbTicket Encryption Type: ...
  //          Ticket Flags ...
  //          Start Time: 4/21/2024 10:00:00 (local)
  //          End Time:   4/21/2024 20:00:00 (local)
  const tickets: KerberosTicket[] = [];
  const blocks = stdout.split(/#\d+>/).slice(1);
  for (const block of blocks) {
    const clientMatch = block.match(/Client:\s*([^\n\r]+?)\s*$/m);
    const serverMatch = block.match(/Server:\s*([^\n\r]+?)\s*$/m);
    const endMatch    = block.match(/End Time:\s*([^\n\r]+?)\s*$/m);
    if (!clientMatch || !serverMatch) continue;
    const client = clientMatch[1].trim();
    const server = serverMatch[1].trim();
    let endIso: string | null = null;
    let endMs:  number | null = null;
    if (endMatch) {
      const raw = endMatch[1].replace(/\s*\((?:local|UTC)\)\s*$/i, "").trim();
      const d   = new Date(raw);
      if (!isNaN(d.getTime())) {
        endIso = d.toISOString();
        endMs  = d.getTime();
      }
    }
    tickets.push({
      clientPrincipal: client,
      serverPrincipal: server,
      endTime:        endIso,
      expired:        endMs !== null ? endMs < now : false,
      expiringSoon:   endMs !== null ? endMs - now < warnMs && endMs > now : false,
    });
  }
  return tickets;
}
 
/**
 * Classifies a raw klist error message as "missing" (no credential cache)
 * vs "error" (something else).  Extracted so the branch logic can be
 * unit-tested without mocking rejected promises (vitest 4 flags those
 * as unhandled rejections even inside try/catch).
 */
export function classifyKlistError(msg: string): "missing" | "error" {
  return /No credentials|cc_default|No such file/i.test(msg) ? "missing" : "error";
}
 
// Exported for unit tests.
export const __testing = { parseDarwinKlist, parseWin32Klist };
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  expiryWarnMinutes = 60,
}: {
  expiryWarnMinutes?: number;
} = {}): Promise<KerberosStatusResult> {
  const platform: "darwin" | "win32" | "other" =
    isDarwin() ? "darwin" : isWin32() ? "win32" : "other";
 
  if (platform === "other") {
    return {
      platform, tickets: [], healthy: false,
      status: "error",
      message: "Unsupported platform — Kerberos tools not available.",
    };
  }
 
  const warnMs = expiryWarnMinutes * 60 * 1_000;
  const now    = Date.now();
 
  try {
    const { stdout } = await execAsync(`klist`, {
      maxBuffer: 2 * 1024 * 1024, timeout: 5_000,
    });
    const tickets = platform === "darwin"
      ? parseDarwinKlist(stdout, warnMs, now)
      : parseWin32Klist(stdout, warnMs, now);
 
    if (tickets.length === 0) {
      return {
        platform, tickets, healthy: false,
        status: "missing",
        message: "No Kerberos tickets present — user is not currently authenticated to a KDC.",
      };
    }
 
    const allExpired = tickets.every((t) => t.expired);
    const anyExpiring = tickets.some((t) => t.expiringSoon && !t.expired);
    const healthy = tickets.some((t) => !t.expired && !t.expiringSoon);
 
    const status: KerberosStatusResult["status"] =
      allExpired ? "expired" : anyExpiring ? "expiring" : healthy ? "ok" : "error";
 
    const message =
      status === "expired"
        ? `All ${tickets.length} Kerberos ticket(s) have expired. The user must re-authenticate.`
        : status === "expiring"
          ? `At least one Kerberos ticket expires within ${expiryWarnMinutes} minute(s).`
          : `Kerberos looks healthy — ${tickets.length} ticket(s) valid.`;
 
    return { platform, tickets, healthy, status, message };
  } catch (err) {
    // klist exits non-zero when no tickets exist on darwin.  Many Heimdal
    // distributions emit "klist: No credentials cache…" to stderr.
    const msg = (err as Error).message;
    const classification = classifyKlistError(msg);
    if (classification === "missing") {
      return {
        platform, tickets: [], healthy: false,
        status: "missing",
        message: "No Kerberos credentials cache found — user has no active tickets.",
      };
    }
    return {
      platform, tickets: [], healthy: false,
      status: "error",
      message: `klist failed: ${msg}`,
    };
  }
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run()
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}