harden: scope .git/entire-sessions/ writes to os.Root (defense in depth) · Entire
harden: scope .git/entire-sessions/ writes to os.Root (defense in depth)
e4df65e→main·
Soph·1mo ago·2 files·+88 added/-41 removed
The session-state writers key their filenames on the session ID. The ID is already validated, so this is belt-and-suspenders: route the writes through an os.Root scoped to the entire-sessions directory so the kernel makes escaping the directory impossible even if a validation gap were ever introduced.
- session/state.go: Save's atomic rename now goes through os.Root (Load and Clear already did). Drops the now-unused stateFilePath.
- strategy/session_state.go: StoreModelHint, StoreAgentTypeHint, ClaimSessionStartBanner, LoadModelHint, LoadAgentTypeHint and ClearSessionState now operate via os.Root. Two small helpers (openSessionStateRoot / ...ForRead) remove the repeated preamble.
Go 1.26's os.Root natively supports Rename/OpenFile(O_EXCL)/WriteFile, so the atomic-rename and first-writer-wins semantics are preserved.
The sibling entire-session-locks/ dir is intentionally left as-is: its flock semantics are inode-bound and it is a separate directory.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
Sessions
f95ae94b9edcView transcript
Changes
2
cmd/entire/cli
- session
Mstate.go+11/-8
strategy
Msession_state.go+77/-33
477 unmodified lines
...
// Scope the final rename to an os.Root so the session-ID-derived destination
// cannot escape the state directory even if validation were ever bypassed
// (defense in depth; the ID is already validated above).
root, err := os.OpenRoot(s.stateDir)
if err != nil {
return fmt.Errorf("failed to open session state directory: %w", err)
}
defer root.Close()
data, err := jsonutil.MarshalIndentWithNewline(state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal session state: %w", err)
}
stateFile := s.stateFilePath(state.SessionID)
fileName := state.SessionID + ".json"
// Use a unique temp file per save. Concurrent hook processes can write the
// Atomic rename into the validated final path.
if err := root.Rename(filepath.Base(tmpFileName), fileName); err != nil {
return fmt.Errorf("failed to rename session state file: %w", err)
}
removeTmp = false
return states, nil
}
// stateFilePath returns the path to a session state file.
func (s *StateStore) stateFilePath(sessionID string) string {
return filepath.Join(s.stateDir, sessionID+".json")
}
// gitCommonDirCache caches the git common dir to avoid repeated subprocess calls.
// Keyed by working directory to handle directory changes (same pattern as paths.WorktreeRoot).
var (
// openSessionStateRoot creates the session state directory if needed and returns // an os.Root scoped to it. Hint/marker files are named from the (already // validated) session ID; routing their writes through os.Root makes escaping // the directory impossible at the kernel level even if validation were bypassed. // Callers must Close the returned root. func openSessionStateRoot(ctx context.Context) (*os.Root, error) { stateDir, err := getSessionStateDir(ctx) if err != nil { return nil, fmt.Errorf("failed to get session state directory: %w", err) } if err := os.MkdirAll(stateDir, 0o750); err != nil { return nil, fmt.Errorf("failed to create session state directory: %w", err) } root, err := os.OpenRoot(stateDir) if err != nil { return nil, fmt.Errorf("failed to open session state directory: %w", err) } return root, nil }
// openSessionStateRootForRead returns an os.Root scoped to the session state // directory without creating it. Returns (nil, nil) when the directory does not // exist, so read paths can treat a missing directory as "no hint". func openSessionStateRootForRead(ctx context.Context) (*os.Root, error) { stateDir, err := getSessionStateDir(ctx) if err != nil { return nil, err } root, err := os.OpenRoot(stateDir) if os.IsNotExist(err) { return nil, nil //nolint:nilnil // missing dir = no hint; callers handle nil root } if err != nil { return nil, fmt.Errorf("failed to open session state directory: %w", err) } return root, nil }
// sessionStateFile returns the path to a session state file. func sessionStateFile(ctx context.Context, sessionID string) (string, error) { stateDir, err := getSessionStateDir(ctx)