feat(exec): 本地执行 Provider + 放权终端沙箱(白名单/工作目录/超时/输出截断/默认关)+ 文件沙箱;config 增 ExecSandbox(默认关)
This commit is contained in:
1 parent
88dc12466d
commit
430fc4666b
11 files changed
+1199
-20
No files matched your search
@@ -0,0 +1,298 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user