Repository navigation
Sandbox can read SSH private keys, the GPG keyring, and git credential stores #815
Description
Activity
Vasanthdev2004 commented
on Jul 27, 2026 CollaboratorAuthorMore actionsPrior 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
~/.sshis not sufficient, and that is not obvious. An SSH config can point key material anywhere:IdentityFile ~/keys/work_ed25519, or anIncludechain that redirects it. So a deny rule on~/.sshleaves 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, followIncludedirectives 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, userknownhostsfileThose 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*anddeny file-write-unlinkfor 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.dBroader than ours in three ways that look deliberate rather than incidental:
.tshand.brevare credential stores for tools we do not cover at all,.pkiand.terraform.dlikewise, and.configis 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
.sshor.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.
- added a commit that references this issue
on Jul 28, 2026 Vasanthdev2004 commented
on Aug 12, 2026 CollaboratorAuthorMore actionsApproved, 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 ininternal/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
~/.sshis 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.- addedissue-approvedReviewed and approved by the core team; community PRs may implement this issue.Reviewed and approved by the core team; community PRs may implement this issue.
on Aug 12, 2026 - added 2 commits that reference this issue
on Aug 28, 2026 - added 15 commits that reference this issue
on Sep 5, 2026 Vasanthdev2004 commented
on Sep 24, 2026 CollaboratorAuthorMore actionsTriaging this one, and the title now covers more than what is left. The git half is closed; SSH and GPG are not.
On
mainat 99721c7,credentialDenyReadPathsIndenies both git credential stores:~/.git-credentials, added by33c94ec7("fix(sandbox): deny reads of git's credential stores"),$XDG_CONFIG_HOME/git/credentials, denied as a FILE rather than the directory, deliberately, souserGitConfigReadPathscan still grant the global git config next to it.
Neither
.sshnor.gnupgappears anywhere ininternal/sandboxoutside 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
~/.sshstops a sandboxedgit pushover 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:
credentialDenyReadPathsreturns an empty set immediately whenruntime.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.
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
credentialDenyReadPathsForEnvironmentagainst a synthetic home with the files materialised, onmainat81a5e6cb. The builder filters to paths that exist, so they have to be real:~/.awsis 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
~/.sshwholesale would stop a sandboxedgit pushover SSH from working, and probablygit pullon 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:
~/.git-credentialsand~/.config/git/credentials. No functional cost, closes the git half, leaves SSH open.~/.ssh/id_*,~/.ssh/*.pem,~/.gnupg. Leaves~/.ssh/configandknown_hostsreadable, so host resolution still works, but SSH auth from inside the sandbox breaks, since the agent cannot read the key it needs.~/.sshand~/.gnupgentirely, 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
credentialDenyReadPathsForEnvironmentwith the file materialised.~/.ssh/known_hostsunder option 2) comes back not denied, so the rule is scoped rather than blanket.allowReadroot covering one of these paths still re-includes it, matching how the existing entries behave.