mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
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>
238 lines
8.1 KiB
Go
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")
|
|
}
|