Checks whether this device is enrolled in an MDM (Mobile Device Management) system such as Jamf, Intune, or Apple Business Manager. MDM enrollment enables remote management and policy-pushed reinstalls.
/** * mcp/skills/checkMdmEnrollment.ts — check_mdm_enrollment skill * * Checks whether this device is enrolled in an MDM (Mobile Device Management) * system such as Jamf, Intune, or Apple Business Manager. MDM enrollment * enables remote management and policy-pushed reinstalls. * * Platform strategy * ----------------- * darwin `profiles status -type enrollment`, `sudo -n profiles show -type enrollment`, * `jamf checkJSSConnection`, `system_profiler SPConfigurationProfileDataType` * win32 PowerShell registry check under HKLM:\SOFTWARE\Microsoft\Enrollments * * Hang prevention * --------------- * Every `exec` call carries a 5 s internal timeout, and the whole platform call * races a 8 s top-level budget so a wedged subprocess can never block past the * G3 probe (3 s) or trip the G4 global tool timeout (30 s). `sudo` is invoked * with `-n` (non-interactive) so it fails fast when cached credentials are * absent, instead of hanging on a TTY-less password prompt. * * Smoke test * npx tsx -r dotenv/config mcp/skills/checkMdmEnrollment.ts */import * as os from "os";import { execAsync } from "./_shared/platform";import { readDeviceSerial } from "./_shared/deviceIdentity";// -- Internal timing constants ------------------------------------------------//// Per-exec timeout (5 s) is well below G3's per-tool budget (3 s × parallel)// and the G4 global tool timeout (30 s). The top-level race budget (8 s) is// the worst-case for a tool that must run several `exec` calls in series on a// healthy MDM-enrolled Mac.const EXEC_TIMEOUT_MS = 5_000;const OVERALL_BUDGET_MS = 8_000;const EXEC_KILL_SIGNAL = "SIGKILL" as const;// -- Meta ---------------------------------------------------------------------export const meta = { name: "check_mdm_enrollment", description: "Checks whether this device is enrolled in an MDM (Mobile Device Management) " + "system such as Jamf, Intune, or Apple Business Manager. MDM enrollment enables " + "remote management and policy-pushed reinstalls.", riskLevel: "low", destructive: false, requiresConsent: false, supportsDryRun: false, affectedScope: ["user"], auditRequired: false, outputKeys: ["isEnrolled","mdmProvider","enrollmentType","serverUrl","supervised","lastCheckinAttempt","serialNumber","source"], schema: {},} as const;// -- Types --------------------------------------------------------------------type EnrollmentType = "user_approved" | "device_enrollment" | "none" | "unknown";/** * `source` records how the result was produced so downstream code (and the * LLM scratchpad) can distinguish a confirmed not-enrolled state from a * partial probe that timed out before it could rule MDM enrollment in or out. */type ResultSource = "full" | "fast-path" | "timeout" | "error";interface MdmEnrollmentResult { isEnrolled: boolean; mdmProvider: string | null; enrollmentType: EnrollmentType; serverUrl: string | null; supervised: boolean | null; lastCheckinAttempt: string | null; /** * Hardware serial. Carried here rather than in a tool of its own because * every MDM-connector skill checks enrollment before acting anyway, and the * connector's device lookup is keyed on the serial — one probe, one call. * Null whenever the read fails; callers must treat it as optional. */ serialNumber: string | null; source: ResultSource;}function emptyResult(source: ResultSource): MdmEnrollmentResult { return { isEnrolled: false, mdmProvider: null, enrollmentType: "none", serverUrl: null, supervised: null, lastCheckinAttempt: null, serialNumber: null, source, };}// -- PowerShell helper --------------------------------------------------------async function runPS(script: string): Promise<string> { const encoded = Buffer.from(script, "utf16le").toString("base64"); const { stdout } = await execAsync( `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encoded}`, { maxBuffer: 20 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS, killSignal: EXEC_KILL_SIGNAL, }, ); return stdout.trim();}// -- darwin implementation ----------------------------------------------------async function checkMdmEnrollmentDarwin(): Promise<MdmEnrollmentResult> { const result = emptyResult("fast-path"); // 1. profiles status -type enrollment — fast, no sudo, available on all Macs try { const { stdout } = await execAsync( "profiles status -type enrollment 2>/dev/null", { maxBuffer: 1024 * 1024, timeout: EXEC_TIMEOUT_MS, killSignal: EXEC_KILL_SIGNAL, }, ); const enrollmentLine = stdout.split("\n").find((l) => /MDM enrollment/i.test(l)) ?? ""; if (/Yes/i.test(enrollmentLine)) { result.isEnrolled = true; result.enrollmentType = /User Approved/i.test(enrollmentLine) ? "user_approved" : "device_enrollment"; } } catch { // profiles not available or permission denied — fall through to other probes } // 2. sudo -n profiles show -type enrollment (more details). // `-n` is critical: if cached sudo credentials aren't present, sudo exits // immediately rather than prompting for a password on a non-existent TTY // (which is what was hanging the call for the full 30 s tool timeout). try { const { stdout } = await execAsync( "sudo -n profiles show -type enrollment 2>/dev/null", { maxBuffer: 2 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS, killSignal: EXEC_KILL_SIGNAL, }, ); const urlMatch = stdout.match(/ServerURL\s*=\s*"?([^";\n]+)/i); if (urlMatch) result.serverUrl = urlMatch[1].trim(); if (!result.mdmProvider && result.serverUrl) { if (/jamf/i.test(result.serverUrl)) result.mdmProvider = "Jamf"; if (/intune|microsoft/i.test(result.serverUrl)) result.mdmProvider = "Microsoft Intune"; if (/apple/i.test(result.serverUrl)) result.mdmProvider = "Apple MDM"; if (!result.mdmProvider) result.mdmProvider = result.serverUrl; } const checkinMatch = stdout.match(/LastCheckin\s*=\s*([^\n;]+)/i); if (checkinMatch) result.lastCheckinAttempt = checkinMatch[1].trim(); // Reaching this branch means sudo ran — promote the source label. result.source = "full"; } catch { // sudo -n failed (no cached creds) OR profiles failed — keep best-effort // data from step 1. Not an error; just less detail. } // 3. Check Jamf specifically (only if not already identified). // Step 1 (profiles status -type enrollment) is the authoritative source. // This Jamf-specific fallback covers cases where Apple's `profiles` CLI is // unavailable or restricted but `jamf checkJSSConnection` confirms an // active JSS link — strong evidence of Jamf-based MDM enrollment. if (!result.mdmProvider) { try { const { stdout } = await execAsync( "jamf checkJSSConnection 2>/dev/null", { maxBuffer: 1024 * 1024, timeout: EXEC_TIMEOUT_MS, killSignal: EXEC_KILL_SIGNAL, }, ); if (/successfully/i.test(stdout) || /connected/i.test(stdout)) { result.isEnrolled = true; result.mdmProvider = "Jamf"; const urlMatch = stdout.match(/https?:\/\/[^\s]+/); if (urlMatch && !result.serverUrl) result.serverUrl = urlMatch[0]; } } catch { // jamf binary not installed — expected on most non-MDM machines } } // 4. system_profiler for supervision status — metadata enrichment only. // SPConfigurationProfileDataType lists ALL installed configuration // profiles (Wi-Fi, VPN, certs, AppleSeed/Beta enrollment, manually // installed .mobileconfig files), NOT just MDM-pushed ones. A device // can have profiles without being MDM-enrolled, so we must NOT use // profile presence to flip isEnrolled. Step 1 (profiles status -type // enrollment) is the authoritative source for MDM enrollment state. if (result.isEnrolled) { try { const { stdout } = await execAsync( "system_profiler SPConfigurationProfileDataType -json 2>/dev/null", { maxBuffer: 5 * 1024 * 1024, timeout: EXEC_TIMEOUT_MS, killSignal: EXEC_KILL_SIGNAL, }, ); if (stdout.trim()) { const parsed = JSON.parse(stdout) as { SPConfigurationProfileDataType?: Array<{ _supervised?: boolean }>; }; const profiles = parsed.SPConfigurationProfileDataType ?? []; if (profiles.length > 0) { result.supervised = profiles[0]._supervised ?? null; if (!result.mdmProvider) result.mdmProvider = "Unknown MDM"; } } } catch { // system_profiler may fail or return empty — best effort } } if (!result.isEnrolled) result.enrollmentType = "none"; return result;}// -- win32 implementation -----------------------------------------------------async function checkMdmEnrollmentWin32(): Promise<MdmEnrollmentResult> { const ps = `$ErrorActionPreference = 'SilentlyContinue'$enrollments = Get-ChildItem 'HKLM:\\SOFTWARE\\Microsoft\\Enrollments' -ErrorAction SilentlyContinue | Get-ItemProperty -ErrorAction SilentlyContinue | Select-Object UPN,ProviderID,EnrollmentState,DiscoveryServiceFullURLif ($enrollments) { @($enrollments) | ConvertTo-Json -Depth 2 -Compress } else { '[]' }`.trim(); const result = emptyResult("full"); try { const raw = await runPS(ps); const parsed = JSON.parse(raw ?? "[]") as Array<{ UPN?: string; ProviderID?: string; EnrollmentState?: number; DiscoveryServiceFullURL?: string; }>; const active = parsed.filter((e) => e.EnrollmentState === 1); if (active.length > 0) { result.isEnrolled = true; result.enrollmentType = "device_enrollment"; result.mdmProvider = active[0].ProviderID ?? null; result.serverUrl = active[0].DiscoveryServiceFullURL ?? null; // ProviderID is often just the generic OMA-DM protocol identifier // ("MS DM Server") on real Intune enrollments, not a vendor name, so it // alone can't be trusted to rule a provider out. Check ProviderID and // DiscoveryServiceFullURL independently (not else-if) and accept a // match from either — mirrors the darwin implementation's sequential // (non-exclusive) ifs against serverUrl. If neither matches, the raw // ProviderID value (e.g. "MS DM Server") is kept as-is rather than // discarded, so the caller still sees real diagnostic data. if (result.mdmProvider) { if (/intune|microsoft/i.test(result.mdmProvider)) result.mdmProvider = "Microsoft Intune"; if (/jamf/i.test(result.mdmProvider)) result.mdmProvider = "Jamf"; } if (result.serverUrl) { if (/intune|microsoft/i.test(result.serverUrl)) result.mdmProvider = "Microsoft Intune"; if (/jamf/i.test(result.serverUrl)) result.mdmProvider = "Jamf"; } } } catch { // Registry access failed or PowerShell hit its internal exec timeout. result.source = "error"; } return result;}// -- Top-level race -----------------------------------------------------------//// Defence-in-depth: even if every individual exec respects its 5 s timeout,// the platform implementation must still settle within OVERALL_BUDGET_MS so// the tool never trips G4's 30 s global gate. When the race fires, downstream// code sees `source: "timeout"` and treats the device as not-enrolled, which// is the correct conservative default for the software-reinstall workflow./** * Generic deadline race — resolves to `fallback` if `work` has not settled * within `ms`, and swallows rejections the same way. Used for the serial * read so it can never outlive the tool's overall budget. */function withDeadline<T>(work: Promise<T>, ms: number, fallback: T): Promise<T> { return new Promise((resolve) => { let settled = false; const done = (val: T) => { if (settled) return; settled = true; clearTimeout(timer); resolve(val); }; const timer = setTimeout(() => { if (settled) return; settled = true; resolve(fallback); }, ms); work.then(done, () => done(fallback)); });}function withOverallBudget<T extends MdmEnrollmentResult>( work: Promise<T>, budget: number,): Promise<MdmEnrollmentResult> { return new Promise((resolve) => { let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; resolve(emptyResult("timeout")); }, budget); work.then( (val) => { if (settled) return; settled = true; clearTimeout(timer); resolve(val); }, () => { if (settled) return; settled = true; clearTimeout(timer); resolve(emptyResult("error")); }, ); });}// -- Exported run function ----------------------------------------------------export async function run(_args: Record<string, never> = {}) { const platform = os.platform(); const work = platform === "win32" ? checkMdmEnrollmentWin32() : checkMdmEnrollmentDarwin(); // The serial read is independent of the enrollment probe, so it runs // concurrently. It races the SAME budget — otherwise a wedged `ioreg` // would hold the whole call past OVERALL_BUDGET_MS and break the timing // contract the G-layers rely on. A late serial is dropped, not awaited. const [result, serialNumber] = await Promise.all([ withOverallBudget(work, __testing.budgetMs), withDeadline(readDeviceSerial(), __testing.budgetMs, null), ]); return { ...result, serialNumber };}/** * Test-only knob — lets unit tests assert the overall-budget race fires * without waiting 8 s of wall-clock time. Production code never imports * this; the runtime simply reads `__testing.budgetMs` once per call. */export const __testing = { budgetMs: OVERALL_BUDGET_MS };// -- CLI smoke test -----------------------------------------------------------if (false) { run({}) .then(r => console.log(JSON.stringify(r, null, 2))) .catch((err: Error) => { console.error(err.message); process.exit(1); });}