Route all settings-package file reads through confined os.Root · Entire
Route all settings-package file reads through confined os.Root
41b7522→main·
Soph·3w ago·2 files·+40 added/-39 removed
Follow-up to the checkpoints-loader TOCTOU fix: make the rest of the settings package consistent. Promote readConfined to settings.go and route every settings/preferences file read through it — loadFromFile (base settings), loadMergedSettings (local override), LoadProjectRaw, LoadLocalRaw, and loadClonePreferencesFromFile — instead of bare os.ReadFile of an absolute path.
Each read now opens an os.Root anchored at the file's parent directory, so a swapped or symlinked path can't redirect the open outside that directory. The helper wraps its errors, so callers classify "missing" with errors.Is(err, fs.ErrNotExist) rather than os.IsNotExist. This removes the earlier split-brain where a symlinked settings.json worked for Load but was refused only by the checkpoints loader.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
50640125952dView transcript
Changes
2
cmd/entire/cli/settings
Mcheckpoints.go-29
- Msettings.go+40/-10
5 unmodified lines
6
7
8
9
9
10
12
13
11
12
13
99 unmodified lines
113
114
115
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
116
117
118
5 unmodified lines
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"github.com/entireio/cli/cmd/entire/cli/logging"
}
// readConfined reads filePath through an os.Root anchored at its parent
directory. The root confines the open to that directory, so the read cannot
be redirected outside it by a swapped or symlinked path between resolution and
open (TOCTOU) — unlike a bare os.ReadFile of an absolute path. A symlink that
escapes the directory surfaces as a non-ENOENT error, which the caller treats
as fail-soft.
func readConfined(filePath string) ([]byte, error) {
root, err := os.OpenRoot(filepath.Dir(filePath))
if err != nil {
return nil, fmt.Errorf("open settings dir: %w", err)
}
defer root.Close()
f, err := root.Open(filepath.Base(filePath))
if err != nil {
return nil, fmt.Errorf("open settings file: %w", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("read settings file: %w", err)
}
return data, nil
}
func (c *CheckpointsConfig) validate() error {
if c.Primary.Type == "" {
return fmt.Errorf("%w: checkpoints.primary.type is required", ErrInvalidCheckpointsConfig)
}
}
Mcmd/entire/cli/settings/checkpoints.go-29
6 unmodified lines
7 8 9 10 11 12 13 14 15 16 443 unmodified lines
460 461 462 460 463 464 462 465 466 467 468 40 unmodified lines
509 510 511 509 512 513 511 514 515 516 517 18 unmodified lines
536 537 538 536 539 540 538 541 542 543 544 91 unmodified lines
636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 1 unmodified line
670 671 672 643 673 674 645 675 676 677 678 26 unmodified lines
705 706 707 678 708 709 680 710 711 712 713
6 unmodified lines
"bytes" "context" "encoding/json" "errors" "fmt" "io" "io/fs" "log/slog" "os" "os/exec"
}
// Apply local overrides if they exist
localData, err := os.ReadFile(localSettingsFileAbs) //nolint:gosec // path is from AbsPath or constant
localData, err := readConfined(localSettingsFileAbs)
if err != nil {
if !os.IsNotExist(err) {
if !errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("reading local settings file: %w", err)
}
// Local file doesn't exist, continue without overrides
}
if err != nil { path = EntireSettingsFile } data, readErr := os.ReadFile(path) //nolint:gosec // path is from AbsPath or a project-relative constant data, readErr := readConfined(path) if readErr != nil { if os.IsNotExist(readErr) { if errors.Is(readErr, fs.ErrNotExist) { return path, map[string]json.RawMessage{}, false, nil } return path, nil, false, fmt.Errorf("reading project settings: %w", readErr) } }
if err != nil { path = EntireSettingsLocalFile } data, readErr := os.ReadFile(path) //nolint:gosec // path is from AbsPath or a project-relative constant data, readErr := readConfined(path) if readErr != nil { if os.IsNotExist(readErr) { if errors.Is(readErr, fs.ErrNotExist) { return path, map[string]json.RawMessage{}, false, nil } return path, nil, false, fmt.Errorf("reading local settings: %w", readErr) } }
return s, nil }
// readConfined reads filePath through an os.Root anchored at its parent directory. The root confines the open to that directory, so the read cannot be redirected outside it by a swapped or symlinked path between resolution and open (TOCTOU) — unlike a bare os.ReadFile of an absolute path. A symlink that escapes the directory surfaces as a non-ENOENT error. Callers must classify "missing" with errors.Is(err, fs.ErrNotExist) rather than os.IsNotExist, since the returned errors are wrapped. func readConfined(filePath string) ([]byte, error) { root, err := os.OpenRoot(filepath.Dir(filePath)) if err != nil { return nil, fmt.Errorf("open settings dir: %w", err) } defer root.Close()
f, err := root.Open(filepath.Base(filePath)) if err != nil { return nil, fmt.Errorf("open settings file: %w", err) } defer f.Close()
data, err := io.ReadAll(f) if err != nil { return nil, fmt.Errorf("read settings file: %w", err) } return data, nil }
// loadFromFile loads settings from a specific file path. // Returns default settings if the file doesn't exist. func loadFromFile(filePath string) (*EntireSettings, error) { }`