//go:build !windows package core import ( "context" "errors" "os/exec" "reflect" "slices" "strings" "testing" ) func TestBuildSpawnCommand_Legacy(t *testing.T) { ctx := context.Background() cmd := BuildSpawnCommand(ctx, SpawnOptions{}, "claude", "--version") if cmd == nil { t.Fatal("BuildSpawnCommand returned nil") } if cmd.Path == "" || !strings.HasSuffix(cmd.Args[0], "claude") { t.Fatalf("legacy spawn: want path ending in claude, got %q (args %v)", cmd.Path, cmd.Args) } if len(cmd.Args) != 2 || cmd.Args[1] != "--version" { t.Fatalf("legacy spawn: unexpected args %v", cmd.Args) } } func TestBuildSpawnCommand_RunAsUser(t *testing.T) { ctx := context.Background() opts := SpawnOptions{ RunAsUser: "partseeker-coder", EnvAllowlist: []string{"PGSSLROOTCERT", "PGSSLMODE"}, } cmd := BuildSpawnCommand(ctx, opts, "claude", "--version", "-p", "hello") if cmd == nil { t.Fatal("BuildSpawnCommand returned nil") } if !strings.HasSuffix(cmd.Args[0], "sudo") { t.Fatalf("want argv[0] ending in sudo, got %q", cmd.Args[0]) } // Expected: sudo -n -iu partseeker-coder --preserve-env= -- claude --version -p hello want := []string{"-n", "-iu", "partseeker-coder"} if !reflect.DeepEqual(cmd.Args[1:4], want) { t.Fatalf("sudo args[1:4] = %v, want %v", cmd.Args[1:4], want) } if !strings.HasPrefix(cmd.Args[4], "--preserve-env=") { t.Fatalf("expected --preserve-env= at args[4], got %q", cmd.Args[4]) } // Allowlist must include both defaults and the extensions, sorted+deduped. preserveList := strings.TrimPrefix(cmd.Args[4], "--preserve-env=") preserved := strings.Split(preserveList, ",") for _, needed := range []string{"LANG", "LC_ALL", "TERM", "PGSSLROOTCERT", "PGSSLMODE"} { if !slices.Contains(preserved, needed) { t.Errorf("preserve-env missing %q; got %v", needed, preserved) } } // PATH must NOT be in the allowlist — sudo -i rebuilds it from the // target user's login profile, and preserving the supervisor's PATH // would leak supervisor work dirs into the isolated session. if slices.Contains(preserved, "PATH") { t.Errorf("preserve-env leaked PATH; got %v", preserved) } if cmd.Args[5] != "--" { t.Fatalf("args[5] = %q, want --", cmd.Args[5]) } if cmd.Args[6] != "claude" { t.Fatalf("args[6] = %q, want claude", cmd.Args[6]) } if !reflect.DeepEqual(cmd.Args[7:], []string{"--version", "-p", "hello"}) { t.Fatalf("agent args = %v, want [--version -p hello]", cmd.Args[7:]) } } func TestFilterEnvForSpawn_Legacy(t *testing.T) { env := []string{"PATH=/usr/bin", "SECRET=top", "PWD=/tmp"} got := FilterEnvForSpawn(env, SpawnOptions{}) if !reflect.DeepEqual(got, env) { t.Fatalf("legacy mode should pass env through unchanged; got %v, want %v", got, env) } } func TestFilterEnvForSpawn_RunAsUser(t *testing.T) { env := []string{ "PATH=/usr/bin", "LANG=en_US.UTF-8", "SECRET=top", "HOME=/home/supervisor", "SUPERVISOR_CREDENTIAL=nope", "PGSSLROOTCERT=/etc/certs/root.crt", } opts := SpawnOptions{ RunAsUser: "target", EnvAllowlist: []string{"PGSSLROOTCERT"}, } got := FilterEnvForSpawn(env, opts) // LANG, PGSSLROOTCERT should survive; PATH, SECRET, HOME, // SUPERVISOR_CREDENTIAL must not. PATH is deliberately dropped so // sudo -i can rebuild it from the target user's login profile // instead of inheriting the supervisor's PATH. wantKept := map[string]bool{ "LANG=en_US.UTF-8": true, "PGSSLROOTCERT=/etc/certs/root.crt": true, } for _, e := range got { if !wantKept[e] { t.Errorf("unexpected env survived filter: %q", e) } delete(wantKept, e) } for e := range wantKept { t.Errorf("expected env missing after filter: %q", e) } for _, e := range got { if strings.HasPrefix(e, "PATH=") || strings.HasPrefix(e, "SECRET=") || strings.HasPrefix(e, "HOME=") || strings.HasPrefix(e, "SUPERVISOR_CREDENTIAL=") { t.Errorf("disallowed env leaked: %q", e) } } } // stubSudoRunner implements SudoRunner for tests. type stubSudoRunner struct { // script maps an argv slice (joined with \x1f) to a response. script map[string]stubResponse // calls records every invocation in order. calls [][]string } type stubResponse struct { out []byte err error } func (s *stubSudoRunner) Run(_ context.Context, args ...string) ([]byte, error) { s.calls = append(s.calls, append([]string{}, args...)) key := strings.Join(args, "\x1f") if r, ok := s.script[key]; ok { return r.out, r.err } return nil, errors.New("stubSudoRunner: unscripted call: " + strings.Join(args, " ")) } func key(args ...string) string { return strings.Join(args, "\x1f") } func TestVerifyRunAsUserCheap_Success(t *testing.T) { ResetVerifyCache() runner := &stubSudoRunner{ script: map[string]stubResponse{ key("-n", "-iu", "target", "--", "/usr/bin/true"): {nil, nil}, key("-n", "-iu", "target", "--", "sudo", "-n", "/usr/bin/true"): {[]byte("a password is required"), &exec.ExitError{}}, }, } if err := VerifyRunAsUserCheap(context.Background(), runner, "target"); err != nil { t.Fatalf("want nil, got %v", err) } if len(runner.calls) != 2 { t.Fatalf("want 2 sudo calls, got %d", len(runner.calls)) } } func TestVerifyRunAsUserCheap_NoPasswordlessSudoToTarget(t *testing.T) { ResetVerifyCache() runner := &stubSudoRunner{ script: map[string]stubResponse{ key("-n", "-iu", "target", "--", "/usr/bin/true"): {[]byte("a password is required"), &exec.ExitError{}}, }, } err := VerifyRunAsUserCheap(context.Background(), runner, "target") if err == nil { t.Fatal("want error, got nil") } if !strings.Contains(err.Error(), "passwordless sudo to user") { t.Errorf("error = %v, want 'passwordless sudo to user' message", err) } } func TestVerifyRunAsUserCheap_TargetCanEscalate(t *testing.T) { ResetVerifyCache() runner := &stubSudoRunner{ script: map[string]stubResponse{ key("-n", "-iu", "target", "--", "/usr/bin/true"): {nil, nil}, key("-n", "-iu", "target", "--", "sudo", "-n", "/usr/bin/true"): {nil, nil}, // BAD — escalation succeeded }, } err := VerifyRunAsUserCheap(context.Background(), runner, "target") if err == nil { t.Fatal("want error, got nil") } if !strings.Contains(err.Error(), "can run passwordless sudo") { t.Errorf("error = %v, want 'can run passwordless sudo' message", err) } } func TestVerifyRunAsUserCheap_EmptyUser(t *testing.T) { ResetVerifyCache() runner := &stubSudoRunner{script: map[string]stubResponse{}} if err := VerifyRunAsUserCheap(context.Background(), runner, ""); err == nil { t.Fatal("want error for empty user") } } func TestVerifyRunAsUserCheap_CacheHit(t *testing.T) { ResetVerifyCache() runner := &stubSudoRunner{ script: map[string]stubResponse{ key("-n", "-iu", "target", "--", "/usr/bin/true"): {nil, nil}, key("-n", "-iu", "target", "--", "sudo", "-n", "/usr/bin/true"): {nil, &exec.ExitError{}}, }, } // First call populates the cache with 2 runner calls. if err := VerifyRunAsUserCheap(context.Background(), runner, "target"); err != nil { t.Fatalf("first call: %v", err) } if len(runner.calls) != 2 { t.Fatalf("first call: want 2 runner calls, got %d", len(runner.calls)) } // Second call should be served from the cache — zero new runner calls. if err := VerifyRunAsUserCheap(context.Background(), runner, "target"); err != nil { t.Fatalf("second call: %v", err) } if len(runner.calls) != 2 { t.Fatalf("cached call made runner calls; want 2 total, got %d", len(runner.calls)) } }