import Foundation #if os(macOS) import os.lock import SweetCookieKit enum BrowserCookieStoreAccessDecision: Equatable { case allowed case suppressed } struct BrowserCookieStoreAccessSuppressedError: LocalizedError { var errorDescription: String? { "Browser cookie store access is suppressed for this process." } } public enum BrowserCookieAccessGate { private struct State { var loaded = false var deniedUntilByBrowser: [String: Date] = [:] } private static let lock = OSAllocatedUnfairLock(initialState: State()) private static let defaultsKey = "browserCookieAccessDeniedUntil" private static let cooldownInterval: TimeInterval = 60 * 60 * 6 private static let log = CodexBarLog.logger(LogCategories.browserCookieGate) static let allowTestCookieAccessEnvironmentKey = "CODEXBAR_ALLOW_TEST_BROWSER_COOKIE_ACCESS" static func cookieStoreAccessDecision( homeDirectories: [URL], processName: String = ProcessInfo.processInfo.processName, environment: [String: String] = ProcessInfo.processInfo.environment) -> BrowserCookieStoreAccessDecision { guard self.isRunningUnderTests(processName: processName, environment: environment), environment[self.allowTestCookieAccessEnvironmentKey] != "1" else { return .allowed } let defaultHomes = Set(BrowserCookieClient.defaultHomeDirectories().map(Self.normalizedPath)) let usesDefaultHome = homeDirectories.contains { defaultHomes.contains(Self.normalizedPath($0)) } return usesDefaultHome ? .suppressed : .allowed } public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { guard browser.usesKeychainForCookieDecryption else { return true } guard !KeychainAccessGate.isDisabled else { return false } let shouldCheckKeychain = self.lock.withLock { state in self.loadIfNeeded(&state) if let blockedUntil = state.deniedUntilByBrowser[browser.rawValue] { if blockedUntil > now { self.log.debug( "Cookie access blocked", metadata: ["browser": browser.displayName, "until": "\(blockedUntil.timeIntervalSince1970)"]) return false } state.deniedUntilByBrowser.removeValue(forKey: browser.rawValue) self.persist(state) } return true } guard shouldCheckKeychain else { return false } let requiresInteraction = self.chromiumKeychainRequiresInteraction() return self.lock.withLock { state in self.loadIfNeeded(&state) if requiresInteraction { state.deniedUntilByBrowser[browser.rawValue] = now.addingTimeInterval(self.cooldownInterval) self.persist(state) self.log.info( "Cookie access requires keychain interaction; suppressing", metadata: ["browser": browser.displayName]) return false } self.log.debug("Cookie access allowed", metadata: ["browser": browser.displayName]) return true } } public static func recordIfNeeded(_ error: Error, now: Date = Date()) { guard let error = error as? BrowserCookieError else { return } guard case .accessDenied = error else { return } self.recordDenied(for: error.browser, now: now) } public static func recordDenied(for browser: Browser, now: Date = Date()) { guard browser.usesKeychainForCookieDecryption else { return } let blockedUntil = now.addingTimeInterval(self.cooldownInterval) self.lock.withLock { state in self.loadIfNeeded(&state) state.deniedUntilByBrowser[browser.rawValue] = blockedUntil self.persist(state) } self.log .info( "Browser cookie access denied; suppressing prompts", metadata: [ "browser": browser.displayName, "until": "\(blockedUntil.timeIntervalSince1970)", ]) } public static func resetForTesting() { self.lock.withLock { state in state.loaded = true state.deniedUntilByBrowser.removeAll() UserDefaults.standard.removeObject(forKey: self.defaultsKey) } } private static func chromiumKeychainRequiresInteraction() -> Bool { for label in self.safeStorageLabels { switch KeychainAccessPreflight.checkGenericPassword(service: label.service, account: label.account) { case .allowed: return false case .interactionRequired: return true case .notFound, .failure: continue } } return false } private static let safeStorageLabels: [(service: String, account: String)] = Browser.safeStorageLabels private static func isRunningUnderTests( processName: String, environment: [String: String]) -> Bool { processName == "swiftpm-testing-helper" || processName.hasSuffix("PackageTests") || environment["XCTestConfigurationFilePath"] != nil || environment["SWIFT_TESTING"] != nil } private static func normalizedPath(_ url: URL) -> String { url.standardizedFileURL.resolvingSymlinksInPath().path } private static func loadIfNeeded(_ state: inout State) { guard !state.loaded else { return } state.loaded = true guard let raw = UserDefaults.standard.dictionary(forKey: self.defaultsKey) as? [String: Double] else { return } state.deniedUntilByBrowser = raw.compactMapValues { Date(timeIntervalSince1970: $0) } } private static func persist(_ state: State) { let raw = state.deniedUntilByBrowser.mapValues { $0.timeIntervalSince1970 } UserDefaults.standard.set(raw, forKey: self.defaultsKey) } } extension BrowserCookieClient { public func codexBarStores(for browser: Browser) throws -> [BrowserCookieStore] { guard BrowserCookieAccessGate.cookieStoreAccessDecision( homeDirectories: self.configuration.homeDirectories) == .allowed else { throw BrowserCookieStoreAccessSuppressedError() } guard BrowserCookieAccessGate.shouldAttempt(browser) else { return [] } return self.stores(for: browser) } public func codexBarRecords( matching query: BrowserCookieQuery, in browser: Browser, logger: ((String) -> Void)? = nil) throws -> [BrowserCookieStoreRecords] { guard BrowserCookieAccessGate.cookieStoreAccessDecision( homeDirectories: self.configuration.homeDirectories) == .allowed else { throw BrowserCookieStoreAccessSuppressedError() } guard BrowserCookieAccessGate.shouldAttempt(browser) else { return [] } return try self.records(matching: query, in: browser, logger: logger) } } #else public enum BrowserCookieAccessGate { public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { true } public static func recordIfNeeded(_ error: Error, now: Date = Date()) {} public static func recordDenied(for browser: Browser, now: Date = Date()) {} public static func resetForTesting() {} } #endif