···1010 "syscall"
1111)
12121313-// ChmodRepoTree sets directory modes to 0770 and file modes to 0660 under
1414-// root, preserving the executable bit on files (hook scripts need it).
1515-// Symlinks are skipped since their mode is not meaningful.
1313+// ChmodRepoTree sets directory modes to 2770 (with the setgid bit) and
1414+// file modes to 0660 under root, preserving the executable bit on files
1515+// (hook scripts need it). Symlinks are skipped since their mode is not
1616+// meaningful.
1617//
1718// The group bits exist so the knot service (running as the git user, which
1819// is in the git group that owns the repos) can still read and write the
1920// repo via group permissions even though the repo's UID owner is a virtual
2020-// UID. Sandbox subprocesses run with NoSetGroups: true so they don't gain
2121-// group access and cross-owner isolation still holds.
2121+// UID. Sandbox subprocesses drop supplementary groups so cross-owner
2222+// isolation still holds.
2323+//
2424+// The setgid bit on directories makes new files and subdirectories created
2525+// by sandbox subprocesses inherit the directory's group (the git group)
2626+// rather than the subprocess's primary group (the virtual UID). Without
2727+// it, sandbox-created files would be unreadable to the knot service.
2228func ChmodRepoTree(root string) error {
2329 return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
2430 if err != nil {
···2834 return nil
2935 }
3036 if d.IsDir() {
3131- return os.Chmod(path, 0770)
3737+ return os.Chmod(path, 0770|os.ModeSetgid)
3238 }
3339 info, err := d.Info()
3440 if err != nil {
···6060 wrapped.Stderr = cmd.Stderr
61616262 // drop to the virtual UID if we can resolve one. the kernel handles
6363- // fork -> setresuid -> chdir -> execve; requires CAP_SETUID/GID on the caller.
6363+ // fork -> setgroups -> setresgid -> setresuid -> chdir -> execve;
6464+ // requires CAP_SETUID/CAP_SETGID on the caller.
6465 //
6566 // the primary GID is intentionally set to the virtual UID, NOT the
6667 // repo's group ownership. repo dirs are owned by virtualUID:gitGroup
6768 // with mode 0770 so the knot service (in gitGroup) can read them, but
6869 // sandbox subprocesses must not inherit gitGroup or they would gain
6970 // group access to every other repo and lose cross-owner isolation.
7171+ //
7272+ // Groups is an empty (non-nil) slice and NoSetGroups is false so the
7373+ // kernel calls setgroups(0, NULL) and clears supplementary groups.
7474+ // NoSetGroups: true would skip setgroups entirely and the subprocess
7575+ // would inherit the parent's supplementary groups (including gitGroup).
7076 if l.lookup != nil {
7177 if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 {
7278 wrapped.SysProcAttr = &syscall.SysProcAttr{
7373- Credential: &syscall.Credential{Uid: uid, Gid: uid, NoSetGroups: true},
7979+ Credential: &syscall.Credential{
8080+ Uid: uid,
8181+ Gid: uid,
8282+ Groups: []uint32{},
8383+ },
7484 }
7585 }
7686 }
···202202 if cred.Gid != 100042 {
203203 t.Errorf("Credential.Gid = %d, want 100042 (must equal Uid, not lookup's gid 1234)", cred.Gid)
204204 }
205205- if !cred.NoSetGroups {
206206- t.Error("NoSetGroups should be true")
205205+ // NoSetGroups must be false (the default) so the kernel calls
206206+ // setgroups(0, NULL) and clears supplementary groups. NoSetGroups: true
207207+ // would let the subprocess inherit the parent's groups (gitGroup).
208208+ if cred.NoSetGroups {
209209+ t.Error("NoSetGroups must be false so supplementary groups get cleared")
210210+ }
211211+ if len(cred.Groups) != 0 {
212212+ t.Errorf("Groups = %v, want empty (no supplementary groups granted)", cred.Groups)
207213 }
208214}
209215