Files
2026-07-13 13:00:08 +08:00

425 lines
14 KiB
Go

package planmode
import (
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestDecideAllowsReadOnlyResearchAndBlocksKnownWriters(t *testing.T) {
p := Policy{}
allowed := p.Decide(Call{Name: "read_file", ReadOnly: true})
if allowed.Blocked {
t.Fatalf("read-only research tool blocked: %s", allowed.Message)
}
blocked := p.Decide(Call{Name: "write_file", ReadOnly: false})
if !blocked.Blocked {
t.Fatal("write_file should be blocked in plan mode")
}
if !strings.Contains(blocked.Message, "not available in plan mode") {
t.Fatalf("blocked message = %q, want plan-mode availability explanation", blocked.Message)
}
}
func TestDecideDoesNotLetOverridesReopenKnownBlockedTools(t *testing.T) {
p := Policy{AllowedTools: []string{"write_file"}}
decision := p.Decide(Call{Name: "write_file", ReadOnly: false})
if !decision.Blocked {
t.Fatal("plan_mode_allowed_tools must not allow known blocked writer tools")
}
if got := p.IgnoredAllowedTools(); len(got) != 1 || got[0] != "write_file" {
t.Fatalf("IgnoredAllowedTools() = %v, want [write_file]", got)
}
}
func TestDecideBlocksSubagentStyleToolsWithCategoryMessage(t *testing.T) {
for _, name := range []string{"explore", "research", "review", "security_review", "security-review"} {
t.Run(name, func(t *testing.T) {
decision := (Policy{}).Decide(Call{Name: name, ReadOnly: false})
if !decision.Blocked {
t.Fatalf("%s should be blocked in plan mode", name)
}
if !strings.Contains(decision.Message, "not available in plan mode") {
t.Fatalf("blocked message = %q, want category message", decision.Message)
}
})
}
}
func TestDecideAllowsReadOnlyTaskDelegation(t *testing.T) {
// read_only_task is not a built-in, so under fail-closed it is allowed only
// because it self-reports plan-safe via PlanModeClassifier (Safety: Safe).
decision := (Policy{}).Decide(Call{Name: "read_only_task", ReadOnly: true, Safety: PlanSafetySafe})
if decision.Blocked {
t.Fatalf("read_only_task should be allowed in plan mode: %s", decision.Message)
}
}
func TestDecideAllowsReadOnlySkillDelegation(t *testing.T) {
decision := (Policy{}).Decide(Call{Name: "read_only_skill", ReadOnly: true, Safety: PlanSafetySafe})
if decision.Blocked {
t.Fatalf("read_only_skill should be allowed in plan mode: %s", decision.Message)
}
}
func TestDecideTrustsReadOnlyButFailsClosedOnNonReadOnly(t *testing.T) {
// Moderate fail-closed boundary: a ReadOnly()==true tool is trusted (only
// in-process tools can assert that flag; plugin/MCP are contractually
// ReadOnly()==false). connect_tool_source / history / recall rely on this.
if d := (Policy{}).Decide(Call{Name: "connect_tool_source", ReadOnly: true}); d.Blocked {
t.Fatalf("a ReadOnly internal tool should be trusted in plan mode: %s", d.Message)
}
// A non-read-only, unclassified tool (the plugin/MCP shape) fails closed.
if d := (Policy{}).Decide(Call{Name: "mcp__x__mutate", ReadOnly: false}); !d.Blocked {
t.Fatal("a non-read-only unclassified tool must fail closed in plan mode")
}
}
func TestDecidePlanSafeReportMustBeReadOnly(t *testing.T) {
// PlanSafe ⇒ ReadOnly: a tool that claims plan-safe but is not read-only is a
// wiring bug and must still be refused.
decision := (Policy{}).Decide(Call{Name: "lying_writer", ReadOnly: false, Safety: PlanSafetySafe})
if !decision.Blocked {
t.Fatal("a non-read-only tool claiming plan-safe must be blocked (invariant)")
}
if !strings.Contains(decision.Message, "invariant") {
t.Fatalf("blocked message = %q, want invariant explanation", decision.Message)
}
}
func TestUnclassifiedExternalToolFailsClosedThenOverride(t *testing.T) {
// Plan mode is fail-closed for tools it cannot classify (MCP/plugin tools,
// whose ReadOnly() defaults to false); plan_mode_allowed_tools is the escape
// valve, and it still never reopens a known blocked writer.
const ext = "mcp__server__query"
if d := (Policy{}).Decide(Call{Name: ext}); !d.Blocked {
t.Fatal("unclassified external tool must fail closed in plan mode")
}
if d := (Policy{AllowedTools: []string{ext}}).Decide(Call{Name: ext}); d.Blocked {
t.Fatalf("plan_mode_allowed_tools must re-enable a declared external tool: %s", d.Message)
}
if d := (Policy{AllowedTools: []string{"write_file"}}).Decide(Call{Name: "write_file"}); !d.Blocked {
t.Fatal("override must not reopen a known blocked writer")
}
}
func TestDecideStillValidatesBashArgumentsWhenOverridden(t *testing.T) {
p := Policy{AllowedTools: []string{"bash"}}
args, err := json.Marshal(map[string]any{"command": "rm -rf /"})
if err != nil {
t.Fatal(err)
}
decision := p.Decide(Call{Name: "bash", ReadOnly: false, Args: args})
if !decision.Blocked {
t.Fatal("bash override must not bypass plan-mode bash safety checks")
}
}
func TestDecideAllowsDeclaredReadOnlyBashCommandPrefix(t *testing.T) {
p := Policy{ReadOnlyCommands: []string{"gh issue view", "internal-report --read"}}
args, err := json.Marshal(map[string]any{"command": "gh issue view 4572 --json title,body"})
if err != nil {
t.Fatal(err)
}
if decision := p.Decide(Call{Name: "bash", Args: args}); decision.Blocked {
t.Fatalf("declared read-only command should be allowed in plan mode: %s", decision.Message)
}
args, err = json.Marshal(map[string]any{"command": "internal-report --read service-a"})
if err != nil {
t.Fatal(err)
}
if decision := p.Decide(Call{Name: "bash", Args: args}); decision.Blocked {
t.Fatalf("declared read-only command with flags should be allowed in plan mode: %s", decision.Message)
}
}
func TestDecideDeclaredReadOnlyBashCommandStillBlocksShellSyntax(t *testing.T) {
p := Policy{ReadOnlyCommands: []string{"gh issue view"}}
args, err := json.Marshal(map[string]any{"command": "gh issue view 4572 && rm -rf /"})
if err != nil {
t.Fatal(err)
}
if decision := p.Decide(Call{Name: "bash", Args: args}); !decision.Blocked {
t.Fatal("declared read-only command must not bypass shell syntax checks")
}
}
func TestDecideAllowsBashSafeNullRedirects(t *testing.T) {
bash := func(cmd string) Call {
args, err := json.Marshal(map[string]any{"command": cmd})
if err != nil {
t.Fatal(err)
}
return Call{Name: "bash", Args: args}
}
for _, cmd := range []string{
"git log 2>/dev/null",
"git log 2> /dev/null",
"git log >/dev/null",
"git log >>/dev/null",
"git log &>/dev/null",
"git log &>> /dev/null",
"git log 2>$null",
"git log 2>NUL",
"git log 2>&1",
"2>/dev/null git log",
} {
t.Run(cmd, func(t *testing.T) {
if decision := (Policy{}).Decide(bash(cmd)); decision.Blocked {
t.Fatalf("safe redirect command should be allowed in plan mode: %s", decision.Message)
}
})
}
}
func TestDecideBashSafeRedirectsStayConservative(t *testing.T) {
bash := func(cmd string) Call {
args, err := json.Marshal(map[string]any{"command": cmd})
if err != nil {
t.Fatal(err)
}
return Call{Name: "bash", Args: args}
}
for _, cmd := range []string{
"git log >/tmp/out",
"git log >/dev/nullish",
"git log 2>$nullish",
"git log 2>nul.txt",
"git log < /dev/null",
"git log 2>/dev/null && rm -rf /tmp/x",
"git diff --output changes.patch 2>/dev/null",
} {
t.Run(cmd, func(t *testing.T) {
if decision := (Policy{}).Decide(bash(cmd)); !decision.Blocked {
t.Fatal("unsafe or non-operator redirect-looking command should be blocked in plan mode")
}
})
}
}
func TestDecideBashAllowsRedirectLookingText(t *testing.T) {
bash := func(cmd string) Call {
args, err := json.Marshal(map[string]any{"command": cmd})
if err != nil {
t.Fatal(err)
}
return Call{Name: "bash", Args: args}
}
for _, cmd := range []string{
`git log "2>/dev/null"`,
`git log 2\>/dev/null`,
} {
t.Run(cmd, func(t *testing.T) {
if decision := (Policy{}).Decide(bash(cmd)); decision.Blocked {
t.Fatalf("redirect-looking text should stay a read-only argument in plan mode: %s", decision.Message)
}
})
}
}
func TestDecideDeclaredReadOnlyBashCommandIgnoresShellInterpreters(t *testing.T) {
p := Policy{ReadOnlyCommands: []string{"bash", "gh", "gh issue", "gh issue close"}}
args, err := json.Marshal(map[string]any{"command": "bash run-anything"})
if err != nil {
t.Fatal(err)
}
if decision := p.Decide(Call{Name: "bash", Args: args}); !decision.Blocked {
t.Fatal("plan_mode_read_only_commands must not reopen shell interpreters")
}
want := []string{"bash", "gh", "gh issue", "gh issue close"}
if got := p.IgnoredReadOnlyCommands(); !reflect.DeepEqual(got, want) {
t.Fatalf("IgnoredReadOnlyCommands() = %v, want %v", got, want)
}
}
func TestDecideUnknownBashReadOnlyCommandCanAskForTrust(t *testing.T) {
args, err := json.Marshal(map[string]any{"command": "gh issue view 4572 --json title"})
if err != nil {
t.Fatal(err)
}
decision := (Policy{}).Decide(Call{Name: "bash", Args: args})
if !decision.Blocked || decision.ReadOnlyCommandTrust == nil {
t.Fatalf("unknown query should be blocked with trust candidate, got %+v", decision)
}
if decision.ReadOnlyCommandTrust.Prefix != "gh issue view" {
t.Fatalf("trust prefix = %q, want gh issue view", decision.ReadOnlyCommandTrust.Prefix)
}
}
func TestDecideUnsafeUnknownBashCommandDoesNotAskForTrust(t *testing.T) {
for _, cmd := range []string{
"gh issue close 4572",
"gh pr merge 5867",
"aws s3 rm s3://bucket/key",
"bash -lc 'gh issue view 1'",
} {
t.Run(cmd, func(t *testing.T) {
args, err := json.Marshal(map[string]any{"command": cmd})
if err != nil {
t.Fatal(err)
}
decision := (Policy{}).Decide(Call{Name: "bash", Args: args})
if decision.ReadOnlyCommandTrust != nil {
t.Fatalf("unsafe command should not ask for trust, got %+v", decision.ReadOnlyCommandTrust)
}
})
}
}
func TestDecideBlocksQuotedBashWriteArguments(t *testing.T) {
tests := []struct {
name string
command string
want string
}{
{
name: "single quoted find delete",
command: "find . -name scratch '-delete'",
want: "-delete",
},
{
name: "backslash escaped find delete",
command: `find . -name scratch \-delete`,
want: "-delete",
},
{
name: "single quoted git pager",
command: `git grep foo '--open-files-in-pager=sh -c echo'`,
want: "--open-files-in-pager=sh -c echo",
},
{
name: "single quoted go mod write mode",
command: "go list ./... '-mod=mod'",
want: "-mod=mod",
},
{
name: "unterminated quote fails closed",
command: "find . '-delete",
want: "malformed shell quoting",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args, err := json.Marshal(map[string]any{"command": tt.command})
if err != nil {
t.Fatal(err)
}
decision := (Policy{}).Decide(Call{Name: "bash", ReadOnly: false, Args: args})
if !decision.Blocked {
t.Fatalf("bash command %q should be blocked in plan mode", tt.command)
}
if !strings.Contains(decision.Message, tt.want) {
t.Fatalf("blocked message = %q, want to mention %q", decision.Message, tt.want)
}
})
}
}
func TestDecideBlocksBashProcessControlArguments(t *testing.T) {
tests := []struct {
name string
args map[string]any
want string
}{
{
name: "background execution",
args: map[string]any{"command": "git status", "run_in_background": true},
want: "background execution",
},
{
name: "process preservation",
args: map[string]any{"command": "git status", "preserve_background_processes": true},
want: "process preservation",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args, err := json.Marshal(tt.args)
if err != nil {
t.Fatal(err)
}
decision := (Policy{}).Decide(Call{Name: "bash", ReadOnly: false, Args: args})
if !decision.Blocked {
t.Fatalf("bash args %v should be blocked in plan mode", tt.args)
}
if !strings.Contains(decision.Message, tt.want) {
t.Fatalf("blocked message = %q, want to mention %q", decision.Message, tt.want)
}
})
}
}
func TestDecideUntrustedReadOnlyFailsClosedThenOverride(t *testing.T) {
// An MCP tool can report ReadOnly()==true via the server's readOnlyHint, but
// that signal is untrusted. The read-only fast path must NOT let it run; it
// fails closed until declared in plan_mode_allowed_tools.
call := Call{Name: "mcp__srv__query", ReadOnly: true, Untrusted: true}
d := (Policy{}).Decide(call)
if !d.Blocked {
t.Fatal("untrusted read-only MCP tool must fail closed in plan mode")
}
if !strings.Contains(d.Message, "readOnlyHint") {
t.Fatalf("blocked message should explain the untrusted hint: %s", d.Message)
}
if d := (Policy{AllowedTools: []string{"mcp__srv__query"}}).Decide(call); d.Blocked {
t.Fatalf("plan_mode_allowed_tools must re-enable the declared MCP tool: %s", d.Message)
}
// A trusted read-only tool (first-party override, Untrusted=false) is allowed.
if d := (Policy{}).Decide(Call{Name: "mcp__srv__read", ReadOnly: true, Untrusted: false}); d.Blocked {
t.Fatalf("a trusted read-only tool should be allowed: %s", d.Message)
}
}
// TestPlanModeAllowsSharedReadOnlyBashSet is the #5341 regression: plan mode now
// classifies bash commands against the same shellsafe table the permission
// auto-approve path uses, so read-only git/tooling commands beyond the old
// status/diff/log/show short list are runnable while planning. Write-capable
// commands and write-capable args remain blocked.
func TestPlanModeAllowsSharedReadOnlyBashSet(t *testing.T) {
bash := func(cmd string) Call {
args, err := json.Marshal(map[string]any{"command": cmd})
if err != nil {
t.Fatal(err)
}
return Call{Name: "bash", Args: args}
}
for _, cmd := range []string{
"git rev-parse --abbrev-ref HEAD", "git describe --tags", "git reflog",
"git for-each-ref", "git cat-file -p HEAD", "git ls-tree HEAD",
"go env", "npm view react version", "docker ps", "kubectl get pods",
"grep 'a|b' file",
} {
if d := (Policy{}).Decide(bash(cmd)); d.Blocked {
t.Errorf("plan mode blocked read-only %q: %s", cmd, d.Message)
}
}
for _, cmd := range []string{
"git checkout main", "git commit -m x", "git branch -d feature",
"go build ./...", "npm install",
"git status && rm -rf /", "git status > out.txt",
"git grep foo --open-files-in-pager=sh", "go list ./... -mod=mod",
"echo $HOME",
} {
if d := (Policy{}).Decide(bash(cmd)); !d.Blocked {
t.Errorf("plan mode allowed unsafe %q", cmd)
}
}
}