438 lines
12 KiB
Go
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}
|
|
}
|