返回 DeepSeek-Reasonix
bash_sandbox_escape_test.go
根目录 / internal / tool / builtin / bash_sandbox_escape_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "reasonix/internal/event"
10 "reasonix/internal/jobs"
11 "reasonix/internal/sandbox"
12 )
13
14 type fakeSandboxEscapeApprover struct {
15 allow bool
16 reason string
17 sessionAllowed bool
18 calls []sandbox.EscapeRequest
19 sessionChecks []sandbox.EscapeRequest
20 }
21
22 func (f *fakeSandboxEscapeApprover) ApproveSandboxEscape(ctx context.Context, req sandbox.EscapeRequest) (bool, string, error) {
23 f.calls = append(f.calls, req)
24 return f.allow, f.reason, nil
25 }
26
27 func (f *fakeSandboxEscapeApprover) SandboxEscapeSessionAllowed(ctx context.Context, req sandbox.EscapeRequest) bool {
28 f.sessionChecks = append(f.sessionChecks, req)
29 return f.sessionAllowed
30 }
31
32 func TestBashSandboxUnavailableCanEscapeOnceWithApproval(t *testing.T) {
33 restore := forceWindowsSandboxEscapeTestMode(t)
34 defer restore()
35
36 sh := sandbox.ResolveShell("", "", nil)
37 oldCommand := bashSandboxCommand
38 bashSandboxCommand = func(spec sandbox.Spec, sh sandbox.Shell, command string) ([]string, bool) {
39 return unconfinedShellArgv(sh, command), false
40 }
41 defer func() { bashSandboxCommand = oldCommand }()
42
43 approver := &fakeSandboxEscapeApprover{allow: true}
44 ctx := sandbox.WithEscapeApprover(context.Background(), approver)
45 args := argsJSON(t, map[string]any{"command": echoForShell(sh, "escaped")})
46 out, err := (bash{sb: sandbox.Spec{Mode: "enforce"}, shell: sh}).Execute(ctx, args)
47 if err != nil {
48 t.Fatalf("Execute returned error after approved escape: %v (out=%q)", err, out)
49 }
50 if !strings.Contains(out, "escaped") {
51 t.Fatalf("output = %q, want escaped command output", out)
52 }
53 if len(approver.calls) != 1 {
54 t.Fatalf("approval calls = %d, want 1", len(approver.calls))
55 }
56 if approver.calls[0].Command != echoForShell(sh, "escaped") {
57 t.Fatalf("approval command = %q", approver.calls[0].Command)
58 }
59 if !json.Valid(approver.calls[0].Args) {
60 t.Fatalf("approval args are not valid JSON: %q", approver.calls[0].Args)
61 }
62 }
63
64 func TestBashSandboxUnavailableStaysClosedWithoutApprover(t *testing.T) {
65 restore := forceWindowsSandboxEscapeTestMode(t)
66 defer restore()
67
68 sh := sandbox.ResolveShell("", "", nil)
69 oldCommand := bashSandboxCommand
70 bashSandboxCommand = func(spec sandbox.Spec, sh sandbox.Shell, command string) ([]string, bool) {
71 return unconfinedShellArgv(sh, command), false
72 }
73 defer func() { bashSandboxCommand = oldCommand }()
74
75 out, err := (bash{sb: sandbox.Spec{Mode: "enforce"}, shell: sh}).Execute(context.Background(), argsJSON(t, map[string]any{"command": echoForShell(sh, "escaped")}))
76 if err == nil {
77 t.Fatalf("Execute succeeded without escape approver, out=%q", out)
78 }
79 if !strings.Contains(err.Error(), "sandbox requested but unavailable") {
80 t.Fatalf("error = %v, want unavailable sandbox message", err)
81 }
82 }
83
84 func TestBashSandboxEscapeDenialBlocksUnconfinedRun(t *testing.T) {
85 restore := forceWindowsSandboxEscapeTestMode(t)
86 defer restore()
87
88 sh := sandbox.ResolveShell("", "", nil)
89 oldCommand := bashSandboxCommand
90 bashSandboxCommand = func(spec sandbox.Spec, sh sandbox.Shell, command string) ([]string, bool) {
91 return unconfinedShellArgv(sh, command), false
92 }
93 defer func() { bashSandboxCommand = oldCommand }()
94
95 approver := &fakeSandboxEscapeApprover{allow: false, reason: "declined escape"}
96 ctx := sandbox.WithEscapeApprover(context.Background(), approver)
97 out, err := (bash{sb: sandbox.Spec{Mode: "enforce"}, shell: sh}).Execute(ctx, argsJSON(t, map[string]any{"command": echoForShell(sh, "escaped")}))
98 if err == nil {
99 t.Fatalf("Execute succeeded after denied escape, out=%q", out)
100 }
101 if !strings.Contains(err.Error(), "declined escape") {
102 t.Fatalf("error = %v, want denial reason", err)
103 }
104 if len(approver.calls) != 1 {
105 t.Fatalf("approval calls = %d, want 1", len(approver.calls))
106 }
107 }
108
109 func TestBashSandboxEscapeSessionGrantRunsForegroundUnconfinedBeforeWrapper(t *testing.T) {
110 restore := forceWindowsSandboxEscapeTestMode(t)
111 defer restore()
112
113 sh := sandbox.ResolveShell("", "", nil)
114 oldCommand := bashSandboxCommand
115 bashSandboxCommand = func(spec sandbox.Spec, sh sandbox.Shell, command string) ([]string, bool) {
116 if spec.Enforce() {
117 return unconfinedShellArgv(sh, windowsSandboxFailureForShell(sh)), true
118 }
119 return unconfinedShellArgv(sh, command), false
120 }
121 defer func() { bashSandboxCommand = oldCommand }()
122 approver := &fakeSandboxEscapeApprover{sessionAllowed: true}
123 ctx := sandbox.WithEscapeApprover(context.Background(), approver)
124 out, err := (bash{sb: sandbox.Spec{Mode: "enforce"}, shell: sh}).Execute(ctx, argsJSON(t, map[string]any{"command": echoForShell(sh, "session-rerun")}))
125 if err != nil {
126 t.Fatalf("Execute returned error with session escape: %v (out=%q)", err, out)
127 }
128 if !strings.Contains(out, "session-rerun") {
129 t.Fatalf("output = %q, want unconfined command output", out)
130 }
131 if strings.Contains(out, "windows sandbox: boom") {
132 t.Fatalf("output should not come from sandbox helper, got %q", out)
133 }
134 if len(approver.calls) != 0 {
135 t.Fatalf("fresh approval calls = %d, want 0", len(approver.calls))
136 }
137 if len(approver.sessionChecks) != 1 {
138 t.Fatalf("session checks = %d, want 1", len(approver.sessionChecks))
139 }
140 }
141
142 func TestBashSandboxEscapeSessionGrantRunsBackgroundUnconfinedBeforeWrapper(t *testing.T) {
143 restore := forceWindowsSandboxEscapeTestMode(t)
144 defer restore()
145
146 sh := sandbox.ResolveShell("", "", nil)
147 oldCommand := bashSandboxCommand
148 bashSandboxCommand = func(spec sandbox.Spec, sh sandbox.Shell, command string) ([]string, bool) {
149 if spec.Enforce() {
150 return unconfinedShellArgv(sh, windowsSandboxFailureForShell(sh)), true
151 }
152 return unconfinedShellArgv(sh, command), false
153 }
154 defer func() { bashSandboxCommand = oldCommand }()
155
156 approver := &fakeSandboxEscapeApprover{sessionAllowed: true}
157 jm := jobs.NewManager(event.Discard)
158 defer jm.Close()
159 ctx := jobs.WithManager(context.Background(), jm)
160 ctx = jobs.WithSession(ctx, "session-a")
161 ctx = sandbox.WithEscapeApprover(ctx, approver)
162
163 out, err := (bash{sb: sandbox.Spec{Mode: "enforce"}, shell: sh}).Execute(ctx, argsJSON(t, map[string]any{
164 "command": echoForShell(sh, "background-real"),
165 "run_in_background": true,
166 }))
167 if err != nil {
168 t.Fatalf("Execute returned error starting background job with session escape: %v (out=%q)", err, out)
169 }
170 jobID := backgroundJobIDFromStartOutput(t, out)
171 results := jm.WaitForSession(context.Background(), "session-a", []string{jobID}, 5)
172 if len(results) != 1 {
173 t.Fatalf("wait results = %d, want 1", len(results))
174 }
175 if results[0].Status != jobs.Done {
176 t.Fatalf("job status = %s, want %s (output=%q)", results[0].Status, jobs.Done, results[0].Output)
177 }
178 if !strings.Contains(results[0].Output, "background-real") {
179 t.Fatalf("job output = %q, want unconfined command output", results[0].Output)
180 }
181 if strings.Contains(results[0].Output, "windows sandbox: boom") {
182 t.Fatalf("job output should not come from sandbox helper, got %q", results[0].Output)
183 }
184 if len(approver.calls) != 0 {
185 t.Fatalf("fresh approval calls = %d, want 0", len(approver.calls))
186 }
187 if len(approver.sessionChecks) != 1 {
188 t.Fatalf("session checks = %d, want 1", len(approver.sessionChecks))
189 }
190 }
191
192 func forceWindowsSandboxEscapeTestMode(t *testing.T) func() {
193 t.Helper()
194 old := bashSandboxEscapePromptEnabled
195 bashSandboxEscapePromptEnabled = func() bool { return true }
196 return func() { bashSandboxEscapePromptEnabled = old }
197 }
198
199 func echoForShell(sh sandbox.Shell, text string) string {
200 if sh.Kind == sandbox.ShellPowerShell {
201 return "Write-Output " + text
202 }
203 return "printf " + text
204 }
205
206 func windowsSandboxFailureForShell(sh sandbox.Shell) string {
207 if sh.Kind == sandbox.ShellPowerShell {
208 return "Write-Error 'windows sandbox: boom'; exit 126"
209 }
210 return "printf 'windows sandbox: boom\\n' >&2; exit 126"
211 }
212
213 func backgroundJobIDFromStartOutput(t *testing.T, out string) string {
214 t.Helper()
215 const prefix = `Started background job "`
216 start := strings.Index(out, prefix)
217 if start < 0 {
218 t.Fatalf("start output = %q, want background job id", out)
219 }
220 rest := out[start+len(prefix):]
221 end := strings.IndexByte(rest, '"')
222 if end < 0 {
223 t.Fatalf("start output = %q, want closing quote for background job id", out)
224 }
225 return rest[:end]
226 }
227
227 lines GO