feat(tools): opt-in shell tool without interpreter: allowlist per chain element, no expansions/redirections, workspace-only, timeout, output limit, scrubbed env
This commit is contained in:
parent
1e32997504
commit
406a523d54
5 changed files with 464 additions and 0 deletions
108
internal/tools/shell_test.go
Normal file
108
internal/tools/shell_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestShell(t *testing.T) *Shell {
|
||||
t.Helper()
|
||||
s, err := NewShell(t.TempDir(), []string{"echo", "ls", "cat", "wc", "printenv", "sleep", "false", "true", "head", "find"}, 2*time.Second, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func run(t *testing.T, s *Shell, cmd string) (string, error) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(map[string]string{"command": cmd})
|
||||
return s.Call(context.Background(), b)
|
||||
}
|
||||
|
||||
func TestShellRejectsBypassAttempts(t *testing.T) {
|
||||
s := newTestShell(t)
|
||||
for _, c := range []string{
|
||||
"rm -rf x", "echo hi && rm x", "echo hi; rm x", "echo hi | sh", "false || bash",
|
||||
"echo $(id)", "echo `id`", "cat <(id)", "echo ${HOME}", "echo $HOME", "echo hi > f", "cat < /etc/passwd",
|
||||
"cat /etc/passwd", "cat ../secret", "cat a/../../secret", "ls ~", "cat --file=/etc/passwd",
|
||||
"sleep 10 &", "(echo hi)", "{ echo hi; }", "/bin/echo hi", "./echo hi", "echo hi\nid",
|
||||
"echo 'unterminated", "echo a\\;id", "find . -exec id ;", "find . -delete", "", "echo hi &&", "| echo",
|
||||
"e''cho hi; r''m x",
|
||||
} {
|
||||
if _, err := s.Parse(c); err == nil || !errors.Is(err, ErrRejected) {
|
||||
t.Errorf("%q should be rejected, got %v", c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellNeverAllowed(t *testing.T) {
|
||||
for _, c := range []string{"sh", "bash", "env", "xargs", "python3", "/bin/ls"} {
|
||||
if _, err := NewShell(t.TempDir(), []string{c}, time.Second, 10); err == nil {
|
||||
t.Errorf("%q must not be allowlistable", c)
|
||||
}
|
||||
}
|
||||
if _, err := NewShell(t.TempDir(), nil, time.Second, 10); err == nil {
|
||||
t.Error("empty allowlist must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRunsChainsAndPipes(t *testing.T) {
|
||||
s := newTestShell(t)
|
||||
os.WriteFile(filepath.Join(s.Dir, "f.txt"), []byte("a\nb\nc\n"), 0o600)
|
||||
out, err := run(t, s, `cat f.txt | wc -l && echo "ok yes" ; false || echo fallback`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "3\nok yes\nfallback") || !strings.HasSuffix(out, "[exit 0]") {
|
||||
t.Fatalf("%q", out)
|
||||
}
|
||||
out, _ = run(t, s, "false && echo never")
|
||||
if strings.Contains(out, "never") || !strings.HasSuffix(out, "[exit 1]") {
|
||||
t.Fatalf("%q", out)
|
||||
}
|
||||
// metacharacters inside quotes are literal arguments, not operators
|
||||
out, _ = run(t, s, `echo "a && b | c ; d"`)
|
||||
if !strings.HasPrefix(out, "a && b | c ; d") {
|
||||
t.Fatalf("%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellEnvironmentIsScrubbed(t *testing.T) {
|
||||
t.Setenv("BYTECLAW_AUTH_TOKEN", "supersecret")
|
||||
t.Setenv("DAHL_API_KEY", "dahl_secret")
|
||||
s := newTestShell(t)
|
||||
out, _ := run(t, s, "printenv")
|
||||
if strings.Contains(out, "secret") || !strings.Contains(out, "HOME="+s.Dir) {
|
||||
t.Fatalf("env leaked or wrong: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellRunsInWorkspace(t *testing.T) {
|
||||
s := newTestShell(t)
|
||||
os.WriteFile(filepath.Join(s.Dir, "inside.txt"), nil, 0o600)
|
||||
if out, _ := run(t, s, "ls"); !strings.Contains(out, "inside.txt") {
|
||||
t.Fatalf("%q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellTimeoutAndOutputLimit(t *testing.T) {
|
||||
s := newTestShell(t)
|
||||
s.Timeout = 300 * time.Millisecond
|
||||
start := time.Now()
|
||||
out, _ := run(t, s, "sleep 5")
|
||||
if time.Since(start) > 3*time.Second || !strings.Contains(out, "timeout") {
|
||||
t.Fatalf("timeout not enforced: %q after %s", out, time.Since(start))
|
||||
}
|
||||
s.Timeout = 2 * time.Second
|
||||
out, _ = run(t, s, "echo "+strings.Repeat("x", 500))
|
||||
if !strings.Contains(out, "[output truncated]") || len(out) > 300 {
|
||||
t.Fatalf("limit not enforced: %d", len(out))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue