Skip to content

feat(security): rework the Ignored items (allowlist) review surface#322

Merged
graydawnc merged 8 commits into
mainfrom
feat/security-allowlist-clarity
May 26, 2026
Merged

feat(security): rework the Ignored items (allowlist) review surface#322
graydawnc merged 8 commits into
mainfrom
feat/security-allowlist-clarity

Conversation

@graydawnc
Copy link
Copy Markdown
Collaborator

What

Redesigns the "Ignored items" surface (formerly "Manage allowlist") — how users review and undo the findings they told Spool to stop flagging.

  • Live values instead of an opaque hash. The allowlist stores only a non-crypto hash of the value (never plaintext). The modal previously showed kind + a hash slice, so users couldn't tell what they'd ignored. Each row now leads with the actual value, reconstructed read-time from the source message (substr at the finding's offsets) — exactly the plaintext the findings view already displays — and shown in the clear (these are values the user deliberately ignored). Nothing new is persisted.
  • Unified vocabulary on "Ignore." Action / toast / list / search qualifier (is:ignored, with is:dismissed kept as an alias) all say "Ignore" now, replacing the old Dismiss/allowlist/ignore mix. Internal function/IPC names and the persisted 'dismissed' state are intentionally unchanged (no migration).
  • Flat, filterable list. Recency-sorted, scope shown per row; a toolbar adds a scope filter (All / Everywhere / Per session), a type filter (kinds actually present), and a text filter. Replaces the scope-grouped layout that buried the smaller group as the larger grew.
  • Shallower entry. Surfaced on the Security page meta row (the sidebar surface) via a new lightweight countAllowlistEntries, instead of only Settings → Security → Manage.
  • Bundled e2e isolation fix: SPOOL_OPENCODE_DIR is now isolated in the e2e launch helper — without it, a developer's real OpenCode sessions leaked into tests and caused machine-dependent first-launch timeouts. Independent infra fix; bundled because the Ignored-items e2e relies on it.

Why

The old modal was unreadable ("Email · 57cfb8f5…") and buried four levels deep in Settings. Reviewing past ignore decisions is the point of the surface, so the value has to be recognizable and the entry discoverable.

How it connects

Part of the pre-GA Security Scan feature (flag-gated, off in prod). No schema change — an earlier iteration in this branch's history added a v14 migration + a stored masked-preview, both fully removed once live reconstruction landed; LATEST_SCHEMA_VERSION stays 13 and the allowlist tables gain no columns. (Squash-merge collapses the intermediate churn.)

Test plan

  • Core vitest: live-value reconstruction (session + global, null on purged/orphaned source), countAllowlistEntries, schema asserts the allowlist tables have no preview/reason columns and version 13.
  • App vitest: filterIgnoredEntries (scope/kind/text/combined) unit suite.
  • e2e: Ignored-items entry opens the modal; scope/type/text filters narrow the list; "Stop ignoring" removes a row; settings-security suite.
  • i18n: key parity across all 7 locales for the new/changed keys; dead keys removed.
  • Typecheck clean (one pre-existing unrelated MessageList.tsx error remains on the base).

graydawnc and others added 8 commits May 26, 2026 19:04
…nizable rows

Users couldn't tell WHAT they had ignored: the modal was titled "Manage
allowlist" and each row showed a kind plus an opaque value-hash slice.
The allowlist deliberately stores only a non-crypto hash of the value
(never plaintext) for rescan matching — correct and privacy-preserving,
but unreadable to a human reviewing past decisions.

Reframe the surface for recognition (industry pattern: separate "what
you store to match" from "what you show to recognize"):

- Title → "Ignored items"; plain-language subtitle; action button
  "Remove" → "Stop ignoring" with a non-destructive EyeOff icon
  (it un-ignores, doesn't delete data). Click-twice in-place confirm
  preserved. Buckets regrouped + sentence-cased: "Everywhere" /
  "This session". All 7 locales updated, plus the Settings → Security
  section copy ("Ignored items" / "Review") so the entry point matches.
- Rows now show kind label, location ("in {title}" / "in every
  session"), relative ignored-time (formatScanAgo), an optional masked
  preview, and an optional reason — no hash slice.

Masked preview (new @spool-lab/redact `previewValueByKind`): a lossy,
non-reversible recognition hint computed at dismiss time from the live
message text — `Stripe ••a39f`, `m••@gmail.com`, `•• 1111`. It retains
only fragments that are not themselves secret (a vendor name inferred
from a public token prefix, an email domain, at most the last 4 chars);
the random secret body is dropped, so a preview can never be expanded
back to the value. Short values collapse to kind-only. Stored via a
forward-only migration (v14: nullable `preview` + `reason` columns on
both allowlist tables); `dismissFinding` reconstructs the value from
message offsets and reduces it to the preview without persisting
plaintext. Pre-v14 rows and unreconstructable values (purged) render
gracefully on the kind-only fallback.

Reason: storage + display wired end-to-end (enum: not-secret /
test-credential / low-risk / acknowledged) and threaded through IPC.
The dismiss-time reason PICKER is deferred — the existing dismiss
control is a flat scope menu (session/everywhere); adding a reason
dimension would either nest 8 combinations or add a second step,
against the project's "single-item dropdowns are over-engineering" /
low-friction guidance. Storage + render land now; the picker is a
clean follow-up.

Tests: unit-test previewValueByKind (kind-aware, non-reversible, short
values); core repo tests for the v14 columns, dismiss populating the
preview/reason, listAllowlistEntries returning them, the upsert
preserving an existing preview, and null fallbacks; migration-v12 spec
extended for the additive v14 upgrade. Adjacent suites green: core
(353), redact (128), locale parity (17). Existing settings-security
e2e relies only on preserved testids + empty-state, so it stays valid;
populated-row recognition is covered by the core unit tests (avoids a
heavy electron-ABI rebuild + scan-dependent fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… into tests

launch.ts isolated SPOOL_CLAUDE_DIR/CODEX/GEMINI but not the OpenCode
source added in #265. With SPOOL_OPENCODE_DIR unset, source-paths.ts
falls back to the real ~/.local/share/opencode, so the developer's
actual OpenCode sessions were indexed into every e2e run — shifting
ordering/counts and stalling first-launch sync. That is the root cause
of the recurring 30s first-launch timeout in security-findings-actions
and others that reproduced locally but stayed green in clean CI.

Point SPOOL_OPENCODE_DIR at an isolated empty dir (zero opencode
sessions, deterministic) and expose opencodeDir to extraFixtures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…" (UI + test-ids + search qualifier + tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hemaSanity

The v14 migration adds preview/reason to the allowlist tables inside a
version-gated block. A DB whose user_version already reached 14 via
another path (shared dev DB stamped by a parallel branch, or a
half-finished upgrade) skips that block forever and sits at 14 with the
columns absent — so the dismiss/ignore INSERT throws
"table allowlist_global has no column named preview" and the action
silently no-ops (no toast, no removal).

ensureSchemaSanity already exists to repair exactly this (version pragma
bumped without the column); extend it to backfill the allowlist
preview/reason columns (tableExists-guarded for pre-v12 DBs), matching
the sessions backfill. Regression test reproduces the v14-without-columns
collision and asserts the heal + a working INSERT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rop the stored-preview/v14 machinery

Abandon the stored masked-preview approach for the "Ignored items" modal.
The plaintext already lives in messages.content_text (the findings view
shows it, blurred), so reconstruct the live value at read time from a
matching finding's message — no new persistence.

Removed:
- v14 schema migration (preview/reason columns); LATEST_SCHEMA_VERSION
  back to 13; ensureSchemaSanity allowlist backfill loop dropped.
- @spool-lab/redact previewValueByKind (preview.ts/preview.test.ts deleted).
- repo AllowlistMeta, DismissReason, previewForFinding, and preview/reason
  on addAllowlist*/dismissFinding(s); add functions back to INSERT OR IGNORE.
- reason threading through DISMISS_FINDING(S) IPC/preload/renderer api.
- allowlist_reason_* / reason_* i18n keys in all 7 locales.

Added:
- AllowlistEntryRow.value reconstructed in listAllowlistEntries via
  substr(messages.content_text, …) over a non-purged finding (session +
  global scopes, prepared statements); null when purged/orphaned/gone.
- Modal renders the value with FindingsStrip's blur + hover/click reveal,
  driven by the canonical securityPageValuesBlurred pref; null falls back
  to kind label + an "original no longer available" hint (i18n, 7 locales).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reconcile the allowlist/ignored i18n keys with the overhauled modal:
add allowlist_all_scopes, allowlist_all_types, allowlist_scope_everywhere,
and security.ignored_label across all 7 locales; remove the now-dead
allowlist_modal_sub, allowlist_bucket_global, allowlist_in_every_session,
and security.ignored_count. Locale key-parity test stays green.

Extract the modal's scope/type/text filter into a pure filterIgnoredEntries
helper with unit tests, and add an e2e spec covering the scope dropdown,
type dropdown, text filter, and Stop ignoring against a populated list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ignored items are values the user explicitly chose to stop flagging, so
the management modal shows them in the clear — no blur/hover-reveal.
(The value is still reconstructed read-time from the source message and
never persisted.) Also clear a pending click-twice 'Stop ignoring'
confirm when the filters change, so a filtered-out row can't reappear
mid-confirm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@graydawnc graydawnc added this pull request to the merge queue May 26, 2026
Merged via the queue into main with commit bffb68c May 26, 2026
3 checks passed
@graydawnc graydawnc deleted the feat/security-allowlist-clarity branch May 26, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant