Files
chenhg5-cc-connect/core/runas_check.go
cg33 f3071ad632 fix(core): use /usr/bin/true instead of /bin/true for macOS compatibility (#737)
macOS does not have /bin/true — it lives at /usr/bin/true. Linux has
both paths, so switching to /usr/bin/true fixes the doctor user-isolation
check on macOS without breaking Linux.

Closes #735

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 15:06:17 +08:00

238 lines
8.1 KiB
Go

//go:build !windows
package core
// runas_check.go — startup-time preflight gates for run_as_user.
//
// These are the hard go/no-go checks described in issue #496. They are
// intentionally more expensive than VerifyRunAsUserCheap (which only runs
// the two sudo probes) because they also touch the filesystem and walk the
// project's work_dir looking for permission problems the target user would
// hit at runtime.
//
// Use PreflightRunAsUser at cc-connect startup, in parallel across all
// projects, and refuse to start the daemon if any project returns any
// fatal error. Warnings are surfaced via slog but do not abort startup.
//
// Tests stub the SudoRunner so this file has no tie to an actual sudo
// binary.
import (
"context"
"errors"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
)
// PreflightResult is the outcome of PreflightRunAsUser for one project.
type PreflightResult struct {
Project string
RunAsUser string
Fatal []error
Warnings []string
// SudoListOutput is captured from `sudo -n -l` when check 2 fails,
// so the fatal message can point at the offending sudoers rule.
SudoListOutput string
}
func (r PreflightResult) HasFatal() bool { return len(r.Fatal) > 0 }
type DescendantScanConfig struct {
PrunePaths []string
MaxReport int
Timeout time.Duration
}
// DefaultDescendantScanConfig is the baseline used unless a caller
// overrides it.
var DefaultDescendantScanConfig = DescendantScanConfig{
PrunePaths: []string{
".git", "node_modules", ".venv", "venv", "dist", "build",
"target", ".pytest_cache", "__pycache__", ".next", ".cache",
},
MaxReport: 50,
Timeout: 10 * time.Second,
}
type PreflightConfig struct {
Project string
RunAsUser string
WorkDir string
Runner SudoRunner
ScanConfig DescendantScanConfig
}
// PreflightRunAsUser runs all three startup safety checks for a single
// project. It never panics and never returns nil; instead all problems are
// accumulated into the returned PreflightResult for the caller to aggregate
// and log.
//
// Checks:
//
// 1. Passwordless sudo -iu <target> is configured (fatal if missing).
// 2. Target user has no passwordless sudo (fatal if they can escalate);
// on failure, captures `sudo -n -iu target -- sudo -n -l` output to
// help the operator find the offending rule.
// 3. Target user can read AND write the work_dir root (fatal if not),
// plus a best-effort descendant walk producing warnings for paths
// the target user cannot access.
func PreflightRunAsUser(ctx context.Context, cfg PreflightConfig) PreflightResult {
result := PreflightResult{Project: cfg.Project, RunAsUser: cfg.RunAsUser}
if cfg.RunAsUser == "" {
result.Fatal = append(result.Fatal, errors.New("PreflightRunAsUser: RunAsUser is empty"))
return result
}
if cfg.Runner == nil {
cfg.Runner = ExecSudoRunner{}
}
if cfg.ScanConfig.MaxReport == 0 {
cfg.ScanConfig = DefaultDescendantScanConfig
}
if _, err := cfg.Runner.Run(ctx, "-n", "-iu", cfg.RunAsUser, "--", "/usr/bin/true"); err != nil {
result.Fatal = append(result.Fatal, fmt.Errorf(
"project %q: passwordless sudo to user %q is not configured. Add a sudoers rule such as:\n %s ALL=(%s) NOPASSWD: ALL\nthen restart cc-connect. Underlying error: %w",
cfg.Project, cfg.RunAsUser, currentUsernameOr("<supervisor>"), cfg.RunAsUser, err))
return result // subsequent checks are pointless
}
if _, err := cfg.Runner.Run(ctx, "-n", "-iu", cfg.RunAsUser, "--", "sudo", "-n", "/usr/bin/true"); err == nil {
// Escalation succeeded — collect sudo -l from the target's
// context to help the operator find the offending rule.
if out, listErr := cfg.Runner.Run(ctx, "-n", "-iu", cfg.RunAsUser, "--", "sudo", "-n", "-l"); listErr == nil {
result.SudoListOutput = strings.TrimSpace(string(out))
}
msg := fmt.Sprintf(
"project %q: target user %q can run passwordless sudo. The run_as_user sandbox provides no isolation if the spawned agent can escalate non-interactively. Remove NOPASSWD sudo access for this user before starting cc-connect.",
cfg.Project, cfg.RunAsUser)
if result.SudoListOutput != "" {
msg += "\n\n`sudo -n -l` as " + cfg.RunAsUser + ":\n" + indent(result.SudoListOutput, " ")
}
result.Fatal = append(result.Fatal, errors.New(msg))
// Don't return — still run check 3 so the operator gets all
// the bad news in a single startup attempt.
}
if cfg.WorkDir == "" {
result.Warnings = append(result.Warnings, fmt.Sprintf(
"project %q: no work_dir configured; skipping filesystem access checks", cfg.Project))
} else {
absWorkDir := cfg.WorkDir
if abs, err := filepath.Abs(absWorkDir); err == nil {
absWorkDir = abs
}
if _, err := cfg.Runner.Run(ctx, "-n", "-iu", cfg.RunAsUser, "--", "test", "-r", absWorkDir, "-a", "-w", absWorkDir); err != nil {
result.Fatal = append(result.Fatal, fmt.Errorf(
"project %q: target user %q cannot read AND write work_dir %q. Agents will fail with EACCES at runtime. Fix ownership/permissions on this directory (chown/chmod or an ACL granting the target user rwx) before starting cc-connect.",
cfg.Project, cfg.RunAsUser, absWorkDir))
} else {
warn := scanDescendants(ctx, cfg.Runner, cfg.RunAsUser, absWorkDir, cfg.ScanConfig)
if warn != "" {
result.Warnings = append(result.Warnings, warn)
}
}
}
return result
}
// scanDescendants runs find as the target user under workDir and
// returns a formatted warning string, or "" if nothing is flagged.
// Respects ScanConfig.Timeout. Output format per line is
// "MODE<TAB>PATH" where MODE is noread / nowrite / nosearch.
func scanDescendants(ctx context.Context, runner SudoRunner, target, workDir string, scan DescendantScanConfig) string {
scanCtx, cancel := context.WithTimeout(ctx, scan.Timeout)
defer cancel()
var prune []string
for _, p := range scan.PrunePaths {
if len(prune) > 0 {
prune = append(prune, "-o")
}
prune = append(prune, "-name", p)
}
// find <workDir> \( <prune exprs> \) -prune -o \( -not -readable -printf "noread\t%p\n" , -type f -not -writable -printf "nowrite\t%p\n" , -type d -not -executable -printf "nosearch\t%p\n" \) -print
args := []string{
"-n", "-iu", target, "--",
"find", workDir,
}
if len(prune) > 0 {
args = append(args, "(")
args = append(args, prune...)
args = append(args, ")", "-prune", "-o")
}
args = append(args,
"(",
"-not", "-readable", "-printf", `noread\t%p\n`,
",",
"-type", "f", "-not", "-writable", "-printf", `nowrite\t%p\n`,
",",
"-type", "d", "-not", "-executable", "-printf", `nosearch\t%p\n`,
")",
)
out, err := runner.Run(scanCtx, args...)
if scanCtx.Err() == context.DeadlineExceeded {
return fmt.Sprintf("work_dir descendant scan timed out after %s (large repo?); skipping detailed access audit. Run `cc-connect doctor user-isolation` manually if you need it.", scan.Timeout)
}
// find exits non-zero if it couldn't stat some path — that's
// actually data for us, parse whatever it printed.
_ = err
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
return ""
}
seen := make(map[string]struct{}, len(lines))
var uniq []string
for _, l := range lines {
if l == "" {
continue
}
if _, ok := seen[l]; ok {
continue
}
seen[l] = struct{}{}
uniq = append(uniq, l)
}
sort.Strings(uniq)
if len(uniq) == 0 {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "work_dir %q contains paths that user %q may not access cleanly:\n", workDir, target)
shown := 0
for _, l := range uniq {
if shown >= scan.MaxReport {
break
}
fmt.Fprintf(&b, " %s\n", l)
shown++
}
if len(uniq) > scan.MaxReport {
fmt.Fprintf(&b, " ... and %d more\n", len(uniq)-scan.MaxReport)
}
b.WriteString("\nThe agent may fail with EACCES when accessing these paths. Fix ownership/permissions, narrow the project scope, or accept the risk if the inaccessible paths are intentionally out of bounds.")
return b.String()
}
func currentUsernameOr(fallback string) string {
if u := currentUsername(); u != "" {
return u
}
return fallback
}
func indent(s, prefix string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Join(lines, "\n")
}