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,275 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// discardLogger returns a logger that writes nowhere, keeping test output clean.
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// requireBin skips the test when name is not on PATH.
|
||||
func requireBin(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
t.Skipf("%s not available: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// enabledCfg builds an enabled sandbox config with a single allowlisted command
|
||||
// and optional allowed roots.
|
||||
func enabledCfg(command string, roots ...string) config.ExecSandbox {
|
||||
return config.ExecSandbox{
|
||||
Enabled: true,
|
||||
CommandList: []string{command},
|
||||
AllowedRoots: roots,
|
||||
}
|
||||
}
|
||||
|
||||
func newEnabledExecutor(t *testing.T, cfg config.ExecSandbox) *TerminalExecutor {
|
||||
t.Helper()
|
||||
ex, err := New(cfg, discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return ex
|
||||
}
|
||||
|
||||
func TestTerminal_DefaultDisabled_Refuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
ex, err := New(config.ExecSandbox{Enabled: false}, discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
_, err = ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}})
|
||||
if !errors.Is(err, ErrExecutionDisabled) {
|
||||
t.Fatalf("err = %v, want ErrExecutionDisabled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_NewNilLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := New(config.ExecSandbox{}, nil); !errors.Is(err, ErrNilLogger) {
|
||||
t.Fatalf("err = %v, want ErrNilLogger", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_NewEnabledNeedsRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := New(enabledCfg("echo"), discardLogger())
|
||||
if !errors.Is(err, ErrNoAllowedRoot) {
|
||||
t.Fatalf("err = %v, want ErrNoAllowedRoot", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_Allowlist_Allowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "echo")
|
||||
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
|
||||
|
||||
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hello"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.ExitCode != 0 {
|
||||
t.Errorf("ExitCode = %d, want 0", res.ExitCode)
|
||||
}
|
||||
if !strings.Contains(res.Stdout, "hello") {
|
||||
t.Errorf("Stdout = %q, want contain hello", res.Stdout)
|
||||
}
|
||||
if res.Truncated {
|
||||
t.Errorf("Truncated = true, want false")
|
||||
}
|
||||
if res.PID <= 0 {
|
||||
t.Errorf("PID = %d, want > 0", res.PID)
|
||||
}
|
||||
if res.StartedAt.IsZero() || res.EndedAt.IsZero() {
|
||||
t.Errorf("timestamps must be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_Allowlist_Denied(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "echo")
|
||||
// pwd is allowlisted; echo is not.
|
||||
ex := newEnabledExecutor(t, enabledCfg("pwd", t.TempDir()))
|
||||
_, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}})
|
||||
if !errors.Is(err, ErrCommandNotAllowed) {
|
||||
t.Fatalf("err = %v, want ErrCommandNotAllowed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_Injection_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "echo")
|
||||
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
arg string
|
||||
}{
|
||||
{"semicolon", "a;b"},
|
||||
{"pipe", "a|b"},
|
||||
{"ampersand", "a&b"},
|
||||
{"subshell", "$(rm -rf /)"},
|
||||
{"backtick", "`id`"},
|
||||
{"redirect-out", ">out"},
|
||||
{"redirect-in", "<in"},
|
||||
{"newline", "a\nb"},
|
||||
{"traversal", "../etc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", c.arg}})
|
||||
if !errors.Is(err, ErrCommandInjection) {
|
||||
t.Errorf("arg %q: err = %v, want ErrCommandInjection", c.arg, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_WorkDir_DefaultToRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "pwd")
|
||||
root := t.TempDir()
|
||||
ex := newEnabledExecutor(t, enabledCfg("pwd", root))
|
||||
|
||||
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"pwd"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
realRoot := root
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
realRoot = r
|
||||
}
|
||||
if !strings.Contains(res.Stdout, realRoot) {
|
||||
t.Errorf("Stdout = %q, want contain %q", res.Stdout, realRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_WorkDir_OutsideRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "echo")
|
||||
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
|
||||
_, err := ex.Execute(context.Background(), ExecRequest{
|
||||
Command: []string{"echo", "hi"},
|
||||
WorkDir: os.TempDir(),
|
||||
})
|
||||
if !errors.Is(err, ErrWorkDirOutsideRoot) {
|
||||
t.Fatalf("err = %v, want ErrWorkDirOutsideRoot", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_Timeout_Killed(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "sleep")
|
||||
ex := newEnabledExecutor(t, enabledCfg("sleep", t.TempDir()))
|
||||
|
||||
start := time.Now()
|
||||
_, err := ex.Execute(context.Background(), ExecRequest{
|
||||
Command: []string{"sleep", "5"},
|
||||
Timeout: 200 * time.Millisecond,
|
||||
})
|
||||
if !errors.Is(err, ErrTimeout) {
|
||||
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Errorf("timeout took %v, want ~200ms (process not killed)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_Output_Truncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "head")
|
||||
ex := newEnabledExecutor(t, enabledCfg("head", t.TempDir()))
|
||||
|
||||
res, err := ex.Execute(context.Background(), ExecRequest{
|
||||
Command: []string{"head", "-c", "2000000", "/dev/zero"},
|
||||
MaxOutputBytes: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !res.Truncated {
|
||||
t.Errorf("Truncated = false, want true")
|
||||
}
|
||||
if len(res.Stdout) != 100 {
|
||||
t.Errorf("len(Stdout) = %d, want 100", len(res.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_NonZeroExit_ReportedNotError(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "false")
|
||||
ex := newEnabledExecutor(t, enabledCfg("false", t.TempDir()))
|
||||
|
||||
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"false"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.ExitCode != 1 {
|
||||
t.Errorf("ExitCode = %d, want 1", res.ExitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_Stdin_Passthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "cat")
|
||||
ex := newEnabledExecutor(t, enabledCfg("cat", t.TempDir()))
|
||||
|
||||
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"cat"}, Stdin: []byte("ping")})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if res.Stdout != "ping" {
|
||||
t.Errorf("Stdout = %q, want ping", res.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_Env_Whitelist(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireBin(t, "env")
|
||||
root := t.TempDir()
|
||||
|
||||
// No allowlist: any injected variable is refused before launching.
|
||||
noEnv := newEnabledExecutor(t, enabledCfg("env", root))
|
||||
_, err := noEnv.Execute(context.Background(), ExecRequest{Command: []string{"env"}, Env: map[string]string{"FOO": "bar"}})
|
||||
if !errors.Is(err, ErrEnvNotAllowed) {
|
||||
t.Fatalf("no-allowlist err = %v, want ErrEnvNotAllowed", err)
|
||||
}
|
||||
|
||||
// Allowlisted variable is applied.
|
||||
cfg := enabledCfg("env", root)
|
||||
cfg.AllowedEnv = []string{"FOO"}
|
||||
ex := newEnabledExecutor(t, cfg)
|
||||
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"env"}, Env: map[string]string{"FOO": "bar"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if !strings.Contains(res.Stdout, "FOO=bar") {
|
||||
t.Errorf("Stdout = %q, want contain FOO=bar", res.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminal_EmptyCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
|
||||
_, err := ex.Execute(context.Background(), ExecRequest{})
|
||||
if !errors.Is(err, ErrEmptyCommand) {
|
||||
t.Fatalf("err = %v, want ErrEmptyCommand", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user