Code

/**
 * mcp/skills/renewKerberosTicket.ts — renew_kerberos_ticket
 *
 * Renews or refreshes the user's Kerberos ticket-granting ticket (TGT)
 * after check_kerberos_ticket reports the ticket expired or expiring.
 *
 * Platform strategy
 * -----------------
 * darwin  `kinit -R` first (silent renewal of an existing TGT; preferred
 *         because no password prompt).  If that fails, we DO NOT fall
 *         back to `kinit <principal>` — that prompts for a password on
 *         stdin, which we refuse to handle in-agent.  Instead we return
 *         an "interactive" status and ask the user to run kinit
 *         themselves in their terminal.
 * win32   `klist purge` then `gpupdate /force` — purging the cache
 *         triggers Windows to re-acquire a TGT on the next operation
 *         that needs one, using the cached user credentials.
 *
 * Never handles passwords.  The destructive=false classification
 * reflects the fact that renewals are reversible (a new TGT can always
 * be acquired).  requiresConsent because repeated kinit failures can
 * lock a domain account.
 */
 
import { z } from "zod";
 
import { execAsync, isDarwin, isWin32 } from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "renew_kerberos_ticket",
  description:
    "Renews the endpoint's Kerberos ticket-granting ticket (TGT) without " +
    "prompting for a password. On macOS uses `kinit -R`; on Windows uses " +
    "`klist purge` + `gpupdate /force`. Never accepts a password parameter " +
    "— if interactive authentication is required, returns a clear " +
    "'interactive' status and asks the user to run kinit in their terminal.",
  riskLevel:       "medium",
  destructive:     false,
  requiresConsent: true,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  // Only the Windows path needs admin (gpupdate /force). The macOS path
  // (kinit -R) runs as the user with no elevation needed, so darwin is
  // omitted here — escalation isn't applicable for that platform.
  escalationHint:  {
    win32: "gpupdate /force  # run from elevated Command Prompt; refreshes Group Policy and re-issues Kerberos tickets",
  },
  schema: {
    dryRun: z
      .boolean()
      .optional()
      .describe("When true, report the command that would run without executing."),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface RenewResult {
  platform: "darwin" | "win32" | "other";
  command:  string;
  dryRun:   boolean;
  status:   "renewed" | "interactive" | "failed" | "unsupported";
  stdout?:  string;
  error?:   string;
  message:  string;
}
 
// -- Implementation -----------------------------------------------------------
 
async function renewDarwin(dryRun: boolean): Promise<RenewResult> {
  const command = `kinit -R`;
  if (dryRun) {
    return {
      platform: "darwin", command, dryRun: true, status: "renewed",
      message: `Would run \`${command}\` to renew the existing TGT.`,
    };
  }
  try {
    const { stdout } = await execAsync(command, {
      maxBuffer: 1 * 1024 * 1024, timeout: 10_000,
    });
    return {
      platform: "darwin", command, dryRun: false, status: "renewed",
      stdout,
      message: "Kerberos ticket renewed successfully.",
    };
  } catch (err) {
    const msg = (err as Error).message;
    // kinit -R fails with "cannot find a renewable ticket" when the
    // existing TGT has already expired or was never renewable — this is
    // a genuine case for interactive auth.  We surface it clearly and
    // refuse to prompt.
    const classification = classifyKinitError(msg);
    if (classification === "interactive") {
      return {
        platform: "darwin", command, dryRun: false, status: "interactive",
        error: msg,
        message:
          "Existing ticket is not renewable (missing or expired). " +
          "Open a terminal and run `kinit <your-principal>` to obtain a " +
          "fresh TGT — the agent will not handle your password.",
      };
    }
    return {
      platform: "darwin", command, dryRun: false, status: "failed",
      error: msg,
      message: `kinit -R failed: ${msg}`,
    };
  }
}
 
async function renewWin32(dryRun: boolean): Promise<RenewResult> {
  const command = `klist purge && gpupdate /force`;
  if (dryRun) {
    return {
      platform: "win32", command, dryRun: true, status: "renewed",
      message: `Would run \`klist purge\` then \`gpupdate /force\` to trigger TGT refresh.`,
    };
  }
  try {
    // Run the two commands sequentially so a failure in one is visible.
    const purge = await execAsync(`klist purge`, {
      maxBuffer: 1 * 1024 * 1024, timeout: 10_000,
    });
    const update = await execAsync(`gpupdate /force`, {
      maxBuffer: 1 * 1024 * 1024, timeout: 30_000,
    });
    return {
      platform: "win32", command, dryRun: false, status: "renewed",
      stdout: `${purge.stdout}\n${update.stdout}`,
      message: "Kerberos ticket cache purged and Group Policy refresh triggered.",
    };
  } catch (err) {
    const msg = (err as Error).message;
    return {
      platform: "win32", command, dryRun: false, status: "failed",
      error: msg,
      message: `Kerberos refresh failed: ${msg}`,
    };
  }
}
 
/**
 * Classifies a raw kinit -R error message as "interactive" (no renewable
 * credential → user must run kinit manually) vs "failed" (any other
 * error).  Extracted so the branch logic can be unit-tested without
 * mocking rejected promises (vitest 4 flags those as unhandled
 * rejections even when caught).
 */
export function classifyKinitError(msg: string): "interactive" | "failed" {
  return /renewable|No credentials|cache|expired/i.test(msg) ? "interactive" : "failed";
}
 
// Exported for unit tests.
export const __testing = { renewDarwin, renewWin32 };
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  dryRun = false,
}: {
  dryRun?: boolean;
} = {}): Promise<RenewResult> {
  const platform: "darwin" | "win32" | "other" =
    isDarwin() ? "darwin" : isWin32() ? "win32" : "other";
 
  if (platform === "darwin") return renewDarwin(dryRun);
  if (platform === "win32")  return renewWin32(dryRun);
 
  return {
    platform: "other", command: "(unsupported)", dryRun, status: "unsupported",
    message: "Unsupported platform — Kerberos tools not available.",
  };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({ dryRun: true })
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}