macOS Kernel & System Extensions · Lesson 2

Anatomy of a System Extension

Before you build one, take a real one apart. Tailscale's extension is sitting on your disk — let's dissect its bundle, Info.plist, and entitlements.

🎯
By the end you'll open the actual Tailscale System Extension installed on your Mac, read its bundle layout, its NEProviderClasses map, and its entitlements — so the files you're about to write in Lesson 3 are already familiar.

Recap. In Lesson 1 you saw three extensions running and chose to build a Network Extension content filter. A System Extension isn't a loose binary you install — it's an .systemextension bundle nested inside a host app. Today we read a real one so the structure is concrete.

1Where installed extensions actually live

When a host app activates an extension, macOS copies it out of the app and into a system-owned store, keyed by UUID. Find yours:

▶ run me$ sudo find /Library/SystemExtensions -name "*.systemextension" -maxdepth 2 /Library/SystemExtensions/74BE8977-…/io.tailscale.ipn.macsys.network-extension.systemextension /Library/SystemExtensions/…/com.swiftlydetecting.agent.securityextension /Library/SystemExtensions/…/com.obsproject.obs-studio.mac-camera-extension

The plist tying UUIDs to bundle IDs and states is /Library/SystemExtensions/db.plist. This store is why a reboot isn't needed to swap extensions (unlike a kext's AuxKC) — and why your app being deleted doesn't immediately kill a running extension.

2The nesting: app ▸ Library ▸ SystemExtensions

Inside the shipping app, the extension lives at a fixed path. This is the layout your build.sh will assemble in Lesson 4:

NetFilterDemo.app/ Contents/ Info.plist— CFBundleIdentifier = com.example.netfilterdemo (APPL) MacOS/ NetFilterDemo← the container app (activates the extension) Library/SystemExtensions/ com.example.netfilterdemo.filter.systemextension/ Contents/Info.plist— NEProviderClasses, SYSX Contents/MacOS/ filter← the extension executable (the provider) Contents/_CodeSignature/ — signed separately, with its own entitlements
Two bundles, two signatures, two Info.plists. The extension is signed first, then the app is signed around it.

3Read the real Info.plist

The Info.plist is where an extension declares what kind it is and which class the framework should instantiate. Dump Tailscale's:

▶ run me$ TS=$(sudo find /Library/SystemExtensions -name "*.systemextension" | grep -i tailscale) $ plutil -p "$TS/Contents/Info.plist"
"CFBundlePackageType" => "SYSX" "NetworkExtension" => { "NEMachServiceName" => "W5364U7YZB.io.tailscale.ipn.macsys.network-extension" "NEProviderClasses" => { "com.apple.networkextension.packet-tunnel" => "…IPNPacketTunnelProvider" } } "NSSystemExtensionUsageDescription" => "Extends the networking capabilities…"
KeyWhat it does
CFBundlePackageType = SYSXMarks the bundle as a System Extension (an app is APPL, a kext is KEXT).
NEProviderClassesMaps a NetworkExtension extension-point identifier → your provider class name (Module.ClassName). Tailscale registers a packet-tunnel; your content filter will register com.apple.networkextension.filter-data.
NEMachServiceNameA Mach service for app↔extension IPC. Needed by VPN/proxy types; a bare content-filter data provider can omit it.
NSSystemExtensionUsageDescriptionThe sentence shown in the approval prompt. Required.
The one swap that makes it a content filter

Everything above is identical for any NetworkExtension; the type is chosen by the NEProviderClasses key. Tailscale uses packet-tunnel; you'll use filter-data. That single string is the difference between a VPN and a firewall.

4Read the real entitlements

Entitlements are signed capabilities baked into the binary. See Tailscale's:

▶ run me$ codesign -d --entitlements - --xml "$TS" | plutil -p -
"com.apple.developer.networking.networkextension" => [ "packet-tunnel-provider-systemextension" ] "com.apple.developer.team-identifier" => "W5364U7YZB" "com.apple.security.app-sandbox" => 1 "com.apple.security.application-groups" => [ "W5364U7YZB.io.tailscale.ipn.macsys" ]

Your content filter's entitlement array will read content-filter-provider-systemextension instead — same key, different value. The -systemextension suffix is what lets it ship outside the Mac App Store with Developer-ID signing. The extension is sandboxed (app-sandbox = 1), like every System Extension.

5The activation lifecycle

Your app drives activation through one API — OSSystemExtensionRequest — and gets called back as the OS works through these states:

submitRequest(your app) needsUserApprovalSystem Settings toggle didFinishWithResultactivated enabled provider runsstartFilter → handleNewFlow didFailWithError
You implement OSSystemExtensionRequestDelegate; the OS calls these methods. We wrote exactly this in the demo's main.swift — next lesson.

6Flashcards

Where, physically, is a System Extension stored before vs. after activation?

Before: nested in the host app at YourApp.app/Contents/Library/SystemExtensions/…systemextension. After activation: copied by the OS into /Library/SystemExtensions/<UUID>/ and tracked in db.plist.

Which Info.plist key decides whether a NetworkExtension is a VPN or a content filter?

NEProviderClasses — the extension-point identifier you map. com.apple.networkextension.packet-tunnel = VPN, com.apple.networkextension.filter-data = content filter.

What's the CFBundlePackageType of a System Extension?

SYSX. (App = APPL, kext = KEXT.)

Which delegate method fires when the user still has to approve in System Settings?

requestNeedsUserApproval(_:). Success is request(_:didFinishWithResult:); rejection is request(_:didFailWithError:).

💬 Ask your teacher. Want to dissect the Mac Monitor (Endpoint Security) bundle the same way, or see what NEMachServiceName is for? Ask me. Otherwise say "Lesson 3" and we'll read the Swift you'll compile.