package ccsettings import ( "context" "fmt" "os/exec" "path/filepath" "time" ) // Permissions is the permissions block of one Claude Code settings file, as // written. Which of it is honoured is decided by NewRuleset, not here. type Permissions struct { Scope Scope // Path is the file, and Shown is how a message names it. Path string Shown string Allow []string Deny []string Ask []string // DefaultMode is Claude Code's starting permission mode. Only // "acceptEdits" and "default" mean anything to Codey, and only from a // file the user wrote. DefaultMode string // Tracked is set on a settings.local.json that git is tracking. Claude // Code keeps that file out of git, so one that is committed came from the // repository like the shared file did, and is treated as project scope. Tracked bool } // settingsFile is the part of a settings file this package reads. Hooks, env, // model, statusLine and the rest are other harnesses' business; see the // README for what is deliberately not carried over. type settingsFile struct { Permissions struct { Allow []string `json:"allow"` Deny []string `json:"deny"` Ask []string `json:"ask"` DefaultMode string `json:"defaultMode"` } `json:"permissions"` } // LoadPermissions reads the three settings files Claude Code would read for // root, in the order it would read them: the user's, the project's shared // file, and the project's local one. A file that is not there is skipped // silently; one that cannot be parsed is reported and skipped, which leaves // its deny rules unenforced — so the error is worth putting in front of the // user rather than in a log. // // home may be empty, for a machine with no home directory; the user's file is // then not read. func LoadPermissions(home, root string) ([]Permissions, []error) { type candidate struct { scope Scope path string } var files []candidate if home != "" { files = append(files, candidate{ScopeUser, filepath.Join(home, UserSettings)}) } // A root that is the home directory would read the user's file twice, // once as a project file; it is the user's. if root != "" && filepath.Clean(root) != filepath.Clean(home) { files = append(files, candidate{ScopeProject, filepath.Join(root, ProjectSettings)}, candidate{ScopeLocal, filepath.Join(root, LocalSettings)}) } var out []Permissions var errs []error for _, f := range files { var s settingsFile ok, err := readJSON(f.path, &s) if err != nil { errs = append(errs, fmt.Errorf("claude code settings: %w", err)) continue } if !ok { continue } p := Permissions{ Scope: f.scope, Path: f.path, Shown: shown(home, root, f.path), Allow: s.Permissions.Allow, Deny: s.Permissions.Deny, Ask: s.Permissions.Ask, DefaultMode: s.Permissions.DefaultMode, } if f.scope == ScopeLocal && gitTracks(root, LocalSettings) { p.Scope, p.Tracked = ScopeProject, true } out = append(out, p) } return out, errs } // gitTracks reports whether git has rel in its index under root. Any failure // — no git, not a repository — reads as untracked, which is the honest answer // for a directory git knows nothing about: nobody committed the file. func gitTracks(root, rel string) bool { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "--error-unmatch", "--", rel) return cmd.Run() == nil }