DEV Community

Euda1mon1a
Euda1mon1a

Posted on • Edited on

macOS Tahoe Broke Keychain CLI Reads: Novel Findings from an AI Agent Deployment

If you're running automated scripts on macOS Tahoe that read from the Keychain, you've probably noticed something broke.

I run a 24/7 Mac Mini M4 Pro as a local AI agent deployment — 12+ API keys, 25 scripts, 15 cron jobs, all orchestrated through OpenClaw. When I upgraded to Tahoe 26.x, my entire secrets pipeline died.

This post documents six Tahoe keychain regressions I found, including one that appears to be genuinely novel (I couldn't find it documented anywhere). If you're a macOS sysadmin or developer hitting keychain issues on Tahoe, this should save you a week of debugging.

The Problem

# This worked on Sequoia. On Tahoe, it hangs forever:
security find-generic-password -s "my-api-key" -a "myaccount" -w
Enter fullscreen mode Exit fullscreen mode

Not "fails with an error." Hangs. Indefinitely. Even after security unlock-keychain with the correct password. Even from a LaunchAgent with GUI session context. Exit code 36 if you're lucky, infinite hang if you're not.

This is a Tahoe 26.x regression. It works fine on Sequoia 15.x and earlier.

Finding 1: The security CLI is broken on Tahoe

The security command-line tool's -w flag (which outputs just the password value) appears to have a regression in Tahoe's SecurityAgent integration. It either hangs waiting for a dialog that never appears, or returns exit code 36.

Workaround: Python's keyring library bypasses the broken CLI entirely. It calls the Security framework's C API via ctypes, never touching the security binary:

import keyring
value = keyring.get_password("my-service", "my-account")
Enter fullscreen mode Exit fullscreen mode

This works from terminal sessions and from LaunchAgent context.

Finding 2: Keychain ACLs are per-binary

This one bit me hard. I stored a secret using /usr/bin/python3 (Python 3.9 on macOS), then tried to read it from /opt/homebrew/bin/python3.14. Access denied.

macOS Keychain Access Control Lists record the specific binary path that created the item. A different binary — even a different version of the same language — gets rejected.

Fix: When storing secrets, inject from every Python version on the system:

import subprocess

pythons = ["/usr/bin/python3", "/opt/homebrew/bin/python3.14"]
for py in pythons:
    subprocess.run([py, "-c",
        "import keyring; keyring.set_password('service', 'account', 'value')"])
Enter fullscreen mode Exit fullscreen mode

Each invocation adds that binary to the ACL. Now both versions can read the item.

Finding 3: The novel one — bash subprocess loses SecurityAgent

This is the one I couldn't find documented anywhere.

Setup: A bash script runs as a LaunchAgent. It calls python3 get_secret.py as a subprocess to read a keychain item.

Expected: Python reads the secret via keyring, returns it to bash.

Actual: The Python process hangs forever. No error, no timeout, just... stuck.

Root cause: When bash is the LaunchAgent's ProgramArguments[0], it has a SecurityAgent session attachment (this is what allows keychain access in a GUI-less context). When bash spawns a Python subprocess, that SecurityAgent session attachment is not inherited. The child Python process has no SecurityAgent context, so keyring.get_password() blocks waiting for a GUI dialog that will never appear.

This doesn't happen on Sequoia. It's specific to Tahoe's SecurityAgent session handling.

Fix: Either make Python the direct LaunchAgent program (not a subprocess of bash), or use a "file bridge" pattern — have a Python-native process read from keychain and write to a chmod 600 file that bash can read.

Finding 4: SSH sessions can't read keychain at all

Running keyring.get_password() from an SSH session returns None or raises errSecInteractionNotAllowed (-25308). The keychain requires a SecurityAgent GUI session that SSH doesn't provide.

Fix: Run keychain reads from a LaunchAgent (which has GUI context). The commonly recommended security unlock-keychain -p also fails on Tahoe (see Finding 6 below).

For SSH writes, you can unlock via the Security framework's C API (ctypes). The unlock + read/write must happen in a single Python process — the unlock is process-scoped and doesn't persist across invocations:

import ctypes, ctypes.util, keyring

Security = ctypes.cdll.LoadLibrary(ctypes.util.find_library("Security"))
keychain = ctypes.c_void_p()
path = b"/Users/USERNAME/Library/Keychains/login.keychain-db"
Security.SecKeychainOpen(path, ctypes.byref(keychain))
pw = b"YOUR_LOGIN_PASSWORD"
Security.SecKeychainUnlock(keychain, ctypes.c_uint32(len(pw)), pw, ctypes.c_bool(True))

# Now keyring works — but ONLY within this same process
keyring.set_password("service", "account", "value")
print("OK" if keyring.get_password("service", "account") else "FAIL")
Enter fullscreen mode Exit fullscreen mode

Caveat: This ctypes unlock only works with /usr/bin/python3 (Apple's system Python). Homebrew Pythons still get -25308 even after the unlock (see Finding 6).

Finding 5: keyring must be installed per-Python

Each Python binary has its own site-packages. Running pip3 install keyring only installs it for whichever Python pip3 points to. If you have system Python 3.9 and Homebrew Python 3.14, you need:

/usr/bin/python3 -m pip install keyring
/opt/homebrew/bin/python3.14 -m pip install --break-system-packages keyring
Enter fullscreen mode Exit fullscreen mode

Finding 6: The security CLI is entirely broken — not just reads

Update (March 2026): My original post said security find-generic-password -w was broken. After further testing, the damage is broader than that. The entire security CLI is unreliable on Tahoe:

  • security find-generic-password -w — hangs or exits 36 (original finding)
  • security unlock-keychain -p — returns "incorrect passphrase" with a known-correct password
  • security show-keychain-info — exits 36

The workaround for SSH is to use the Security framework C API directly via Python ctypes (see Finding 4 above). But even that has a quirk: the unlock only works with /usr/bin/python3 (Apple's system Python 3.9). Homebrew Python binaries (3.12, 3.14) still get errSecInteractionNotAllowed (-25308) after the same ctypes unlock, even in the same process. Root cause unknown — likely an entitlement or codesigning difference between Apple-signed and Homebrew-built binaries.

Practical impact: If you need to write secrets from an SSH session, do the ctypes unlock + keyring.set_password() from /usr/bin/python3, then write a Group B bridge file in the same process for immediate SSH access. For multi-Python ACL coverage, inject from Homebrew Pythons via a VNC/GUI Terminal session.

The Solution: Group A/B Architecture

After a week of debugging, I landed on an architecture that handles all five issues:

Group A: Python scripts that are direct LaunchAgent programs read from keychain via keyring. No files on disk.

Group B: For bash scripts (which can't reliably call Python keyring as a subprocess), a boot-time Python LaunchAgent reads secrets from keychain and writes them to chmod 600 files. Bash scripts read from files.

A shared get_secret() helper tries keychain first, falls back to file:

def get_secret(service, account="moltbot"):
    try:
        import keyring
        value = keyring.get_password(service, account)
        if value:
            return value
    except Exception:
        pass
    # Fallback to Group B file
    path = os.path.join(SECRETS_DIR, service)
    with open(path) as f:
        return f.read().strip()
Enter fullscreen mode Exit fullscreen mode

Results

Before: 12 API keys stored as plaintext files. Any process with file read access could exfiltrate them.

After: 7 secrets are Group A (keychain only, no file on disk). 5 are Group B (keychain + boot-time file bridge for bash tools). All 12 are in macOS Keychain with proper ACLs.

The Skill

I packaged all of this — migration tool, audit checker, file bridge, diagnostic tools — as an OpenClaw skill called Keychain Bridge.

The migration tool auto-detects all Python versions, injects secrets from each for ACL coverage, verifies the round-trip, and optionally deletes the original plaintext files. The audit tool checks for leaked files and keychain health.

100% local. Zero network calls. No telemetry. All code open for inspection.

Even if you're not using OpenClaw, the key takeaways apply to any macOS Tahoe deployment:

  1. Use Python keyring instead of the security CLI — the entire CLI is broken, not just reads
  2. Inject keychain items from every Python version that needs to read them
  3. Never spawn Python keychain reads as a subprocess from bash LaunchAgents
  4. Use a file bridge for bash scripts that need secrets
  5. Install keyring separately for each Python binary
  6. For SSH access, use ctypes SecKeychainUnlock from /usr/bin/python3 — Homebrew Pythons can't unlock even via the C API

Top comments (0)