初始化仓库
This commit is contained in:
+267
@@ -0,0 +1,267 @@
|
||||
//go:build !windows
|
||||
|
||||
// Package core — runas.go provides the spawn-as-different-Unix-user primitive
|
||||
// used when a project sets `run_as_user` in config.toml.
|
||||
//
|
||||
// # Mechanism
|
||||
//
|
||||
// We intentionally spawn via:
|
||||
//
|
||||
// sudo -n -iu <target-user> -- <command> [args...]
|
||||
//
|
||||
// The flags are load-bearing and should NOT be "simplified":
|
||||
//
|
||||
// - -n (non-interactive): never prompt for a password. If passwordless
|
||||
// sudo to the target user is not configured, fail loudly instead of
|
||||
// hanging on a prompt that nobody will ever see.
|
||||
//
|
||||
// - -i (simulate initial login): run the target user's full login shell,
|
||||
// loading their ~/.profile / ~/.bashrc, setting HOME to their home
|
||||
// directory, and clearing the supervisor's environment. This is what
|
||||
// makes the spawned process a "real session as that user" — their
|
||||
// ~/.claude/settings.json, their PGSSL certs, their plugin state.
|
||||
//
|
||||
// - -u <target-user>: the target uid. Must be a specific username; the
|
||||
// sudoers rule that allows this should be scoped to this user only,
|
||||
// not ALL.
|
||||
//
|
||||
// - -- : end of sudo options. Everything after this is the command to run
|
||||
// as the target user. Prevents an argv element that starts with "-"
|
||||
// from being reinterpreted as a sudo flag.
|
||||
//
|
||||
// Alternatives that are NOT used, with reasons:
|
||||
//
|
||||
// - setuid(): loses the target user's shell profile entirely. No
|
||||
// ~/.bashrc, no ~/.profile, no login env. Also has to be done before
|
||||
// exec, which means the supervisor process needs CAP_SETUID or to be
|
||||
// running as root — strictly worse than sudo on both fronts.
|
||||
//
|
||||
// - su - <target>: interactive-only on many distros (no -c equivalent
|
||||
// for a non-shell argv), and it consults PAM differently from sudo,
|
||||
// making the "passwordless" surface harder to reason about.
|
||||
//
|
||||
// - sudo -u <target> (without -i): preserves the supervisor's cwd and
|
||||
// most of its environment. This leaks the supervisor's HOME and any
|
||||
// unset-by-default env vars, which defeats the isolation story.
|
||||
//
|
||||
// # Environment handling
|
||||
//
|
||||
// When RunAsUser is set, the supervisor's environment is NOT forwarded to
|
||||
// the target user. Only variables on the explicit allowlist are passed
|
||||
// through via `sudo --preserve-env=VAR1,VAR2`. The default allowlist is
|
||||
// intentionally minimal; anything else should live in the target user's
|
||||
// own shell profile or ~/.claude/settings.json.
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// currentUsername returns the current Unix login name, or "" if it can't
|
||||
// be determined. Used by runas_check.go when building example sudoers
|
||||
// snippets in error messages.
|
||||
func currentUsername() string {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// DefaultEnvAllowlist is the minimal env preserved across the sudo
|
||||
// boundary. Deliberately excluded:
|
||||
// - HOME / USER / LOGNAME / SHELL — sudo -i overrides them
|
||||
// - PWD — set by cmd.Dir
|
||||
// - PATH — sudo -i builds it from the target user's /etc/profile +
|
||||
// ~/.profile. Preserving the supervisor's PATH would (a) leak
|
||||
// supervisor work directories into the isolated session and
|
||||
// (b) defeat the whole point of -i by overriding the login shell's
|
||||
// PATH. If the target user needs specific binaries on PATH, put
|
||||
// them in the system PATH (e.g. /usr/local/bin symlinks) or in
|
||||
// the target user's own shell profile.
|
||||
// - anything secret
|
||||
var DefaultEnvAllowlist = []string{
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"LC_MESSAGES",
|
||||
"TERM",
|
||||
}
|
||||
|
||||
// SpawnOptions controls how a command is spawned. Zero value = legacy
|
||||
// supervisor-user spawn. Non-empty RunAsUser triggers sudo wrapping.
|
||||
type SpawnOptions struct {
|
||||
RunAsUser string
|
||||
EnvAllowlist []string // extends DefaultEnvAllowlist, not a replacement
|
||||
}
|
||||
|
||||
func (o SpawnOptions) IsolationMode() bool {
|
||||
return o.RunAsUser != ""
|
||||
}
|
||||
|
||||
func (o SpawnOptions) mergedAllowlist() []string {
|
||||
seen := make(map[string]struct{}, len(DefaultEnvAllowlist)+len(o.EnvAllowlist))
|
||||
for _, v := range DefaultEnvAllowlist {
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
for _, v := range o.EnvAllowlist {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for v := range seen {
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildSpawnCommand returns an *exec.Cmd that either invokes name/args
|
||||
// directly (legacy) or wraps them in `sudo -n -iu <user> --preserve-env=... -- name args...`.
|
||||
//
|
||||
// Does NOT run the per-spawn re-check — callers should invoke
|
||||
// VerifyRunAsUserCheap immediately before Start() so a sudoers edit
|
||||
// between startup preflight and spawn is caught.
|
||||
func BuildSpawnCommand(ctx context.Context, opts SpawnOptions, name string, args ...string) *exec.Cmd {
|
||||
if !opts.IsolationMode() {
|
||||
return exec.CommandContext(ctx, name, args...)
|
||||
}
|
||||
sudoArgs := []string{
|
||||
"-n",
|
||||
"-iu", opts.RunAsUser,
|
||||
"--preserve-env=" + strings.Join(opts.mergedAllowlist(), ","),
|
||||
"--",
|
||||
name,
|
||||
}
|
||||
sudoArgs = append(sudoArgs, args...)
|
||||
return exec.CommandContext(ctx, "sudo", sudoArgs...)
|
||||
}
|
||||
|
||||
// FilterEnvForSpawn strips env down to the merged allowlist when
|
||||
// opts.IsolationMode() is true. Belt-and-braces with sudo's own
|
||||
// --preserve-env, but having cc-connect's spawn argv be the single
|
||||
// source of truth keeps test assertions clean.
|
||||
func FilterEnvForSpawn(env []string, opts SpawnOptions) []string {
|
||||
if !opts.IsolationMode() {
|
||||
return env
|
||||
}
|
||||
allow := opts.mergedAllowlist()
|
||||
allowSet := make(map[string]struct{}, len(allow))
|
||||
for _, v := range allow {
|
||||
allowSet[v] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(env))
|
||||
for _, e := range env {
|
||||
eq := strings.IndexByte(e, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowSet[e[:eq]]; ok {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SudoRunner runs `sudo <args...>` and returns combined output. Tests
|
||||
// inject a stub; production uses ExecSudoRunner.
|
||||
type SudoRunner interface {
|
||||
Run(ctx context.Context, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
type ExecSudoRunner struct{}
|
||||
|
||||
func (ExecSudoRunner) Run(ctx context.Context, args ...string) ([]byte, error) {
|
||||
return exec.CommandContext(ctx, "sudo", args...).CombinedOutput()
|
||||
}
|
||||
|
||||
// VerifyRunAsUserCheap runs the two cheap preflight checks that must pass
|
||||
// before every spawn, not just at startup:
|
||||
//
|
||||
// 1. `sudo -n -iu <user> -- /usr/bin/true` must succeed — the supervisor still
|
||||
// has passwordless sudo to the target user.
|
||||
// 2. `sudo -n -iu <user> -- sudo -n /usr/bin/true` must FAIL — the target user
|
||||
// cannot non-interactively escalate.
|
||||
//
|
||||
// Returns nil if both checks behave as expected. Results are cached for
|
||||
// verifyCacheTTL keyed by runAsUser so rapid-fire messages don't pay the
|
||||
// ~100ms cost per spawn. A failure evicts the cache immediately so the
|
||||
// next spawn re-verifies fresh.
|
||||
//
|
||||
// The expensive checks (work_dir access, isolation probe) live in the
|
||||
// preflight and audit packages and only run at startup / via `cc-connect
|
||||
// doctor user-isolation`.
|
||||
func VerifyRunAsUserCheap(ctx context.Context, runner SudoRunner, runAsUser string) error {
|
||||
if runAsUser == "" {
|
||||
return errors.New("VerifyRunAsUserCheap: runAsUser is empty")
|
||||
}
|
||||
if verifyCacheHit(runAsUser) {
|
||||
return nil
|
||||
}
|
||||
if out, err := runner.Run(ctx, "-n", "-iu", runAsUser, "--", "/usr/bin/true"); err != nil {
|
||||
verifyCacheEvict(runAsUser)
|
||||
return fmt.Errorf("passwordless sudo to user %q failed (check that your sudoers rule is present and scoped to this user): %w: %s", runAsUser, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
out, err := runner.Run(ctx, "-n", "-iu", runAsUser, "--", "sudo", "-n", "/usr/bin/true")
|
||||
if err == nil {
|
||||
verifyCacheEvict(runAsUser)
|
||||
return fmt.Errorf("target user %q can run passwordless sudo; isolation is meaningless. Remove NOPASSWD sudo for this user. Output: %s", runAsUser, strings.TrimSpace(string(out)))
|
||||
}
|
||||
verifyCacheStore(runAsUser)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyCacheTTL is short by design. It absorbs a burst of messages
|
||||
// (one Slack user typing rapidly) while still re-verifying often enough
|
||||
// that a sudoers edit during a long idle gap is caught on the next spawn.
|
||||
const verifyCacheTTL = 30 * time.Second
|
||||
|
||||
var (
|
||||
verifyCacheMu sync.Mutex
|
||||
verifyCache = map[string]time.Time{}
|
||||
)
|
||||
|
||||
func verifyCacheHit(user string) bool {
|
||||
verifyCacheMu.Lock()
|
||||
defer verifyCacheMu.Unlock()
|
||||
expires, ok := verifyCache[user]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(expires) {
|
||||
delete(verifyCache, user)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func verifyCacheStore(user string) {
|
||||
verifyCacheMu.Lock()
|
||||
defer verifyCacheMu.Unlock()
|
||||
verifyCache[user] = time.Now().Add(verifyCacheTTL)
|
||||
}
|
||||
|
||||
func verifyCacheEvict(user string) {
|
||||
verifyCacheMu.Lock()
|
||||
defer verifyCacheMu.Unlock()
|
||||
delete(verifyCache, user)
|
||||
}
|
||||
|
||||
// ResetVerifyCache clears all cached positive verification results. Used
|
||||
// by tests and available for any caller that wants to force a re-check
|
||||
// on the next spawn (e.g. after reloading sudoers).
|
||||
func ResetVerifyCache() {
|
||||
verifyCacheMu.Lock()
|
||||
defer verifyCacheMu.Unlock()
|
||||
verifyCache = map[string]time.Time{}
|
||||
}
|
||||
Reference in New Issue
Block a user