299 lines
8.4 KiB
Go
299 lines
8.4 KiB
Go
package exec
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
// TerminalExecutor is the default command Provider. It runs allowlisted commands
|
|
// inside a grant-controlled terminal sandbox (DevDocs/design/04-运行时.md §8):
|
|
// command allowlist, working-directory confinement, environment allowlist,
|
|
// timeout, and output truncation. GUI and virtual-machine providers are reserved
|
|
// for later milestones and surface only through the Provider interface.
|
|
type TerminalExecutor struct {
|
|
cfg config.ExecSandbox
|
|
log *slog.Logger
|
|
roots []string // absolute, clean allowed roots for workdir confinement
|
|
}
|
|
|
|
// New builds a TerminalExecutor from cfg and log. The sandbox is closed unless
|
|
// cfg.Enabled is true; a closed executor still constructs fine and refuses every
|
|
// request with ErrExecutionDisabled. When enabled, at least one allowed root is
|
|
// required so workdir confinement has an anchor.
|
|
func New(cfg config.ExecSandbox, log *slog.Logger) (*TerminalExecutor, error) {
|
|
if log == nil {
|
|
return nil, ErrNilLogger
|
|
}
|
|
roots := absCleaners(cfg.AllowedRoots)
|
|
if cfg.Enabled && len(roots) == 0 {
|
|
return nil, fmt.Errorf("%w: an enabled sandbox needs at least one allowed root", ErrNoAllowedRoot)
|
|
}
|
|
return &TerminalExecutor{
|
|
cfg: cfg,
|
|
log: logger.WithSource(log, "runtime/exec"),
|
|
roots: roots,
|
|
}, nil
|
|
}
|
|
|
|
// Execute runs the command in req under the sandbox. It returns an
|
|
// *[ExecResult] on success (including a non-zero exit code) or a sentinel error
|
|
// (wrapped) on refusal or an underlying context failure.
|
|
func (t *TerminalExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
|
if !t.cfg.Enabled {
|
|
t.log.Warn("sandbox disabled; refusing to execute command")
|
|
return nil, ErrExecutionDisabled
|
|
}
|
|
if len(req.Command) == 0 {
|
|
return nil, fmt.Errorf("%w: no argv", ErrEmptyCommand)
|
|
}
|
|
if err := t.checkCommand(req.Command); err != nil {
|
|
return nil, err
|
|
}
|
|
workdir, err := t.resolveWorkdir(req.WorkDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
env, err := t.buildEnv(req.Env)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return t.runCmd(ctx, req, workdir, env)
|
|
}
|
|
|
|
// runCmd launches the already-validated command, streams its output through a
|
|
// capped writer, honors the deadline, and classifies the outcome.
|
|
func (t *TerminalExecutor) runCmd(ctx context.Context, req ExecRequest, workdir string, env []string) (*ExecResult, error) {
|
|
timeout := req.Timeout
|
|
if timeout <= 0 {
|
|
timeout = DefaultTimeout
|
|
}
|
|
maxOut := req.MaxOutputBytes
|
|
if maxOut <= 0 {
|
|
maxOut = DefaultMaxOutputBytes
|
|
}
|
|
|
|
runCtx := ctx
|
|
cancel := func() {}
|
|
if timeout > 0 {
|
|
runCtx, cancel = context.WithTimeout(ctx, timeout)
|
|
}
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(runCtx, req.Command[0], req.Command[1:]...)
|
|
cmd.Dir = workdir
|
|
cmd.Env = env
|
|
if len(req.Stdin) > 0 {
|
|
cmd.Stdin = bytes.NewReader(req.Stdin)
|
|
}
|
|
out := &cappedOutput{max: maxOut}
|
|
cmd.Stdout = out.stdoutWriter()
|
|
cmd.Stderr = out.stderrWriter()
|
|
|
|
t.log.Debug("starting command", "cmd", strings.Join(req.Command, " "),
|
|
"workdir", workdir, "timeout", timeout.String())
|
|
startedAt := time.Now()
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("%w: %s: %w", ErrExecFailed, strings.Join(req.Command, " "), err)
|
|
}
|
|
result := &ExecResult{StartedAt: startedAt, PID: cmd.Process.Pid}
|
|
|
|
waitErr := cmd.Wait()
|
|
result.EndedAt = time.Now()
|
|
result.Stdout = out.stdout.String()
|
|
result.Stderr = out.stderr.String()
|
|
result.Truncated = out.truncated
|
|
|
|
if waitErr != nil {
|
|
switch {
|
|
case errors.Is(runCtx.Err(), context.DeadlineExceeded):
|
|
t.log.Warn("command timed out; killed", "cmd", strings.Join(req.Command, " "), "pid", result.PID)
|
|
return nil, fmt.Errorf("%w: %s", ErrTimeout, strings.Join(req.Command, " "))
|
|
case errors.Is(runCtx.Err(), context.Canceled):
|
|
return nil, fmt.Errorf("%w: %s", context.Canceled, strings.Join(req.Command, " "))
|
|
}
|
|
var exitErr *exec.ExitError
|
|
if errors.As(waitErr, &exitErr) {
|
|
result.ExitCode = exitErr.ExitCode()
|
|
} else {
|
|
return nil, fmt.Errorf("%w: %s: %w", ErrExecFailed, strings.Join(req.Command, " "), waitErr)
|
|
}
|
|
}
|
|
t.log.Debug("command finished", "cmd", strings.Join(req.Command, " "),
|
|
"exit", result.ExitCode, "truncated", result.Truncated)
|
|
return result, nil
|
|
}
|
|
|
|
// checkCommand enforces the command allowlist and the per-argument injection
|
|
// rules. The command runs via exec.Command (never a shell), so metacharacters
|
|
// are rejected rather than interpreted.
|
|
func (t *TerminalExecutor) checkCommand(argv []string) error {
|
|
if !t.allowed(argv[0]) {
|
|
return fmt.Errorf("%w: %q", ErrCommandNotAllowed, argv[0])
|
|
}
|
|
for _, a := range argv {
|
|
if err := validateArg(a); err != nil {
|
|
return fmt.Errorf("%w: %q: %w", ErrCommandInjection, a, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *TerminalExecutor) allowed(name string) bool {
|
|
for _, c := range t.cfg.CommandList {
|
|
if c == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (t *TerminalExecutor) envAllowed(name string) bool {
|
|
for _, a := range t.cfg.AllowedEnv {
|
|
if a == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// resolveWorkdir confines the process working directory to an allowed root. An
|
|
// empty request uses the first root; a relative path is resolved against it.
|
|
func (t *TerminalExecutor) resolveWorkdir(workdir string) (string, error) {
|
|
if workdir == "" {
|
|
if len(t.roots) == 0 {
|
|
return "", ErrNoAllowedRoot
|
|
}
|
|
real, err := resolveReal(t.roots[0])
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %s", ErrWorkDirOutsideRoot, t.roots[0])
|
|
}
|
|
return real, nil
|
|
}
|
|
abs, err := resolveInsideRoot(workdir, t.roots)
|
|
if err != nil {
|
|
if errors.Is(err, ErrPathOutsideRoot) || errors.Is(err, ErrSymlinkEscape) {
|
|
return "", fmt.Errorf("%w: %s", ErrWorkDirOutsideRoot, workdir)
|
|
}
|
|
return "", err
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
// buildEnv returns the child environment: the base process environment plus any
|
|
// allowlisted overrides from req.Env. Names outside the allowlist are rejected.
|
|
func (t *TerminalExecutor) buildEnv(extra map[string]string) ([]string, error) {
|
|
base := os.Environ()
|
|
if len(extra) == 0 {
|
|
return base, nil
|
|
}
|
|
|
|
overlays := make(map[string]string, len(extra))
|
|
for k, v := range extra {
|
|
if !t.envAllowed(k) {
|
|
return nil, fmt.Errorf("%w: %s", ErrEnvNotAllowed, k)
|
|
}
|
|
overlays[k] = v
|
|
}
|
|
|
|
out := make([]string, 0, len(base)+len(overlays))
|
|
seen := make(map[string]struct{}, len(base)+len(overlays))
|
|
for _, e := range base {
|
|
k, _, _ := strings.Cut(e, "=")
|
|
if v, ok := overlays[k]; ok {
|
|
out = append(out, k+"="+v)
|
|
delete(overlays, k)
|
|
} else {
|
|
out = append(out, e)
|
|
}
|
|
seen[k] = struct{}{}
|
|
}
|
|
|
|
rest := make([]string, 0, len(overlays))
|
|
for k := range overlays {
|
|
if _, ok := seen[k]; !ok {
|
|
rest = append(rest, k)
|
|
}
|
|
}
|
|
sort.Strings(rest)
|
|
for _, k := range rest {
|
|
out = append(out, k+"="+overlays[k])
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// validateArg rejects anything unsafe in a command argument: empty value, shell
|
|
// metacharacters, control characters, or a path-traversal ("..") component.
|
|
func validateArg(s string) error {
|
|
if s == "" {
|
|
return errors.New("empty argument")
|
|
}
|
|
if strings.ContainsAny(s, `;&|`+"`$()<>") {
|
|
return errors.New("contains shell metacharacters")
|
|
}
|
|
if strings.ContainsAny(s, "\x00\r\n") {
|
|
return errors.New("contains control characters")
|
|
}
|
|
for _, sep := range []string{"/", `\`} {
|
|
for _, part := range strings.Split(s, sep) {
|
|
if part == ".." {
|
|
return errors.New("contains path traversal")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// cappedOutput captures stdout and stderr while enforcing a combined byte cap.
|
|
// Writes past the cap are dropped and the truncated flag is raised. The shared
|
|
// mutex makes the two streams safe to write concurrently by os/exec.
|
|
type cappedOutput struct {
|
|
mu sync.Mutex
|
|
stdout bytes.Buffer
|
|
stderr bytes.Buffer
|
|
max int
|
|
used int
|
|
truncated bool
|
|
}
|
|
|
|
func (o *cappedOutput) stdoutWriter() io.Writer { return &capWriter{o: o, buf: &o.stdout} }
|
|
func (o *cappedOutput) stderrWriter() io.Writer { return &capWriter{o: o, buf: &o.stderr} }
|
|
|
|
type capWriter struct {
|
|
o *cappedOutput
|
|
buf *bytes.Buffer
|
|
}
|
|
|
|
func (w *capWriter) Write(p []byte) (int, error) {
|
|
w.o.mu.Lock()
|
|
defer w.o.mu.Unlock()
|
|
n := len(p)
|
|
remaining := w.o.max - w.o.used
|
|
if remaining <= 0 {
|
|
w.o.truncated = true
|
|
return n, nil
|
|
}
|
|
take := remaining
|
|
if take > n {
|
|
take = n
|
|
}
|
|
w.buf.Write(p[:take])
|
|
w.o.used += take
|
|
if take < n {
|
|
w.o.truncated = true
|
|
}
|
|
return n, nil
|
|
}
|