···11+package sandbox
22+33+import (
44+ "fmt"
55+ "io/fs"
66+ "os"
77+ "path/filepath"
88+ "sort"
99+ "strings"
1010+ "syscall"
1111+)
1212+1313+// 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.
1616+//
1717+// The group bits exist so the knot service (running as the git user, which
1818+// is in the git group that owns the repos) can still read and write the
1919+// 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.
2222+func ChmodRepoTree(root string) error {
2323+ return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
2424+ if err != nil {
2525+ return err
2626+ }
2727+ if d.Type()&fs.ModeSymlink != 0 {
2828+ return nil
2929+ }
3030+ if d.IsDir() {
3131+ return os.Chmod(path, 0770)
3232+ }
3333+ info, err := d.Info()
3434+ if err != nil {
3535+ return err
3636+ }
3737+ mode := fs.FileMode(0660)
3838+ if info.Mode()&0100 != 0 {
3939+ mode = 0770
4040+ }
4141+ return os.Chmod(path, mode)
4242+ })
4343+}
4444+4545+// ChownRepoTree recursively chowns every entry under root to uid:gid.
4646+// Entries are processed deepest-first so a directory is only chowned after
4747+// its contents, preserving the calling process's access throughout the walk.
4848+// Call ChmodRepoTree first if you also want to tighten permissions; this
4949+// function only changes ownership.
5050+func ChownRepoTree(root string, uid int, gid int) error {
5151+ type entry struct {
5252+ path string
5353+ depth int
5454+ }
5555+ var entries []entry
5656+ if err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
5757+ if err != nil {
5858+ return err
5959+ }
6060+ depth := strings.Count(path, string(filepath.Separator))
6161+ entries = append(entries, entry{path, depth})
6262+ return nil
6363+ }); err != nil {
6464+ return err
6565+ }
6666+6767+ sort.Slice(entries, func(i, j int) bool {
6868+ return entries[i].depth > entries[j].depth
6969+ })
7070+7171+ for _, e := range entries {
7272+ if err := os.Lchown(e.path, uid, gid); err != nil {
7373+ return err
7474+ }
7575+ }
7676+ return nil
7777+}
7878+7979+// LookupUIDForRepoPath returns the owner UID and GID of the repo directory at
8080+// repoPath. scanPath is validated as a prefix to guard against directory escape.
8181+func LookupUIDForRepoPath(scanPath, repoPath string) (uid uint32, gid uint32, err error) {
8282+ if !strings.HasPrefix(repoPath, scanPath) {
8383+ return 0, 0, fmt.Errorf("repo path %q is outside scan path %q", repoPath, scanPath)
8484+ }
8585+ var stat syscall.Stat_t
8686+ if err := syscall.Stat(repoPath, &stat); err != nil {
8787+ return 0, 0, err
8888+ }
8989+ return stat.Uid, stat.Gid, nil
9090+}
9191+9292+// ServiceGid returns the GID of scanPath, which is treated as the "service
9393+// group" that owns all repositories. Callers chown repo trees to
9494+// (virtualUID, ServiceGid(scanPath)) so the knot service (a member of this
9595+// group) retains read+write access via the group bits set by ChmodRepoTree.
9696+func ServiceGid(scanPath string) (uint32, error) {
9797+ var stat syscall.Stat_t
9898+ if err := syscall.Stat(scanPath, &stat); err != nil {
9999+ return 0, fmt.Errorf("stat %s: %w", scanPath, err)
100100+ }
101101+ return stat.Gid, nil
102102+}
···11+package sandboxexec
22+33+import (
44+ "context"
55+ "fmt"
66+ "os"
77+88+ "github.com/urfave/cli/v3"
99+)
1010+1111+// Command returns the hidden sandbox-exec subcommand used by LandlockBackend.
1212+//
1313+// landlock_restrict_self only restricts the calling OS thread, so it cannot be
1414+// called from a goroutine (the Go scheduler may migrate the goroutine across
1515+// threads). The workaround is to re-exec the knot binary with this subcommand,
1616+// which runs single-threaded before the Go runtime starts its thread pool,
1717+// applies the ruleset, then exec's into the target git process.
1818+func Command() *cli.Command {
1919+ return &cli.Command{
2020+ Name: "sandbox-exec",
2121+ Hidden: true,
2222+ Usage: "apply landlock sandbox and exec into git (internal use only)",
2323+ Action: Run,
2424+ Flags: []cli.Flag{
2525+ &cli.StringSliceFlag{
2626+ Name: "repo-path",
2727+ Usage: "repository path(s) to allow read/write access to",
2828+ },
2929+ },
3030+ }
3131+}
3232+3333+func Run(ctx context.Context, cmd *cli.Command) error {
3434+ repoPaths := cmd.StringSlice("repo-path")
3535+ gitArgs := cmd.Args().Slice()
3636+3737+ if len(gitArgs) == 0 {
3838+ fmt.Fprintln(os.Stderr, "sandbox-exec: no command specified after --")
3939+ os.Exit(1)
4040+ }
4141+4242+ return applyAndExec(repoPaths, gitArgs)
4343+}