Skip to content

fix(sandbox): deny reads of git's credential stores (#815) - #816

Merged
kevincodex1 merged 2 commits into
mainfrom
fix/815-deny-git-credential-stores
Jul 28, 2026
Merged

fix(sandbox): deny reads of git's credential stores (#815)#816
kevincodex1 merged 2 commits into
mainfrom
fix/815-deny-git-credential-stores

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Takes option 1 from #815: the git credential stores only.

What

~/.git-credentials and ~/.config/git/credentials are git's credential-store backend. They hold host passwords and personal access tokens in cleartext, and neither was on the deny list, so any command the agent ran could read them.

Measured on main before the change, with the files materialised, since the builder only emits paths that exist:

denied=false  ~/.git-credentials
denied=false  ~/.config/git/credentials
denied=true   ~/.aws/credentials     (included to show the mechanism works)

Why only these two, and not the rest of #815

#815 also covers SSH private keys and the GPG keyring. Those are a genuinely harder trade and this deliberately does not attempt them:

  • Denying ~/.ssh stops a sandboxed git push over SSH from working. That is a functional cost, not a free win.
  • Excluding ~/.ssh is not even sufficient. An IdentityFile directive, or an Include chain, can point key material anywhere, so a directory rule leaves the key readable exactly when a careful user has moved it.

The credential stores have neither problem. git reads them to authenticate, not to know who the user is, so a sandboxed git keeps working and simply cannot act as the user. That is the behaviour you want from a sandbox.

One asymmetry worth explaining

The XDG entry denies the FILE, ~/.config/git/credentials, not the directory around it. That is the opposite of the choice made for ~/.config/zero in #801, and it is deliberate.

~/.config/git also holds the global git config, and userGitConfigReadPaths grants that on purpose so a sandboxed git can read user.name and aliases rather than failing with "unable to access". Denying the directory would take that away. Its comment already says the surrounding directory should not be exposed, which is the same instinct, applied from the allow side on macOS.

Verification

gofmt, go vet, GOOS=windows go vet, and builds for linux, darwin and windows are clean. internal/sandbox passes.

Three tests, each mutation-checked rather than merely written:

  • dropping ~/.git-credentials fails the coverage test
  • dropping the XDG entry fails it too
  • widening the XDG entry to the whole directory fails the assertion that the git config beside it stays readable, which is the regression this scoping exists to prevent

The third is the one worth having. It is what stops someone "simplifying" this later into a directory deny and quietly breaking git identity inside the sandbox.

Scope

Linux in practice, like the rest of the list: it is skipped on Windows by design, and macOS allow-lists reads. #815 stays open for the SSH and GPG half, which still needs the posture decision.

Summary by CodeRabbit

  • Bug Fixes
    • Improved credential protection by blocking access to Git credential helper store files in sandboxed environments (including both legacy and XDG locations).
    • Deny rules still respect explicit allow-read permissions, so explicitly permitted paths are not blocked.
  • Tests
    • Added coverage to verify credential store deny-path behavior and precedence with allow-read settings.

git's credential-store backend keeps host passwords and personal access tokens
in cleartext, at ~/.git-credentials or ~/.config/git/credentials depending on
whether the user is on the XDG layout. Neither was on the deny list, so any
command the agent runs could read them.

Scoped to those two files deliberately. #815 also covers SSH private keys and
the GPG keyring, and those are a harder trade: denying ~/.ssh stops a sandboxed
git push over SSH from working, and excluding that directory is not even
sufficient, since an IdentityFile directive can point key material anywhere.
These two cost nothing by comparison, because git reads them to authenticate
rather than to know who the user is, so a sandboxed git keeps working and
simply cannot act as the user.

The XDG one denies the file rather than the directory around it, which is the
opposite of the choice made for ~/.config/zero and is deliberate: ~/.config/git
also holds the global git config, which userGitConfigReadPaths grants on
purpose so a sandboxed git can read user.name and aliases instead of failing
outright. Denying the directory would take that away.

Tests assert both stores are denied, that the git config beside the XDG one
stays readable, and that an explicit read grant still re-includes them like
every other entry. Dropping either entry fails the first; widening the XDG
entry to the directory fails the config assertion.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f30b9724-76d1-4ccb-a3a5-0bf80ec3894b

📥 Commits

Reviewing files that changed from the base of the PR and between 6140e84 and afbe47f.

📒 Files selected for processing (1)
  • internal/sandbox/profile_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/sandbox/profile_test.go

Walkthrough

Credential deny-path generation now covers home and XDG Git credential stores. Tests verify existence-based inclusion, preservation of Git configuration access, and explicit allow-read precedence.

Changes

Git credential deny coverage

Layer / File(s) Summary
Add Git credential store targets
internal/sandbox/profile.go
Credential deny-path generation considers ~/.git-credentials and $XDG_CONFIG_HOME/git/credentials, subject to existing existence and allow-read filtering.
Validate credential store filtering
internal/sandbox/profile_test.go
Tests verify both credential stores are denied, user Git configuration remains readable, and explicit home-directory access takes precedence.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

Possibly related PRs

  • Gitlawb/zero#681 — Modifies the same credential deny-path construction logic.
  • Gitlawb/zero#685 — Extends the same candidate deny list with another environment-derived credential path.
  • Gitlawb/zero#801 — Tests the same credential deny-path behavior and precedence handling.

Suggested reviewers: gnanam1990, kevincodex1

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: denying sandbox reads of Git credential store files.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/815-deny-git-credential-stores

Comment @coderabbitai help to get the list of available commands.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Jul 27, 2026
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: afbe47f38263
Changed files (2): internal/sandbox/profile.go, internal/sandbox/profile_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

gnanam1990
gnanam1990 previously approved these changes Jul 28, 2026

@gnanam1990 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Approve.

Reviewed at 6140e840945e, base 81a5e6c, re-confirmed against the live head before posting. Up to date with main.

Declaring an interest first: this closes a gap I raised as non-blocking on #801, so I am reviewing a fix to my own finding. That does not change whether the code holds up, but you should weigh my approval accordingly.

The fix is verified, not taken on trust. Both paths now come back denied, and both mutations bind: dropping ~/.git-credentials fails TestCredentialDenyReadPathsCoversGitCredentialStores, and dropping the XDG entry fails it too. TestGitCredentialDenyYieldsExplicitAllowRead covers the opt-out, which matters because it is the behaviour the existing entries already have and the one a user relies on to get a path back.

The asymmetry you explain is correct, and I checked it rather than accepting the reasoning. Denying the file ~/.config/git/credentials rather than the directory is right because userGitConfigReadPaths grants ~/.gitconfig and ~/.config/git/config — the config files — so a sibling-file deny does not collide with that grant, where a directory deny would have taken it away. The contrast with ~/.config/zero in #801 is genuine and the reason is the one you give.

Scoping to the credential stores and leaving SSH alone is the right call, and your second argument for it is stronger than the one #815 makes. "Denying ~/.ssh costs a sandboxed git push" is a trade. "Excluding ~/.ssh is not even sufficient, because an IdentityFile directive or an Include chain can put key material anywhere" is a reason the directory rule is the wrong shape, which is a different and better claim.

One non-blocking observation, because that argument cuts both ways. git's credential store is not fixed to these two locations either: credential.helper = "store --file=/anywhere" puts it wherever the user says, exactly as IdentityFile does for SSH keys. So this covers the defaults and not the mechanism. I do not think that argues against merging — covering the default locations is worth doing and the alternative is unbounded — but it is worth stating in the issue so #815 does not read as "SSH is hard, git is solved". The honest framing is that both are default-only, and neither can be closed by enumerating paths.

Verification. On macOS (darwin/arm64): gofmt -l, go vet ./... and git diff --check clean; ./internal/sandbox/... passing; builds clean for linux/amd64 and windows/amd64.

Limitations. The deny list returns early on Windows and is a no-op on macOS, where seatbelt reads are allow-listed, so this only changes Linux behaviour — and I exercised it through the profile builder, never against a live Linux sandbox. That gap is now standing across #801, #815 and this: nobody on any of these threads has run the enforcement path.

Process. The body has no Fixes #815; the reference is in the title only, so tooling reads this as unparented. #815 also carries no issue-approved label yet. Neither affects the code, and you are a collaborator so the gate is process rather than quality, but three of your open PRs now reference issues in a form nothing can see (#808 "foundation for #662", #813, and this).

Merge is kevin's call per the program gate.

…ts it

The allow-read case only checked that the credential store was absent from
the deny list once an explicit grant covered it. A deny rule that was never
added satisfies that just as well, so the test passed with the production
change reverted and proved nothing about the interaction it was named for.

Check both runs: denied with no grant, absent with one. The first leg is
what establishes there is something for the grant to override.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

Self review before this goes to anyone else. One of the two tests was not testing anything.

TestGitCredentialDenyYieldsToExplicitAllowRead only checked that the credential store was absent from the deny list once an explicit grant covered it. A deny rule that was never added satisfies that equally well, so with both production lines reverted the test still passed. The coverage test correctly failed, so only this one was affected.

Now asserted from both sides: denied with no grant, absent with one. The first leg is what establishes there is anything for the grant to override. Verified it fails under the same revert.

Two limits worth stating rather than leaving implied:

  • A store relocated with credential.helper 'store --file=<path>' is not covered. Reaching it means parsing git config for arbitrary paths, which is the same relocation problem the SSH half of Sandbox can read SSH private keys, the GPG keyring, and git credential stores #815 runs into, so I would rather handle both there than half solve it here.
  • The builder only emits paths that exist when the profile is built, which is true of every entry including ~/.aws. A store created after the sandbox starts is not retroactively denied. Pre-existing property, noting it so it is not mistaken for a promise this PR makes.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@kevincodex1 ready to merge from my side. All nine checks green on afbe47f3 and CodeRabbit has approved the current head.

Same situation as #814 and it is my doing: @gnanam1990 approved at 6140e840 earlier today and my follow up push dismissed that approval, so GitHub is holding on zero human approvals. Re-requested.

What the push fixed: on a second pass over my own work, TestGitCredentialDenyYieldsToExplicitAllowRead was one sided. It only checked the credential store was absent from the deny list once an explicit grant covered it, which a deny rule that was never added satisfies just as well, so it passed with both production lines reverted. Now it asserts both legs, denied with no grant and absent with one, and fails under that same revert. The coverage test was sound throughout.

gnanam, the delta since your approval is confined to profile_test.go, 19 added and 5 removed. The production change and the two limits noted in the PR body are untouched.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator Author

@gnanam1990 same ask as #814, and same cause: my follow up push dismissed your approval at 6140e840.

Delta is 6140e840..afbe47f3, confined to profile_test.go, 19 added and 5 removed. The production change and the two limits in the PR body are untouched.

To check the fix rather than take my word for it, revert both deny lines in profile.go:

go test ./internal/sandbox/ -run 'TestCredentialDenyReadPathsCoversGitCredentialStores|TestGitCredentialDenyYieldsToExplicitAllowRead' -count=1

On the old head the coverage test failed and the allow read test passed, which is what gave it away: a deny rule that was never added satisfies "absent when granted" just as well as one that was. On the current head both fail under that revert.

@Vasanthdev2004
Vasanthdev2004 requested a review from anandh8x July 28, 2026 08:51
@kevincodex1
kevincodex1 merged commit 33c94ec into main Jul 28, 2026
9 checks passed
PierrunoYT added a commit to PierrunoYT/zero that referenced this pull request Jul 29, 2026
…fixes

Resolves the two sandbox conflicts deliberately rather than by side:

- internal/sandbox/profile.go keeps this branch's multi-home
  credentialDenyReadPathsIn and ports Gitlawb#816's git credential-store denies into
  it. ~/.git-credentials joins candidates only, not dirs, because it is a file
  and dirs drives directory-shaped handling; the XDG credentials file is added
  before the zero-directory handling so a normalization failure there cannot
  drop it. Gitlawb#816's tests come with it, rewritten against the new helper, and the
  policy JSON baseline in internal/cli/sandbox_test.go now lists both paths so
  the exported contract catches a regression.
- internal/sandbox/profile_test.go stays this branch's external-package file;
  the ported Gitlawb#816 tests need unexported symbols and live in their own internal
  test file.

runner.go's ZERO_DAEMON_REMOTE_TOKEN_FILE scrub from Gitlawb#677 merged cleanly and is
verified present. internal/config/command.go and the rest of main come through
untouched.

Also carries the review's two design fixes and the introspection note; see the
following commit message trailers in the PR discussion for what each one does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants