diff --git a/docs/usage/viewer.mdx b/docs/usage/viewer.mdx index 01f42c57..a04120d6 100644 --- a/docs/usage/viewer.mdx +++ b/docs/usage/viewer.mdx @@ -11,7 +11,7 @@ strix view my-run-name # a specific run under ./strix_run strix view --host 0.0.0.0 --port 8080 --no-open ``` -The UI ships prebuilt with Strix, so there is no extra install and no JavaScript build step. The dashboard reads the run files straight off disk. Nothing leaves your machine, and you do not need a cloud account. +The UI ships prebuilt with Strix, so there is no extra install and no JavaScript build step. The dashboard reads the run files straight off disk. You do not need a cloud account to review or triage the launched run. ## Options @@ -33,17 +33,29 @@ The UI ships prebuilt with Strix, so there is no extra install and no JavaScript ## What Is In The Dashboard -- **Overview** — run status, target, and a severity breakdown of everything found so far. -- **Vulnerabilities** — each validated finding with its severity, details, and reproduction steps. +- **Overview** — run status, target, a severity breakdown of open findings, and counts of false positives and total findings detected. +- **Vulnerabilities** — each validated finding with its severity, details, and reproduction steps. Mark incorrect findings as false positives, reopen them, and filter by **Open / Closed / All**. - **Agent graph** — a live map of the multi-agent team, and what each agent is doing. - **Steering** — send instructions to a live scan to redirect the agents during the run. Steering works only in the dashboard the running scan opens. A standalone `strix view` has no live scan to steer. - **History** — browse past runs on this machine and move between them. Verify your email address in the dashboard to unlock the other runs. -- **Reports** — generate a shareable report and send it by email. Verify your email address first. +- **Reports** — generate an **Original scan report** and send it by email. Verify your email address first. This PDF includes all detected findings and excludes subsequent local triage decisions and notes. + +## Marking False Positives + +At the bottom of a finding, choose **Mark as false positive** when it is incorrect or does not apply to your target. Choose an optional reason, add an optional note of up to 2,000 characters for future review, and select **Close issue**. The finding shows **Closed · False positive**. Use **Undo** or **Reopen** to reverse the decision. + +The **Open / Closed / All** filters keep closed findings available with their original severity and evidence. Counts distinguish open findings from false positives and the total detected. Closing an issue does not mean it was fixed. + +In the terminal UI, press **F2** to open findings, including on narrow terminals where the sidebar is hidden. In a finding, click **False positive (f)** or press **f** to open the native false-positive form; **r** reopens a closed issue, and **u** undoes a recent change. Use **Tab / Shift+Tab** to move through the optional reason, note and buttons, **Enter** to activate a button, and **Esc** to return. Press **v** in the finding detail or focused findings panel to cycle **Open / Closed / All**. If the selected filter is empty, **F2** opens **All** so closed issues remain accessible. No browser is required. + +Decisions apply to that finding in that run. They are saved locally in `triage.json`, survive restarts and scan resume, and are shared by the viewer and terminal UI. Closing and reopening work offline. They do not require an email address for the launched run. A separate scan starts with its own review state; material changes to a closed finding show **Needs review** and return it to the Open filter while preserving the previous decision. + +The original scan report, emailed PDF, raw finding files, and SARIF retain the scanner's findings. The PDF cover and filename identify it as the **Original scan report**; its counts describe detected findings, including those you later closed. Local decisions do not change the original scan's exit code or coverage assessment. ## Sharing The Link - The token in the printed URL grants access to the run data, and to the steering of a live scan. Share it only with trusted users. + The token in the printed URL grants access to the run data, changes to local finding triage, and steering of a live scan. Share it only with trusted users. To reach the viewer from another machine, start it with `--host 0.0.0.0` and replace `0.0.0.0` in the printed URL with a reachable IP address or hostname. Restrict the port with your firewall. A request without the token-derived session cannot read run data. diff --git a/scripts/tui_sidecar_hook.py b/scripts/tui_sidecar_hook.py index 01de6fdc..0ee50a3b 100644 --- a/scripts/tui_sidecar_hook.py +++ b/scripts/tui_sidecar_hook.py @@ -20,8 +20,8 @@ class CustomBuildHook(BuildHookInterface): # type: ignore[type-arg] """ def initialize(self, version: str, build_data: dict[str, Any]) -> None: - # Editable installs run from the checkout, where the TUI is started - # with ``go run``; there is nothing to bundle. + # Editable installs compile the checkout's TUI at startup; + # there is nothing to bundle. if version == "editable": return diff --git a/strix/interface/tui/backend/controller.py b/strix/interface/tui/backend/controller.py index b2f1eb75..66bd0ad4 100644 --- a/strix/interface/tui/backend/controller.py +++ b/strix/interface/tui/backend/controller.py @@ -34,6 +34,20 @@ if TYPE_CHECKING: _STOPPABLE_AGENT_STATUSES = frozenset({"running", "waiting", "budget_paused"}) +_TRIAGE_FIELDS = ( + "status", + "triage_status", + "resolution_reason", + "reason_code", + "status_note", + "status_changed_at", + "status_changed_by", + "triage_revision", + "finding_digest", + "review_stale", + "can_triage", + "triage_error", +) ChangeCallback = Callable[[], None] StartCallback = Callable[[], Awaitable[None]] @@ -264,9 +278,29 @@ class TuiController: reports = ( self.report_state.vulnerability_reports if self.report_state is not None else [] )[-MAX_TERMINAL_VULNERABILITIES:] + if self.report_state is not None and hasattr(self.report_state, "get_run_dir"): + from strix.report.triage import TriageError, read_triaged_vulnerabilities + + try: + reports = read_triaged_vulnerabilities(self.report_state.get_run_dir(), reports) + except TriageError as exc: + # A broken sidecar must not hide evidence or stop the scan UI. + reports = [ + {**report, "can_triage": False, "triage_error": str(exc)} + for report in reports + ] result: list[dict[str, Any]] = [] for index, report in enumerate(reports): - projected = collection_item_projection(report) + projected = collection_item_projection( + {key: value for key, value in report.items() if key != "triage_history"} + ) + # The optional evidence projection may truncate a large finding; + # its small revision and write-capability fields must survive. + projected.update( + terminal_projection( + {key: report[key] for key in _TRIAGE_FIELDS if key in report} + ) + ) report_id = projected.get("id") if not isinstance(report_id, str) or not report_id: projected["id"] = f"vulnerability-{index}" @@ -303,6 +337,7 @@ class TuiController: "agent.send_message": self._send_message, "agent.stop": self._stop_agent, "viewer.open": self._open_viewer, + "vulnerability.triage": self._triage_vulnerability, "app.quit": self._quit, } handler = handlers.get(command) @@ -312,6 +347,47 @@ class TuiController: self.notify_changed() return result + def triage_stamp(self) -> tuple[int, int] | None: + """Observe external decisions even while the scan has nothing to broadcast.""" + if self.report_state is None or not hasattr(self.report_state, "get_run_dir"): + return None + from strix.report.triage import triage_stamp + + return triage_stamp(self.report_state.get_run_dir()) + + async def _triage_vulnerability(self, payload: dict[str, Any]) -> dict[str, Any]: + from strix.report.triage import TriageError, triage_finding + + if self.report_state is None: + raise TriageError("unavailable", "Scan output is not ready") + finding_id = self._required_string(payload, "finding_id") + status = self._required_string(payload, "status") + if payload.get("resolution_reason") not in (None, "false_positive"): + raise TriageError("invalid_request", "Unsupported resolution reason") + revision = payload.get("expected_revision") + digest = payload.get("reviewed_digest") + if ( + not isinstance(revision, int) + or isinstance(revision, bool) + or not isinstance(digest, str) + ): + raise TriageError("invalid_request", "Review revision and finding digest are required") + result = await asyncio.to_thread( + triage_finding, + self.report_state.get_run_dir(), + finding_id, + status=status, + expected_revision=revision, + reviewed_digest=digest, + reason_code=payload.get("reason_code", "unspecified"), + note=payload.get("note", ""), + surface="tui", + ) + # The evidence travels in collection frames; keep the acknowledgement + # below the command-result size limit even for very large findings. + finding = {key: result["finding"].get(key) for key in ("id", *_TRIAGE_FIELDS)} + return {"changed": result["changed"], "finding": terminal_projection(finding)} + async def _add_target(self, payload: dict[str, Any]) -> dict[str, Any]: self._require_setup_mutable() target = self._required_string(payload, "target") diff --git a/strix/interface/tui/backend/server.py b/strix/interface/tui/backend/server.py index f884b3b6..eaedca4c 100644 --- a/strix/interface/tui/backend/server.py +++ b/strix/interface/tui/backend/server.py @@ -20,6 +20,7 @@ from strix.interface.tui.backend.protocol import ( ProtocolHandshakeError, envelope, ) +from strix.report.triage import TriageError if TYPE_CHECKING: @@ -60,6 +61,7 @@ class TuiBackendServer: self._reader_task: asyncio.Task[None] | None = None self._broadcast_event = asyncio.Event() self._broadcast_task: asyncio.Task[None] | None = None + self._triage_watch_task: asyncio.Task[None] | None = None self._write_lock = asyncio.Lock() self._sync_lock = asyncio.Lock() self._state_revision = 0 @@ -89,10 +91,15 @@ class TuiBackendServer: self.activated = True self._reader_task = asyncio.create_task(self._read_loop()) self._broadcast_task = asyncio.create_task(self._broadcast_loop()) + self._triage_watch_task = asyncio.create_task(self._watch_triage()) self.notify_changed() async def close(self) -> None: - tasks = [task for task in (self._reader_task, self._broadcast_task) if task is not None] + tasks = [ + task + for task in (self._reader_task, self._broadcast_task, self._triage_watch_task) + if task is not None + ] for task in tasks: task.cancel() for task in tasks: @@ -102,6 +109,7 @@ class TuiBackendServer: await task self._reader_task = None self._broadcast_task = None + self._triage_watch_task = None self._close_socket() def _close_socket(self) -> None: @@ -188,6 +196,8 @@ class TuiBackendServer: @staticmethod def _structured_error(exc: Exception) -> dict[str, object]: + if isinstance(exc, TriageError): + return {"code": exc.code, "message": str(exc), "retryable": False} if isinstance(exc, OSError): return {"code": "persistence_error", "message": str(exc), "retryable": True} if isinstance(exc, TypeError | ValueError | json.JSONDecodeError | UnicodeDecodeError): @@ -529,3 +539,15 @@ class TuiBackendServer: self._close_socket() except (ConnectionError, OSError): self._close_socket() + + async def _watch_triage(self) -> None: + previous: tuple[int, int] | None = None + while True: + await asyncio.sleep(0.5) + try: + current = self.controller.triage_stamp() + except (OSError, TriageError): + current = None + if current != previous: + previous = current + self.notify_changed() diff --git a/strix/interface/tui/internal/app/model.go b/strix/interface/tui/internal/app/model.go index e7cc8975..315fecca 100644 --- a/strix/interface/tui/internal/app/model.go +++ b/strix/interface/tui/internal/app/model.go @@ -64,6 +64,7 @@ const ( modalStop modalConfirmMount modalVulnerability + modalTriage ) type focusMode int @@ -134,6 +135,12 @@ type Model struct { seenMessages map[string]bool vulnerabilityCopied bool vulnerabilityCopyError string + findingFilter int + triage triageForm + triagePending *triageRequest + triageUndo *triageUndoState + triageError string + triageErrorID string } var ( @@ -335,6 +342,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resizeVulnerabilityViewport() m.ensureAgentVisible() m.ensureVulnerabilityVisible() + m.resizeTriageForm() case wireErrMsg: if !m.quitting { m.errorText = "Backend disconnected: " + msg.err.Error() @@ -354,6 +362,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, readWire(m.client)) case sentMsg: if msg.err != nil { + if msg.command == "vulnerability.triage" { + if m.triagePending != nil { + m.triageErrorID = m.triagePending.findingID + } + m.triagePending = nil + m.triageError = msg.err.Error() + } m.errorText = msg.err.Error() if msg.command == "collection.resync" && msg.collection != "" { m.resyncRequested[msg.collection] = false @@ -407,6 +422,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd if m.modal == modalNone { m.input, cmd = m.input.Update(msg) + } else if m.modal == modalTriage { + m.triage.note, cmd = m.triage.note.Update(msg) } cmds = append(cmds, cmd) return m, tea.Batch(cmds...) diff --git a/strix/interface/tui/internal/app/triage.go b/strix/interface/tui/internal/app/triage.go new file mode 100644 index 00000000..825ae589 --- /dev/null +++ b/strix/interface/tui/internal/app/triage.go @@ -0,0 +1,453 @@ +package app + +import ( + "encoding/json" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/usestrix/strix/tui/internal/protocol" + "github.com/usestrix/strix/tui/internal/render" +) + +var triageReasons = []struct{ code, label string }{ + {"unspecified", "Select a reason (optional)"}, + {"incorrect_assumption", "Incorrect assumption"}, + {"existing_protection", "Existing protection prevents the exploit"}, + {"not_affected", "Code or dependency is not affected"}, + {"expected_behavior", "Expected behavior, not a vulnerability"}, + {"other", "Other"}, +} + +var triageFields = []string{"status", "triage_status", "resolution_reason", "reason_code", + "status_note", "status_changed_at", "status_changed_by", "triage_revision", "finding_digest", "review_stale", "can_triage"} + +type triageForm struct { + findingID string + revision int64 + digest string + reason int + focus int // reason, note, cancel, submit + note textarea.Model +} + +type triageRequest struct { + findingID string + previous map[string]any + undo bool +} + +type triageUndoState struct { + findingID string + previous map[string]any + revision int64 + digest string + expires time.Time +} + +func boolField(finding map[string]any, key string) bool { + value, _ := finding[key].(bool) + return value +} + +func findingStatus(finding map[string]any) string { + if render.StringValue(finding["status"]) == "closed" { + return "closed" + } + return "open" +} + +func findingStatusLabel(finding map[string]any) string { + if boolField(finding, "review_stale") { + return "Needs review · evidence changed since review" + } + if findingStatus(finding) == "closed" { + return "Closed · False positive" + } + return "Open" +} + +func triageReasonLabel(code string) string { + if code == "" || code == "unspecified" { + return "" + } + for _, reason := range triageReasons { + if reason.code == code { + return reason.label + } + } + return "" +} + +func (m Model) selectedFinding() map[string]any { + if m.selectedVuln < 0 || m.selectedVuln >= len(m.snapshot.Vulnerabilities) { + return nil + } + return m.snapshot.Vulnerabilities[m.selectedVuln] +} + +func (m Model) selectedFindingID() string { return collectionItemID(m.selectedFinding()) } + +func (m Model) findingFilterLabel() string { return []string{"Open", "Closed", "All"}[m.findingFilter] } + +func (m Model) findingVisible(index int) bool { + if index < 0 || index >= len(m.snapshot.Vulnerabilities) { + return false + } + return m.findingFilter == 2 || (findingStatus(m.snapshot.Vulnerabilities[index]) == "closed") == (m.findingFilter == 1) +} + +func (m Model) visibleFindingIndices() []int { + indices := make([]int, 0, len(m.snapshot.Vulnerabilities)) + for i := range m.snapshot.Vulnerabilities { + if m.findingVisible(i) { + indices = append(indices, i) + } + } + return indices +} + +func (m *Model) selectVisibleFinding() { + if m.findingVisible(m.selectedVuln) { + return + } + indices := m.visibleFindingIndices() + if len(indices) > 0 { + m.selectedVuln = indices[0] + } +} + +func (m *Model) restoreFindingSelection(id string) { + for i, finding := range m.snapshot.Vulnerabilities { + if collectionItemID(finding) == id { + m.selectedVuln = i + return + } + } + // Never show a different issue under an already-open review form. + if m.modal == modalTriage { + m.triageError = "This finding is no longer available." + } + m.selectedVuln = min(m.selectedVuln, max(0, len(m.snapshot.Vulnerabilities)-1)) + if m.modal != modalTriage { + m.selectVisibleFinding() + } +} + +func (m *Model) stepVulnerability(direction int) { + indices := m.visibleFindingIndices() + if direction < 0 { + for i := len(indices) - 1; i >= 0; i-- { + if indices[i] < m.selectedVuln { + m.showVulnerability(indices[i]) + return + } + } + } else { + for _, index := range indices { + if index > m.selectedVuln { + m.showVulnerability(index) + return + } + } + } +} + +func (m *Model) openTriageForm() tea.Cmd { + finding := m.selectedFinding() + if m.triagePending != nil || !boolField(finding, "can_triage") || findingStatus(finding) == "closed" { + return nil + } + input := textarea.New() + input.ShowLineNumbers = false + input.CharLimit = 2000 + input.Prompt = "" + input.Placeholder = "Optional context for your future review" + input.SetHeight(4) + reason := 0 + if m.triage.findingID == collectionItemID(finding) { + input.SetValue(m.triage.note.Value()) + reason = m.triage.reason + } + m.triage = triageForm{findingID: collectionItemID(finding), revision: numberValue(finding["triage_revision"]), + digest: render.StringValue(finding["finding_digest"]), note: input, reason: reason} + m.triageError = "" + m.triageErrorID = collectionItemID(finding) + m.modal = modalTriage + m.input.Blur() + m.resizeTriageForm() + return nil +} + +func (m *Model) resizeTriageForm() { + if m.modal != modalTriage { + return + } + m.triage.note.SetWidth(max(12, min(66, m.width-12))) + m.triage.note.SetHeight(max(2, min(4, m.height-18))) +} + +func (m Model) updateTriageForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.triagePending != nil { + return m, nil + } + switch key.String() { + case "esc": + m.modal = modalVulnerability + m.triage.note.Blur() + m.triageError = "" + m.resizeVulnerabilityViewport() + return m, nil + case "tab", "shift+tab": + delta := 1 + if key.String() == "shift+tab" { + delta = -1 + } + m.triage.focus = clampCycle(m.triage.focus+delta, 4) + if m.triage.focus == 1 { + return m, m.triage.note.Focus() + } + m.triage.note.Blur() + return m, nil + case "left", "up", "right", "down": + if m.triage.focus == 0 { + delta := 1 + if key.String() == "left" || key.String() == "up" { + delta = -1 + } + m.triage.reason = clampCycle(m.triage.reason+delta, len(triageReasons)) + return m, nil + } + case "enter": + switch m.triage.focus { + case 0: + m.triage.focus = 1 + return m, m.triage.note.Focus() + case 2: + return m.updateTriageForm(tea.KeyMsg{Type: tea.KeyEsc}) + case 3: + return m, m.submitTriage("closed", triageReasons[m.triage.reason].code, m.triage.note.Value()) + } + } + if m.triage.focus == 1 { + var cmd tea.Cmd + m.triage.note, cmd = m.triage.note.Update(key) + return m, cmd + } + return m, nil +} + +func (m Model) triageFormView() string { + width := max(20, min(72, m.width-6)) + inner := width - 6 + gap, padding := "\n\n", 1 + uncertain := "Not sure? Keep open for review." + if m.height < 26 { + gap, padding, uncertain = "\n", 0, "Not sure? Keep open." + } + button := func(text string, focus int) string { + style := lipgloss.NewStyle().Foreground(textColor) + if m.triage.focus == focus { + style = style.Bold(true).Background(dark).Foreground(white) + } + return style.Render(" " + text + " ") + } + content := render.Bold(white).Render("Mark as false positive") + "\n" + + wrapBlock("Applies to this finding in this run.", inner) + gap + + "Reason (optional)\n" + button("‹ "+triageReasons[m.triage.reason].label+" ›", 0) + gap + + "Note (optional)\n" + m.triage.note.View() + "\n" + + wrapBlock(uncertain, inner) + gap + + button("Cancel", 2) + " " + button("Close issue", 3) + "\n" + + render.Dim().Render("Tab: next field · Esc: back") + content = wrapBlock(content, inner) + if m.triagePending != nil { + content += "\nSaving…" + } else if m.triageError != "" { + lines := strings.Split(wrapBlock(m.triageError, inner), "\n") + room := max(1, m.height-lipgloss.Height(content)-padding*2-3) + if len(lines) > room { + lines = lines[:room] + lines[room-1] = truncate(lines[room-1], max(1, inner-1)) + "…" + } + content += "\n" + render.Col(red).Render(strings.Join(lines, "\n")) + } + return lipgloss.NewStyle().Width(width-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Background(black).Padding(padding, 2).Render(content) +} + +func (m *Model) submitTriage(status, reason, note string) tea.Cmd { + if m.triagePending != nil || m.client == nil { + return nil + } + finding := m.selectedFinding() + if !boolField(finding, "can_triage") { + return nil + } + id := collectionItemID(finding) + revision := numberValue(finding["triage_revision"]) + digest := render.StringValue(finding["finding_digest"]) + if m.modal == modalTriage { + id, revision, digest = m.triage.findingID, m.triage.revision, m.triage.digest + if id != collectionItemID(finding) { + m.triageError = "This finding changed. Review it again." + return nil + } + } + previous := make(map[string]any, len(finding)) + for key, value := range finding { + previous[key] = value + } + m.triagePending = &triageRequest{findingID: id, previous: previous} + m.triageError = "" + m.triageErrorID = id + return send(m.client, "vulnerability.triage", map[string]any{"finding_id": id, "status": status, + "resolution_reason": "false_positive", "expected_revision": revision, "reviewed_digest": digest, + "reason_code": reason, "note": note}) +} + +func (m Model) canUndoTriage() bool { + return m.triageUndo != nil && time.Now().Before(m.triageUndo.expires) && m.selectedFindingID() == m.triageUndo.findingID && + numberValue(m.selectedFinding()["triage_revision"]) == m.triageUndo.revision && + render.StringValue(m.selectedFinding()["finding_digest"]) == m.triageUndo.digest +} + +func (m *Model) undoTriage() tea.Cmd { + if !m.canUndoTriage() || m.triagePending != nil { + return nil + } + previous := m.triageUndo.previous + cmd := m.submitTriage(findingStatus(previous), normalizeTriageReason(render.StringValue(previous["reason_code"])), render.StringValue(previous["status_note"])) + if m.triagePending != nil { + m.triagePending.undo = true + } + return cmd +} + +func (m *Model) handleTriageResult(result protocol.CommandResult) tea.Cmd { + pending := m.triagePending + if pending == nil { + return nil + } + m.triagePending = nil + m.triageErrorID = pending.findingID + if !result.OK { + m.triageError = "Could not save this decision." + if result.Error != nil { + m.triageError = result.Error.Message + } + if result.Error != nil && result.Error.Code == "conflict" { + m.triageError += " Press Esc and review the updated finding before retrying." + } + return m.collectionMismatch("vulnerabilities") + } + var data struct { + Changed bool `json:"changed"` + Finding map[string]any `json:"finding"` + } + if err := json.Unmarshal(result.Result, &data); err != nil || collectionItemID(data.Finding) != pending.findingID { + m.triageError = "Save outcome unknown. Refreshing the finding before another decision." + return m.collectionMismatch("vulnerabilities") + } + for i, finding := range m.snapshot.Vulnerabilities { + if collectionItemID(finding) == pending.findingID { + updated := make(map[string]any, len(finding)) + for key, value := range finding { + updated[key] = value + } + if numberValue(data.Finding["triage_revision"]) >= numberValue(finding["triage_revision"]) { + for key, value := range data.Finding { + updated[key] = value + } + // A command acknowledges a decision, not a new evidence snapshot. + // Preserve evidence that arrived while the save was in flight. + preserveCurrentEvidence(finding, updated) + } + m.snapshot.Vulnerabilities[i] = updated + } + } + m.triageError = "" + if m.modal == modalTriage && m.triage.findingID == pending.findingID { + m.modal = modalVulnerability + m.triage.note.Blur() + } + if m.triage.findingID == pending.findingID { + m.triage.findingID = "" + } + if data.Changed && !pending.undo { + m.triageUndo = &triageUndoState{findingID: pending.findingID, previous: pending.previous, + revision: numberValue(data.Finding["triage_revision"]), digest: render.StringValue(data.Finding["finding_digest"]), expires: time.Now().Add(15 * time.Second)} + } else if pending.undo { + m.triageUndo = nil + } + m.resizeViewport() + m.resizeVulnerabilityViewport() + return nil +} + +// A queued collection frame predating the save cannot roll its acknowledgement back. +func keepNewerTriage(current, incoming map[string]any) map[string]any { + if render.StringValue(incoming["triage_error"]) != "" { + return incoming + } + if numberValue(current["triage_revision"]) > numberValue(incoming["triage_revision"]) { + evidence := map[string]any{"finding_digest": incoming["finding_digest"]} + for _, key := range triageFields { + incoming[key] = current[key] + } + preserveCurrentEvidence(evidence, incoming) + } + return incoming +} + +func preserveCurrentEvidence(evidence, decision map[string]any) { + digest := render.StringValue(evidence["finding_digest"]) + if digest != "" && digest != render.StringValue(decision["finding_digest"]) { + decision["finding_digest"] = digest + if render.StringValue(decision["triage_status"]) == "closed" { + decision["status"], decision["review_stale"] = "open", true + } + } +} + +func (m *Model) preserveTriageUpdates(incoming []map[string]any) { + current := map[string]map[string]any{} + for _, finding := range m.snapshot.Vulnerabilities { + current[collectionItemID(finding)] = finding + } + for _, finding := range incoming { + keepNewerTriage(current[collectionItemID(finding)], finding) + } +} + +func (m Model) updateTriageMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if msg.Button != tea.MouseButtonLeft || msg.Action != tea.MouseActionPress || m.triagePending != nil { + return m, nil + } + view := m.triageFormView() + if m.centeredLabelHit(view, "Close issue", msg.X, msg.Y) { + m.triage.focus = 3 + return m.updateTriageForm(tea.KeyMsg{Type: tea.KeyEnter}) + } + if m.centeredLabelHit(view, "Cancel", msg.X, msg.Y) { + return m.updateTriageForm(tea.KeyMsg{Type: tea.KeyEsc}) + } + if m.centeredLabelHit(view, triageReasons[m.triage.reason].label, msg.X, msg.Y) { + m.triage.focus = 0 + m.triage.reason = (m.triage.reason + 1) % len(triageReasons) + m.triage.note.Blur() + } + if m.centeredLabelHit(view, "Note (optional)", msg.X, msg.Y) { + m.triage.focus = 1 + return m, m.triage.note.Focus() + } + return m, nil +} + +// Legacy decisions can omit the optional category. +func normalizeTriageReason(reason string) string { + if strings.TrimSpace(reason) == "" { + return "unspecified" + } + return reason +} diff --git a/strix/interface/tui/internal/app/triage_test.go b/strix/interface/tui/internal/app/triage_test.go new file mode 100644 index 00000000..67981762 --- /dev/null +++ b/strix/interface/tui/internal/app/triage_test.go @@ -0,0 +1,231 @@ +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/usestrix/strix/tui/internal/protocol" +) + +func triageModel(t *testing.T) Model { + t.Helper() + m := reportModel(t, 2) + m.client = newClient(&recordingConn{}) + for _, finding := range m.snapshot.Vulnerabilities { + finding["status"] = "open" + finding["triage_revision"] = 0 + finding["finding_digest"] = strings.Repeat("a", 64) + finding["can_triage"] = true + } + return m +} + +func triageKey(m Model, key tea.KeyMsg) Model { + updated, _ := m.updateModal(key) + return updated.(Model) +} + +func TestNativeTriageFormKeepsLettersAndComposerDraft(t *testing.T) { + m := triageModel(t) + m.input.SetValue("unfinished scan instruction") + m = triageKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}}) + if m.modal != modalTriage { + t.Fatal("f did not open a native form") + } + view := ansi.Strip(m.triageFormView()) + for _, label := range []string{"Mark as false positive", "Reason (optional)", "Note (optional)", "Close issue", "Cancel"} { + if !strings.Contains(view, label) { + t.Fatalf("form lacks %q", label) + } + } + if strings.Contains(strings.ToLower(view), "telemetry") { + t.Fatal("form contains telemetry copy") + } + m = triageKey(m, tea.KeyMsg{Type: tea.KeyTab}) + for _, letter := range "frv" { + m = triageKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{letter}}) + } + if m.triage.note.Value() != "frv" || m.triagePending != nil { + t.Fatal("note typing triggered a command") + } + m = triageKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + if m.modal != modalVulnerability || m.input.Value() != "unfinished scan instruction" { + t.Fatal("cancel lost detail or composer draft") + } +} + +func TestNativeTriageRequestUsesOriginallyReviewedDigest(t *testing.T) { + m := triageModel(t) + connection := &recordingConn{} + m.client = newClient(connection) + m.openTriageForm() + m.triage.reason = 1 + m.triage.note.SetValue("The claim is incorrect") + m.selectedFinding()["finding_digest"] = strings.Repeat("b", 64) + m.triage.focus = 3 + updated, cmd := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if cmd == nil { + t.Fatal("close did not send a command") + } + message := cmd().(sentMsg) + if message.err != nil || message.command != "vulnerability.triage" { + t.Fatalf("wrong command: %#v", message) + } + frame, err := readEnvelopeFrame(bytes.NewReader(connection.Bytes())) + if err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(frame.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload["reviewed_digest"] != strings.Repeat("a", 64) || payload["finding_id"] != "a" || payload["status"] != "closed" || payload["note"] != "The claim is incorrect" { + t.Fatalf("review request changed: %#v", payload) + } + if retry := m.submitTriage("closed", "unspecified", ""); retry != nil { + t.Fatal("sent duplicate pending review") + } + result := protocol.CommandResult{OK: false, Command: "vulnerability.triage", Error: &protocol.CommandError{Code: "conflict", Message: "Evidence changed"}} + m.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "command_result", RequestID: message.requestID, Payload: rawJSON(t, result)}) + if m.modal != modalTriage || m.triage.note.Value() != "The claim is incorrect" || !strings.Contains(m.triageError, "review") { + t.Fatal("conflict lost the editable form") + } + m = triageKey(m, tea.KeyMsg{Type: tea.KeyEsc}) + m.openTriageForm() + if m.triage.note.Value() != "The claim is incorrect" || m.triage.digest != strings.Repeat("b", 64) { + t.Fatal("fresh review lost the failed submission draft") + } +} + +func TestSavedTriagePinsDetailOffersUndoAndFiltersClosedFinding(t *testing.T) { + m := triageModel(t) + m.openTriageForm() + m.submitTriage("closed", "incorrect_assumption", "note") + closed := map[string]any{"id": "a", "status": "closed", "triage_status": "closed", "resolution_reason": "false_positive", "reason_code": "incorrect_assumption", "status_note": "note", "triage_revision": 1, "finding_digest": strings.Repeat("a", 64), "can_triage": true} + m.handleTriageResult(protocol.CommandResult{OK: true, Command: "vulnerability.triage", Result: rawJSON(t, map[string]any{"changed": true, "finding": closed})}) + if m.modal != modalVulnerability || m.selectedFindingID() != "a" || !m.canUndoTriage() { + t.Fatal("saved closure lost the detail or undo") + } + if rows := m.vulnerabilityRows(60); len(rows) != 1 || rows[0].index != 1 { + t.Fatalf("closed finding remained active: %#v", rows) + } + if !strings.Contains(ansi.Strip(m.vulnerabilityDetail()), "Reopen (r)") { + t.Fatal("native reopen is missing") + } + if !strings.Contains(vulnerabilityMarkdownReport(m.selectedFinding()), "Closed · False positive") { + t.Fatal("copy lacks triage") + } + if cmd := m.undoTriage(); cmd == nil || m.triagePending == nil || !m.triagePending.undo { + t.Fatal("undo unavailable") + } + // A saved reopen is active again and consumes the brief undo action. + closed["status"], closed["triage_revision"] = "open", 2 + m.handleTriageResult(protocol.CommandResult{OK: true, Command: "vulnerability.triage", Result: rawJSON(t, map[string]any{"changed": true, "finding": closed})}) + if len(m.visibleFindingIndices()) != 2 || m.triageUndo != nil { + t.Fatal("undo did not restore active finding") + } +} + +func TestNativeClosedFilterIsReachableWithoutViewer(t *testing.T) { + m := triageModel(t) + m.snapshot.Vulnerabilities[0]["status"] = "closed" + m.closeModal() + m.focus = focusVulnerabilities + updated, cmd := m.updateMain(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'v'}}) + m = updated.(Model) + if cmd != nil || m.findingFilterLabel() != "Closed" || m.selectedFindingID() != "a" { + t.Fatal("v did not select the closed view locally") + } + updated, _ = m.updateMain(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + updated, cmd = m.updateModal(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + m = updated.(Model) + if cmd == nil || m.triagePending == nil { + t.Fatal("r did not submit native reopen") + } +} + +func TestCollectionRefreshPreservesReviewSelectionAndNewerAcknowledgement(t *testing.T) { + m := triageModel(t) + m.selectedVuln = 1 + m.openTriageForm() + m.snapshot.Vulnerabilities[1]["triage_revision"] = 2 + m.snapshot.Vulnerabilities[1]["status"] = "closed" + items := []json.RawMessage{rawJSON(t, map[string]any{"id": "b", "title": "Updated", "status": "open", "triage_revision": 1}), rawJSON(t, m.snapshot.Vulnerabilities[0])} + m.handleCollectionBootstrap(rawJSON(t, protocol.CollectionBootstrap{Collection: "vulnerabilities", Revision: 2, Cursor: 0, NextCursor: 2, Done: true, Items: items})) + if m.selectedFindingID() != "b" || m.triage.findingID != "b" || m.selectedVuln != 0 { + t.Fatal("reorder changed the finding being reviewed") + } + if findingStatus(m.selectedFinding()) != "closed" || numberValue(m.selectedFinding()["triage_revision"]) != 2 { + t.Fatal("old queued projection overwrote saved decision") + } +} + +func TestStaleClosureRemainsActiveAndCanBeReviewedAgain(t *testing.T) { + m := triageModel(t) + finding := m.selectedFinding() + finding["triage_status"], finding["status"], finding["review_stale"] = "closed", "open", true + if !m.findingVisible(0) || !strings.Contains(findingStatusLabel(finding), "Needs review") { + t.Fatal("stale review remained suppressed") + } + m.openTriageForm() + if m.modal != modalTriage { + t.Fatal("fresh review unavailable") + } +} + +func TestNativeTriageFitsSmallTerminal(t *testing.T) { + for _, size := range [][2]int{{130, 30}, {80, 24}, {60, 22}, {40, 18}} { + m := triageModel(t) + m.width, m.height = size[0], size[1] + m.openTriageForm() + m.triage.reason = 2 + m.triageError = "Finding evidence changed. Reload and review it again. Press Esc and review the updated finding before retrying." + view := m.triageFormView() + if lipgloss.Width(view) > m.width || lipgloss.Height(view) > m.height { + t.Errorf("%dx%d form is %dx%d:\n%s", m.width, m.height, lipgloss.Width(view), lipgloss.Height(view), ansi.Strip(view)) + } + } +} + +func TestF2OpensClosedOnlyFindingsOnNarrowTerminal(t *testing.T) { + m := triageModel(t) + m.width, m.height = 80, 24 + for _, finding := range m.snapshot.Vulnerabilities { + finding["status"] = "closed" + } + m.closeModal() + updated, cmd := m.updateMain(tea.KeyMsg{Type: tea.KeyF2}) + m = updated.(Model) + if cmd != nil || m.modal != modalVulnerability || m.findingFilterLabel() != "All" { + t.Fatal("F2 did not reach closed findings without a sidebar") + } + if !strings.Contains(ansi.Strip(m.statusView(80)), "F2 findings") { + t.Fatal("entry point is not discoverable") + } +} + +func TestSaveAcknowledgementCannotDismissEvidenceArrivingInFlight(t *testing.T) { + m := triageModel(t) + m.openTriageForm() + m.submitTriage("closed", "unspecified", "") + finding := m.selectedFinding() + finding["finding_digest"] = strings.Repeat("b", 64) + finding["evidence"] = "Changed after submission" + finding["triage_revision"] = 1 + finding["review_stale"] = true + m.handleTriageResult(protocol.CommandResult{OK: true, Command: "vulnerability.triage", Result: rawJSON(t, map[string]any{"changed": true, "finding": map[string]any{ + "id": "a", "status": "closed", "triage_status": "closed", "triage_revision": 1, "finding_digest": strings.Repeat("a", 64), "review_stale": false}})}) + if findingStatus(m.selectedFinding()) != "open" || !boolField(m.selectedFinding(), "review_stale") || m.selectedFinding()["finding_digest"] != strings.Repeat("b", 64) { + t.Fatal("save hid evidence the user never reviewed") + } + if m.canUndoTriage() { + t.Fatal("undo should be unavailable after evidence changed") + } +} diff --git a/strix/interface/tui/internal/app/update.go b/strix/interface/tui/internal/app/update.go index 3b962495..f7e2a928 100644 --- a/strix/interface/tui/internal/app/update.go +++ b/strix/interface/tui/internal/app/update.go @@ -10,9 +10,26 @@ import ( func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch key.String() { + case "v": + if m.focus == focusVulnerabilities { + m.findingFilter = (m.findingFilter + 1) % 3 + m.selectVisibleFinding() + m.vulnOffset = 0 + m.resizeViewport() + return m, nil + } case "f1": m.openModal(modalHelp) return m, nil + case "f2": + if len(m.snapshot.Vulnerabilities) > 0 { + if len(m.visibleFindingIndices()) == 0 { + m.findingFilter = 2 + } + m.selectVisibleFinding() + m.openModal(modalVulnerability) + } + return m, nil case "ctrl+c", "ctrl+q": // Nothing to lose on the start screen; quit without confirmation. if m.snapshot.SetupMode { @@ -70,6 +87,9 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter", " ": if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { if key.String() == "enter" { + if !m.findingVisible(m.selectedVuln) { + return m, nil + } m.openModal(modalVulnerability) return m, nil } @@ -128,12 +148,16 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { case "home": if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { m.selectedVuln = 0 + m.selectVisibleFinding() m.ensureVulnerabilityVisible() return m, nil } case "end": if m.focus == focusVulnerabilities && len(m.snapshot.Vulnerabilities) > 0 { m.selectedVuln = len(m.snapshot.Vulnerabilities) - 1 + if indices := m.visibleFindingIndices(); len(indices) > 0 { + m.selectedVuln = indices[len(indices)-1] + } m.ensureVulnerabilityVisible() return m, nil } @@ -466,9 +490,15 @@ func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) { switch button { case reportPrev: - m.showVulnerability(m.selectedVuln - 1) + m.stepVulnerability(-1) case reportNext: - m.showVulnerability(m.selectedVuln + 1) + m.stepVulnerability(1) + case reportTriage: + return m, m.openTriageForm() + case reportReopen: + return m, m.submitTriage("open", "unspecified", "") + case reportUndo: + return m, m.undoTriage() case reportCopy: m.reportFocus = reportCopy return m, m.startVulnerabilityCopy() @@ -479,6 +509,9 @@ func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) { } func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.modal == modalTriage { + return m.updateTriageMouse(msg) + } if m.modal == modalVulnerability { view := m.modalView() left, top, _, _ := m.centeredViewBounds(view) @@ -610,6 +643,9 @@ func clampCycle(value, length int) int { } func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.modal == modalTriage { + return m.updateTriageForm(key) + } if m.modal == modalHelp { if key.String() != "" { m.closeModal() @@ -622,9 +658,23 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) { m.closeModal() // The arrows step between reports directly; tab walks the button row. case "left": - m.showVulnerability(m.selectedVuln - 1) + m.stepVulnerability(-1) case "right": - m.showVulnerability(m.selectedVuln + 1) + m.stepVulnerability(1) + case "f": + return m, m.openTriageForm() + case "v": + m.findingFilter = (m.findingFilter + 1) % 3 + m.selectVisibleFinding() + m.vulnOffset = 0 + m.resizeVulnerabilityViewport() + return m, nil + case "r": + if findingStatus(m.selectedFinding()) == "closed" { + return m, m.submitTriage("open", "unspecified", "") + } + case "u": + return m, m.undoTriage() case "tab": m.stepReportFocus(1) case "shift+tab": @@ -706,6 +756,7 @@ func (m *Model) openModal(mode modalMode) { func (m *Model) closeModal() { m.modal = modalNone + m.selectVisibleFinding() if m.focus == focusInput { m.input.Focus() } diff --git a/strix/interface/tui/internal/app/view.go b/strix/interface/tui/internal/app/view.go index 8588df18..287623d0 100644 --- a/strix/interface/tui/internal/app/view.go +++ b/strix/interface/tui/internal/app/view.go @@ -266,7 +266,7 @@ func (m Model) viewInner() string { } else if m.modal != modalNone { // Only the vulnerability detail dims its backdrop (#000000 80%); Help, // Quit and Stop are transparent. - main = m.overlay(main, m.modalView(), m.modal == modalVulnerability) + main = m.overlay(main, m.modalView(), m.modal == modalVulnerability || m.modal == modalTriage) } return m.toastOverlay(main) } @@ -569,7 +569,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, mcpHeight, agentHeight statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView())) statsHeight = min(15, statsRows+2) if len(m.snapshot.Vulnerabilities) > 0 { - vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2) + vulnHeight = min(12, max(3, len(m.vulnerabilityRows(m.vulnerabilityListWidth())))+2) } // One header line + one line per connection + the box border (2). Capped so a // long roster cannot crowd out the agent tree; a roster past the cap scrolls @@ -614,6 +614,15 @@ func (m Model) viewerView(width int) string { func (m Model) statsView() string { w := lipgloss.NewStyle().Foreground(white) var b strings.Builder + if total := len(m.snapshot.Vulnerabilities); total > 0 { + closed := 0 + for _, finding := range m.snapshot.Vulnerabilities { + if findingStatus(finding) == "closed" { + closed++ + } + } + b.WriteString(w.Render(fmt.Sprintf("%d open · %d false positives\n%d found · %s (v)\n", total-closed, closed, total, m.findingFilterLabel()))) + } if model := m.snapshot.Model; model != "" { b.WriteString(w.Render(model)) } @@ -826,6 +835,14 @@ func (m Model) statusView(width int) string { if m.errorText != "" { left = statusMessage(m.errorText, red, "", width-lipgloss.Width(right)) } + if len(m.snapshot.Vulnerabilities) > 0 { + hint := lipgloss.NewStyle().Foreground(textColor).Render("F2 findings") + if right == "" { + right = hint + } else { + right = hint + " · " + right + } + } return composeStatusRow(left, right, width) } diff --git a/strix/interface/tui/internal/app/vuln_report.go b/strix/interface/tui/internal/app/vuln_report.go index 2f460926..538fd036 100644 --- a/strix/interface/tui/internal/app/vuln_report.go +++ b/strix/interface/tui/internal/app/vuln_report.go @@ -65,6 +65,9 @@ func vulnerabilityMarkdownReport(v map[string]any) string { } } field("ID", render.StringValue(v["id"])) + field("Status", findingStatusLabel(v)) + field("Reviewed", render.StringValue(v["status_changed_at"])) + field("Review reason", triageReasonLabel(render.StringValue(v["reason_code"]))) field("Severity", strings.ToUpper(render.StringValue(v["severity"]))) field("Found", render.StringValue(v["timestamp"])) field("Agent", render.StringValue(v["agent_name"])) diff --git a/strix/interface/tui/internal/app/vulnerabilities.go b/strix/interface/tui/internal/app/vulnerabilities.go index 6a59a5aa..838a7341 100644 --- a/strix/interface/tui/internal/app/vulnerabilities.go +++ b/strix/interface/tui/internal/app/vulnerabilities.go @@ -28,7 +28,7 @@ func (m Model) vulnerabilityRows(width int) []vulnerabilityRow { // Wrapped lines sit under the title rather than under the severity dot. body := max(1, width-2) rows := make([]vulnerabilityRow, 0, len(m.snapshot.Vulnerabilities)) - for i := range m.snapshot.Vulnerabilities { + for _, i := range m.visibleFindingIndices() { for line, text := range strings.Split(wrapBlock(m.vulnerabilityTitle(i), body), "\n") { rows = append(rows, vulnerabilityRow{index: i, text: text, first: line == 0}) } @@ -38,6 +38,9 @@ func (m Model) vulnerabilityRows(width int) []vulnerabilityRow { func (m Model) vulnerabilitiesView(width, height int) string { rows := m.vulnerabilityRows(width) + if len(rows) == 0 { + return wrapBlock("No "+strings.ToLower(m.findingFilterLabel())+" findings.\nv: Open / Closed / All", width) + } start := min(max(0, m.vulnOffset), max(0, len(rows)-1)) end := min(len(rows), start+height) lines := make([]string, 0, max(0, end-start)) @@ -78,6 +81,12 @@ func (m Model) vulnerabilityTitle(index int) string { if title == "" { title = "Unknown Vulnerability" } + if boolField(m.snapshot.Vulnerabilities[index], "review_stale") { + return "[Needs review] " + title + } + if findingStatus(m.snapshot.Vulnerabilities[index]) == "closed" { + return "[False positive] " + title + } return title } @@ -157,7 +166,18 @@ func (m Model) vulnerabilityPageItems() int { } func (m *Model) moveVulnerabilitySelection(delta int) { - m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta)) + indices := m.visibleFindingIndices() + if len(indices) == 0 { + return + } + position := 0 + for i, index := range indices { + if index == m.selectedVuln { + position = i + break + } + } + m.selectedVuln = indices[max(0, min(len(indices)-1, position+delta))] } // keepVulnerabilitySelectionInWindow pulls the selection to the nearest finding @@ -192,7 +212,7 @@ func (m Model) modalView() string { switch m.modal { case modalHelp: title := lipgloss.NewStyle().Bold(true).Foreground(green).Width(34).Align(lipgloss.Center).Render("Strix Help") - body := lipgloss.NewStyle().Foreground(textColor).Render("F1 Help\nCtrl+O Open viewer\nCtrl+Q/C Quit\nESC Stop Agent\nEnter Send / expand node\nCtrl+J Newline in message\nTab Switch panels\n↑/↓ Navigate tree\nDrag Select & copy text\nClick Expand/collapse tool") + body := lipgloss.NewStyle().Foreground(textColor).Render("F1 Help\nF2 Findings\nCtrl+O Open viewer\nCtrl+Q/C Quit\nESC Stop Agent\nEnter Send / expand node\nCtrl+J Newline in message\nTab Switch panels\n↑/↓ Navigate tree\nf / r / u Review / reopen / undo (finding)\nv Open / Closed / All (findings)\nDrag Select & copy text\nClick Expand/collapse tool") content := title + "\n\n" + body return lipgloss.NewStyle().Width(38).Border(lipgloss.RoundedBorder()).BorderForeground(green).Background(black).Padding(1, 2).Render(content) case modalQuit: @@ -212,6 +232,8 @@ func (m Model) modalView() string { return "" } return m.vulnerabilityDetail() + case modalTriage: + return m.triageFormView() } return "" } @@ -328,6 +350,14 @@ func vulnerabilityBody(v map[string]any) string { } } field("Agent", render.StringValue(v["agent_name"])) + field("Status", findingStatusLabel(v)) + field("Reviewed", render.StringValue(v["status_changed_at"])) + field("Reason", triageReasonLabel(render.StringValue(v["reason_code"]))) + field("Note", render.StringValue(v["status_note"])) + field("Review unavailable", render.StringValue(v["triage_error"])) + if allowed, present := v["can_triage"]; present && allowed == false && v["triage_error"] == nil { + field("Review", "Read-only finding; decisions cannot be changed.") + } field("Title", render.StringValue(v["title"])) if sev := render.StringValue(v["severity"]); sev != "" { b.WriteString("\n\n" + fieldStyle.Render("Severity: ") + @@ -389,8 +419,9 @@ func (m *Model) resizeVulnerabilityViewport() { width, height := m.vulnerabilityDialogSize() innerWidth := max(1, width-8) // border plus three cells of horizontal padding m.vulnViewport.Width = max(1, innerWidth-2) // right padding and one-cell scrollbar - m.vulnViewport.Height = max(1, height-9) // padding, one-row grid gutter, and two-row footer - m.vulnViewport.SetContent(wrapBlock(vulnerabilityBody(m.snapshot.Vulnerabilities[m.selectedVuln]), m.vulnViewport.Width)) + m.vulnViewport.Height = max(1, height-11) // action row, navigation and mutation outcome + body := vulnerabilityBody(m.snapshot.Vulnerabilities[m.selectedVuln]) + m.vulnViewport.SetContent(wrapBlock(body, m.vulnViewport.Width)) m.vulnViewport.SetYOffset(m.vulnViewport.YOffset) } @@ -430,13 +461,21 @@ func (m Model) vulnerabilityDetail() string { } // Stepping sits on the left behind the position, acting on the right. right := strings.Join(acting, " ") - left := strings.Join(stepping, " ") + left := m.findingFilterLabel() + " (v) " + strings.Join(stepping, " ") if total := len(m.snapshot.Vulnerabilities); total > 1 { left = render.Dim().Render(fmt.Sprintf("%d/%d", m.selectedVuln+1, total)) + " " + left } - room := max(0, inner-lipgloss.Width(right)) - buttonRow := rule + "\n" + - lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right + buttonRow := rule + "\n" + truncate(left, inner) + "\n" + wrapBlock(right, inner) + outcome := "" + if m.triageErrorID == m.selectedFindingID() { + outcome = m.triageError + } + if m.triagePending != nil && m.triagePending.findingID == m.selectedFindingID() { + outcome = "Saving…" + } + if outcome != "" { + buttonRow += "\n" + truncate(outcome, inner) + } content := m.vulnerabilityScrollView() + "\n" + buttonRow return lipgloss.NewStyle().Width(width-2).Height(height-2).Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#262626")).Background(lipgloss.Color("#0a0a0a")).Padding(2, 3).Render(content) } @@ -459,10 +498,13 @@ func (m *Model) showVulnerability(index int) { // The report's buttons. Prev and Next carry their arrows so a click test cannot // be fooled by the same word appearing in the body of a finding. const ( - reportPrev = "‹ Prev" - reportNext = "Next ›" - reportCopy = "Copy" - reportDone = "Done" + reportPrev = "‹ Prev" + reportNext = "Next ›" + reportCopy = "Copy" + reportDone = "Done" + reportTriage = "False positive (f)" + reportReopen = "Reopen (r)" + reportUndo = "Undo (u)" ) // reportButtons is the row as it stands, left to right. Stepping is offered only @@ -476,6 +518,16 @@ func (m Model) reportButtons() []string { if next { buttons = append(buttons, reportNext) } + if boolField(m.selectedFinding(), "can_triage") { + if findingStatus(m.selectedFinding()) == "closed" { + buttons = append(buttons, reportReopen) + } else { + buttons = append(buttons, reportTriage) + } + } + if m.canUndoTriage() { + buttons = append(buttons, reportUndo) + } return append(buttons, reportCopy, reportDone) } @@ -507,7 +559,11 @@ func (m *Model) stepReportFocus(delta int) { // ends are not wrapped: a report is one of an ordered list, and rolling from the // last to the first hides that you reached the end. func (m Model) vulnerabilityNeighbors() (previous, next bool) { - return m.selectedVuln > 0, m.selectedVuln < len(m.snapshot.Vulnerabilities)-1 + for _, index := range m.visibleFindingIndices() { + previous = previous || index < m.selectedVuln + next = next || index > m.selectedVuln + } + return } // reportButton renders one button of the report row. Copy reports the outcome of diff --git a/strix/interface/tui/internal/app/wire.go b/strix/interface/tui/internal/app/wire.go index 1536110b..e13f80d2 100644 --- a/strix/interface/tui/internal/app/wire.go +++ b/strix/interface/tui/internal/app/wire.go @@ -76,6 +76,9 @@ func (m *Model) handleEnvelope(envelope protocol.Envelope) tea.Cmd { if result.Command != expectedCommand || !m.client.Resolve(envelope.RequestID, result.Command) { return nil } + if result.Command == "vulnerability.triage" { + return m.handleTriageResult(result) + } if !result.OK { if result.Command == "collection.resync" { if collection := m.resyncRequests[envelope.RequestID]; collection != "" { @@ -240,7 +243,10 @@ func (m *Model) handleCollectionBootstrap(payload json.RawMessage) tea.Cmd { } else if chunk.Collection == "events" { m.snapshot.Events = assembly.events } else { + selectedID := m.selectedFindingID() + m.preserveTriageUpdates(assembly.findings) m.snapshot.Vulnerabilities = assembly.findings + m.restoreFindingSelection(selectedID) } m.collectionRevisions[chunk.Collection] = chunk.Revision delete(m.collectionAssemblies, chunk.Collection) @@ -379,6 +385,7 @@ func (m *Model) applyCollectionOperations(name string, operations []protocol.Col return true } + selectedID := m.selectedFindingID() values := append([]map[string]any(nil), m.snapshot.Vulnerabilities...) positions := make(map[string]int, len(values)) for index, finding := range values { @@ -417,13 +424,14 @@ func (m *Model) applyCollectionOperations(name string, operations []protocol.Col } seen[id] = true if index, exists := positions[id]; exists { - values[index] = finding + values[index] = keepNewerTriage(values[index], finding) } else { positions[id] = len(values) values = append(values, finding) } } m.snapshot.Vulnerabilities = values + m.restoreFindingSelection(selectedID) return true } @@ -443,6 +451,9 @@ func (m *Model) refreshAfterCollection(name string) tea.Cmd { return nil } m.selectedVuln = min(m.selectedVuln, max(0, len(m.snapshot.Vulnerabilities)-1)) + if m.modal == modalNone { + m.selectVisibleFinding() + } m.ensureVulnerabilityVisible() m.resizeVulnerabilityViewport() return nil diff --git a/strix/interface/tui/runtime.py b/strix/interface/tui/runtime.py index a20960ce..1ed11147 100644 --- a/strix/interface/tui/runtime.py +++ b/strix/interface/tui/runtime.py @@ -9,6 +9,7 @@ import logging import os import shutil import sys +import tempfile from copy import deepcopy from pathlib import Path from typing import TYPE_CHECKING, Any @@ -26,6 +27,7 @@ from strix.interface.scan_setup import ( from strix.interface.tui.backend import TuiBackendServer, TuiController from strix.interface.tui.backend.live_view import TuiLiveView from strix.interface.tui.sidecar import ( + build_tui_source, check_return_code, child_environment, launch_tui_process, @@ -45,6 +47,7 @@ if TYPE_CHECKING: import argparse import socket import subprocess + from typing import TextIO logger = logging.getLogger(__name__) @@ -410,6 +413,19 @@ class GoTuiRuntime: with contextlib.suppress(asyncio.CancelledError): await task + @staticmethod + async def _source_command(directory: str, env: dict[str, str], output: TextIO) -> list[str]: + # Compile before IPC starts: a cold build/toolchain download must not + # consume the sidecar's protocol-handshake timeout. + print( + "\x1b[2mCompiling the TUI from source (cached after the first run)...\x1b[0m", + file=output, + flush=True, + ) + executable = Path(directory) / tui_executable() + await build_tui_source(tui_source_dir(), executable, env) + return [str(executable)] + async def run(self) -> None: # Redirect the process's sys.stdout/sys.stderr while the TUI runs so # logging handlers created during the scan never paint over the Go @@ -424,20 +440,15 @@ class GoTuiRuntime: sync_task: asyncio.Task[None] | None = None prepare_task: asyncio.Task[None] | None = None process: asyncio.subprocess.Process | subprocess.Popen[bytes] | None = None + build_directory: tempfile.TemporaryDirectory[str] | None = None try: env = child_environment() env["STRIX_VERSION"] = package_version() command = self.binary_command() - cwd = str(tui_source_dir()) if command[:2] == ["go", "run"] else None - if cwd is not None: - # go run compiles the sidecar when the build cache is cold, so - # tell the terminal why nothing is on screen yet. - print( - "\x1b[2mCompiling the TUI from source (cached after the first run)...\x1b[0m", - file=original_stdout, - flush=True, - ) - process, backend_socket = await launch_tui_process(command, env, cwd) + if command[:2] == ["go", "run"]: + build_directory = tempfile.TemporaryDirectory(prefix="strix-tui-") + command = await self._source_command(build_directory.name, env, original_stdout) + process, backend_socket = await launch_tui_process(command, env, None) await self.server.start(backend_socket) prepare_task = self._start_preparation() sync_task = asyncio.create_task(self.sync_state()) @@ -462,6 +473,8 @@ class GoTuiRuntime: sys.stdout = original_stdout sys.stderr = original_stderr output_sink.close() + if build_directory is not None: + build_directory.cleanup() # Mirror run_tui: surface the captured scan failure once the app has # exited cleanly so the CLI reports it instead of exiting 0. if self.scan_error is not None: diff --git a/strix/interface/tui/sidecar.py b/strix/interface/tui/sidecar.py index d08d04ac..bc8765f4 100644 --- a/strix/interface/tui/sidecar.py +++ b/strix/interface/tui/sidecar.py @@ -13,6 +13,8 @@ from importlib.metadata import PackageNotFoundError, version from pathlib import Path from typing import Any +from strix.interface.terminal_text import sanitize_terminal_text + _WINDOWS_AUTH_TIMEOUT = 10.0 _PROCESS_EXIT_TIMEOUT = 5.0 @@ -122,6 +124,50 @@ async def terminate_process( await asyncio.wait_for(asyncio.shield(wait_task), _PROCESS_EXIT_TIMEOUT) +async def build_tui_source(source: Path, output: Path, env: dict[str, str]) -> None: + """Compile before opening IPC so toolchain downloads cannot time out the handshake.""" + command = ["go", "build", "-o", str(output), "./cmd/strix-tui"] + build_env = {**env, "GOTOOLCHAIN": "auto", "CGO_ENABLED": "0"} + process: asyncio.subprocess.Process | subprocess.Popen[bytes] + communication: asyncio.Task[tuple[bytes | None, bytes | None]] + if os.name == "nt": + # The Windows selector loop used by Strix does not support async subprocesses. + process = subprocess.Popen( # noqa: S603 + command, + cwd=str(source), + env=build_env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + communication = asyncio.create_task(asyncio.to_thread(process.communicate)) + else: + process = await asyncio.create_subprocess_exec( + *command, + cwd=str(source), + env=build_env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + communication = asyncio.create_task(process.communicate()) + try: + _, stderr = await asyncio.shield(communication) + except BaseException: + await terminate_process(process) + # In particular, wait for the Windows pipe reader before cleaning up the build. + with contextlib.suppress(Exception, asyncio.CancelledError): + await communication + raise + if process.returncode != 0: + detail = sanitize_terminal_text((stderr or b"").decode("utf-8", errors="replace")).strip() + raise RuntimeError( + "Could not compile the TUI. Go must be able to select the toolchain " + "required by go.mod and download any missing build dependencies." + + (f"\n{detail[-4000:]}" if detail else "") + ) + + async def launch_tui_process( command: list[str], env: dict[str, str], diff --git a/strix/interface/viewer/frontend/src/App.tsx b/strix/interface/viewer/frontend/src/App.tsx index bbd24f4c..87ac3a3f 100644 --- a/strix/interface/viewer/frontend/src/App.tsx +++ b/strix/interface/viewer/frontend/src/App.tsx @@ -26,10 +26,12 @@ import { fetchAll, fetchAuthStatus, fetchCapabilities, - fetchRunSummary, fetchRuns, - fetchTranscript, fetchVulnerabilities, + fetchTriageRevision, + updateFindingTriage, + TriageRequestError, + type TriageUpdate, forgetAuth, parseMcpConnectionStatus, type AuthStatus, @@ -67,6 +69,13 @@ export default function App() { // Whether this viewer can steer a live scan (true only inside the in-TUI // launcher that shares the running scan's coordinator + event loop). const [canSteer, setCanSteer] = useState(false); + const [issueFilter, setIssueFilter] = useState<"open" | "closed" | "all">("open"); + const activeRunRef = useRef(activeRun); + activeRunRef.current = activeRun; + const dataVersionRef = useRef(0); + const mutationsPendingRef = useRef(0); + const uncertainWritesRef = useRef(new Set()); + const refreshCurrentRef = useRef<() => void>(() => {}); const refreshAuth = useCallback(async () => { try { @@ -95,77 +104,110 @@ export default function App() { }); }, [refreshAuth, refreshRuns]); - // Live polling, scoped to the active run. Re-runs when the active run changes - // so switching to a past run (?run=) reloads its data; a finished run - // does a single full fetch and stops. - const finishedRef = useRef(false); + // Finished runs only poll a small file revision token. Live scans, external + // triage edits and focus changes refresh the same authoritative projection. useEffect(() => { let cancelled = false; let timer: ReturnType | undefined; - finishedRef.current = false; - - const schedule = () => { - timer = setTimeout(tick, POLL_MS); - }; + let finished = false; + let lastStamp = ""; + let forceRefresh = true; + let busy = false; + dataVersionRef.current += 1; const tick = async () => { - if (cancelled) return; + if (cancelled || busy) return; + if (timer) clearTimeout(timer); + busy = true; + const version = dataVersionRef.current; try { - const { summary, raw, finished } = await fetchRunSummary(activeRun); - if (cancelled) return; - if (finished && !finishedRef.current) { - finishedRef.current = true; - const full = await fetchAll(activeRun); - if (!cancelled) setRun(full); - return; // stop polling - } - const [transcript, vulnerabilities] = await Promise.all([ - fetchTranscript(activeRun).catch(() => ({ agents: [], events: [] })), - fetchVulnerabilities(summary.runId, activeRun).catch(() => [] as Vulnerability[]), - ]); - if (cancelled) return; - setRun((prev) => ({ - summary, - raw, - finished, - transcript, - vulnerabilities, - reportMarkdown: prev?.reportMarkdown ?? null, - })); - schedule(); + if (mutationsPendingRef.current) return; + const stamp = await fetchTriageRevision(activeRun); + if (finished && stamp === lastStamp && !forceRefresh) return; + const full = await fetchAll(activeRun); + if (cancelled || version !== dataVersionRef.current || mutationsPendingRef.current) return; + finished = full.finished; + if (finished && lastStamp && stamp !== lastStamp) void refreshRuns(); + lastStamp = stamp; + forceRefresh = false; + setRun(full); + setError(null); } catch (e) { if (cancelled) return; setError(e instanceof Error ? e.message : "Could not load run data."); - schedule(); + } finally { + busy = false; + if (!cancelled) timer = setTimeout(tick, finished ? 1500 : POLL_MS); } }; - - (async () => { - try { - const full = await fetchAll(activeRun); - if (cancelled) return; - setRun(full); - if (full.finished) { - finishedRef.current = true; - } else { - schedule(); - } - } catch (e) { - if (cancelled) return; - setError(e instanceof Error ? e.message : "Could not load run data."); - schedule(); - } - })(); - + const refresh = () => { forceRefresh = true; void tick(); }; + refreshCurrentRef.current = refresh; + window.addEventListener("focus", refresh); + void tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); + window.removeEventListener("focus", refresh); }; - }, [activeRun]); + }, [activeRun, refreshRuns]); + const saveTriage = useCallback(async (finding: Vulnerability, update: TriageUpdate) => { + const requestedRun = activeRun; + const requestKey = JSON.stringify([requestedRun, finding.id]); + dataVersionRef.current += 1; + mutationsPendingRef.current += 1; + const applyFindings = (findings: Vulnerability[]) => { + if (activeRunRef.current === requestedRun) { + setRun((previous) => previous ? { ...previous, vulnerabilities: findings } : previous); + } + }; + try { + if (uncertainWritesRef.current.has(requestKey)) { + const latest = await fetchVulnerabilities(finding.scan_id, requestedRun); + applyFindings(latest); + uncertainWritesRef.current.delete(requestKey); + const current = latest.find((item) => item.id === finding.id); + if (!current || current.triage_revision !== finding.triage_revision || current.finding_digest !== finding.finding_digest) { + throw new TriageRequestError("conflict", "The saved finding changed. Review its current state before trying again."); + } + } + const saved = await updateFindingTriage(finding, update, requestedRun); + if (activeRunRef.current === requestedRun) { + setRun((previous) => previous ? { + ...previous, + vulnerabilities: previous.vulnerabilities.map((item) => item.id === saved.id ? saved : item), + } : previous); + } + void refreshRuns(); + return saved; + } catch (error) { + if (!(error instanceof TriageRequestError)) uncertainWritesRef.current.add(requestKey); + // A lost response can follow a successful write. Read the saved state + // before allowing another attempt, without automatically repeating it. + try { + const latest = await fetchVulnerabilities(finding.scan_id, requestedRun); + applyFindings(latest); + uncertainWritesRef.current.delete(requestKey); + const saved = latest.find((item) => item.id === finding.id); + if (!(error instanceof TriageRequestError) && saved && + saved.triage_revision === (finding.triage_revision ?? 0) + 1 && + saved.finding_digest === finding.finding_digest && saved.status === update.status && + (update.status === "open" || (saved.reason_code === (update.reason_code ?? "unspecified") && + (saved.status_note ?? "") === (update.note ?? "").trim()))) return saved; + } catch { /* Leave the current view intact if reconciliation also fails. */ } + if (error instanceof TriageRequestError) throw error; + throw new Error("Could not confirm the save. The latest saved state will refresh before you retry."); + } finally { + mutationsPendingRef.current -= 1; + dataVersionRef.current += 1; + if (activeRunRef.current === requestedRun) refreshCurrentRef.current(); + } + }, [activeRun, refreshRuns]); + + const openFindings = useMemo(() => run?.vulnerabilities.filter((finding) => finding.status !== "closed") ?? [], [run]); const counts = useMemo( - () => (run ? severityCounts(run.vulnerabilities) : null), - [run] + () => (run ? severityCounts(openFindings) : null), + [run, openFindings] ); const selected = run?.vulnerabilities.find((v) => v.id === selectedId) ?? null; const agentCount = run?.transcript.agents.length ?? 0; @@ -228,6 +270,7 @@ export default function App() { setSelectedId(null); setRun(null); setError(null); + setIssueFilter("open"); // Reset the guard so the per-run default applies to the newly selected run. initialViewAppliedRef.current = false; }, []); @@ -272,7 +315,7 @@ export default function App() { if (v === "history") openHistory(); else userSetView(v); }} - issuesCount={run?.vulnerabilities.length ?? 0} + issuesCount={openFindings.length} agentCount={agentCount} mcpConnections={mcpConnections} mcpInUse={mcpInUse} @@ -325,7 +368,7 @@ export default function App() {
- {error && !run && view !== "history" && view !== "email" && ( + {error && view !== "history" && view !== "email" && (
) : ( setSelectedId(id)} /> )} @@ -550,16 +596,23 @@ function Meta({ label }: { label: string }) { function FindingsList({ vulnerabilities, finished, + filter, + onFilter, onSelect, }: { vulnerabilities: Vulnerability[]; finished: boolean; + filter: "open" | "closed" | "all"; + onFilter: (filter: "open" | "closed" | "all") => void; onSelect: (id: string) => void; }) { - const sorted = [...vulnerabilities].sort( + const open = vulnerabilities.filter((finding) => finding.status !== "closed").length; + const closed = vulnerabilities.length - open; + const sorted = vulnerabilities.filter((finding) => filter === "all" || + (filter === "closed" ? finding.status === "closed" : finding.status !== "closed")).sort( (a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity) ); - if (sorted.length === 0) { + if (vulnerabilities.length === 0) { return (
@@ -585,6 +638,22 @@ function FindingsList({ } return (
+
+
+ {(["open", "closed", "all"] as const).map((value) => ( + + ))} +
+

{open} open · {closed} false positives · {vulnerabilities.length} found

+
+ {sorted.length === 0 && ( +
+ {filter === "open" ? `No open findings. ${closed} marked as false positives.` : "No closed findings."} +
+ )} {sorted.map((v) => (
-

Email an encrypted PDF report of this run

+

Email the original scan report as an encrypted PDF

- Encrypted with a key only you can see, email verified with a one-time code before sending. + Excludes subsequent triage. Encrypted with a key only you can see; email verified before sending.

- Export report to PDF + Export original report
@@ -663,6 +733,7 @@ function OverviewTab({ summary, counts, total, + detected, reportMarkdown, raw, finished, @@ -671,6 +742,7 @@ function OverviewTab({ summary: ParsedRunSummary; counts: Record; total: number; + detected: number; reportMarkdown: string | null; raw: Record; finished: boolean; @@ -693,8 +765,9 @@ function OverviewTab({
- {total > 0 && ( + {detected > 0 && (
+

{total} open · {detected - total} false positives · {detected} found

)} @@ -734,12 +807,14 @@ function OverviewTab({ {sections.length > 0 ? (
+ {sections.map((s) => ( ))}
) : reportMarkdown ? (
+
) : ( @@ -752,6 +827,11 @@ function OverviewTab({ ); } +function OriginalReportLabel() { + return

Original scan report

+

Preserved as generated. Subsequent false-positive decisions are excluded; current triage counts appear above.

; +} + function TabButton({ active, onClick, diff --git a/strix/interface/viewer/frontend/src/components/EmailReportView.tsx b/strix/interface/viewer/frontend/src/components/EmailReportView.tsx index a44da4ce..86aaf1c0 100644 --- a/strix/interface/viewer/frontend/src/components/EmailReportView.tsx +++ b/strix/interface/viewer/frontend/src/components/EmailReportView.tsx @@ -192,9 +192,10 @@ export default function EmailReportView({
+ {!verifyOnly &&

This PDF preserves the original detected findings. Subsequent false-positive decisions and local triage notes are excluded.

}
- Export report + Export original report {verified && auth?.email && (

Sending to {auth.email}

diff --git a/strix/interface/viewer/frontend/src/components/PastRunsView.tsx b/strix/interface/viewer/frontend/src/components/PastRunsView.tsx index adfeb4dd..89c9d116 100644 --- a/strix/interface/viewer/frontend/src/components/PastRunsView.tsx +++ b/strix/interface/viewer/frontend/src/components/PastRunsView.tsx @@ -22,7 +22,7 @@ const SEV = [ function SeverityChips({ counts }: { counts: RunSeverityCounts }) { const shown = SEV.filter((s) => counts[s.key] > 0); if (shown.length === 0) { - return No findings; + return No open findings; } return (
@@ -164,9 +164,14 @@ export default function PastRunsView({ {date && {date}} {date && run.status && ·} {run.status && {run.status}} + {run.open_count !== undefined && · {run.open_count} open · {run.closed_count ?? 0} false positives · {run.detected_count ?? run.open_count} found}
- + {run.severity_counts ? ( + + ) : ( + Review unavailable + )}