Files
enkly-ai/services/workspace/internal/infrastructure/sandboxfs/sandbox.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

438 lines
12 KiB
Go

package sandboxfs
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// This file implements the run_bash_command tool's sandbox. The name is
// historical: nothing here ever invokes a shell. The model's command
// string is tokenized into an argv slice and executed directly with
// os/exec, so there is no shell to inject into.
//
// Layers of defense, outermost first:
//
// 1. Tokenizer: a command string containing an unquoted shell
// metacharacter (| & ; < > ` $ or a newline) is rejected with a clear
// message instead of being silently passed through as literal
// arguments, which would confuse the model.
// 2. Allowlist on the base binary ONLY (first token, no path separators).
// Subcommand-level restrictions (go, git) are enforced separately, in
// code, below - never by string-matching the allowlist.
// 3. Per-binary flag denylist for flags that execute other programs or
// follow symlinks (find -exec, go test -exec, grep -R, ...).
// 4. Path validation: every argument that could be a path is resolved
// (symlinks included) and must land inside WorkspaceRoot.
// 5. A scrubbed environment (no DB DSN / SMTP / Stripe secrets reach the
// child), a wall-clock timeout, and a cap on captured output.
//
// Residual risk, stated plainly: `go test` and `go build` compile and run
// workspace code, and that code can do anything the enkly process can.
// Run enkly in a container (docker-compose.yml does) for real isolation.
// DefaultAllowedCommands is the default set of base binaries the agent may
// run. Override with ENKLY_BASH_ALLOWLIST (comma-separated base binaries).
var DefaultAllowedCommands = []string{"go", "git", "ls", "cat", "grep", "find", "gofmt"}
// allowedSubcommands restricts binaries that have dangerous subcommands.
// A binary that is not a key here has no subcommand restriction.
var allowedSubcommands = map[string]map[string]bool{
"go": {"build": true, "test": true, "vet": true, "fmt": true, "list": true, "version": true, "mod": true},
"git": {"status": true, "diff": true, "log": true, "show": true, "branch": true,
"rev-parse": true, "ls-files": true, "blame": true},
}
// deniedFlags are flags (matched exactly, or as a "--flag=value" prefix)
// that let an otherwise-harmless binary run other programs or follow
// symlinks out of the workspace.
var deniedFlags = map[string][]string{
"find": {"-exec", "-execdir", "-ok", "-okdir", "-delete", "-fprint", "-fprint0", "-fprintf", "-fls", "-L", "-H", "-follow"},
"grep": {"-R", "--dereference-recursive"},
"go": {"-exec", "-toolexec", "-vettool", "-overlay", "-modfile"},
"git": {"--output", "--ext-diff", "--textconv", "-c", "--exec-path", "--git-dir", "--work-tree"},
}
// maxCommandOutput bounds how much stdout+stderr is captured. Anything
// beyond it is dropped with a marker, so a runaway `cat` of a huge file
// can't balloon memory or flood the model's context.
const maxCommandOutput = 64 * 1024
// ParseAllowlist parses ENKLY_BASH_ALLOWLIST. Entries are base binaries
// only; anything with a space or a path separator is rejected so the
// ambiguous "go vet" / "go test" style entries can't creep back in.
func ParseAllowlist(raw string) ([]string, error) {
var out []string
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if strings.ContainsAny(part, " \t/\\") {
return nil, fmt.Errorf("allowlist entry %q must be a bare binary name (subcommand rules live in code)", part)
}
out = append(out, part)
}
if len(out) == 0 {
return nil, errors.New("allowlist is empty")
}
return out, nil
}
func (t *Toolbox) allowed(bin string) bool {
list := t.AllowedCommands
if len(list) == 0 {
list = DefaultAllowedCommands
}
for _, a := range list {
if a == bin {
return true
}
}
return false
}
// tokenize splits a command line into argv, honoring single quotes,
// double quotes and backslash escapes, and refusing unquoted shell
// metacharacters.
func tokenize(s string) ([]string, error) {
var (
args []string
cur strings.Builder
inTok bool
quote rune
escaped bool
)
flush := func() {
if inTok {
args = append(args, cur.String())
cur.Reset()
inTok = false
}
}
for _, r := range s {
switch {
case escaped:
cur.WriteRune(r)
inTok = true
escaped = false
case quote == '\'':
if r == '\'' {
quote = 0
} else {
cur.WriteRune(r)
}
case quote == '"':
switch r {
case '"':
quote = 0
case '\\':
escaped = true
case '$', '`':
return nil, fmt.Errorf("%q is not supported inside double quotes (no shell expansion happens here); use single quotes", string(r))
default:
cur.WriteRune(r)
}
case r == '\\':
escaped = true
inTok = true
case r == '\'' || r == '"':
quote = r
inTok = true
case r == ' ' || r == '\t':
flush()
case strings.ContainsRune("|&;<>`$\n\r*?", r):
return nil, fmt.Errorf("shell syntax %q is not supported: commands run directly, without a shell (no pipes, redirects, globbing, substitution or chaining) - run one simple command per call", string(r))
default:
cur.WriteRune(r)
inTok = true
}
}
if quote != 0 {
return nil, errors.New("unterminated quote")
}
if escaped {
return nil, errors.New("trailing backslash")
}
flush()
return args, nil
}
func flagDenied(bin, arg string) bool {
for _, d := range deniedFlags[bin] {
if arg == d || strings.HasPrefix(arg, d+"=") {
return true
}
}
return false
}
// checkArgs validates the argv (already known to start with an allowlisted
// binary) and returns a user-facing error describing the first violation.
func (t *Toolbox) checkArgs(argv []string) error {
bin := argv[0]
rest := argv[1:]
if subs, ok := allowedSubcommands[bin]; ok {
if len(rest) == 0 {
return fmt.Errorf("%s needs a subcommand; allowed: %s", bin, sortedKeys(subs))
}
if !subs[rest[0]] {
return fmt.Errorf("%s %s is not allowed; allowed subcommands: %s", bin, rest[0], sortedKeys(subs))
}
}
// grep's first positional argument is a regex, not a path (unless the
// pattern came from -e/--regexp, in which case every positional is a path).
skipPattern := bin == "grep"
for _, a := range rest {
if a == "-e" || strings.HasPrefix(a, "--regexp") {
skipPattern = false
}
}
for _, a := range rest {
if flagDenied(bin, a) {
return fmt.Errorf("flag %s is not allowed for %s", a, bin)
}
if strings.HasPrefix(a, "-") && len(a) > 1 {
// Flag. Validate a value glued on with "=" or a slash.
if k := strings.Index(a, "="); k >= 0 {
if err := t.checkPathArg(a[k+1:]); err != nil {
return err
}
} else if k := strings.Index(a, "/"); k >= 0 {
if err := t.checkPathArg(a[k:]); err != nil {
return err
}
}
continue
}
if skipPattern {
// The first non-flag argument to grep is the pattern.
skipPattern = false
continue
}
// `go test ./...`-style package patterns and git revisions are not
// files, but they are also harmless to run through the path check:
// they resolve inside the workspace or don't exist.
if err := t.checkPathArg(a); err != nil {
return err
}
}
return nil
}
func sortedKeys(m map[string]bool) string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, ", ")
}
// checkPathArg rejects an argument that resolves outside WorkspaceRoot.
// Arguments that are not paths at all (a package name, a git revision)
// simply don't exist on disk and pass as long as they don't look like an
// escape (absolute or containing "..").
func (t *Toolbox) checkPathArg(arg string) error {
if arg == "" {
return nil
}
root, err := filepath.EvalSymlinks(t.WorkspaceRoot)
if err != nil {
return fmt.Errorf("workspace root: %w", err)
}
var full string
if filepath.IsAbs(arg) {
full = filepath.Clean(arg)
} else {
full = filepath.Join(t.WorkspaceRoot, arg)
}
looksLikeEscape := filepath.IsAbs(arg) || hasDotDot(arg)
resolved, exists := evalExisting(full)
if !exists && !looksLikeEscape {
return nil
}
if !within(root, resolved) {
return fmt.Errorf("argument %q resolves outside the workspace; use paths inside the project", arg)
}
return nil
}
func hasDotDot(p string) bool {
for _, seg := range strings.FieldsFunc(p, func(r rune) bool { return r == '/' || r == '\\' }) {
if seg == ".." {
return true
}
}
return false
}
// evalExisting resolves symlinks in the longest existing prefix of p and
// re-appends the non-existent tail. exists reports whether the whole path
// exists.
func evalExisting(p string) (resolved string, exists bool) {
if r, err := filepath.EvalSymlinks(p); err == nil {
return r, true
}
tail := ""
cur := p
for {
parent := filepath.Dir(cur)
tail = filepath.Join(filepath.Base(cur), tail)
if r, err := filepath.EvalSymlinks(parent); err == nil {
return filepath.Join(r, tail), false
}
if parent == cur {
return p, false
}
cur = parent
}
}
// within reports whether path is root itself or nested under it, comparing
// whole path segments (so /ws-evil is NOT within /ws).
func within(root, path string) bool {
rel, err := filepath.Rel(root, path)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// cappedBuffer is a mutex-guarded writer that stops growing at max bytes.
type cappedBuffer struct {
mu sync.Mutex
buf bytes.Buffer
max int
truncated bool
}
func (c *cappedBuffer) Write(p []byte) (int, error) {
c.mu.Lock()
defer c.mu.Unlock()
room := c.max - c.buf.Len()
if room <= 0 {
c.truncated = true
return len(p), nil // swallow, so the child never blocks on a full pipe
}
if len(p) > room {
c.buf.Write(p[:room])
c.truncated = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *cappedBuffer) String() string {
c.mu.Lock()
defer c.mu.Unlock()
s := strings.ToValidUTF8(c.buf.String(), "\uFFFD")
if c.truncated {
s += fmt.Sprintf("\n... [output truncated at %d bytes]", c.max)
}
return s
}
// scrubbedEnv builds the child's environment from a small allowlist so
// secrets held by the enkly process (DB DSN, SMTP and Stripe keys) never
// reach a command the model controls.
func (t *Toolbox) scrubbedEnv() []string {
keep := []string{"PATH", "HOME", "LANG", "TMPDIR", "GOPATH", "GOCACHE", "GOMODCACHE", "GOROOT"}
env := make([]string, 0, len(keep)+6)
for _, k := range keep {
if v, ok := os.LookupEnv(k); ok {
env = append(env, k+"="+v)
}
}
env = append(env,
"GOPROXY=off", // the stack must not need the internet
"GOTOOLCHAIN=local",
"GIT_TERMINAL_PROMPT=0",
"GIT_CONFIG_NOSYSTEM=1",
"GIT_PAGER=cat",
"PAGER=cat",
)
return env
}
func (t *Toolbox) runBashCommand(ctx context.Context, call ToolCallRequest) ToolResult {
fail := func(format string, a ...any) ToolResult {
return ToolResult{Name: call.Name, Content: "run_bash_command: " + fmt.Sprintf(format, a...), IsError: true}
}
command, _ := call.Arguments["command"].(string)
if strings.TrimSpace(command) == "" {
return fail("empty command")
}
argv, err := tokenize(command)
if err != nil {
return fail("%v", err)
}
if len(argv) == 0 {
return fail("empty command")
}
bin := argv[0]
if strings.ContainsAny(bin, `/\`) {
return fail("run the binary by name (e.g. %q), not by path", filepath.Base(bin))
}
if !t.allowed(bin) {
list := t.AllowedCommands
if len(list) == 0 {
list = DefaultAllowedCommands
}
return fail("%q is not an allowed command; allowed: %s", bin, strings.Join(list, ", "))
}
if err := t.checkArgs(argv); err != nil {
return fail("%v", err)
}
timeout := t.CommandTimeout
if timeout <= 0 {
timeout = DefaultCommandTimeout
}
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// argv slice, never a shell string.
cmd := exec.CommandContext(cmdCtx, bin, argv[1:]...)
cmd.Dir = t.WorkspaceRoot
cmd.Env = t.scrubbedEnv()
cmd.WaitDelay = 2 * time.Second
out := &cappedBuffer{max: maxCommandOutput}
cmd.Stdout = out
cmd.Stderr = out
runErr := cmd.Run()
text := out.String()
if runErr != nil {
msg := runErr.Error()
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
msg = fmt.Sprintf("timed out after %s", timeout)
} else if errors.Is(runErr, exec.ErrNotFound) {
msg = fmt.Sprintf("%s is not installed in this environment", bin)
}
return ToolResult{
Name: call.Name,
Content: fmt.Sprintf("exit error: %s\n\n%s", msg, text),
IsError: true,
}
}
return ToolResult{Name: call.Name, Content: text}
}