Code

/**
 * mcp/skills/checkAgentHeartbeat.ts — check_agent_heartbeat skill
 *
 * Reports the heartbeat state of the locally-installed endpoint
 * security agent (CrowdStrike, SentinelOne, Jamf Protect, Microsoft
 * Defender, Carbon Black, Cylance) as a flat top-level object suitable
 * for the proactive evaluator's restricted DSL.  Combines two existing
 * signals into one telemetry-shaped result:
 *
 *   1. process / service running state — matches `check_agent_process`'s
 *      detection logic but inlined here so the tool is self-contained
 *      under tsconfig.skills.json (rootDir: mcp/skills).
 *   2. recent activity — mtime of the agent's per-vendor log directory
 *      as a proxy for "last heartbeat".  Vendor SDKs would expose a
 *      proper liveness ping, but log-dir mtime is a robust available-
 *      everywhere fallback.
 *
 * Wave 2 Track B Phase 4 attaches Trigger 3 (`agent-not-heartbeating`) to
 * the security-agent-repair skill with the condition:
 *   "healthy == false && ageSec >= 900"
 *
 * Read-only.  Always returns a result — even when no agent is detected
 * the tool returns a `healthy: false` shape so the rule can fire.
 */
 
import * as os   from "os";
import * as path from "path";
import { promises as fs } from "fs";
import { z }     from "zod";
 
import {
  execAsync,
  runPS,
  isDarwin,
  isWin32,
}                from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "check_agent_heartbeat",
  description:
    "Reports a top-level liveness summary for the installed endpoint " +
    "security agent — vendor name, last activity timestamp (log-dir " +
    "mtime), age in seconds, status, and a boolean `healthy`. Used both " +
    "by the security-agent-repair skill as a quick health probe and as " +
    "the telemetry source for the proactive agent-not-heartbeating " +
    "trigger. Read-only.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  // See docs/proactivesupport/PROACTIVE-ARCHITECTURE.md §6.
  outputKeys: [
    "platform",
    "vendor",
    "lastHeartbeatMs",
    "ageSec",
    "status",
    "healthy",
  ],
  schema: {
    healthyAgeSec: z
      .number()
      .int()
      .min(60)
      .max(86_400)
      .optional()
      .describe(
        "Maximum age (seconds) before the agent is considered " +
        "not-heartbeating. Default 900 (15 min) matches the Wave 2 Trigger 3 " +
        "duration window.",
      ),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export type AgentVendor =
  | "crowdstrike"
  | "sentinelone"
  | "jamf"
  | "defender"
  | "carbonblack"
  | "cylance";
 
export type HeartbeatStatus =
  | "healthy"
  | "stale"            // process running but log-mtime old
  | "process-stopped"  // process not running
  | "not-installed"
  | "unknown";
 
export interface CheckAgentHeartbeatResult {
  platform:        NodeJS.Platform;
  /** Detected vendor.  null when no known agent is installed. */
  vendor:          AgentVendor | null;
  /** ms since epoch — null when no log path was readable. */
  lastHeartbeatMs: number | null;
  /** Seconds since lastHeartbeatMs.  When null lastHeartbeatMs, returns a large sentinel so DSL conditions on `ageSec >= N` evaluate true. */
  ageSec:          number;
  /** Process / service running flag. */
  isRunning:       boolean;
  status:          HeartbeatStatus;
  healthy:         boolean;
}
 
// -- Vendor probe registry ----------------------------------------------------
 
interface VendorProbe {
  vendor:        AgentVendor;
  darwinNames:   string[];        // pgrep -x candidates
  win32Service:  string | null;
  darwinLogDirs: string[];        // mtime probe candidates
  win32LogDirs:  string[];
}
 
const VENDORS: VendorProbe[] = [
  {
    vendor:        "crowdstrike",
    darwinNames:   ["com.crowdstrike.falcon.Agent", "falcond"],
    win32Service:  "CSFalconService",
    darwinLogDirs: ["/Library/Logs/Crowdstrike", "/Library/Logs/CrowdStrike"],
    win32LogDirs:  [
      "C:\\Windows\\System32\\drivers\\CrowdStrike",
      "C:\\ProgramData\\CrowdStrike\\Logs",
    ],
  },
  {
    vendor:        "sentinelone",
    darwinNames:   ["SentinelAgent", "sentineld"],
    win32Service:  "SentinelAgent",
    darwinLogDirs: ["/Library/Logs/SentinelOne"],
    win32LogDirs:  ["C:\\ProgramData\\Sentinel\\Logs"],
  },
  {
    vendor:        "jamf",
    darwinNames:   ["JamfAgent", "jamf"],
    win32Service:  null,   // macOS-only
    darwinLogDirs: ["/Library/Logs/jamfprotect", "/var/log/jamf.log"],
    win32LogDirs:  [],
  },
  {
    vendor:        "defender",
    darwinNames:   ["wdavdaemon", "mdatp", "Microsoft Defender"],
    win32Service:  "WinDefend",
    darwinLogDirs: ["/Library/Logs/Microsoft/mdatp"],
    win32LogDirs:  ["C:\\ProgramData\\Microsoft\\Windows Defender\\Support"],
  },
  {
    vendor:        "carbonblack",
    darwinNames:   ["cbagentd", "CbOsxSensorService"],
    win32Service:  "CbDefense",
    darwinLogDirs: ["/Library/Logs/CbDefense"],
    win32LogDirs:  ["C:\\ProgramData\\CarbonBlack\\Logs"],
  },
  {
    vendor:        "cylance",
    darwinNames:   ["CylanceUI", "cylance"],
    win32Service:  "CylanceSvc",
    darwinLogDirs: ["/Library/Application Support/Cylance/Desktop/log"],
    win32LogDirs:  ["C:\\Program Files\\Cylance\\Desktop\\log"],
  },
];
 
// -- Filesystem + process helpers ---------------------------------------------
 
async function pathExists(p: string): Promise<boolean> {
  try {
    await fs.access(p);
    return true;
  } catch {
    return false;
  }
}
 
async function newestMtimeMs(paths: string[]): Promise<number | null> {
  let newest: number | null = null;
  for (const p of paths) {
    try {
      const s = await fs.stat(p);
      if (s.isDirectory()) {
        // Walk one level deep and take the newest entry mtime.
        try {
          const entries = await fs.readdir(p, { withFileTypes: true });
          for (const e of entries) {
            try {
              const inner = await fs.stat(path.join(p, e.name));
              if (newest === null || inner.mtimeMs > newest) newest = inner.mtimeMs;
            } catch {
              // ignore unreadable entry
            }
          }
        } catch {
          // ignore unreadable dir
        }
        // Also consider the dir's own mtime.
        if (newest === null || s.mtimeMs > newest) newest = s.mtimeMs;
      } else {
        if (newest === null || s.mtimeMs > newest) newest = s.mtimeMs;
      }
    } catch {
      // missing path — skip
    }
  }
  return newest;
}
 
async function isProcessRunningDarwin(names: string[]): Promise<boolean> {
  for (const n of names) {
    try {
      // pgrep -x exits 0 when at least one process matches.
      // shell-quote the name to handle spaces (Microsoft Defender etc.).
      await execAsync(`pgrep -x ${JSON.stringify(n)}`, { timeout: 5_000 });
      return true;
    } catch {
      // pgrep exits non-zero when no match — try the next candidate.
    }
  }
  return false;
}
 
async function isServiceRunningWin32(serviceName: string): Promise<boolean> {
  try {
    const stdout = await runPS(
      `(Get-Service -Name ${JSON.stringify(serviceName)} -ErrorAction SilentlyContinue).Status`,
      { timeoutMs: 5_000 },
    );
    return stdout.trim().toLowerCase() === "running";
  } catch {
    return false;
  }
}
 
// -- Per-vendor probe ---------------------------------------------------------
 
async function probeVendor(
  v:        VendorProbe,
  platform: NodeJS.Platform,
): Promise<{ vendor: AgentVendor; isRunning: boolean; lastHeartbeatMs: number | null }> {
  const isRunning = platform === "darwin"
    ? await isProcessRunningDarwin(v.darwinNames)
    : v.win32Service ? await isServiceRunningWin32(v.win32Service) : false;
 
  const logDirs = platform === "darwin" ? v.darwinLogDirs : v.win32LogDirs;
  const lastHeartbeatMs = await newestMtimeMs(logDirs);
 
  return { vendor: v.vendor, isRunning, lastHeartbeatMs };
}
 
// -- Status computation -------------------------------------------------------
 
const SENTINEL_AGE_SEC = 24 * 60 * 60 * 365 * 10;   // 10 years — "no heartbeat ever".
 
interface ComputeInputs {
  isRunning:        boolean;
  lastHeartbeatMs:  number | null;
  vendor:           AgentVendor | null;
  healthyAgeSec:    number;
}
 
function computeStatus(i: ComputeInputs): { ageSec: number; status: HeartbeatStatus; healthy: boolean } {
  if (i.vendor === null) {
    return { ageSec: SENTINEL_AGE_SEC, status: "not-installed", healthy: false };
  }
  if (!i.isRunning) {
    const age = i.lastHeartbeatMs === null
      ? SENTINEL_AGE_SEC
      : Math.round((Date.now() - i.lastHeartbeatMs) / 1000);
    return { ageSec: age, status: "process-stopped", healthy: false };
  }
  // Process IS running.
  if (i.lastHeartbeatMs === null) {
    // No log path readable — surface as unknown rather than guessing healthy.
    return { ageSec: SENTINEL_AGE_SEC, status: "unknown", healthy: false };
  }
  const ageSec = Math.round((Date.now() - i.lastHeartbeatMs) / 1000);
  if (ageSec <= i.healthyAgeSec) {
    return { ageSec, status: "healthy", healthy: true };
  }
  return { ageSec, status: "stale", healthy: false };
}
 
// -- Top-level vendor picker --------------------------------------------------
 
interface VendorState {
  vendor:          AgentVendor;
  isRunning:       boolean;
  lastHeartbeatMs: number | null;
}
 
function pickVendor(states: VendorState[]): VendorState | null {
  const installed = states.filter((s) => s.isRunning || s.lastHeartbeatMs !== null);
  if (installed.length === 0) return null;
 
  // Prefer the freshest heartbeat among running agents — that's the
  // user's "real" agent.  If none running but logs present, return the
  // freshest log (a recently-stopped agent).
  const running = installed.filter((s) => s.isRunning);
  if (running.length > 0) {
    return running.reduce((best, s) =>
      (s.lastHeartbeatMs ?? 0) > (best.lastHeartbeatMs ?? 0) ? s : best);
  }
  return installed.reduce((best, s) =>
    (s.lastHeartbeatMs ?? 0) > (best.lastHeartbeatMs ?? 0) ? s : best);
}
 
// -- Exported run function ----------------------------------------------------
 
const DEFAULT_HEALTHY_AGE_SEC = 900;   // 15 min — matches Trigger 3 duration.
 
export async function run({
  healthyAgeSec = DEFAULT_HEALTHY_AGE_SEC,
}: { healthyAgeSec?: number } = {}): Promise<CheckAgentHeartbeatResult> {
  const platform = os.platform();
  if (!isDarwin() && !isWin32()) {
    throw new Error(`check_agent_heartbeat: unsupported platform "${platform}"`);
  }
 
  const states = await Promise.all(VENDORS.map((v) => probeVendor(v, platform)));
  const top    = pickVendor(states);
 
  const { ageSec, status, healthy } = computeStatus({
    isRunning:        top?.isRunning      ?? false,
    lastHeartbeatMs:  top?.lastHeartbeatMs ?? null,
    vendor:           top?.vendor          ?? null,
    healthyAgeSec,
  });
 
  return {
    platform,
    vendor:          top?.vendor          ?? null,
    lastHeartbeatMs: top?.lastHeartbeatMs ?? null,
    ageSec,
    isRunning:       top?.isRunning      ?? false,
    status,
    healthy,
  };
}
 
// -- Test helpers -------------------------------------------------------------
 
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
  VENDORS,
  pathExists,
  newestMtimeMs,
  isProcessRunningDarwin,
  isServiceRunningWin32,
  probeVendor,
  pickVendor,
  computeStatus,
  SENTINEL_AGE_SEC,
};