Reads endpoint security agent health in one call: which known agents are installed, which are running, their installed versions, and when each last reported to its management console. Read-only. Use at the start of a security-agent workflow instead of check_agent_process, get_agent_version and check_agent_heartbeat separately.
/** * mcp/skills/surveySecurityAgent.ts — survey_security_agent * * Coarse-grained read of endpoint-agent health: which agents are installed and * running, their versions, and whether they are still reporting to their console. * * Why coarse * ---------- * `security-agent-repair` ran seven probes before it attempted anything. Four of * them are one question — "is the agent alive and talking to its console?" — * split across `check_agent_process`, `get_agent_version` and * `check_agent_heartbeat`, with the version call needing a specific vendor name * the first call produces. That chain cost three executor iterations to gather * facts that never branch between themselves. * * The fine-grained tools remain registered and are NOT deprecated. * * What it decides, and what it refuses to decide * ---------------------------------------------- * It resolves the vendor list and fans the per-agent version lookups out itself * — `get_agent_version` takes one vendor per call and rejects `"auto"`, so the * skill previously had to say "call it once per agent detected in Step 1". That * is a mechanical fan-out with one right answer. * * It does NOT decide whether to restart anything. A stopped agent with SIP * disabled, an unapproved extension, or a tamper-protection log entry must not * be restarted — the restart fails and the real blocker goes unreported. Those * blockers live in other probes, so only the skill can see the whole picture. */import { run as checkAgentProcess } from "./checkAgentProcess";import { run as getAgentVersion } from "./getAgentVersion";import { run as checkAgentHeartbeat } from "./checkAgentHeartbeat";/** * Vendors `get_agent_version` and `check_agent_heartbeat` support. An agent * outside this set is still reported by the process probe — it simply gets no * version or heartbeat, which is a gap to surface rather than an error. *//** Exported so a test can pin it against what check_agent_process probes. */export const SUPPORTED_VENDORS = new Set([ "crowdstrike", "sentinelone", "jamf", "carbonblack", "cylance", "defender",]);type Vendor = "crowdstrike" | "sentinelone" | "jamf" | "carbonblack" | "cylance" | "defender";// -- Meta ---------------------------------------------------------------------export const meta = { name: "survey_security_agent", description: "Reads endpoint security agent health in one call: which known agents are " + "installed, which are running, their installed versions, and when each last " + "reported to its management console. Read-only. Use at the start of a " + "security-agent workflow instead of check_agent_process, get_agent_version " + "and check_agent_heartbeat separately.", riskLevel: "low", destructive: false, requiresConsent: false, supportsDryRun: false, affectedScope: ["user"], auditRequired: false, // Inherited from check_agent_heartbeat, which this tool calls. // // G4's tccPreflightCheck scans the PLAN's tool metas, not what those tools // call internally (execution.ts — it reads meta.tccCategories per step). A // wrapper that omits the category silently drops the whole plan-level gate: // instead of blocking before any step runs and deep-linking the user to // System Settings, the run proceeds and fails mid-flight on a permission // error. Any wrapper MUST re-declare the union of what it wraps. tccCategories: ["FullDiskAccess"], outputKeys: [ "platform", "detectedAgents", "checkedAgents", "anyRunning", "anyDetected", "versions", "heartbeat", "unsupportedVendors", ], schema: {},} as const;// -- Types --------------------------------------------------------------------export interface SurveySecurityAgentResult { platform: string; /** * Agents actually PRESENT on this machine: running, or installed and stopped. * * Not what check_agent_process returns. That probes all six known vendors and * reports a row for each, present or not — a candidate list whose `name` * field made it read like a detection result. Passing it straight through * made `anyDetected` permanently true (see `checkedAgents`). */ detectedAgents: unknown[]; /** Every vendor probed, present or not — the input behind the conclusion. */ checkedAgents: string[]; anyRunning: boolean; /** * False means no KNOWN agent was found. That is a terminal finding — this * skill covers six vendors, and a custom EDR needs IT, not a repair attempt. */ anyDetected: boolean; /** One entry per PRESENT supported vendor, keyed by vendor name. */ versions: Record<string, unknown>; /** * Console reporting. Null when no supported vendor was detected — absence, * not a failed check. */ heartbeat: unknown | null; /** * Detected agents this toolchain has no version or heartbeat support for. * Surface them: "console reachability not tested" is a real caveat on the * final report, not something to omit silently. */ unsupportedVendors: string[];}// -- Implementation -----------------------------------------------------------/** * Mirrors check_agent_process output. Its `name` is already the vendor key * ("crowdstrike", "defender", ...), not a display string, so it feeds * get_agent_version directly. */interface DetectedAgent { name: string; processName: string; isRunning: boolean; pid: number | null; platform: string;}export async function run( _args: Record<string, never> = {},): Promise<SurveySecurityAgentResult> { const proc = await checkAgentProcess({ agent: "auto" }); const detectedAgents: DetectedAgent[] = proc.detectedAgents ?? []; const names = detectedAgents .map((a) => String(a.name ?? "").toLowerCase()) .filter((n) => n.length > 0); const supported = names.filter((n) => SUPPORTED_VENDORS.has(n)) as Vendor[]; const unsupportedVendors = names.filter((n) => !SUPPORTED_VENDORS.has(n)); if (supported.length === 0) { return { platform: process.platform, // No supported vendor to look up a version for, so a stopped agent // cannot be evidenced — only a running one counts as present. detectedAgents: detectedAgents.filter((a) => a.isRunning === true), checkedAgents: names, anyRunning: Boolean(proc.anyRunning), anyDetected: detectedAgents.some((a) => a.isRunning === true), versions: {}, heartbeat: null, unsupportedVendors, }; } // get_agent_version takes one vendor per call and rejects "auto", so the // fan-out lives here rather than as prose telling the model to loop. // allSettled: one vendor's lookup failing must not blank the others. const [versionResults, heartbeatResult] = await Promise.all([ Promise.allSettled(supported.map((agent) => getAgentVersion({ agent }))), Promise.allSettled([checkAgentHeartbeat({})]), ]); const versions: Record<string, unknown> = {}; supported.forEach((agent, i) => { const r = versionResults[i]!; versions[agent] = r.status === "fulfilled" ? r.value : { status: "error", message: String(r.reason) }; }); const hb = heartbeatResult[0]!; // check_agent_process reports a row per KNOWN vendor whether or not it is // installed, so its list is candidates, not detections. Taking it at face // value made anyDetected permanently true: the skill's "no known agent — // advise contacting IT" exit was unreachable, every conditional keyed on // anyDetected walked the full repair path, and six vendor rows plus six // version records rode along in the scratchpad on every iteration. // // An agent is present if it is running, or if its version lookup found an // install on disk. Both signals were already here; only the second was // computed after the conclusion that needed it. const presentNames = new Set<string>(); for (const a of detectedAgents) { const n = String(a.name ?? "").toLowerCase(); if (!n) continue; if (a.isRunning === true) { presentNames.add(n); continue; } if ((versions[n] as { found?: unknown } | undefined)?.found === true) presentNames.add(n); } const present = detectedAgents.filter((a) => presentNames.has(String(a.name ?? "").toLowerCase()), ); // Carry versions only for what is present — the absent vendors' records are // the evidence for excluding them, and repeating six of those per iteration // is what made this the most expensive skill measured. const presentVersions: Record<string, unknown> = {}; for (const n of presentNames) if (n in versions) presentVersions[n] = versions[n]; return { platform: process.platform, detectedAgents: present, checkedAgents: names, anyRunning: Boolean(proc.anyRunning), anyDetected: present.length > 0, versions: presentVersions, heartbeat: hb.status === "fulfilled" ? hb.value : { status: "error", message: String(hb.reason) }, unsupportedVendors, };}