package ccsettings
import (
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
)
// write puts a file under dir, creating its parents. Every test here builds
// its own home and root under t.TempDir: nothing may read the real ~/.claude*.
func write(t *testing.T, dir, rel, body string) string {
t.Helper()
path := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func dirs(t *testing.T) (home, root string) {
t.Helper()
base := t.TempDir()
home = filepath.Join(base, "home")
root = filepath.Join(home, "src", "proj")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
return home, root
}
func TestLoadPermissionsReadsTheThreeFiles(t *testing.T) {
home, root := dirs(t)
write(t, home, UserSettings, `{"permissions":{"allow":["Bash(go test:*)"],"defaultMode":"acceptEdits"},"model":"opus","env":{"X":"1"}}`)
write(t, root, ProjectSettings, `{"permissions":{"allow":["Bash(make:*)"],"deny":["Read(.env)"]}}`)
write(t, root, LocalSettings, `{"permissions":{"ask":["Bash(git push:*)"]}}`)
got, errs := LoadPermissions(home, root)
if len(errs) != 0 {
t.Fatalf("errors: %v", errs)
}
want := []Permissions{
{Scope: ScopeUser, Shown: "~/.claude/settings.json", Allow: []string{"Bash(go test:*)"}, DefaultMode: "acceptEdits"},
{Scope: ScopeProject, Shown: ".claude/settings.json", Allow: []string{"Bash(make:*)"}, Deny: []string{"Read(.env)"}},
{Scope: ScopeLocal, Shown: ".claude/settings.local.json", Ask: []string{"Bash(git push:*)"}},
}
for i := range got {
got[i].Path = ""
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v\nwant %+v", got, want)
}
}
func TestLoadPermissionsShapes(t *testing.T) {
tests := []struct {
name string
body string
wantFiles int
wantErr bool
}{
{"no file", "", 0, false},
{"empty object", `{}`, 1, false},
{"no permissions block", `{"hooks":{"PreToolUse":[]}}`, 1, false},
{"broken JSON", `{"permissions":`, 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
home, root := dirs(t)
if tc.body != "" {
write(t, root, ProjectSettings, tc.body)
}
got, errs := LoadPermissions(home, root)
if len(got) != tc.wantFiles || (len(errs) > 0) != tc.wantErr {
t.Errorf("files %d errs %v, want %d files, error %v", len(got), errs, tc.wantFiles, tc.wantErr)
}
})
}
}
// Started in the home directory, the user's settings are the user's, not a
// project's: reading them a second time as shared project settings would hold
// their allow rules hostage to a trust prompt about themselves.
func TestLoadPermissionsRootIsHome(t *testing.T) {
home, _ := dirs(t)
write(t, home, UserSettings, `{"permissions":{"allow":["Edit"]}}`)
got, _ := LoadPermissions(home, home)
if len(got) != 1 || got[0].Scope != ScopeUser {
t.Errorf("got %+v, want only the user file", got)
}
}
// A settings.local.json is supposed to be kept out of git. One that git is
// tracking arrived with the clone, and gets no more trust than the shared
// file.
func TestTrackedLocalSettingsAreProjectScope(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
home, root := dirs(t)
write(t, root, LocalSettings, `{"permissions":{"allow":["Bash"]}}`)
for _, args := range [][]string{{"init", "-q"}, {"add", "-f", LocalSettings}} {
cmd := exec.Command("git", args...)
cmd.Dir = root
cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
got, _ := LoadPermissions(home, root)
if len(got) != 1 || got[0].Scope != ScopeProject || !got[0].Tracked {
t.Fatalf("got %+v, want a tracked, project-scope file", got)
}
rs, _ := NewRuleset(home, root, got, false)
if v := rs.Check("bash", []byte(`{"command":"ls"}`)); v.Verdict != 0 {
t.Errorf("an untrusted, committed local file's allow rule was honoured: %+v", v)
}
}