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
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")
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')"])
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
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
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()
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.
-
Free on ClawHub:
clawhub install keychain-bridge - Premium on ClawMarket: claw-market.xyz/skills/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:
- Use Python
keyringinstead of thesecurityCLI - Inject keychain items from every Python version that needs to read them
- Never spawn Python keychain reads as a subprocess from bash LaunchAgents
- Use a file bridge for bash scripts that need secrets
- Install
keyringseparately for each Python binary
Top comments (0)