Skip to content

sandbox: a junction at .git sends the fallback carveouts outside the workspace on Windows #1024

Description

@Vasanthdev2004

On main at f30f550, gitMetadataWriteCarveouts in internal/sandbox/profile.go emits <root>/.git/hooks and <root>/.git/config as write-denied carveouts. The Windows ACL apply then opens those paths with FILE_FLAG_OPEN_REPARSE_POINT, which guards only the final path component. A junction at <root>/.git is traversed as an intermediate, so the deny-write ACEs land on <junction-target>/hooks and <junction-target>/config, outside the workspace.

mklink /J needs no privilege, so any local user with write access to a workspace that has no .git yet can plant one.

Evidence, with provenance. I confirmed the carveout shape by reading the code on main: the two children are emitted unconditionally, with no lstat of .git at all. The traversal itself was probed during the review of #805: a junction created unelevated with mklink /J, then both <root>\.git\hooks through the junction and <junction-target>\hooks directly opened with the apply's exact CreateFile flags, and both resolved to the same volume serial and file index. Opening <root>\.git itself reports reparse=true and is refused, but with these carveouts that path is never opened.

Consequences differ by path. On the unelevated per-command path this only touches objects the user could already re-ACL, and it is rolled back after the command. On zero sandbox setup, which requires Administrator, the same redirect happens with an elevated token: the same TOCTOU class as #728, one component further up.

#805 does not introduce or widen this; it improves the regular-file case and leaves the junction case exactly as main has it. It also does not close it, which is why this is its own issue against the ACL apply rather than against that PR. The fix belongs in the apply: refuse to write an ACE through any owned intermediate that is a reparse point, which is the rule the rooted descent in #901 already follows for the runtime tree.

Activity

  1. Vasanthdev2004 commented on Sep 9, 2026

    @Vasanthdev2004
    CollaboratorAuthor

    Fix is up in #1040, scoped to what this issue describes: refusing the ACE when a derived carveout resolves outside the write root it was derived from, plus the same check before materialization creates anything. Confirmed the mechanism by handle rather than by reasoning: opened through a junction at .git, <root>/.git/hooks and the outside path return the same GetFinalPathNameByHandle answer, so the handle knows where it really is even when the name does not.

    Left out on purpose, and still open after it: the window between that pre-create check and os.MkdirAll, and the os.RemoveAll on the failure path. Both need components created relative to retained handles instead of by pathname, which is the rooted descent in #808. Keeping this PR narrow keeps it out of that one's way.

  2. added a commit that references this issue on Sep 12, 2026
    a9aa37e
  3. added a commit that references this issue on Sep 15, 2026
    3b43c43
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 working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions