DEV Community

Euda1mon1a
Euda1mon1a

Posted 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 five 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), or pre-unlock the keychain:

security unlock-keychain -p "$PASSWORD" ~/Library/Keychains/login.keychain-db
Enter fullscreen mode Exit fullscreen mode

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

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
  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

Top comments (0)