feat(exec): 本地执行 Provider + 放权终端沙箱(白名单/工作目录/超时/输出截断/默认关)+ 文件沙箱;config 增 ExecSandbox(默认关)

This commit is contained in:
Aska committed 2026-09-06 06:59:23 +08:00
1 parent 88dc12466d
commit 430fc4666b
11 files changed
+1199 -20

No files matched your search

+21 -20
View File
@@ -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.
+24
View File
@@ -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"`
}
+51
View File
@@ -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)
}
}
+99
View File
@@ -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
}
+93
View File
@@ -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)
}
}
+129
View File
@@ -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
}
+50
View File
@@ -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)
}
}
+105
View File
@@ -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
}
+54
View File
@@ -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)
}
}
+298
View File
@@ -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
}
+275
View File
@@ -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)
}
}