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
}