diff --git a/internal/config/config.go b/internal/config/config.go index a8dc016..478d6d4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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. diff --git a/internal/config/sandbox.go b/internal/config/sandbox.go new file mode 100644 index 0000000..5624a4c --- /dev/null +++ b/internal/config/sandbox.go @@ -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"` +} diff --git a/internal/config/sandbox_test.go b/internal/config/sandbox_test.go new file mode 100644 index 0000000..4dc7d49 --- /dev/null +++ b/internal/config/sandbox_test.go @@ -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) + } +} diff --git a/internal/runtime/exec/file.go b/internal/runtime/exec/file.go new file mode 100644 index 0000000..d282f59 --- /dev/null +++ b/internal/runtime/exec/file.go @@ -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 +} diff --git a/internal/runtime/exec/file_test.go b/internal/runtime/exec/file_test.go new file mode 100644 index 0000000..606ed56 --- /dev/null +++ b/internal/runtime/exec/file_test.go @@ -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) + } +} diff --git a/internal/runtime/exec/provider.go b/internal/runtime/exec/provider.go new file mode 100644 index 0000000..6d634b4 --- /dev/null +++ b/internal/runtime/exec/provider.go @@ -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 +} diff --git a/internal/runtime/exec/provider_test.go b/internal/runtime/exec/provider_test.go new file mode 100644 index 0000000..0bfa334 --- /dev/null +++ b/internal/runtime/exec/provider_test.go @@ -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) + } +} diff --git a/internal/runtime/exec/sandbox.go b/internal/runtime/exec/sandbox.go new file mode 100644 index 0000000..7a81760 --- /dev/null +++ b/internal/runtime/exec/sandbox.go @@ -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 +} diff --git a/internal/runtime/exec/sandbox_test.go b/internal/runtime/exec/sandbox_test.go new file mode 100644 index 0000000..dab4176 --- /dev/null +++ b/internal/runtime/exec/sandbox_test.go @@ -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) + } +} diff --git a/internal/runtime/exec/terminal.go b/internal/runtime/exec/terminal.go new file mode 100644 index 0000000..bafdb0f --- /dev/null +++ b/internal/runtime/exec/terminal.go @@ -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 +} diff --git a/internal/runtime/exec/terminal_test.go b/internal/runtime/exec/terminal_test.go new file mode 100644 index 0000000..fe21157 --- /dev/null +++ b/internal/runtime/exec/terminal_test.go @@ -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", " 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) + } +}