You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
sandbox: a junction at .git sends the fallback carveouts outside the workspace on Windows #1024
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.
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.
Activity
Vasanthdev2004 commentedon Sep 9, 2026
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/hooksand the outside path return the sameGetFinalPathNameByHandleanswer, 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 theos.RemoveAllon 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.fix(sandbox): keep a derived ACL target inside the write root it came…
fix(sandbox): keep a derived ACL target inside the write root it came…