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
+21
-20
@@ -15,21 +15,22 @@ import (
|
||||
|
||||
// Config is the root configuration tree.
|
||||
type Config struct {
|
||||
Env string `env:"ENV" default:"development"`
|
||||
LogLevel string `env:"LOG_LEVEL" default:"debug"`
|
||||
Timezone string `env:"TZ" default:"Asia/Shanghai"`
|
||||
DataDir string `env:"DATA_DIR" default:"./data"`
|
||||
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
|
||||
Env string `env:"ENV" default:"development"`
|
||||
LogLevel string `env:"LOG_LEVEL" default:"debug"`
|
||||
Timezone string `env:"TZ" default:"Asia/Shanghai"`
|
||||
DataDir string `env:"DATA_DIR" default:"./data"`
|
||||
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
|
||||
|
||||
Server ServerConfig
|
||||
Auth AuthConfig
|
||||
DB DBConfig
|
||||
LLM LLMConfig
|
||||
Embedding EmbeddingConfig
|
||||
IoT IoTConfig
|
||||
Voice VoiceConfig
|
||||
Background BackgroundConfig
|
||||
WSHub WSHubConfig
|
||||
Server ServerConfig
|
||||
Auth AuthConfig
|
||||
DB DBConfig
|
||||
LLM LLMConfig
|
||||
Embedding EmbeddingConfig
|
||||
IoT IoTConfig
|
||||
Voice VoiceConfig
|
||||
Background BackgroundConfig
|
||||
WSHub WSHubConfig
|
||||
ExecSandbox ExecSandbox
|
||||
}
|
||||
|
||||
// ServerConfig holds the single gateway listener settings.
|
||||
@@ -76,16 +77,16 @@ type IoTConfig struct {
|
||||
|
||||
// VoiceConfig holds TTS/STT provider settings.
|
||||
type VoiceConfig struct {
|
||||
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
|
||||
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
|
||||
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
|
||||
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
|
||||
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
|
||||
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
|
||||
}
|
||||
|
||||
// BackgroundConfig holds the autonomous thinking settings.
|
||||
type BackgroundConfig struct {
|
||||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"`
|
||||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"`
|
||||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||
}
|
||||
|
||||
// WSHubConfig holds the WebSocket hub limits.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package config
|
||||
|
||||
// ExecSandbox holds the grant-based local execution sandbox settings
|
||||
// (DevDocs/design/04-运行时.md §8 and design/11 §5). The sandbox is closed by
|
||||
// default: the agent may run commands only when Enabled is true, and even then
|
||||
// only within the command allowlist and the root boundaries configured here.
|
||||
type ExecSandbox struct {
|
||||
// Enabled opens the sandbox. It is false by default — the agent cannot
|
||||
// execute anything unless the owner explicitly enables it. This is the
|
||||
// "default off, explicit grant" red line.
|
||||
Enabled bool `env:"EXEC_SANDBOX_ENABLED" default:"false"`
|
||||
|
||||
// CommandList is the allowlist of command names (argv[0]) the agent may run.
|
||||
// An empty list denies every command (fail closed).
|
||||
CommandList []string `env:"EXEC_SANDBOX_COMMANDS"`
|
||||
|
||||
// AllowedRoots are the directories commands and files are confined to.
|
||||
// Working directories and file paths must resolve inside one of them.
|
||||
AllowedRoots []string `env:"EXEC_SANDBOX_ROOTS"`
|
||||
|
||||
// AllowedEnv lists the environment variable names the agent may set on a
|
||||
// subprocess. Variable names outside this list are rejected.
|
||||
AllowedEnv []string `env:"EXEC_SANDBOX_ENV"`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecSandbox_DefaultDisabled(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
t.Setenv("EXEC_SANDBOX_ENABLED", "false")
|
||||
t.Setenv("EXEC_SANDBOX_COMMANDS", "")
|
||||
t.Setenv("EXEC_SANDBOX_ROOTS", "")
|
||||
t.Setenv("EXEC_SANDBOX_ENV", "")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if cfg.ExecSandbox.Enabled {
|
||||
t.Errorf("ExecSandbox.Enabled = true, want default false (sandbox closed)")
|
||||
}
|
||||
if len(cfg.ExecSandbox.CommandList) != 0 {
|
||||
t.Errorf("CommandList = %v, want empty", cfg.ExecSandbox.CommandList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecSandbox_EnvOverrides(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("s", 40))
|
||||
t.Setenv("EXEC_SANDBOX_ENABLED", "true")
|
||||
t.Setenv("EXEC_SANDBOX_COMMANDS", "echo,pwd")
|
||||
t.Setenv("EXEC_SANDBOX_ROOTS", "/tmp/sandbox,/home/me/workspace")
|
||||
t.Setenv("EXEC_SANDBOX_ENV", "PATH,HOME")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if !cfg.ExecSandbox.Enabled {
|
||||
t.Errorf("ExecSandbox.Enabled = false, want true")
|
||||
}
|
||||
if want := []string{"echo", "pwd"}; !reflect.DeepEqual(cfg.ExecSandbox.CommandList, want) {
|
||||
t.Errorf("CommandList = %v, want %v", cfg.ExecSandbox.CommandList, want)
|
||||
}
|
||||
if want := []string{"/tmp/sandbox", "/home/me/workspace"}; !reflect.DeepEqual(cfg.ExecSandbox.AllowedRoots, want) {
|
||||
t.Errorf("AllowedRoots = %v, want %v", cfg.ExecSandbox.AllowedRoots, want)
|
||||
}
|
||||
if want := []string{"PATH", "HOME"}; !reflect.DeepEqual(cfg.ExecSandbox.AllowedEnv, want) {
|
||||
t.Errorf("AllowedEnv = %v, want %v", cfg.ExecSandbox.AllowedEnv, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// FileSandbox confines file read, write, and list access to a set of allowed
|
||||
// root directories (DevDocs/design/04-运行时.md §8, design/11 §5). Every path is
|
||||
// validated by [Resolve] before use, which rejects absolute paths outside the
|
||||
// roots, relative paths that escape them, and symlinks that resolve outside
|
||||
// them.
|
||||
type FileSandbox struct {
|
||||
mu sync.RWMutex
|
||||
roots []string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewFileSandbox builds a FileSandbox confined to roots. Raw roots are made
|
||||
// absolute and cleaned. The logger is required.
|
||||
func NewFileSandbox(roots []string, log *slog.Logger) (*FileSandbox, error) {
|
||||
if log == nil {
|
||||
return nil, ErrNilLogger
|
||||
}
|
||||
return &FileSandbox{
|
||||
roots: absCleaners(roots),
|
||||
log: logger.WithSource(log, "runtime/exec"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetRoots atomically replaces the allowed roots. It enables a live config
|
||||
// reload without racing in-flight Resolve calls.
|
||||
func (s *FileSandbox) SetRoots(roots []string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.roots = absCleaners(roots)
|
||||
}
|
||||
|
||||
// Resolve validates path against the current allowed roots and returns its
|
||||
// absolute, symlink-resolved location, or an error if it would escape. A
|
||||
// relative path is resolved against the first allowed root.
|
||||
func (s *FileSandbox) Resolve(path string) (string, error) {
|
||||
roots := s.rootsLocked()
|
||||
if len(roots) == 0 {
|
||||
return "", ErrNoAllowedRoot
|
||||
}
|
||||
return resolveInsideRoot(path, roots)
|
||||
}
|
||||
|
||||
// ReadFile reads a file confined to the allowed roots.
|
||||
func (s *FileSandbox) ReadFile(path string) ([]byte, error) {
|
||||
abs, err := s.Resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec: read %s: %w", abs, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// WriteFile writes data to a path confined to the allowed roots.
|
||||
func (s *FileSandbox) WriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
abs, err := s.Resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(abs, data, perm); err != nil {
|
||||
return fmt.Errorf("exec: write %s: %w", abs, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns the directory entries of a directory confined to the allowed
|
||||
// roots.
|
||||
func (s *FileSandbox) List(dir string) ([]os.DirEntry, error) {
|
||||
abs, err := s.Resolve(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exec: list %s: %w", abs, err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *FileSandbox) rootsLocked() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
roots := make([]string, len(s.roots))
|
||||
copy(roots, s.roots)
|
||||
return roots
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newFileSandbox(t *testing.T, roots ...string) *FileSandbox {
|
||||
t.Helper()
|
||||
fs, err := NewFileSandbox(roots, discardLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("NewFileSandbox: %v", err)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_Allowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
realRoot := root
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
realRoot = r
|
||||
}
|
||||
fs := newFileSandbox(t, root)
|
||||
|
||||
got, err := fs.Resolve(filepath.Join(root, "a.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if want := filepath.Join(realRoot, "a.txt"); got != want {
|
||||
t.Errorf("Resolve = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_OutsideRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, t.TempDir())
|
||||
outside := filepath.Join(os.TempDir(), "cyrene-outside-file.txt")
|
||||
_, err := fs.Resolve(outside)
|
||||
if !errors.Is(err, ErrPathOutsideRoot) {
|
||||
t.Fatalf("err = %v, want ErrPathOutsideRoot", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_Resolve_SymlinkEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(root, "escape")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
fs := newFileSandbox(t, root)
|
||||
|
||||
_, err := fs.Resolve(filepath.Join(link, "secret.txt"))
|
||||
if !errors.Is(err, ErrSymlinkEscape) {
|
||||
t.Fatalf("err = %v, want ErrSymlinkEscape", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_WriteReadList(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, t.TempDir())
|
||||
|
||||
if err := fs.WriteFile("a.txt", []byte("content"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
data, err := fs.ReadFile("a.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != "content" {
|
||||
t.Errorf("ReadFile = %q, want content", data)
|
||||
}
|
||||
entries, err := fs.List(".")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("len(entries) = %d, want 1", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSandbox_NoRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
fs := newFileSandbox(t, "")
|
||||
_, err := fs.Resolve("a.txt")
|
||||
if !errors.Is(err, ErrNoAllowedRoot) {
|
||||
t.Fatalf("err = %v, want ErrNoAllowedRoot", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package exec implements the unified local execution abstraction for the
|
||||
// Cyrene agent runtime (DevDocs/design/04-运行时.md §8 and design/02-会话与上下文模型.md §4).
|
||||
//
|
||||
// A Provider is the grant-based, sandboxed entry point through which the agent
|
||||
// may run code on its own host. This milestone ships two concrete pieces:
|
||||
//
|
||||
// - [TerminalExecutor], the default command provider, which runs allowlisted
|
||||
// commands inside a locked-down terminal sandbox (command allowlist, working
|
||||
// directory confinement, environment allowlist, timeout, output truncation).
|
||||
// - [FileSandbox], which confines file read/write/list access to a set of
|
||||
// allowed root directories.
|
||||
//
|
||||
// GUI operations and a real virtual machine are reserved for later milestones;
|
||||
// they surface only through the [Provider] interface and are not implemented
|
||||
// here. The sandbox is DISABLED BY DEFAULT: an [TerminalExecutor] created from
|
||||
// a closed config refuses every request with [ErrExecutionDisabled], so the
|
||||
// agent gains the ability to run code only after the owner explicitly enables
|
||||
// it (see config.ExecSandbox).
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultEnabled is the sandbox red line: execution is off unless the owner
|
||||
// explicitly enables it via config.ExecSandbox.Enabled.
|
||||
const DefaultEnabled = false
|
||||
|
||||
// DefaultTimeout is the per-request deadline used when ExecRequest.Timeout is
|
||||
// zero. 30s balances responsiveness with enough room for a real command.
|
||||
const DefaultTimeout = 30 * time.Second
|
||||
|
||||
// DefaultMaxOutputBytes is the combined stdout+stderr cap used when
|
||||
// ExecRequest.MaxOutputBytes is zero (1 MiB).
|
||||
const DefaultMaxOutputBytes = 1 << 20
|
||||
|
||||
// Sentinel errors returned by this package. Use errors.Is to test for them;
|
||||
// they are always wrapped with %w so the underlying cause stays available.
|
||||
var (
|
||||
// ErrExecutionDisabled is returned when the sandbox is closed (config
|
||||
// Enabled is false). This is the security red line: default off.
|
||||
ErrExecutionDisabled = errors.New("exec: sandbox disabled for this role")
|
||||
// ErrEmptyCommand is returned when ExecRequest.Command is empty.
|
||||
ErrEmptyCommand = errors.New("exec: command is empty")
|
||||
// ErrCommandNotAllowed is returned when argv[0] is not in the allowlist.
|
||||
ErrCommandNotAllowed = errors.New("exec: command not in allowlist")
|
||||
// ErrCommandInjection is returned when a command argument contains shell
|
||||
// metacharacters, control characters, or a path-traversal component.
|
||||
ErrCommandInjection = errors.New("exec: command argument rejected")
|
||||
// ErrWorkDirOutsideRoot is returned when the requested working directory
|
||||
// escapes the allowed roots.
|
||||
ErrWorkDirOutsideRoot = errors.New("exec: working directory outside allowed roots")
|
||||
// ErrEnvNotAllowed is returned when ExecRequest.Env names a variable outside
|
||||
// the configured environment allowlist.
|
||||
ErrEnvNotAllowed = errors.New("exec: environment variable not in allowlist")
|
||||
// ErrTimeout is returned when the command exceeds its deadline and is killed.
|
||||
ErrTimeout = errors.New("exec: execution timed out")
|
||||
// ErrExecFailed is returned when a command could not be started or finished.
|
||||
ErrExecFailed = errors.New("exec: process failed")
|
||||
// ErrNoAllowedRoot is returned when an operation needs a root but none is
|
||||
// configured.
|
||||
ErrNoAllowedRoot = errors.New("exec: no allowed root configured")
|
||||
// ErrEmptyPath is returned when a path to resolve is empty.
|
||||
ErrEmptyPath = errors.New("exec: path is empty")
|
||||
// ErrPathOutsideRoot is returned when a path escapes the allowed roots.
|
||||
ErrPathOutsideRoot = errors.New("exec: path outside allowed roots")
|
||||
// ErrSymlinkEscape is returned when a path resolves through a symlink that
|
||||
// points outside the allowed roots.
|
||||
ErrSymlinkEscape = errors.New("exec: path escapes root via symlink")
|
||||
// ErrNullByte is returned when a path contains a NUL byte.
|
||||
ErrNullByte = errors.New("exec: path contains NUL byte")
|
||||
// ErrNilLogger is returned by New when no logger is supplied.
|
||||
ErrNilLogger = errors.New("exec: nil logger")
|
||||
)
|
||||
|
||||
// Provider is the unified local execution abstraction. It covers command
|
||||
// execution now and, in later milestones, GUI operations and virtual machines,
|
||||
// all behind the same grant-based, sandboxed interface.
|
||||
//
|
||||
// Implementations must honor the context, which carries cancellation and
|
||||
// deadlines, and must bound their output to ExecRequest.MaxOutputBytes,
|
||||
// reporting truncation via ExecResult.Truncated.
|
||||
type Provider interface {
|
||||
// Execute runs req and returns its outcome, or an error that is one of the
|
||||
// sentinels above (or wraps one). A non-zero exit code is NOT an error: it
|
||||
// is reported in ExecResult.ExitCode.
|
||||
Execute(ctx context.Context, req ExecRequest) (*ExecResult, error)
|
||||
}
|
||||
|
||||
// ExecRequest describes a single local execution.
|
||||
type ExecRequest struct {
|
||||
// Command is the argv vector. It must be non-empty; argv[0] is the command
|
||||
// name that must appear in the allowlist.
|
||||
Command []string
|
||||
// WorkDir is the process working directory. It must resolve inside an
|
||||
// allowed root. When empty, the provider defaults to the first allowed root.
|
||||
WorkDir string
|
||||
// Env carries extra environment to set on the process. Only names in the
|
||||
// environment allowlist are accepted.
|
||||
Env map[string]string
|
||||
// Timeout bounds the run. Zero uses DefaultTimeout.
|
||||
Timeout time.Duration
|
||||
// Stdin is the optional stdin payload.
|
||||
Stdin []byte
|
||||
// MaxOutputBytes caps combined stdout+stderr. Zero uses
|
||||
// DefaultMaxOutputBytes. Output past the cap is dropped and Truncated is set.
|
||||
MaxOutputBytes int
|
||||
}
|
||||
|
||||
// ExecResult is the outcome of an execution.
|
||||
type ExecResult struct {
|
||||
// Stdout is the captured standard output (truncated to MaxOutputBytes).
|
||||
Stdout string
|
||||
// Stderr is the captured standard error (truncated to MaxOutputBytes).
|
||||
Stderr string
|
||||
// ExitCode is the process exit code. -1 when terminated by a signal.
|
||||
ExitCode int
|
||||
// Truncated reports whether output was dropped because MaxOutputBytes was
|
||||
// reached.
|
||||
Truncated bool
|
||||
// StartedAt is when the process started.
|
||||
StartedAt time.Time
|
||||
// EndedAt is when the process finished (or was killed).
|
||||
EndedAt time.Time
|
||||
// PID is the process identifier assigned by the OS.
|
||||
PID int
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockProvider is a configurable Provider used to prove the interface can be
|
||||
// mocked. It mocks the interface, not a concrete type, per the test
|
||||
// conventions.
|
||||
type mockProvider struct {
|
||||
execute func(ctx context.Context, req ExecRequest) (*ExecResult, error)
|
||||
}
|
||||
|
||||
func (m mockProvider) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
if m.execute == nil {
|
||||
return &ExecResult{}, nil
|
||||
}
|
||||
return m.execute(ctx, req)
|
||||
}
|
||||
|
||||
// TestProvider_InterfaceConformance is a compile-time assertion that both the
|
||||
// mock and the real executor satisfy Provider.
|
||||
func TestProvider_InterfaceConformance(t *testing.T) {
|
||||
t.Parallel()
|
||||
var _ Provider = mockProvider{}
|
||||
var _ Provider = (*TerminalExecutor)(nil)
|
||||
}
|
||||
|
||||
// TestProvider_MockRoundTrip exercises the interface through a mock without any
|
||||
// real process.
|
||||
func TestProvider_MockRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
var got ExecRequest
|
||||
p := mockProvider{execute: func(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
got = req
|
||||
return &ExecResult{Stdout: "ok"}, nil
|
||||
}}
|
||||
|
||||
res, err := p.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute error: %v", err)
|
||||
}
|
||||
if res.Stdout != "ok" {
|
||||
t.Errorf("Stdout = %q, want ok", res.Stdout)
|
||||
}
|
||||
if len(got.Command) != 2 || got.Command[0] != "echo" {
|
||||
t.Errorf("got.Command = %v, want [echo hi]", got.Command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// absCleaners returns a slice of absolute, clean paths from raw roots. Dirty or
|
||||
// empty entries are dropped; non-absolute roots are made absolute against the
|
||||
// process working directory.
|
||||
func absCleaners(raw []string) []string {
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
r = strings.TrimSpace(r)
|
||||
if r == "" {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(r) {
|
||||
r = filepath.Join(".", r)
|
||||
}
|
||||
out = append(out, filepath.Clean(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withinAnyRoot reports whether path is lexically confined to one of roots.
|
||||
// Containment is judged by filepath.Rel: the path is inside a root when the
|
||||
// relative path is "." or does not begin with "..".
|
||||
func withinAnyRoot(path string, roots []string) bool {
|
||||
for _, root := range roots {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveReal returns the real (symlink-free) path for p, resolving the deepest
|
||||
// existing ancestor when p itself does not exist (for example a file about to be
|
||||
// created). This prevents a symlink inside a root from pointing at a location
|
||||
// outside it.
|
||||
func resolveReal(p string) (string, error) {
|
||||
cur := filepath.Clean(p)
|
||||
remaining := ""
|
||||
for {
|
||||
if real, err := filepath.EvalSymlinks(cur); err == nil {
|
||||
if remaining == "" {
|
||||
return real, nil
|
||||
}
|
||||
return filepath.Join(real, remaining), nil
|
||||
}
|
||||
base := filepath.Base(cur)
|
||||
parent := filepath.Dir(cur)
|
||||
if parent == cur {
|
||||
return "", fmt.Errorf("no existing ancestor for %s", p)
|
||||
}
|
||||
if remaining == "" {
|
||||
remaining = base
|
||||
} else {
|
||||
remaining = filepath.Join(base, remaining)
|
||||
}
|
||||
cur = parent
|
||||
}
|
||||
}
|
||||
|
||||
// resolveInsideRoot validates a caller-supplied path and returns its absolute,
|
||||
// symlink-resolved path, guaranteeing that both the lexical path and its real
|
||||
// target stay inside one of roots. A relative path is resolved against
|
||||
// roots[0]. The returned path is absolute and clean.
|
||||
func resolveInsideRoot(candidate string, roots []string) (string, error) {
|
||||
if candidate == "" {
|
||||
return "", ErrEmptyPath
|
||||
}
|
||||
if strings.IndexByte(candidate, 0) >= 0 {
|
||||
return "", ErrNullByte
|
||||
}
|
||||
|
||||
var abs string
|
||||
if filepath.IsAbs(candidate) {
|
||||
abs = filepath.Clean(candidate)
|
||||
} else {
|
||||
if len(roots) == 0 {
|
||||
return "", ErrNoAllowedRoot
|
||||
}
|
||||
abs = filepath.Join(roots[0], candidate)
|
||||
}
|
||||
|
||||
if !withinAnyRoot(abs, roots) {
|
||||
return "", fmt.Errorf("%w: %s", ErrPathOutsideRoot, candidate)
|
||||
}
|
||||
|
||||
real, err := resolveReal(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s: %w", ErrPathOutsideRoot, candidate, err)
|
||||
}
|
||||
if !withinAnyRoot(real, roots) {
|
||||
return "", fmt.Errorf("%w: %s (real target %s)", ErrSymlinkEscape, candidate, real)
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWithinAnyRoot covers lexical containment (filepath.Rel based).
|
||||
func TestWithinAnyRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := filepath.Clean("/allow/root")
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"inside", filepath.Join(root, "a", "b.txt"), true},
|
||||
{"exact-root", root, true},
|
||||
{"parent", filepath.Dir(root), false},
|
||||
{"sibling", "/allow/other", false},
|
||||
{"sibling-prefix", "/allow/root-other", false},
|
||||
{"deep", filepath.Join(root, "x", "y", "z"), true},
|
||||
{"empty-sub", filepath.Join(root, "sub", "..", "file.txt"), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := withinAnyRoot(c.path, []string{root}); got != c.want {
|
||||
t.Errorf("withinAnyRoot(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveReal_NonExistentNested resolves a path whose leaf components do not
|
||||
// exist yet, walking up to the deepest existing ancestor.
|
||||
func TestResolveReal_NonExistentNested(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
realDir := dir
|
||||
if r, err := filepath.EvalSymlinks(dir); err == nil {
|
||||
realDir = r
|
||||
}
|
||||
p := filepath.Join(dir, "a", "b", "c")
|
||||
got, err := resolveReal(p)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveReal(%q) error: %v", p, err)
|
||||
}
|
||||
want := filepath.Join(realDir, "a", "b", "c")
|
||||
if got != want {
|
||||
t.Errorf("resolveReal = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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