- buildEnv 不再以 os.Environ() 全集为基,改为「平台最小必需键白名单」裁剪后再叠加 req.Env · Windows:PATH/PATHEXT/SYSTEMROOT/WINDIR/COMSPEC/TEMP/TMP/USERPROFILE/HOMEDRIVE/HOMEPATH/APPDATA/LOCALAPPDATA/NUMBER_OF_PROCESSORS/OS/PROCESSOR_ARCHITECTURE · Unix:PATH/HOME/TMPDIR/LANG/LC_ALL/USER/SHELL - 键名匹配在 Windows 上大小写不敏感(避免宿主写 Path 时漏掉),overlay 覆盖 base 时同口径去重 - envAllowed 语义不变:调用方能显式放行的键照旧,仅改变 base 来源 - 新增 TestTerminal_Env_HostNotInherited(非并行,t.Setenv):父进程设非白名单变量,断言子进程读不到;反证=把该键加进白名单立刻 FAIL
360 lines
11 KiB
Go
360 lines
11 KiB
Go
package exec
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"os"
|
||
"os/exec"
|
||
"runtime"
|
||
"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
|
||
}
|
||
|
||
// terminalEnvAllowlist 返回运行终端子进程所必需的最小宿主环境变量键集合。
|
||
//
|
||
// 为什么是这些(审计口径):终端命令可由模型/插件触发,若继承 os.Environ() 全集,
|
||
// 子进程就能读到宿主的 API key、token、DB 口令等一切凭据。但子进程又必须拿到一
|
||
// 撮由操作系统/运行时提供的定位与查找变量,否则连可执行文件都解析不了。这里只保
|
||
// 留「去哪找可执行文件、临时目录在哪、用户目录在哪、常规系统信息」这类不含凭据的
|
||
// 键:
|
||
// - Windows:PATH/PATHEXT 定位可执行文件;SYSTEMROOT/WINDIR/COMSPEC 是 cmd 与
|
||
// 系统 DLL 加载的前提;TEMP/TMP 是临时目录;USERPROFILE/HOMEDRIVE/HOMEPATH/
|
||
// APPDATA/LOCALAPPDATA 是用户目录;NUMBER_OF_PROCESSORS/OS/
|
||
// PROCESSOR_ARCHITECTURE 是常规系统信息(部分程序依赖)。
|
||
// - Unix:PATH 定位可执行文件;HOME/TMPDIR 是用户与临时目录;USER/SHELL/LANG/
|
||
// LC_ALL 是常规终端身份与本地化变量。
|
||
//
|
||
// 其余变量(如 *_API_KEY、*_TOKEN、DB_*、JWT_*)一律不透传;调用方确需某个键时,
|
||
// 由 cfg.AllowedEnv 显式放行并经 req.Env 覆盖(见 envAllowed)。
|
||
func terminalEnvAllowlist() []string {
|
||
if runtime.GOOS == "windows" {
|
||
windowsKeys := []string{
|
||
"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC",
|
||
"TEMP", "TMP", "USERPROFILE", "HOMEDRIVE", "HOMEPATH",
|
||
"APPDATA", "LOCALAPPDATA", "NUMBER_OF_PROCESSORS", "OS",
|
||
"PROCESSOR_ARCHITECTURE",
|
||
}
|
||
return windowsKeys
|
||
}
|
||
unixKeys := []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "USER", "SHELL"}
|
||
return unixKeys
|
||
}
|
||
|
||
// envKeyEqual 比较两个环境变量名是否指向同一变量:Windows 的名字大小写不敏感
|
||
// (宿主常写成 Path,不能因此漏掉 PATH),Unix 敏感。
|
||
func envKeyEqual(a, b string) bool {
|
||
if runtime.GOOS == "windows" {
|
||
return strings.EqualFold(a, b)
|
||
}
|
||
return a == b
|
||
}
|
||
|
||
// envKeyIn 判断 name 是否属于 keys(按平台口径比较)。
|
||
func envKeyIn(name string, keys []string) bool {
|
||
for _, k := range keys {
|
||
if envKeyEqual(k, name) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// envKeyOf 返回 m 中与 name 等价的键的原始拼写,用于覆盖时避免同名重复键。
|
||
func envKeyOf(m map[string]string, name string) (string, bool) {
|
||
if _, ok := m[name]; ok {
|
||
return name, true
|
||
}
|
||
if runtime.GOOS != "windows" {
|
||
return "", false
|
||
}
|
||
for k := range m {
|
||
if strings.EqualFold(k, name) {
|
||
return k, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// buildEnv 组装子进程环境:只透传运行终端所必需的最小宿主键集合
|
||
// (terminalEnvAllowlist),再叠加 req.Env 中经 envAllowed 显式放行的覆盖值。
|
||
// 宿主其余变量(含密钥)一律不透传;req.Env 里非白名单的键直接拒绝。
|
||
func (t *TerminalExecutor) buildEnv(extra map[string]string) ([]string, error) {
|
||
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
|
||
}
|
||
|
||
allow := terminalEnvAllowlist()
|
||
kept := make(map[string]string)
|
||
for _, e := range os.Environ() {
|
||
k, v, ok := strings.Cut(e, "=")
|
||
if !ok || k == "" || !envKeyIn(k, allow) {
|
||
continue
|
||
}
|
||
kept[k] = v
|
||
}
|
||
|
||
for k, v := range overlays {
|
||
if existing, ok := envKeyOf(kept, k); ok {
|
||
delete(kept, existing)
|
||
}
|
||
kept[k] = v
|
||
}
|
||
|
||
out := make([]string, 0, len(kept))
|
||
for k, v := range kept {
|
||
out = append(out, k+"="+v)
|
||
}
|
||
sort.Strings(out)
|
||
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
|
||
}
|