| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "runtime" |
| 8 | "slices" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func TestExistingProjectGrantsCoverDeclarationsAndDoNotResurrect(t *testing.T) { |
| 14 | out := t.TempDir() |
| 15 | rule := "Bash=echo exact $(value)" |
| 16 | _, root := loadScoped(t, "", "[permissions]\nallow = "+renderStringArray([]string{rule})+"\n[sandbox]\nallow_write = ["+tomlQuote(filepath.Join(out, "child"))+"]\n") |
| 17 | store := NewProjectGrantStore(ReasonixHomeDir()) |
| 18 | if err := store.Update(root, func(g ProjectGrant) (ProjectGrant, error) { |
| 19 | g.Allow = []string{rule} |
| 20 | g.AllowWrite = []string{out} |
| 21 | return g, nil |
| 22 | }); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | cfg, err := LoadForRootReadOnly(root) |
| 26 | if err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | if len(cfg.IgnoredProjectSettings()) != 0 || !slices.Equal(cfg.Permissions.Allow, []string{rule}) || !slices.Equal(cfg.Sandbox.AllowWrite, []string{out}) { |
| 30 | t.Fatalf("effective=%+v diagnostics=%+v", cfg.Sandbox, cfg.IgnoredProjectSettings()) |
| 31 | } |
| 32 | other := t.TempDir() |
| 33 | otherCfg, err := LoadForRootReadOnly(other) |
| 34 | if err != nil || len(otherCfg.Permissions.Allow) != 0 { |
| 35 | t.Fatal("grant crossed workspace boundary", err) |
| 36 | } |
| 37 | if err := store.Update(root, func(g ProjectGrant) (ProjectGrant, error) { return ProjectGrant{}, nil }); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | cfg, err = LoadForRootReadOnly(root) |
| 41 | if err != nil || len(cfg.Permissions.Allow) != 0 || len(cfg.Sandbox.AllowWrite) != 0 || len(cfg.IgnoredProjectSettings()) != 2 { |
| 42 | t.Fatalf("revoked grant restored: %v %+v", err, cfg.IgnoredProjectSettings()) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | func TestEquivalentProjectRootUsesExistingUserValue(t *testing.T) { |
| 47 | parent := t.TempDir() |
| 48 | root := filepath.Join(parent, "project") |
| 49 | if err := os.Mkdir(root, 0700); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | home := t.TempDir() |
| 53 | t.Setenv("REASONIX_HOME", home) |
| 54 | userPath := filepath.Clean(parent) |
| 55 | projectPath := parent + string(filepath.Separator) + "." |
| 56 | if runtime.GOOS == "windows" { |
| 57 | projectPath = filepath.ToSlash(parent) |
| 58 | } |
| 59 | if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[sandbox]\nworkspace_root = "+tomlQuote(userPath)+"\n"), 0600); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[sandbox]\nworkspace_root = "+tomlQuote(projectPath)+"\n"), 0600); err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | cfg, err := LoadForRootReadOnly(root) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | if len(cfg.IgnoredProjectSettings()) != 0 || cfg.Sandbox.WorkspaceRoot != userPath { |
| 70 | t.Fatalf("root=%q diagnostics=%+v", cfg.Sandbox.WorkspaceRoot, cfg.IgnoredProjectSettings()) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestProjectPathIdentityAndBoundary(t *testing.T) { |
| 75 | parent := t.TempDir() |
| 76 | root, sibling := filepath.Join(parent, "repo"), filepath.Join(parent, "repo-other") |
| 77 | for _, p := range []string{root, sibling} { |
| 78 | if err := os.Mkdir(p, 0700); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | } |
| 82 | c := Default() |
| 83 | if c.authorizedPath(root, []string{root}, sibling) { |
| 84 | t.Fatal("string prefix granted sibling") |
| 85 | } |
| 86 | if !c.authorizedPath(root, []string{root}, filepath.Join(root, "missing", "child")) { |
| 87 | t.Fatal("missing tail lost its authorized ancestor") |
| 88 | } |
| 89 | link := filepath.Join(root, "link") |
| 90 | if err := os.Symlink(sibling, link); err != nil { |
| 91 | t.Skipf("symlink unavailable: %v", err) |
| 92 | } |
| 93 | if c.authorizedPath(root, []string{root}, filepath.Join(link, "new")) { |
| 94 | t.Fatal("external link granted by project root") |
| 95 | } |
| 96 | if !c.authorizedPath(root, []string{sibling}, filepath.Join(link, "new")) { |
| 97 | t.Fatal("existing external grant not recognized") |
| 98 | } |
| 99 | upper := filepath.Join(parent, "REPO") |
| 100 | if err := os.Mkdir(upper, 0700); err == nil { |
| 101 | if c.equivalentConfigPath(root, root, upper) { |
| 102 | t.Fatal("distinct case-sensitive directories conflated") |
| 103 | } |
| 104 | } else if os.IsExist(err) && !c.equivalentConfigPath(root, root, upper) { |
| 105 | t.Fatal("case-insensitive directory identity lost") |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | func TestProjectPermissionCoverageRetainsRuleSemantics(t *testing.T) { |
| 110 | for _, tc := range []struct { |
| 111 | name, global, project string |
| 112 | pending bool |
| 113 | }{ |
| 114 | {"exact", "Bash=echo one", "Bash=echo one", false}, |
| 115 | {"glob", "Bash(go test:*)", "Bash=go test ./...", false}, |
| 116 | {"tool", "Read", "Read(src/**)", false}, |
| 117 | {"no_broadening", "Bash=echo one", "Bash", true}, |
| 118 | {"literal_star", "Bash=echo *", "Bash=echo secret", true}, |
| 119 | } { |
| 120 | t.Run(tc.name, func(t *testing.T) { |
| 121 | cfg, _ := loadScoped(t, "[permissions]\nmode='ask'\nallow="+renderStringArray([]string{tc.global})+"\ndeny=['Bash(rm:*)']\nask=['Write']\n", "[permissions]\nallow="+renderStringArray([]string{tc.project})+"\n") |
| 122 | if len(cfg.IgnoredProjectSettings()) > 0 != tc.pending { |
| 123 | t.Fatalf("diagnostics=%+v", cfg.IgnoredProjectSettings()) |
| 124 | } |
| 125 | if !slices.Equal(cfg.Permissions.Allow, []string{tc.global}) || !slices.Contains(cfg.Permissions.Ask, "Write") || !slices.Contains(cfg.Permissions.Deny, "Bash(rm:*)") || cfg.Permissions.Mode != "ask" { |
| 126 | t.Fatalf("permission semantics changed: %+v", cfg.Permissions) |
| 127 | } |
| 128 | }) |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | func TestExistingWorkspaceRootCoversExternalDeclaration(t *testing.T) { |
| 133 | root := t.TempDir() |
| 134 | cfg, _ := loadScoped(t, "[sandbox]\nworkspace_root="+tomlQuote(root)+"\n", "[sandbox]\nallow_write=["+tomlQuote(filepath.Join(root, "child"))+"]\n") |
| 135 | if len(cfg.IgnoredProjectSettings()) != 0 || len(cfg.Sandbox.AllowWrite) != 0 { |
| 136 | t.Fatalf("existing root not recognized: %+v", cfg.IgnoredProjectSettings()) |
| 137 | } |
| 138 | // Declaring an unapproved workspace root must not bootstrap authorization. |
| 139 | cfg, _ = loadScoped(t, "", "[sandbox]\nworkspace_root="+tomlQuote(root)+"\nallow_write=["+tomlQuote(filepath.Join(root, "child"))+"]\n") |
| 140 | if len(cfg.IgnoredProjectSettings()) != 2 || len(cfg.Sandbox.AllowWrite) != 0 { |
| 141 | t.Fatal("project root granted its own write declaration") |
| 142 | } |
| 143 | } |
| 144 | func TestLegacyProjectDeclarationsAreNotGrantsOrLoadFailures(t *testing.T) { |
| 145 | rules := make([]string, 39) |
| 146 | for i := range rules { |
| 147 | rules[i] = fmt.Sprintf("Bash=echo synthetic-command-%d", i) |
| 148 | } |
| 149 | project := "# preserved comment\n[model_roles]\nanswerer = 'unchanged'\n[permissions]\nallow = " + renderStringArray(rules) + "\n" |
| 150 | cfg, root := loadScoped(t, "[permissions]\nmode='ask'\ndeny=['Bash(rm:*)']\n", project) |
| 151 | file := filepath.Join(root, "reasonix.toml") |
| 152 | before, _ := os.Stat(file) |
| 153 | if cfg.HasLoadWarnings() || len(cfg.Permissions.Allow) != 0 || len(cfg.IgnoredProjectSettings()) != 39 { |
| 154 | t.Fatalf("warnings=%v permissions=%+v ignored=%v", cfg.LoadWarnings(), cfg.Permissions, cfg.IgnoredProjectSettings()) |
| 155 | } |
| 156 | for range 2 { |
| 157 | if _, err := LoadForRootReadOnly(root); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | } |
| 161 | after, _ := os.Stat(file) |
| 162 | content, _ := os.ReadFile(file) |
| 163 | if string(content) != project || !before.ModTime().Equal(after.ModTime()) { |
| 164 | t.Fatal("read-only loading rewrote project file") |
| 165 | } |
| 166 | } |
| 167 | func TestCorruptProjectGrantsRemainLoadWarnings(t *testing.T) { |
| 168 | _, root := loadScoped(t, "", "") |
| 169 | path := NewProjectGrantStore(ReasonixHomeDir()).Path() |
| 170 | if err := os.WriteFile(path, []byte("{broken"), 0600); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | cfg, err := LoadForRootReadOnly(root) |
| 174 | if err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | if !cfg.HasLoadWarnings() || !strings.Contains(strings.Join(cfg.LoadWarnings(), "\n"), path) { |
| 178 | t.Fatalf("warnings=%v", cfg.LoadWarnings()) |
| 179 | } |
| 180 | } |
| 181 | func TestProjectUnknownPresetWarningKeepsItsOrigin(t *testing.T) { |
| 182 | cfg, root := loadScoped(t, "[desktop]\ndefault_tool_approval_mode='workspace-write'\n", "[desktop]\ndefault_tool_approval_mode='unknown-project-preset'\n") |
| 183 | warnings := strings.Join(cfg.LoadWarnings(), "\n") |
| 184 | if !strings.Contains(warnings, "project config sets desktop.default_tool_approval_mode") || !strings.Contains(warnings, "unknown-project-preset") || strings.Contains(warnings, filepath.Join(ReasonixHomeDir(), "config.toml")) { |
| 185 | t.Fatalf("root=%s warnings=%s", root, warnings) |
| 186 | } |
| 187 | } |
| 188 |