| 1 | package evidence |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func TestClassifyEffectBashReadersAndWriters(t *testing.T) { |
| 11 | tests := []struct { |
| 12 | name string |
| 13 | command string |
| 14 | want EffectProfile |
| 15 | }{ |
| 16 | {name: "status", command: "git status", want: EffectProfile{Known: true, ReadOnly: true, Reason: ReasonReadOnly}}, |
| 17 | {name: "diff", command: "git diff", want: EffectProfile{Known: true, ReadOnly: true, Reason: ReasonReadOnly}}, |
| 18 | {name: "push", command: "git push origin main", want: EffectProfile{Known: true, ExternalState: true, UsesNetwork: true, Reason: ReasonExternalState, Targets: []Target{{Kind: TargetExternal}}}}, |
| 19 | {name: "force push", command: "git push --force origin main", want: EffectProfile{Known: true, ExternalState: true, UsesNetwork: true, Destructive: true, Irreversible: true, Reason: ReasonExternalState, Targets: []Target{{Kind: TargetExternal}}}}, |
| 20 | {name: "unknown", command: "custom-tool --run", want: EffectProfile{WorkspaceWrite: true, Reason: "command effects are not statically known"}}, |
| 21 | } |
| 22 | for _, tt := range tests { |
| 23 | t.Run(tt.name, func(t *testing.T) { |
| 24 | args, _ := json.Marshal(map[string]string{"command": tt.command}) |
| 25 | got := ClassifyEffect(EffectInput{ToolName: "bash", Args: args}) |
| 26 | if got.Known != tt.want.Known || got.ReadOnly != tt.want.ReadOnly || got.WorkspaceWrite != tt.want.WorkspaceWrite || |
| 27 | got.ExternalState != tt.want.ExternalState || got.Destructive != tt.want.Destructive || |
| 28 | got.Irreversible != tt.want.Irreversible || got.UsesNetwork != tt.want.UsesNetwork { |
| 29 | t.Fatalf("profile = %+v, want %+v", got, tt.want) |
| 30 | } |
| 31 | }) |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | func TestClassifyEffectFileAndMCP(t *testing.T) { |
| 36 | edit := ClassifyEffect(EffectInput{ |
| 37 | ToolName: "edit_file", |
| 38 | Args: json.RawMessage(`{"path":"internal/agent/agent.go"}`), |
| 39 | }) |
| 40 | if !edit.Known || !edit.WorkspaceWrite || len(edit.Targets) != 1 || edit.Targets[0].Path != "internal/agent/agent.go" { |
| 41 | t.Fatalf("edit profile = %+v", edit) |
| 42 | } |
| 43 | |
| 44 | read := ClassifyEffect(EffectInput{ |
| 45 | ToolName: "read_file", |
| 46 | Args: json.RawMessage(`{"path":"auth/session.go"}`), |
| 47 | StaticReadOnly: true, |
| 48 | }) |
| 49 | if !read.ReadOnly || read.MutatesState() { |
| 50 | t.Fatalf("read-only file must not mutate: %+v", read) |
| 51 | } |
| 52 | |
| 53 | mcpRead := ClassifyEffect(EffectInput{ |
| 54 | ToolName: "mcp__srv__get", |
| 55 | Hint: CallHint{Present: true, ReadOnly: true}, |
| 56 | }) |
| 57 | if !mcpRead.ReadOnly || mcpRead.MutatesState() { |
| 58 | t.Fatalf("MCP read-only annotation = %+v", mcpRead) |
| 59 | } |
| 60 | |
| 61 | opaque := ClassifyEffect(EffectInput{ToolName: "mcp__srv__write", Args: json.RawMessage(`{}`)}) |
| 62 | if opaque.Known || !opaque.OpaqueWriter() { |
| 63 | t.Fatalf("unknown MCP writer must fail closed: %+v", opaque) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestClassifyEffectKillShellIsHostStateOnly(t *testing.T) { |
| 68 | profile := ClassifyEffect(EffectInput{ToolName: "kill_shell", Args: json.RawMessage(`{"job_id":"task-1"}`)}) |
| 69 | if !profile.Known || profile.ReadOnly || !profile.HostState || profile.WorkspaceWrite || profile.RepoMetadata || profile.ExternalState { |
| 70 | t.Fatalf("kill_shell profile = %+v, want host-state-only mutation", profile) |
| 71 | } |
| 72 | effects := profile.ToolEffects() |
| 73 | if !effects.StateMutation || effects.WorkspaceMutation || effects.ContentMutation || effects.RepositoryMutation { |
| 74 | t.Fatalf("kill_shell effects = %+v, want state mutation without workspace mutation", effects) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestClassifyWriteScopeScratchWriteFile(t *testing.T) { |
| 79 | workspace := t.TempDir() |
| 80 | scratchPath := filepath.Join(os.TempDir(), "reasonix-scope-probe.py") |
| 81 | if got := ClassifyWriteScope(scratchPath, workspace, nil); got != WriteScopeScratch { |
| 82 | t.Fatalf("write_file /tmp = %s, want scratch", got) |
| 83 | } |
| 84 | if got := ClassifyWriteScope("internal/agent/agent.go", workspace, nil); got != WriteScopeWorkspace { |
| 85 | t.Fatalf("workspace edit = %s, want workspace", got) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestClassifyEffectPrefersReceiptPaths(t *testing.T) { |
| 90 | got := ClassifyEffect(EffectInput{ |
| 91 | ToolName: "edit_file", |
| 92 | Args: json.RawMessage(`{"path":"expected.go"}`), |
| 93 | ActualPaths: []string{"actual.go"}, |
| 94 | }) |
| 95 | if len(got.Targets) != 1 || got.Targets[0].Path != "actual.go" { |
| 96 | t.Fatalf("receipt paths must win: %+v", got) |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestEffectProfileCloneCopiesTargets(t *testing.T) { |
| 101 | orig := EffectProfile{Targets: []Target{{Path: "a.go", Kind: TargetFile}}} |
| 102 | clone := orig.Clone() |
| 103 | clone.Targets[0].Path = "b.go" |
| 104 | if orig.Targets[0].Path != "a.go" { |
| 105 | t.Fatal("clone must not share the target slice") |
| 106 | } |
| 107 | } |
| 108 |