返回 DeepSeek-Reasonix
project_scope_test.go
根目录 / internal / config / project_scope_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "runtime"
7 "slices"
8 "strings"
9 "testing"
10 )
11
12 // loadScoped writes the user and project files and loads the workspace.
13 func loadScoped(t *testing.T, user, project string) (*Config, string) {
14 t.Helper()
15 home := t.TempDir()
16 root := t.TempDir()
17 t.Setenv("REASONIX_HOME", home)
18 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(user), 0o600); err != nil {
19 t.Fatal(err)
20 }
21 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(project), 0o600); err != nil {
22 t.Fatal(err)
23 }
24 cfg, err := LoadForRootReadOnly(root)
25 if err != nil {
26 t.Fatal(err)
27 }
28 return cfg, root
29 }
30
31 func ignoredKeys(cfg *Config) []string {
32 var keys []string
33 for _, ig := range cfg.IgnoredProjectSettings() {
34 keys = append(keys, ig.Key)
35 }
36 return keys
37 }
38
39 func realDir(t *testing.T, dir string) string {
40 t.Helper()
41 real, err := filepath.EvalSymlinks(dir)
42 if err != nil {
43 t.Fatal(err)
44 }
45 return real
46 }
47
48 func TestProjectCannotTurnBashSandboxOff(t *testing.T) {
49 for name, user := range map[string]string{
50 "platform default": "",
51 "explicit enforce": "[sandbox]\nbash = \"enforce\"\n",
52 } {
53 t.Run(name, func(t *testing.T) {
54 cfg, _ := loadScoped(t, user, "[sandbox]\nbash = \"off\"\n")
55 if cfg.Sandbox.Bash == "off" || cfg.BashModeForGOOS("linux") != "enforce" {
56 t.Fatalf("bash = %q, want the user's jailed mode kept", cfg.Sandbox.Bash)
57 }
58 if !slices.Contains(ignoredKeys(cfg), "sandbox.bash") {
59 t.Fatalf("ignored = %v, want sandbox.bash reported", ignoredKeys(cfg))
60 }
61 })
62 }
63 }
64
65 func TestProjectMayTurnBashSandboxOn(t *testing.T) {
66 cfg, _ := loadScoped(t, "[sandbox]\nbash = \"off\"\n", "[sandbox]\nbash = \"enforce\"\n")
67 if cfg.Sandbox.Bash != "enforce" {
68 t.Fatalf("bash = %q, want the project's narrowing to enforce", cfg.Sandbox.Bash)
69 }
70 if len(cfg.IgnoredProjectSettings()) != 0 {
71 t.Fatalf("ignored = %v, want nothing", ignoredKeys(cfg))
72 }
73 }
74
75 func TestProjectCannotOpenSandboxNetwork(t *testing.T) {
76 cfg, _ := loadScoped(t, "[sandbox]\nnetwork = false\n", "[sandbox]\nnetwork = true\n")
77 if cfg.Sandbox.Network {
78 t.Fatal("network = true, want the user's false kept")
79 }
80 if !slices.Contains(ignoredKeys(cfg), "sandbox.network") {
81 t.Fatalf("ignored = %v, want sandbox.network reported", ignoredKeys(cfg))
82 }
83 closed, _ := loadScoped(t, "", "[sandbox]\nnetwork = false\n")
84 if closed.Sandbox.Network {
85 t.Fatal("network = true, want the project's narrowing to false")
86 }
87 }
88
89 func TestProjectForbidReadOnlyAdds(t *testing.T) {
90 cfg, _ := loadScoped(t, "[sandbox]\nforbid_read = [\"/secret/a\"]\n", "[sandbox]\nforbid_read = [\"/secret/b\"]\n")
91 for _, want := range []string{"/secret/a", "/secret/b"} {
92 if !slices.Contains(cfg.Sandbox.ForbidRead, want) {
93 t.Fatalf("forbid_read = %v, want %s in the union", cfg.Sandbox.ForbidRead, want)
94 }
95 }
96 }
97
98 func TestProjectAllowWriteStaysInsideWorkspace(t *testing.T) {
99 outside := t.TempDir()
100 project := "[sandbox]\nallow_write = [\"build/out\", \"/\", \"../\", " + tomlQuote(outside) + "]\n"
101 cfg, root := loadScoped(t, "[sandbox]\nallow_write = [\"/user/extra\"]\n", project)
102 want := filepath.Join(realDir(t, root), "build", "out")
103 if !slices.Contains(cfg.Sandbox.AllowWrite, "/user/extra") {
104 t.Fatalf("allow_write = %v, want the user's entry kept", cfg.Sandbox.AllowWrite)
105 }
106 if !slices.Contains(cfg.Sandbox.AllowWrite, want) {
107 t.Fatalf("allow_write = %v, want %s", cfg.Sandbox.AllowWrite, want)
108 }
109 if len(cfg.Sandbox.AllowWrite) != 2 {
110 t.Fatalf("allow_write = %v, want only the user entry and the in-workspace one", cfg.Sandbox.AllowWrite)
111 }
112 if n := countKey(cfg, "sandbox.allow_write"); n != 3 {
113 t.Fatalf("allow_write refusals = %d, want 3 (%v)", n, cfg.IgnoredProjectSettings())
114 }
115 }
116
117 func TestProjectAllowWriteSymlinkOutOfWorkspaceIsRefused(t *testing.T) {
118 if runtime.GOOS == "windows" {
119 t.Skip("symlink creation needs privileges on Windows")
120 }
121 home := t.TempDir()
122 root := t.TempDir()
123 outside := t.TempDir()
124 t.Setenv("REASONIX_HOME", home)
125 if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
126 t.Fatal(err)
127 }
128 project := "[sandbox]\nallow_write = [\"link/deeper\"]\nworkspace_root = \"link\"\n"
129 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(project), 0o600); err != nil {
130 t.Fatal(err)
131 }
132 cfg, err := LoadForRootReadOnly(root)
133 if err != nil {
134 t.Fatal(err)
135 }
136 if len(cfg.Sandbox.AllowWrite) != 0 || cfg.Sandbox.WorkspaceRoot != "" {
137 t.Fatalf("sandbox = %+v, want the symlinked escape refused", cfg.Sandbox)
138 }
139 }
140
141 func TestProjectWorkspaceRootMustBeInsideWorkspace(t *testing.T) {
142 for name, value := range map[string]string{
143 "filesystem root": "/",
144 "parent": "..",
145 "sibling prefix": "../" + "x-sibling",
146 } {
147 t.Run(name, func(t *testing.T) {
148 cfg, _ := loadScoped(t, "", "[sandbox]\nworkspace_root = "+tomlQuote(value)+"\n")
149 if cfg.Sandbox.WorkspaceRoot != "" {
150 t.Fatalf("workspace_root = %q, want the user's (unset) kept", cfg.Sandbox.WorkspaceRoot)
151 }
152 if !slices.Contains(ignoredKeys(cfg), "sandbox.workspace_root") {
153 t.Fatalf("ignored = %v, want sandbox.workspace_root reported", ignoredKeys(cfg))
154 }
155 })
156 }
157 cfg, root := loadScoped(t, "", "[sandbox]\nworkspace_root = \"src\"\n")
158 if want := filepath.Join(realDir(t, root), "src"); cfg.Sandbox.WorkspaceRoot != want {
159 t.Fatalf("workspace_root = %q, want the subdirectory %q", cfg.Sandbox.WorkspaceRoot, want)
160 }
161 }
162
163 // A root whose name extends the workspace's is not inside it.
164 func TestProjectWorkspaceRootSharingANamePrefixIsOutside(t *testing.T) {
165 home := t.TempDir()
166 base := t.TempDir()
167 root := filepath.Join(base, "repo")
168 evil := filepath.Join(base, "repo-evil")
169 for _, dir := range []string{root, evil} {
170 if err := os.MkdirAll(dir, 0o700); err != nil {
171 t.Fatal(err)
172 }
173 }
174 t.Setenv("REASONIX_HOME", home)
175 project := "[sandbox]\nworkspace_root = " + tomlQuote(evil) + "\nallow_write = [" + tomlQuote(evil) + "]\n"
176 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(project), 0o600); err != nil {
177 t.Fatal(err)
178 }
179 cfg, err := LoadForRootReadOnly(root)
180 if err != nil {
181 t.Fatal(err)
182 }
183 if cfg.Sandbox.WorkspaceRoot != "" || len(cfg.Sandbox.AllowWrite) != 0 {
184 t.Fatalf("sandbox = %+v, want the sibling refused", cfg.Sandbox)
185 }
186 }
187
188 func TestProjectPermissionsOnlyNarrow(t *testing.T) {
189 user := "[permissions]\nmode = \"ask\"\ndeny = [\"Bash(rm -rf*)\"]\nask = [\"Edit(src/**)\"]\nallow = [\"Bash(go test:*)\"]\n"
190 project := "[permissions]\nmode = \"allow\"\nallow_dynamic_bash = true\nallow = [\"Bash\"]\ndeny = [\"Bash(git push*)\"]\nask = [\"Write\"]\n"
191 cfg, _ := loadScoped(t, user, project)
192 p := cfg.Permissions
193 if p.Mode != "ask" || p.AllowDynamicBash {
194 t.Fatalf("mode = %q dynamic = %v, want the user's ask and false", p.Mode, p.AllowDynamicBash)
195 }
196 if !slices.Equal(p.Allow, []string{"Bash(go test:*)"}) {
197 t.Fatalf("allow = %v, want only the user's rule", p.Allow)
198 }
199 if !slices.Equal(p.Deny, []string{"Bash(rm -rf*)", "Bash(git push*)"}) {
200 t.Fatalf("deny = %v, want the union", p.Deny)
201 }
202 if !slices.Equal(p.Ask, []string{"Edit(src/**)", "Write"}) {
203 t.Fatalf("ask = %v, want the union", p.Ask)
204 }
205 for _, key := range []string{"permissions.mode", "permissions.allow", "permissions.allow_dynamic_bash"} {
206 if !slices.Contains(ignoredKeys(cfg), key) {
207 t.Fatalf("ignored = %v, want %s reported", ignoredKeys(cfg), key)
208 }
209 }
210 }
211
212 func TestProjectCannotClearUserDeny(t *testing.T) {
213 cfg, _ := loadScoped(t, "[permissions]\ndeny = [\"Bash(curl*)\"]\n", "[permissions]\ndeny = []\n")
214 if !slices.Contains(cfg.Permissions.Deny, "Bash(curl*)") {
215 t.Fatalf("deny = %v, want the user's rule kept", cfg.Permissions.Deny)
216 }
217 }
218
219 func TestProjectCannotChooseToolApprovalPosture(t *testing.T) {
220 cfg, _ := loadScoped(t, "", "[desktop]\ndefault_tool_approval_mode = \"danger-full-access\"\n")
221 if got := cfg.DesktopDefaultToolApprovalMode(); got == "danger-full-access" {
222 t.Fatalf("approval mode = %q, want the user's default", got)
223 }
224 if !slices.Contains(ignoredKeys(cfg), "desktop.default_tool_approval_mode") {
225 t.Fatalf("ignored = %v, want the posture reported", ignoredKeys(cfg))
226 }
227 }
228
229 // Equal values are not a widening and deserve no notice.
230 func TestProjectRepeatingUserValuesIsQuiet(t *testing.T) {
231 same := "[sandbox]\nbash = \"enforce\"\nnetwork = true\n[permissions]\nmode = \"ask\"\n"
232 cfg, _ := loadScoped(t, same, same)
233 if len(cfg.IgnoredProjectSettings()) != 0 {
234 t.Fatalf("ignored = %v, want nothing", cfg.IgnoredProjectSettings())
235 }
236 }
237
238 func countKey(cfg *Config, key string) int {
239 n := 0
240 for _, ig := range cfg.IgnoredProjectSettings() {
241 if ig.Key == key {
242 n++
243 }
244 }
245 return n
246 }
247
248 func tomlQuote(s string) string {
249 return "'" + s + "'"
250 }
251
252 // A project value that expands to a literal "${B}" must not be expanded again
253 // later from the project's own .env.
254 func TestProjectPathsAreNotExpandedTwice(t *testing.T) {
255 home := t.TempDir()
256 root := t.TempDir()
257 t.Setenv("REASONIX_HOME", home)
258 up := strings.Repeat("../", 12)
259 if err := os.WriteFile(filepath.Join(root, ".env"), []byte("B="+up+"\n"), 0o600); err != nil {
260 t.Fatal(err)
261 }
262 project := "[sandbox]\nallow_write = [\"${REASONIX_TEST_UNSET_A:-$}{B}\"]\nworkspace_root = \"${REASONIX_TEST_UNSET_A:-$}{B}\"\n"
263 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(project), 0o600); err != nil {
264 t.Fatal(err)
265 }
266 cfg, err := LoadForRootReadOnly(root)
267 if err != nil {
268 t.Fatal(err)
269 }
270 ws := realDir(t, root)
271 for _, dir := range cfg.WriteRootsForRoot(root) {
272 abs, err := evalSymlinksAllowMissing(dir)
273 if err != nil {
274 t.Fatal(err)
275 }
276 if rel, err := filepath.Rel(ws, abs); err != nil || strings.HasPrefix(rel, "..") {
277 t.Fatalf("write roots = %v, want every root inside %s", cfg.WriteRootsForRoot(root), ws)
278 }
279 }
280 }
281
282 // A user's own ${VAR} never resolves from a workspace .env.
283 func TestUserSandboxPathsIgnoreTheWorkspaceEnv(t *testing.T) {
284 home := t.TempDir()
285 root := t.TempDir()
286 t.Setenv("REASONIX_HOME", home)
287 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nforbid_read = [\"${RX_TEST_UNSET_SECRETS_DIR:-/secrets}\"]\n"), 0o600); err != nil {
288 t.Fatal(err)
289 }
290 if err := os.WriteFile(filepath.Join(root, ".env"), []byte("RX_TEST_UNSET_SECRETS_DIR=/nothing-here\n"), 0o600); err != nil {
291 t.Fatal(err)
292 }
293 cfg, err := LoadForRootReadOnly(root)
294 if err != nil {
295 t.Fatal(err)
296 }
297 roots := cfg.ForbidReadRootsForRoot(root)
298 if len(roots) != 1 || filepath.Base(roots[0]) != "secrets" {
299 t.Fatalf("forbid_read = %v, want the user's default /secrets", roots)
300 }
301 }
302
303 func TestProjectCannotChooseTheNetworkProxy(t *testing.T) {
304 cfg, _ := loadScoped(t, "", "[network]\nproxy_mode = \"custom\"\nproxy_url = \"http://proxy.invalid:8080\"\n")
305 if cfg.Network.ProxyURL != "" || !slices.Contains(ignoredKeys(cfg), "network") {
306 t.Fatalf("network = %+v ignored = %v, want the user's proxy settings", cfg.Network, ignoredKeys(cfg))
307 }
308 }
309
310 // A checkout's [bot] never reaches the gateway, with or without a user [bot].
311 func TestProjectCannotConfigureTheBot(t *testing.T) {
312 user := "[bot]\nenabled = false\n[bot.allowlist]\nfeishu_users = [\"ou-user\"]\n"
313 project := "[bot]\nenabled = true\n[bot.allowlist]\nallow_all = true\nfeishu_users = [\"ou-project\"]\n[[bot.connections]]\nid = \"feishu-lark\"\nprovider = \"feishu\"\nenabled = true\n"
314 for name, u := range map[string]string{"user bot": user, "no user bot": ""} {
315 t.Run(name, func(t *testing.T) {
316 cfg, _ := loadScoped(t, u, project)
317 if cfg.Bot.Enabled || cfg.Bot.Allowlist.AllowAll || len(cfg.Bot.Connections) != 0 || slices.Contains(cfg.Bot.Allowlist.FeishuUsers, "ou-project") {
318 t.Fatalf("bot = %+v, want the user's", cfg.Bot)
319 }
320 if !slices.Contains(ignoredKeys(cfg), "bot") {
321 t.Fatalf("ignored = %v, want bot reported", ignoredKeys(cfg))
322 }
323 })
324 }
325 cfg, _ := loadScoped(t, user, project)
326 if !slices.Equal(cfg.Bot.Allowlist.FeishuUsers, []string{"ou-user"}) {
327 t.Fatalf("feishu users = %v, want the user's list intact", cfg.Bot.Allowlist.FeishuUsers)
328 }
329 }
330
330 lines GO