| 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 |