v0.3.21 7fe8dbc

sandbox_test.go RawHistoryFolder

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}), 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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close