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
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). 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")
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
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()
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 — the entire CLI is broken, not just reads - 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 - For SSH access, use ctypes
SecKeychainUnlockfrom/usr/bin/python3— Homebrew Pythons can't unlock even via the C API
Top comments (0)