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.

Activity

  1. Vasanthdev2004 commented on Jul 27, 2026

    @Vasanthdev2004
    CollaboratorAuthor

    Prior art, since this decision has been made elsewhere

    I looked at how another agent sandbox with the same problem resolved it, because the git-over-SSH trade above is the part worth not guessing at. Two findings, and the second one changes what a fix here should look like.

    Excluding ~/.ssh is not sufficient, and that is not obvious. An SSH config can point key material anywhere: IdentityFile ~/keys/work_ed25519, or an Include chain that redirects it. So a deny rule on ~/.ssh leaves the actual private key readable whenever the user has moved it, which is exactly the configuration a careful user is most likely to have.

    Their answer is to parse ~/.ssh/config, follow Include directives recursively with cycle detection and a depth cap, collect every path referenced by the path-valued directives, and exclude all of them:

    certificatefile, controlpath, globalknownhostsfile, identityagent,
    identityfile, revokedhostkeys, userknownhostsfile
    

    Those paths are then filtered out of BOTH the read roots and the write roots, so a denied key cannot be read, and cannot be unlinked to probe for its existence either. Their macOS policy does the same pairing, emitting deny file-read* and deny file-write-unlink for every unreadable pattern. Worth copying: a read-deny that leaves delete permitted is still an information leak.

    Their static exclusion list, for comparison with ours:

    .ssh  .tsh  .brev  .gnupg  .aws  .azure  .kube  .docker  .config  .npm  .pki  .terraform.d
    

    Broader than ours in three ways that look deliberate rather than incidental: .tsh and .brev are credential stores for tools we do not cover at all, .pki and .terraform.d likewise, and .config is excluded WHOLESALE rather than by naming individual subdirectories the way we do.

    They have the same gap we do on Unix. All of the above is their Windows path. On macOS and Linux their deny-read is a configurable glob mechanism with no shipped default covering .ssh or .gnupg, and the macOS policy is read-all with denies layered on, the same posture as our Linux profile. So on the platform this issue is about, the reference implementation is in the same position: protected only if the user configures it.

    That is worth saying plainly rather than presenting them as ahead. On this specific gap they are not; they simply solved it on the platform where they had to build an identity model anyway.

    What I would take from it

    • The SSH config parsing is the genuinely valuable idea and applies to us unchanged, whichever option is chosen above. Option 1 (credential stores only) does not need it; options 2 and 3 are incomplete without it.
    • Pairing every read-deny with an unlink-deny is a cheap correctness fix and probably belongs in our existing deny list regardless of what this issue decides.
    • The wider exclusion set (.tsh, .brev, .pki, .terraform.d) is worth a look, but enumerating more tools is the shape gnanam questioned on fix(sandbox): preserve user config and retry denied writes #801, so it should follow the posture decision rather than lead it.
  2. Vasanthdev2004 commented on Aug 12, 2026

    @Vasanthdev2004
    CollaboratorAuthor

    Approved, with one scope correction so nobody redoes work that has landed.

    The git credential store is now covered: filepath.Join(home, ".git-credentials") is in the deny candidates in internal/sandbox/profile.go, and the comment beside it explicitly defers SSH to this issue.

    Remaining scope is therefore SSH private keys and the GPG secret keyring. The same comment names the reason SSH was held back: denying all of ~/.ssh is a harder trade than denying a credential file, because git and tooling legitimately read from that directory. Worth deciding whether to deny key material specifically (id_*, no .pub) rather than the whole directory.

  3. added
    issue-approvedReviewed and approved by the core team; community PRs may implement this issue.
    on Aug 12, 2026
  4. Vasanthdev2004 commented on Sep 24, 2026

    @Vasanthdev2004
    CollaboratorAuthor

    Triaging this one, and the title now covers more than what is left. The git half is closed; SSH and GPG are not.

    On main at 99721c7, credentialDenyReadPathsIn denies both git credential stores:

    • ~/.git-credentials, added by 33c94ec7 ("fix(sandbox): deny reads of git's credential stores"),
    • $XDG_CONFIG_HOME/git/credentials, denied as a FILE rather than the directory, deliberately, so userGitConfigReadPaths can still grant the global git config next to it.

    Neither .ssh nor .gnupg appears anywhere in internal/sandbox outside one comment, and that comment is this issue:

    // Denied rather than the whole of ~/.ssh, because these cost nothing
    // functionally: git reads them through a credential helper for
    // authentication, not for identity, so a sandboxed git still works and
    // simply cannot authenticate as the user. SSH key material is a harder
    // trade and is tracked separately (#815).

    So the deferral was deliberate and the remaining scope is exactly the part the comment calls the harder trade. Worth saying out loud what makes it harder, since it is the decision whoever picks this up has to make first: denying ~/.ssh stops a sandboxed git push over SSH from working at all, where denying the git credential stores only stopped it authenticating as the user. Those are different costs, and the second was free in a way the first is not.

    I could not reproduce the issue's table natively: credentialDenyReadPaths returns an empty set immediately when runtime.GOOS == "windows", so the evidence above is from reading the builder rather than driving it. That early return is its own open issue (#662) and should not be read as part of this one.

    Not picking this up, since the SSH trade-off is a policy call rather than a missing entry. Flagging the split so the title and the remaining work agree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingissue-approvedReviewed and approved by the core team; community PRs may implement this issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions