diff --git a/README.md b/README.md index 78d34d3e0..71ef24236 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ The menu bar icon is a tiny two-bar meter: ## Privacy note Wondering if CodexBar scans your disk? It doesn’t crawl your filesystem; it reads a small set of known locations (browser cookies/local storage, local JSONL logs) when the related features are enabled. See the discussion and audit notes in [issue #12](https://github.com/steipete/CodexBar/issues/12). +Governance Audit Mode writes a local-only Markdown summary to `~/Library/Logs/CodexBar/Governance Audit Summary.md`. It is opt-in, meant for troubleshooting and observability, groups repeated sensitive actions into a readable summary, and keeps the existing `CodexBar.log` file as the technical debug log. + ## macOS permissions (why they’re needed) - **Full Disk Access (optional)**: only required to read Safari cookies/local storage for web-based providers (Codex web, Claude web, Cursor, Droid/Factory). If you don’t grant it, use Chrome/Firefox cookies or CLI-only sources instead. - **Keychain access (prompted by macOS)**: diff --git a/Sources/CodexBar/CodexAccountPromotionService.swift b/Sources/CodexBar/CodexAccountPromotionService.swift index c8a4abb73..29243518e 100644 --- a/Sources/CodexBar/CodexAccountPromotionService.swift +++ b/Sources/CodexBar/CodexAccountPromotionService.swift @@ -44,6 +44,13 @@ struct DefaultCodexAuthMaterialReader: CodexAuthMaterialReading { guard FileManager.default.fileExists(atPath: authFileURL.path) else { return nil } + AuditLogger.recordSecretAccess( + action: "file.auth_json.read", + target: authFileURL.lastPathComponent, + metadata: [ + "path": authFileURL.path, + "operation": "read", + ]) return try Data(contentsOf: authFileURL) } } @@ -56,6 +63,14 @@ struct DefaultCodexLiveAuthSwapper: CodexLiveAuthSwapping { let stagedAuthURL = liveHomeURL.appendingPathComponent( "auth.json.codexbar-staged-\(UUID().uuidString)", isDirectory: false) + AuditLogger.recordSecretAccess( + action: "file.auth_json.write", + target: liveAuthURL.lastPathComponent, + metadata: [ + "path": liveAuthURL.path, + "staged_path": stagedAuthURL.lastPathComponent, + "operation": "write", + ]) do { try data.write(to: stagedAuthURL) diff --git a/Sources/CodexBar/CookieHeaderStore.swift b/Sources/CodexBar/CookieHeaderStore.swift index ac3905b03..365618216 100644 --- a/Sources/CodexBar/CookieHeaderStore.swift +++ b/Sources/CodexBar/CookieHeaderStore.swift @@ -60,6 +60,15 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { return cached.value } Self.cacheLock.unlock() + let auditMetadata = [ + "service": self.service, + "account": self.account, + "operation": "read", + ] + AuditLogger.recordSecretAccess( + action: "keychain.cookie_header.read", + target: self.account, + metadata: auditMetadata) var result: CFTypeRef? let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -110,6 +119,11 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { Self.log.debug("Keychain access disabled; skipping cookie store") return } + let auditMetadata = [ + "service": self.service, + "account": self.account, + "operation": "write", + ] guard let raw = header?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { @@ -129,6 +143,10 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { return } + AuditLogger.recordSecretAccess( + action: "keychain.cookie_header.write", + target: self.account, + metadata: auditMetadata) let data = raw.data(using: .utf8)! let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -171,6 +189,14 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring { private func deleteIfPresent() throws { guard !KeychainAccessGate.isDisabled else { return } + AuditLogger.recordSecretAccess( + action: "keychain.cookie_header.delete", + target: self.account, + metadata: [ + "service": self.service, + "account": self.account, + "operation": "delete", + ]) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.service, diff --git a/Sources/CodexBar/KeychainPromptCoordinator.swift b/Sources/CodexBar/KeychainPromptCoordinator.swift index a6add39ab..a93d47633 100644 --- a/Sources/CodexBar/KeychainPromptCoordinator.swift +++ b/Sources/CodexBar/KeychainPromptCoordinator.swift @@ -18,6 +18,14 @@ enum KeychainPromptCoordinator { private static func presentKeychainPrompt(_ context: KeychainPromptContext) { let (title, message) = self.keychainCopy(for: context) self.log.info("Keychain prompt requested", metadata: ["kind": "\(context.kind)"]) + AuditLogger.recordSecretAccess( + action: "keychain.prompt_requested", + target: context.account ?? context.service, + metadata: [ + "service": context.service, + "kind": "\(context.kind)", + "interactive": "1", + ]) self.presentAlert(title: title, message: message) } @@ -28,6 +36,13 @@ enum KeychainPromptCoordinator { "and authenticate your account. Click OK to continue.", ].joined(separator: " ") self.log.info("Browser cookie keychain prompt requested", metadata: ["label": context.label]) + AuditLogger.recordSecretAccess( + action: "keychain.browser_cookie_prompt_requested", + target: context.label, + metadata: [ + "label": context.label, + "interactive": "1", + ]) self.presentAlert(title: title, message: message) } diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f2..8050f0cd0 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -1,3 +1,4 @@ +import CodexBarCore import KeyboardShortcuts import SwiftUI @@ -6,6 +7,7 @@ struct AdvancedPane: View { @Bindable var settings: SettingsStore @State private var isInstallingCLI = false @State private var cliStatus: String? + @State private var auditStatus: String? var body: some View { ScrollView(.vertical, showsIndicators: true) { @@ -88,6 +90,75 @@ struct AdvancedPane: View { subtitle: "Prevents any Keychain access while enabled.", binding: self.$settings.debugDisableKeychainAccess) } + + Divider() + + SettingsSection( + title: "Governance Summary", + caption: """ + Keep a local summary of sensitive actions for troubleshooting. This is observability \ + only and does not block or alter behavior. + """) { + PreferenceToggleRow( + title: "Enable Governance Summary", + subtitle: "Write a local Markdown summary of sensitive actions on this Mac.", + binding: self.$settings.governanceAuditModeEnabled) + + DisclosureGroup("Customize recorded event types") { + VStack(alignment: .leading, spacing: 10) { + PreferenceToggleRow( + title: "Audit network requests", + subtitle: "Record request metadata and elevated-risk network flows without storing request secrets.", + binding: self.$settings.governanceAuditNetworkRequestsEnabled) + PreferenceToggleRow( + title: "Audit command execution", + subtitle: "Record spawned commands and high-risk subprocess flows.", + binding: self.$settings.governanceAuditCommandExecutionEnabled) + PreferenceToggleRow( + title: "Audit secret access", + subtitle: "Record keychain and auth-material access without storing accessed values.", + binding: self.$settings.governanceAuditSecretAccessEnabled) + } + .padding(.top, 8) + } + .font(.footnote) + + Text("By default, enabling the summary records network, command, and secret events.") + .font(.footnote) + .foregroundStyle(.tertiary) + + HStack(spacing: 12) { + Button { + self.revealAuditLogFolder() + } label: { + Label("Reveal log folder", systemImage: "folder") + } + .controlSize(.small) + + Button(role: .destructive) { + self.clearAuditLogs() + } label: { + Label("Clear governance summary", systemImage: "trash") + } + .controlSize(.small) + } + + Text( + """ + Governance summaries stay on this Mac in \(Self.auditLogDisplayPath). CodexBar groups \ + sensitive actions into a Markdown summary and keeps technical debug logging in \ + CodexBar.log. + """) + .font(.footnote) + .foregroundStyle(.tertiary) + .textSelection(.enabled) + + if let auditStatus = self.auditStatus { + Text(auditStatus) + .font(.footnote) + .foregroundStyle(.tertiary) + } + } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 20) @@ -97,6 +168,15 @@ struct AdvancedPane: View { } extension AdvancedPane { + private static var auditLogDisplayPath: String { + let path = AuditLogger.summaryFileURL.path + let home = FileManager.default.homeDirectoryForCurrentUser.path + if path.hasPrefix(home) { + return "~" + path.dropFirst(home.count) + } + return path + } + private func installCLI() async { if self.isInstallingCLI { return } self.isInstallingCLI = true @@ -153,4 +233,23 @@ extension AdvancedPane { .path return resolved == destination } + + private func revealAuditLogFolder() { + do { + let url = try AuditLogger.ensureLogDirectoryExists() + NSWorkspace.shared.open(url) + self.auditStatus = "Opened \(url.path)." + } catch { + self.auditStatus = "Failed to open log folder." + } + } + + private func clearAuditLogs() { + do { + try AuditLogger.clearLogs() + self.auditStatus = "Cleared governance audit summary." + } catch { + self.auditStatus = "Failed to clear governance audit summary." + } + } } diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index a86a55434..8589c7adf 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -37,6 +37,11 @@ struct DebugPane: View { } } + PreferenceToggleRow( + title: "Privacy-conscious file logging", + subtitle: "Normalize home paths and trim obvious IDs in file-log paths and URLs without changing verbosity.", + binding: self.$settings.debugPrivacyLoggingEnabled) + HStack(alignment: .center, spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text("Verbosity") diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 84aebdd9f..e890b08c3 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -42,6 +42,54 @@ extension SettingsStore { } } + var governanceAuditModeEnabled: Bool { + get { self.defaultsState.governanceAuditModeEnabled } + set { + let shouldMaterializeDefaultCategories = + newValue && + !self.defaultsState.governanceAuditNetworkRequestsEnabled && + !self.defaultsState.governanceAuditCommandExecutionEnabled && + !self.defaultsState.governanceAuditSecretAccessEnabled + + self.defaultsState.governanceAuditModeEnabled = newValue + self.persistAuditSetting(newValue, key: AuditSettings.modeEnabledKey) + + guard shouldMaterializeDefaultCategories else { return } + + self.defaultsState.governanceAuditNetworkRequestsEnabled = true + self.defaultsState.governanceAuditCommandExecutionEnabled = true + self.defaultsState.governanceAuditSecretAccessEnabled = true + + self.persistAuditSetting(true, key: AuditSettings.networkEnabledKey) + self.persistAuditSetting(true, key: AuditSettings.commandEnabledKey) + self.persistAuditSetting(true, key: AuditSettings.secretEnabledKey) + } + } + + var governanceAuditNetworkRequestsEnabled: Bool { + get { self.defaultsState.governanceAuditNetworkRequestsEnabled } + set { + self.defaultsState.governanceAuditNetworkRequestsEnabled = newValue + self.persistAuditSetting(newValue, key: AuditSettings.networkEnabledKey) + } + } + + var governanceAuditCommandExecutionEnabled: Bool { + get { self.defaultsState.governanceAuditCommandExecutionEnabled } + set { + self.defaultsState.governanceAuditCommandExecutionEnabled = newValue + self.persistAuditSetting(newValue, key: AuditSettings.commandEnabledKey) + } + } + + var governanceAuditSecretAccessEnabled: Bool { + get { self.defaultsState.governanceAuditSecretAccessEnabled } + set { + self.defaultsState.governanceAuditSecretAccessEnabled = newValue + self.persistAuditSetting(newValue, key: AuditSettings.secretEnabledKey) + } + } + var debugFileLoggingEnabled: Bool { get { self.defaultsState.debugFileLoggingEnabled } set { @@ -51,6 +99,15 @@ extension SettingsStore { } } + var debugPrivacyLoggingEnabled: Bool { + get { self.defaultsState.debugPrivacyLoggingEnabled } + set { + self.defaultsState.debugPrivacyLoggingEnabled = newValue + self.userDefaults.set(newValue, forKey: "debugPrivacyLoggingEnabled") + CodexBarLog.setPrivacyMinimizationEnabled(newValue) + } + } + var debugLogLevel: CodexBarLog.Level { get { let raw = self.defaultsState.debugLogLevelRaw @@ -87,6 +144,13 @@ extension SettingsStore { } } + private func persistAuditSetting(_ value: Bool, key: String) { + self.userDefaults.set(value, forKey: key) + if Self.shouldBridgeSharedDefaults(for: self.userDefaults) { + Self.sharedDefaults?.set(value, forKey: key) + } + } + var statusChecksEnabled: Bool { get { self.defaultsState.statusChecksEnabled } set { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 68ba707aa..121d5e11a 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -154,6 +154,7 @@ final class SettingsStore { self.updateProviderState(config: config) self.configLoading = false CodexBarLog.setFileLoggingEnabled(self.debugFileLoggingEnabled) + CodexBarLog.setPrivacyMinimizationEnabled(self.debugPrivacyLoggingEnabled) userDefaults.removeObject(forKey: "showCodexUsage") userDefaults.removeObject(forKey: "showClaudeUsage") LaunchAtLoginManager.setEnabled(self.launchAtLogin) @@ -207,7 +208,28 @@ extension SettingsStore { } return false }() + let governanceAuditModeEnabled = userDefaults.object(forKey: AuditSettings.modeEnabledKey) as? Bool ?? false + let rawGovernanceAuditNetworkRequestsEnabled = userDefaults.object( + forKey: AuditSettings.networkEnabledKey) as? Bool ?? false + let rawGovernanceAuditCommandExecutionEnabled = userDefaults.object( + forKey: AuditSettings.commandEnabledKey) as? Bool ?? false + let rawGovernanceAuditSecretAccessEnabled = userDefaults.object( + forKey: AuditSettings.secretEnabledKey) as? Bool ?? false + let implicitAllGovernanceCategories = governanceAuditModeEnabled + && !rawGovernanceAuditNetworkRequestsEnabled + && !rawGovernanceAuditCommandExecutionEnabled + && !rawGovernanceAuditSecretAccessEnabled + let governanceAuditNetworkRequestsEnabled = implicitAllGovernanceCategories + ? true + : rawGovernanceAuditNetworkRequestsEnabled + let governanceAuditCommandExecutionEnabled = implicitAllGovernanceCategories + ? true + : rawGovernanceAuditCommandExecutionEnabled + let governanceAuditSecretAccessEnabled = implicitAllGovernanceCategories + ? true + : rawGovernanceAuditSecretAccessEnabled let debugFileLoggingEnabled = userDefaults.object(forKey: "debugFileLoggingEnabled") as? Bool ?? false + let debugPrivacyLoggingEnabled = userDefaults.object(forKey: "debugPrivacyLoggingEnabled") as? Bool ?? false let debugLogLevelRaw = userDefaults.string(forKey: "debugLogLevel") ?? CodexBarLog.Level.verbose.rawValue if userDefaults.string(forKey: "debugLogLevel") == nil { userDefaults.set(debugLogLevelRaw, forKey: "debugLogLevel") @@ -268,7 +290,12 @@ extension SettingsStore { launchAtLogin: launchAtLogin, debugMenuEnabled: debugMenuEnabled, debugDisableKeychainAccess: debugDisableKeychainAccess, + governanceAuditModeEnabled: governanceAuditModeEnabled, + governanceAuditNetworkRequestsEnabled: governanceAuditNetworkRequestsEnabled, + governanceAuditCommandExecutionEnabled: governanceAuditCommandExecutionEnabled, + governanceAuditSecretAccessEnabled: governanceAuditSecretAccessEnabled, debugFileLoggingEnabled: debugFileLoggingEnabled, + debugPrivacyLoggingEnabled: debugPrivacyLoggingEnabled, debugLogLevelRaw: debugLogLevelRaw, debugLoadingPatternRaw: debugLoadingPatternRaw, debugKeepCLISessionsAlive: debugKeepCLISessionsAlive, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 69e676032..9bbb46653 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -5,7 +5,12 @@ struct SettingsDefaultsState { var launchAtLogin: Bool var debugMenuEnabled: Bool var debugDisableKeychainAccess: Bool + var governanceAuditModeEnabled: Bool + var governanceAuditNetworkRequestsEnabled: Bool + var governanceAuditCommandExecutionEnabled: Bool + var governanceAuditSecretAccessEnabled: Bool var debugFileLoggingEnabled: Bool + var debugPrivacyLoggingEnabled: Bool var debugLogLevelRaw: String? var debugLoadingPatternRaw: String? var debugKeepCLISessionsAlive: Bool diff --git a/Sources/CodexBar/UsageStore+Status.swift b/Sources/CodexBar/UsageStore+Status.swift index abf7aee47..328d4d32b 100644 --- a/Sources/CodexBar/UsageStore+Status.swift +++ b/Sources/CodexBar/UsageStore+Status.swift @@ -6,7 +6,7 @@ extension UsageStore { var request = URLRequest(url: apiURL) request.timeoutInterval = 10 - let (data, _) = try await URLSession.shared.data(for: request, delegate: nil) + let (data, _) = try await URLSession.shared.codexbarData(for: request, delegate: nil) struct Response: Decodable { struct Status: Decodable { @@ -52,7 +52,7 @@ extension UsageStore { } var request = URLRequest(url: url) request.timeoutInterval = 10 - let (data, _) = try await URLSession.shared.data(for: request, delegate: nil) + let (data, _) = try await URLSession.shared.codexbarData(for: request, delegate: nil) return try Self.parseGoogleWorkspaceStatus(data: data, productID: productID) } diff --git a/Sources/CodexBarCore/Governance/AuditEvent.swift b/Sources/CodexBarCore/Governance/AuditEvent.swift new file mode 100644 index 000000000..7e6d7629c --- /dev/null +++ b/Sources/CodexBarCore/Governance/AuditEvent.swift @@ -0,0 +1,51 @@ +import Foundation + +public enum AuditCategory: String, Codable, Sendable { + case network + case command + case secret +} + +public enum AuditRisk: String, Codable, Sendable { + case normal + case sensitive + case elevatedRisk = "elevated-risk" +} + +public struct GovernanceContext: Codable, Sendable, Equatable { + public let flow: String + public let detail: String? + + public init(flow: String, detail: String? = nil) { + self.flow = flow + self.detail = detail + } +} + +public struct AuditEvent: Codable, Sendable, Equatable { + public let timestamp: Date + public let category: AuditCategory + public let action: String + public let target: String + public let risk: AuditRisk + public let metadata: [String: String] + public let context: GovernanceContext? + + public init( + timestamp: Date = Date(), + category: AuditCategory, + action: String, + target: String, + risk: AuditRisk = .normal, + metadata: [String: String] = [:], + context: GovernanceContext? = nil) + { + self.timestamp = timestamp + self.category = category + self.action = action + self.target = target + self.risk = risk + self.metadata = metadata + self.context = context + } +} diff --git a/Sources/CodexBarCore/Governance/AuditLogger.swift b/Sources/CodexBarCore/Governance/AuditLogger.swift new file mode 100644 index 000000000..78337211c --- /dev/null +++ b/Sources/CodexBarCore/Governance/AuditLogger.swift @@ -0,0 +1,687 @@ +import Foundation +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +struct GovernanceSummaryEntry: Codable, Equatable { + let key: String + let day: String + let category: AuditCategory + let action: String + let target: String + let resource: String + let risk: AuditRisk + let flow: String? + let detail: String? + var count: Int + var firstSeen: Date + var lastSeen: Date +} + +private enum GovernanceSummaryStatus: String { + case expected = "Expected" + case unexpected = "Unexpected" +} + +private struct GovernanceSummaryInterpretation: Equatable { + let status: GovernanceSummaryStatus + let why: String +} + +struct GovernanceSummaryState: Codable, Equatable { + var entries: [GovernanceSummaryEntry] = [] + + mutating func record(_ event: AuditEvent) { + let day = Self.dayString(for: event.timestamp) + let resource = Self.resource(for: event) + let key = [ + day, + event.category.rawValue, + event.action, + event.target, + resource, + event.risk.rawValue, + event.context?.flow ?? "", + event.context?.detail ?? "", + ].joined(separator: "||") + + if let index = self.entries.firstIndex(where: { $0.key == key }) { + self.entries[index].count += 1 + self.entries[index].lastSeen = event.timestamp + return + } + + self.entries.append(GovernanceSummaryEntry( + key: key, + day: day, + category: event.category, + action: event.action, + target: event.target, + resource: resource, + risk: event.risk, + flow: event.context?.flow, + detail: event.context?.detail, + count: 1, + firstSeen: event.timestamp, + lastSeen: event.timestamp)) + } + + private static func dayString(for date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: date) + } + + private static func resource(for event: AuditEvent) -> String { + let priorityKeys = ["path", "service", "account", "binary", "host"] + for key in priorityKeys { + if let value = event.metadata[key], !value.isEmpty { + return value + } + } + return event.target + } +} + +enum GovernanceSummaryRenderer { + static func render(_ state: GovernanceSummaryState) -> String { + var lines = [ + "# Governance Audit Summary", + "", + "Human-readable summary of privacy-sensitive and elevated-risk actions captured by Governance Audit Mode.", + "", + ] + + guard !state.entries.isEmpty else { + lines.append("No governance audit events have been recorded yet.") + lines.append("") + return lines.joined(separator: "\n") + } + + let groupedByDay = Dictionary(grouping: state.entries) { $0.day } + for day in groupedByDay.keys.sorted(by: >) { + lines.append("## \(day)") + lines.append("") + + let entries = groupedByDay[day, default: []] + for risk in [AuditRisk.elevatedRisk, .sensitive, .normal] { + let riskEntries = entries + .filter { $0.risk == risk } + .sorted { lhs, rhs in + if lhs.lastSeen == rhs.lastSeen { + return lhs.action < rhs.action + } + return lhs.lastSeen > rhs.lastSeen + } + guard !riskEntries.isEmpty else { continue } + + lines.append("### \(self.heading(for: risk))") + lines.append("") + for entry in riskEntries { + let interpretation = self.interpret(entry) + lines.append("- **\(self.title(for: entry.action))**") + lines.append(" - Count: \(entry.count)") + lines.append(" - Resource: `\(entry.resource)`") + lines.append(" - First seen: \(self.timeString(entry.firstSeen))") + lines.append(" - Last seen: \(self.timeString(entry.lastSeen))") + lines.append(" - Risk: \(self.riskLabel(entry.risk))") + lines.append(" - Status: \(interpretation.status.rawValue)") + lines.append(" - Why: \(interpretation.why)") + } + lines.append("") + } + } + + return lines.joined(separator: "\n") + } + + private static func heading(for risk: AuditRisk) -> String { + switch risk { + case .elevatedRisk: "Elevated-risk events" + case .sensitive: "Sensitive events" + case .normal: "Observed events" + } + } + + private static func riskLabel(_ risk: AuditRisk) -> String { + switch risk { + case .elevatedRisk: "Elevated risk" + case .sensitive: "Sensitive" + case .normal: "Observed" + } + } + + private static func interpret(_ entry: GovernanceSummaryEntry) -> GovernanceSummaryInterpretation { + if entry.category == .secret, entry.action == "file.auth_json.read", entry.resource == "~/.codex/auth.json" { + return GovernanceSummaryInterpretation( + status: .expected, + why: "Needed for normal Codex authentication and usage probing.") + } + + if entry.category == .secret, + entry.action == "keychain.preflight", + self.expectedKeychainServices.contains(entry.resource) + { + return GovernanceSummaryInterpretation( + status: .expected, + why: "Expected keychain preflight for browser or provider credentials before reading secrets.") + } + + if entry.category == .secret, + self.expectedSecretActions.contains(entry.action) + { + return GovernanceSummaryInterpretation( + status: .expected, + why: self.secretWhy(for: entry.action)) + } + + if entry.category == .command, + self.expectedCommandActions.contains(entry.action) + { + return GovernanceSummaryInterpretation( + status: .expected, + why: self.commandWhy(for: entry.action, target: entry.resource)) + } + + if entry.category == .network { + if self.expectedLocalNetworkActions.contains(entry.action), + entry.flow == "antigravity-localhost-trust" + { + return GovernanceSummaryInterpretation( + status: .expected, + why: "Expected localhost trust override or fallback used for Antigravity local connectivity checks.") + } + + if let host = self.host(from: entry.target) { + if self.expectedNetworkHosts.contains(host) { + return GovernanceSummaryInterpretation( + status: .expected, + why: self.networkWhy(for: entry.action, host: host)) + } + + return GovernanceSummaryInterpretation( + status: .unexpected, + why: "Outside the known set of CodexBar provider and authentication hosts.") + } + } + + return GovernanceSummaryInterpretation( + status: .expected, + why: "Observed within CodexBar's known governance-audited command, network, or credential flows.") + } + + private static func host(from target: String) -> String? { + guard let url = URL(string: target), let host = url.host?.lowercased(), !host.isEmpty else { + return nil + } + return host + } + + private static let expectedSecretActions: Set = [ + "file.auth_json.read", + "file.auth_json.write", + "keychain.cookie_header.read", + "keychain.cookie_header.write", + "keychain.cookie_header.delete", + "keychain.cache.read", + "keychain.cache.write", + "keychain.cache.delete", + "keychain.prompt_requested", + "keychain.browser_cookie_prompt_requested", + "keychain.read_via_security_cli", + "oauth.credentials.read", + "oauth.credentials.write", + "oauth.credentials.refresh", + ] + + private static let expectedKeychainServices: Set = [ + "Chrome Safe Storage", + "Claude Code-credentials", + "com.steipete.CodexBar", + ] + + private static let expectedCommandTargets: Set = [ + "security", + "ps", + "codex", + "claude", + "gemini", + "kilo", + "kiro", + "auggie", + ] + + private static let expectedCommandActions: Set = [ + "process.started", + "process.launched", + "process.launch_failed", + "process.timed_out", + "process.failed", + "process.error", + "process.completed", + ] + + private static let expectedLocalNetworkActions: Set = [ + "request.http_fallback", + "trust_override.accepted", + ] + + private static let expectedNetworkHosts: Set = [ + "127.0.0.1", + "localhost", + "ampcode.com", + "api.anthropic.com", + "api.factory.ai", + "api.github.com", + "api.minimax.io", + "api.minimaxi.com", + "chatgpt.com", + "api.openai.com", + "api.synthetic.new", + "api.workos.com", + "api.z.ai", + "app.augmentcode.com", + "app.factory.ai", + "app.kilo.ai", + "app.kiro.dev", + "app.warp.dev", + "auth.factory.ai", + "auth.openai.com", + "bailian-beijing-cs.aliyuncs.com", + "bailian-singapore-cs.alibabacloud.com", + "bailian.console.aliyun.com", + "chat.openai.com", + "claude.ai", + "cloudcode-pa.googleapis.com", + "cloudresourcemanager.googleapis.com", + "code.claude.com", + "console.anthropic.com", + "console.cloud.google.com", + "cursor.com", + "docs.warp.dev", + "gemini.google.com", + "github.com", + "health.aws.amazon.com", + "kimi-k2.ai", + "kiro.dev", + "minimax.io", + "minimaxi.com", + "modelstudio.console.alibabacloud.com", + "monitoring.googleapis.com", + "openai.com", + "oauth2.googleapis.com", + "ollama.com", + "open.bigmodel.cn", + "opencode.ai", + "openrouter.ai", + "platform.claude.com", + "platform.minimax.io", + "platform.minimaxi.com", + "status.aliyun.com", + "status.claude.com", + "status.cloud.google.com", + "status.cursor.com", + "status.factory.ai", + "status.openai.com", + "status.openrouter.ai", + "status.perplexity.com", + "www.githubstatus.com", + "www.google.com", + "www.googleapis.com", + "www.kimi.com", + "www.minimax.io", + "www.minimaxi.com", + "www.perplexity.ai", + "z.ai", + ] + + private static func secretWhy(for action: String) -> String { + switch action { + case "file.auth_json.read", "file.auth_json.write": + return "Expected local auth-file access for provider authentication and account management." + case "keychain.preflight": + return "Expected keychain access check before reading provider or browser credentials." + case "keychain.prompt_requested", "keychain.browser_cookie_prompt_requested": + return "Expected prompt coordination when browser or keychain access requires user approval." + case "keychain.cookie_header.read", "keychain.cookie_header.write", "keychain.cookie_header.delete": + return "Expected cookie-header access during provider authentication and session management." + case "keychain.cache.read", "keychain.cache.write", "keychain.cache.delete": + return "Expected cached credential access used to persist provider state between refreshes." + case "keychain.read_via_security_cli": + return "Expected credential read path when Claude keychain access falls back to the security CLI." + case "oauth.credentials.read", "oauth.credentials.write", "oauth.credentials.refresh": + return "Expected OAuth credential access during normal token loading and refresh flows." + default: + return "Expected credential or cookie access during normal provider authentication flows." + } + } + + private static func commandWhy(for action: String, target: String) -> String { + let loweredTarget = target.lowercased() + if self.expectedCommandTargets.contains(loweredTarget) { + return "Expected helper command used for provider detection, authentication, or usage probing." + } + + switch action { + case "process.started", "process.launched": + return "Expected helper process launch during provider detection or usage probing." + case "process.completed": + return "Expected helper process completion during normal app flows." + case "process.launch_failed", "process.timed_out", "process.failed", "process.error": + return "Expected reporting for helper-process failures encountered during normal app flows." + default: + return "Expected command used during normal CodexBar helper-process flows." + } + } + + private static func networkWhy(for action: String, host: String) -> String { + switch action { + case "request.started", "request.completed", "request.failed": + return "Expected network request to a known CodexBar provider, dashboard, or authentication endpoint." + case "request.http_fallback", "trust_override.accepted": + return "Expected elevated network behavior for Antigravity localhost connectivity and trust handling." + default: + return "Expected network access to a host supported by CodexBar." + } + } + + private static func title(for action: String) -> String { + let separators = CharacterSet(charactersIn: "._-") + let words = action + .components(separatedBy: separators) + .filter { !$0.isEmpty } + .map { word -> String in + switch word.lowercased() { + case "json": "JSON" + case "oauth": "OAuth" + case "cli": "CLI" + case "pty": "PTY" + default: word.capitalized + } + } + return words.joined(separator: " ") + } + + private static func timeString(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "HH:mm:ss" + return formatter.string(from: date) + } +} + +final class GovernanceSummarySink: @unchecked Sendable { + static let shared = GovernanceSummarySink() + static let defaultDirectoryURL: URL = { + let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) + return base + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent("CodexBar", isDirectory: true) + }() + + static let summaryFileURL = GovernanceSummarySink.defaultDirectoryURL + .appendingPathComponent("Governance Audit Summary.md", isDirectory: false) + static let stateFileURL = GovernanceSummarySink.defaultDirectoryURL + .appendingPathComponent(".governance-audit-state.json", isDirectory: false) + static let legacyDirectoryURL = GovernanceSummarySink.defaultDirectoryURL + .appendingPathComponent("Governance", isDirectory: true) + static let lockFileURL = GovernanceSummarySink.defaultDirectoryURL + .appendingPathComponent(".governance-audit.lock", isDirectory: false) + + private let queue = DispatchQueue(label: "com.steipete.codexbar.auditlog", qos: .utility) + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func write(_ event: AuditEvent) { + self.queue.async { + do { + try self.prepareDirectory() + try self.withLock { + var state = try self.loadState() + state.record(event) + try self.writeState(state) + try self.writeSummary(state) + } + } catch { + // Keep audit logging non-fatal and silent. + } + } + } + + func clear() throws { + try self.queue.sync { + try self.withLock { + if self.fileManager.fileExists(atPath: Self.summaryFileURL.path) { + try self.fileManager.removeItem(at: Self.summaryFileURL) + } + if self.fileManager.fileExists(atPath: Self.stateFileURL.path) { + try self.fileManager.removeItem(at: Self.stateFileURL) + } + if self.fileManager.fileExists(atPath: Self.legacyDirectoryURL.path) { + try self.fileManager.removeItem(at: Self.legacyDirectoryURL) + } + } + } + } + + func ensureDirectoryExists() throws -> URL { + try self.queue.sync { + try self.prepareDirectory() + return Self.defaultDirectoryURL + } + } + + private func prepareDirectory() throws { + if !self.fileManager.fileExists(atPath: Self.defaultDirectoryURL.path) { + try self.fileManager.createDirectory( + at: Self.defaultDirectoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } + if !self.fileManager.fileExists(atPath: Self.lockFileURL.path) { + _ = self.fileManager.createFile(atPath: Self.lockFileURL.path, contents: nil) + } + try self.enforcePermissions(at: Self.lockFileURL, mode: 0o600) + } + + private func enforcePermissions(at url: URL, mode: Int16) throws { + let attributes = try? self.fileManager.attributesOfItem(atPath: url.path) + let currentMode = (attributes?[.posixPermissions] as? NSNumber)?.int16Value + if currentMode != mode { + try self.fileManager.setAttributes([.posixPermissions: NSNumber(value: mode)], ofItemAtPath: url.path) + } + } + + private func withLock(_ operation: () throws -> T) throws -> T { + let descriptor = open(Self.lockFileURL.path, O_RDWR | O_CREAT, 0o600) + guard descriptor >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { close(descriptor) } + guard flock(descriptor, LOCK_EX) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { _ = flock(descriptor, LOCK_UN) } + return try operation() + } + + private func loadState() throws -> GovernanceSummaryState { + guard self.fileManager.fileExists(atPath: Self.stateFileURL.path) else { + return GovernanceSummaryState() + } + let data = try Data(contentsOf: Self.stateFileURL) + guard !data.isEmpty else { return GovernanceSummaryState() } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(GovernanceSummaryState.self, from: data) + } + + private func writeState(_ state: GovernanceSummaryState) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(state) + try data.write(to: Self.stateFileURL, options: .atomic) + try self.enforcePermissions(at: Self.stateFileURL, mode: 0o600) + } + + private func writeSummary(_ state: GovernanceSummaryState) throws { + let markdown = GovernanceSummaryRenderer.render(state) + try markdown.write(to: Self.summaryFileURL, atomically: true, encoding: .utf8) + try self.enforcePermissions(at: Self.summaryFileURL, mode: 0o600) + } +} + +public enum AuditLogger { + private static let sink = GovernanceSummarySink.shared + private static let log = CodexBarLog.logger(LogCategories.governanceAudit) + private static let sensitiveHeaderKeys = ["authorization", "cookie", "x-api-key"] + + public static var summaryFileURL: URL { + GovernanceSummarySink.summaryFileURL + } + + public static var logDirectoryURL: URL { + GovernanceSummarySink.defaultDirectoryURL + } + + public static func ensureLogDirectoryExists() throws -> URL { + try self.sink.ensureDirectoryExists() + } + + public static func clearLogs() throws { + try self.sink.clear() + } + + public static func record(_ event: AuditEvent) { + guard AuditSettings.current().isEnabled(for: event.category) else { return } + let sanitized = self.sanitizeForSummary(event) + self.sink.write(sanitized) + } + + public static func recordCommand( + action: String, + binary: String, + risk: AuditRisk = .normal, + metadata: [String: String] = [:], + context: GovernanceContext? = nil) + { + self.record(AuditEvent( + category: .command, + action: action, + target: URL(fileURLWithPath: binary).lastPathComponent, + risk: risk, + metadata: metadata, + context: context)) + } + + public static func inferredCommandRisk(binary: String, usesShell: Bool = false) -> AuditRisk { + if usesShell { + return .elevatedRisk + } + + let name = URL(fileURLWithPath: binary).lastPathComponent.lowercased() + switch name { + case "security": + return .elevatedRisk + case "codex", "claude", "gemini", "kilo", "kiro", "auggie": + return .sensitive + default: + return .normal + } + } + + public static func recordSecretAccess( + action: String, + target: String, + risk: AuditRisk = .sensitive, + metadata: [String: String] = [:], + context: GovernanceContext? = nil) + { + self.record(AuditEvent( + category: .secret, + action: action, + target: target, + risk: risk, + metadata: metadata, + context: context)) + } + + public static func recordNetwork( + action: String, + request: URLRequest, + response: URLResponse? = nil, + error: Error? = nil, + risk: AuditRisk? = nil, + metadata: [String: String] = [:], + context: GovernanceContext? = nil) + { + let computedRisk = risk ?? self.defaultNetworkRisk(for: request) + var combinedMetadata = metadata + combinedMetadata["method"] = request.httpMethod ?? "GET" + combinedMetadata["has_query"] = request.url?.query?.isEmpty == false ? "1" : "0" + combinedMetadata["body_bytes"] = request.httpBody.map { "\($0.count)" } ?? "0" + + let headerKeys = request.allHTTPHeaderFields?.keys.map { $0.lowercased() } ?? [] + for header in self.sensitiveHeaderKeys { + combinedMetadata["header_\(header.replacingOccurrences(of: "-", with: "_"))"] = + headerKeys.contains(header) ? "1" : "0" + } + + if let http = response as? HTTPURLResponse { + combinedMetadata["status_code"] = "\(http.statusCode)" + } + if let error { + let nsError = error as NSError + combinedMetadata["error_domain"] = nsError.domain + combinedMetadata["error_code"] = "\(nsError.code)" + } + + self.record(AuditEvent( + category: .network, + action: action, + target: self.networkTarget(for: request), + risk: computedRisk, + metadata: combinedMetadata, + context: context)) + } + + static func sanitizeForPersistence(_ event: AuditEvent) -> AuditEvent { + self.sanitizeForSummary(event) + } + + static func sanitizeForSummary(_ event: AuditEvent) -> AuditEvent { + AuditPrivacySanitizer.sanitizeEvent(event) + } + + private static func networkTarget(for request: URLRequest) -> String { + guard let url = request.url else { return "unknown" } + var target = "\(url.scheme ?? "unknown")://\(url.host ?? "unknown")" + if let port = url.port { + target += ":\(port)" + } + target += url.path.isEmpty ? "/" : AuditPrivacySanitizer.redactPathSegments(in: url.path) + return target + } + + private static func defaultNetworkRisk(for request: URLRequest) -> AuditRisk { + let headerKeys = Set((request.allHTTPHeaderFields ?? [:]).keys.map { $0.lowercased() }) + if !headerKeys.isDisjoint(with: self.sensitiveHeaderKeys) { + return .sensitive + } + return .normal + } + + static func recordInternalFailure(_ message: String, metadata: [String: String] = [:]) { + self.log.warning(message, metadata: AuditPrivacySanitizer.sanitizeMetadata(metadata)) + } +} diff --git a/Sources/CodexBarCore/Governance/AuditPrivacySanitizer.swift b/Sources/CodexBarCore/Governance/AuditPrivacySanitizer.swift new file mode 100644 index 000000000..6c150042f --- /dev/null +++ b/Sources/CodexBarCore/Governance/AuditPrivacySanitizer.swift @@ -0,0 +1,130 @@ +import Foundation + +enum AuditPrivacySanitizer { + private static let homeDirectoryPath = FileManager.default.homeDirectoryForCurrentUser.path + private static let resourceIdentifierPattern = + #"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^[0-9a-f]{24,}$"# + private static let resourceIdentifierRegex = try? NSRegularExpression(pattern: Self.resourceIdentifierPattern) + private static let embeddedURLRegex = try? NSRegularExpression(pattern: #"https?://[^\s)]+"#) + private static let embeddedHomePathRegex = try? NSRegularExpression( + pattern: #"~\/[^\n)]*?(?=(?: \(|[),;]|$))"#) + + static func sanitizeEvent(_ event: AuditEvent) -> AuditEvent { + AuditEvent( + timestamp: event.timestamp, + category: event.category, + action: self.sanitizeText(event.action), + target: self.sanitizeText(event.target), + risk: event.risk, + metadata: self.sanitizeMetadata(event.metadata), + context: event.context.map { + GovernanceContext( + flow: self.sanitizeText($0.flow), + detail: $0.detail.map(self.sanitizeText)) + }) + } + + static func sanitizeMetadata(_ metadata: [String: String]) -> [String: String] { + metadata.reduce(into: [:]) { partial, entry in + partial[entry.key] = self.sanitizeText(entry.value) + } + } + + static func sanitizeText(_ value: String) -> String { + let redacted = LogRedactor.redact(value) + let homeNormalized = self.normalizeHomePath(in: redacted) + let embeddedURLsSanitized = self.redactEmbeddedURLs(in: homeNormalized) + let embeddedPathsSanitized = self.redactEmbeddedHomePaths(in: embeddedURLsSanitized) + if let urlSanitized = self.redactResourceIdentifiersInURL(embeddedPathsSanitized) { + return urlSanitized + } + return self.redactResourceIdentifiersInPath(embeddedPathsSanitized) + } + + static func redactPathSegments(in path: String) -> String { + let hasLeadingSlash = path.hasPrefix("/") + let hasHomePrefix = path.hasPrefix("~/") + let leading = hasHomePrefix ? "~/" : (hasLeadingSlash ? "/" : "") + let trimmed: String = if hasHomePrefix { + String(path.dropFirst(2)) + } else if hasLeadingSlash { + String(path.dropFirst()) + } else { + path + } + + let sanitizedSegments = trimmed + .split(separator: "/", omittingEmptySubsequences: false) + .map { segment -> String in + let text = String(segment) + return self.looksLikeResourceIdentifier(text) ? "" : text + } + return leading + sanitizedSegments.joined(separator: "/") + } + + private static func normalizeHomePath(in value: String) -> String { + guard value.contains(self.homeDirectoryPath) else { return value } + return value.replacingOccurrences(of: self.homeDirectoryPath, with: "~") + } + + private static func redactEmbeddedURLs(in value: String) -> String { + self.replacingMatches(in: value, regex: self.embeddedURLRegex) { match in + self.redactResourceIdentifiersInURL(match) ?? match + } + } + + private static func redactEmbeddedHomePaths(in value: String) -> String { + self.replacingMatches(in: value, regex: self.embeddedHomePathRegex) { match in + self.redactPathSegments(in: match) + } + } + + private static func redactResourceIdentifiersInURL(_ value: String) -> String? { + guard let components = URLComponents(string: value), + let scheme = components.scheme, + let host = components.host + else { + return nil + } + + var sanitized = "\(scheme)://\(host)" + if let port = components.port { + sanitized += ":\(port)" + } + sanitized += self.redactPathSegments(in: components.path.isEmpty ? "/" : components.path) + return sanitized + } + + private static func redactResourceIdentifiersInPath(_ value: String) -> String { + if value.hasPrefix("~/") || value.hasPrefix("/") { + return self.redactPathSegments(in: value) + } + return value + } + + private static func looksLikeResourceIdentifier(_ segment: String) -> Bool { + let range = NSRange(segment.startIndex.. String) + -> String + { + guard let regex else { return value } + let range = NSRange(value.startIndex.. Bool { + guard self.modeEnabled else { return false } + if self.usesImplicitAllCategories { + return true + } + return switch category { + case .network: + self.networkEnabled + case .command: + self.commandEnabled + case .secret: + self.secretEnabled + } + } + + public var usesImplicitAllCategories: Bool { + self.modeEnabled && !self.networkEnabled && !self.commandEnabled && !self.secretEnabled + } +} + +public enum AuditSettings { + public static let appGroupSuiteName = "group.com.steipete.codexbar" + public static let modeEnabledKey = "governanceAuditModeEnabled" + public static let networkEnabledKey = "governanceAuditNetworkRequestsEnabled" + public static let commandEnabledKey = "governanceAuditCommandExecutionEnabled" + public static let secretEnabledKey = "governanceAuditSecretAccessEnabled" + + public static func current( + userDefaults: UserDefaults = .standard, + sharedDefaults: UserDefaults? = UserDefaults(suiteName: AuditSettings.appGroupSuiteName)) + -> AuditSettingsSnapshot + { + let modeEnabled = self.bool(forKey: self.modeEnabledKey, userDefaults: userDefaults, sharedDefaults: sharedDefaults) + let networkEnabled = self.bool( + forKey: self.networkEnabledKey, + userDefaults: userDefaults, + sharedDefaults: sharedDefaults) + let commandEnabled = self.bool( + forKey: self.commandEnabledKey, + userDefaults: userDefaults, + sharedDefaults: sharedDefaults) + let secretEnabled = self.bool( + forKey: self.secretEnabledKey, + userDefaults: userDefaults, + sharedDefaults: sharedDefaults) + let effective = self.effectiveCategories( + modeEnabled: modeEnabled, + networkEnabled: networkEnabled, + commandEnabled: commandEnabled, + secretEnabled: secretEnabled) + + return AuditSettingsSnapshot( + modeEnabled: modeEnabled, + networkEnabled: effective.networkEnabled, + commandEnabled: effective.commandEnabled, + secretEnabled: effective.secretEnabled) + } + + private static func bool( + forKey key: String, + userDefaults: UserDefaults, + sharedDefaults: UserDefaults?) + -> Bool + { + if let value = userDefaults.object(forKey: key) as? Bool { + return value + } + if let value = sharedDefaults?.object(forKey: key) as? Bool { + return value + } + return false + } + + private static func effectiveCategories( + modeEnabled: Bool, + networkEnabled: Bool, + commandEnabled: Bool, + secretEnabled: Bool) + -> (networkEnabled: Bool, commandEnabled: Bool, secretEnabled: Bool) + { + guard modeEnabled else { + return (networkEnabled, commandEnabled, secretEnabled) + } + guard !networkEnabled && !commandEnabled && !secretEnabled else { + return (networkEnabled, commandEnabled, secretEnabled) + } + return (true, true, true) + } +} diff --git a/Sources/CodexBarCore/Governance/AuditedURLSession.swift b/Sources/CodexBarCore/Governance/AuditedURLSession.swift new file mode 100644 index 000000000..eb61503f2 --- /dev/null +++ b/Sources/CodexBarCore/Governance/AuditedURLSession.swift @@ -0,0 +1,84 @@ +import Foundation + +public struct NetworkAuditOptions: Sendable { + public let risk: AuditRisk? + public let metadata: [String: String] + public let context: GovernanceContext? + + public init( + risk: AuditRisk? = nil, + metadata: [String: String] = [:], + context: GovernanceContext? = nil) + { + self.risk = risk + self.metadata = metadata + self.context = context + } +} + +public extension URLSession { + func codexbarData( + for request: URLRequest, + audit options: NetworkAuditOptions = NetworkAuditOptions()) async throws -> (Data, URLResponse) + { + AuditLogger.recordNetwork( + action: "request.started", + request: request, + risk: options.risk, + metadata: options.metadata, + context: options.context) + do { + let result = try await self.data(for: request) + AuditLogger.recordNetwork( + action: "request.completed", + request: request, + response: result.1, + risk: options.risk, + metadata: options.metadata, + context: options.context) + return result + } catch { + AuditLogger.recordNetwork( + action: "request.failed", + request: request, + error: error, + risk: options.risk, + metadata: options.metadata, + context: options.context) + throw error + } + } + + func codexbarData( + for request: URLRequest, + delegate: (any URLSessionTaskDelegate)?, + audit options: NetworkAuditOptions = NetworkAuditOptions()) async throws -> (Data, URLResponse) + { + AuditLogger.recordNetwork( + action: "request.started", + request: request, + risk: options.risk, + metadata: options.metadata, + context: options.context) + do { + let result = try await self.data(for: request, delegate: delegate) + AuditLogger.recordNetwork( + action: "request.completed", + request: request, + response: result.1, + risk: options.risk, + metadata: options.metadata, + context: options.context) + return result + } catch { + AuditLogger.recordNetwork( + action: "request.failed", + request: request, + error: error, + risk: options.risk, + metadata: options.metadata, + context: options.context) + throw error + } + } +} diff --git a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift index d55bff859..1b523178a 100644 --- a/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift +++ b/Sources/CodexBarCore/Host/PTY/TTYCommandRunner.swift @@ -428,10 +428,18 @@ public struct TTYCommandRunner { env["PWD"] = workingDirectory.path } proc.environment = env + let auditMetadata = [ + "argument_count": "\(options.extraArgs.count)", + "timeout_ms": "\(Int(options.timeout * 1000))", + "binary_name": binaryName, + "working_directory_set": options.workingDirectory == nil ? "0" : "1", + ] + let auditRisk = AuditLogger.inferredCommandRisk(binary: resolved) var cleanedUp = false var didLaunch = false var processGroup: pid_t? + var auditCompletionAction: String? /// Always tear down the PTY child (and its process group) even if we throw early /// while bootstrapping the CLI (e.g. when it prompts for login/telemetry). func cleanup() { @@ -469,6 +477,14 @@ public struct TTYCommandRunner { } cleanedUp = true + let terminalAuditAction = auditCompletionAction ?? (didLaunch ? "process.failed" : nil) + if let terminalAuditAction { + AuditLogger.recordCommand( + action: terminalAuditAction, + binary: resolved, + risk: auditRisk, + metadata: auditMetadata.merging(["status": "\(proc.terminationStatus)"], uniquingKeysWith: { _, new in new })) + } if didLaunch { TTYCommandRunnerActiveProcessRegistry.unregister(pid: proc.processIdentifier) } @@ -477,6 +493,11 @@ public struct TTYCommandRunner { // Ensure the PTY process is always torn down, even when we throw early (e.g. login prompt). defer { cleanup() } + AuditLogger.recordCommand( + action: "process.started", + binary: resolved, + risk: auditRisk, + metadata: auditMetadata) do { try proc.run() didLaunch = true @@ -484,6 +505,11 @@ public struct TTYCommandRunner { Self.log.warning( "PTY launch failed", metadata: ["binary": binaryName, "error": error.localizedDescription]) + AuditLogger.recordCommand( + action: "process.launch_failed", + binary: resolved, + risk: auditRisk, + metadata: auditMetadata.merging(["error": error.localizedDescription], uniquingKeysWith: { _, new in new })) throw Error.launchFailed(error.localizedDescription) } @@ -502,6 +528,11 @@ public struct TTYCommandRunner { TTYCommandRunnerActiveProcessRegistry.updateProcessGroup(pid: pid, processGroup: processGroup) } Self.log.debug("PTY launched", metadata: ["binary": binaryName]) + AuditLogger.recordCommand( + action: "process.launched", + binary: resolved, + risk: auditRisk, + metadata: auditMetadata.merging(["pid": "\(pid)"], uniquingKeysWith: { _, new in new })) func send(_ text: String) throws { guard let data = text.data(using: .utf8) else { return } @@ -706,7 +737,11 @@ public struct TTYCommandRunner { } let text = String(data: buffer, encoding: .utf8) ?? "" - guard !text.isEmpty else { throw Error.timedOut } + guard !text.isEmpty else { + auditCompletionAction = "process.timed_out" + throw Error.timedOut + } + auditCompletionAction = "process.completed" return Result(text: text) } @@ -849,9 +884,11 @@ public struct TTYCommandRunner { } guard let text = String(data: buffer, encoding: .utf8), !text.isEmpty else { + auditCompletionAction = "process.timed_out" throw Error.timedOut } + auditCompletionAction = "process.completed" return Result(text: text) } diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 34c8bf38b..bf96a4879 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -142,9 +142,19 @@ public enum SubprocessRunner { let start = Date() let binaryName = URL(fileURLWithPath: binary).lastPathComponent + let auditMetadata = [ + "label": label, + "argument_count": "\(arguments.count)", + "timeout_ms": "\(Int(timeout * 1000))", + ] self.log.debug( "Subprocess start", metadata: ["label": label, "binary": binaryName, "timeout": "\(timeout)"]) + AuditLogger.recordCommand( + action: "process.started", + binary: binary, + risk: AuditLogger.inferredCommandRisk(binary: binary), + metadata: auditMetadata) let process = Process() process.executableURL = URL(fileURLWithPath: binary) @@ -170,6 +180,11 @@ public enum SubprocessRunner { stdoutPipe.fileHandleForWriting.closeFile() stderrPipe.fileHandleForReading.closeFile() stderrPipe.fileHandleForWriting.closeFile() + AuditLogger.recordCommand( + action: "process.launch_failed", + binary: binary, + risk: AuditLogger.inferredCommandRisk(binary: binary), + metadata: auditMetadata.merging(["error": error.localizedDescription], uniquingKeysWith: { _, new in new })) throw SubprocessRunnerError.launchFailed(error.localizedDescription) } @@ -222,6 +237,13 @@ public enum SubprocessRunner { "binary": binaryName, "duration_ms": "\(Int(duration * 1000))", ]) + AuditLogger.recordCommand( + action: "process.timed_out", + binary: binary, + risk: AuditLogger.inferredCommandRisk(binary: binary), + metadata: auditMetadata.merging( + ["duration_ms": "\(Int(duration * 1000))"], + uniquingKeysWith: { _, new in new })) stdoutTask.cancel() stderrTask.cancel() throw SubprocessRunnerError.timedOut(label) @@ -242,6 +264,16 @@ public enum SubprocessRunner { "status": "\(exitCode)", "duration_ms": "\(Int(duration * 1000))", ]) + AuditLogger.recordCommand( + action: "process.failed", + binary: binary, + risk: AuditLogger.inferredCommandRisk(binary: binary), + metadata: auditMetadata.merging( + [ + "status": "\(exitCode)", + "duration_ms": "\(Int(duration * 1000))", + ], + uniquingKeysWith: { _, new in new })) throw SubprocessRunnerError.nonZeroExit(code: exitCode, stderr: stderr) } @@ -254,6 +286,16 @@ public enum SubprocessRunner { "status": "\(exitCode)", "duration_ms": "\(Int(duration * 1000))", ]) + AuditLogger.recordCommand( + action: "process.completed", + binary: binary, + risk: AuditLogger.inferredCommandRisk(binary: binary), + metadata: auditMetadata.merging( + [ + "status": "\(exitCode)", + "duration_ms": "\(Int(duration * 1000))", + ], + uniquingKeysWith: { _, new in new })) return SubprocessResult(stdout: stdout, stderr: stderr) } catch { let duration = Date().timeIntervalSince(start) @@ -264,6 +306,16 @@ public enum SubprocessRunner { "binary": binaryName, "duration_ms": "\(Int(duration * 1000))", ]) + AuditLogger.recordCommand( + action: "process.error", + binary: binary, + risk: AuditLogger.inferredCommandRisk(binary: binary), + metadata: auditMetadata.merging( + [ + "duration_ms": "\(Int(duration * 1000))", + "error": error.localizedDescription, + ], + uniquingKeysWith: { _, new in new })) // Safety net: ensure the process is dead (may already be killed by timeout task). self.terminateProcess(process, processGroup: processGroup) exitCodeTask.cancel() diff --git a/Sources/CodexBarCore/KeychainAccessPreflight.swift b/Sources/CodexBarCore/KeychainAccessPreflight.swift index 260435ff5..16b223292 100644 --- a/Sources/CodexBarCore/KeychainAccessPreflight.swift +++ b/Sources/CodexBarCore/KeychainAccessPreflight.swift @@ -148,6 +148,15 @@ public enum KeychainAccessPreflight { query[kSecAttrAccount as String] = account } + AuditLogger.recordSecretAccess( + action: "keychain.preflight", + target: account ?? service, + metadata: [ + "service": service, + "account": account ?? "", + "operation": "preflight", + ]) + var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) switch status { diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index ebe5c45ae..d04162b90 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -46,6 +46,15 @@ public enum KeychainCacheStore { return testResult } #if os(macOS) + let auditMetadata = [ + "service": self.serviceName, + "account": key.account, + "operation": "read", + ] + AuditLogger.recordSecretAccess( + action: "keychain.cache.read", + target: key.account, + metadata: auditMetadata) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, @@ -84,6 +93,15 @@ public enum KeychainCacheStore { return } #if os(macOS) + let auditMetadata = [ + "service": self.serviceName, + "account": key.account, + "operation": "write", + ] + AuditLogger.recordSecretAccess( + action: "keychain.cache.write", + target: key.account, + metadata: auditMetadata) let encoder = Self.makeEncoder() guard let data = try? encoder.encode(entry) else { self.log.error("Failed to encode keychain cache (\(key.account))") @@ -125,6 +143,14 @@ public enum KeychainCacheStore { return } #if os(macOS) + AuditLogger.recordSecretAccess( + action: "keychain.cache.delete", + target: key.account, + metadata: [ + "service": self.serviceName, + "account": key.account, + "operation": "delete", + ]) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, diff --git a/Sources/CodexBarCore/Logging/CodexBarLog.swift b/Sources/CodexBarCore/Logging/CodexBarLog.swift index 2f9db069c..4519f1b22 100644 --- a/Sources/CodexBarCore/Logging/CodexBarLog.swift +++ b/Sources/CodexBarCore/Logging/CodexBarLog.swift @@ -71,8 +71,10 @@ public enum CodexBarLog { private static let lock = NSLock() private static let levelLock = NSLock() + private static let privacyLock = NSLock() private nonisolated(unsafe) static var isBootstrapped = false private nonisolated(unsafe) static var currentLevel: Level = .info + private nonisolated(unsafe) static var privacyMinimizationEnabled = false public static func bootstrapIfNeeded(_ config: Configuration) { self.lock.lock() @@ -152,6 +154,21 @@ public enum CodexBarLog { let logger = self.logger(LogCategories.logging) logger.info("File logging \(state)", metadata: ["path": self.fileLogURL.path]) } + + public static func setPrivacyMinimizationEnabled(_ enabled: Bool) { + self.privacyLock.lock() + self.privacyMinimizationEnabled = enabled + self.privacyLock.unlock() + let state = enabled ? "enabled" : "disabled" + let logger = self.logger(LogCategories.logging) + logger.info("Privacy-conscious file logging \(state)") + } + + static func isPrivacyMinimizationEnabled() -> Bool { + self.privacyLock.lock() + defer { self.privacyLock.unlock() } + return self.privacyMinimizationEnabled + } } public struct CodexBarLogger: Sendable { diff --git a/Sources/CodexBarCore/Logging/FileLogHandler.swift b/Sources/CodexBarCore/Logging/FileLogHandler.swift index effe9ff5c..fa07715eb 100644 --- a/Sources/CodexBarCore/Logging/FileLogHandler.swift +++ b/Sources/CodexBarCore/Logging/FileLogHandler.swift @@ -122,13 +122,13 @@ struct FileLogHandler: LogHandler { .sorted(by: { $0.key < $1.key }) .map { key, value in let rendered = Self.renderMetadataValue(value) - let safeValue = LogRedactor.redact(rendered) + let safeValue = Self.sanitizeForFileLog(rendered) return "\(key)=\(safeValue)" } .joined(separator: " ") metaText = " \(pairs)" } - let safeMessage = LogRedactor.redact("\(message)") + let safeMessage = Self.sanitizeForFileLog("\(message)") let lineText = "[\(ts)] [\(level.rawValue.uppercased())] \(self.label): \(safeMessage)\(metaText)\n" _ = source _ = file @@ -151,4 +151,12 @@ struct FileLogHandler: LogHandler { String(describing: value) } } + + private static func sanitizeForFileLog(_ value: String) -> String { + let redacted = LogRedactor.redact(value) + if CodexBarLog.isPrivacyMinimizationEnabled() { + return AuditPrivacySanitizer.sanitizeText(redacted) + } + return redacted + } } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 5f6cf9217..56f0d97c4 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -18,6 +18,7 @@ public enum LogCategories { public static let creditsPurchase = "creditsPurchase" public static let cursorLogin = "cursor-login" public static let geminiProbe = "gemini-probe" + public static let governanceAudit = "governance-audit" public static let keychainCache = "keychain-cache" public static let keychainMigration = "keychain-migration" public static let keychainPreflight = "keychain-preflight" diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift index 200193f62..3f318a9b0 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift @@ -533,7 +533,7 @@ public struct OpenAIDashboardBrowserCookieImporter { request.setValue("application/json", forHTTPHeaderField: "Accept") do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? -1 logger("API \(url.host ?? "chatgpt.com") \(url.path) status=\(status)") guard status >= 200, status < 300 else { continue } diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index e845f1a86..7028e6d11 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -263,9 +263,25 @@ public enum ShellCommandLocator { let stdout = Pipe() process.standardOutput = stdout process.standardError = Pipe() + let auditMetadata = [ + "timeout_ms": "\(Int(timeout * 1000))", + "interactive_login_shell": isCI ? "0" : "1", + "command_length": "\(command.count)", + "purpose": "path-shell-capture", + ] + AuditLogger.recordCommand( + action: "process.started", + binary: shellPath, + risk: AuditLogger.inferredCommandRisk(binary: shellPath, usesShell: true), + metadata: auditMetadata) do { try process.run() } catch { + AuditLogger.recordCommand( + action: "process.launch_failed", + binary: shellPath, + risk: AuditLogger.inferredCommandRisk(binary: shellPath, usesShell: true), + metadata: auditMetadata.merging(["error": error.localizedDescription], uniquingKeysWith: { _, new in new })) return nil } @@ -276,10 +292,20 @@ public enum ShellCommandLocator { if process.isRunning { process.terminate() + AuditLogger.recordCommand( + action: "process.timed_out", + binary: shellPath, + risk: AuditLogger.inferredCommandRisk(binary: shellPath, usesShell: true), + metadata: auditMetadata) return nil } let data = stdout.fileHandleForReading.readDataToEndOfFile() + AuditLogger.recordCommand( + action: "process.completed", + binary: shellPath, + risk: AuditLogger.inferredCommandRisk(binary: shellPath, usesShell: true), + metadata: auditMetadata.merging(["status": "\(process.terminationStatus)"], uniquingKeysWith: { _, new in new })) return String(data: data, encoding: .utf8) } @@ -424,9 +450,24 @@ enum LoginShellPathCapturer { let stdout = Pipe() process.standardOutput = stdout process.standardError = Pipe() + let auditMetadata = [ + "timeout_ms": "\(Int(timeout * 1000))", + "interactive_login_shell": isCI ? "0" : "1", + "purpose": "login-shell-path-capture", + ] + AuditLogger.recordCommand( + action: "process.started", + binary: shellPath, + risk: AuditLogger.inferredCommandRisk(binary: shellPath, usesShell: true), + metadata: auditMetadata) do { try process.run() } catch { + AuditLogger.recordCommand( + action: "process.launch_failed", + binary: shellPath, + risk: AuditLogger.inferredCommandRisk(binary: shellPath, usesShell: true), + metadata: auditMetadata.merging(["error": error.localizedDescription], uniquingKeysWith: { _, new in new })) return nil } @@ -437,10 +478,20 @@ enum LoginShellPathCapturer { if process.isRunning { process.terminate() + AuditLogger.recordCommand( + action: "process.timed_out", + binary: shellPath, + risk: AuditLogger.inferredCommandRisk(binary: shellPath, usesShell: true), + metadata: auditMetadata) return nil } let data = stdout.fileHandleForReading.readDataToEndOfFile() + AuditLogger.recordCommand( + action: "process.completed", + binary: shellPath, + risk: AuditLogger.inferredCommandRisk(binary: shellPath, usesShell: true), + metadata: auditMetadata.merging(["status": "\(process.terminationStatus)"], uniquingKeysWith: { _, new in new })) guard let raw = String(data: data, encoding: .utf8), !raw.isEmpty else { return nil } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift index e3509d438..139ca3f7e 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaCodingPlanUsageFetcher.swift @@ -108,7 +108,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { request.setValue(region.gatewayBaseURLString, forHTTPHeaderField: "Origin") request.setValue(region.dashboardURL.absoluteString, forHTTPHeaderField: "Referer") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AlibabaCodingPlanUsageError.networkError("Invalid response") } @@ -158,7 +158,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { request.setValue(region.gatewayBaseURLString, forHTTPHeaderField: "Origin") request.setValue(region.consoleRefererURL.absoluteString, forHTTPHeaderField: "Referer") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AlibabaCodingPlanUsageError.networkError("Invalid response") } @@ -338,7 +338,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", forHTTPHeaderField: "Accept") - if let (data, response) = try? await URLSession.shared.data(for: request), + if let (data, response) = try? await URLSession.shared.codexbarData(for: request), let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let html = String(data: data, encoding: .utf8), @@ -404,7 +404,7 @@ public struct AlibabaCodingPlanUsageFetcher: Sendable { .absoluteString + "/" request.setValue(referer, forHTTPHeaderField: "Referer") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { return nil } diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift index bd6c62f6e..42ce8c75a 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift @@ -249,7 +249,7 @@ public struct AmpUsageFetcher: Sendable { request.setValue(Self.settingsURL.absoluteString, forHTTPHeaderField: "referer") let session = URLSession(configuration: .ephemeral, delegate: diagnostics, delegateQueue: nil) - let (data, response) = try await session.data(for: request) + let (data, response) = try await session.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AmpUsageError.networkError("Invalid response") } diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index cec06f366..8d70befdb 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -599,6 +599,20 @@ public struct AntigravityStatusProbe: Sendable { context: context) } catch { guard let httpPort = context.httpPort, httpPort != context.httpsPort else { throw error } + AuditLogger.record(AuditEvent( + category: .network, + action: "request.http_fallback", + target: "127.0.0.1:\(httpPort)", + risk: .elevatedRisk, + metadata: [ + "provider": "antigravity", + "https_port": "\(context.httpsPort)", + "http_port": "\(httpPort)", + "error": error.localizedDescription, + ], + context: GovernanceContext( + flow: "antigravity-localhost-trust", + detail: "https-failed-http-fallback"))) return try await Self.sendRequest( scheme: "http", port: httpPort, @@ -637,8 +651,20 @@ public struct AntigravityStatusProbe: Sendable { let delegate = LocalhostSessionDelegate() let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) defer { session.invalidateAndCancel() } - - let (data, response) = try await delegate.data(for: request, session: session) + let auditOptions = NetworkAuditOptions( + risk: .elevatedRisk, + metadata: [ + "provider": "antigravity", + "scheme": scheme, + "port": "\(port)", + "localhost": "1", + "tls_bypass": scheme == "https" ? "1" : "0", + "http_fallback": scheme == "http" ? "1" : "0", + ], + context: GovernanceContext( + flow: "antigravity-localhost-trust", + detail: scheme == "https" ? "self-signed-certificate-bypass" : "http-fallback")) + let (data, response) = try await session.codexbarData(for: request, delegate: delegate, audit: auditOptions) guard let http = response as? HTTPURLResponse else { throw AntigravityStatusProbeError.apiError("Invalid response") } @@ -729,6 +755,22 @@ extension LocalhostSessionDelegate: URLSessionTaskDelegate { else { return (.performDefaultHandling, nil) } + + AuditLogger.record(AuditEvent( + category: .network, + action: "trust_override.accepted", + target: protectionSpace.host, + risk: .elevatedRisk, + metadata: [ + "provider": "antigravity", + "authentication_method": protectionSpace.authenticationMethod, + "host": protectionSpace.host, + "port": "\(protectionSpace.port)", + ], + context: GovernanceContext( + flow: "antigravity-localhost-trust", + detail: "server-trust-override"))) + return (.useCredential, URLCredential(trust: trust)) #endif } diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift index 9485c8ef7..88147ae1f 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift @@ -371,7 +371,7 @@ public final class AugmentSessionKeepalive { request.setValue("https://app.augmentcode.com", forHTTPHeaderField: "Referer") do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { self.log(" ✗ Invalid response type") diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift index 60ceb0b15..9fe03fb55 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift @@ -492,7 +492,7 @@ public struct AugmentStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AugmentStatusProbeError.networkError("Invalid response") @@ -530,7 +530,7 @@ public struct AugmentStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw AugmentStatusProbeError.networkError("Invalid response") diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift index f6a7e48ad..478e0f352 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials+SecurityCLIReader.swift @@ -194,11 +194,36 @@ extension ClaudeOAuthCredentialsStore { process.standardOutput = stdoutPipe process.standardError = stderrPipe process.standardInput = nil + let auditMetadata = [ + "argument_count": "\(arguments.count)", + "timeout_ms": "\(Int(timeout * 1000))", + "service": self.claudeKeychainService, + "account_pinned": account == nil ? "0" : "1", + ] + let governanceContext = GovernanceContext(flow: "claude-security-cli-keychain-read") + AuditLogger.recordSecretAccess( + action: "keychain.read_via_security_cli", + target: self.claudeKeychainService, + risk: .elevatedRisk, + metadata: ["account_pinned": account == nil ? "0" : "1"], + context: governanceContext) + AuditLogger.recordCommand( + action: "process.started", + binary: self.securityBinaryPath, + risk: .elevatedRisk, + metadata: auditMetadata, + context: governanceContext) let startedAt = DispatchTime.now().uptimeNanoseconds do { try process.run() } catch { + AuditLogger.recordCommand( + action: "process.launch_failed", + binary: self.securityBinaryPath, + risk: .elevatedRisk, + metadata: auditMetadata.merging(["error": error.localizedDescription], uniquingKeysWith: { _, new in new }), + context: governanceContext) throw SecurityCLIReadError.launchFailed } @@ -215,6 +240,12 @@ extension ClaudeOAuthCredentialsStore { if process.isRunning { self.terminate(process: process, processGroup: processGroup) + AuditLogger.recordCommand( + action: "process.timed_out", + binary: self.securityBinaryPath, + risk: .elevatedRisk, + metadata: auditMetadata, + context: governanceContext) throw SecurityCLIReadError.timedOut } @@ -223,9 +254,32 @@ extension ClaudeOAuthCredentialsStore { let status = process.terminationStatus let durationMs = Double(DispatchTime.now().uptimeNanoseconds - startedAt) / 1_000_000.0 guard status == 0 else { + AuditLogger.recordCommand( + action: "process.failed", + binary: self.securityBinaryPath, + risk: .elevatedRisk, + metadata: auditMetadata.merging( + [ + "status": "\(status)", + "duration_ms": String(format: "%.2f", durationMs), + ], + uniquingKeysWith: { _, new in new }), + context: governanceContext) throw SecurityCLIReadError.nonZeroExit(status: status, stderrLength: stderr.count) } + AuditLogger.recordCommand( + action: "process.completed", + binary: self.securityBinaryPath, + risk: .elevatedRisk, + metadata: auditMetadata.merging( + [ + "status": "\(status)", + "duration_ms": String(format: "%.2f", durationMs), + ], + uniquingKeysWith: { _, new in new }), + context: governanceContext) + return SecurityCLIReadCommandResult( status: status, stdout: stdout, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 35dfdc274..9a0b0e842 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -982,7 +982,7 @@ public enum ClaudeOAuthCredentialsStore { ] request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let http = response as? HTTPURLResponse else { throw ClaudeOAuthCredentialsError.refreshFailed("Invalid response") diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 22003fb23..9c75f2931 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -52,7 +52,7 @@ enum ClaudeOAuthUsageFetcher { request.setValue(Self.claudeCodeUserAgent(), forHTTPHeaderField: "User-Agent") do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let http = response as? HTTPURLResponse else { throw ClaudeOAuthFetchError.invalidResponse } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index c350d30c4..865ec038b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -264,7 +264,7 @@ public enum ClaudeWebAPIFetcher { request.timeoutInterval = 20 do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) let http = response as? HTTPURLResponse let contentType = http?.allHeaderFields["Content-Type"] as? String let truncated = data.prefix(Self.maxProbeBytes) @@ -399,7 +399,7 @@ public enum ClaudeWebAPIFetcher { request.httpMethod = "GET" request.timeoutInterval = 15 - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FetchError.invalidResponse @@ -429,7 +429,7 @@ public enum ClaudeWebAPIFetcher { request.httpMethod = "GET" request.timeoutInterval = 15 - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FetchError.invalidResponse @@ -530,7 +530,7 @@ public enum ClaudeWebAPIFetcher { request.timeoutInterval = 15 do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { return nil } logger?("Overage API status: \(httpResponse.statusCode)") guard httpResponse.statusCode == 200 else { return nil } @@ -677,7 +677,7 @@ public enum ClaudeWebAPIFetcher { request.timeoutInterval = 15 do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { return nil } logger?("Account API status: \(httpResponse.statusCode)") guard httpResponse.statusCode == 200 else { return nil } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift index c6c5698b2..05b4e2e13 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift @@ -63,6 +63,13 @@ public enum CodexOAuthCredentialsStore { throw CodexOAuthCredentialsError.notFound } + AuditLogger.recordSecretAccess( + action: "file.auth_json.read", + target: url.lastPathComponent, + metadata: [ + "path": url.path, + "operation": "read", + ]) let data = try Data(contentsOf: url) return try self.parse(data: data) } @@ -113,6 +120,13 @@ public enum CodexOAuthCredentialsStore { env: [String: String] = ProcessInfo.processInfo.environment) throws { let url = self.authFilePath(env: env) + AuditLogger.recordSecretAccess( + action: "file.auth_json.write", + target: url.lastPathComponent, + metadata: [ + "path": url.path, + "operation": "write", + ]) var json: [String: Any] = [:] if let data = try? Data(contentsOf: url), diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 7bcfe079e..4cab70ce4 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -167,7 +167,7 @@ public enum CodexOAuthUsageFetcher { } do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let http = response as? HTTPURLResponse else { throw CodexOAuthFetchError.invalidResponse } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift index aa49311fa..52c25e65c 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexTokenRefresher.swift @@ -49,7 +49,7 @@ public enum CodexTokenRefresher { request.httpBody = try JSONSerialization.data(withJSONObject: body) do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let http = response as? HTTPURLResponse else { throw RefreshError.invalidResponse("No HTTP response") } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift index 6887b4ce9..ebdd13ab8 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOpenAIWorkspaceResolver.swift @@ -52,7 +52,7 @@ public enum CodexOpenAIWorkspaceResolver { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(workspaceAccountID, forHTTPHeaderField: "ChatGPT-Account-Id") - let (data, response) = try await session.data(for: request) + let (data, response) = try await session.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift index 094ad9f2e..ccfeffb81 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotDeviceFlow.swift @@ -52,7 +52,7 @@ public struct CopilotDeviceFlow: Sendable { ] postRequest.httpBody = Self.formURLEncodedBody(body) - let (data, response) = try await URLSession.shared.data(for: postRequest) + let (data, response) = try await URLSession.shared.codexbarData(for: postRequest) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw URLError(.badServerResponse) @@ -79,7 +79,7 @@ public struct CopilotDeviceFlow: Sendable { try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) try Task.checkCancellation() - let (data, _) = try await URLSession.shared.data(for: request) + let (data, _) = try await URLSession.shared.codexbarData(for: request) // Check for error in JSON if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index ddf41a10a..90a06350c 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -20,7 +20,7 @@ public struct CopilotUsageFetcher: Sendable { request.setValue("token \(self.token)", forHTTPHeaderField: "Authorization") self.addCommonHeaders(to: &request) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index b94a35115..616d906bb 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -883,7 +883,7 @@ public struct CursorStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await self.urlSession.data(for: request) + let (data, response) = try await self.urlSession.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw CursorStatusProbeError.networkError("Invalid response") @@ -916,7 +916,7 @@ public struct CursorStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await self.urlSession.data(for: request) + let (data, response) = try await self.urlSession.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw CursorStatusProbeError.networkError("Failed to fetch user info") @@ -937,7 +937,7 @@ public struct CursorStatusProbe: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue(cookieHeader, forHTTPHeaderField: "Cookie") - let (data, response) = try await self.urlSession.data(for: request) + let (data, response) = try await self.urlSession.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw CursorStatusProbeError.networkError("Failed to fetch request usage") diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index 2f45886a2..77ee5639e 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -1031,7 +1031,7 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1087,7 +1087,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try? JSONSerialization.data(withJSONObject: body) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1221,7 +1221,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1291,7 +1291,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } diff --git a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift index d978630aa..8dcd2a5c3 100644 --- a/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Gemini/GeminiStatusProbe.swift @@ -147,7 +147,7 @@ public struct GeminiStatusProbe: Sendable { timeout: TimeInterval = 10.0, homeDirectory: String = NSHomeDirectory(), dataLoader: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in - try await URLSession.shared.data(for: request) + try await URLSession.shared.codexbarData(for: request) }) { self.timeout = timeout diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift index 8e3d8fad3..e48d2453d 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloProviderDescriptor.swift @@ -118,6 +118,14 @@ struct KiloCLIFetchStrategy: ProviderFetchStrategy { let data: Data do { + AuditLogger.recordSecretAccess( + action: "file.auth_json.read", + target: authFileURL.lastPathComponent, + metadata: [ + "path": authFileURL.path, + "operation": "read", + "provider": "kilo", + ]) data = try Data(contentsOf: authFileURL) } catch { throw KiloUsageError.cliSessionUnreadable(authFileURL.path) diff --git a/Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift index a274b113a..bc7b9d506 100644 --- a/Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kilo/KiloUsageFetcher.swift @@ -275,7 +275,7 @@ public struct KiloUsageFetcher: Sendable { let data: Data let response: URLResponse do { - (data, response) = try await URLSession.shared.data(for: request) + (data, response) = try await URLSession.shared.codexbarData(for: request) } catch { throw KiloUsageError.networkError(error.localizedDescription) } diff --git a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift index c6d6c2a2e..12313bbda 100644 --- a/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Kimi/KimiUsageFetcher.swift @@ -46,7 +46,7 @@ public struct KimiUsageFetcher: Sendable { let requestBody = ["scope": ["FEATURE_CODING"]] request.httpBody = try? JSONSerialization.data(withJSONObject: requestBody) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw KimiAPIError.networkError("Invalid response") } diff --git a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift index 775e109bf..b8993f8fe 100644 --- a/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/KimiK2/KimiK2UsageFetcher.swift @@ -136,7 +136,7 @@ public struct KimiK2UsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw KimiK2UsageError.networkError("Invalid response") diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift index 1b78131d5..062c56cba 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift @@ -98,7 +98,7 @@ public struct MiniMaxUsageFetcher: Sendable { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("CodexBar", forHTTPHeaderField: "MM-API-Source") - let (data, response) = try await session.data(for: request) + let (data, response) = try await session.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MiniMaxUsageError.networkError("Invalid response") } @@ -142,7 +142,7 @@ public struct MiniMaxUsageFetcher: Sendable { self.resolveCodingPlanRefererURL(region: region, environment: environment).absoluteString, forHTTPHeaderField: "referer") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MiniMaxUsageError.networkError("Invalid response") } @@ -201,7 +201,7 @@ public struct MiniMaxUsageFetcher: Sendable { self.resolveCodingPlanRefererURL(region: region, environment: environment).absoluteString, forHTTPHeaderField: "referer") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw MiniMaxUsageError.networkError("Invalid response") } diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index d42077fc1..95487ecd9 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -509,7 +509,7 @@ public struct OllamaUsageFetcher: Sendable { request.setValue(Self.settingsURL.absoluteString, forHTTPHeaderField: "referer") let session = self.makeURLSession(diagnostics) - let (data, response) = try await session.data(for: request) + let (data, response) = try await session.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw OllamaUsageError.networkError("Invalid response") } diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift index 0946a4c4f..02f93cb06 100644 --- a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeUsageFetcher.swift @@ -273,7 +273,7 @@ public struct OpenCodeUsageFetcher: Sendable { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") } - let (data, response) = try await session.data(for: urlRequest) + let (data, response) = try await session.codexbarData(for: urlRequest) guard let httpResponse = response as? HTTPURLResponse else { throw OpenCodeUsageError.networkError("Invalid response") } diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index fbee8eec8..70c7d65bc 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -595,7 +595,7 @@ public struct OpenCodeGoUsageFetcher: Sendable { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") } - let (data, response) = try await session.data(for: urlRequest) + let (data, response) = try await session.codexbarData(for: urlRequest) guard let httpResponse = response as? HTTPURLResponse else { throw OpenCodeGoUsageError.networkError("Invalid response") } @@ -637,7 +637,7 @@ public struct OpenCodeGoUsageFetcher: Sendable { "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", forHTTPHeaderField: "Accept") - let (data, response) = try await session.data(for: request) + let (data, response) = try await session.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw OpenCodeGoUsageError.networkError("Invalid response") } diff --git a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift index 251e2c47d..745409fed 100644 --- a/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift +++ b/Sources/CodexBarCore/Providers/OpenRouter/OpenRouterUsageStats.swift @@ -216,7 +216,7 @@ public struct OpenRouterUsageFetcher: Sendable { let title = Self.sanitizedHeaderValue(environment[self.clientTitleEnvKey]) ?? Self.defaultClientTitle request.setValue(title, forHTTPHeaderField: "X-Title") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw OpenRouterUsageError.networkError("Invalid response") @@ -323,7 +323,7 @@ public struct OpenRouterUsageFetcher: Sendable { request.timeoutInterval = timeoutSeconds do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 diff --git a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift index 2a9fe5211..34787bae7 100644 --- a/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Perplexity/PerplexityUsageFetcher.swift @@ -43,7 +43,7 @@ public struct PerplexityUsageFetcher: Sendable { "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" request.setValue(userAgent, forHTTPHeaderField: "User-Agent") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw PerplexityAPIError.networkError("Invalid response") } diff --git a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift index 0c9197462..936d771db 100644 --- a/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift +++ b/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift @@ -64,14 +64,36 @@ public enum ProviderVersionDetector { exitSemaphore.signal() } + let auditMetadata = [ + "argument_count": "\(args.count)", + "timeout_ms": "\(Int(timeout * 1000))", + "mode": "version-detect", + ] + AuditLogger.recordCommand( + action: "process.started", + binary: path, + risk: AuditLogger.inferredCommandRisk(binary: path), + metadata: auditMetadata) + do { try proc.run() } catch { + AuditLogger.recordCommand( + action: "process.launch_failed", + binary: path, + risk: AuditLogger.inferredCommandRisk(binary: path), + metadata: auditMetadata.merging(["error": error.localizedDescription], uniquingKeysWith: { _, new in new })) return nil } let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success - if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) { + if !didExit { + _ = Self.forceExit(proc, exitSemaphore: exitSemaphore) + AuditLogger.recordCommand( + action: "process.timed_out", + binary: path, + risk: AuditLogger.inferredCommandRisk(binary: path), + metadata: auditMetadata) return nil } @@ -79,8 +101,20 @@ public enum ProviderVersionDetector { guard proc.terminationStatus == 0, let text = String(data: data, encoding: .utf8)? .split(whereSeparator: \.isNewline).first - else { return nil } + else { + AuditLogger.recordCommand( + action: "process.failed", + binary: path, + risk: AuditLogger.inferredCommandRisk(binary: path), + metadata: auditMetadata.merging(["status": "\(proc.terminationStatus)"], uniquingKeysWith: { _, new in new })) + return nil + } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + AuditLogger.recordCommand( + action: "process.completed", + binary: path, + risk: AuditLogger.inferredCommandRisk(binary: path), + metadata: auditMetadata.merging(["status": "\(proc.terminationStatus)"], uniquingKeysWith: { _, new in new })) return trimmed.isEmpty ? nil : trimmed } diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index 198c42c25..d2b4c1b83 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -85,7 +85,7 @@ public struct SyntheticUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw SyntheticUsageError.networkError("Invalid response") diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift index b5e8e3f68..cdf799429 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAITokenRefresher.swift @@ -49,7 +49,7 @@ public enum VertexAITokenRefresher { request.httpBody = bodyString.data(using: .utf8) do { - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let http = response as? HTTPURLResponse else { throw RefreshError.invalidResponse("No HTTP response") } diff --git a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift index 2c9da2033..93cf0ee7a 100644 --- a/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/VertexAI/VertexAIOAuth/VertexAIUsageFetcher.swift @@ -219,7 +219,7 @@ public enum VertexAIUsageFetcher { let response: URLResponse do { - (data, response) = try await URLSession.shared.data(for: request) + (data, response) = try await URLSession.shared.codexbarData(for: request) } catch { throw VertexAIFetchError.networkError(error) } diff --git a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift index c5e2bf8a4..abf314652 100644 --- a/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Warp/WarpUsageFetcher.swift @@ -206,7 +206,7 @@ public struct WarpUsageFetcher: Sendable { request.httpBody = try JSONSerialization.data(withJSONObject: body) - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw WarpUsageError.networkError("Invalid response") diff --git a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift index 1936c9f7e..245a7de43 100644 --- a/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Zai/ZaiUsageStats.swift @@ -316,7 +316,7 @@ public struct ZaiUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") request.setValue("application/json", forHTTPHeaderField: "accept") - let (data, response) = try await URLSession.shared.data(for: request) + let (data, response) = try await URLSession.shared.codexbarData(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw ZaiUsageError.networkError("Invalid response") diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index b560f005e..343a74296 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -410,11 +410,34 @@ private final class CodexRPCClient: @unchecked Sendable { self.process.standardOutput = self.stdoutPipe self.process.standardError = self.stderrPipe + let auditMetadata = [ + "argument_count": "\(arguments.count)", + "mode": "rpc", + ] + AuditLogger.recordCommand( + action: "process.started", + binary: resolvedExec, + risk: .sensitive, + metadata: auditMetadata, + context: GovernanceContext(flow: "codex-rpc")) + do { try self.process.run() Self.log.debug("Codex RPC started", metadata: ["binary": resolvedExec]) + AuditLogger.recordCommand( + action: "process.launched", + binary: resolvedExec, + risk: .sensitive, + metadata: auditMetadata.merging(["pid": "\(self.process.processIdentifier)"], uniquingKeysWith: { _, new in new }), + context: GovernanceContext(flow: "codex-rpc")) } catch { Self.log.warning("Codex RPC failed to start", metadata: ["error": error.localizedDescription]) + AuditLogger.recordCommand( + action: "process.launch_failed", + binary: resolvedExec, + risk: .sensitive, + metadata: auditMetadata.merging(["error": error.localizedDescription], uniquingKeysWith: { _, new in new }), + context: GovernanceContext(flow: "codex-rpc")) throw RPCWireError.startFailed(error.localizedDescription) } diff --git a/Tests/CodexBarTests/AuditLoggerTests.swift b/Tests/CodexBarTests/AuditLoggerTests.swift new file mode 100644 index 000000000..6bd789bb4 --- /dev/null +++ b/Tests/CodexBarTests/AuditLoggerTests.swift @@ -0,0 +1,208 @@ +import Foundation +import XCTest +@testable import CodexBarCore + +final class AuditLoggerTests: XCTestCase { + func test_sanitizeForSummary_normalizesHomePathsAndRedactsURLIdentifiers() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let event = AuditEvent( + timestamp: Date(timeIntervalSince1970: 1_744_000_000), + category: .secret, + action: "file.auth_json.read", + target: "https://claude.ai/api/organizations/fdcaffd7-1712-4028-b00f-0acdb6ed15fd/usage", + risk: .sensitive, + metadata: [ + "path": "\(home)/.codex/auth.json", + "cache_path": "\(home)/Library/Application Support/CodexBar/managed-codex-homes/E9DAE246-F251-4B46-A36A-E652B135D4FB/log", + ], + context: GovernanceContext(flow: "governance", detail: "\(home)/tmp/check")) + + let sanitized = AuditLogger.sanitizeForSummary(event) + + XCTAssertEqual(sanitized.metadata["path"], "~/.codex/auth.json") + XCTAssertEqual( + sanitized.metadata["cache_path"], + "~/Library/Application Support/CodexBar/managed-codex-homes//log") + XCTAssertEqual( + sanitized.target, + "https://claude.ai/api/organizations//usage") + XCTAssertEqual(sanitized.context?.detail, "~/tmp/check") + } + + func test_sanitizeForSummary_keepsSecretPresenceFlagsButNotSecretValues() { + let event = AuditEvent( + category: .network, + action: "request.completed", + target: "https://chatgpt.com/backend-api/wham/usage", + risk: .sensitive, + metadata: [ + "header_authorization": "1", + "header_cookie": "0", + "status_code": "200", + ]) + + let sanitized = AuditLogger.sanitizeForSummary(event) + + XCTAssertEqual(sanitized.metadata["header_authorization"], "1") + XCTAssertEqual(sanitized.metadata["header_cookie"], "0") + XCTAssertEqual(sanitized.metadata["status_code"], "200") + } + + func test_sanitizeText_redactsIdentifiersInsideEmbeddedURLAndHomePathFragments() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let text = """ + request failed for https://claude.ai/api/organizations/fdcaffd7-1712-4028-b00f-0acdb6ed15fd/usage \ + while reading \(home)/Library/Application Support/CodexBar/managed-codex-homes/E9DAE246-F251-4B46-A36A-E652B135D4FB/log (permission denied) + """ + + let sanitized = AuditPrivacySanitizer.sanitizeText(text) + + XCTAssertTrue(sanitized.contains("https://claude.ai/api/organizations//usage")) + XCTAssertTrue( + sanitized.contains( + "~/Library/Application Support/CodexBar/managed-codex-homes//log (permission denied)")) + XCTAssertFalse(sanitized.contains(home)) + XCTAssertFalse(sanitized.contains("fdcaffd7-1712-4028-b00f-0acdb6ed15fd")) + XCTAssertFalse(sanitized.contains("E9DAE246-F251-4B46-A36A-E652B135D4FB")) + } + + func test_summaryState_groupsIdenticalEventsAndTracksTimes() { + let baseline = Date(timeIntervalSince1970: 1_744_000_000) + let event = AuditEvent( + timestamp: baseline, + category: .secret, + action: "file.auth_json.read", + target: "auth.json", + risk: .sensitive, + metadata: ["path": "~/.codex/auth.json"]) + + var state = GovernanceSummaryState() + state.record(event) + + let repeated = AuditEvent( + timestamp: baseline.addingTimeInterval(0.2), + category: .secret, + action: "file.auth_json.read", + target: "auth.json", + risk: .sensitive, + metadata: ["path": "~/.codex/auth.json"]) + state.record(repeated) + + let later = AuditEvent( + timestamp: baseline.addingTimeInterval(2.0), + category: .secret, + action: "file.auth_json.read", + target: "auth.json", + risk: .sensitive, + metadata: ["path": "~/.codex/auth.json"]) + state.record(later) + + let different = AuditEvent( + timestamp: baseline.addingTimeInterval(2.2), + category: .secret, + action: "keychain.cache.read", + target: "cookie.codex", + risk: .sensitive, + metadata: ["account": "cookie.codex"]) + state.record(different) + + XCTAssertEqual(state.entries.count, 2) + XCTAssertEqual(state.entries[0].count, 3) + XCTAssertEqual(state.entries[0].resource, "~/.codex/auth.json") + XCTAssertEqual(state.entries[0].firstSeen, baseline) + XCTAssertEqual(state.entries[0].lastSeen, baseline.addingTimeInterval(2.0)) + } + + func test_summaryRenderer_rendersMarkdownByDayAndRisk() { + let baseline = Date(timeIntervalSince1970: 1_744_000_000) + var state = GovernanceSummaryState() + state.record(AuditEvent( + timestamp: baseline, + category: .secret, + action: "file.auth_json.read", + target: "auth.json", + risk: .sensitive, + metadata: ["path": "~/.codex/auth.json"])) + state.record(AuditEvent( + timestamp: baseline.addingTimeInterval(60), + category: .command, + action: "subprocess.start", + target: "security", + risk: .elevatedRisk, + metadata: ["binary": "security"])) + + let markdown = GovernanceSummaryRenderer.render(state) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + let expectedDay = formatter.string(from: baseline) + + XCTAssertTrue(markdown.contains("# Governance Audit Summary")) + XCTAssertTrue(markdown.contains("## \(expectedDay)")) + XCTAssertTrue(markdown.contains("### Elevated-risk events")) + XCTAssertTrue(markdown.contains("### Sensitive events")) + XCTAssertTrue(markdown.contains("`~/.codex/auth.json`")) + XCTAssertTrue(markdown.contains("`security`")) + XCTAssertTrue(markdown.contains("Status: Expected")) + XCTAssertTrue(markdown.contains("Needed for normal Codex authentication and usage probing.")) + } + + func test_summaryRenderer_marksUnknownNetworkHostsAsUnexpected() { + let baseline = Date(timeIntervalSince1970: 1_744_000_000) + var state = GovernanceSummaryState() + state.record(AuditEvent( + timestamp: baseline, + category: .network, + action: "request.completed", + target: "https://unknown-host.example/api/usage", + risk: .sensitive, + metadata: ["host": "unknown-host.example"])) + + let markdown = GovernanceSummaryRenderer.render(state) + + XCTAssertTrue(markdown.contains("Status: Unexpected")) + XCTAssertTrue(markdown.contains("Outside the known set of CodexBar provider and authentication hosts.")) + XCTAssertTrue(markdown.contains("`unknown-host.example`")) + } + + func test_summaryRenderer_marksSupportedProviderHostsAsExpected() { + let baseline = Date(timeIntervalSince1970: 1_744_000_000) + var state = GovernanceSummaryState() + state.record(AuditEvent( + timestamp: baseline, + category: .network, + action: "request.completed", + target: "https://app.augmentcode.com/api/session", + risk: .sensitive, + metadata: ["host": "app.augmentcode.com"])) + + let markdown = GovernanceSummaryRenderer.render(state) + + XCTAssertTrue(markdown.contains("Status: Expected")) + XCTAssertTrue( + markdown.contains( + "Expected network request to a known CodexBar provider, dashboard, or authentication endpoint.")) + XCTAssertTrue(markdown.contains("`app.augmentcode.com`")) + } + + func test_summaryRenderer_marksKnownCommandLifecycleAsExpected() { + let baseline = Date(timeIntervalSince1970: 1_744_000_000) + var state = GovernanceSummaryState() + state.record(AuditEvent( + timestamp: baseline, + category: .command, + action: "process.failed", + target: "unknown-helper", + risk: .normal, + metadata: ["binary": "unknown-helper"])) + + let markdown = GovernanceSummaryRenderer.render(state) + + XCTAssertTrue(markdown.contains("Status: Expected")) + XCTAssertTrue( + markdown.contains( + "Expected reporting for helper-process failures encountered during normal app flows.")) + XCTAssertTrue(markdown.contains("`unknown-helper`")) + } +} diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 0b18ad89a..fa2b5c105 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -224,6 +224,113 @@ struct SettingsStoreCoverageTests { #expect(settings.kimiCookieSource == .off) } + @Test + func `governance audit toggles persist across store reload`() throws { + let suite = "SettingsStoreCoverageTests-governance-audit" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let first = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(first.governanceAuditModeEnabled == false) + #expect(first.governanceAuditNetworkRequestsEnabled == false) + #expect(first.governanceAuditCommandExecutionEnabled == false) + #expect(first.governanceAuditSecretAccessEnabled == false) + #expect(first.debugPrivacyLoggingEnabled == false) + + first.governanceAuditModeEnabled = true + first.governanceAuditNetworkRequestsEnabled = true + first.governanceAuditCommandExecutionEnabled = true + first.governanceAuditSecretAccessEnabled = true + first.debugPrivacyLoggingEnabled = true + + #expect(defaults.bool(forKey: AuditSettings.modeEnabledKey) == true) + #expect(defaults.bool(forKey: AuditSettings.networkEnabledKey) == true) + #expect(defaults.bool(forKey: AuditSettings.commandEnabledKey) == true) + #expect(defaults.bool(forKey: AuditSettings.secretEnabledKey) == true) + #expect(defaults.bool(forKey: "debugPrivacyLoggingEnabled") == true) + + let second = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(second.governanceAuditModeEnabled == true) + #expect(second.governanceAuditNetworkRequestsEnabled == true) + #expect(second.governanceAuditCommandExecutionEnabled == true) + #expect(second.governanceAuditSecretAccessEnabled == true) + #expect(second.debugPrivacyLoggingEnabled == true) + } + + @Test + func `enabling governance audit mode materializes default category coverage`() throws { + let suite = "SettingsStoreCoverageTests-governance-audit-materialize-defaults" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let settings = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(settings.governanceAuditModeEnabled == false) + #expect(settings.governanceAuditNetworkRequestsEnabled == false) + #expect(settings.governanceAuditCommandExecutionEnabled == false) + #expect(settings.governanceAuditSecretAccessEnabled == false) + + settings.governanceAuditModeEnabled = true + + #expect(settings.governanceAuditModeEnabled == true) + #expect(settings.governanceAuditNetworkRequestsEnabled == true) + #expect(settings.governanceAuditCommandExecutionEnabled == true) + #expect(settings.governanceAuditSecretAccessEnabled == true) + #expect(defaults.bool(forKey: AuditSettings.modeEnabledKey) == true) + #expect(defaults.bool(forKey: AuditSettings.networkEnabledKey) == true) + #expect(defaults.bool(forKey: AuditSettings.commandEnabledKey) == true) + #expect(defaults.bool(forKey: AuditSettings.secretEnabledKey) == true) + } + + @Test + func `audit settings fall back to shared defaults and local overrides win`() throws { + let localSuite = "SettingsStoreCoverageTests-audit-local-\(UUID().uuidString)" + let sharedSuite = "SettingsStoreCoverageTests-audit-shared-\(UUID().uuidString)" + let localDefaults = try #require(UserDefaults(suiteName: localSuite)) + let sharedDefaults = try #require(UserDefaults(suiteName: sharedSuite)) + localDefaults.removePersistentDomain(forName: localSuite) + sharedDefaults.removePersistentDomain(forName: sharedSuite) + + sharedDefaults.set(true, forKey: AuditSettings.modeEnabledKey) + sharedDefaults.set(true, forKey: AuditSettings.networkEnabledKey) + sharedDefaults.set(true, forKey: AuditSettings.commandEnabledKey) + sharedDefaults.set(false, forKey: AuditSettings.secretEnabledKey) + + let inherited = AuditSettings.current(userDefaults: localDefaults, sharedDefaults: sharedDefaults) + #expect(inherited.modeEnabled == true) + #expect(inherited.networkEnabled == true) + #expect(inherited.commandEnabled == true) + #expect(inherited.secretEnabled == false) + + localDefaults.set(false, forKey: AuditSettings.modeEnabledKey) + localDefaults.set(false, forKey: AuditSettings.networkEnabledKey) + let overridden = AuditSettings.current(userDefaults: localDefaults, sharedDefaults: sharedDefaults) + #expect(overridden.modeEnabled == false) + #expect(overridden.networkEnabled == false) + #expect(overridden.commandEnabled == true) + #expect(overridden.secretEnabled == false) + } + + @Test + func `audit settings treat enabled mode with no explicit categories as all categories`() throws { + let suite = "SettingsStoreCoverageTests-audit-implicit-all-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + defaults.set(true, forKey: AuditSettings.modeEnabledKey) + + let snapshot = AuditSettings.current(userDefaults: defaults, sharedDefaults: nil) + + #expect(snapshot.modeEnabled == true) + #expect(snapshot.networkEnabled == true) + #expect(snapshot.commandEnabled == true) + #expect(snapshot.secretEnabled == true) + #expect(snapshot.isEnabled(for: .network) == true) + #expect(snapshot.isEnabled(for: .command) == true) + #expect(snapshot.isEnabled(for: .secret) == true) + } + @Test func `claude keychain prompt mode defaults to only on user action`() { let settings = Self.makeSettingsStore()