# 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](https://github.com/idemeum/skills/blob/main/repairKeychain.ts)

#### 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); });
}

```
