Linux sandbox replaces /run/user with an empty tmpfs, so sandboxed commands cannot reach the D-Bus session bus or the system keyring — gh breaks and the only workaround is plaintext credentials

Status Open
Reported on v2.1.236
Maintainer reply None cached
Activity 1 comment · opened Aug 30, 2026

On Linux the sandbox mounts a fresh empty tmpfs over /run/user, so $XDG_RUNTIME_DIR and the D-Bus session bus socket do not exist inside a sandboxed Bash call. Any tool that stores its credential in the system keyring (libsecret/gnome-keyring, reached over D-Bus) therefore cannot read it. gh is the case I hit, but this applies to every libsecret-backed credential helper.

The failure is silent and misattributed: gh reports The token in default is invalid., which points the user at their token rather than at the sandbox. This is the Linux counterpart of #87008 ("Sandboxed commands can't reach the macOS keychain, and the tools that need it blame the credential").

Environment

  • Claude Code 2.1.236 (native installer, ~/.local/share/claude/versions/2.1.236)
  • Ubuntu 22.04.5 LTS, kernel 5.15.0-190-generic, x86_64, bubblewrap 0.6.1
  • gh 2.96.0, authenticated with the default secure storage (keyring)
  • Relevant ~/.claude/settings.json:
"sandbox": {
  "enabled": false,
  "filesystem": {
    "denyRead": ["~/.aws/credentials", "~/.aws/sso"]
  }
}

(enabled: false is not honored on Linux and has not been since 2.1.220 — the sandbox runs regardless. Noted only so the config above is not misread as "sandbox off".)

Repro

Inside a sandboxed Bash tool call:

$ echo "$DBUS_SESSION_BUS_ADDRESS"
unix:path=/run/user/1000/bus

$ ls -a /run/user/1000
ls: cannot access '/run/user/1000': No such file or directory

$ gh auth status
github.com
  X Failed to log in to github.com account <user> (default)
  - Active account: true
  - The token in default is invalid.

Control, same machine, plain terminal outside Claude Code — the token is valid and the keyring is reachable:

$ gh auth status
github.com
  ✓ Logged in to github.com account <user> (keyring)
  - Active account: true
  - Token scopes: 'gist', 'read:org', 'repo'

The cause is visible in the mount table of the sandboxed call:

$ grep '/run/user ' /proc/self/mountinfo
1174 1154 0:69 / /run/user rw,nosuid,nodev,relatime - tmpfs tmpfs rw,mode=755,uid=1000,gid=1000,inode64

An empty tmpfs is mounted over /run/user, so the real /run/user/1000 — including bus — is not reachable. DBUS_SESSION_BUS_ADDRESS is still exported and still points at the now-nonexistent path.

Workarounds tried, all measured, none work

All probes were run from a plain terminal, as claude --settings <probe>.json --allowedTools Bash -p '…' < /dev/null, with the shipping config plus exactly one candidate line. See the methodology note at the end for why they cannot be run from inside a Claude Code session.

1. sandbox.network.allowUnixSockets: ["/run/user/1000/bus"] — no effect. ls -a /run/user/1000 still No such file or directory, gh auth status unchanged. Also tried with allowedDomains: ["github.com", "api.github.com", "*.githubusercontent.com"] added, in case an empty allowlist was silently blocking egress and producing a false negative — identical result, so it is not a network-layer false negative. This is consistent with #44180: the setting is not implemented on Linux. It could not help anyway — it authorizes connect(), but the socket's path has been masked at the mount layer.

2. sandbox.excludedCommands: ["gh *", …] — no effect; gh still runs sandboxed. Filed separately as #89931 (reported there on macOS 2.1.232; I have added a Linux 2.1.236 datapoint to that issue).

3. dangerouslyDisableSandbox: true — does not remove the filesystem masks either. With ~/.ssh in denyRead, ls ~/.ssh shows the same empty tmpfs overlay with the flag set as without it, and chezmoi apply ~/.claude/settings.json fails with read-only file system against the sandbox's own ro bind of ~/.claude.

Taken together there appears to be no working per-command escape from the Linux sandbox on this build.

The only workaround that works makes security strictly worse

gh auth login --insecure-storage moves the token out of the keyring into plaintext at ~/.config/gh/hosts.yml, which is not masked, so gh works again. That is the state I am now running in. Trading a keyring-held credential for a plaintext one, in order to satisfy a sandbox, is the wrong direction.

Worse, that plaintext file cannot then be protected. Adding Read(~/.config/gh/hosts.yml) to permissions.deny does not scope to the Read tool on Linux — it propagates into the sandbox filesystem deny and breaks gh itself:

$ gh api user
warning: failed to load config: open /home/<user>/.config/gh/hosts.yml: permission denied
failed to create root command: failed to read configuration: open /home/<user>/.config/gh/hosts.yml: permission denied

$ grep 'gh/hosts' /proc/self/mountinfo
1420 1381 0:5 /null /home/<user>/.config/gh/hosts.yml ro,nosuid,nodev,relatime master:2

The same propagation applies to any home-anchored entry: adding Read(~/.ssh/**) to permissions.deny masks ~/.ssh with an empty tmpfs and breaks git-over-SSH (Host key verification failed.), even though ssh and git never go through the Read tool. So there is no way to deny the Read tool a file while leaving it readable to the Bash tool that must open it.

(Separately, and possibly worth its own issue: recursive-glob entries such as Read(**/*.key) resolve relative to the working directory and do not cover $HOME at all. A probe.key placed outside the project was read by the Read tool despite that rule. So the two forms differ in both scope and in whether they reach the sandbox.)

Not tested: sandbox.filesystem.allowRead. It is documented as re-allowing reads within denyRead regions, and the /run/user replacement is not a denyRead region, so I do not expect it to apply — but I have not measured it.

What I would like

Either of:

  • bind the real $XDG_RUNTIME_DIR — or at minimum the D-Bus session bus socket — into the sandbox by default, the way the macOS sandbox would need to allow the keychain service; or
  • provide a supported, working way to allow it (allowUnixSockets on Linux per #44180, or a filesystem allow that can re-expose a baseline-masked path).

Failing that, the practical advice this configuration forces on Linux users is "move your credentials to plaintext", which is worth avoiding.

Methodology note for anyone re-testing this on Linux

Do not run these probes from inside a Claude Code session. The inner bubblewrap can add masks on top of the outer session's, but it cannot remove them, so any test of the form "does this setting un-block something" is a guaranteed false negative when run from a sandboxed Bash tool. Tests of the form "does this get blocked" are fine from inside. Every probe above was run from a plain terminal.

Also pair every probe with controls in the same session: a path that should still be denied (~/.aws/sso/cache/*.json, which stayed blocked throughout) and a path that should still be readable (~/.bashrc, 5913 bytes throughout). Without them a probe that silently failed to load its settings looks identical to a probe that loaded them and found the feature broken.

Related: #87008 (macOS keychain, same class), #44180 (allowUnixSockets on Linux), #89931 (excludedCommands inert).

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗