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

+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)
}
}