feat(security): incognito coherence (Wave 2) #2

Merged
vikingowl merged 2 commits from feat/security-wave2-incognito into main 2026-05-19 23:00:23 +02:00
Owner

Summary

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. After this PR, security.IncognitoMode is the canonical source of truth and the three downstream flags follow.

Plan: docs/superpowers/plans/2026-05-19-security-wave2-incognito.md

Builds on Wave 1 (#1, merged) which closed the firewall-bypass call sites at the provider boundary.

What's fixed

  • W2-1 `Router.Select` rejects forced non-local arms when localOnly is on instead of silently routing to cloud under an "incognito" badge. `main` fails fast on `--incognito --provider `. The TUI toggle refuses with an actionable message when a non-local arm is pinned. Three duplicated toggle sites factored into `Model.attemptIncognitoToggle`.
  • W2-2 `persist.Store.Save` consults an `IncognitoGate` (local interface; `*security.IncognitoMode` satisfies it). Gate is consulted dynamically per call so TUI runtime toggles take effect. File mode 0o600, dir 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.
  • W2-4 `saveQuality` gates on both the CLI flag (defensive, covers the pre-`fwRef.Set` window) and `fw.Incognito().ShouldLearn()` (catches TUI runtime toggles). Quality restore skipped under `--incognito`. `engine.reportOutcome` and `elf.Manager.ReportResult` both gated — bandit signal no longer leaks.
  • W2-5 session files 0o600 in dirs 0o700 (was 0o644 / 0o755).
  • W2-6 dead `IncognitoMode.LocalOnly` field removed.

Also wires `rtr.SetLocalOnly(true)` on `--incognito` launch — `main` previously activated the firewall 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".

Notable design decisions

  • Firewall owns intent (`Active()`), router owns enforcement (`localOnly`). They must agree but live on different types because the router has the access pattern (mutex around arm selection) and the firewall has no business carrying routing state.
  • Persist gate is dynamic (per-Save check), not frozen at construction.
  • Quality restore uses the CLI flag instead of the firewall — at restore time (line ~357) the firewall doesn't exist yet, and the CLI flag is the only signal available. TUI-runtime incognito starts with prior bandit signal loaded; matches the plan's "session-forward only" semantics.
  • Advisor follow-up: `saveQuality` keeps the CLI-flag check as a defensive first gate before the firewall check. No early returns exist between `defer saveQuality()` and `fwRef.Set` today, but the defensive check costs nothing and removes the regression class.

Test plan

  • `go test ./...` green
  • `go test -race ./...` green
  • `golangci-lint run ./...` — 0 issues
  • `go build ./...` clean
  • New tests:
    • router: forced cloud + localOnly → error; forced local + localOnly → success
    • persist: nil gate behaves like legacy; ShouldPersist=false blocks Save; file 0o600 / dir 0o700
    • tui: `attemptIncognitoToggle` refuses on forced cloud arm but allows toggle-off; `New` seeds from active firewall
    • engine: outcome suppressed when firewall.Incognito active
    • elf: `ReportResult` suppressed when firewall.Incognito active
    • session: files 0o600, dirs 0o700

Out of scope

  • Wave 3 — scanner + path hygiene (PEM block redaction, strict entropy, canonical AllowedPaths, MCP path policy, `fs.grep` symlink follow)
  • PostToolUse hook ordering — needs a design decision on raw vs. redacted contract
## Summary 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. After this PR, security.IncognitoMode is the canonical source of truth and the three downstream flags follow. Plan: [`docs/superpowers/plans/2026-05-19-security-wave2-incognito.md`](docs/superpowers/plans/2026-05-19-security-wave2-incognito.md) Builds on Wave 1 (#1, merged) which closed the firewall-bypass call sites at the provider boundary. ## What's fixed - **W2-1** \`Router.Select\` rejects forced non-local arms when localOnly is on instead of silently routing to cloud under an "incognito" badge. \`main\` fails fast on \`--incognito --provider <cloud>\`. The TUI toggle refuses with an actionable message when a non-local arm is pinned. Three duplicated toggle sites factored into \`Model.attemptIncognitoToggle\`. - **W2-2** \`persist.Store.Save\` consults an \`IncognitoGate\` (local interface; \`*security.IncognitoMode\` satisfies it). Gate is consulted dynamically per call so TUI runtime toggles take effect. File mode 0o600, dir 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. - **W2-4** \`saveQuality\` gates on both the CLI flag (defensive, covers the pre-\`fwRef.Set\` window) and \`fw.Incognito().ShouldLearn()\` (catches TUI runtime toggles). Quality restore skipped under \`--incognito\`. \`engine.reportOutcome\` and \`elf.Manager.ReportResult\` both gated — bandit signal no longer leaks. - **W2-5** session files 0o600 in dirs 0o700 (was 0o644 / 0o755). - **W2-6** dead \`IncognitoMode.LocalOnly\` field removed. Also wires \`rtr.SetLocalOnly(true)\` on \`--incognito\` launch — \`main\` previously activated the firewall 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". ## Notable design decisions - Firewall owns _intent_ (\`Active()\`), router owns _enforcement_ (\`localOnly\`). They must agree but live on different types because the router has the access pattern (mutex around arm selection) and the firewall has no business carrying routing state. - Persist gate is _dynamic_ (per-Save check), not frozen at construction. - Quality _restore_ uses the CLI flag instead of the firewall — at restore time (line ~357) the firewall doesn't exist yet, and the CLI flag is the only signal available. TUI-runtime incognito starts with prior bandit signal loaded; matches the plan's "session-forward only" semantics. - Advisor follow-up: \`saveQuality\` keeps the CLI-flag check as a defensive first gate before the firewall check. No early returns exist between \`defer saveQuality()\` and \`fwRef.Set\` today, but the defensive check costs nothing and removes the regression class. ## Test plan - [x] \`go test ./...\` green - [x] \`go test -race ./...\` green - [x] \`golangci-lint run ./...\` — 0 issues - [x] \`go build ./...\` clean - [x] New tests: - router: forced cloud + localOnly → error; forced local + localOnly → success - persist: nil gate behaves like legacy; ShouldPersist=false blocks Save; file 0o600 / dir 0o700 - tui: \`attemptIncognitoToggle\` refuses on forced cloud arm but allows toggle-off; \`New\` seeds from active firewall - engine: outcome suppressed when firewall.Incognito active - elf: \`ReportResult\` suppressed when firewall.Incognito active - session: files 0o600, dirs 0o700 ## Out of scope - Wave 3 — scanner + path hygiene (PEM block redaction, strict entropy, canonical AllowedPaths, MCP path policy, \`fs.grep\` symlink follow) - PostToolUse hook ordering — needs a design decision on raw vs. redacted contract
vikingowl added 2 commits 2026-05-19 22:58:08 +02:00
Plan for the second hardening wave. Six findings closed in one PR:
W2-1 router rejects forced non-local under local-only; W2-2 persist
store consults IncognitoMode + 0o600/0o700 perms; W2-3 TUI seeds
incognito from firewall; W2-4 quality/outcome gates read firewall
instead of CLI flag; W2-5 session perms 0o600; W2-6 remove dead
IncognitoMode.LocalOnly field.
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'.
vikingowl merged commit 1ac6a01b22 into main 2026-05-19 23:00:23 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Owlibou/gnoma#2