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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user