Code

/**
 * mcp/skills/purgeCachedCredentials.ts — purge_cached_credentials
 *
 * Removes stored credentials for specified domains from macOS Keychain
 * (login keychain) / Windows Credential Manager.  Used after a cloud
 * IDP password reset so local apps stop presenting the stale cached
 * password to the IDP.
 *
 * WILDCARD DOMAINS ARE REJECTED.  The caller must supply exact domain
 * names (e.g. "okta.com", not "*.okta.com").  Wildcard matching would
 * broaden the blast radius beyond the IDP-domain-only scope G4 and the
 * product docs promise.
 *
 * Platform strategy
 * -----------------
 * darwin  `security delete-internet-password -s <domain>` +
 *         `security delete-generic-password -s <domain>` in a loop
 *         until the exit code is non-zero (no more matching entries).
 * win32   `cmdkey /list` to enumerate; `cmdkey /delete:<target>` for
 *         each target whose name contains the domain.
 *
 * Dry-run enumerates what WOULD be deleted and returns the count and
 * sample target names — no entries are removed.
 *
 * Guardrail: high risk, destructive, requires consent, supportsDryRun,
 * affectedScope ["user"], auditRequired.
 */
 
import { z } from "zod";
import { isDarwin, isWin32, execAsync } from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "purge_cached_credentials",
  description:
    "Removes stored credentials for specified IDP domains from macOS Keychain " +
    "(login keychain) or Windows Credential Manager. Use ONLY after the user " +
    "has confirmed a cloud IDP password reset succeeded. Each domain MUST be " +
    "an exact host match (e.g. 'okta.com') — wildcards are rejected at the " +
    "schema level to prevent broadening the blast radius.",
  riskLevel:       "high",
  destructive:     true,
  requiresConsent: true,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    domains: z
      .array(
        z
          .string()
          .min(1)
          .refine(
            (v) => !/[*?]/.test(v),
            { message: "Wildcard domains are not permitted — supply exact host strings." },
          )
          .refine(
            (v) => /^[a-zA-Z0-9.\-]+$/.test(v),
            { message: "Domain must contain only letters, digits, dots and dashes." },
          ),
      )
      .min(1)
      .max(8)
      .describe("Exact domain strings whose cached credentials should be removed."),
    dryRun: z
      .boolean()
      .optional()
      .describe("When true, enumerate matching entries without deleting."),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface PurgeResult {
  platform:        "darwin" | "win32" | "other";
  dryRun:          boolean;
  /** Per-domain outcome. */
  results:         Array<{
    domain:  string;
    removed: number;
    found:   number;
    sample:  string[];
    error?:  string;
  }>;
  totalRemoved:    number;
  totalFound:      number;
}
 
// -- darwin implementation ----------------------------------------------------
 
/**
 * Delete every `security`-style password entry whose server/service matches
 * the domain.  We loop invoking `security delete-internet-password -s` and
 * `security delete-generic-password -s` until both exit non-zero, which
 * indicates no remaining match.  macOS returns exit code 44 when no item
 * is found — that's the terminating condition.
 */
async function purgeDarwin(domain: string, dryRun: boolean): Promise<{
  removed: number; found: number; sample: string[]; error?: string;
}> {
  const sample: string[] = [];
  let removed = 0;
  let found   = 0;
 
  // Enumerate first so dry-run is informative.  `security find-internet-password
  // -s <domain> -g` prints a short block per hit; we count occurrences of
  // the "acct" field.
  try {
    const { stdout } = await execAsync(
      `security find-internet-password -s ${shellQuote(domain)} 2>&1 || true`,
      { maxBuffer: 2 * 1024 * 1024, timeout: 5_000 },
    );
    const hits = stdout.match(/"acct"<blob>="[^"]*"/g) ?? [];
    found += hits.length;
    sample.push(...hits.slice(0, 5).map((s) => s.replace(/"acct"<blob>=/, "")));
  } catch {
    // ignore — non-zero exit means no match.
  }
  try {
    const { stdout } = await execAsync(
      `security find-generic-password -s ${shellQuote(domain)} 2>&1 || true`,
      { maxBuffer: 2 * 1024 * 1024, timeout: 5_000 },
    );
    const hits = stdout.match(/"acct"<blob>="[^"]*"/g) ?? [];
    found += hits.length;
    sample.push(...hits.slice(0, 5).map((s) => s.replace(/"acct"<blob>=/, "")));
  } catch {
    // ignore
  }
 
  if (dryRun) return { removed: 0, found, sample: sample.slice(0, 10) };
 
  // Delete loops — exit when `security delete-*` exits non-zero.
  const MAX_ITERS = 64; // safety cap against a pathological loop
  for (let i = 0; i < MAX_ITERS; i++) {
    try {
      await execAsync(
        `security delete-internet-password -s ${shellQuote(domain)} 2>&1`,
        { maxBuffer: 1 * 1024 * 1024, timeout: 5_000 },
      );
      removed++;
    } catch {
      break;
    }
  }
  for (let i = 0; i < MAX_ITERS; i++) {
    try {
      await execAsync(
        `security delete-generic-password -s ${shellQuote(domain)} 2>&1`,
        { maxBuffer: 1 * 1024 * 1024, timeout: 5_000 },
      );
      removed++;
    } catch {
      break;
    }
  }
 
  return { removed, found, sample: sample.slice(0, 10) };
}
 
// -- win32 implementation -----------------------------------------------------
 
async function purgeWin32(domain: string, dryRun: boolean): Promise<{
  removed: number; found: number; sample: string[]; error?: string;
}> {
  // Enumerate — cmdkey /list prints blocks like:
  //   Target: Domain:target=xyz.okta.com
  //   Type:   Domain Password
  //   User:   user@xyz.okta.com
  let targets: string[] = [];
  try {
    const { stdout } = await execAsync(`cmdkey /list`, {
      maxBuffer: 2 * 1024 * 1024, timeout: 5_000,
    });
    const lines = stdout.split(/\r?\n/);
    for (const line of lines) {
      const m = line.match(/^\s*Target:\s*(.+?)\s*$/);
      if (!m) continue;
      const raw = m[1].trim();
      // "Domain:target=xyz.okta.com" or "LegacyGeneric:target=…"
      const target = raw.replace(/^[^:]+:target=/, "");
      if (target.toLowerCase().includes(domain.toLowerCase())) {
        targets.push(raw);
      }
    }
  } catch (err) {
    return { removed: 0, found: 0, sample: [], error: (err as Error).message };
  }
 
  const found  = targets.length;
  const sample = targets.slice(0, 10);
 
  if (dryRun) return { removed: 0, found, sample };
 
  let removed = 0;
  for (const raw of targets) {
    try {
      await execAsync(`cmdkey /delete:${shellQuoteWin(raw)}`, {
        maxBuffer: 1 * 1024 * 1024, timeout: 5_000,
      });
      removed++;
    } catch {
      // Continue on individual failures; the caller sees removed < found.
    }
  }
  return { removed, found, sample };
}
 
// -- Shell-escape helpers -----------------------------------------------------
 
function shellQuote(s: string): string {
  // Wrap in single quotes, escape any embedded single quotes.
  return `'${s.replace(/'/g, `'\\''`)}'`;
}
 
function shellQuoteWin(s: string): string {
  return `"${s.replace(/"/g, '""')}"`;
}
 
// Exported for unit tests.
export const __testing = { purgeDarwin, purgeWin32 };
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  domains,
  dryRun = false,
}: {
  domains: string[];
  dryRun?: boolean;
}): Promise<PurgeResult> {
  const platform: "darwin" | "win32" | "other" =
    isDarwin() ? "darwin" : isWin32() ? "win32" : "other";
 
  const results: PurgeResult["results"] = [];
  let totalRemoved = 0;
  let totalFound   = 0;
 
  for (const domain of domains) {
    if (platform === "darwin") {
      const r = await purgeDarwin(domain, dryRun);
      results.push({ domain, ...r });
      totalRemoved += r.removed; totalFound += r.found;
    } else if (platform === "win32") {
      const r = await purgeWin32(domain, dryRun);
      results.push({ domain, ...r });
      totalRemoved += r.removed; totalFound += r.found;
    } else {
      results.push({
        domain, removed: 0, found: 0, sample: [],
        error: `Unsupported platform — cannot purge credentials.`,
      });
    }
  }
 
  return { platform, dryRun, results, totalRemoved, totalFound };
}
 
// -- CLI smoke test -----------------------------------------------------------
 
if (false) {
  run({ domains: ["okta.com"], dryRun: true })
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}