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.
|
// Config is the root configuration tree.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Env string `env:"ENV" default:"development"`
|
Env string `env:"ENV" default:"development"`
|
||||||
LogLevel string `env:"LOG_LEVEL" default:"debug"`
|
LogLevel string `env:"LOG_LEVEL" default:"debug"`
|
||||||
Timezone string `env:"TZ" default:"Asia/Shanghai"`
|
Timezone string `env:"TZ" default:"Asia/Shanghai"`
|
||||||
DataDir string `env:"DATA_DIR" default:"./data"`
|
DataDir string `env:"DATA_DIR" default:"./data"`
|
||||||
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
|
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
|
||||||
|
|
||||||
Server ServerConfig
|
Server ServerConfig
|
||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
DB DBConfig
|
DB DBConfig
|
||||||
LLM LLMConfig
|
LLM LLMConfig
|
||||||
Embedding EmbeddingConfig
|
Embedding EmbeddingConfig
|
||||||
IoT IoTConfig
|
IoT IoTConfig
|
||||||
Voice VoiceConfig
|
Voice VoiceConfig
|
||||||
Background BackgroundConfig
|
Background BackgroundConfig
|
||||||
WSHub WSHubConfig
|
WSHub WSHubConfig
|
||||||
|
ExecSandbox ExecSandbox
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerConfig holds the single gateway listener settings.
|
// ServerConfig holds the single gateway listener settings.
|
||||||
@@ -76,16 +77,16 @@ type IoTConfig struct {
|
|||||||
|
|
||||||
// VoiceConfig holds TTS/STT provider settings.
|
// VoiceConfig holds TTS/STT provider settings.
|
||||||
type VoiceConfig struct {
|
type VoiceConfig struct {
|
||||||
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
|
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
|
||||||
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
|
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
|
||||||
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
|
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackgroundConfig holds the autonomous thinking settings.
|
// BackgroundConfig holds the autonomous thinking settings.
|
||||||
type BackgroundConfig struct {
|
type BackgroundConfig struct {
|
||||||
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
|
||||||
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"`
|
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"300"`
|
||||||
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WSHubConfig holds the WebSocket hub limits.
|
// 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