package tools import ( "context" "encoding/json" "errors" "strings" "testing" "time" ) func TestScopeMatches(t *testing.T) { cases := []struct { pattern string scope string want bool }{ // "cmd *" word-boundary prefix (maki semantics). {"pwd *", "pwd", true}, {"pwd *", "pwd -L", true}, {"pwd *", "pwdx", false}, {"pwd *", "pwd secret", true}, {"git diff *", "git diff", true}, {"git diff *", "git diff HEAD~1", true}, {"git diff *", "git difftool", false}, // Universal wildcards. {"*", "anything at all", true}, {"**", "anything", false}, // not a universal form; exact match // Bare "*" raw prefix. {"pfx*", "pfxthing", true}, {"pfx*", "pfz", false}, // Exact. {"go version", "go version", true}, {"go version", "go version -m", false}, // Path prefix. {"/tmp/work/**", "/tmp/work", true}, {"/tmp/work/**", "/tmp/work/sub/f", true}, {"/tmp/work/**", "/tmp/workx/f", false}, {"/tmp/work/**", "/tmp/other", false}, } for _, tc := range cases { if got := ScopeMatches(tc.pattern, tc.scope); got != tc.want { t.Errorf("ScopeMatches(%q, %q) = %v, want %v", tc.pattern, tc.scope, got, tc.want) } } } func TestSplitCommand(t *testing.T) { cases := []struct { cmd string want []string }{ {"ls -la", []string{"ls -la"}}, {"git diff && rm -rf /", []string{"git diff", "rm -rf /"}}, {"a || b ; c", []string{"a", "b", "c"}}, {"cat f | grep x", []string{"cat f", "grep x"}}, {`echo "a && b"`, []string{`echo "a && b"`}}, {`echo 'p | q'`, []string{`echo 'p | q'`}}, {" ", nil}, {"echo hi > out.txt", []string{"echo hi > out.txt"}}, } for _, tc := range cases { got := SplitCommand(tc.cmd) if len(got) != len(tc.want) { t.Errorf("SplitCommand(%q) = %v, want %v", tc.cmd, got, tc.want) continue } for i := range got { if got[i] != tc.want[i] { t.Errorf("SplitCommand(%q)[%d] = %q, want %q", tc.cmd, i, got[i], tc.want[i]) } } } } func newTool(t *testing.T, allow, deny []string) *BashTool { t.Helper() b, err := NewBashTool(BashOptions{ WorkRoot: t.TempDir(), Allow: allow, Deny: deny, Timeout: 5 * time.Second, MaxOutputBytes: 100_000, }) if err != nil { t.Fatal(err) } return b } func TestCheckGate(t *testing.T) { cases := []struct { name string allow []string deny []string cmd string ok bool want string // substring of denial reason when !ok }{ {"simple allow", []string{"echo *"}, nil, "echo hi", true, ""}, {"exact allow", []string{"pwd"}, nil, "pwd", true, ""}, {"uncovered", []string{"echo *"}, nil, "ls -la", false, "not covered by any allow rule"}, {"compound all allowed", []string{"git *"}, nil, "git diff && git status", true, ""}, {"compound one bad", []string{"git *"}, nil, "git diff && rm -rf /", false, `segment "rm -rf /"`}, {"deny beats allow", []string{"*"}, []string{"sudo *"}, "sudo id", false, `matches deny rule "sudo *"`}, {"deny beats allow compound", []string{"*"}, []string{"rm -rf *"}, "git status && rm -rf /", false, "deny rule"}, {"pipe splits", []string{"cat *", "grep *"}, nil, "cat f | grep x", true, ""}, {"pipe uncovered", []string{"cat *"}, nil, "cat f | grep x", false, `segment "grep x"`}, {"command substitution denied", []string{"*"}, nil, "echo $(rm -rf /)", false, "not decomposable"}, {"backticks denied", []string{"*"}, nil, "echo `id`", false, "not decomposable"}, {"subshell denied", []string{"*"}, nil, "(cd / && rm -rf /)", false, "not decomposable"}, {"empty", []string{"*"}, nil, " ", false, "empty command"}, {"word boundary", []string{"pwd *"}, nil, "pwdx", false, "not covered"}, {"redirect is part of scope", []string{"go *"}, nil, "go test > /tmp/out", true, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { b := newTool(t, tc.allow, tc.deny) err := b.Check(tc.cmd) if tc.ok { if err != nil { t.Fatalf("Check(%q) = %v, want nil", tc.cmd, err) } return } if !errors.Is(err, ErrDenied) { t.Fatalf("Check(%q) = %v, want ErrDenied", tc.cmd, err) } if !strings.Contains(err.Error(), tc.want) { t.Fatalf("denial %q does not contain %q", err, tc.want) } }) } } func TestDefaultAllow(t *testing.T) { b, err := NewBashTool(BashOptions{ WorkRoot: t.TempDir(), Allow: nil, DefaultAllow: true, }) if err != nil { t.Fatal(err) } if err := b.Check("anything --at --all"); err != nil { t.Errorf("default allow should pass uncovered scopes: %v", err) } } func TestRun(t *testing.T) { t.Run("allowed echo", func(t *testing.T) { b := newTool(t, []string{"echo *"}, nil) out, err := b.Run(context.Background(), json.RawMessage(`{"command":"echo hello mopac"}`)) if err != nil { t.Fatalf("Run: %v", err) } if !strings.Contains(out, "hello mopac") { t.Errorf("output = %q", out) } }) t.Run("denied returns ErrDenied without executing", func(t *testing.T) { b := newTool(t, []string{"echo *"}, []string{"sudo *"}) out, err := b.Run(context.Background(), json.RawMessage(`{"command":"sudo rm -rf /"}`)) if !errors.Is(err, ErrDenied) { t.Fatalf("err = %v, want ErrDenied", err) } if out != "" { t.Errorf("denied run must not produce output, got %q", out) } }) t.Run("nonzero exit reports output", func(t *testing.T) { b := newTool(t, []string{"false"}, nil) out, err := b.Run(context.Background(), json.RawMessage(`{"command":"false"}`)) if err == nil { t.Fatal("expected error for exit 1") } if out != "" { t.Errorf("no output expected, got %q", out) } }) t.Run("timeout kills command", func(t *testing.T) { b, err := NewBashTool(BashOptions{ WorkRoot: t.TempDir(), Allow: []string{"sleep *"}, Timeout: 300 * time.Millisecond, }) if err != nil { t.Fatal(err) } start := time.Now() _, err = b.Run(context.Background(), json.RawMessage(`{"command":"sleep 30"}`)) if err == nil || !strings.Contains(err.Error(), "timed out") { t.Fatalf("err = %v, want timeout", err) } if elapsed := time.Since(start); elapsed > 5*time.Second { t.Fatalf("timeout did not fire, took %s", elapsed) } }) t.Run("output truncation", func(t *testing.T) { b, err := NewBashTool(BashOptions{ WorkRoot: t.TempDir(), Allow: []string{"echo *"}, Timeout: 5 * time.Second, MaxOutputBytes: 100, }) if err != nil { t.Fatal(err) } out, err := b.Run(context.Background(), json.RawMessage(`{"command":"echo 012345678901234567890123456789012345678901456789012345678901234567890123456789012345678901234567890123456789"}`)) if err != nil { t.Fatalf("Run: %v", err) } if !strings.Contains(out, "[truncated") { t.Errorf("expected truncation marker in %q", out) } if len(out) > 200 { t.Errorf("output not truncated: %d bytes", len(out)) } }) t.Run("bad json args", func(t *testing.T) { b := newTool(t, []string{"echo *"}, nil) if _, err := b.Run(context.Background(), json.RawMessage(`{"command": 42}`)); err == nil { t.Fatal("expected arg error") } }) } func TestDefinitionSchema(t *testing.T) { b := newTool(t, nil, nil) def := b.Definition() if def.Function.Name != "bash" || def.Type != "function" { t.Errorf("definition = %+v", def) } if def.Function.Parameters["required"] == nil { t.Error("parameters must mark command required") } // Sanity: the schema must marshal (it goes straight into requests). if _, err := json.Marshal(def); err != nil { t.Fatal(err) } }