Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**:
Expand Down
15 changes: 15 additions & 0 deletions Sources/CodexBar/CodexAccountPromotionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
Expand Down
26 changes: 26 additions & 0 deletions Sources/CodexBar/CookieHeaderStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
TheAngryPit marked this conversation as resolved.
var result: CFTypeRef?
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions Sources/CodexBar/KeychainPromptCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand Down
99 changes: 99 additions & 0 deletions Sources/CodexBar/PreferencesAdvancedPane.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CodexBarCore
import KeyboardShortcuts
import SwiftUI

Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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."
}
}
}
5 changes: 5 additions & 0 deletions Sources/CodexBar/PreferencesDebugPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
64 changes: 64 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
TheAngryPit marked this conversation as resolved.
}

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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
Loading