diff --git a/MacPersistenceChecker/App/AppState.swift b/MacPersistenceChecker/App/AppState.swift index d6fd5e5..be01354 100644 --- a/MacPersistenceChecker/App/AppState.swift +++ b/MacPersistenceChecker/App/AppState.swift @@ -77,8 +77,8 @@ final class AppState: ObservableObject { /// Whether to show snapshots sheet @Published var showSnapshotsSheet: Bool = false - /// Whether to skip FDA check (persisted - survives app restarts) - @AppStorage("skipFDACheck") var skipFDACheck: Bool = false + /// Whether to skip FDA check for the current app session only. + @Published var skipFDACheckForCurrentSession: Bool = false /// Sidebar collapsed state @Published var sidebarCollapsed: Bool = false @@ -130,24 +130,46 @@ final class AppState: ObservableObject { private let scanner = ScannerOrchestrator() private var cancellables = Set() - private init() { + static let legacySkipFDACheckKey = "skipFDACheck" + + init( + defaults: UserDefaults = .standard, + initializeDatabase: Bool = true, + setupBindings: Bool = true, + startBackgroundLoading: Bool = true, + preloadCaches: Bool = true + ) { + Self.clearLegacySkipFDACheck(defaults: defaults) + // Ensure database is initialized before we try to load from it - ensureDatabaseInitialized() - setupBindings() + if initializeDatabase { + ensureDatabaseInitialized() + } + if setupBindings { + self.setupBindings() + } // Heavy DB reads (snapshots, cached scan with 6800+ JSON-decoded items) // run off the main thread so the UI can show the chrome immediately. // The @Published assignments hop back to main when the data is ready. - Task.detached(priority: .userInitiated) { [weak self] in - await self?.loadSnapshotsAsync() - await self?.loadCachedScanAsync() + if startBackgroundLoading { + Task.detached(priority: .userInitiated) { [weak self] in + await self?.loadSnapshotsAsync() + await self?.loadCachedScanAsync() + } } // Pre-warm the knowledge-graph rule cache + concept cache off the main // thread so the first filter / eye-toggle never blocks on SQLite. - RuleMatcher.shared.preload() - ConceptStore.shared.preload() + if preloadCaches { + RuleMatcher.shared.preload() + ConceptStore.shared.preload() + } // Note: Containment and Monitor are initialized lazily to avoid permission prompts on launch } + static func clearLegacySkipFDACheck(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: legacySkipFDACheckKey) + } + /// Ensure database is initialized (idempotent) private func ensureDatabaseInitialized() { if DatabaseManager.shared.dbQueue == nil { diff --git a/MacPersistenceChecker/App/MacPersistenceCheckerApp.swift b/MacPersistenceChecker/App/MacPersistenceCheckerApp.swift index a141f9d..ff77df8 100644 --- a/MacPersistenceChecker/App/MacPersistenceCheckerApp.swift +++ b/MacPersistenceChecker/App/MacPersistenceCheckerApp.swift @@ -16,6 +16,7 @@ struct MacPersistenceCheckerApp: App { @StateObject private var appState = AppState.shared @StateObject private var fdaChecker = FullDiskAccessChecker.shared @StateObject private var monitor = PersistenceMonitor.shared + @Environment(\.scenePhase) private var scenePhase init() { // Ensure database initialization runs (static property is lazy) @@ -27,9 +28,15 @@ struct MacPersistenceCheckerApp: App { var body: some Scene { WindowGroup { Group { - if fdaChecker.hasFullDiskAccess || appState.skipFDACheck { - ContentView() - .environmentObject(appState) + if fdaChecker.hasFullDiskAccess || appState.skipFDACheckForCurrentSession { + VStack(spacing: 0) { + if appState.skipFDACheckForCurrentSession && !fdaChecker.hasFullDiskAccess { + ReducedVisibilityBanner(appState: appState, fdaChecker: fdaChecker) + } + + ContentView() + .environmentObject(appState) + } } else { PermissionGuideView() .environmentObject(fdaChecker) @@ -37,6 +44,11 @@ struct MacPersistenceCheckerApp: App { } } .frame(minWidth: 1000, minHeight: 600) + .onChange(of: scenePhase) { phase in + if phase == .active { + fdaChecker.refreshAccess(reason: .sceneBecameActive) + } + } } .windowStyle(.automatic) .commands { @@ -183,6 +195,7 @@ struct SettingsView: View { } struct GeneralSettingsView: View { + @EnvironmentObject var appState: AppState @AppStorage("autoScanOnLaunch") private var autoScanOnLaunch = true @AppStorage("showNotifications") private var showNotifications = true @@ -190,11 +203,70 @@ struct GeneralSettingsView: View { Form { Toggle("Scan automatically on launch", isOn: $autoScanOnLaunch) Toggle("Show notifications for new items", isOn: $showNotifications) + + Section { + Button { + FullDiskAccessChecker.shared.refreshAccess(reason: .manual) + } label: { + Label("Check Full Disk Access Again", systemImage: "arrow.clockwise") + } + + if appState.skipFDACheckForCurrentSession { + Button { + appState.skipFDACheckForCurrentSession = false + } label: { + Label("Show Full Disk Access Guide", systemImage: "shield") + } + } + } header: { + Text("Full Disk Access") + } footer: { + Text("Run another permission check after changing Full Disk Access in System Settings.") + } } .padding() } } +private struct ReducedVisibilityBanner: View { + @ObservedObject var appState: AppState + @ObservedObject var fdaChecker: FullDiskAccessChecker + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + + VStack(alignment: .leading, spacing: 2) { + Text("Running without Full Disk Access") + .font(.subheadline) + .fontWeight(.semibold) + Text("Some persistence locations may be hidden until permission is granted.") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button { + fdaChecker.refreshAccess(reason: .manual) + } label: { + Label("Check Again", systemImage: "arrow.clockwise") + } + + Button { + appState.skipFDACheckForCurrentSession = false + } label: { + Label("Show Guide", systemImage: "shield") + } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.orange.opacity(0.12)) + } +} + struct ScanSettingsView: View { @AppStorage("includeSystemItems") private var includeSystemItems = true @AppStorage("verifySignatures") private var verifySignatures = true diff --git a/MacPersistenceChecker/Services/Permissions/FullDiskAccessChecker.swift b/MacPersistenceChecker/Services/Permissions/FullDiskAccessChecker.swift index 10fb98b..b6b4958 100644 --- a/MacPersistenceChecker/Services/Permissions/FullDiskAccessChecker.swift +++ b/MacPersistenceChecker/Services/Permissions/FullDiskAccessChecker.swift @@ -1,6 +1,195 @@ import Foundation import Combine import AppKit +import Darwin + +enum FDAProbeAPI: String { + case posixOpen + case contentsOfDirectory + case isReadableFile +} + +enum FDAProbeID: String, CaseIterable { + case systemTCCDatabase + case userSafariHistory + case userMailDirectory + case launchDaemonsDirectory + case launchDaemonsTCCDatabaseReadable + + var contributesToGrant: Bool { + switch self { + case .launchDaemonsDirectory: + return false + case .systemTCCDatabase, .userSafariHistory, .userMailDirectory, .launchDaemonsTCCDatabaseReadable: + return true + } + } +} + +enum FDAAccessRefreshReason: String { + case startup + case previousGrantValidation + case manual + case polling + case appBecameActive + case sceneBecameActive +} + +struct FDAProbeResult { + let probeID: FDAProbeID + let path: String + let api: FDAProbeAPI + let success: Bool + let errnoCode: Int32? + let cocoaErrorDomain: String? + let cocoaErrorCode: Int? + let timestamp: Date + + init( + probeID: FDAProbeID, + path: String, + api: FDAProbeAPI, + success: Bool, + errnoCode: Int32? = nil, + cocoaErrorDomain: String? = nil, + cocoaErrorCode: Int? = nil, + timestamp: Date = Date() + ) { + self.probeID = probeID + self.path = path + self.api = api + self.success = success + self.errnoCode = errnoCode + self.cocoaErrorDomain = cocoaErrorDomain + self.cocoaErrorCode = cocoaErrorCode + self.timestamp = timestamp + } + + var diagnosticSummary: String { + var parts = [ + "\(probeID.rawValue)", + "api=\(api.rawValue)", + "success=\(success)" + ] + + if let errnoCode { + parts.append("errno=\(errnoCode)") + } + + if let cocoaErrorDomain, let cocoaErrorCode { + parts.append("cocoa=\(cocoaErrorDomain)(\(cocoaErrorCode))") + } + + return parts.joined(separator: " ") + } +} + +struct FDAAccessSnapshot { + let reason: FDAAccessRefreshReason + let results: [FDAProbeResult] + let timestamp: Date + + var isGranted: Bool { + results.contains { result in + result.success && result.probeID.contributesToGrant + } + } + + var diagnosticSummary: String { + let probeSummaries = results + .map(\.diagnosticSummary) + .joined(separator: "; ") + return "reason=\(reason.rawValue) granted=\(isGranted) probes=[\(probeSummaries)]" + } +} + +protocol FDAProbeExecuting { + func canOpenForRead(probeID: FDAProbeID, path: String) -> FDAProbeResult + func canListDirectory(probeID: FDAProbeID, path: String) -> FDAProbeResult + func isReadableFile(probeID: FDAProbeID, path: String) -> FDAProbeResult +} + +struct DefaultFDAProbeExecutor: FDAProbeExecuting { + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func canOpenForRead(probeID: FDAProbeID, path: String) -> FDAProbeResult { + Darwin.errno = 0 + let fileHandle = Darwin.open(path, O_RDONLY) + + if fileHandle != -1 { + Darwin.close(fileHandle) + return FDAProbeResult( + probeID: probeID, + path: path, + api: .posixOpen, + success: true + ) + } + + return FDAProbeResult( + probeID: probeID, + path: path, + api: .posixOpen, + success: false, + errnoCode: Darwin.errno + ) + } + + func canListDirectory(probeID: FDAProbeID, path: String) -> FDAProbeResult { + do { + _ = try fileManager.contentsOfDirectory(atPath: path) + return FDAProbeResult( + probeID: probeID, + path: path, + api: .contentsOfDirectory, + success: true + ) + } catch let error as NSError { + return FDAProbeResult( + probeID: probeID, + path: path, + api: .contentsOfDirectory, + success: false, + cocoaErrorDomain: error.domain, + cocoaErrorCode: error.code + ) + } + } + + func isReadableFile(probeID: FDAProbeID, path: String) -> FDAProbeResult { + if fileManager.isReadableFile(atPath: path) { + return FDAProbeResult( + probeID: probeID, + path: path, + api: .isReadableFile, + success: true + ) + } + + let cocoaError = readabilityFailure(forPath: path) + return FDAProbeResult( + probeID: probeID, + path: path, + api: .isReadableFile, + success: false, + cocoaErrorDomain: cocoaError.domain, + cocoaErrorCode: cocoaError.code + ) + } + + private func readabilityFailure(forPath path: String) -> NSError { + do { + _ = try fileManager.attributesOfItem(atPath: path) + return NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoPermissionError) + } catch let error as NSError { + return error + } + } +} /// Verifica e monitora Full Disk Access final class FullDiskAccessChecker: ObservableObject { @@ -13,81 +202,61 @@ final class FullDiskAccessChecker: ObservableObject { /// Whether we're actively checking for access @Published private(set) var isChecking: Bool = false + /// Most recent structured FDA probe evidence. + @Published private(set) var lastSnapshot: FDAAccessSnapshot? + /// Key for persisting FDA granted status private let fdaGrantedKey = "fdaWasGranted" + private let executor: FDAProbeExecuting + private let defaults: UserDefaults + private let notificationCenter: NotificationCenter private var checkTimer: Timer? private var cancellables = Set() - private init() { - // Check if FDA was previously granted (fast path) - if UserDefaults.standard.bool(forKey: fdaGrantedKey) { - // Verify it's still valid - if performAccessCheck() { - hasFullDiskAccess = true - return - } else { - // FDA was revoked, clear the flag - UserDefaults.standard.set(false, forKey: fdaGrantedKey) - } + init( + executor: FDAProbeExecuting = DefaultFDAProbeExecutor(), + defaults: UserDefaults = .standard, + notificationCenter: NotificationCenter = .default, + observeAppActivation: Bool = true, + performInitialCheck: Bool = true + ) { + self.executor = executor + self.defaults = defaults + self.notificationCenter = notificationCenter + + if observeAppActivation { + registerAppActivationRefresh() } - // Initial check - hasFullDiskAccess = checkAccess() + if performInitialCheck { + initializeAccessState() + } } /// Check if the app has Full Disk Access (and persist if granted) @discardableResult func checkAccess() -> Bool { - let hasAccess = performAccessCheck() - - if hasAccess { - hasFullDiskAccess = true - // Persist that FDA was granted for faster startup next time - UserDefaults.standard.set(true, forKey: fdaGrantedKey) - return true - } - - hasFullDiskAccess = false - return false + refreshAccess(reason: .manual).isGranted } - /// Perform the actual FDA check without side effects - private func performAccessCheck() -> Bool { - // Method 1: Try to read the TCC database (requires FDA) - let tccPath = "/Library/Application Support/com.apple.TCC/TCC.db" - if canReadFile(at: tccPath) { - return true - } - - // Method 2: Try to read Safari history (requires FDA) - let safariHistory = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Safari/History.db") - .path - if canReadFile(at: safariHistory) { - return true - } + /// Refresh FDA state and keep the persisted fast-path key in sync. + @discardableResult + func refreshAccess(reason: FDAAccessRefreshReason) -> FDAAccessSnapshot { + let wasPreviouslyGranted = defaults.bool(forKey: fdaGrantedKey) + let snapshot = performAccessCheck(reason: reason) - // Method 3: Try to read Mail data (requires FDA) - let mailPath = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Mail") - .path - if canListDirectory(at: mailPath) { - return true - } + lastSnapshot = snapshot + hasFullDiskAccess = snapshot.isGranted - // Method 4: Check if we can read /Library/LaunchDaemons fully - // Some items there require FDA - let launchDaemonsPath = "/Library/LaunchDaemons" - if canListDirectory(at: launchDaemonsPath) { - // Additional check: try to read a specific protected plist - let testPath = "/Library/Application Support/com.apple.TCC/TCC.db" - if FileManager.default.isReadableFile(atPath: testPath) { - return true - } + if snapshot.isGranted { + defaults.set(true, forKey: fdaGrantedKey) + } else if wasPreviouslyGranted { + defaults.set(false, forKey: fdaGrantedKey) } - return false + NSLog("[FDA] %@", snapshot.diagnosticSummary) + return snapshot } /// Start polling for FDA status (useful during onboarding) @@ -96,9 +265,10 @@ final class FullDiskAccessChecker: ObservableObject { isChecking = true checkTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in - guard let self = self else { return } + guard let self else { return } - if self.checkAccess() { + let snapshot = self.refreshAccess(reason: .polling) + if snapshot.isGranted { self.stopPolling() onGranted?() } @@ -147,22 +317,49 @@ final class FullDiskAccessChecker: ObservableObject { // MARK: - Private Helpers - private func canReadFile(at path: String) -> Bool { - let fileHandle = open(path, O_RDONLY) - if fileHandle != -1 { - close(fileHandle) - return true + private func initializeAccessState() { + if defaults.bool(forKey: fdaGrantedKey) { + let snapshot = refreshAccess(reason: .previousGrantValidation) + if snapshot.isGranted { + return + } } - return false + + _ = refreshAccess(reason: .startup) } - private func canListDirectory(at path: String) -> Bool { - do { - _ = try FileManager.default.contentsOfDirectory(atPath: path) - return true - } catch { - return false - } + private func registerAppActivationRefresh() { + notificationCenter.publisher(for: NSApplication.didBecomeActiveNotification) + .sink { [weak self] _ in + _ = self?.refreshAccess(reason: .appBecameActive) + } + .store(in: &cancellables) + } + + /// Perform the actual FDA check without mutating checker state. + private func performAccessCheck(reason: FDAAccessRefreshReason) -> FDAAccessSnapshot { + let tccPath = "/Library/Application Support/com.apple.TCC/TCC.db" + let safariHistory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Safari/History.db") + .path + let mailPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Mail") + .path + let launchDaemonsPath = "/Library/LaunchDaemons" + + let results = [ + executor.canOpenForRead(probeID: .systemTCCDatabase, path: tccPath), + executor.canOpenForRead(probeID: .userSafariHistory, path: safariHistory), + executor.canListDirectory(probeID: .userMailDirectory, path: mailPath), + executor.canListDirectory(probeID: .launchDaemonsDirectory, path: launchDaemonsPath), + executor.isReadableFile(probeID: .launchDaemonsTCCDatabaseReadable, path: tccPath) + ] + + return FDAAccessSnapshot( + reason: reason, + results: results, + timestamp: Date() + ) } } diff --git a/MacPersistenceChecker/Views/Onboarding/PermissionGuideView.swift b/MacPersistenceChecker/Views/Onboarding/PermissionGuideView.swift index a5c83af..c71285f 100644 --- a/MacPersistenceChecker/Views/Onboarding/PermissionGuideView.swift +++ b/MacPersistenceChecker/Views/Onboarding/PermissionGuideView.swift @@ -4,6 +4,7 @@ struct PermissionGuideView: View { @EnvironmentObject var fdaChecker: FullDiskAccessChecker @EnvironmentObject var appState: AppState @State private var currentStep = 0 + @State private var signingStatusSummary = "Signing: checking..." var body: some View { VStack(spacing: 0) { @@ -13,14 +14,16 @@ struct PermissionGuideView: View { Divider() // Content - contentView + ScrollView { + contentView + } Divider() // Footer footerView } - .frame(width: 600, height: 500) + .frame(width: 680, height: 640) .onAppear { // Auto-open System Settings immediately DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -31,6 +34,7 @@ struct PermissionGuideView: View { fdaChecker.startPolling { // Permission granted, view will automatically dismiss } + loadSigningStatus() } .onDisappear { fdaChecker.stopPolling() @@ -100,10 +104,10 @@ struct PermissionGuideView: View { } .padding(.horizontal) - Spacer() - // Status indicator statusView + + diagnosticDisclosure } .padding() } @@ -136,12 +140,53 @@ struct PermissionGuideView: View { .cornerRadius(8) } + private var diagnosticDisclosure: some View { + DisclosureGroup { + VStack(alignment: .leading, spacing: 8) { + Text(signingStatusSummary) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + + if let snapshot = fdaChecker.lastSnapshot { + Text("Last refresh: \(snapshot.reason.rawValue), granted: \(snapshot.isGranted ? "yes" : "no")") + .font(.caption) + .foregroundColor(.secondary) + + ForEach(snapshot.results, id: \.probeID) { result in + Text(result.diagnosticSummary) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } else { + Text("No FDA probe snapshot has been recorded yet.") + .font(.caption) + .foregroundColor(.secondary) + } + + if !fdaChecker.hasFullDiskAccess { + Label("Restart may be required by macOS after changing this permission.", systemImage: "arrow.clockwise") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(.top, 8) + } label: { + Text("Diagnostic Details") + .font(.callout) + .fontWeight(.medium) + } + .padding() + .background(Color.secondary.opacity(0.08)) + .cornerRadius(8) + } + // MARK: - Footer private var footerView: some View { HStack { - Button("Skip for now") { - appState.skipFDACheck = true + Button("Continue without Full Disk Access") { + fdaChecker.stopPolling() + appState.skipFDACheckForCurrentSession = true } .buttonStyle(.plain) .foregroundColor(.secondary) @@ -158,6 +203,17 @@ struct PermissionGuideView: View { } .padding() } + + private func loadSigningStatus() { + let bundlePath = Bundle.main.bundlePath + + Task.detached { + let summary = AppSigningStatus.summary(for: bundlePath) + await MainActor.run { + signingStatusSummary = summary + } + } + } } // MARK: - Step View @@ -196,6 +252,50 @@ struct StepView: View { } } +private enum AppSigningStatus { + static func summary(for bundlePath: String) -> String { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["-dvvv", "-r-", bundlePath] + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + } catch { + return "Signing: unavailable (\(error.localizedDescription))" + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(decoding: data, as: UTF8.self) + + let signature = value(for: "Signature", in: output) ?? "unknown" + let teamIdentifier = value(for: "TeamIdentifier", in: output) ?? "unknown" + let requirement: String + if output.contains("designated => cdhash") { + requirement = "requirement=cdhash-only" + } else if output.contains("designated =>") { + requirement = "requirement=stable" + } else { + requirement = "requirement=unknown" + } + + return "Signing: Signature=\(signature); TeamIdentifier=\(teamIdentifier); \(requirement)" + } + + private static func value(for key: String, in output: String) -> String? { + guard let line = output + .split(separator: "\n") + .first(where: { $0.hasPrefix("\(key)=") }) else { + return nil + } + + return String(line.dropFirst(key.count + 1)) + } +} + #if XCODE_PREVIEWS #Preview { PermissionGuideView() diff --git a/Package.swift b/Package.swift index 59a2775..715c0fc 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,11 @@ let package = Package( .process("Resources/ConceptHints.json"), .copy("Resources/AIPrompts") ] + ), + .testTarget( + name: "MacPersistenceCheckerTests", + dependencies: ["MacPersistenceChecker"], + path: "Tests/MacPersistenceCheckerTests" ) ] ) diff --git a/README.md b/README.md index 8a468b1..580399c 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,64 @@ cd MacPersistenceChecker Copy `MacPersistenceChecker.app` to `/Applications/`. +### Signing and Full Disk Access + +`./build.sh` signs local builds ad-hoc by default. That is fine for smoke tests, but it is not a durable Full Disk Access identity: macOS TCC can treat a rebuilt ad-hoc binary as a different app because the signing requirement is tied to the build's hash. + +Use the build output, or run this directly, to inspect the app's current signing identity: + +```bash +/usr/bin/codesign -dvvv -r- MacPersistenceChecker.app +``` + +If the designated requirement is `cdhash`-only or `TeamIdentifier=not set`, expect Full Disk Access grants to be build-specific. Use a stable signing identity before claiming FDA persists across rebuilds. + +For stable FDA/TCC validation, build with a stable signing identity: + +```bash +/usr/bin/security find-identity -v -p codesigning +SIGNING_IDENTITY="Developer ID Application: Example Corp (TEAMID)" REQUIRE_STABLE_SIGNING=1 ./build.sh +``` + +Replace `Developer ID Application: Example Corp (TEAMID)` with the exact identity name printed by `security find-identity`. + +Run the read-only build diagnostics before granting or re-granting Full Disk Access: + +```bash +scripts/verify-fda-build.sh MacPersistenceChecker.app +``` + +### Full Disk Access verification + +1. Build ad-hoc and observe the warning: + ```bash + ./build.sh + ``` +2. Build with a stable signing identity when validating FDA persistence across rebuilds: + ```bash + /usr/bin/security find-identity -v -p codesigning + SIGNING_IDENTITY="Developer ID Application: Example Corp (TEAMID)" REQUIRE_STABLE_SIGNING=1 ./build.sh + ``` + Replace the example identity with the exact value from `security find-identity`. +3. Inspect the designated requirement: + ```bash + /usr/bin/codesign -dvvv -r- MacPersistenceChecker.app + ``` +4. Install the built app: + ```bash + cp -R MacPersistenceChecker.app /Applications/ + ``` +5. Open `/Applications/MacPersistenceChecker.app` and grant Full Disk Access in System Settings. +6. If macOS shows **Quit & Reopen**, use it. The app may detect a successful probe before relaunch, but scanner validation should use the relaunched process. +7. Confirm the app reports Full Disk Access granted and shows the successful diagnostic probe. +8. Rebuild with the same stable identity and confirm FDA still resolves without re-granting. + +For test-account cleanup only, you can reset this app's Full Disk Access decision: + +```bash +tccutil reset SystemPolicyAllFiles com.pinperepette.MacPersistenceChecker +``` + ### Requirements - macOS 13.0+ - Xcode 15+ or Swift 5.9+ diff --git a/Tests/MacPersistenceCheckerTests/AppStatePermissionTests.swift b/Tests/MacPersistenceCheckerTests/AppStatePermissionTests.swift new file mode 100644 index 0000000..85aab27 --- /dev/null +++ b/Tests/MacPersistenceCheckerTests/AppStatePermissionTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import MacPersistenceChecker + +@MainActor +final class AppStatePermissionTests: XCTestCase { + func testLegacySkipFDACheckKeyIsClearedOnInitialization() { + let defaults = makeDefaults() + defaults.set(true, forKey: AppState.legacySkipFDACheckKey) + + _ = makeAppState(defaults: defaults) + + XCTAssertNil(defaults.object(forKey: AppState.legacySkipFDACheckKey)) + } + + func testSessionSkipDoesNotPersistAcrossFreshAppStateInitialization() { + let defaults = makeDefaults() + let firstState = makeAppState(defaults: defaults) + firstState.skipFDACheckForCurrentSession = true + + let freshState = makeAppState(defaults: defaults) + + XCTAssertTrue(firstState.skipFDACheckForCurrentSession) + XCTAssertFalse(freshState.skipFDACheckForCurrentSession) + XCTAssertNil(defaults.object(forKey: AppState.legacySkipFDACheckKey)) + } + + private func makeAppState(defaults: UserDefaults) -> AppState { + AppState( + defaults: defaults, + initializeDatabase: false, + setupBindings: false, + startBackgroundLoading: false, + preloadCaches: false + ) + } + + private func makeDefaults( + file: StaticString = #filePath, + line: UInt = #line + ) -> UserDefaults { + let suiteName = "MacPersistenceCheckerTests.AppState.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Could not create isolated UserDefaults suite", file: file, line: line) + return .standard + } + + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} diff --git a/Tests/MacPersistenceCheckerTests/FullDiskAccessCheckerTests.swift b/Tests/MacPersistenceCheckerTests/FullDiskAccessCheckerTests.swift new file mode 100644 index 0000000..3010605 --- /dev/null +++ b/Tests/MacPersistenceCheckerTests/FullDiskAccessCheckerTests.swift @@ -0,0 +1,225 @@ +import XCTest +@testable import MacPersistenceChecker + +final class FullDiskAccessCheckerTests: XCTestCase { + private let fdaGrantedKey = "fdaWasGranted" + + func testAnySuccessfulProtectedProbeGrantsFDA() { + let fakeExecutor = FakeFDAProbeExecutor(results: [ + .systemTCCDatabase: .failure(.systemTCCDatabase, api: .posixOpen), + .userSafariHistory: .success(.userSafariHistory, api: .posixOpen), + .userMailDirectory: .failure(.userMailDirectory, api: .contentsOfDirectory), + .launchDaemonsDirectory: .failure(.launchDaemonsDirectory, api: .contentsOfDirectory), + .launchDaemonsTCCDatabaseReadable: .failure(.launchDaemonsTCCDatabaseReadable, api: .isReadableFile) + ]) + let defaults = makeDefaults() + let checker = makeChecker(executor: fakeExecutor, defaults: defaults) + + let snapshot = checker.refreshAccess(reason: .manual) + + XCTAssertTrue(snapshot.isGranted) + XCTAssertTrue(checker.hasFullDiskAccess) + } + + func testAllFailedProtectedProbesDenyFDA() { + let fakeExecutor = FakeFDAProbeExecutor.allFailed() + let defaults = makeDefaults() + let checker = makeChecker(executor: fakeExecutor, defaults: defaults) + + let snapshot = checker.refreshAccess(reason: .manual) + + XCTAssertFalse(snapshot.isGranted) + XCTAssertFalse(checker.hasFullDiskAccess) + } + + func testStaleGrantedDefaultIsClearedAfterDeniedRefresh() { + let fakeExecutor = FakeFDAProbeExecutor.allFailed() + let defaults = makeDefaults() + defaults.set(true, forKey: fdaGrantedKey) + let checker = makeChecker(executor: fakeExecutor, defaults: defaults) + + _ = checker.refreshAccess(reason: .manual) + + XCTAssertFalse(defaults.bool(forKey: fdaGrantedKey)) + XCTAssertFalse(checker.hasFullDiskAccess) + } + + func testSuccessfulRefreshWritesGrantedDefault() { + let fakeExecutor = FakeFDAProbeExecutor(results: [ + .systemTCCDatabase: .success(.systemTCCDatabase, api: .posixOpen), + .userSafariHistory: .failure(.userSafariHistory, api: .posixOpen), + .userMailDirectory: .failure(.userMailDirectory, api: .contentsOfDirectory), + .launchDaemonsDirectory: .failure(.launchDaemonsDirectory, api: .contentsOfDirectory), + .launchDaemonsTCCDatabaseReadable: .failure(.launchDaemonsTCCDatabaseReadable, api: .isReadableFile) + ]) + let defaults = makeDefaults() + let checker = makeChecker(executor: fakeExecutor, defaults: defaults) + + _ = checker.refreshAccess(reason: .manual) + + XCTAssertTrue(defaults.bool(forKey: fdaGrantedKey)) + XCTAssertTrue(checker.hasFullDiskAccess) + } + + func testPollingUsesRefreshPath() { + let fakeExecutor = FakeFDAProbeExecutor(results: [ + .systemTCCDatabase: .success(.systemTCCDatabase, api: .posixOpen), + .userSafariHistory: .failure(.userSafariHistory, api: .posixOpen), + .userMailDirectory: .failure(.userMailDirectory, api: .contentsOfDirectory), + .launchDaemonsDirectory: .failure(.launchDaemonsDirectory, api: .contentsOfDirectory), + .launchDaemonsTCCDatabaseReadable: .failure(.launchDaemonsTCCDatabaseReadable, api: .isReadableFile) + ]) + let defaults = makeDefaults() + let checker = makeChecker(executor: fakeExecutor, defaults: defaults) + let granted = expectation(description: "polling detected FDA grant") + + checker.startPolling(interval: 0.01) { + granted.fulfill() + } + + wait(for: [granted], timeout: 1.0) + checker.stopPolling() + + XCTAssertEqual(checker.lastSnapshot?.reason, .polling) + XCTAssertEqual(fakeExecutor.calls.map(\.probeID), FDAProbeID.allCases) + XCTAssertFalse(checker.isChecking) + } + + func testLastSnapshotContainsEveryProbeAndPreservesErrorDetails() throws { + let fakeExecutor = FakeFDAProbeExecutor(results: [ + .systemTCCDatabase: .failure(.systemTCCDatabase, api: .posixOpen, errnoCode: EACCES), + .userSafariHistory: .failure(.userSafariHistory, api: .posixOpen, errnoCode: ENOENT), + .userMailDirectory: .failure( + .userMailDirectory, + api: .contentsOfDirectory, + cocoaErrorDomain: NSCocoaErrorDomain, + cocoaErrorCode: NSFileReadNoPermissionError + ), + .launchDaemonsDirectory: .success(.launchDaemonsDirectory, api: .contentsOfDirectory), + .launchDaemonsTCCDatabaseReadable: .failure( + .launchDaemonsTCCDatabaseReadable, + api: .isReadableFile, + cocoaErrorDomain: NSCocoaErrorDomain, + cocoaErrorCode: NSFileNoSuchFileError + ) + ]) + let defaults = makeDefaults() + let checker = makeChecker(executor: fakeExecutor, defaults: defaults) + + _ = checker.refreshAccess(reason: .manual) + + let snapshot = try XCTUnwrap(checker.lastSnapshot) + XCTAssertEqual(snapshot.results.map(\.probeID), FDAProbeID.allCases) + XCTAssertEqual(snapshot.results.first?.errnoCode, EACCES) + + let mailProbe = try XCTUnwrap(snapshot.results.first { $0.probeID == .userMailDirectory }) + XCTAssertEqual(mailProbe.cocoaErrorDomain, NSCocoaErrorDomain) + XCTAssertEqual(mailProbe.cocoaErrorCode, NSFileReadNoPermissionError) + + let readableProbe = try XCTUnwrap(snapshot.results.first { $0.probeID == .launchDaemonsTCCDatabaseReadable }) + XCTAssertEqual(readableProbe.api, .isReadableFile) + XCTAssertEqual(readableProbe.cocoaErrorCode, NSFileNoSuchFileError) + } + + private func makeChecker( + executor: FDAProbeExecuting, + defaults: UserDefaults + ) -> FullDiskAccessChecker { + FullDiskAccessChecker( + executor: executor, + defaults: defaults, + notificationCenter: NotificationCenter(), + observeAppActivation: false, + performInitialCheck: false + ) + } + + private func makeDefaults( + file: StaticString = #filePath, + line: UInt = #line + ) -> UserDefaults { + let suiteName = "MacPersistenceCheckerTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Could not create isolated UserDefaults suite", file: file, line: line) + return .standard + } + + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + +private final class FakeFDAProbeExecutor: FDAProbeExecuting { + struct Call { + let probeID: FDAProbeID + let api: FDAProbeAPI + let path: String + } + + private let results: [FDAProbeID: FDAProbeResult] + private(set) var calls: [Call] = [] + + init(results: [FDAProbeID: FDAProbeResult]) { + self.results = results + } + + static func allFailed() -> FakeFDAProbeExecutor { + FakeFDAProbeExecutor(results: [ + .systemTCCDatabase: .failure(.systemTCCDatabase, api: .posixOpen), + .userSafariHistory: .failure(.userSafariHistory, api: .posixOpen), + .userMailDirectory: .failure(.userMailDirectory, api: .contentsOfDirectory), + .launchDaemonsDirectory: .failure(.launchDaemonsDirectory, api: .contentsOfDirectory), + .launchDaemonsTCCDatabaseReadable: .failure(.launchDaemonsTCCDatabaseReadable, api: .isReadableFile) + ]) + } + + func canOpenForRead(probeID: FDAProbeID, path: String) -> FDAProbeResult { + calls.append(Call(probeID: probeID, api: .posixOpen, path: path)) + return result(for: probeID, fallbackAPI: .posixOpen) + } + + func canListDirectory(probeID: FDAProbeID, path: String) -> FDAProbeResult { + calls.append(Call(probeID: probeID, api: .contentsOfDirectory, path: path)) + return result(for: probeID, fallbackAPI: .contentsOfDirectory) + } + + func isReadableFile(probeID: FDAProbeID, path: String) -> FDAProbeResult { + calls.append(Call(probeID: probeID, api: .isReadableFile, path: path)) + return result(for: probeID, fallbackAPI: .isReadableFile) + } + + private func result(for probeID: FDAProbeID, fallbackAPI: FDAProbeAPI) -> FDAProbeResult { + results[probeID] ?? .failure(probeID, api: fallbackAPI) + } +} + +private extension FDAProbeResult { + static func success(_ probeID: FDAProbeID, api: FDAProbeAPI) -> FDAProbeResult { + FDAProbeResult( + probeID: probeID, + path: "/test/\(probeID.rawValue)", + api: api, + success: true, + timestamp: Date(timeIntervalSince1970: 1) + ) + } + + static func failure( + _ probeID: FDAProbeID, + api: FDAProbeAPI, + errnoCode: Int32? = nil, + cocoaErrorDomain: String? = nil, + cocoaErrorCode: Int? = nil + ) -> FDAProbeResult { + FDAProbeResult( + probeID: probeID, + path: "/test/\(probeID.rawValue)", + api: api, + success: false, + errnoCode: errnoCode, + cocoaErrorDomain: cocoaErrorDomain, + cocoaErrorCode: cocoaErrorCode, + timestamp: Date(timeIntervalSince1970: 1) + ) + } +} diff --git a/build.sh b/build.sh index 4ea9a79..88b4fab 100755 --- a/build.sh +++ b/build.sh @@ -13,6 +13,32 @@ APP_NAME="MacPersistenceChecker" VERSION="2.0.0" BUNDLE_ID="com.pinperepette.MacPersistenceChecker" MIN_MACOS="13.0" +SIGNING_IDENTITY="${SIGNING_IDENTITY:--}" +REQUIRE_STABLE_SIGNING="${REQUIRE_STABLE_SIGNING:-0}" + +# System tools used for bundle cleanup and signing. These are absolute paths +# so a broken Homebrew shim earlier on PATH cannot change build behavior. +XATTR="/usr/bin/xattr" +CODESIGN="/usr/bin/codesign" +LIPO="/usr/bin/lipo" +DU="/usr/bin/du" +MKTEMP="/usr/bin/mktemp" +SECURITY="/usr/bin/security" + +if [ "$REQUIRE_STABLE_SIGNING" = "1" ] && [ "$SIGNING_IDENTITY" = "-" ]; then + echo "ERROR: REQUIRE_STABLE_SIGNING=1 requires SIGNING_IDENTITY to name a stable code-signing identity." + echo "Example: SIGNING_IDENTITY=\"Developer ID Application: Example Corp (TEAMID)\" REQUIRE_STABLE_SIGNING=1 ./build.sh" + exit 2 +fi + +if [ "$SIGNING_IDENTITY" != "-" ]; then + if ! "$SECURITY" find-identity -v -p codesigning | grep -F -- "$SIGNING_IDENTITY" >/dev/null; then + echo "ERROR: signing identity not found: $SIGNING_IDENTITY" + echo "Use one of the identities listed by:" + echo " /usr/bin/security find-identity -v -p codesigning" + exit 2 + fi +fi # Directories SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -21,6 +47,21 @@ APP_DIR="$SCRIPT_DIR/$APP_NAME.app" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RESOURCES_DIR="$CONTENTS_DIR/Resources" +TEMP_DIR="$("$MKTEMP" -d "${TMPDIR:-/tmp}/mpc-build.XXXXXX")" +ENTITLEMENTS_FILE="$TEMP_DIR/entitlements.plist" + +cleanup() { + rm -rf "$TEMP_DIR" +} +trap cleanup EXIT + +echo "Tool preflight:" +echo " xattr: $XATTR (PATH resolves to: $(command -v xattr || echo "not found"))" +echo " codesign: $CODESIGN (PATH resolves to: $(command -v codesign || echo "not found"))" +echo " swift: $(command -v swift || echo "not found")" +echo " lipo: $LIPO (PATH resolves to: $(command -v lipo || echo "not found"))" +echo " signing: $SIGNING_IDENTITY" +echo "" # Clean previous build echo "[1/6] Cleaning previous build..." @@ -37,11 +78,11 @@ if [ "$UNIVERSAL" = "1" ]; then swift build -c release --triple arm64-apple-macosx${MIN_MACOS} swift build -c release --triple x86_64-apple-macosx${MIN_MACOS} mkdir -p "$BUILD_DIR/release" - lipo -create \ + "$LIPO" -create \ "$BUILD_DIR/arm64-apple-macosx/release/$APP_NAME" \ "$BUILD_DIR/x86_64-apple-macosx/release/$APP_NAME" \ -output "$BUILD_DIR/release/$APP_NAME" - echo " Architectures: $(lipo -archs "$BUILD_DIR/release/$APP_NAME")" + echo " Architectures: $("$LIPO" -archs "$BUILD_DIR/release/$APP_NAME")" else swift build -c release fi @@ -116,7 +157,7 @@ EOF # Create entitlements echo "[5/6] Creating entitlements and signing..." -cat > /tmp/entitlements.plist << EOF +cat > "$ENTITLEMENTS_FILE" << EOF @@ -132,8 +173,43 @@ cat > /tmp/entitlements.plist << EOF EOF # Remove quarantine and sign -xattr -cr "$APP_DIR" -codesign --force --deep --sign - --entitlements /tmp/entitlements.plist "$APP_DIR" +"$XATTR" -cr "$APP_DIR" +"$CODESIGN" --force --deep --sign "$SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS_FILE" "$APP_DIR" + +echo " Verifying signature..." +"$CODESIGN" --verify --verbose=4 "$APP_DIR" + +echo " Code signing details:" +SIGNING_DETAILS="$("$CODESIGN" -dvvv -r- "$APP_DIR" 2>&1)" +echo "$SIGNING_DETAILS" + +SIGNATURE_LINE="$(printf '%s\n' "$SIGNING_DETAILS" | awk -F= '/^Signature=/{print $2; exit}')" +TEAM_IDENTIFIER="$(printf '%s\n' "$SIGNING_DETAILS" | awk -F= '/^TeamIdentifier=/{print $2; exit}')" +DESIGNATED_REQUIREMENT="$(printf '%s\n' "$SIGNING_DETAILS" | sed -n 's/^# designated => //p')" + +if [[ "$SIGNATURE_LINE" == "adhoc" ]]; then + echo "WARNING: Signature=adhoc." +fi + +if [[ "$SIGNING_DETAILS" == *"designated => cdhash"* ]]; then + echo "WARNING: designated requirement is cdhash-only." +fi + +if [[ "$SIGNING_DETAILS" == *"designated => cdhash"* || "$SIGNING_DETAILS" == *"TeamIdentifier=not set"* ]]; then + echo "WARNING: ad-hoc signing creates a build-specific TCC identity; FDA grants may not survive rebuilds." +fi + +if [ "$REQUIRE_STABLE_SIGNING" = "1" ] && [ "${TEAM_IDENTIFIER:-not set}" = "not set" ]; then + echo "WARNING: TeamIdentifier=not set; this is not a Developer ID style identity even if the certificate requirement is stable." +fi + +if [[ "$SIGNATURE_LINE" != "adhoc" && "$SIGNING_DETAILS" != *"designated => cdhash"* ]]; then + echo "Stable signing check: designated requirement is not cdhash-only." + echo "Durable TCC identity: $DESIGNATED_REQUIREMENT" +elif [ "$REQUIRE_STABLE_SIGNING" = "1" ]; then + echo "ERROR: REQUIRE_STABLE_SIGNING=1 expected a non-ad-hoc, non-cdhash-only designated requirement." + exit 3 +fi # Done echo "[6/6] Build complete!" @@ -141,7 +217,7 @@ echo "" echo "=== Build Summary ===" echo "App: $APP_DIR" echo "Version: $VERSION" -echo "Size: $(du -sh "$APP_DIR" | cut -f1)" +echo "Size: $("$DU" -sh "$APP_DIR" | cut -f1)" echo "" echo "To install, run:" echo " cp -r $APP_NAME.app /Applications/" diff --git a/scripts/verify-fda-build.sh b/scripts/verify-fda-build.sh new file mode 100755 index 0000000..738c157 --- /dev/null +++ b/scripts/verify-fda-build.sh @@ -0,0 +1,53 @@ +#!/bin/bash + +# Non-destructive diagnostics for FDA/TCC build identity. +# This script does not reset TCC, modify permissions, or change the app bundle. + +set -u + +APP_PATH="${1:-MacPersistenceChecker.app}" +CODESIGN="/usr/bin/codesign" +PLISTBUDDY="/usr/libexec/PlistBuddy" +SPCTL="/usr/sbin/spctl" + +run_check() { + local title="$1" + shift + + echo "" + echo "== $title ==" + echo "$*" + "$@" 2>&1 + local status=$? + echo "exit status: $status" +} + +if [ ! -d "$APP_PATH" ]; then + echo "ERROR: app bundle not found: $APP_PATH" + echo "Usage: scripts/verify-fda-build.sh /path/to/MacPersistenceChecker.app" + exit 2 +fi + +INFO_PLIST="$APP_PATH/Contents/Info.plist" +if [ ! -f "$INFO_PLIST" ]; then + echo "ERROR: Info.plist not found at $INFO_PLIST" + exit 2 +fi + +BUNDLE_ID="$("$PLISTBUDDY" -c "Print :CFBundleIdentifier" "$INFO_PLIST" 2>/dev/null || true)" + +echo "=== MacPersistenceChecker FDA Build Diagnostics ===" +echo "App path: $APP_PATH" +echo "Bundle ID: ${BUNDLE_ID:-unknown}" + +run_check "codesign verify" "$CODESIGN" --verify --verbose=4 "$APP_PATH" +run_check "codesign designated requirement" "$CODESIGN" -dvvv -r- "$APP_PATH" +run_check "Gatekeeper assessment" "$SPCTL" --assess --type execute --verbose=4 "$APP_PATH" + +echo "" +echo "== Optional TCC log command ==" +echo "Run this in a separate terminal while granting Full Disk Access:" +echo "/usr/bin/log stream --predicate 'subsystem == \"com.apple.TCC\" OR process == \"tccd\"' --info" + +echo "" +echo "This script is read-only. It does not reset TCC or modify Full Disk Access."