purge_cached_credentials
purge_cached_credentials
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.
Metadata
Name
purge_cached_credentials
Updated
2 months ago
Source
Risk
High
Requires consent
true
Affected scope
user
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); });
}