Review round 1 on PR #187: - realDir checked the cleaned parent while Lstat/OpenFile used the raw path; os.Root resolves ".." after following a symlink, so a ".." placed after a symlinked directory passed the check and redirected the read or write. ReadRegular, ReadRegularMax and WriteRegular now refuse a path that is not clean and local (ErrNotRegular). No current entry point reached it (callers clean first); the primitive no longer relies on that. - session.Append checked the sessions dir with Lstat, then opened by plain path: a symlink swapped in between created logs outside the brain (29k in the reviewer's RENAME_EXCHANGE race). It now opens through an os.Root on the sessions dir's parent, so a swap can only redirect inside the brain (#167), and, since os.Root follows an in-root final symlink even with O_NOFOLLOW, writes only after confirming the path is the regular file it opened (Lstat + os.SameFile). - Endpoint tests assert ErrorIs(brainfs.ErrNotRegular) and have positive controls. golang.org/x/sys moves from indirect to direct (unix.Renameat2 in the session race test); it was already in the module graph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
5.1 KiB
Go
151 lines
5.1 KiB
Go
// ingestion/internal/session/session.go
|
|
package session
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Entry is one skill invocation record, appended to the session JSONL log.
|
|
type Entry struct {
|
|
SessionID string `json:"session_id"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Skill string `json:"skill"`
|
|
Phase string `json:"phase,omitempty"`
|
|
ProjectRoot string `json:"project_root,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
Attempts []Attempt `json:"attempts,omitempty"`
|
|
FinalStatus string `json:"final_status"`
|
|
FilePath string `json:"file_path,omitempty"`
|
|
ModelUsed string `json:"model_used,omitempty"`
|
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// Attempt represents one subprocess invocation within a skill call.
|
|
type Attempt struct {
|
|
Attempt int `json:"attempt"`
|
|
Model string `json:"model"`
|
|
Tier string `json:"tier"` // local | subagent | managed
|
|
DurationMs int64 `json:"duration_ms"`
|
|
WarmStart bool `json:"warm_start"` // model already loaded in llama-swap
|
|
Verified bool `json:"verified"`
|
|
Verdict string `json:"verdict,omitempty"` // accept | escalate | error
|
|
Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation
|
|
OutputSummary string `json:"output_summary,omitempty"`
|
|
RunnerOutput string `json:"runner_output,omitempty"`
|
|
}
|
|
|
|
// idPattern is a plain file name: session_log takes the id from the caller,
|
|
// and a path segment in it created or appended files anywhere the pod could
|
|
// write (hyperguild#169).
|
|
var idPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$`)
|
|
|
|
// ErrInvalidID is returned when a session id is not a plain file name.
|
|
var ErrInvalidID = errors.New("session id is not a plain name")
|
|
|
|
func checkID(sessionID string) error {
|
|
if !idPattern.MatchString(sessionID) {
|
|
return fmt.Errorf("%w: %q", ErrInvalidID, sessionID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl.
|
|
// The open does not follow a final symlink.
|
|
func Append(sessionsDir, sessionID string, entry Entry) error {
|
|
if err := checkID(sessionID); err != nil {
|
|
return err
|
|
}
|
|
// Opened through an os.Root on the sessions dir's parent (the brain dir),
|
|
// so a sessions dir swapped for a symlink after the check below can
|
|
// redirect the log only inside that parent, never outside it (#173; the
|
|
// in-brain swap is the #167 residual).
|
|
parentDir, name := filepath.Dir(sessionsDir), filepath.Base(sessionsDir)
|
|
if err := os.MkdirAll(parentDir, 0o755); err != nil {
|
|
return fmt.Errorf("create sessions parent: %w", err)
|
|
}
|
|
root, err := os.OpenRoot(parentDir)
|
|
if err != nil {
|
|
return fmt.Errorf("open sessions parent: %w", err)
|
|
}
|
|
defer func() { _ = root.Close() }()
|
|
if err := root.Mkdir(name, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
|
|
return fmt.Errorf("create sessions dir: %w", err)
|
|
}
|
|
// The sessions dir itself must be a real directory, not a symlink that
|
|
// redirects the log elsewhere (hyperguild#173).
|
|
if fi, err := root.Lstat(name); err != nil {
|
|
return fmt.Errorf("stat sessions dir: %w", err)
|
|
} else if !fi.IsDir() {
|
|
return fmt.Errorf("sessions dir %s is not a real directory", sessionsDir)
|
|
}
|
|
logRel := name + "/" + sessionID + ".jsonl"
|
|
f, err := root.OpenFile(logRel, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("open session log: %w", err)
|
|
}
|
|
// os.Root follows an in-root final symlink even with O_NOFOLLOW, so
|
|
// confirm, before writing, that the path is the regular file we opened.
|
|
hfi, herr := f.Stat()
|
|
lfi, lerr := root.Lstat(logRel)
|
|
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
|
|
_ = f.Close()
|
|
return fmt.Errorf("session log %s is not a regular file", logRel)
|
|
}
|
|
|
|
line, err := json.Marshal(entry)
|
|
if err != nil {
|
|
_ = f.Close()
|
|
return fmt.Errorf("marshal entry: %w", err)
|
|
}
|
|
if _, err = fmt.Fprintf(f, "%s\n", line); err != nil {
|
|
_ = f.Close()
|
|
return fmt.Errorf("write entry: %w", err)
|
|
}
|
|
if err = f.Close(); err != nil {
|
|
return fmt.Errorf("close session log: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Read returns all entries for sessionID. Returns empty slice if no log exists.
|
|
func Read(sessionsDir, sessionID string) ([]Entry, error) {
|
|
if err := checkID(sessionID); err != nil {
|
|
return nil, err
|
|
}
|
|
path := filepath.Join(sessionsDir, sessionID+".jsonl")
|
|
f, err := os.Open(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return []Entry{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open session log: %w", err)
|
|
}
|
|
defer f.Close() //nolint:errcheck
|
|
|
|
var entries []Entry
|
|
scanner := bufio.NewScanner(f)
|
|
scanner.Buffer(make([]byte, 0, 256*1024), 1<<20) // up to 1 MB per line
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var e Entry
|
|
if err := json.Unmarshal(line, &e); err != nil {
|
|
return nil, fmt.Errorf("parse entry: %w", err)
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, scanner.Err()
|
|
}
|