Stop CodexBar Cache keychain prompts from dev and test tooling

Audit findings behind the recurring 'CodexBar wants to use your
confidential information stored in CodexBar Cache' dialogs:

- The cache item is a legacy file-keychain item whose trusted-application
  ACL freezes at creation. A 'swift build' binary with no .app ancestor
  could create it trusting only its own ephemeral unsigned path, after
  which the packaged app prompts on every background cookie refresh.
  Unbundled processes now use a process-local in-memory cache and the ACL
  builder refuses bare dev binaries outright.
- Test-process self-detection is name/env based and cannot cover spawned
  CLI child binaries. When a process decides keychain access is blocked it
  now exports CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS for children and
  disables legacy keychain interaction process-wide
  (SecKeychainSetUserInteractionAllowed(false)), the documented switch for
  the ACL dialog the per-query no-UI attributes cannot always prevent.
- make test-tty ran swift test without the suppression flag; make
  test-live now opts into keychain access explicitly.
- Test spawn sites for CLI binaries pass the suppression flag explicitly,
  and the prompt-safety audit test now also flags dlsym-resolved Security
  symbols so runtime-resolved calls cannot bypass the grep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Peter Steinberger
2026-08-01 15:51:02 -07:00
parent 78523f4ad8
commit c881bbefa0
8 changed files with 146 additions and 9 deletions
+1
View File
@@ -7,6 +7,7 @@
- Menu: the compact multi-account layout now covers every stacked multi-account list — token accounts on any provider and Codex accounts (flat lists; workspace-grouped Codex lists keep their sections).
### Fixed
- Keychain: stop "CodexBar Cache" login-keychain password prompts from dev and test tooling. Unbundled processes (`swift build` binaries, dev CLI runs) now use a process-local cache instead of the shared keychain item, never freeze a broken trusted-app ACL onto it, and test-blocked processes disable legacy keychain interaction process-wide and export the suppression flag to spawned child binaries.
- Menu: switching provider tabs no longer flashes. The sibling-tab warmup now runs off a tracking-safe timer (the previous Task-based warmup never fired while the menu was open, which is the only time it matters), and provider tabs share one stable menu height via an invisible spacer, so a switch is a single-frame content swap with no window resize. Verified frame-by-frame with a new env-gated self-probe (`CODEXBAR_FLICKER_PROBE_DIR`).
### Changed
+2 -2
View File
@@ -34,10 +34,10 @@ test:
./Scripts/test.sh
test-tty:
swift test --filter TTYIntegrationTests
CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS=1 swift test --filter TTYIntegrationTests
test-live:
LIVE_TEST=1 swift test --filter LiveAccountTests
LIVE_TEST=1 CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=1 swift test --filter LiveAccountTests
release:
./Scripts/package_app.sh release
+33 -7
View File
@@ -436,10 +436,9 @@ public enum KeychainCacheStore {
/// reconciliation can still succeed without treating every refresh as a session change.
/// Unit tests keep using the isolated test stores instead, unless a test explicitly opts in.
private static func shouldUseDisabledAccessMemoryStore(for category: String) -> Bool {
guard category == "cookie" else { return false }
#if DEBUG
if self.disabledAccessMemoryStoreEnabledForTesting == true {
return true
return category == "cookie"
}
if KeychainTestSafety.isRunningUnderTests(
processName: ProcessInfo.processInfo.processName,
@@ -448,9 +447,32 @@ public enum KeychainCacheStore {
return false
}
#endif
// Unbundled processes (no .app ancestor: `swift build` binaries, dev CLI
// runs) must never touch the shared cache item. Creating it would freeze
// a trusted-application ACL onto an ephemeral unsigned binary after
// which the real app prompts forever and reading someone else's item
// raises the login-keychain password dialog. They get a process-local
// in-memory cache instead.
if self.isUnbundledProcess {
return true
}
guard category == "cookie" else { return false }
return KeychainAccessGate.isExplicitlyDisabled
}
/// True when the running executable has no `.app` bundle ancestor.
static let isUnbundledProcess: Bool = {
if Self.appBundleURL(containing: Bundle.main.bundleURL) != nil {
return false
}
if let executableURL = Bundle.main.executableURL,
Self.appBundleURL(containing: executableURL) != nil
{
return false
}
return true
}()
#if DEBUG
@TaskLocal private static var disabledAccessMemoryStoreEnabledForTesting: Bool?
@@ -588,12 +610,16 @@ public enum KeychainCacheStore {
paths.append(path)
}
let appBundle = self.appBundleURL(containing: bundleURL)
// No .app ancestor means an ephemeral dev binary; trusting its bare path
// would freeze a broken ACL onto the shared item (the packaged app would
// then prompt on every read). Refuse the ACL entirely in that case
// unbundled processes use the in-memory store and never reach this path
// in practice.
guard let appBundle = self.appBundleURL(containing: bundleURL)
?? executableURL.flatMap(self.appBundleURL(containing:))
if let appBundle {
append(appBundle.path)
append(appBundle.appendingPathComponent("Contents/Helpers/CodexBarCLI").path)
}
else { return [] }
append(appBundle.path)
append(appBundle.appendingPathComponent("Contents/Helpers/CodexBarCLI").path)
if let executableURL {
append(executableURL.path)
}
@@ -4,9 +4,42 @@ enum KeychainTestSafety {
static let suppressAccessEnvironmentKey = "CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS"
static let allowAccessEnvironmentKey = "CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"
/// One-shot side effects when this process decides keychain access is blocked:
/// export the suppression variable so spawned children (debug CLI binaries,
/// PTY helpers) inherit the decision name-based self-detection cannot cover
/// arbitrary child process names and turn off legacy-keychain interaction
/// process-wide so no code path can raise the login-keychain ACL dialog.
private nonisolated(unsafe) static var didApplyBlockedProcessSideEffects = false
private static let blockedProcessSideEffectsLock = NSLock()
private static func applyBlockedProcessSideEffectsOnce() {
self.blockedProcessSideEffectsLock.lock()
defer { self.blockedProcessSideEffectsLock.unlock() }
guard !self.didApplyBlockedProcessSideEffects else { return }
self.didApplyBlockedProcessSideEffects = true
setenv(self.suppressAccessEnvironmentKey, "1", 1)
#if os(macOS)
KeychainLegacyInteraction.disableProcessWideInteraction()
#endif
}
static func shouldBlockRealKeychainAccess(
processName: String = ProcessInfo.processInfo.processName,
environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool
{
let blocked = self.resolveShouldBlockRealKeychainAccess(
processName: processName,
environment: environment)
if blocked {
self.applyBlockedProcessSideEffectsOnce()
}
return blocked
}
/// Pure decision, side-effect free; kept separate for tests.
static func resolveShouldBlockRealKeychainAccess(
processName: String,
environment: [String: String]) -> Bool
{
if environment[self.allowAccessEnvironmentKey] == "1" { return false }
if environment[self.suppressAccessEnvironmentKey] == "1" { return true }
@@ -41,6 +74,27 @@ enum KeychainTestSafety {
#if os(macOS)
import Security
/// Process-wide legacy-keychain interaction switch. `KeychainNoUIQuery` marks
/// individual queries non-interactive, but the repo has observed legacy ACL
/// dialogs surfacing regardless (see KeychainAccessPreflight); for processes
/// that must never prompt test runners and suppressed children flipping the
/// documented process-wide switch is the airtight variant. Resolved via dlsym
/// to avoid deprecation warnings, matching the existing pattern in
/// KeychainNoUIQuery/KeychainCacheStore.
enum KeychainLegacyInteraction {
private typealias SetUserInteractionAllowedFn = @convention(c) (DarwinBoolean) -> OSStatus
static func disableProcessWideInteraction() {
guard let handle = dlopen(
"/System/Library/Frameworks/Security.framework/Security",
RTLD_LAZY | RTLD_NOLOAD) else { return }
defer { dlclose(handle) }
guard let symbol = dlsym(handle, "SecKeychainSetUserInteractionAllowed") else { return }
let setUserInteractionAllowed = unsafeBitCast(symbol, to: SetUserInteractionAllowedFn.self)
_ = setUserInteractionAllowed(false)
}
}
/// The only first-party entry point for Security.framework item operations.
/// Test processes fail closed before touching the user's Keychain, even when a test enables
/// higher-level Keychain logic with `KeychainAccessGate.withTaskOverrideForTesting(false)`.
@@ -305,6 +305,9 @@ struct CLIConfigCommandTests {
process.arguments = ["config", "dump"] + (showSecrets ? ["--show-secrets"] : [])
process.environment = ProcessInfo.processInfo.environment.merging([
CodexBarConfigStore.pathEnvironmentKey: configURL.path,
// Spawned CLI binaries match no test-process name pattern; make the
// keychain suppression explicit instead of relying on env inheritance.
"CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS": "1",
]) { _, fixturePath in fixturePath }
let stdout = Pipe()
+4
View File
@@ -184,6 +184,10 @@ final class CLIEntryTests: XCTestCase {
executableURL.path,
]
process.currentDirectoryURL = currentDirectoryURL
// Spawned CLI binaries match no test-process name pattern; make the
// keychain suppression explicit instead of relying on env inheritance.
process.environment = ProcessInfo.processInfo.environment.merging(
["CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS": "1"]) { _, new in new }
let stdout = Pipe()
let stderr = Pipe()
@@ -326,5 +326,32 @@ struct KeychainCacheStoreTests {
executable.path,
])
}
@Test
func `cache ACL refuses bare dev binaries without an app bundle`() {
// Trusting an ephemeral `swift build` binary would freeze a broken ACL
// onto the shared item; the packaged app would then prompt on every read.
let bundleURL = URL(fileURLWithPath: "/Users/dev/project/.build/debug")
let executable = URL(fileURLWithPath: "/Users/dev/project/.build/debug/CodexBarCLI")
let paths = KeychainCacheStore.trustedApplicationPathsForCacheAccess(
bundleURL: bundleURL,
executableURL: executable,
fileExists: { _ in true })
#expect(paths.isEmpty)
}
@Test
func `blocked keychain access exports suppression for child processes`() {
// Under tests access is always blocked; the decision must export the
// suppression variable so spawned CLI children inherit it (their process
// names match no test pattern).
#expect(KeychainTestSafety.shouldBlockRealKeychainAccess())
let exported = getenv(KeychainTestSafety.suppressAccessEnvironmentKey)
#expect(exported != nil)
#expect(exported.map { String(cString: $0) } == "1")
}
#endif
}
@@ -142,6 +142,28 @@ struct KeychainPromptSafetyAuditTests {
#expect(offenders.isEmpty, "Security item access bypasses KeychainSecurity: \(offenders.map(\.path))")
}
@Test
func `production source resolves Security symbols via dlsym only in audited files`() throws {
// dlsym-resolved Security APIs (deprecated ACL/interaction functions) bypass
// a plain "SecItem*" grep; keep them enumerable so new runtime-resolved
// Security calls cannot slip past this audit unseen.
let allowedFiles = [
"Sources/CodexBarCore/KeychainCacheStore.swift",
"Sources/CodexBarCore/KeychainNoUIQuery.swift",
"Sources/CodexBarCore/KeychainSecurity.swift",
]
let offenders = try Self.swiftFiles(
under: Self.repoRoot().appendingPathComponent("Sources", isDirectory: true))
.filter { file in
guard !allowedFiles.contains(where: file.path.hasSuffix) else { return false }
let text = try Self.readFile(file)
guard text.contains("dlsym") else { return false }
return text.contains("\"Sec") || text.contains("Security.framework")
}
#expect(offenders.isEmpty, "Unaudited dlsym-resolved Security access: \(offenders.map(\.path))")
}
private static func repoRoot() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()