diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 962620d..d8726e1 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -145,6 +145,8 @@ type runner struct { verbose bool shareDirCreated bool keepContainers bool + selinuxRelabel bool + selinuxChecked bool logger *log.Logger mountState *mountState @@ -1843,7 +1845,7 @@ func (r *runner) launchTargetContainer(ctx context.Context, stopSignal string) e "NODE_OPTIONS=--use-openssl-ca", } args = append(args, - "-v", fmt.Sprintf("%s:%s", r.cfg.shareDir, leashPublicMount), + "-v", r.internalBindMountSpec(r.cfg.shareDir, leashPublicMount, ""), "-v", fmt.Sprintf("%s:%s", r.cfg.callerDir, r.cfg.callerDir), "-w", r.cfg.callerDir, "-e", fmt.Sprintf("LEASH_DIR=%s", leashPublicMount), @@ -1973,6 +1975,72 @@ func (r *runner) detectImageArch(ctx context.Context) (string, error) { return normalizeArch(out) } +func selinuxEnabled() bool { + const enforcePath = "/sys/fs/selinux/enforce" + data, err := os.ReadFile(enforcePath) + if err != nil { + return false + } + // Only canonical enforcing/permissive values indicate a usable SELinux mount. + // Treat unknown content as disabled to avoid forcing relabel options on + // hosts where detection is ambiguous. + mode := strings.TrimSpace(string(data)) + return mode == "0" || mode == "1" +} + +func withSELinuxRelabelMode(mode string) string { + mode = strings.TrimSpace(mode) + if mode == "" { + return "z" + } + for _, option := range strings.Split(mode, ",") { + trimmed := strings.TrimSpace(option) + if strings.EqualFold(trimmed, "z") { + return mode + } + } + return mode + ",z" +} + +func (r *runner) shouldRelabelInternalMounts() bool { + if r.selinuxChecked { + return r.selinuxRelabel + } + r.selinuxChecked = true + if r.cfg.hostOS != "linux" { + return false + } + if !selinuxEnabled() { + return false + } + r.selinuxRelabel = true + r.debugf("SELinux detected; applying :z to leash-managed bind mounts") + return true +} + +func (r *runner) shouldRelabelMountPath(host string) bool { + if !r.shouldRelabelInternalMounts() { + return false + } + workDir := filepath.Clean(strings.TrimSpace(r.cfg.workDir)) + if workDir == "" || workDir == "." { + return false + } + host = filepath.Clean(strings.TrimSpace(host)) + return host == workDir || strings.HasPrefix(host, workDir+string(os.PathSeparator)) +} + +func (r *runner) internalBindMountSpec(host, container, mode string) string { + mountMode := strings.TrimSpace(mode) + if r.shouldRelabelMountPath(host) { + mountMode = withSELinuxRelabelMode(mountMode) + } + if mountMode == "" { + return fmt.Sprintf("%s:%s", host, container) + } + return fmt.Sprintf("%s:%s:%s", host, container, mountMode) +} + func normalizeArch(raw string) (string, error) { switch strings.ToLower(strings.TrimSpace(raw)) { case "amd64", "x86_64": @@ -2026,10 +2094,10 @@ func (r *runner) launchLeashContainer(ctx context.Context, cgroupPath string) er "--cgroupns=host", // Use host cgroup namespace for iptables cgroup matching "--network", fmt.Sprintf("container:%s", r.cfg.targetContainer), "-v", "/sys/fs/cgroup:/sys/fs/cgroup:ro", - "-v", fmt.Sprintf("%s:/log", r.cfg.logDir), - "-v", fmt.Sprintf("%s:/cfg", r.cfg.cfgDir), - "-v", fmt.Sprintf("%s:%s", r.cfg.shareDir, leashPublicMount), - "-v", fmt.Sprintf("%s:%s", r.cfg.privateDir, leashPrivateMount), + "-v", r.internalBindMountSpec(r.cfg.logDir, "/log", ""), + "-v", r.internalBindMountSpec(r.cfg.cfgDir, "/cfg", ""), + "-v", r.internalBindMountSpec(r.cfg.shareDir, leashPublicMount, ""), + "-v", r.internalBindMountSpec(r.cfg.privateDir, leashPrivateMount, ""), "-e", fmt.Sprintf("LEASH_PROXY_PORT=%s", r.cfg.proxyPort), "-e", fmt.Sprintf("LEASH_LISTEN=%s", r.cfg.listenCfg.Address()), "-e", "LEASH_LOG=/log/events.log", diff --git a/internal/runner/runner_selinux_test.go b/internal/runner/runner_selinux_test.go new file mode 100644 index 0000000..1cce810 --- /dev/null +++ b/internal/runner/runner_selinux_test.go @@ -0,0 +1,199 @@ +package runner + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/strongdm/leash/internal/configstore" + "github.com/strongdm/leash/internal/leashd/listen" +) + +func TestWithSELinuxRelabelMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + {name: "empty", in: "", want: "z"}, + {name: "rw", in: "rw", want: "rw,z"}, + {name: "ro with other options", in: "ro,delegated", want: "ro,delegated,z"}, + {name: "already z", in: "rw,z", want: "rw,z"}, + {name: "already Z", in: "ro,Z", want: "ro,Z"}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := withSELinuxRelabelMode(tc.in); got != tc.want { + t.Fatalf("withSELinuxRelabelMode(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestInternalBindMountSpecUsesSELinuxRelabel(t *testing.T) { + t.Parallel() + + r := &runner{ + cfg: config{ + hostOS: "linux", + workDir: "/tmp/leash-work", + }, + } + r.selinuxChecked = true + r.selinuxRelabel = true + + if got, want := r.internalBindMountSpec("/tmp/leash-work/share", "/leash", ""), "/tmp/leash-work/share:/leash:z"; got != want { + t.Fatalf("internalBindMountSpec without mode = %q, want %q", got, want) + } + if got, want := r.internalBindMountSpec("/tmp/leash-work/share", "/leash", "rw"), "/tmp/leash-work/share:/leash:rw,z"; got != want { + t.Fatalf("internalBindMountSpec with mode = %q, want %q", got, want) + } + if got, want := r.internalBindMountSpec("/home/user/project", "/workspace", "rw"), "/home/user/project:/workspace:rw"; got != want { + t.Fatalf("internalBindMountSpec outside workdir = %q, want %q", got, want) + } +} + +func TestLaunchContainersAddSELinuxRelabelToInternalMounts(t *testing.T) { + t.Parallel() + mountStateTestMu.Lock() + t.Cleanup(mountStateTestMu.Unlock) + + workDir := t.TempDir() + shareDir := filepath.Join(workDir, "share") + privateDir := filepath.Join(workDir, "private") + if err := os.MkdirAll(privateDir, 0o700); err != nil { + t.Fatalf("failed to create private dir: %v", err) + } + logDir := filepath.Join(workDir, "log") + cfgDir := filepath.Join(workDir, "cfg") + callerDir := t.TempDir() + if err := os.MkdirAll(shareDir, 0o755); err != nil { + t.Fatalf("failed to create share dir: %v", err) + } + if err := os.MkdirAll(logDir, 0o755); err != nil { + t.Fatalf("failed to create log dir: %v", err) + } + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("failed to create cfg dir: %v", err) + } + + type recorded struct { + name string + args []string + } + var ( + mu sync.Mutex + commands []recorded + origRun = runCommand + origCommand = commandOutput + ) + runCommand = func(_ context.Context, name string, args ...string) error { + mu.Lock() + defer mu.Unlock() + copied := make([]string, len(args)) + copy(copied, args) + commands = append(commands, recorded{name: name, args: copied}) + return nil + } + commandOutput = func(_ context.Context, name string, args ...string) (string, error) { + switch { + case name == "docker" && len(args) >= 3 && args[0] == "inspect" && args[1] == "--format" && strings.Contains(args[2], "{{.Architecture}}"): + return "amd64\n", nil + default: + return "", fmt.Errorf("unexpected commandOutput call: %s %s", name, strings.Join(args, " ")) + } + } + t.Cleanup(func() { + runCommand = origRun + commandOutput = origCommand + }) + + hostRoot := t.TempDir() + autoHost := filepath.Join(hostRoot, ".codex") + if err := os.Mkdir(autoHost, 0o755); err != nil { + t.Fatalf("mkdir auto host dir: %v", err) + } + + r := &runner{ + logger: log.New(ioDiscard{}, "", 0), + cfg: config{ + hostOS: "linux", + workDir: workDir, + shareDir: shareDir, + privateDir: privateDir, + logDir: logDir, + cfgDir: cfgDir, + callerDir: callerDir, + targetImage: "example/target:latest", + leashImage: "example/leash:latest", + targetContainer: "leash-target-123", + leashContainer: "leash-manager-123", + proxyPort: "18000", + bootstrapTimeout: 30 * time.Second, + listenCfg: listen.Default(), + }, + opts: options{ + command: []string{"bash"}, + }, + mountState: &mountState{ + command: "codex", + mounts: []configstore.Mount{ + {Host: autoHost, Container: "/root/.codex", Mode: "rw", Scope: configstore.ScopeGlobal}, + }, + }, + } + r.verbose = true + r.selinuxChecked = true + r.selinuxRelabel = true + + if err := r.launchTargetContainer(context.Background(), "SIGTERM"); err != nil { + t.Fatalf("target launch failed: %v", err) + } + if err := r.launchLeashContainer(context.Background(), "/sys/fs/cgroup/unified"); err != nil { + t.Fatalf("leash launch failed: %v", err) + } + + mu.Lock() + if len(commands) != 2 { + t.Fatalf("expected 2 docker runs, got %d", len(commands)) + } + targetArgs := commands[0].args + leashArgs := commands[1].args + mu.Unlock() + + if !containsArg(targetArgs, fmt.Sprintf("%s:%s:z", shareDir, leashPublicMount)) { + t.Fatalf("target container missing relabeled share mount, args=%v", targetArgs) + } + if !containsArg(targetArgs, fmt.Sprintf("%s:%s", callerDir, callerDir)) { + t.Fatalf("target container missing caller mount, args=%v", targetArgs) + } + if !containsArg(targetArgs, fmt.Sprintf("%s:%s:rw", autoHost, "/root/.codex")) { + t.Fatalf("target container unexpectedly relabeled auto mount, args=%v", targetArgs) + } + if !containsArg(leashArgs, fmt.Sprintf("%s:%s:z", logDir, "/log")) { + t.Fatalf("leash container missing relabeled log mount, args=%v", leashArgs) + } + if !containsArg(leashArgs, fmt.Sprintf("%s:%s:z", cfgDir, "/cfg")) { + t.Fatalf("leash container missing relabeled cfg mount, args=%v", leashArgs) + } + if !containsArg(leashArgs, fmt.Sprintf("%s:%s:z", shareDir, leashPublicMount)) { + t.Fatalf("leash container missing relabeled share mount, args=%v", leashArgs) + } + if !containsArg(leashArgs, fmt.Sprintf("%s:%s:z", privateDir, leashPrivateMount)) { + t.Fatalf("leash container missing relabeled private mount, args=%v", leashArgs) + } + if !containsArg(leashArgs, "/sys/fs/cgroup:/sys/fs/cgroup:ro") { + t.Fatalf("leash container unexpectedly changed /sys/fs/cgroup mount, args=%v", leashArgs) + } +}