Sandboxed commands can't reach the macOS keychain, and the tools that need it blame the credential
Sandboxed commands can't reach the macOS keychain, and the tools that need it blame the credential
Environment: Claude Code 2.1.226 (native), macOS darwin-arm64, sandbox enabled via user settings (sandbox.enabled: true), Seatbelt backend.
What happens
With the sandbox on, commands that use the macOS keychain fail in two different ways, and neither says "sandbox".
1. git — write-back denied, on every authenticated operation. Git calls the credential helper's approve (store) after each successful auth, so git-credential-osxkeychain tries to write and can't:
$ git fetch origin
failed to store: 100001
$ echo $?
0
Reads still work, so fetch and push succeed. Harmless day to day, but the credential can't be updated or erased from inside a session — a rotated token can't be persisted, and a rejected one can't be cleared.
2. gh — reports a valid token as invalid. Same token, same machine, seconds apart; the only difference is whether the command matched excludedCommands:
$ gh auth status # matches "gh *" → runs unsandboxed
- Active account: true
- Token: gho_****
- Token scopes: 'repo', 'workflow', …
$ bash -c 'gh auth status' # command starts with bash → sandboxed
- The token in keyring is invalid.
- To re-authenticate, run: gh auth refresh -h github.com
The second message is actively misleading: it instructs the user to re-authenticate a credential that is fine. (gh keeps working via its config-file token; only the status wording is wrong.)
Why this is worth addressing
The failure is loud but misattributed. A user hitting either message will go rotate a PAT, run gh auth refresh, or suspect GitHub — none of which is the problem. The sandbox is doing exactly what it should; it just doesn't get the credit, and the tools' own error text points the wrong way.
Why the existing levers don't cover it
excludedCommandsis coarse and doesn't cover children. It removes the whole command from the boundary, and it matches the command Claude runs — not what that command spawns.gh *coversgh auth status; it does not coverbash -c 'gh auth status', a shell script that callsgh, or a Makefile target. Excludinggitto silence case 1 would put everygitcommand outside the sandbox, including the writes the sandbox exists to constrain.allowUnixSockets/allowAllUnixSocketsdon't apply. macOS keychain access is Mach IPC tosecurityd, not a Unix socket.sandbox.credentialsonly restricts. Its modes aredenyandmask; there is no grant.
Requested change
Primary: an opt-in keychain grant, default off. There is a working precedent — Microsoft's mxc seatbelt backend ships seatbelt.keychainAccess, off by default, and documents exactly what the profile needs:
mach-lookup:com.apple.SecurityServer,com.apple.securityd,com.apple.trustd,com.apple.ocspd,com.apple.cfprefsd.daemon,com.apple.xpcd, and thecom.apple.lsd.*family read:/private/var/db/mds,/private/var/protected/trustdread+write:~/Library/Keychains,/private/var/folders"Off by default — opt in only when the inner workload genuinely needs Keychain access."
Something like sandbox.keychainAccess: true would fit the existing settings shape. The exposure is real and should be documented plainly — it makes the login keychain readable from inside the sandbox — which is exactly why default-off matters.
Secondary, and possibly sufficient on its own: fix the attribution. Most of the cost here is a wrong diagnosis, not a blocked capability. Two cheap options:
- Surface the keychain denial in the violation details already appended to a failed sandboxed command's output, so the sandbox's own explanation travels with the confusing message.
- Document the limitation in the sandboxing page (Limitations or Troubleshooting) — "keychain-backed credential helpers cannot write from inside the sandbox; tools may report this as an invalid credential."
If only one of these is feasible, the documentation fix alone would have saved this investigation.
Reproduction
- Enable the sandbox in
~/.claude/settings.json(sandbox.enabled: true) on macOS. - In a repo whose
originis an authenticated HTTPS remote, rungit fetch origin→failed to store: 100001. - With
"excludedCommands": ["gh", "gh *"]set, comparegh auth statusagainstbash -c 'gh auth status'→ the second reports the keyring token as invalid.
3 Comments
Thanks for the detailed diagnosis. What exists today:
sandbox.excludedCommandsruns matching commands outside the sandbox (which is why bareghworks), and Claude can also request an unsandboxed run for a specific command when the sandbox blocks something, unless you've turned that escape hatch off. Neither helps when the keychain-using tool is nested insidebash -c, and neither fixes the misleading error text, so leaving this open as an enhancement.Docs: https://code.claude.com/docs/en/sandboxing
🤖 Generated with Claude Code
Confirming this on macOS 27.0 beta (BuildVersion 26A5416b), gh 2.98.0, Claude Code sandbox
enabled — with one addition that may be a useful diagnostic, and one variant failure mode.
The parenthetical in
gh auth statustells you which credential source it used, and that is thetell. Same binary, same token, seconds apart, differing only by sandbox:
```
# unsandboxed
✓ Logged in to github.com account <redacted> (keyring)
# sandboxed
X Failed to log in to github.com account <redacted> (default)
```
When gh can reach the keyring it reports
(keyring). When it can't, it falls back to thedefaultsource, finds nothing usable there, and reports that as an invalid token. So
(default)where youexpect
(keyring)is a reliable signal that the keychain was unreachable rather than that anythingis wrong with the credential.
Variant failure mode: with no config-file token, gh hard-fails rather than degrading cosmetically.
The report notes gh "keeps working via its config-file token; only the status wording is wrong."
That fallback doesn't exist here —
hosts.ymlholds the account name and nooauth_token:``
yaml
``github.com:
users:
<redacted>:
user: <redacted>
so the failure is real, not just wording:
``
``$ gh api user
{ "message": "Requires authentication", "status": "401" }
$ echo $?
1
Verified against the unsandboxed run above: the token is valid and works fine outside the sandbox.
The sandbox reports no default keychain at all, which is a stronger statement than an IPC block:
``
``$ security show-keychain-info
security: SecKeychainCopySettings <NULL>: A default keychain could not be found.
Filesystem reads of the Keychain paths are denied independently of the Mach IPC path:
``
`$ ls -la ~/Library/Keychains/
ls: /Users/<redacted>/Library/Keychains/: Operation not permitted
denyOnlyThis session's sandbox policy carries a
filesystem rule covering~/Library/Keychains,
/Users/*/Library/Keychains, and/Library/Keychains. So there appear to be two independent layerssandbox.keychainAccess` would need to lift both to work.— the securityd IPC block plus a filesystem deny on the Keychain paths. Worth confirming both are
intentional, since an opt-in
git credential-osxkeychain getfails silently rather than loudly:``
``$ echo -e "protocol=https\nhost=github.com" | git credential-osxkeychain get
$ echo $?
0
Empty output, exit 0 — git reads that as "no stored credential," so this path produces no error
message at all, wrong or otherwise.
+1 on the documentation fix. The current error text points at GitHub and at token rotation well
before the sandbox becomes a suspect.
Second data point from a different machine, which presents differently enough to be worth recording — my earlier comment was from a work laptop.
Personal machine: same gh 2.98.0, same macOS 27.0 (26A5416b), different Claude Code sandbox settings.
The
(keyring)vs(default)tell is config-dependent, not universal. Here gh reports(keyring)and still fails:X Failed to log in to github.com account <redacted> (keyring)
So
(default)is a sufficient signal that the keychain was unreachable, but not a necessary one — don't rely on it to reproduce.Only the securityd IPC block is inherent to the sandbox. On this machine
~/Library/Keychains/is readable (login.keychain-db,metadata.keychain-dblist fine) and there is no keychain entry in the session'sdenyReadpolicy, yet gh still can't use the credential. The filesystem deny in my earlier comment came from that machine's own settings, not from the sandbox. An opt-insandbox.keychainAccesswould therefore only need to lift the IPC layer in the default case.excludedCommandsis measurably insufficient, confirming thebash -cpoint above. With"gh"and"gh *"both set, stripping the injected proxy env leaves gh with no network at all:$ env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy -u ALL_PROXY -u all_proxy gh api /zen
error connecting to api.github.com
Direct egress is blocked, so it ran inside the sandbox despite the exclusion. With the proxy env left in place it instead fails TLS —
curlreturns 200 and gh fails on the identical URL, which is #77333. In practice the two issues compound: the exclusion doesn't apply to tool-invoked commands, so gh is sandboxed, and being sandboxed it hits both the keychain block and the proxy TLS failure.git credential-osxkeychain getsilent failure reproduces here too — empty output, exit 0. That one is machine-independent and is arguably the strongest case for the error-attribution half of this report, since it emits no error text at all.