1package tools
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8)
9
10func TestSandboxLockedBlocksOutside(t *testing.T) {
11 root := t.TempDir()
12 outside := t.TempDir()
13 outsideFile := filepath.Join(outside, "a.txt")
14 os.WriteFile(outsideFile, []byte("secret"), 0o644)
15
16 sb := NewSandbox(root)
17 sb.Lock()
18
19 if err := sb.CheckPath(outsideFile); err == nil {
20 t.Fatal("expected outside path to be blocked")
21 }
22 inside := filepath.Join(root, "ok.txt")
23 if err := sb.CheckPath(inside); err != nil {
24 t.Fatalf("inside path blocked unexpectedly: %v", err)
25 }
26}
27
28func TestSandboxUnlockedAllows(t *testing.T) {
29 root := t.TempDir()
30 outside := t.TempDir()
31 sb := NewSandbox(root)
32 if err := sb.CheckPath(filepath.Join(outside, "a.txt")); err != nil {
33 t.Fatalf("unlocked should allow: %v", err)
34 }
35}
36
37func TestSandboxCommandBanned(t *testing.T) {
38 sb := NewSandbox(t.TempDir())
39 sb.Lock()
40 cases := []string{
41 "sudo apt-get install foo",
42 "rm -rf /",
43 "cd /etc && ls",
44 "cd .. && rm foo",
45 }
46 for _, c := range cases {
47 if err := sb.CheckCommand(c); err == nil {
48 t.Fatalf("expected %q to be banned", c)
49 }
50 }
51 // Allowed:
52 for _, c := range []string{"ls", "go test ./...", "cd subdir && ls"} {
53 if err := sb.CheckCommand(c); err != nil {
54 t.Fatalf("expected %q to be allowed: %v", c, err)
55 }
56 }
57}
58
59// TestBashToolRejectsOutsidePathWhenLocked is a regression for issue #94.
60func TestBashToolRejectsOutsidePathWhenLocked(t *testing.T) {
61 root := t.TempDir()
62 outside := t.TempDir()
63 outsideFile := filepath.Join(outside, "secret.txt")
64 if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
65 t.Fatal(err)
66 }
67
68 sb := NewSandbox(root)
69 sb.Lock()
70 tool := &BashTool{CWD: root, Sandbox: sb}
71
72 _, err := tool.Execute(context.Background(), mustJSONRaw(t, map[string]any{"command": "cat " + outsideFile, "timeout": 1}), nil)
73 if err == nil {
74 t.Fatal("expected jailed bash to reject outside path")
75 }
76}
77
78func TestSandboxAllowsNullDeviceRedirectionWhenLocked(t *testing.T) {
79 sb := NewSandbox(t.TempDir())
80 sb.Lock()
81
82 allowed := []string{
83 "command -v go >" + os.DevNull + " 2>&1",
84 "cat <" + os.DevNull,
85 "cat \"" + os.DevNull + "\"",
86 }
87 for _, command := range allowed {
88 if err := sb.CheckCommand(command); err != nil {
89 t.Errorf("expected %q to be allowed: %v", command, err)
90 }
91 }
92
93 blocked := []string{"cat /dev/zero", "cat /dev/null/child"}
94 for _, command := range blocked {
95 if err := sb.CheckCommand(command); err == nil {
96 t.Errorf("expected null-device lookalike %q to be blocked", command)
97 }
98 }
99}
100
101// TestSandboxAllowsCDIntoSubdir is the regression for issue #39: a `cd`
102// into a subdirectory of the sandbox root, spelled as an absolute path,
103// must be allowed. The old guard rejected any `cd /...` outright, which
104// wasted turns and nudged the model toward trying to break out of jail.
105func TestSandboxAllowsCDIntoSubdir(t *testing.T) {
106 root := t.TempDir()
107 sub := filepath.Join(root, "packages", "provider")
108 if err := os.MkdirAll(sub, 0o755); err != nil {
109 t.Fatal(err)
110 }
111 sb := NewSandbox(root)
112 sb.Lock()
113
114 insideFile := filepath.Join(root, "inside file.txt")
115 if err := os.WriteFile(insideFile, []byte("inside"), 0o644); err != nil {
116 t.Fatal(err)
117 }
118 allowed := []string{
119 "cd " + sub + " && go build ./...",
120 "cd " + root + " && go build ./...",
121 "cd " + root, // bare cd to root
122 "cd packages/provider && go build",
123 "cd \"" + sub + "\" && ls", // quoted absolute path
124 "cat \"" + insideFile + "\"",
125 }
126 for _, c := range allowed {
127 if err := sb.CheckCommand(c); err != nil {
128 t.Fatalf("expected %q to be allowed: %v", c, err)
129 }
130 }
131
132 outside := t.TempDir()
133 outsideFile := filepath.Join(outside, "secret.txt")
134 if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
135 t.Fatal(err)
136 }
137 blocked := []string{
138 "cd /etc",
139 "cd / && ls",
140 "cd ..", // parent of root escapes
141 "cd " + filepath.Dir(root), // explicit parent
142 "cat " + outsideFile,
143 "cat <" + outsideFile,
144 "grep secret ../secret.txt",
145 "cat ${HOME}/.ssh/config",
146 }
147 for _, c := range blocked {
148 if err := sb.CheckCommand(c); err == nil {
149 t.Fatalf("expected %q to be blocked", c)
150 }
151 }
152}
153
154// TestSandboxDisplayPath covers issue #39's secondary cause: tool
155// results / errors should present paths relative to the sandbox root
156// when jailed, so the model isn't biased toward absolute paths.
157func TestSandboxDisplayPath(t *testing.T) {
158 root := t.TempDir()
159 sub := filepath.Join(root, "pkg", "foo.go")
160 outside := filepath.Join(t.TempDir(), "x.go")
161
162 sb := NewSandbox(root)
163
164 // Unlocked: returns the given form verbatim.
165 if got := sb.DisplayPath(sub, "pkg/foo.go"); got != "pkg/foo.go" {
166 t.Fatalf("unlocked DisplayPath = %q; want verbatim", got)
167 }
168
169 sb.Lock()
170 if got := sb.DisplayPath(sub, sub); got != "./pkg/foo.go" {
171 t.Fatalf("DisplayPath(abs inside) = %q; want ./pkg/foo.go", got)
172 }
173 if got := sb.DisplayPath(root, root); got != "." {
174 t.Fatalf("DisplayPath(root) = %q; want .", got)
175 }
176 // Outside root: fall back to the given form (don't fabricate a path).
177 if got := sb.DisplayPath(outside, "x.go"); got != "x.go" {
178 t.Fatalf("DisplayPath(outside) = %q; want given fallback", got)
179 }
180}
181
182func TestReadToolRejectsOutsideWhenLocked(t *testing.T) {
183 root := t.TempDir()
184 outside := t.TempDir()
185 outsideFile := filepath.Join(outside, "a.txt")
186 os.WriteFile(outsideFile, []byte("x"), 0o644)
187
188 sb := NewSandbox(root)
189 sb.Lock()
190 tool := &ReadTool{CWD: root, Sandbox: sb}
191
192 _, err := tool.Execute(context.Background(),
193 mustJSONRaw(t, map[string]any{"path": outsideFile}), nil)
194 if err == nil {
195 t.Fatal("expected sandbox error")
196 }
197}
198
199func mustJSONRaw(t *testing.T, v any) []byte {
200 t.Helper()
201 return mustJSON(t, v)
202}