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.

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

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)