Skip to content

Sandbox can read SSH private keys, the GPG keyring, and git credential stores #815

Description

@Vasanthdev2004

Summary

The credential deny list covers cloud CLI and tool credentials, and since #801 it covers Zero's own store, but it does not cover SSH private keys, the GPG secret keyring, or either git credential store. On Linux, where the sandbox uses a read-all filesystem posture with explicit deny rules, a sandboxed command can read all of them.

Split out of #675, which named Zero's own credential files and is now fixed. These were listed there as out of scope and should not be assumed closed with it.

Evidence

Driving credentialDenyReadPathsForEnvironment against a synthetic home with the files materialised, on main at 81a5e6cb. The builder filters to paths that exist, so they have to be real:

denied=false  ~/.ssh/id_rsa
denied=false  ~/.ssh/config
denied=false  ~/.gnupg/secring.gpg
denied=false  ~/.git-credentials
denied=false  ~/.config/git/credentials
denied=true   ~/.aws/credentials

~/.aws is included to show the mechanism works and these are simply absent from the list.

Why this is worth more than the entries already covered

An SSH private key is usually a broader credential than a provider API key. It typically grants push access to every repository the user can reach, and on many setups shell access to servers as well. The same is true of the GPG secret keyring for signing identity. Both are readable today by any command the agent runs.

The git case has a wrinkle worth naming

userGitConfigReadPaths (internal/sandbox/profile.go:286) already grants only the two git config FILES, and its comment says why: "granting only these files avoids exposing the surrounding configuration directory". That reasoning is about ~/.config/git, whose XDG credential store lives at ~/.config/git/credentials.

So macOS, which allow-lists reads, deliberately avoids exposing that directory. Linux, which denies instead, has no rule for the credential file inside it. The intent exists in the codebase; only the Linux half is missing.

Relevant code

  • internal/sandbox/profile.go, credentialDenyReadPathsForEnvironment (the candidate list)
  • internal/sandbox/profile.go:286, userGitConfigReadPaths (the comment describing the intent)

Expected behavior

A sandboxed command cannot read SSH private keys, the GPG secret keyring, or either git credential store, on the same terms as the entries already denied.

Decision needed before implementing

Denying ~/.ssh wholesale would stop a sandboxed git push over SSH from working, and probably git pull on private remotes too. That is a real functional trade rather than a free win, and it is the reason this is an issue rather than a one-line addition.

Options, roughly in increasing order of disruption:

  1. Deny the credential stores only: ~/.git-credentials and ~/.config/git/credentials. No functional cost, closes the git half, leaves SSH open.
  2. Deny key material but not the directory: ~/.ssh/id_*, ~/.ssh/*.pem, ~/.gnupg. Leaves ~/.ssh/config and known_hosts readable, so host resolution still works, but SSH auth from inside the sandbox breaks, since the agent cannot read the key it needs.
  3. Deny ~/.ssh and ~/.gnupg entirely, and treat git-over-SSH as something that requires escalation.

Worth deciding deliberately rather than by patch order. This deny list has grown a few entries at a time (cloud CLIs, then tool configs, then Zero's own store), which gnanam flagged on #801 as a shape worth settling rather than continuing.

Platform scope

Linux only, in practice. The deny list is skipped on Windows by design, and macOS allow-lists reads so these paths are already not granted there. #808 approaches the Windows side differently, by giving the sandbox its own identity so credential paths are unreachable by construction rather than by enumeration.

Suggested tests

  • Each newly denied path comes back denied from credentialDenyReadPathsForEnvironment with the file materialised.
  • A path that must stay readable under the chosen option (for example ~/.ssh/known_hosts under option 2) comes back not denied, so the rule is scoped rather than blanket.
  • An explicit allowRead root covering one of these paths still re-includes it, matching how the existing entries behave.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions