repair_keychain

repair_keychain

Diagnoses and repairs macOS Keychain issues. Can check keychain status, attempt first-aid repair, or delete and recreate the login keychain. Common after password changes that desync the login keychain. Use when apps report repeated keychain prompts or authentication failures.

Metadata

Name

repair_keychain

Updated

3 weeks ago

Source

GitHub source

Risk

High

Requires consent

true

Affected scope

user

Code

/**
 * mcp/skills/repairKeyChain.ts — repair_keychain skill
 *
 * Diagnoses and repairs macOS Keychain issues. Can check keychain status,
 * attempt first-aid repair, or delete and recreate the login keychain.
 * Common after password changes that desync the login keychain. Use when apps
 * report repeated keychain prompts or authentication failures.
 *
 * Platform strategy
 * -----------------
 * darwin  security list-keychains, security show-keychain-info,
 *         security unlock-keychain, fs.rename for reset
 * win32   cmdkey /list for check, vaultcmd /listcreds:* for full credential list
 *
 * Smoke test
 *   npx tsx -r dotenv/config mcp/skills/repairKeyChain.ts
 */

import * as fs       from "fs/promises";
import * as os       from "os";
import * as nodePath from "path";
import { exec }      from "child_process";
import { promisify } from "util";
import { z }         from "zod";

const execAsync = promisify(exec);

// Every keychain command is fast (list / show-info / lock / rename). Cap each so
// a wedged subprocess can never hang the run, and thread the executor's
// AbortSignal so Stop / the G4 deadline can actually kill the child. Without
// both, `security unlock-keychain` (the old repair path) blocked indefinitely on
// an interactive password dialog and could not be cancelled.
const KEYCHAIN_TIMEOUT_MS = 10_000;

/** Optional run-context handed in by the G4 executor (signal + deadline). */
interface ToolCtx { signal?: AbortSignal; deadlineMs?: number }

// -- Meta ---------------------------------------------------------------------

export const meta = {
  name: "repair_keychain",
  description:
    "Diagnoses and repairs macOS Keychain issues. Can check keychain status, " +
    "attempt first-aid repair, or delete and recreate the login keychain. " +
    "Common after password changes that desync the login keychain. " +
    "Use when apps report repeated keychain prompts or authentication failures.",
  riskLevel:       "high",
  destructive:     false,
  requiresConsent: true,
  supportsDryRun:  true,
  affectedScope:   ["user"],
  auditRequired:   true,
  schema: {
    action: z
      .enum(["check", "repair", "reset"])
      .describe("check=status only, repair=lock the login keychain so the next app access re-prompts once with the current password (non-interactive; clears a post-password-change desync), reset=move the login keychain aside so macOS recreates it (destructive)"),
    dryRun: z
      .boolean()
      .optional()
      .describe("For reset action: if true show what would be deleted. Default: true"),
  },
} as const;

// -- Types --------------------------------------------------------------------

interface KeychainResult {
  action:   string;
  keychains: string[];
  status:   string;
  repaired: boolean;
  message:  string;
}

// -- PowerShell helper --------------------------------------------------------

async function runPS(script: string, signal?: AbortSignal): Promise<string> {
  const encoded = Buffer.from(script, "utf16le").toString("base64");
  const { stdout } = await execAsync(
    `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
    { maxBuffer: 10 * 1024 * 1024, timeout: KEYCHAIN_TIMEOUT_MS, signal },
  );
  return stdout.trim();
}

// -- darwin implementation ----------------------------------------------------

async function repairKeychainDarwin(
  action: "check" | "repair" | "reset",
  dryRun: boolean,
  signal?: AbortSignal,
): Promise<KeychainResult> {
  const loginKeychainPath = nodePath.join(
    os.homedir(), "Library", "Keychains", "login.keychain-db",
  );

// Always list keychains
  let keychains: string[] = [];
  try {
    const { stdout } = await execAsync(
      "security list-keychains 2>/dev/null",
      { maxBuffer: 1 * 1024 * 1024, timeout: KEYCHAIN_TIMEOUT_MS, signal },
    );
    keychains = stdout
      .trim()
      .split("\n")
      .map((l) => l.trim().replace(/^\"|\"$/g, ""))
      .filter(Boolean);
  } catch {
    // ignore
  }

if (action === "check") {
    let status = "unknown";
    try {
      const { stdout } = await execAsync(
        `security show-keychain-info '${loginKeychainPath.replace(/'/g, `'\\''`)}' 2>&1`,
        { maxBuffer: 1 * 1024 * 1024, shell: "/bin/bash", timeout: KEYCHAIN_TIMEOUT_MS, signal },
      );
      status = stdout.trim() || "ok";
    } catch (err) {
      status = (err as { stderr?: string }).stderr?.trim() ?? "error reading keychain info";
    }
    return {
      action,
      keychains,
      status,
      repaired: false,
      message:  "Keychain status retrieved. No changes made.",
    };
  }

if (action === "repair") {
    // LOCK (not unlock) the login keychain. `security unlock-keychain` without a
    // password opens an interactive GUI dialog and blocks the run forever in a
    // non-TTY context — and we must never handle the user's password. Locking
    // needs no password, never prompts, and is the right gentle repair for the
    // common "password change desynced the keychain → repeated prompts" case:
    // after a lock, the next app that needs a keychain item triggers ONE normal
    // macOS unlock dialog where the user enters their CURRENT login password,
    // re-syncing the keychain.
    try {
      await execAsync(
        `security lock-keychain '${loginKeychainPath.replace(/'/g, `'\\''`)}' 2>/dev/null`,
        { maxBuffer: 1 * 1024 * 1024, shell: "/bin/bash", timeout: KEYCHAIN_TIMEOUT_MS, signal },
      );
      return {
        action,
        keychains,
        status:   "login keychain locked",
        repaired: true,
        message:
          "Locked the login keychain. The next app that needs it will prompt once " +
          "for your CURRENT login password — enter it to re-sync. If prompts persist " +
          "afterward, run a reset.",
      };
    } catch (err) {
      return {
        action,
        keychains,
        status:   "lock failed",
        repaired: false,
        message:  `Could not lock the login keychain: ${(err as Error).message}. Try a reset instead.`,
      };
    }
  }

// action === "reset"
  const backupPath = loginKeychainPath + `.backup-${Date.now()}`;

if (dryRun) {
    let exists = false;
    try {
      await fs.access(loginKeychainPath);
      exists = true;
    } catch {
      // file does not exist
    }
    return {
      action,
      keychains,
      status:   exists ? "login keychain found" : "login keychain not found",
      repaired: false,
      message:  exists
        ? `Dry run: would rename '${loginKeychainPath}' to '${backupPath}'. Run with dryRun=false to apply.`
        : `Dry run: login keychain not found at expected path. Nothing to reset.`,
    };
  }

// Perform actual reset
  try {
    await fs.rename(loginKeychainPath, backupPath);
    return {
      action,
      keychains,
      status:   "keychain moved to backup",
      repaired: true,
      message:
        `Login keychain moved to '${backupPath}'. ` +
        "A new keychain will be created on next login. " +
        "You will need to re-enter passwords for apps that used the old keychain.",
    };
  } catch (err) {
    return {
      action,
      keychains,
      status:   "reset failed",
      repaired: false,
      message:  `Failed to reset keychain: ${(err as Error).message}`,
    };
  }
}

// -- win32 implementation -----------------------------------------------------

async function repairKeychainWin32(
  action: "check" | "repair" | "reset",
  signal?: AbortSignal,
): Promise<KeychainResult> {
  if (action === "check") {
    // List Windows Credential Manager entries
    let keychains: string[] = [];
    let status = "unknown";
    try {
      const { stdout } = await execAsync(
        "cmdkey /list 2>nul",
        { maxBuffer: 2 * 1024 * 1024, timeout: KEYCHAIN_TIMEOUT_MS, signal },
      );
      keychains = stdout
        .split("\n")
        .filter((l) => l.trim().startsWith("Target:"))
        .map((l) => l.replace("Target:", "").trim());
      status = `${keychains.length} credential(s) found in Windows Credential Manager`;
    } catch {
      status = "Could not read Windows Credential Manager";
    }

// Try vaultcmd for full list
    try {
      const ps = `
$ErrorActionPreference = 'SilentlyContinue'
(vaultcmd /listcreds:"Windows Credentials" 2>&1) -join "|"`.trim();
      const vaultOut = await runPS(ps, signal);
      if (vaultOut) status += `. Vault: ${vaultOut.substring(0, 200)}`;
    } catch {
      // ignore
    }

return {
      action,
      keychains,
      status,
      repaired: false,
      message:  "Windows Credential Manager status retrieved. No changes made.",
    };
  }

// repair/reset on Windows: note keychain concept is macOS specific
  return {
    action,
    keychains: [],
    status:    "not applicable",
    repaired:  false,
    message:
      "Keychain repair/reset is a macOS concept. " +
      "On Windows, use Credential Manager (control panel) to manage stored credentials.",
  };
}

// -- Exported run function ----------------------------------------------------

export async function run(
  {
    action,
    dryRun = true,
  }: {
    action: "check" | "repair" | "reset";
    dryRun?: boolean;
  },
  ctx: ToolCtx = {},
) {
  if (!action) throw new Error("[repair_keychain] action is required");

const platform = os.platform();

const result = platform === "win32"
    ? await repairKeychainWin32(action, ctx.signal)
    : await repairKeychainDarwin(action, dryRun, ctx.signal);

return { platform, dryRun, ...result };
}

// -- CLI smoke test -----------------------------------------------------------

if (false) {
  run({ action: "check" })
    .then(r => console.log(JSON.stringify(r, null, 2)))
    .catch((err: Error) => { console.error(err.message); process.exit(1); });
}