Files
vikingowl 34f6f1c786 feat(security): incognito coherence across firewall/router/persist (Wave 2)
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'.
2026-05-19 22:57:36 +02:00

181 lines
4.5 KiB
Go

package session_test
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/session"
)
func makeSnap(id string, updated time.Time) session.Snapshot {
return session.Snapshot{
ID: id,
Metadata: session.Metadata{
ID: id,
Provider: "anthropic",
Model: "claude",
TurnCount: 1,
UpdatedAt: updated,
CreatedAt: updated,
MessageCount: 2,
},
Messages: []message.Message{
message.NewUserText("hello"),
message.NewAssistantText("hi"),
},
}
}
func makeStore(t *testing.T) *session.SessionStore {
t.Helper()
root := t.TempDir()
return session.NewSessionStore(root, 3, slog.Default())
}
func TestSessionStore_SaveLoad(t *testing.T) {
store := makeStore(t)
snap := makeSnap("sess-001", time.Now().UTC())
if err := store.Save(snap); err != nil {
t.Fatal(err)
}
got, err := store.Load("sess-001")
if err != nil {
t.Fatal(err)
}
if got.ID != "sess-001" {
t.Errorf("ID mismatch: %q", got.ID)
}
if len(got.Messages) != 2 {
t.Errorf("messages: %d", len(got.Messages))
}
if got.Metadata.Provider != "anthropic" {
t.Errorf("provider: %q", got.Metadata.Provider)
}
}
func TestSessionStore_Load_Missing(t *testing.T) {
store := makeStore(t)
_, err := store.Load("nonexistent")
if err == nil {
t.Error("expected error for missing session")
}
}
func TestSessionStore_Load_CorruptMetadata(t *testing.T) {
root := t.TempDir()
store := session.NewSessionStore(root, 3, slog.Default())
dir := filepath.Join(root, ".gnoma", "sessions", "corrupt-sess")
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "metadata.json"), []byte("not json"), 0o644)
_ = os.WriteFile(filepath.Join(dir, "messages.json"), []byte("[]"), 0o644)
_, err := store.Load("corrupt-sess")
if err == nil {
t.Error("expected error for corrupt metadata")
}
}
func TestSessionStore_List_SortedByUpdatedAt(t *testing.T) {
store := makeStore(t)
now := time.Now().UTC()
_ = store.Save(makeSnap("sess-old", now.Add(-2*time.Hour)))
_ = store.Save(makeSnap("sess-new", now))
_ = store.Save(makeSnap("sess-mid", now.Add(-1*time.Hour)))
list, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(list) != 3 {
t.Fatalf("expected 3 sessions, got %d", len(list))
}
if list[0].ID != "sess-new" {
t.Errorf("first should be newest: %q", list[0].ID)
}
if list[2].ID != "sess-old" {
t.Errorf("last should be oldest: %q", list[2].ID)
}
}
func TestSessionStore_Load_RejectsPathTraversal(t *testing.T) {
store := makeStore(t)
cases := []string{"../../etc/passwd", "../sibling", ""}
for _, id := range cases {
_, err := store.Load(id)
if err == nil {
t.Errorf("Load(%q): expected error for invalid ID", id)
}
}
}
func TestSessionStore_Save_RejectsPathTraversal(t *testing.T) {
store := makeStore(t)
snap := makeSnap("../../evil", time.Now().UTC())
if err := store.Save(snap); err == nil {
t.Error("Save with traversal ID: expected error")
}
}
func TestSessionStore_Save_FilesArePrivate(t *testing.T) {
// Session files contain conversation history including raw user
// input — keep them 0o600 / 0o700 so other local users on shared
// hosts can't read them.
root := t.TempDir()
store := session.NewSessionStore(root, 3, slog.Default())
snap := makeSnap("sess-perms", time.Now().UTC())
if err := store.Save(snap); err != nil {
t.Fatal(err)
}
dir := filepath.Join(root, ".gnoma", "sessions", "sess-perms")
dirInfo, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat session dir: %v", err)
}
if dirInfo.Mode().Perm() != 0o700 {
t.Errorf("session dir mode = %o, want 0700", dirInfo.Mode().Perm())
}
for _, name := range []string{"metadata.json", "messages.json"} {
info, err := os.Stat(filepath.Join(dir, name))
if err != nil {
t.Fatalf("stat %s: %v", name, err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("%s mode = %o, want 0600", name, info.Mode().Perm())
}
}
}
func TestSessionStore_Prune_RemovesOldest(t *testing.T) {
store := makeStore(t) // maxKeep = 3
now := time.Now().UTC()
for i := 0; i < 5; i++ {
id := fmt.Sprintf("sess-%03d", i)
_ = store.Save(makeSnap(id, now.Add(time.Duration(i)*time.Minute)))
}
list, err := store.List()
if err != nil {
t.Fatal(err)
}
if len(list) != 3 {
t.Errorf("expected 3 sessions after prune, got %d", len(list))
}
for _, m := range list {
if m.ID == "sess-000" || m.ID == "sess-001" {
t.Errorf("oldest session %q should have been pruned", m.ID)
}
}
}