Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions internal/tools/bash/executor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package bash

import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"

"neo-code/internal/security"
"neo-code/internal/tools"
)

// SecurityExecutor is the bash execution boundary that enforces workspace and
// runtime safety checks before running a shell command.
type SecurityExecutor interface {
Execute(ctx context.Context, call tools.ToolCallInput, command string, requestedWorkdir string) (tools.ToolResult, error)
}

type commandRunner interface {
CombinedOutput(ctx context.Context, binary string, args []string, workdir string) ([]byte, error)
}

type execCommandRunner struct{}

func (execCommandRunner) CombinedOutput(
ctx context.Context,
binary string,
args []string,
workdir string,
) ([]byte, error) {
cmd := exec.CommandContext(ctx, binary, args...)
cmd.Dir = workdir
return cmd.CombinedOutput()
}

type defaultSecurityExecutor struct {
root string
shell string
timeout time.Duration
runner commandRunner
}

// NewDefaultSecurityExecutor returns the default secure bash executor.
func NewDefaultSecurityExecutor(root string, shell string, timeout time.Duration) SecurityExecutor {
return &defaultSecurityExecutor{
root: root,
shell: shell,
timeout: timeout,
runner: execCommandRunner{},
}
}

func (e *defaultSecurityExecutor) Execute(
ctx context.Context,
call tools.ToolCallInput,
command string,
requestedWorkdir string,
) (tools.ToolResult, error) {
if strings.TrimSpace(command) == "" {
err := errors.New("bash: command is empty")
return tools.NewErrorResult("bash", tools.NormalizeErrorReason("bash", err), "", nil), err
}

base := strings.TrimSpace(call.Workdir)
if base == "" {
base = e.root
}
_, workdir, err := tools.ResolveWorkspaceTarget(
call,
security.TargetTypeDirectory,
base,
requestedWorkdir,
resolveWorkdir,
)
if err != nil {
return tools.NewErrorResult("bash", tools.NormalizeErrorReason("bash", err), "", nil), err
}

runCtx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()

binary, args := shellCommand(e.shell, command)
output, runErr := e.runner.CombinedOutput(runCtx, binary, args, workdir)
content := string(output)
if runErr != nil {
result := tools.NewErrorResult(
"bash",
tools.NormalizeErrorReason("bash", runErr),
content,
map[string]any{"workdir": workdir},
)
result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes)
return result, runErr
}

result := tools.ToolResult{
Name: "bash",
Content: content,
Metadata: map[string]any{
"workdir": workdir,
},
}
result = tools.ApplyOutputLimit(result, tools.DefaultOutputLimitBytes)
return result, nil
}

func shellCommand(shell string, command string) (string, []string) {
switch strings.ToLower(strings.TrimSpace(shell)) {
case "powershell", "pwsh":
return "powershell", []string{"-NoProfile", "-Command", command}
case "bash":
return "bash", []string{"-lc", command}
case "sh":
return "sh", []string{"-lc", command}
}

if runtime.GOOS == "windows" {
return "powershell", []string{"-NoProfile", "-Command", command}
}
return "sh", []string{"-lc", command}
}

func resolveWorkdir(root string, requested string) (string, error) {
if strings.ContainsRune(root, '\x00') || strings.ContainsRune(requested, '\x00') {
return "", errors.New("bash: invalid path contains NUL")
}
base, err := filepath.Abs(root)
if err != nil {
return "", err
}
target := requested
if strings.TrimSpace(target) == "" {
target = base
} else if !filepath.IsAbs(target) {
target = filepath.Join(base, target)
}
target, err = filepath.Abs(target)
if err != nil {
return "", err
}
rel, err := filepath.Rel(base, target)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", errors.New("bash: workdir escapes workspace root")
}
return target, nil
}
203 changes: 203 additions & 0 deletions internal/tools/bash/executor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package bash

import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"

"neo-code/internal/tools"
)

type stubRunner struct {
run func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error)
}

func (r stubRunner) CombinedOutput(
ctx context.Context,
binary string,
args []string,
workdir string,
) ([]byte, error) {
if r.run != nil {
return r.run(ctx, binary, args, workdir)
}
return []byte("ok"), nil
}

func TestDefaultSecurityExecutorExecute(t *testing.T) {
t.Parallel()

workspace := t.TempDir()
executor := &defaultSecurityExecutor{
root: workspace,
shell: defaultShell(),
timeout: 20 * time.Millisecond,
runner: stubRunner{},
}

tests := []struct {
name string
callWorkdir string
command string
requestedDir string
overrideRun func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error)
expectErr string
expectResult []string
expectMeta string
}{
{
name: "rejects empty command",
command: "",
callWorkdir: workspace,
expectErr: "command is empty",
},
{
name: "rejects escaped workdir",
command: "echo hi",
callWorkdir: workspace,
requestedDir: "..",
expectErr: "workdir escapes workspace root",
},
{
name: "handles timeout from runner context",
command: "slow",
callWorkdir: workspace,
overrideRun: func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) {
<-ctx.Done()
return nil, ctx.Err()
},
expectErr: context.DeadlineExceeded.Error(),
expectResult: []string{"tool error", "tool: bash"},
expectMeta: workspace,
},
{
name: "applies output truncation on error details",
command: "boom",
callWorkdir: workspace,
overrideRun: func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) {
return []byte(strings.Repeat("x", tools.DefaultOutputLimitBytes+100)), errors.New("exit status 1")
},
expectErr: "exit status 1",
expectResult: []string{"...[truncated]"},
expectMeta: workspace,
},
{
name: "success returns output and metadata",
command: "ok",
callWorkdir: workspace,
overrideRun: func(ctx context.Context, binary string, args []string, workdir string) ([]byte, error) {
return []byte("hello"), nil
},
expectResult: []string{"hello"},
expectMeta: workspace,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
runner := stubRunner{}
if tt.overrideRun != nil {
runner.run = tt.overrideRun
}
executor.runner = runner

result, err := executor.Execute(
context.Background(),
tools.ToolCallInput{Workdir: tt.callWorkdir},
tt.command,
tt.requestedDir,
)

if tt.expectErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.expectErr) {
t.Fatalf("expected error containing %q, got %v", tt.expectErr, err)
}
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}

for _, fragment := range tt.expectResult {
if !strings.Contains(result.Content, fragment) {
t.Fatalf("expected content containing %q, got %q", fragment, result.Content)
}
}
if tt.expectMeta != "" {
got, _ := result.Metadata["workdir"].(string)
if !strings.Contains(got, tt.expectMeta) {
t.Fatalf("expected workdir metadata containing %q, got %q", tt.expectMeta, got)
}
}
})
}
}

func TestShellCommand(t *testing.T) {
t.Parallel()

tests := []struct {
name string
shell string
binary string
}{
{name: "powershell", shell: "powershell", binary: "powershell"},
{name: "pwsh", shell: "pwsh", binary: "powershell"},
{name: "bash", shell: "bash", binary: "bash"},
{name: "sh", shell: "sh", binary: "sh"},
{name: "fallback", shell: "unknown", binary: defaultShell()},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
binary, args := shellCommand(tt.shell, "echo hi")
if binary != tt.binary {
t.Fatalf("shellCommand(%q) binary=%q, want %q", tt.shell, binary, tt.binary)
}
if len(args) == 0 {
t.Fatalf("expected non-empty args")
}
})
}
}

func TestResolveWorkdir(t *testing.T) {
t.Parallel()

workspace := t.TempDir()
tests := []struct {
name string
root string
requested string
expectErr string
}{
{name: "uses root for empty requested", root: workspace, requested: ""},
{name: "resolves relative path", root: workspace, requested: "sub"},
{name: "rejects traversal", root: workspace, requested: "..", expectErr: "escapes workspace root"},
{name: "rejects invalid root", root: string([]byte{0}), requested: "", expectErr: "invalid"},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
target, err := resolveWorkdir(tt.root, tt.requested)
if tt.expectErr != "" {
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.expectErr)) {
t.Fatalf("expected error containing %q, got %v", tt.expectErr, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.requested != "" && !filepath.IsAbs(target) {
t.Fatalf("expected absolute target, got %q", target)
}
})
}
}
6 changes: 4 additions & 2 deletions internal/tools/bash/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ func TestToolHelpers(t *testing.T) {
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got := New(t.TempDir(), tt.shell, time.Second).shellArgs("echo hi")
binary, args := shellCommand(tt.shell, "echo hi")
got := append([]string{binary}, args...)
if len(got) < len(tt.want) {
t.Fatalf("expected shell args prefix %v, got %v", tt.want, got)
}
Expand All @@ -57,7 +58,8 @@ func TestToolHelpers(t *testing.T) {
}

t.Run("default shell args", func(t *testing.T) {
got := New(t.TempDir(), "unknown", time.Second).shellArgs("echo hi")
binary, args := shellCommand("unknown", "echo hi")
got := append([]string{binary}, args...)
if goruntime.GOOS == "windows" {
if got[0] != "powershell" {
t.Fatalf("expected windows fallback to powershell, got %v", got)
Expand Down
Loading
Loading