/**
* 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'
Get-NetAdapter ${ filterClause } |
Select-Object Name,Status,DriverDescription,DriverVersion,DriverProvider |
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 ); });
}