34f6f1c786
Closes the cluster of audit findings where gnoma's incognito promise
('no persistence, no learning, local-only routing') silently broke
because state was duplicated across the CLI flag, the firewall's
IncognitoMode, the router's localOnly flag, and the TUI's local
m.incognito field. Wave 2 makes security.IncognitoMode the canonical
source of truth.
W2-1 Router.Select rejects forced non-local arms when localOnly is on
rather than short-circuiting and silently routing to cloud. Main
fails fast when --incognito + --provider <cloud> are combined; the
TUI toggle (Ctrl+X, /incognito, config panel) refuses with an
actionable message when a non-local arm is pinned. Factored the
three duplicated toggle sites into Model.attemptIncognitoToggle.
W2-2 persist.Store.Save consults an IncognitoGate (local interface,
*security.IncognitoMode satisfies it). nil gate = always persist
(legacy behaviour for tests); non-nil gate is consulted on every
Save so TUI runtime toggles take effect without reconstructing the
store. File mode 0o600, dir mode 0o700.
W2-3 tui.New seeds m.incognito from cfg.Firewall.Incognito().Active().
Fixes the Ctrl+X-on-launch-with-incognito case where the first
toggle silently turned the firewall OFF because the local flag
started false out of sync with the firewall.
W2-4 saveQuality gates on both *incognito (defensive, covers the
window before fwRef.Set fires) and fw.Incognito().ShouldLearn() (so
TUI Ctrl+X suppresses the snapshot on exit). Quality restore skipped
under --incognito. Quality file written 0o600 in dir 0o700.
engine.reportOutcome and elf.Manager.ReportResult both gate on
fw.Incognito().ShouldLearn() — bandit signal no longer leaks out of
incognito sessions.
W2-5 session files written 0o600 in dirs 0o700 (was 0o644 / 0o755).
W2-6 IncognitoMode.LocalOnly dropped — dead field with no readers;
routing local-only state lives on the router, not the firewall.
Also wires rtr.SetLocalOnly(true) when --incognito at launch — main
previously activated the firewall's flag but never told the router to
filter, so even without the forced-arm bug, launching with
--incognito alone gave you 'incognito badge but full arm pool'.
175 lines
4.5 KiB
Go
175 lines
4.5 KiB
Go
package session
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
)
|
|
|
|
// SessionStore manages session persistence to .gnoma/sessions/.
|
|
type SessionStore struct {
|
|
dir string
|
|
maxKeep int
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewSessionStore creates a store rooted at <projectRoot>/.gnoma/sessions/.
|
|
func NewSessionStore(projectRoot string, maxKeep int, logger *slog.Logger) *SessionStore {
|
|
return NewSessionStoreAt(filepath.Join(projectRoot, ".gnoma", "sessions"), maxKeep, logger)
|
|
}
|
|
|
|
// NewSessionStoreAt creates a store rooted at an explicit sessions directory.
|
|
// Use this when the directory layout differs from <projectRoot>/.gnoma/sessions
|
|
// (e.g. per-profile session segregation under .gnoma/sessions/<profile>/).
|
|
func NewSessionStoreAt(sessionsDir string, maxKeep int, logger *slog.Logger) *SessionStore {
|
|
return &SessionStore{
|
|
dir: sessionsDir,
|
|
maxKeep: maxKeep,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// sessionDir validates a session ID and returns its absolute path within the store.
|
|
// Rejects empty IDs and path traversal attempts.
|
|
func (s *SessionStore) sessionDir(id string) (string, error) {
|
|
if id == "" {
|
|
return "", fmt.Errorf("session ID must not be empty")
|
|
}
|
|
dir := filepath.Join(s.dir, id)
|
|
storeRoot := filepath.Clean(s.dir) + string(os.PathSeparator)
|
|
if !strings.HasPrefix(dir+string(os.PathSeparator), storeRoot) {
|
|
return "", fmt.Errorf("invalid session ID %q", id)
|
|
}
|
|
return dir, nil
|
|
}
|
|
|
|
func (s *SessionStore) Save(snap Snapshot) error {
|
|
dir, err := s.sessionDir(snap.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("session save: %w", err)
|
|
}
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("session %q: create dir: %w", snap.ID, err)
|
|
}
|
|
|
|
if err := atomicWrite(filepath.Join(dir, "metadata.json"), snap.Metadata); err != nil {
|
|
return fmt.Errorf("session %q: write metadata: %w", snap.ID, err)
|
|
}
|
|
|
|
if err := atomicWrite(filepath.Join(dir, "messages.json"), snap.Messages); err != nil {
|
|
return fmt.Errorf("session %q: write messages: %w", snap.ID, err)
|
|
}
|
|
|
|
return s.Prune()
|
|
}
|
|
|
|
func (s *SessionStore) Load(id string) (Snapshot, error) {
|
|
dir, err := s.sessionDir(id)
|
|
if err != nil {
|
|
return Snapshot{}, fmt.Errorf("session load: %w", err)
|
|
}
|
|
|
|
metaBytes, err := os.ReadFile(filepath.Join(dir, "metadata.json"))
|
|
if err != nil {
|
|
return Snapshot{}, fmt.Errorf("session %q not found: %w", id, err)
|
|
}
|
|
|
|
var meta Metadata
|
|
if err := json.Unmarshal(metaBytes, &meta); err != nil {
|
|
return Snapshot{}, fmt.Errorf("session %q: corrupt metadata: %w", id, err)
|
|
}
|
|
|
|
msgBytes, err := os.ReadFile(filepath.Join(dir, "messages.json"))
|
|
if err != nil {
|
|
return Snapshot{}, fmt.Errorf("session %q: read messages: %w", id, err)
|
|
}
|
|
|
|
var msgs []message.Message
|
|
if err := json.Unmarshal(msgBytes, &msgs); err != nil {
|
|
return Snapshot{}, fmt.Errorf("session %q: corrupt messages: %w", id, err)
|
|
}
|
|
|
|
return Snapshot{
|
|
ID: id,
|
|
Metadata: meta,
|
|
Messages: msgs,
|
|
}, nil
|
|
}
|
|
|
|
func (s *SessionStore) List() ([]Metadata, error) {
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("list sessions: %w", err)
|
|
}
|
|
|
|
var result []Metadata
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
id := entry.Name()
|
|
metaBytes, err := os.ReadFile(filepath.Join(s.dir, id, "metadata.json"))
|
|
if err != nil {
|
|
s.logger.Warn("session: skip unreadable metadata", "id", id, "err", err)
|
|
continue
|
|
}
|
|
var meta Metadata
|
|
if err := json.Unmarshal(metaBytes, &meta); err != nil {
|
|
s.logger.Warn("session: skip corrupt metadata", "id", id, "err", err)
|
|
continue
|
|
}
|
|
meta.ID = id
|
|
result = append(result, meta)
|
|
}
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].UpdatedAt.After(result[j].UpdatedAt)
|
|
})
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *SessionStore) Prune() error {
|
|
list, err := s.List()
|
|
if err != nil {
|
|
return fmt.Errorf("prune: list sessions: %w", err)
|
|
}
|
|
|
|
if len(list) <= s.maxKeep {
|
|
return nil
|
|
}
|
|
|
|
for _, meta := range list[s.maxKeep:] {
|
|
dir := filepath.Join(s.dir, meta.ID)
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
s.logger.Warn("session: prune failed", "id", meta.ID, "err", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func atomicWrite(path string, v any) error {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal: %w", err)
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return fmt.Errorf("write tmp: %w", err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return fmt.Errorf("rename: %w", err)
|
|
}
|
|
return nil
|
|
}
|