Vectrum
Sign In
106 lines · 3.4 KB · Go
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
}