Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions internal/guard/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,8 @@ func readClaudeSettings() (string, map[string]any, error) {
}

func backupFile(path, label string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return nil
} else if err != nil {
return err
Expand All @@ -450,7 +451,7 @@ func backupFile(path, label string) error {
if err != nil {
return err
}
return os.WriteFile(backupPath, input, 0o644)
return os.WriteFile(backupPath, input, info.Mode().Perm())
}
Comment on lines 442 to 455

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Existing backups stay readable

When backupPath already exists, os.WriteFile truncates and rewrites it without changing its mode. The backup name only has second-level precision, so two hook operations in the same second can reuse the same path; the path can also already exist from a previous install that created a 0644 backup. In that case this writes private 0600 settings content into a backup that remains world-readable, so the permission fix does not cover the backup file.

Suggested change
func backupFile(path, label string) error {
info, err := os.Stat(path)
if os.IsNotExist(err) {
return nil
} else if err != nil {
return err
}
mode := info.Mode().Perm()
backupPath := fmt.Sprintf("%s.%s-backup-%s", path, label, time.Now().UTC().Format("20060102T150405Z"))
input, err := os.ReadFile(path)
if err != nil {
return err
}
if err := os.WriteFile(backupPath, input, mode); err != nil {
return err
}
return os.Chmod(backupPath, mode)
}


func writeJSONFile(path string, value any) error {
Expand All @@ -459,7 +460,15 @@ func writeJSONFile(path string, value any) error {
return err
}
bytes = append(bytes, '\n')
return os.WriteFile(path, bytes, 0o644)
return os.WriteFile(path, bytes, fileModeOrDefault(path, 0o600))
}

func fileModeOrDefault(path string, fallback os.FileMode) os.FileMode {
info, err := os.Stat(path)
if err != nil {
return fallback
}
return info.Mode().Perm()
}

func mergeHooks(raw any, hookCommand string) map[string]any {
Expand Down
38 changes: 38 additions & 0 deletions internal/guard/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,44 @@ func TestUninstallClaudeHooksPreservesNonGuardHookInMixedGroup(t *testing.T) {
}
}

func TestInstallClaudeHooksPreservesRestrictiveSettingsMode(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
settingsPath := filepath.Join(home, ".claude", "settings.json")
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(settingsPath, []byte(`{"hooks":{}}`), 0o600); err != nil {
t.Fatal(err)
}

var stdout bytes.Buffer
if err := installClaudeHooks(&stdout, "/tmp/kontext.sock"); err != nil {
t.Fatal(err)
}

assertFileMode(t, settingsPath, 0o600)
backups, err := filepath.Glob(settingsPath + ".kontext-guard-backup-*")
if err != nil {
t.Fatal(err)
}
if len(backups) != 1 {
t.Fatalf("backup count = %d, want 1", len(backups))
}
assertFileMode(t, backups[0], 0o600)
}

func assertFileMode(t *testing.T, path string, want os.FileMode) {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != want {
t.Fatalf("%s mode = %o, want %o", path, got, want)
}
}

func TestPrintHookStatusReportsGuardAndHostedConflict(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
Expand Down
Loading