Code

/**
 * mcp/skills/checkNtpStatus.ts — check_ntp_status
 *
 * Reports the endpoint's NTP configuration + offset from a reference
 * source.  An offset > ~5 min silently breaks Kerberos, SAML, and TOTP
 * simultaneously — this tool is how the identity-auth-repair skill
 * detects that root cause.
 *
 * Platform strategy
 * -----------------
 * darwin  `sntp -d <server>` — unauthenticated NTP query (no admin).
 *         Parses "offset <seconds>" from the debug output.
 * win32   `w32tm /query /status` — reports Source, Phase Offset,
 *         Last Successful Sync Time.  Also surfaces the service state
 *         so we can flag disabled W32Time on workgroup machines.
 *
 * Returns an offset in milliseconds (positive = endpoint is ahead of
 * reference; negative = behind).  Never throws — network / exec
 * failures resolve to { offsetMs: null, status: "error", … }.
 */
 
import { z } from "zod";
 
import { execAsync, isDarwin, isWin32 } from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "check_ntp_status",
  description:
    "Reports the endpoint's NTP synchronisation status and current offset " +
    "from a reference time source, in milliseconds. An offset > ~300000 ms " +
    "(5 minutes) breaks Kerberos, SAML, and TOTP simultaneously — this tool " +
    "is the first diagnostic step when users report 'all SSO apps are " +
    "broken.' Read-only; safe to run without consent.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  schema: {
    server: z
      .string()
      .optional()
      .describe(
        "Reference NTP server. Defaults to 'time.apple.com' on macOS and " +
        "'time.windows.com' on Windows.",
      ),
  },
  // Top-level keys proactive-trigger DSL conditions may reference.
  // See docs/proactivesupport/PROACTIVE-ARCHITECTURE.md §6.
  outputKeys: [
    "platform",
    "server",
    "offsetMs",
    "absOffsetMs",
    "lastSync",
    "serviceState",
    "status",
    "message",
  ],
} as const;
 
// -- Types --------------------------------------------------------------------
 
export interface NtpStatusResult {
  platform:    "darwin" | "win32" | "other";
  server:      string;
  /** Offset in milliseconds (positive = endpoint ahead, negative = behind). */
  offsetMs:    number | null;
  /** Absolute value of offsetMs; convenient for threshold checks. */
  absOffsetMs: number | null;
  /** Human-readable last successful sync time (null on darwin fallback or unknown). */
  lastSync:    string | null;
  /** W32Time / timed service state ("running" / "stopped" / "unknown"). */
  serviceState: "running" | "stopped" | "unknown";
  status:      "ok" | "drifted" | "error";
  message:     string;
}
 
const DRIFT_THRESHOLD_MS = 5 * 60 * 1_000; // 5 min — standard Kerberos tolerance
 
// -- darwin implementation ----------------------------------------------------
 
async function checkDarwin(server: string): Promise<NtpStatusResult> {
  // `sntp -d` emits lines like:
  //   sntp: 2024-04-21 23:10:01 -0.123456 +/- 0.045 time.apple.com
  // and also a "leap 0, ..." line. We grep for the signed float after the date.
  try {
    const { stdout } = await execAsync(
      `sntp -d ${shellQuote(server)} 2>&1`,
      { maxBuffer: 1 * 1024 * 1024, timeout: 5_000 },
    );
    const match = stdout.match(/[-+]\d+(?:\.\d+)?\s+\+\/-/);
    if (!match) {
      return {
        platform: "darwin", server,
        offsetMs: null, absOffsetMs: null,
        lastSync: null, serviceState: "unknown",
        status: "error",
        message: `sntp returned no parseable offset: ${stdout.slice(0, 200)}`,
      };
    }
    const offsetSeconds = parseFloat(match[0].replace(/\s+\+\/-$/, "").trim());
    const offsetMs = Math.round(offsetSeconds * 1_000);
    const absMs    = Math.abs(offsetMs);
    const drifted  = absMs > DRIFT_THRESHOLD_MS;
    return {
      platform: "darwin", server,
      offsetMs, absOffsetMs: absMs,
      lastSync:     null, // sntp one-shot doesn't report last-sync from the daemon
      serviceState: "unknown",
      status:       drifted ? "drifted" : "ok",
      message: drifted
        ? `Endpoint clock is ${Math.round(absMs / 1000)}s ${offsetMs > 0 ? "ahead of" : "behind"} ${server} — Kerberos/SAML/TOTP will fail.`
        : `Endpoint clock is within ${Math.round(absMs)}ms of ${server}.`,
    };
  } catch (err) {
    return {
      platform: "darwin", server,
      offsetMs: null, absOffsetMs: null,
      lastSync: null, serviceState: "unknown",
      status: "error",
      message: `sntp failed: ${(err as Error).message}`,
    };
  }
}
 
// -- win32 implementation -----------------------------------------------------
 
async function checkWin32(server: string): Promise<NtpStatusResult> {
  // Service probe first — a stopped W32Time service is the top cause of
  // inaccurate Windows clocks.  `sc query w32time` reports STATE: 4 RUNNING.
  let serviceState: "running" | "stopped" | "unknown" = "unknown";
  try {
    const { stdout } = await execAsync(`sc query w32time`, {
      maxBuffer: 1 * 1024 * 1024, timeout: 5_000,
    });
    if (/STATE\s*:\s*4\s*RUNNING/i.test(stdout))   serviceState = "running";
    else if (/STATE\s*:\s*1\s*STOPPED/i.test(stdout)) serviceState = "stopped";
  } catch {
    // Service query failed — leave serviceState as "unknown".
  }
 
  try {
    const { stdout } = await execAsync(`w32tm /query /status`, {
      maxBuffer: 1 * 1024 * 1024, timeout: 5_000,
    });
    // Phase Offset: 0.0012345s  (English locale)
    const offMatch = stdout.match(/Phase Offset\s*:\s*([-+]?\d+(?:\.\d+)?)s/i);
    const lastMatch = stdout.match(/Last Successful Sync Time\s*:\s*(.+)/i);
    if (!offMatch) {
      return {
        platform: "win32", server,
        offsetMs: null, absOffsetMs: null,
        lastSync: lastMatch ? lastMatch[1].trim() : null,
        serviceState,
        status: "error",
        message: `w32tm returned no parseable Phase Offset.`,
      };
    }
    const offsetMs = Math.round(parseFloat(offMatch[1]) * 1_000);
    const absMs    = Math.abs(offsetMs);
    const drifted  = absMs > DRIFT_THRESHOLD_MS;
    return {
      platform: "win32", server,
      offsetMs, absOffsetMs: absMs,
      lastSync: lastMatch ? lastMatch[1].trim() : null,
      serviceState,
      status:   drifted ? "drifted" : "ok",
      message: drifted
        ? `Endpoint clock is ${Math.round(absMs / 1000)}s ${offsetMs > 0 ? "ahead of" : "behind"} reference — Kerberos/SAML/TOTP will fail.`
        : `Endpoint clock is within ${absMs}ms of reference (last sync: ${lastMatch ? lastMatch[1].trim() : "unknown"}).`,
    };
  } catch (err) {
    return {
      platform: "win32", server,
      offsetMs: null, absOffsetMs: null,
      lastSync:     null,
      serviceState,
      status: "error",
      message: `w32tm /query /status failed: ${(err as Error).message}`,
    };
  }
}
 
// -- Helpers ------------------------------------------------------------------
 
function shellQuote(s: string): string {
  return `'${s.replace(/'/g, `'\\''`)}'`;
}
 
// Exported for unit tests.
export const __testing = { checkDarwin, checkWin32, DRIFT_THRESHOLD_MS };
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  server,
}: {
  server?: string;
} = {}): Promise<NtpStatusResult> {
  const platform: "darwin" | "win32" | "other" =
    isDarwin() ? "darwin" : isWin32() ? "win32" : "other";
 
  const defaultServer = platform === "win32" ? "time.windows.com" : "time.apple.com";
  const resolvedServer = server ?? defaultServer;
 
  if (platform === "darwin") return checkDarwin(resolvedServer);
  if (platform === "win32")  return checkWin32(resolvedServer);
 
  return {
    platform: "other", server: resolvedServer,
    offsetMs: null, absOffsetMs: null,
    lastSync: null, serviceState: "unknown",
    status: "error",
    message: "Unsupported platform — cannot check NTP status.",
  };
}
 
// -- 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); });
}