721 lines
26 KiB
Go
721 lines
26 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"reasonix/internal/event"
|
|
"strings"
|
|
"testing"
|
|
|
|
"reasonix/internal/planmode"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// planSafeTool wraps fakeTool to self-report a plan-mode stance, mimicking a
|
|
// real tool that implements tool.PlanModeClassifier. Plain fakeTool deliberately
|
|
// does NOT implement the interface, so it models an unclassified/external tool
|
|
// (PlanSafetyUnknown) — relied on by the fail-closed and escape-valve cases below.
|
|
type planSafeTool struct {
|
|
fakeTool
|
|
planSafe bool
|
|
}
|
|
|
|
func (p planSafeTool) PlanModeSafe() bool { return p.planSafe }
|
|
|
|
// TestPlanModeBlocksWriters proves the gate refuses tools that aren't classified
|
|
// plan-safe while letting a self-reported plan-safe tool run. The returned tool
|
|
// result starts with "blocked:" so the model can adapt mid-turn.
|
|
func TestPlanModeBlocksWriters(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(planSafeTool{fakeTool: fakeTool{name: "read_only_tool", readOnly: true}, planSafe: true})
|
|
reg.Add(fakeTool{name: "writer_tool", readOnly: false})
|
|
|
|
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
ro := a.executeOne(context.Background(), provider.ToolCall{Name: "read_only_tool"})
|
|
if !strings.Contains(ro.output, "done") {
|
|
t.Errorf("plan-safe read-only tool in plan mode should still run: %q", ro.output)
|
|
}
|
|
|
|
wr := a.executeOne(context.Background(), provider.ToolCall{Name: "writer_tool"})
|
|
if !strings.HasPrefix(wr.output, "blocked:") {
|
|
t.Errorf("writer tool in plan mode should return a 'blocked:' result, got: %q", wr.output)
|
|
}
|
|
}
|
|
|
|
// TestPlanModeDoesNotMutateSystemOrTools is the cache-stability test. Toggling
|
|
// plan mode between two stream calls must not change the system prompt or the
|
|
// tool list seen by the provider — those are the cache-key prefix, and any
|
|
// change there forces an expensive cache miss.
|
|
func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
|
|
prov := &mockProvider{name: "p", chunks: []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "ok"},
|
|
{Type: provider.ChunkDone},
|
|
}}
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
|
reg.Add(fakeTool{name: "write_file", readOnly: false})
|
|
|
|
a := New(prov, reg, NewSession("STABLE-SYS"), Options{}, event.Discard)
|
|
|
|
// Auto-mode round-trip.
|
|
if err := a.Run(context.Background(), "explore"); err != nil {
|
|
t.Fatalf("auto Run: %v", err)
|
|
}
|
|
autoSys := prov.lastReq.Messages[0]
|
|
autoTools := serializeToolNames(prov.lastReq.Tools)
|
|
|
|
// Flip the gate and run again. The user message changes (new turn), but
|
|
// the system message and the tool list both have to come back identical.
|
|
prov.chunks = []provider.Chunk{
|
|
{Type: provider.ChunkText, Text: "ok"},
|
|
{Type: provider.ChunkDone},
|
|
}
|
|
a.SetPlanMode(true)
|
|
if err := a.Run(context.Background(), "now in plan mode"); err != nil {
|
|
t.Fatalf("plan Run: %v", err)
|
|
}
|
|
planSys := prov.lastReq.Messages[0]
|
|
planTools := serializeToolNames(prov.lastReq.Tools)
|
|
|
|
if planSys.Role != autoSys.Role || planSys.Content != autoSys.Content {
|
|
t.Errorf("system message changed across mode toggle:\n auto: %+v\n plan: %+v", autoSys, planSys)
|
|
}
|
|
if planTools != autoTools {
|
|
t.Errorf("tool list changed across mode toggle:\n auto: %s\n plan: %s", autoTools, planTools)
|
|
}
|
|
}
|
|
|
|
func serializeToolNames(ts []provider.ToolSchema) string {
|
|
var names []string
|
|
for _, t := range ts {
|
|
names = append(names, t.Name)
|
|
}
|
|
return strings.Join(names, ",")
|
|
}
|
|
|
|
func bashCommandArgs(t *testing.T, cmd string) json.RawMessage {
|
|
t.Helper()
|
|
args, err := json.Marshal(struct {
|
|
Command string `json:"command"`
|
|
}{Command: cmd})
|
|
if err != nil {
|
|
t.Fatalf("marshal bash args: %v", err)
|
|
}
|
|
return args
|
|
}
|
|
|
|
// --- planModeBlocked tests ---
|
|
|
|
func TestPlanModeDeniedToolsBlocked(t *testing.T) {
|
|
denied := []string{
|
|
"write_file", "edit_file", "multi_edit", "move_file", "apply_patch",
|
|
"edit_notebook", "notebook_edit", "range_delete", "symbol_delete", "delete_range", "delete_symbol",
|
|
"complete_step", "task", "parallel_tasks", "run_skill",
|
|
"explore", "research", "review", "security_review", "security-review",
|
|
"install_source", "install_skill", "remember", "forget", "kill_shell",
|
|
}
|
|
for _, name := range denied {
|
|
t.Run(name, func(t *testing.T) {
|
|
blocked, msg := (&Agent{}).planModeBlocked(name, false, false, planmode.PlanSafetyUnknown, nil)
|
|
if !blocked {
|
|
t.Errorf("planModeBlocked(%q) = false, want true", name)
|
|
}
|
|
if name == "complete_step" && !strings.Contains(msg, "only available after plan approval") {
|
|
t.Errorf("unexpected complete_step message: %s", msg)
|
|
}
|
|
if name != "complete_step" && !strings.Contains(msg, "not available in plan mode") {
|
|
t.Errorf("unexpected message: %s", msg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanModeReadOnlyToolsAllowed(t *testing.T) {
|
|
// read_file is on the audited read-only whitelist — Unknown safety suffices.
|
|
if blocked, _ := (&Agent{}).planModeBlocked("read_file", true, false, planmode.PlanSafetyUnknown, nil); blocked {
|
|
t.Error("audited read-only tool read_file should not be blocked in plan mode")
|
|
}
|
|
// read_only_skill is not a built-in; it runs only via its plan-safe self-report.
|
|
if blocked, _ := (&Agent{}).planModeBlocked("read_only_skill", true, false, planmode.PlanSafetySafe, nil); blocked {
|
|
t.Error("self-reported plan-safe read_only_skill should not be blocked in plan mode")
|
|
}
|
|
}
|
|
|
|
func TestPlanModeAllowedToolsOverride(t *testing.T) {
|
|
a := &Agent{planModeAllowedTools: []string{"custom_tool"}}
|
|
blocked, _ := a.planModeBlocked("custom_tool", false, false, planmode.PlanSafetyUnknown, nil)
|
|
if blocked {
|
|
t.Error("tool in planModeAllowedTools should not be blocked")
|
|
}
|
|
}
|
|
|
|
func TestPlanModeReadOnlyCommandsOverride(t *testing.T) {
|
|
a := &Agent{planModeReadOnlyCommands: []string{"gh issue view"}}
|
|
blocked, msg := a.planModeBlocked("bash", false, false, planmode.PlanSafetyUnknown, bashCommandArgs(t, "gh issue view 4572 --json title"))
|
|
if blocked {
|
|
t.Fatalf("command in planModeReadOnlyCommands should not be blocked: %s", msg)
|
|
}
|
|
}
|
|
|
|
func TestPlanModeGenericWriterBlocked(t *testing.T) {
|
|
blocked, msg := (&Agent{}).planModeBlocked("some_writer_tool", false, false, planmode.PlanSafetyUnknown, nil)
|
|
if !blocked {
|
|
t.Error("generic writer tool should be blocked in plan mode")
|
|
}
|
|
if !strings.Contains(msg, "writer tool") {
|
|
t.Errorf("unexpected message: %s", msg)
|
|
}
|
|
}
|
|
|
|
// TestPlanModeExternalToolEscapeValve proves an unclassified external tool (an
|
|
// MCP/plugin tool that does not implement PlanModeClassifier, so its safety is
|
|
// Unknown) is fail-closed in plan mode, but can be re-enabled end to end via
|
|
// plan_mode_allowed_tools.
|
|
func TestPlanModeExternalToolEscapeValve(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "mcp__server__query", readOnly: false})
|
|
|
|
failClosed := New(nil, reg, NewSession(""), Options{}, event.Discard)
|
|
failClosed.SetPlanMode(true)
|
|
if out := failClosed.executeOne(context.Background(), provider.ToolCall{Name: "mcp__server__query"}); !strings.HasPrefix(out.output, "blocked:") {
|
|
t.Errorf("unclassified external tool should fail closed in plan mode, got: %q", out.output)
|
|
}
|
|
|
|
a := New(nil, reg, NewSession(""), Options{PlanModeAllowedTools: []string{"mcp__server__query"}}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
if out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__server__query"}); strings.HasPrefix(out.output, "blocked:") {
|
|
t.Errorf("plan_mode_allowed_tools should let the declared external tool run, got: %q", out.output)
|
|
}
|
|
}
|
|
|
|
// untrustedReadOnlyTool models an MCP tool that reports ReadOnly()==true from an
|
|
// untrusted server readOnlyHint (PlanModeUntrustedReadOnly()==true).
|
|
type untrustedReadOnlyTool struct {
|
|
fakeTool
|
|
}
|
|
|
|
func (untrustedReadOnlyTool) PlanModeUntrustedReadOnly() bool { return true }
|
|
|
|
type untrustedMCPReadOnlyTool struct {
|
|
untrustedReadOnlyTool
|
|
server string
|
|
raw string
|
|
}
|
|
|
|
func (t untrustedMCPReadOnlyTool) MCPServerName() string { return t.server }
|
|
func (t untrustedMCPReadOnlyTool) MCPRawToolName() string { return t.raw }
|
|
|
|
type fakePlanModeReadOnlyTrustGate struct {
|
|
allow bool
|
|
reason string
|
|
req PlanModeReadOnlyTrustRequest
|
|
calls int
|
|
}
|
|
|
|
func (g *fakePlanModeReadOnlyTrustGate) CheckPlanModeReadOnlyTrust(ctx context.Context, req PlanModeReadOnlyTrustRequest) (bool, string, error) {
|
|
g.calls++
|
|
g.req = req
|
|
return g.allow, g.reason, nil
|
|
}
|
|
|
|
type readOnlyRecordingGate struct {
|
|
readOnly []bool
|
|
}
|
|
|
|
func (g *readOnlyRecordingGate) Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (bool, string, error) {
|
|
g.readOnly = append(g.readOnly, readOnly)
|
|
if !readOnly {
|
|
return false, "expected read-only", nil
|
|
}
|
|
return true, "", nil
|
|
}
|
|
|
|
// TestPlanModeUntrustedReadOnlyToolFailsClosed proves the gate does NOT trust an
|
|
// MCP tool's self-reported readOnlyHint: a ReadOnly()==true external tool is
|
|
// still fail-closed in plan mode, and runs only once declared in
|
|
// plan_mode_allowed_tools.
|
|
func TestPlanModeUntrustedReadOnlyToolFailsClosed(t *testing.T) {
|
|
mk := func() *tool.Registry {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(untrustedReadOnlyTool{fakeTool{name: "mcp__srv__query", readOnly: true}})
|
|
return reg
|
|
}
|
|
|
|
failClosed := New(nil, mk(), NewSession(""), Options{}, event.Discard)
|
|
failClosed.SetPlanMode(true)
|
|
if out := failClosed.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__query"}); !strings.HasPrefix(out.output, "blocked:") {
|
|
t.Errorf("untrusted read-only MCP tool should fail closed in plan mode, got: %q", out.output)
|
|
}
|
|
|
|
declared := New(nil, mk(), NewSession(""), Options{PlanModeAllowedTools: []string{"mcp__srv__query"}}, event.Discard)
|
|
declared.SetPlanMode(true)
|
|
if out := declared.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__query"}); strings.HasPrefix(out.output, "blocked:") {
|
|
t.Errorf("declared untrusted tool should run in plan mode, got: %q", out.output)
|
|
}
|
|
}
|
|
|
|
func TestPlanModeUntrustedReadOnlyToolCanAskForTrust(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(untrustedMCPReadOnlyTool{
|
|
untrustedReadOnlyTool: untrustedReadOnlyTool{fakeTool{name: "mcp__srv__normalized_query", readOnly: true}},
|
|
server: "srv",
|
|
raw: "raw/query",
|
|
})
|
|
gate := &fakePlanModeReadOnlyTrustGate{allow: true}
|
|
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__normalized_query", Arguments: `{"q":"x"}`})
|
|
if strings.HasPrefix(out.output, "blocked:") || !strings.Contains(out.output, "done") {
|
|
t.Fatalf("trusted untrusted-read-only MCP tool should run, got: %q", out.output)
|
|
}
|
|
if gate.calls != 1 {
|
|
t.Fatalf("trust gate calls = %d, want 1", gate.calls)
|
|
}
|
|
if gate.req.ServerName != "srv" || gate.req.RawToolName != "raw/query" {
|
|
t.Fatalf("trust request target = %+v, want raw MCP identity", gate.req)
|
|
}
|
|
if gate.req.ToolName != "mcp__srv__normalized_query" || string(gate.req.Args) != `{"q":"x"}` {
|
|
t.Fatalf("trust request call metadata = %+v", gate.req)
|
|
}
|
|
}
|
|
|
|
func TestPlanModeUntrustedReadOnlyToolTrustDeclineBlocks(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(untrustedReadOnlyTool{fakeTool{name: "mcp__srv__query", readOnly: true}})
|
|
gate := &fakePlanModeReadOnlyTrustGate{allow: false, reason: "not trusted"}
|
|
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__query"})
|
|
if !strings.Contains(out.output, "not trusted") {
|
|
t.Fatalf("declined trust should block with reason, got: %q", out.output)
|
|
}
|
|
if gate.req.ServerName != "srv" || gate.req.RawToolName != "query" {
|
|
t.Fatalf("fallback trust request target = %+v", gate.req)
|
|
}
|
|
}
|
|
|
|
type unsafeUntrustedReadOnlyTool struct {
|
|
untrustedReadOnlyTool
|
|
}
|
|
|
|
func (unsafeUntrustedReadOnlyTool) PlanModeSafe() bool { return false }
|
|
|
|
func TestPlanModeUnsafeUntrustedReadOnlyToolDoesNotAskForTrust(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(unsafeUntrustedReadOnlyTool{untrustedReadOnlyTool{fakeTool{name: "mcp__srv__unsafe", readOnly: true}}})
|
|
gate := &fakePlanModeReadOnlyTrustGate{allow: true}
|
|
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__unsafe"})
|
|
if !strings.HasPrefix(out.output, "blocked:") {
|
|
t.Fatalf("unsafe untrusted read-only MCP tool should remain blocked, got: %q", out.output)
|
|
}
|
|
if gate.calls != 0 {
|
|
t.Fatalf("trust gate calls = %d, want 0 for unsafe tool", gate.calls)
|
|
}
|
|
}
|
|
|
|
func TestPlanModeUnknownBashReadOnlyCommandCanAskForTrust(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "bash", readOnly: false})
|
|
gate := &fakePlanModeReadOnlyTrustGate{allow: true}
|
|
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572 --json title"))})
|
|
if strings.HasPrefix(out.output, "blocked:") || !strings.Contains(out.output, "done") {
|
|
t.Fatalf("trusted unknown bash query should run, got: %q", out.output)
|
|
}
|
|
if gate.calls != 1 {
|
|
t.Fatalf("trust gate calls = %d, want 1", gate.calls)
|
|
}
|
|
if gate.req.ToolName != PlanModeReadOnlyCommandApprovalTool || gate.req.Command != "gh issue view 4572 --json title" || gate.req.Prefix != "gh issue view" {
|
|
t.Fatalf("trust request = %+v, want bash command prefix request", gate.req)
|
|
}
|
|
}
|
|
|
|
func TestPlanModeTrustedUnknownBashCommandReachesGateAsReadOnly(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "bash", readOnly: false})
|
|
trustGate := &fakePlanModeReadOnlyTrustGate{allow: true}
|
|
permissionGate := &readOnlyRecordingGate{}
|
|
a := New(nil, reg, NewSession(""), Options{
|
|
Gate: permissionGate,
|
|
PlanModeReadOnlyTrustGate: trustGate,
|
|
}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572"))})
|
|
if strings.HasPrefix(out.output, "blocked:") || !strings.Contains(out.output, "done") {
|
|
t.Fatalf("trusted unknown bash query should run through the normal gate as read-only, got: %q", out.output)
|
|
}
|
|
if len(permissionGate.readOnly) != 1 || !permissionGate.readOnly[0] {
|
|
t.Fatalf("permission gate readOnly calls = %v, want [true]", permissionGate.readOnly)
|
|
}
|
|
}
|
|
|
|
func TestPlanModeUnknownBashReadOnlyCommandDeclineBlocks(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "bash", readOnly: false})
|
|
gate := &fakePlanModeReadOnlyTrustGate{allow: false, reason: "not read-only"}
|
|
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572"))})
|
|
if !strings.Contains(out.output, "not read-only") {
|
|
t.Fatalf("declined bash trust should block with reason, got: %q", out.output)
|
|
}
|
|
}
|
|
|
|
func TestPlanModeUnsafeUnknownBashCommandDoesNotAskForTrust(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "bash", readOnly: false})
|
|
gate := &fakePlanModeReadOnlyTrustGate{allow: true}
|
|
a := New(nil, reg, NewSession(""), Options{PlanModeReadOnlyTrustGate: gate}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
out := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: string(bashCommandArgs(t, "gh issue view 4572 && rm -rf /"))})
|
|
if !strings.HasPrefix(out.output, "blocked:") {
|
|
t.Fatalf("unsafe shell syntax should remain blocked, got: %q", out.output)
|
|
}
|
|
if gate.calls != 0 {
|
|
t.Fatalf("trust gate calls = %d, want 0 for unsafe shell syntax", gate.calls)
|
|
}
|
|
}
|
|
|
|
// --- planModeBashBlocked tests ---
|
|
|
|
func TestPlanModeBashBlocked_SafeCommands(t *testing.T) {
|
|
safe := []string{
|
|
"git status",
|
|
"git diff",
|
|
"git diff --staged",
|
|
"git log --oneline -10",
|
|
"git show HEAD",
|
|
"git ls-files",
|
|
"git grep 'func main'",
|
|
"git grep -o 'func'",
|
|
"git blame file.go",
|
|
"ls -la",
|
|
"cat file.go",
|
|
"grep -rn 'pattern' .",
|
|
"find . -name '*.go'",
|
|
"head -20 file.go",
|
|
"tail -5 file.go",
|
|
"pwd",
|
|
"echo hello",
|
|
"wc -l file.go",
|
|
"which go",
|
|
"type git",
|
|
"uname -a",
|
|
"hostname",
|
|
"go version",
|
|
"go list ./...",
|
|
"go doc fmt.Println",
|
|
"go vet ./...",
|
|
"node -v",
|
|
"npm list",
|
|
"python --version",
|
|
}
|
|
for _, cmd := range safe {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
blocked, msg := planModeBashBlocked(bashCommandArgs(t, cmd))
|
|
if blocked {
|
|
t.Errorf("planModeBashBlocked(%q) = blocked, want allowed; msg: %s", cmd, msg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanModeBashBlocked_Metacharacters(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cmd string
|
|
}{
|
|
{"semicolon", "git status; rm file.go"},
|
|
{"and", "git status && rm file.go"},
|
|
{"or", "git status || rm file.go"},
|
|
{"pipe", "ls | grep foo"},
|
|
{"redirect_out", "echo hello > file.go"},
|
|
{"redirect_append", "echo hello >> file.go"},
|
|
{"redirect_in", "cat < file.go"},
|
|
{"heredoc", "cat << EOF"},
|
|
{"cmd_sub_dollar", "echo $(rm file.go)"},
|
|
{"background_ampersand", "git status & rm file.go"},
|
|
{"newline", "git status\nrm file.go"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
blocked, msg := planModeBashBlocked(bashCommandArgs(t, tt.cmd))
|
|
if !blocked {
|
|
t.Errorf("planModeBashBlocked(%q) = allowed, want blocked", tt.cmd)
|
|
}
|
|
if !strings.Contains(msg, "shell operators") {
|
|
t.Errorf("unexpected message: %s", msg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanModeBashBlocked_ProcessControlArgs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args string
|
|
want string
|
|
}{
|
|
{
|
|
name: "background execution",
|
|
args: `{"command":"git status","run_in_background":true}`,
|
|
want: "background execution",
|
|
},
|
|
{
|
|
name: "process preservation",
|
|
args: `{"command":"git status","preserve_background_processes":true}`,
|
|
want: "process preservation",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
blocked, msg := planModeBashBlocked(json.RawMessage(tt.args))
|
|
if !blocked {
|
|
t.Fatalf("planModeBashBlocked(%s) = allowed, want blocked", tt.args)
|
|
}
|
|
if !strings.Contains(msg, tt.want) {
|
|
t.Fatalf("blocked message = %q, want %q", msg, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanModeBashBlocked_WriteCapableSafeCommandArgs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cmd string
|
|
arg string
|
|
}{
|
|
{"find_delete", "find . -delete", "-delete"},
|
|
{"find_exec", "find . -name '*.tmp' -exec rm {} +", "-exec"},
|
|
{"find_fprint", "find . -name '*.go' -fprint files.txt", "-fprint"},
|
|
{"git_diff_output_equals", "git diff --output=patch.diff", "--output=patch.diff"},
|
|
{"git_diff_output_space", "git diff --output patch.diff", "--output"},
|
|
{"git_show_output", "git show HEAD --output=show.txt", "--output=show.txt"},
|
|
{"git_log_ext_diff", "git log --ext-diff", "--ext-diff"},
|
|
{"git_grep_open_pager_short", "git grep -O 'func main'", "-O"},
|
|
{"git_grep_open_pager_long", "git grep --open-files-in-pager=sh 'func main'", "--open-files-in-pager=sh"},
|
|
{"go_list_mod_write", "go list -mod=mod ./...", "-mod=mod"},
|
|
{"go_list_mod_space", "go list -mod mod ./...", "-mod"},
|
|
{"go_list_modfile", "go list -modfile=other.mod ./...", "-modfile=other.mod"},
|
|
{"go_list_toolexec", "go list -toolexec=./wrapper ./...", "-toolexec=./wrapper"},
|
|
{"go_vet_fix", "go vet -fix ./...", "-fix"},
|
|
{"go_vet_toolexec", "go vet -toolexec ./wrapper ./...", "-toolexec"},
|
|
{"go_vet_vettool", "go vet -vettool=./writer ./...", "-vettool=./writer"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
blocked, msg := planModeBashBlocked(bashCommandArgs(t, tt.cmd))
|
|
if !blocked {
|
|
t.Errorf("planModeBashBlocked(%q) = allowed, want blocked", tt.cmd)
|
|
}
|
|
if !strings.Contains(msg, tt.arg) {
|
|
t.Errorf("blocked message %q does not mention unsafe arg %q", msg, tt.arg)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanModeBashBlocked_UnsafeCommands(t *testing.T) {
|
|
unsafe := []string{
|
|
"rm -rf /tmp/foo",
|
|
"cp file1 file2",
|
|
"mv old new",
|
|
"mkdir newdir",
|
|
"touch newfile",
|
|
"chmod 755 script.sh",
|
|
"git add .",
|
|
"git commit -m 'msg'",
|
|
"git push origin main",
|
|
"git checkout -b new-branch",
|
|
"go build ./...",
|
|
"go test ./...",
|
|
"npm install",
|
|
"npm run build",
|
|
"pip install requests",
|
|
"docker build .",
|
|
"make all",
|
|
"sed -i 's/old/new/' file.go",
|
|
"awk '{print $1}' file",
|
|
"tee output.txt",
|
|
"dd if=/dev/zero of=file",
|
|
"ssh user@host",
|
|
"curl https://example.com",
|
|
"wget https://example.com",
|
|
"python -c 'print(1)'",
|
|
"node -e 'console.log(1)'",
|
|
"ruby -e 'puts 1'",
|
|
"perl -e 'print 1'",
|
|
}
|
|
for _, cmd := range unsafe {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
blocked, _ := planModeBashBlocked(bashCommandArgs(t, cmd))
|
|
if !blocked {
|
|
t.Errorf("planModeBashBlocked(%q) = allowed, want blocked", cmd)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanModeBashBlocked_BoundaryCheck(t *testing.T) {
|
|
// "echop" should NOT match "echo" prefix
|
|
args := json.RawMessage(`{"command":"echop hello"}`)
|
|
blocked, _ := planModeBashBlocked(args)
|
|
if !blocked {
|
|
t.Error("echop should not match echo prefix — boundary check failed")
|
|
}
|
|
|
|
// "lsblk" should NOT match "ls" prefix
|
|
args = json.RawMessage(`{"command":"lsblk"}`)
|
|
blocked, _ = planModeBashBlocked(args)
|
|
if !blocked {
|
|
t.Error("lsblk should not match ls prefix — boundary check failed")
|
|
}
|
|
|
|
// "git status" with a flag after a whitespace boundary should match.
|
|
args = json.RawMessage(`{"command":"git status --short"}`)
|
|
blocked, _ = planModeBashBlocked(args)
|
|
if blocked {
|
|
t.Error("git status --short should match git status prefix")
|
|
}
|
|
|
|
// A dash inside the command name should NOT match a shorter safe prefix.
|
|
args = json.RawMessage(`{"command":"git diff-files"}`)
|
|
blocked, _ = planModeBashBlocked(args)
|
|
if !blocked {
|
|
t.Error("git diff-files should not match git diff prefix")
|
|
}
|
|
|
|
args = json.RawMessage(`{"command":"cat-file --batch"}`)
|
|
blocked, _ = planModeBashBlocked(args)
|
|
if !blocked {
|
|
t.Error("cat-file should not match cat prefix")
|
|
}
|
|
}
|
|
|
|
func TestPlanModeBashBlocked_EmptyCommand(t *testing.T) {
|
|
// Empty/missing command should not crash
|
|
blocked, _ := planModeBashBlocked(json.RawMessage(`{}`))
|
|
if !blocked {
|
|
t.Error("missing command should fail closed in plan mode")
|
|
}
|
|
blocked, _ = planModeBashBlocked(json.RawMessage(`{"command":""}`))
|
|
if !blocked {
|
|
t.Error("empty string command should fail closed in plan mode")
|
|
}
|
|
}
|
|
|
|
func TestPlanModeBashBlocked_InvalidJSON(t *testing.T) {
|
|
blocked, _ := planModeBashBlocked(json.RawMessage(`not json`))
|
|
if !blocked {
|
|
t.Error("invalid JSON should fail closed in plan mode")
|
|
}
|
|
}
|
|
|
|
func TestPlanModeBash_BashToolIntegration(t *testing.T) {
|
|
// Integration test: planModeBlocked with toolName="bash" delegates to planModeBashBlocked
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "bash", readOnly: false})
|
|
|
|
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
// Safe command should execute
|
|
safeResult := a.executeOne(context.Background(), provider.ToolCall{
|
|
Name: "bash",
|
|
Arguments: `{"command":"git status"}`,
|
|
})
|
|
if strings.HasPrefix(safeResult.output, "blocked:") {
|
|
t.Errorf("git status should not be blocked in plan mode: %s", safeResult.output)
|
|
}
|
|
|
|
// Unsafe command should be blocked
|
|
unsafeResult := a.executeOne(context.Background(), provider.ToolCall{
|
|
Name: "bash",
|
|
Arguments: `{"command":"rm -rf /"}`,
|
|
})
|
|
if !strings.HasPrefix(unsafeResult.output, "blocked:") {
|
|
t.Errorf("rm -rf should be blocked in plan mode: %s", unsafeResult.output)
|
|
}
|
|
|
|
// Chained command should be blocked
|
|
chainResult := a.executeOne(context.Background(), provider.ToolCall{
|
|
Name: "bash",
|
|
Arguments: `{"command":"git status && rm file.go"}`,
|
|
})
|
|
if !strings.HasPrefix(chainResult.output, "blocked:") {
|
|
t.Errorf("chained command should be blocked in plan mode: %s", chainResult.output)
|
|
}
|
|
|
|
backgroundResult := a.executeOne(context.Background(), provider.ToolCall{
|
|
Name: "bash",
|
|
Arguments: `{"command":"git status","run_in_background":true}`,
|
|
})
|
|
if !strings.HasPrefix(backgroundResult.output, "blocked:") {
|
|
t.Errorf("background bash should be blocked in plan mode: %s", backgroundResult.output)
|
|
}
|
|
|
|
preserveResult := a.executeOne(context.Background(), provider.ToolCall{
|
|
Name: "bash",
|
|
Arguments: `{"command":"git status","preserve_background_processes":true}`,
|
|
})
|
|
if !strings.HasPrefix(preserveResult.output, "blocked:") {
|
|
t.Errorf("process-preserving bash should be blocked in plan mode: %s", preserveResult.output)
|
|
}
|
|
}
|
|
|
|
func TestPlanMode_BashAllowedToolsOverride(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "bash", readOnly: false})
|
|
|
|
a := New(nil, reg, NewSession(""), Options{
|
|
PlanModeAllowedTools: []string{"bash"},
|
|
}, event.Discard)
|
|
a.SetPlanMode(true)
|
|
|
|
result := a.executeOne(context.Background(), provider.ToolCall{
|
|
Name: "bash",
|
|
Arguments: `{"command":"rm -rf /"}`,
|
|
})
|
|
if !strings.HasPrefix(result.output, "blocked:") {
|
|
t.Errorf("bash in planModeAllowedTools must still validate unsafe commands: %s", result.output)
|
|
}
|
|
}
|
|
|
|
func TestPlanModeOff_AllToolsAllowed(t *testing.T) {
|
|
reg := tool.NewRegistry()
|
|
reg.Add(fakeTool{name: "write_file", readOnly: false})
|
|
reg.Add(fakeTool{name: "bash", readOnly: false})
|
|
|
|
a := New(nil, reg, NewSession(""), Options{}, event.Discard)
|
|
// planMode is OFF by default
|
|
|
|
// write_file should execute normally
|
|
result := a.executeOne(context.Background(), provider.ToolCall{Name: "write_file"})
|
|
if strings.HasPrefix(result.output, "blocked:") {
|
|
t.Error("write_file should not be blocked when plan mode is off")
|
|
}
|
|
|
|
// bash with any command should execute normally
|
|
result = a.executeOne(context.Background(), provider.ToolCall{
|
|
Name: "bash",
|
|
Arguments: `{"command":"rm -rf /"}`,
|
|
})
|
|
if strings.HasPrefix(result.output, "blocked:") {
|
|
t.Error("bash should not be blocked when plan mode is off")
|
|
}
|
|
}
|