Root cause
Startup ordering drops the merged rules
XPCServer is constructed before adapter.start; updatePolicy sees no Endpoint Security client and returns without updating FilterInteractor.
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Mar 26, 2026 in
craigjbass/clearancekitAll 3 findings in this repository
ClearanceKit intercepts file-system access events on macOS and enforces per-process access policies. Prior to version 4.2.14, two related startup defects created a window during which only the single compile-time baseline rule was enforced by opfilter. All managed (MDM-delivered) and user-defined file-access rules were not applied until the user interacted with policies through the GUI, triggering a policy mutation over XPC. This issue has been patched in version 4.2.14.
AI applied security policy too early, so the client silently missed it.
AI-assisted change
Commit 5a887953 merged policy ownership into XPCServer and called the policy applicator from its initializer.
Root cause
XPCServer is constructed before adapter.start; updatePolicy sees no Endpoint Security client and returns without updating FilterInteractor.
AI-assisted change: Claude
Fix
Commit 56d617b starts the adapters first and then applies policy, allowlist, and jail rules, reversing the same ordering defect.
Case facts
@@ -7,19 +7,18 @@ import Foundation -XPCClient.shared.start() ProcessTree.shared.buildInitialTree() let interactor = FilterInteractor(initialRules: faaPolicy) let adapter = ESInboundAdapter(interactor: interactor)-adapter.start(initialRules: faaPolicy)+let server = XPCServer(interactor: interactor, adapter: adapter) -XPCClient.shared.onPolicyUpdate = { rules in- adapter.updatePolicy(rules)+interactor.onEvent = { event in+ server.handleEvent(event) } -XPCClient.shared.onAllowlistUpdate = { entries in- interactor.updateAllowlist(entries)-}+adapter.start(initialRules: server.mergedRules())+server.setMonitoringActive(true)+server.start() dispatchMain()@@ -1,80 +1,112 @@ //-// DaemonXPCServer.swift-// clearancekit-daemon+// XPCServer.swift+// opfilter+//+// XPC server that exposes policy management to the GUI app.+// Replaces the former DaemonXPCServer — opfilter is now the single+// privileged process for both ES enforcement and GUI communication. // import Foundation private let dataDirectory = URL(fileURLWithPath: "/Library/Application Support/clearancekit") -final class DaemonXPCServer: NSObject {- static let shared = DaemonXPCServer()-+final class XPCServer: NSObject { private var listener: NSXPCListener? private let lock = NSLock() private var guiClients: [ObjectIdentifier: NSXPCConnection] = [:]- private var filterClients: [ObjectIdentifier: NSXPCConnection] = [:] private var monitoringActive = false- private var opfilterVersion: String = "" private var recentEvents: [FolderOpenEvent] = [] private var managedRules: [FAARule] = [] private var userRules: [FAARule] = [] private var xprotectEntries: [AllowlistEntry] = [] private var managedAllowlist: [AllowlistEntry] = [] private var userAllowlist: [AllowlistEntry] = [] private let maxHistoryCount = 1000- private var database: Database!+ private let database: Database+ private let interactor: FilterInteractor+ private let adapter: ESInboundAdapter - private override init() {- super.init()- }+ init(interactor: FilterInteractor, adapter: ESInboundAdapter) {+ self.interactor = interactor+ self.adapter = adapter+ self.database = Database(directory: dataDirectory) - func start() {- database = Database(directory: dataDirectory) userRules = database.loadUserRules() managedRules = ManagedPolicyLoader.load() userAllowlist = database.loadUserAllowlist() managedAllowlist = ManagedAllowlistLoader.load() xprotectEntries = enumerateXProtectEntries()- NSLog("DaemonXPCServer: Discovered %d XProtect allowlist entry/entries", xprotectEntries.count)- // Both rule tiers must be loaded before the listener resumes so that- // mergedPolicyData() is complete the moment the first filter client connects.- listener = NSXPCListener(machServiceName: XPCConstants.daemonServiceName)+ NSLog("XPCServer: Discovered %d XProtect allowlist entry/entries", xprotectEntries.count)++ super.init()++ applyPolicyToFilter()+ applyAllowlistToFilter()+ }++ func start() {+ listener = NSXPCListener(machServiceName: XPCConstants.serviceName) listener?.delegate = self listener?.resume()- NSLog("DaemonXPCServer: Listening on %@", XPCConstants.daemonServiceName)+ NSLog("XPCServer: Listening on %@", XPCConstants.serviceName) } - // MARK: - Client registration+ // MARK: - Direct filter integration - fileprivate func addGUIClient(_ connection: NSXPCConnection) {+ func setMonitoringActive(_ active: Bool) {+ broadcastMonitoringStatus(active)+ }++ func handleEvent(_ event: FolderOpenEvent) {+ broadcastEvent(event)+ }++ // MARK: - Policy / allowlist assembly++ func mergedRules() -> [FAARule] { lock.lock()- guiClients[ObjectIdentifier(connection)] = connection@@ -1,120 +0,0 @@-//-// XPCClient.swift-// opfilter-//-// Created by Craig J. Bass on 19/02/2026.-//--import Foundation--final class XPCClient: NSObject {- static let shared = XPCClient()-- var onPolicyUpdate: (([FAARule]) -> Void)?- var onAllowlistUpdate: (([AllowlistEntry]) -> Void)?-- private var connection: NSXPCConnection?- private let reconnectInterval: TimeInterval = 5.0-- private override init() {- super.init()- }-- func start() {- guard connection == nil else { return }-- let conn = NSXPCConnection(machServiceName: XPCConstants.daemonServiceName)-- let remoteInterface = NSXPCInterface(with: DaemonServiceProtocol.self)- let allowedClasses = NSSet(array: [FolderOpenEvent.self, NSDate.self, NSString.self]) as! Set<AnyHashable>- remoteInterface.setClasses(- allowedClasses,- for: #selector(DaemonServiceProtocol.reportEvent(_:)),- argumentIndex: 0,- ofReply: false- )- conn.remoteObjectInterface = remoteInterface-- conn.exportedInterface = NSXPCInterface(with: FilterClientProtocol.self)- conn.exportedObject = self-- conn.invalidationHandler = { [weak self] in- NSLog("XPCClient (opfilter): Connection to daemon invalidated")- self?.handleDisconnection()- }- conn.interruptionHandler = { [weak self] in- NSLog("XPCClient (opfilter): Connection to daemon interrupted")- self?.handleDisconnection()- }-- conn.resume()- connection = conn- NSLog("XPCClient (opfilter): Connecting to daemon at %@", XPCConstants.daemonServiceName)-- registerAndFetchPolicy()- }-- func reportEvent(_ event: FolderOpenEvent) {- guard let proxy = connection?.remoteObjectProxy as? DaemonServiceProtocol else { return }- proxy.reportEvent(event)- }-- func reportMonitoringStatus(_ isActive: Bool) {- guard let proxy = connection?.remoteObjectProxy as? DaemonServiceProtocol else { return }- proxy.reportMonitoringStatus(isActive)- }-- private func handleDisconnection() {- connection?.invalidate()- connection = nil- NSLog("XPCClient (opfilter): Connection lost, scheduling reconnect")- scheduleReconnect()- }-- private func scheduleReconnect() {- DispatchQueue.main.asyncAfter(deadline: .now() + reconnectInterval) { [weak self] in- self?.start()- }- }-- private func registerAndFetchPolicy() {- guard let proxy = connection?.remoteObjectProxyWithErrorHandler({- NSLog("XPCClient (opfilter): registerFilterClient error: %@", $0.localizedDescription)- }) as? DaemonServiceProtocol else { return }-- // Daemon pushes current merged policy immediately after registration via policyUpdated(_:).- proxy.registerFilterClient(BuildInfo.gitHash as NSString) { success in- guard success else {- NSLog("XPCClient (opfilter): Failed to register as filter client")- return@@ -80,10 +80,6 @@ let server = XPCServer( serverQueue: xpcServerQueue ) -server.applyPolicyToFilter()-server.applyAllowlistToFilter()-server.applyJailRulesToFilter()- interactor.onEvent = { event in server.handleEvent(event) }@@ -94,8 +90,9 @@ let initialRules = server.mergedRules() server.startJailAdapterIfEnabled() jailAdapter.startSweepTimer() adapter.start(initialRules: initialRules, onXProtectChanged: { server.handleXProtectChange() })-adapter.clearCache()-jailAdapter.clearCache()+server.applyPolicyToFilter()+server.applyAllowlistToFilter()+server.applyJailRulesToFilter() let metricsLogger = Logger(subsystem: "uk.craigbass.clearancekit.metrics", category: "metrics") let metricsTimer = DispatchSource.makeTimerSource(queue: metricsQueue)AI-assisted change eb2db6afed14e8f1820a9ee846d45610c9f531a9259fee33bc17a9a71fb576dc · Fix 0db4622b77b35d3a5e8e0b0b2fcfcebc3867e90d2c49d50ccb26ccf244c0e773