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'.
160 lines
4.7 KiB
Go
160 lines
4.7 KiB
Go
package tui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/provider"
|
|
"somegit.dev/Owlibou/gnoma/internal/router"
|
|
"somegit.dev/Owlibou/gnoma/internal/security"
|
|
)
|
|
|
|
func newToggleTestModel(rtr *router.Router, fw *security.Firewall) Model {
|
|
return Model{
|
|
config: Config{
|
|
Firewall: fw,
|
|
Router: rtr,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestAttemptIncognitoToggle_NilFirewallReturnsRefused(t *testing.T) {
|
|
m := newToggleTestModel(nil, nil)
|
|
_, status, refused := m.attemptIncognitoToggle()
|
|
if !refused {
|
|
t.Error("expected refused=true when firewall is nil")
|
|
}
|
|
if !strings.Contains(status, "firewall") {
|
|
t.Errorf("status = %q, want mention of firewall", status)
|
|
}
|
|
}
|
|
|
|
func TestAttemptIncognitoToggle_NoForcedArmFlipsOn(t *testing.T) {
|
|
rtr := router.New(router.Config{})
|
|
fw := security.NewFirewall(security.FirewallConfig{ScanOutgoing: true})
|
|
m := newToggleTestModel(rtr, fw)
|
|
|
|
newM, status, refused := m.attemptIncognitoToggle()
|
|
if refused {
|
|
t.Fatalf("expected refused=false, got refused; status=%q", status)
|
|
}
|
|
if !newM.incognito {
|
|
t.Error("expected newM.incognito = true after toggle")
|
|
}
|
|
if !fw.Incognito().Active() {
|
|
t.Error("firewall incognito should be active after toggle")
|
|
}
|
|
if !rtr.LocalOnly() {
|
|
t.Error("router localOnly should be true after toggle")
|
|
}
|
|
if !strings.Contains(status, "incognito ON") {
|
|
t.Errorf("status = %q, want incognito ON marker", status)
|
|
}
|
|
}
|
|
|
|
func TestAttemptIncognitoToggle_ForcedLocalArmAllowed(t *testing.T) {
|
|
rtr := router.New(router.Config{})
|
|
rtr.RegisterArm(&router.Arm{
|
|
ID: router.NewArmID("ollama", "qwen"),
|
|
IsLocal: true,
|
|
Capabilities: provider.Capabilities{ToolUse: true},
|
|
})
|
|
rtr.ForceArm(router.NewArmID("ollama", "qwen"))
|
|
|
|
fw := security.NewFirewall(security.FirewallConfig{ScanOutgoing: true})
|
|
m := newToggleTestModel(rtr, fw)
|
|
|
|
_, _, refused := m.attemptIncognitoToggle()
|
|
if refused {
|
|
t.Error("forced LOCAL arm + incognito should NOT be refused")
|
|
}
|
|
}
|
|
|
|
func TestAttemptIncognitoToggle_ForcedCloudArmRefused(t *testing.T) {
|
|
rtr := router.New(router.Config{})
|
|
rtr.RegisterArm(&router.Arm{
|
|
ID: router.NewArmID("anthropic", "sonnet"),
|
|
IsLocal: false,
|
|
Capabilities: provider.Capabilities{ToolUse: true},
|
|
})
|
|
rtr.ForceArm(router.NewArmID("anthropic", "sonnet"))
|
|
|
|
fw := security.NewFirewall(security.FirewallConfig{ScanOutgoing: true})
|
|
m := newToggleTestModel(rtr, fw)
|
|
|
|
_, status, refused := m.attemptIncognitoToggle()
|
|
if !refused {
|
|
t.Fatalf("forced CLOUD arm + incognito should be refused; status=%q", status)
|
|
}
|
|
if fw.Incognito().Active() {
|
|
t.Error("firewall must NOT activate when toggle is refused")
|
|
}
|
|
if rtr.LocalOnly() {
|
|
t.Error("router localOnly must NOT flip when toggle is refused")
|
|
}
|
|
if !strings.Contains(status, "non-local") && !strings.Contains(status, "pin") {
|
|
t.Errorf("status should explain the refusal; got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestNew_SeedsIncognitoFromActiveFirewall(t *testing.T) {
|
|
fw := security.NewFirewall(security.FirewallConfig{ScanOutgoing: true})
|
|
fw.Incognito().Activate()
|
|
|
|
m := New(nil, Config{Firewall: fw})
|
|
if !m.incognito {
|
|
t.Error("New() should seed m.incognito=true when firewall already active")
|
|
}
|
|
}
|
|
|
|
func TestNew_SeedsIncognitoFalseWhenFirewallInactive(t *testing.T) {
|
|
fw := security.NewFirewall(security.FirewallConfig{ScanOutgoing: true})
|
|
|
|
m := New(nil, Config{Firewall: fw})
|
|
if m.incognito {
|
|
t.Error("New() should seed m.incognito=false when firewall inactive")
|
|
}
|
|
}
|
|
|
|
func TestNew_SeedsIncognitoFalseWhenNoFirewall(t *testing.T) {
|
|
m := New(nil, Config{})
|
|
if m.incognito {
|
|
t.Error("New() should seed m.incognito=false when no firewall")
|
|
}
|
|
}
|
|
|
|
func TestAttemptIncognitoToggle_TurningOffNotBlockedByForcedCloud(t *testing.T) {
|
|
// Once incognito is ON, the user must always be able to turn it OFF
|
|
// regardless of the forced-arm state. Otherwise they're trapped.
|
|
rtr := router.New(router.Config{})
|
|
rtr.RegisterArm(&router.Arm{
|
|
ID: router.NewArmID("anthropic", "sonnet"),
|
|
IsLocal: false,
|
|
Capabilities: provider.Capabilities{ToolUse: true},
|
|
})
|
|
// Note: not forcing the arm yet — start incognito on a clean state,
|
|
// then pretend a forced cloud arm appears (which shouldn't happen in
|
|
// practice, but the toggle-off path must be robust).
|
|
fw := security.NewFirewall(security.FirewallConfig{ScanOutgoing: true})
|
|
fw.Incognito().Activate()
|
|
rtr.SetLocalOnly(true)
|
|
rtr.ForceArm(router.NewArmID("anthropic", "sonnet"))
|
|
|
|
m := newToggleTestModel(rtr, fw)
|
|
m.incognito = true
|
|
|
|
newM, _, refused := m.attemptIncognitoToggle()
|
|
if refused {
|
|
t.Fatal("turning incognito OFF must never be refused")
|
|
}
|
|
if newM.incognito {
|
|
t.Error("incognito should be false after toggle-off")
|
|
}
|
|
if fw.Incognito().Active() {
|
|
t.Error("firewall incognito should be off")
|
|
}
|
|
if rtr.LocalOnly() {
|
|
t.Error("router localOnly should be off")
|
|
}
|
|
}
|