check_network_extension
check_network_extension
Checks if VPN or security agent network extensions (system extensions) are loaded and approved. Network extensions must be approved in System Settings to function. macOS only for system extension checking; Windows checks network driver status.
Metadata
Name
check_network_extension
Updated
last month
Source
Risk
Low
Requires consent
false
Affected scope
user
Code
/**
* mcp/skills/checkNetworkExtension.ts — check_network_extension skill
*
* Checks if VPN or security agent network extensions (system extensions) are
* loaded and approved. Network extensions must be approved in System Settings
* to function. macOS only for system extension checking; Windows checks network
* driver status.
*
* Platform strategy
* -----------------
* darwin TWO sources merged: `systemextensionsctl list` for system extensions
* (sysext — carry the activated / enabled / waiting-for-user approval
* state) AND `pluginkit -m -p <ne-protocol>` for app-extension (appex)
* network providers, which many VPN clients ship and which do NOT appear
* in systemextensionsctl (the reason this tool used to return empty for a
* configured VPN). Apple's own providers are filtered out.
* win32 PowerShell Get-NetAdapter for network driver/adapter status
*
* Smoke test
* npx tsx -r dotenv/config mcp/skills/checkNetworkExtension.ts
*/
import * as os from "os";
import { exec } from "child_process";
import { promisify } from "util";
import { z } from "zod";
const execAsync = promisify(exec);
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "check_network_extension",
description:
"Checks if VPN or security agent network extensions (system extensions) are " +
"loaded and approved. Network extensions must be approved in System Settings " +
"to function. macOS only for system extension checking; Windows checks network " +
"driver status.",
riskLevel: "low",
destructive: false,
requiresConsent: false,
supportsDryRun: false,
affectedScope: ["user"],
auditRequired: false,
schema: {
extensionName: z
.string()
.optional()
.describe(
"Extension name or bundle ID to check " +
"(e.g. 'com.cisco.anyconnect.macos.acsock'). " +
"Omit to list all network extensions."
),
},
} as const;
// -- Types --------------------------------------------------------------------
interface ExtensionEntry {
identifier: string; // bundle ID
name: string; // display name (sysext "name" column / appex bundle leaf)
teamId: string;
bundleVersion: string;
state: string; // e.g. "activated enabled", "activated waiting for user", "enabled" (appex)
type: string; // "System Extension" | "App Extension" | "Network Adapter" (win32)
}
interface CheckNetworkExtensionResult {
extensions: ExtensionEntry[];
allActivated: boolean;
}
// -- 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 },
);
return stdout.trim();
}
// -- darwin implementation ----------------------------------------------------
/** An extension is "ready" only when enabled AND not still pending user approval.
* NOTE: "activated waiting for user" CONTAINS "activated", so we must NOT treat
* presence of "activated" as ready (the prior bug) — gate on "enabled" and
* explicitly exclude the pending state. */
function isReady(state: string): boolean {
return state.includes("enabled") && !state.includes("waiting for user");
}
/**
* Parse one `systemextensionsctl list` data row. Real output is TAB-separated:
* enabled active teamID bundleID (version) name [state]
* * * 7L2RM5R3FG com.paloaltonetworks.GlobalProtect.client.extension (6.0.4) GlobalProtect [activated enabled]
* Falls back to whitespace/regex extraction if the row isn't cleanly tabbed.
*/
function parseSysextRow(line: string): ExtensionEntry | null {
const cols = line.split("\t").map((c) => c.trim()).filter(Boolean);
if (cols.length >= 5) {
const bundleCol = cols.find((c) => /[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+\s*\(/.test(c)) ?? cols[3];
const idMatch = bundleCol.match(/([a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/);
if (!idMatch) return null;
const verMatch = bundleCol.match(/\(([^)]+)\)/);
const stateCol = cols.find((c) => c.startsWith("[")) ?? cols[cols.length - 1];
const bundleIdx = cols.indexOf(bundleCol);
const nameCol = cols[bundleIdx + 1];
return {
identifier: idMatch[1],
name: nameCol && !nameCol.startsWith("[") ? nameCol : idMatch[1],
teamId: cols.find((c) => /^[A-Z0-9]{10}$/.test(c)) ?? "Unknown",
bundleVersion: verMatch ? verMatch[1] : "Unknown",
state: stateCol.replace(/[[\]]/g, "").trim(),
type: "System Extension",
};
}
const idMatch = line.match(/([a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)\s*\(/);
if (!idMatch) return null;
const verMatch = line.match(/\(([^)]+)\)/);
const stateMatch = line.match(/\[([^\]]+)\]/);
const teamMatch = line.match(/\b([A-Z0-9]{10})\b/);
return {
identifier: idMatch[1],
name: idMatch[1],
teamId: teamMatch ? teamMatch[1] : "Unknown",
bundleVersion: verMatch ? verMatch[1] : "Unknown",
state: stateMatch ? stateMatch[1].trim() : "Unknown",
type: "System Extension",
};
}
/** System extensions (sysext) — Cisco Secure Client, GlobalProtect sysext, etc.
* These carry the user-approval state the Step 7 gate keys on. */
async function listSystemExtensions(): Promise<ExtensionEntry[]> {
let out = "";
try {
({ stdout: out } = await execAsync("systemextensionsctl list 2>/dev/null", { maxBuffer: 5 * 1024 * 1024 }));
} catch (err) {
out = (err as { stdout?: string }).stdout ?? "";
}
const entries: ExtensionEntry[] = [];
for (const line of out.split("\n")) {
const t = line.trim();
if (!t || t.startsWith("---") || t.startsWith("enabled") || /^\d+\s+extension/.test(t)) continue;
const row = parseSysextRow(line);
if (row) entries.push(row);
}
return entries;
}
/**
* App-extension (appex) network providers. Many VPN clients ship these instead
* of system extensions, and they do NOT appear in `systemextensionsctl` — the
* main reason check_network_extension used to return empty for a configured VPN.
* `pluginkit -m -p <protocol>` lists them. appex have no separate System-Settings
* approval (they enable with the VPN config), so we mark them "enabled". Apple's
* own providers (com.apple.*) are filtered out — we only want third-party clients.
*/
async function listAppExtensions(): Promise<ExtensionEntry[]> {
const protocols = [
"com.apple.networkextension.packet-tunnel",
"com.apple.networkextension.app-proxy",
];
const entries: ExtensionEntry[] = [];
for (const proto of protocols) {
let out = "";
try {
({ stdout: out } = await execAsync(`pluginkit -m -p ${proto} 2>/dev/null`, { maxBuffer: 2 * 1024 * 1024 }));
} catch { /* pluginkit absent or no matches — skip */ }
for (const line of out.split("\n")) {
const m = line.trim().match(/^([a-zA-Z0-9._-]+?)\(([^)]*)\)\s*$/);
if (!m) continue;
const identifier = m[1];
if (identifier.startsWith("com.apple.")) continue; // skip Apple's own providers
if (entries.some((e) => e.identifier === identifier)) continue;
entries.push({
identifier,
name: identifier.split(".").pop() ?? identifier,
teamId: "Unknown",
bundleVersion: m[2] || "Unknown",
state: "enabled",
type: "App Extension",
});
}
}
return entries;
}
async function checkNetworkExtensionDarwin(
extensionName: string | undefined,
): Promise<CheckNetworkExtensionResult> {
const [sysext, appex] = await Promise.all([listSystemExtensions(), listAppExtensions()]);
// sysext first (they carry the approval state), then appex not already seen as a sysext.
const all = [...sysext, ...appex.filter((a) => !sysext.some((s) => s.identifier === a.identifier))];
const filtered = extensionName
? all.filter(
(e) =>
e.identifier.toLowerCase().includes(extensionName.toLowerCase()) ||
e.name.toLowerCase().includes(extensionName.toLowerCase()),
)
: all;
const allActivated = filtered.length > 0 && filtered.every((e) => isReady(e.state));
return { extensions: filtered, allActivated };
}
// -- win32 implementation -----------------------------------------------------
async function checkNetworkExtensionWin32(
extensionName: string | undefined,
): Promise<CheckNetworkExtensionResult> {
const filterClause = extensionName
? `| Where-Object { $_.DriverDescription -match '${extensionName.replace(/'/g, "''")}' -or $_.Name -match '${extensionName.replace(/'/g, "''")}' }`
: "";
const ps = `
$ErrorActionPreference = 'SilentlyContinue'\nGet-NetAdapter ${filterClause} |\n Select-Object Name,Status,DriverDescription,DriverVersion,DriverProvider |\n ConvertTo-Json -Depth 2 -Compress`.trim();
const raw = await runPS(ps);
if (!raw) return { extensions: [], allActivated: false };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { extensions: [], allActivated: false };
}
const arr = Array.isArray(parsed) ? parsed : [parsed];
const extensions: ExtensionEntry[] = (arr as Record<string, unknown>[]).map((a) => ({
identifier: String(a.Name ?? "Unknown"),
name: String(a.DriverDescription ?? a.Name ?? "Unknown"),
teamId: String(a.DriverProvider ?? "Unknown"),
bundleVersion: String(a.DriverVersion ?? "Unknown"),
state: String(a.Status ?? "Unknown"),
type: String(a.DriverDescription ?? "Network Adapter"),
}));
const allActivated =
extensions.length > 0 &&
extensions.every((e) => e.state === "Up");
return { extensions, allActivated };
}
// -- Exported run function ----------------------------------------------------
export async function run({
extensionName,
}: {
extensionName?: string;
} = {}): Promise<CheckNetworkExtensionResult> {
const platform = os.platform();
return platform === "win32"
? checkNetworkExtensionWin32(extensionName)
: checkNetworkExtensionDarwin(extensionName);
}
// -- Smoke test ---------------------------------------------------------------
if (false) {
run({})
.then(r => console.log(JSON.stringify(r, null, 2)))
.catch((err: Error) => { console.error(err.message); process.exit(1); });
}