···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+}
+45
knotserver/sandbox/sandbox.go
···11+package sandbox
22+33+import "os/exec"
44+55+// Backend wraps git subprocesses in a filesystem sandbox.
66+type Backend interface {
77+ Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error)
88+ WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error)
99+ Name() string
1010+}
1111+1212+// NoopBackend passes commands through unchanged.
1313+type NoopBackend struct{}
1414+1515+func (n *NoopBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) {
1616+ cmd.Dir = repoPath
1717+ return cmd, nil
1818+}
1919+2020+func (n *NoopBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) {
2121+ if len(paths) > 0 {
2222+ cmd.Dir = paths[0]
2323+ }
2424+ return cmd, nil
2525+}
2626+2727+func (n *NoopBackend) Name() string { return "noop" }
2828+2929+// LookupUID resolves a repo path to its owner virtual UID. Used by the sandbox
3030+// to drop privileges before running git. Returning 0 (or any error) means
3131+// don't drop, i.e. the subprocess runs as the calling user.
3232+type LookupUID func(repoPath string) (uid uint32, gid uint32, err error)
3333+3434+// New returns the best available sandboxing backend. If landlock is not
3535+// available, the warning string is non-empty and the backend falls back
3636+// to NoopBackend. lookup is optional; nil means subprocesses keep the
3737+// caller's UID/GID.
3838+func New(lookup LookupUID) (Backend, string) {
3939+ return platformNew(lookup)
4040+}
4141+4242+// Probe returns a human-readable description of sandbox capability on this host.
4343+func Probe() string {
4444+ return platformProbe()
4545+}
+183
knotserver/sandbox/sandbox_linux.go
···11+//go:build linux
22+33+package sandbox
44+55+import (
66+ "errors"
77+ "fmt"
88+ "os"
99+ "os/exec"
1010+ "path/filepath"
1111+ "syscall"
1212+ "unsafe"
1313+1414+ "github.com/landlock-lsm/go-landlock/landlock"
1515+ "golang.org/x/sys/unix"
1616+)
1717+1818+var ErrUnsupportedPlatform = errors.New("no sandbox backend available")
1919+2020+// LandlockBackend uses the Linux Landlock LSM via a re-exec pattern.
2121+// landlock_restrict_self only affects the calling OS thread, so we re-exec
2222+// the binary as "sandbox-exec" which runs single-threaded before exec'ing git.
2323+type LandlockBackend struct {
2424+ selfExe string
2525+ lookup LookupUID
2626+}
2727+2828+func (l *LandlockBackend) Wrap(repoPath string, cmd *exec.Cmd) (*exec.Cmd, error) {
2929+ return l.WrapMulti([]string{repoPath}, cmd)
3030+}
3131+3232+func (l *LandlockBackend) WrapMulti(paths []string, cmd *exec.Cmd) (*exec.Cmd, error) {
3333+ if len(paths) == 0 {
3434+ return cmd, nil
3535+ }
3636+3737+ // resolve the executable to an absolute path now, while $PATH is still
3838+ // intact; the re-exec'd sandbox-exec subprocess inherits the env we pass
3939+ // via cmd.Env, which may not include the wrappers that set up $PATH.
4040+ args := cmd.Args
4141+ if len(args) > 0 {
4242+ if abs, err := exec.LookPath(args[0]); err == nil {
4343+ args = append([]string{abs}, args[1:]...)
4444+ }
4545+ }
4646+4747+ var sandboxArgs []string
4848+ sandboxArgs = append(sandboxArgs, "sandbox-exec")
4949+ for _, p := range paths {
5050+ sandboxArgs = append(sandboxArgs, "--repo-path="+p)
5151+ }
5252+ sandboxArgs = append(sandboxArgs, "--")
5353+ sandboxArgs = append(sandboxArgs, args...)
5454+5555+ wrapped := exec.Command(l.selfExe, sandboxArgs...)
5656+ wrapped.Env = cmd.Env
5757+ wrapped.Dir = paths[0] // kernel chdir's here after setuid, before execve
5858+ wrapped.Stdin = cmd.Stdin
5959+ wrapped.Stdout = cmd.Stdout
6060+ wrapped.Stderr = cmd.Stderr
6161+6262+ // 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.
6464+ //
6565+ // the primary GID is intentionally set to the virtual UID, NOT the
6666+ // repo's group ownership. repo dirs are owned by virtualUID:gitGroup
6767+ // with mode 0770 so the knot service (in gitGroup) can read them, but
6868+ // sandbox subprocesses must not inherit gitGroup or they would gain
6969+ // group access to every other repo and lose cross-owner isolation.
7070+ if l.lookup != nil {
7171+ if uid, _, err := l.lookup(paths[0]); err == nil && uid > 0 {
7272+ wrapped.SysProcAttr = &syscall.SysProcAttr{
7373+ Credential: &syscall.Credential{Uid: uid, Gid: uid, NoSetGroups: true},
7474+ }
7575+ }
7676+ }
7777+7878+ return wrapped, nil
7979+}
8080+8181+func (l *LandlockBackend) Name() string { return "landlock" }
8282+8383+// ApplyLandlock applies a Landlock ruleset to the current process then
8484+// exec's into gitArgs. Called from the hidden "sandbox-exec" subcommand.
8585+func ApplyLandlock(repoPaths []string, gitArgs []string) error {
8686+ if len(gitArgs) == 0 {
8787+ return fmt.Errorf("sandbox-exec: no command specified")
8888+ }
8989+9090+ // collect unique parent directories so git can read global config
9191+ // under $HOME/.config/git/config. repo contents stay DAC-locked
9292+ // (0700) so other repos can't actually be read.
9393+ parents := map[string]struct{}{}
9494+ for _, p := range repoPaths {
9595+ parents[filepath.Dir(p)] = struct{}{}
9696+ }
9797+ parentSlice := make([]string, 0, len(parents))
9898+ for p := range parents {
9999+ parentSlice = append(parentSlice, p)
100100+ }
101101+102102+ // each repo gets full read/write plus REFER (needed for git's quarantine
103103+ // rename in receive-pack, which moves objects across directories).
104104+ repoRules := make([]landlock.Rule, len(repoPaths))
105105+ for i, p := range repoPaths {
106106+ repoRules[i] = landlock.RWDirs(p).WithRefer()
107107+ }
108108+109109+ rules := append([]landlock.Rule{
110110+ // system dirs: read + execute only, no writes
111111+ landlock.RODirs("/usr", "/bin", "/lib", "/lib64", "/nix", "/etc").IgnoreIfMissing(),
112112+ // /dev/null and friends: read/write files + ioctl (V5+ restricts ioctl
113113+ // on device files; WithIoctlDev keeps /dev/null fully accessible)
114114+ landlock.RWFiles("/dev").WithIoctlDev().IgnoreIfMissing(),
115115+ // parent dirs: read + execute so git can traverse to the repo and read
116116+ // global git config; 0700 DAC permissions prevent cross-repo reads
117117+ landlock.RODirs(parentSlice...).IgnoreIfMissing(),
118118+ // /tmp: read/write for temporary patch and object files
119119+ landlock.RWDirs("/tmp").IgnoreIfMissing(),
120120+ }, repoRules...)
121121+122122+ // V8.BestEffort enforces the strongest ruleset the running kernel supports,
123123+ // up to V8. RestrictPaths also sets PR_SET_NO_NEW_PRIVS automatically.
124124+ if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil {
125125+ return fmt.Errorf("sandbox-exec: restrict paths: %w", err)
126126+ }
127127+128128+ gitBin := gitArgs[0]
129129+ if !filepath.IsAbs(gitBin) {
130130+ return fmt.Errorf("sandbox-exec: expected absolute path, got %q", gitBin)
131131+ }
132132+133133+ return unix.Exec(gitBin, gitArgs, os.Environ())
134134+}
135135+136136+func probeLandlock() bool {
137137+ _, err := landlockCreateRuleset(nil, unix.LANDLOCK_CREATE_RULESET_VERSION)
138138+ // EOPNOTSUPP and ENOSYS mean the kernel doesn't support landlock.
139139+ // Any other result (including EINVAL for the nil attr) means it's available.
140140+ return !errors.Is(err, unix.EOPNOTSUPP) && !errors.Is(err, unix.ENOSYS)
141141+}
142142+143143+func platformNew(lookup LookupUID) (Backend, string) {
144144+ if probeLandlock() {
145145+ selfExe, err := os.Readlink("/proc/self/exe")
146146+ if err != nil {
147147+ selfExe = "/proc/self/exe"
148148+ }
149149+ return &LandlockBackend{selfExe: selfExe, lookup: lookup}, ""
150150+ }
151151+152152+ return &NoopBackend{}, "landlock unavailable (kernel < 5.13); git subprocesses run unsandboxed"
153153+}
154154+155155+func platformProbe() string {
156156+ if probeLandlock() {
157157+ return "landlock available (kernel >= 5.13)"
158158+ }
159159+ return "no sandbox backend available (kernel < 5.13)"
160160+}
161161+162162+// landlockCreateRuleset wraps the landlock_create_ruleset(2) syscall.
163163+// Pass attr=nil and flags=LANDLOCK_CREATE_RULESET_VERSION to query ABI version.
164164+// Used only for the non-destructive probe in probeLandlock; all ruleset
165165+// construction is handled by go-landlock.
166166+func landlockCreateRuleset(attr *unix.LandlockRulesetAttr, flags uint) (int, error) {
167167+ var attrPtr unsafe.Pointer
168168+ var attrSize uintptr
169169+ if attr != nil {
170170+ attrPtr = unsafe.Pointer(attr)
171171+ attrSize = unsafe.Sizeof(*attr)
172172+ }
173173+ fd, _, errno := unix.Syscall(
174174+ unix.SYS_LANDLOCK_CREATE_RULESET,
175175+ uintptr(attrPtr),
176176+ attrSize,
177177+ uintptr(flags),
178178+ )
179179+ if errno != 0 {
180180+ return 0, errno
181181+ }
182182+ return int(fd), nil
183183+}
+15
knotserver/sandbox/sandbox_other.go
···11+//go:build !linux
22+33+package sandbox
44+55+import "fmt"
66+77+var ErrUnsupportedPlatform = fmt.Errorf("sandboxing is only supported on Linux")
88+99+func platformNew(_ LookupUID) (Backend, string) {
1010+ return &NoopBackend{}, "sandboxing is not supported on this platform (Linux only)"
1111+}
1212+1313+func platformProbe() string {
1414+ return "sandboxing not supported (Linux only)"
1515+}
···11+//go:build !linux
22+33+package sandboxexec
44+55+import "fmt"
66+77+func applyAndExec(repoPaths, gitArgs []string) error {
88+ return fmt.Errorf("sandbox-exec is only supported on Linux")
99+}
+43
knotserver/sandbox/sandboxexec/sandboxexec.go
···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+}
+6
nix/gomod2nix.toml
···548548 [mod."github.com/labstack/gommon"]
549549 version = "v0.4.1"
550550 hash = "sha256-qfjV9jmtR8I7gC7/Hm02XDbuVLX8UkRNi3wPer8Jkm4="
551551+ [mod."github.com/landlock-lsm/go-landlock"]
552552+ version = "v0.8.1"
553553+ hash = "sha256-7H6/LBmv/d4vDIfZcJ0PeNiNU2PInkw29aQU1yHWxsU="
551554 [mod."github.com/lucasb-eyer/go-colorful"]
552555 version = "v1.3.0"
553556 hash = "sha256-6BKrJsfmxie+YFAWzTYVPQfrwjQEXRo+J8LY+50C1BU="
···846849 [mod."gotest.tools/v3"]
847850 version = "v3.5.2"
848851 hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE="
852852+ [mod."kernel.org/pub/linux/libs/security/libcap/psx"]
853853+ version = "v1.2.77"
854854+ hash = "sha256-oqlAG5XMkQ4toFSIbqGg+2biuw+IyNrogcMralnmvfA="
849855 [mod."lukechampine.com/blake3"]
850856 version = "v1.4.1"
851857 hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc="