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'.
181 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|