Compare commits

...
3 Commits
14 changed files with 900 additions and 184 deletions
+4 -4
View File
@@ -68,10 +68,10 @@ Framework-specific testing patterns.
Third-party service and platform security. Third-party service and platform security.
| Skill | Coverage | | Skill | Coverage |
| -------------------- | ---------------------------------- | | ---------- | ------------------------------------------------------ |
| `supabase` | Supabase RLS bypasses, auth issues | | `supabase` | Supabase RLS bypasses, auth issues |
| `firebase_firestore` | Firestore rules, Firebase auth | | `firebase` | Firebase Firestore, Storage rules, Auth, and Functions |
### Protocols ### Protocols
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "strix-agent" name = "strix-agent"
version = "1.5.0" version = "1.5.1"
description = "Open-source AI Hackers for your apps" description = "Open-source AI Hackers for your apps"
readme = "README.md" readme = "README.md"
license = "Apache-2.0" license = "Apache-2.0"
+4 -2
View File
@@ -490,8 +490,10 @@ Default user: pentester (sudo available)
<available_skills> <available_skills>
On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `<specialized_knowledge>` above is already loaded for you. On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `<specialized_knowledge>` above is already loaded for you.
{% for category, names in available_skills | dictsort -%} {% for category, skills in available_skills | dictsort -%}
- {{ category }}: {{ names | join(', ') }} {% for skill in skills -%}
- {{ category }}/{{ skill.name }}{% if skill.description %}: {{ skill.description }}{% endif %}
{% endfor -%}
{% endfor -%} {% endfor -%}
</available_skills> </available_skills>
{% endif %} {% endif %}
@@ -0,0 +1,299 @@
package app
import (
"encoding/json"
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"github.com/usestrix/strix/tui/internal/protocol"
)
func findingsModel(t *testing.T, titles ...string) Model {
t.Helper()
m := New(nil)
m.width, m.height = 130, 30
m.showSplash = false
m.handleEnvelope(stateEnvelope(t, 1, protocol.Snapshot{ScanState: "running"}))
items := make([]json.RawMessage, 0, len(titles))
for i, title := range titles {
items = append(items, rawJSON(t, map[string]any{
"id": string(rune('a' + i)), "title": title, "severity": "high",
}))
}
m.handleEnvelope(protocol.Envelope{Version: protocol.Version, Type: "collection_bootstrap",
Payload: rawJSON(t, protocol.CollectionBootstrap{
Collection: "vulnerabilities", Revision: 1, Cursor: 0,
NextCursor: len(items), Done: true, Items: items,
})})
m.resizeViewport()
return m
}
// The list scrolls by row, not by finding. Stepping a whole entry at a time is
// what made a list of wrapped titles feel paginated.
func TestFindingsScrollByRow(t *testing.T) {
long := "A deliberately long finding title that wraps across several rows in the sidebar"
m := findingsModel(t, long, long, long)
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
if len(rows) <= 3 {
t.Fatalf("titles did not wrap, so this proves nothing: %d rows", len(rows))
}
total, offset := m.vulnerabilityScrollRows()
if total != len(rows) || offset != 0 {
t.Fatalf("scroll metrics are not in rows: total=%d offset=%d rows=%d", total, offset, len(rows))
}
// One step of the offset moves one row, and the first visible line follows it.
first := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0]
m.vulnOffset = 1
second := strings.Split(ansi.Strip(m.vulnerabilitiesView(40, 4)), "\n")[0]
if first == second {
t.Fatalf("advancing one row did not move the list: %q", first)
}
// That row still belongs to the first finding, which an item-stepping list
// would have skipped past entirely.
if got := m.vulnerabilityIndexAtRow(0); got != 0 {
t.Fatalf("one row in, the top line belongs to finding %d, want 0", got)
}
}
// Selecting a finding scrolls the least it can, and never past its own start.
func TestSelectingAFindingBringsItIntoView(t *testing.T) {
long := "A deliberately long finding title that wraps across several rows in the sidebar"
m := findingsModel(t, long, long, long, long)
m.selectedVuln = 3
m.ensureVulnerabilityVisible()
rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
height := m.vulnerabilityPageSize()
end := min(len(rows), m.vulnOffset+height)
found := false
for _, row := range rows[m.vulnOffset:end] {
if row.index == 3 {
found = true
break
}
}
if !found {
t.Fatalf("the selected finding is not on screen: offset=%d height=%d", m.vulnOffset, height)
}
if m.vulnOffset > len(rows)-height && len(rows) > height {
t.Fatalf("scrolled past the end: offset=%d rows=%d height=%d", m.vulnOffset, len(rows), height)
}
}
func reportModel(t *testing.T, count int) Model {
t.Helper()
titles := make([]string, 0, count)
for i := range count {
titles = append(titles, fmt.Sprintf("Finding number %d", i+1))
}
m := findingsModel(t, titles...)
m.openModal(modalVulnerability)
return m
}
// The open report can be stepped through the list without closing it.
func TestReportStepsBetweenFindings(t *testing.T) {
m := reportModel(t, 3)
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyRight})
m = updated.(Model)
if m.selectedVuln != 1 {
t.Fatalf("right moved to %d, want 1", m.selectedVuln)
}
if m.modal != modalVulnerability {
t.Fatal("stepping closed the report")
}
updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
m = updated.(Model)
if m.selectedVuln != 0 {
t.Fatalf("left moved to %d, want 0", m.selectedVuln)
}
}
// The ends do not wrap: rolling from the last report to the first would hide
// that you had reached the end.
func TestReportStepsStopAtTheEnds(t *testing.T) {
m := reportModel(t, 3)
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyLeft})
m = updated.(Model)
if m.selectedVuln != 0 {
t.Fatalf("left from the first report moved to %d, want 0", m.selectedVuln)
}
m.selectedVuln = 2
updated, _ = m.updateModal(tea.KeyMsg{Type: tea.KeyRight})
m = updated.(Model)
if m.selectedVuln != 2 {
t.Fatalf("right from the last report moved to %d, want 2", m.selectedVuln)
}
}
// Each direction is offered only when there is a report that way, and a lone
// finding is offered neither.
func TestReportNavigationHintsFollowAvailability(t *testing.T) {
m := reportModel(t, 3)
for _, testCase := range []struct {
index int
wantPrev, wantNext bool
position string
}{
{index: 0, wantNext: true, position: "1/3"},
{index: 1, wantPrev: true, wantNext: true, position: "2/3"},
{index: 2, wantPrev: true, position: "3/3"},
} {
m.selectedVuln = testCase.index
view := ansi.Strip(m.modalView())
if !strings.Contains(view, testCase.position) {
t.Fatalf("report %d does not show %q", testCase.index, testCase.position)
}
if got := strings.Contains(view, reportPrev); got != testCase.wantPrev {
t.Fatalf("report %d prev hint = %v, want %v", testCase.index, got, testCase.wantPrev)
}
if got := strings.Contains(view, reportNext); got != testCase.wantNext {
t.Fatalf("report %d next hint = %v, want %v", testCase.index, got, testCase.wantNext)
}
}
lone := reportModel(t, 1)
view := ansi.Strip(lone.modalView())
if strings.Contains(view, reportPrev) || strings.Contains(view, reportNext) || strings.Contains(view, "1/1") {
t.Fatalf("a lone finding offered navigation:\n%s", view)
}
}
// A new report opens at its top, and the copy state does not carry over.
func TestSteppingResetsTheReportView(t *testing.T) {
m := reportModel(t, 3)
m.vulnerabilityCopied = true
m.vulnViewport.SetYOffset(3)
m.showVulnerability(1)
if m.vulnViewport.YOffset != 0 {
t.Fatalf("the next report opened scrolled to %d", m.vulnViewport.YOffset)
}
if m.vulnerabilityCopied {
t.Fatal("the copy state carried over to another report")
}
}
// Prev and Next are buttons, not just key hints: they can be clicked.
func TestReportStepButtonsAreClickable(t *testing.T) {
m := reportModel(t, 3)
m.selectedVuln = 1
click := func(label string) Model {
t.Helper()
view := m.modalView()
left, top, _, _ := m.centeredViewBounds(view)
for row, line := range strings.Split(view, "\n") {
plain := ansi.Strip(line)
index := strings.Index(plain, label)
if index < 0 {
continue
}
updated, _ := m.updateModalMouse(tea.MouseMsg{
X: left + ansi.StringWidth(plain[:index]) + 1, Y: top + row,
Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
})
return updated.(Model)
}
t.Fatalf("%q was not rendered", label)
return m
}
if got := click(reportNext).selectedVuln; got != 2 {
t.Fatalf("clicking Next selected %d, want 2", got)
}
if got := click(reportPrev).selectedVuln; got != 0 {
t.Fatalf("clicking Prev selected %d, want 0", got)
}
if got := click(reportNext).modal; got != modalVulnerability {
t.Fatalf("clicking Next closed the report: modal=%v", got)
}
}
// Tab walks the whole row, so the step buttons are reachable from the keyboard
// as well, and Enter presses whichever one is focused.
func TestTabReachesTheStepButtons(t *testing.T) {
m := reportModel(t, 3)
m.selectedVuln = 1
if got := m.focusedReportButton(); got != reportDone {
t.Fatalf("the report opened focused on %q, want %q", got, reportDone)
}
seen := map[string]bool{}
for range len(m.reportButtons()) {
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(Model)
seen[m.focusedReportButton()] = true
}
for _, want := range []string{reportPrev, reportNext, reportCopy, reportDone} {
if !seen[want] {
t.Fatalf("tab never reached %q: %v", want, seen)
}
}
// Enter on a focused step button steps.
m.reportFocus = reportNext
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
if got := updated.(Model).selectedVuln; got != 2 {
t.Fatalf("enter on Next selected %d, want 2", got)
}
}
// Stepping to an end drops that button from the row; focus must not be stranded
// on it.
func TestFocusFallsBackWhenAStepButtonDisappears(t *testing.T) {
m := reportModel(t, 2)
m.selectedVuln = 0
m.reportFocus = reportNext
updated, _ := m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(Model)
if m.selectedVuln != 1 {
t.Fatalf("enter on Next selected %d, want 1", m.selectedVuln)
}
// Next is gone at the last report, so the focus cannot still be on it.
if got := m.focusedReportButton(); got == reportNext {
t.Fatalf("focus stayed on a button that is no longer shown: %q", got)
}
if got := m.focusedReportButton(); got != reportDone {
t.Fatalf("focus fell back to %q, want %q", got, reportDone)
}
}
// The list must be laid out at one width. Rendering at one and hit-testing at
// another gives two different row counts for the same title, and then a click
// resolves to the wrong finding and the scrollbar reports the wrong length.
func TestFindingsUseOneWidthForRenderAndInteraction(t *testing.T) {
// This title wraps to one row at 21 columns and two at 20, which is exactly
// the pair of widths the two paths used to disagree on.
m := findingsModel(t, "ffffff dddd a a a a", "eeeee eeeee a a a a", "header dddd a a a a")
width := m.vulnerabilityListWidth()
rows := m.vulnerabilityRows(width)
rendered := strings.Split(ansi.Strip(m.vulnerabilitiesView(width, len(rows))), "\n")
if len(rendered) != len(rows) {
t.Fatalf("rendered %d rows, interaction counts %d", len(rendered), len(rows))
}
for row := range rendered {
if got := m.vulnerabilityIndexAtRow(row); got != rows[row].index {
t.Fatalf("row %d shows finding %d but a click resolves to %d",
row, rows[row].index, got)
}
}
if total, _ := m.vulnerabilityScrollRows(); total != len(rendered) {
t.Fatalf("the scrollbar reports %d rows, %d are rendered", total, len(rendered))
}
}
+10 -5
View File
@@ -110,6 +110,7 @@ type Model struct {
agentOffset int agentOffset int
vulnOffset int vulnOffset int
modalChoice int modalChoice int
reportFocus string
ready bool ready bool
quitting bool quitting bool
showSplash bool showSplash bool
@@ -157,12 +158,16 @@ const (
treeCursorBg = lipgloss.Color("#0178d4") treeCursorBg = lipgloss.Color("#0178d4")
) )
// Scrollbar thumbs. Each panel keeps its own, and the track stays blank so a // Scrollbar thumbs. The track stays blank so a scrollable panel does not gain a
// scrollable panel does not gain a visible rule down its edge. // visible rule down its edge, and the thumb brightens while it is dragged, which
// is the feedback Textual gave through scrollbar-color-active.
//
// One resting color for every panel, rather than the three the stylesheet named.
// The chat pane's was #1a1a1a on black, which is invisible - the bar could not be
// found, let alone grabbed (#1005).
const ( const (
thumbTrace = lipgloss.Color("#1a1a1a") thumbResting = lipgloss.Color("#3f3f46")
thumbAgents = lipgloss.Color("#404040") thumbActive = lipgloss.Color("#9ca3af")
thumbFindings = lipgloss.Color("#333333")
) )
// Composer placeholders. The launch screen falls back to the short prompt when // Composer placeholders. The launch screen falls back to the short prompt when
@@ -527,7 +527,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
} }
model := newModel() model := newModel()
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyLeft}) // Tab moves between the buttons; the arrows step between reports.
updated, _ := model.updateModal(tea.KeyMsg{Type: tea.KeyTab})
model = updated.(Model) model = updated.(Model)
updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) updated, cmd := model.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model) model = updated.(Model)
@@ -560,8 +561,8 @@ func TestVulnerabilityCopySupportsKeyboardAndMouse(t *testing.T) {
X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: copyX, Y: copyY, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress,
}) })
model = updated.(Model) model = updated.(Model)
if cmd == nil || model.modalChoice != 0 { if cmd == nil || model.reportFocus != reportCopy {
t.Fatalf("mouse Copy was not activated: choice=%d cmd=%v", model.modalChoice, cmd) t.Fatalf("mouse Copy was not activated: focus=%q cmd=%v", model.reportFocus, cmd)
} }
cmd() cmd()
if len(copied) != 2 { if len(copied) != 2 {
@@ -820,8 +821,8 @@ func TestRunningViewerShowsCompleteWrappedURL(t *testing.T) {
} }
func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) { func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbAgents)), "\n") top := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 0, thumbResting)), "\n")
bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbAgents)), "\n") bottom := strings.Split(ansi.Strip(verticalScrollbar(6, 24, 6, 18, thumbResting)), "\n")
// The track is blank, so only the thumb is drawn. // The track is blank, so only the thumb is drawn.
if top[0] != "█" || top[5] != " " { if top[0] != "█" || top[5] != " " {
@@ -830,10 +831,10 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
if bottom[0] != " " || bottom[5] != "█" { if bottom[0] != " " || bottom[5] != "█" {
t.Fatalf("bottom scrollbar is incorrect: %#v", bottom) t.Fatalf("bottom scrollbar is incorrect: %#v", bottom)
} }
if full := verticalScrollbar(4, 4, 4, 0, thumbAgents); full != "" { if full := verticalScrollbar(4, 4, 4, 0, thumbResting); full != "" {
t.Fatalf("non-overflowing scrollbar should be hidden: %q", full) t.Fatalf("non-overflowing scrollbar should be hidden: %q", full)
} }
withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbAgents)) withoutBar := ansi.Strip(withVerticalScrollbar("content", 12, 2, 2, 2, 0, thumbResting))
if strings.ContainsAny(withoutBar, "█") { if strings.ContainsAny(withoutBar, "█") {
t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar) t.Fatalf("non-overflowing panel rendered a scrollbar: %q", withoutBar)
} }
@@ -841,7 +842,7 @@ func TestVerticalScrollbarThumbTracksScrollOffset(t *testing.T) {
// The bar takes exactly one column, so a scrolling panel keeps the rest. // The bar takes exactly one column, so a scrolling panel keeps the rest.
func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) { func TestVerticalScrollbarOccupiesOneColumn(t *testing.T) {
rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbTrace), "\n") rows := strings.Split(withVerticalScrollbar("content", 12, 2, 24, 2, 0, thumbResting), "\n")
for _, row := range rows { for _, row := range rows {
if width := ansi.StringWidth(row); width != 12 { if width := ansi.StringWidth(row); width != 12 {
t.Fatalf("scrolling panel row width = %d, want 12", width) t.Fatalf("scrolling panel row width = %d, want 12", width)
+78 -31
View File
@@ -219,7 +219,8 @@ func (m Model) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight: case vulnHeight > 0 && y < viewerHeight+agentHeight+vulnHeight:
m.focus = focusVulnerabilities m.focus = focusVulnerabilities
m.input.Blur() m.input.Blur()
m.vulnOffset = min(max(0, len(m.snapshot.Vulnerabilities)-1), m.vulnOffset+3) totalRows, _ := m.vulnerabilityScrollRows()
m.vulnOffset = min(max(0, totalRows-m.vulnerabilityPageSize()), m.vulnOffset+3)
m.keepVulnerabilitySelectionInWindow() m.keepVulnerabilitySelectionInWindow()
} }
return m, nil return m, nil
@@ -318,24 +319,7 @@ func (m *Model) updateMainScrollbarMouse(
if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft { if msg.Action != tea.MouseActionPress || msg.Button != tea.MouseButtonLeft {
return false return false
} }
target := m.scrollbarAt(msg, showSidebar, chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight)
target := scrollbarNone
switch {
case msg.X == chatWidth-2 && msg.Y >= 1 && msg.Y < chatHeight-1 &&
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
target = scrollbarTrace
case showSidebar && msg.X == m.width-3 && msg.Y >= viewerHeight+2 &&
msg.Y < viewerHeight+agentHeight-2 &&
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
target = scrollbarAgents
case showSidebar && vulnHeight > 0 && msg.X == m.width-3 &&
msg.Y >= viewerHeight+agentHeight+1 &&
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
totalRows, _ := m.vulnerabilityScrollRows()
if totalRows > m.vulnerabilityPageSize() {
target = scrollbarFindings
}
}
if target == scrollbarNone { if target == scrollbarNone {
return false return false
} }
@@ -344,6 +328,40 @@ func (m *Model) updateMainScrollbarMouse(
return true return true
} }
// scrollbarGrab is how far either side of the bar still counts as grabbing it. A
// one column target is unreasonable to hit with a mouse, and nothing else lives
// in the column beside it.
const scrollbarGrab = 1
func nearColumn(x, column int) bool {
return x >= column-scrollbarGrab && x <= column+scrollbarGrab
}
// scrollbarAt reports which scrollbar, if any, the pointer is over.
func (m Model) scrollbarAt(
msg tea.MouseMsg,
showSidebar bool,
chatWidth, chatHeight, viewerHeight, agentHeight, vulnHeight int,
) scrollbarTarget {
switch {
case nearColumn(msg.X, chatWidth-2) && msg.Y >= 1 && msg.Y < chatHeight-1 &&
m.viewport.TotalLineCount() > m.viewport.VisibleLineCount():
return scrollbarTrace
case showSidebar && nearColumn(msg.X, m.width-3) && msg.Y >= viewerHeight+2 &&
msg.Y < viewerHeight+agentHeight-2 &&
len(agentTreeEntries(m.snapshot.Agents, m.collapsedAgents)) > m.agentPageSize():
return scrollbarAgents
case showSidebar && vulnHeight > 0 && nearColumn(msg.X, m.width-3) &&
msg.Y >= viewerHeight+agentHeight+1 &&
msg.Y < viewerHeight+agentHeight+vulnHeight-1:
totalRows, _ := m.vulnerabilityScrollRows()
if totalRows > m.vulnerabilityPageSize() {
return scrollbarFindings
}
}
return scrollbarNone
}
func (m *Model) scrollFromMouse( func (m *Model) scrollFromMouse(
target scrollbarTarget, target scrollbarTarget,
y, chatHeight, viewerHeight, agentHeight int, y, chatHeight, viewerHeight, agentHeight int,
@@ -367,10 +385,10 @@ func (m *Model) scrollFromMouse(
case scrollbarFindings: case scrollbarFindings:
height := m.vulnerabilityPageSize() height := m.vulnerabilityPageSize()
totalRows, _ := m.vulnerabilityScrollRows() totalRows, _ := m.vulnerabilityScrollRows()
rowOffset := scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
m.focus = focusVulnerabilities m.focus = focusVulnerabilities
m.input.Blur() m.input.Blur()
m.vulnOffset = m.vulnerabilityOffsetAtRow(rowOffset) // The offset is a row, so dragging moves the list continuously.
m.vulnOffset = scrollbarOffset(y-viewerHeight-agentHeight-1, height, totalRows, height)
m.keepVulnerabilitySelectionInWindow() m.keepVulnerabilitySelectionInWindow()
} }
} }
@@ -405,6 +423,22 @@ func (m Model) updateSetupMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// pressReportButton performs a button of the report row, however it was reached.
func (m Model) pressReportButton(button string) (tea.Model, tea.Cmd) {
switch button {
case reportPrev:
m.showVulnerability(m.selectedVuln - 1)
case reportNext:
m.showVulnerability(m.selectedVuln + 1)
case reportCopy:
m.reportFocus = reportCopy
return m, m.startVulnerabilityCopy()
default:
m.closeModal()
}
return m, nil
}
func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modal == modalVulnerability { if m.modal == modalVulnerability {
view := m.modalView() view := m.modalView()
@@ -441,13 +475,22 @@ func (m Model) updateModalMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter}) return m.updateModal(tea.KeyMsg{Type: tea.KeyEnter})
} }
case modalVulnerability: case modalVulnerability:
for _, button := range m.reportButtons() {
if button == reportCopy || button == reportDone {
continue
}
if m.centeredLabelHit(view, button, msg.X, msg.Y) {
m.reportFocus = button
return m.pressReportButton(button)
}
}
if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) { if m.centeredLabelHit(view, "Copy", msg.X, msg.Y) {
m.modalChoice = 0 m.reportFocus = reportCopy
cmd := m.startVulnerabilityCopy() cmd := m.startVulnerabilityCopy()
return m, cmd return m, cmd
} }
if m.centeredLabelHit(view, "Done", msg.X, msg.Y) { if m.centeredLabelHit(view, "Done", msg.X, msg.Y) {
m.modalChoice = 1 m.reportFocus = reportDone
m.closeModal() m.closeModal()
} }
} }
@@ -516,16 +559,19 @@ func (m Model) updateModal(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() { switch key.String() {
case "esc": case "esc":
m.closeModal() m.closeModal()
case "left", "right", "tab", "shift+tab": // The arrows step between reports directly; tab walks the button row.
m.modalChoice = 1 - m.modalChoice case "left":
m.showVulnerability(m.selectedVuln - 1)
case "right":
m.showVulnerability(m.selectedVuln + 1)
case "tab":
m.stepReportFocus(1)
case "shift+tab":
m.stepReportFocus(-1)
case "enter": case "enter":
if m.modalChoice == 0 { return m.pressReportButton(m.focusedReportButton())
cmd := m.startVulnerabilityCopy()
return m, cmd
}
m.closeModal()
case "c": case "c":
m.modalChoice = 0 m.reportFocus = reportCopy
cmd := m.startVulnerabilityCopy() cmd := m.startVulnerabilityCopy()
return m, cmd return m, cmd
case "up": case "up":
@@ -584,6 +630,7 @@ func (m *Model) openModal(mode modalMode) {
m.modalChoice = 1 m.modalChoice = 1
} }
if mode == modalVulnerability { if mode == modalVulnerability {
m.reportFocus = reportDone
m.modalChoice = 1 m.modalChoice = 1
m.vulnerabilityCopied = false m.vulnerabilityCopied = false
m.vulnerabilityCopyError = "" m.vulnerabilityCopyError = ""
+14 -10
View File
@@ -164,6 +164,15 @@ func wrapBlock(value string, width int) string {
return strings.Join(out, "\n") return strings.Join(out, "\n")
} }
// scrollbarThumb brightens the bar being dragged so the grab reads as taking
// hold of it.
func (m Model) scrollbarThumb(target scrollbarTarget) lipgloss.Color {
if m.draggingScrollbar == target {
return thumbActive
}
return thumbResting
}
func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string { func verticalScrollbar(height, total, visible, offset int, thumb lipgloss.Color) string {
if height <= 0 || total <= visible { if height <= 0 || total <= visible {
return "" return ""
@@ -424,7 +433,7 @@ func (m Model) renderChatPane(width, height int, border lipgloss.Color) string {
m.viewport.TotalLineCount(), m.viewport.TotalLineCount(),
m.viewport.VisibleLineCount(), m.viewport.VisibleLineCount(),
m.viewport.YOffset, m.viewport.YOffset,
thumbTrace, m.scrollbarThumb(scrollbarTrace),
) )
out := lipgloss.NewStyle().Width(width).Height(height). out := lipgloss.NewStyle().Width(width).Height(height).
Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace) Border(lipgloss.RoundedBorder()).BorderForeground(border).Render(trace)
@@ -489,7 +498,7 @@ func (m Model) sidebarView(width, height int) string {
len(agentEntries), len(agentEntries),
agentRows, agentRows,
m.agentOffset, m.agentOffset,
thumbAgents, m.scrollbarThumb(scrollbarAgents),
) )
parts := []string{ parts := []string{
lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)), lipgloss.NewStyle().Width(width-2).Height(m.viewerHeight()-2).Border(lipgloss.RoundedBorder()).BorderForeground(dark).Padding(0, 1).Render(m.viewerView(width - 4)),
@@ -503,13 +512,13 @@ func (m Model) sidebarView(width, height int) string {
vulnRows := max(1, vulnHeight-2) vulnRows := max(1, vulnHeight-2)
totalRows, offsetRows := m.vulnerabilityScrollRows() totalRows, offsetRows := m.vulnerabilityScrollRows()
findings := withVerticalScrollbar( findings := withVerticalScrollbar(
m.vulnerabilitiesView(max(1, width-5), vulnRows), m.vulnerabilitiesView(m.vulnerabilityListWidth(), vulnRows),
width-4, width-4,
vulnRows, vulnRows,
totalRows, totalRows,
vulnRows, vulnRows,
offsetRows, offsetRows,
thumbFindings, m.scrollbarThumb(scrollbarFindings),
) )
parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings)) parts = append(parts, lipgloss.NewStyle().Width(width-2).Height(vulnRows).Border(lipgloss.RoundedBorder()).BorderForeground(vulnBorder).Padding(0, 1).Render(findings))
} }
@@ -524,12 +533,7 @@ func (m Model) sidebarHeights() (statsHeight, vulnHeight, agentHeight int) {
statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView())) statsRows := lipgloss.Height(lipgloss.NewStyle().Width(m.viewerContentWidth()).Render(m.statsView()))
statsHeight = min(15, statsRows+2) statsHeight = min(15, statsRows+2)
if len(m.snapshot.Vulnerabilities) > 0 { if len(m.snapshot.Vulnerabilities) > 0 {
rows := 0 vulnHeight = min(12, len(m.vulnerabilityRows(m.vulnerabilityListWidth()))+2)
width := m.vulnerabilityListWidth()
for i := range m.snapshot.Vulnerabilities {
rows += len(m.vulnerabilityTitleLines(i, width))
}
vulnHeight = min(12, rows+2)
} }
agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight) agentHeight = max(3, m.height-m.viewerHeight()-statsHeight-vulnHeight)
return return
@@ -1,6 +1,7 @@
package app package app
import ( import (
"fmt"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -13,115 +14,123 @@ var panelSeverityColors = map[string]lipgloss.Color{
"critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue, "critical": render.SevCrit, "high": render.SevHigh, "medium": render.SevMed, "low": green, "info": blue,
} }
func (m Model) vulnerabilitiesView(width, height int) string { // vulnerabilityRow is one rendered line of the findings list. The list scrolls by
var lines []string // row rather than by finding, so a long title does not make the panel jump a
start := min(max(0, m.vulnOffset), max(0, len(m.snapshot.Vulnerabilities)-1)) // whole entry at a time.
for i := start; i < len(m.snapshot.Vulnerabilities) && len(lines) < height; i++ { type vulnerabilityRow struct {
vuln := m.snapshot.Vulnerabilities[i] index int // the finding this line belongs to
severity := strings.ToLower(render.StringValue(vuln["severity"])) text string // one wrapped line of its title
color, ok := panelSeverityColors[severity] first bool // the line that carries the number and the severity dot
if !ok { }
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
// vulnerabilityRows lays every finding out as the lines it will occupy.
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 line, text := range strings.Split(wrapBlock(m.vulnerabilityTitle(i), body), "\n") {
rows = append(rows, vulnerabilityRow{index: i, text: text, first: line == 0})
} }
marker := lipgloss.NewStyle().Foreground(color).Render("● ") }
return rows
}
func (m Model) vulnerabilitiesView(width, height int) string {
rows := m.vulnerabilityRows(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))
for _, row := range rows[start:end] {
style := lipgloss.NewStyle().Foreground(textColor) style := lipgloss.NewStyle().Foreground(textColor)
if i == m.selectedVuln { if row.index == m.selectedVuln {
style = style.Bold(true).Foreground(white) style = style.Bold(true).Foreground(white)
} }
for row, titleLine := range m.vulnerabilityTitleLines(i, width) { prefix := " "
if len(lines) >= height { if row.first {
break severity := strings.ToLower(render.StringValue(m.snapshot.Vulnerabilities[row.index]["severity"]))
color, ok := panelSeverityColors[severity]
if !ok {
color = blue // matches SEVERITY_COLORS.get(severity, "#3b82f6")
} }
prefix := " " prefix = lipgloss.NewStyle().Foreground(color).Render(" ")
if row == 0 {
prefix = marker
}
lines = append(lines, prefix+style.Render(titleLine))
} }
lines = append(lines, prefix+style.Render(row.text))
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
// vulnerabilityListWidth is the one width the findings list is laid out at, for
// rendering and for every interaction alike. Wrapping a title at two widths a
// column apart gives two different row counts, and then a click resolves to the
// wrong finding and the scrollbar reports the wrong length.
//
// The panel is sidebarWidth-2 wide with a column of padding either side, and the
// scrollbar takes one more. That last column is reserved whether or not the bar
// is showing, so the layout does not shift as the list grows past the panel.
func (m Model) vulnerabilityListWidth() int { func (m Model) vulnerabilityListWidth() int {
_, sidebarWidth, _, _ := m.layout() _, sidebarWidth, _, _ := m.layout()
return max(1, sidebarWidth-6) return max(1, sidebarWidth-5)
} }
func (m Model) vulnerabilityTitleLines(index, width int) []string { func (m Model) vulnerabilityTitle(index int) string {
title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"]) title := render.StringValue(m.snapshot.Vulnerabilities[index]["title"])
if title == "" { if title == "" {
title = "Unknown Vulnerability" title = "Unknown Vulnerability"
} }
return strings.Split(wrapBlock(title, max(1, width-2)), "\n") return title
} }
// vulnerabilityScrollRows reports the list length and position in rows, which is
// what the scrollbar needs to move continuously.
func (m Model) vulnerabilityScrollRows() (total, offset int) { func (m Model) vulnerabilityScrollRows() (total, offset int) {
width := m.vulnerabilityListWidth() return len(m.vulnerabilityRows(m.vulnerabilityListWidth())), m.vulnOffset
for i := range m.snapshot.Vulnerabilities {
rows := len(m.vulnerabilityTitleLines(i, width))
total += rows
if i < m.vulnOffset {
offset += rows
}
}
return total, offset
}
func (m Model) vulnerabilityOffsetAtRow(targetRow int) int {
width := m.vulnerabilityListWidth()
row := 0
for i := range m.snapshot.Vulnerabilities {
row += len(m.vulnerabilityTitleLines(i, width))
if targetRow < row {
return i
}
}
return max(0, len(m.snapshot.Vulnerabilities)-1)
}
func (m Model) vulnerabilityVisibleEnd(start int) int {
height := m.vulnerabilityPageSize()
width := m.vulnerabilityListWidth()
rows := 0
end := min(max(0, start), len(m.snapshot.Vulnerabilities))
for end < len(m.snapshot.Vulnerabilities) {
itemRows := len(m.vulnerabilityTitleLines(end, width))
if rows > 0 && rows+itemRows > height {
break
}
rows += itemRows
end++
if rows >= height {
break
}
}
return end
} }
// vulnerabilityIndexAtRow maps a click on a visible row back to its finding.
func (m Model) vulnerabilityIndexAtRow(row int) int { func (m Model) vulnerabilityIndexAtRow(row int) int {
width := m.vulnerabilityListWidth() rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
currentRow := 0 target := m.vulnOffset + row
for i := m.vulnOffset; i < m.vulnerabilityVisibleEnd(m.vulnOffset); i++ { if target < 0 || target >= len(rows) {
currentRow += len(m.vulnerabilityTitleLines(i, width)) return -1
if row < currentRow {
return i
}
} }
return -1 return rows[target].index
} }
// ensureVulnerabilityVisible scrolls the least it can to bring the selected
// finding into view, keeping the whole entry visible where it fits.
func (m *Model) ensureVulnerabilityVisible() { func (m *Model) ensureVulnerabilityVisible() {
if len(m.snapshot.Vulnerabilities) == 0 { rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
if len(rows) == 0 {
m.vulnOffset = 0 m.vulnOffset = 0
return return
} }
if m.selectedVuln < m.vulnOffset { height := m.vulnerabilityPageSize()
m.vulnOffset = m.selectedVuln firstRow, lastRow := -1, -1
for row, entry := range rows {
if entry.index != m.selectedVuln {
continue
}
if firstRow < 0 {
firstRow = row
}
lastRow = row
} }
for m.selectedVuln >= m.vulnerabilityVisibleEnd(m.vulnOffset) && m.vulnOffset < m.selectedVuln { if firstRow < 0 {
m.vulnOffset++ m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
return
} }
m.vulnOffset = min(m.vulnOffset, len(m.snapshot.Vulnerabilities)-1) if firstRow < m.vulnOffset {
m.vulnOffset = firstRow
} else if lastRow >= m.vulnOffset+height {
// Prefer showing the whole entry, but never scroll its start out of view.
m.vulnOffset = min(firstRow, lastRow-height+1)
}
m.vulnOffset = clampVulnerabilityOffset(m.vulnOffset, len(rows), height)
}
func clampVulnerabilityOffset(offset, total, height int) int {
return min(max(0, offset), max(0, total-height))
} }
func (m Model) vulnerabilityPageSize() int { func (m Model) vulnerabilityPageSize() int {
@@ -129,23 +138,52 @@ func (m Model) vulnerabilityPageSize() int {
return max(1, vulnHeight-2) return max(1, vulnHeight-2)
} }
// vulnerabilityPageItems is how many findings a page step should move by: the
// number of distinct entries currently on screen.
func (m Model) vulnerabilityPageItems() int { func (m Model) vulnerabilityPageItems() int {
return max(1, m.vulnerabilityVisibleEnd(m.vulnOffset)-m.vulnOffset) rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
height := m.vulnerabilityPageSize()
start := min(max(0, m.vulnOffset), max(0, len(rows)))
end := min(len(rows), start+height)
seen := 0
previous := -1
for _, row := range rows[start:end] {
if row.index != previous {
seen++
previous = row.index
}
}
return max(1, seen)
} }
func (m *Model) moveVulnerabilitySelection(delta int) { func (m *Model) moveVulnerabilitySelection(delta int) {
m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta)) m.selectedVuln = max(0, min(len(m.snapshot.Vulnerabilities)-1, m.selectedVuln+delta))
} }
// keepVulnerabilitySelectionInWindow pulls the selection to the nearest finding
// still on screen after the list has been scrolled directly.
func (m *Model) keepVulnerabilitySelectionInWindow() { func (m *Model) keepVulnerabilitySelectionInWindow() {
if len(m.snapshot.Vulnerabilities) == 0 { rows := m.vulnerabilityRows(m.vulnerabilityListWidth())
if len(rows) == 0 {
return return
} }
if m.selectedVuln < m.vulnOffset { height := m.vulnerabilityPageSize()
m.selectedVuln = m.vulnOffset start := min(max(0, m.vulnOffset), max(0, len(rows)-1))
} else if end := m.vulnerabilityVisibleEnd(m.vulnOffset); m.selectedVuln >= end { end := min(len(rows), start+height)
m.selectedVuln = max(m.vulnOffset, end-1) visible := rows[start:end]
if len(visible) == 0 {
return
} }
for _, row := range visible {
if row.index == m.selectedVuln {
return
}
}
if m.selectedVuln < visible[0].index {
m.selectedVuln = visible[0].index
return
}
m.selectedVuln = visible[len(visible)-1].index
} }
// statsView ports build_tui_stats_text + the version line appended in // statsView ports build_tui_stats_text + the version line appended in
@@ -373,25 +411,116 @@ func (m Model) vulnerabilityDetail() string {
inner := max(1, width-8) inner := max(1, width-8)
// Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons). // Button row: right-aligned Copy / Done above a top rule (#vuln_detail_buttons).
rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner))) rule := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a1a")).Render(strings.Repeat("─", max(1, inner)))
copyLabel := "Copy" focused := m.focusedReportButton()
if m.vulnerabilityCopied { var stepping, acting []string
copyLabel = "Copied!" for _, button := range m.reportButtons() {
} else if m.vulnerabilityCopyError != "" { rendered := m.reportButton(button, button == focused)
copyLabel = "Copy failed" if button == reportPrev || button == reportNext {
stepping = append(stepping, rendered)
continue
}
acting = append(acting, rendered)
} }
copyButton := lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")) // Stepping sits on the left behind the position, acting on the right.
doneButton := lipgloss.NewStyle().Foreground(mid) right := strings.Join(acting, " ")
if m.modalChoice == 0 { left := strings.Join(stepping, " ")
copyButton = copyButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1) if total := len(m.snapshot.Vulnerabilities); total > 1 {
} else { left = render.Dim().Render(fmt.Sprintf("%d/%d", m.selectedVuln+1, total)) + " " + left
doneButton = doneButton.Background(lipgloss.Color("#363636")).Foreground(brightWhite).Bold(true).Padding(0, 1)
} }
buttons := copyButton.Render(copyLabel) + " " + doneButton.Render("Done") room := max(0, inner-lipgloss.Width(right))
buttonRow := rule + "\n" + lipgloss.NewStyle().Width(inner).Align(lipgloss.Right).Render(buttons) buttonRow := rule + "\n" +
lipgloss.NewStyle().Width(room).Render(truncate(left, room)) + right
content := m.vulnerabilityScrollView() + "\n" + buttonRow 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) 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)
} }
// showVulnerability moves the open report to another finding, keeping the list
// behind it in step and starting the new report at its top.
func (m *Model) showVulnerability(index int) {
if index < 0 || index >= len(m.snapshot.Vulnerabilities) || index == m.selectedVuln {
return
}
m.selectedVuln = index
m.ensureVulnerabilityVisible()
// The copy state belongs to the report that was on screen, not this one.
m.vulnerabilityCopied = false
m.vulnerabilityCopyError = ""
m.resizeVulnerabilityViewport()
m.vulnViewport.GotoTop()
}
// 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"
)
// reportButtons is the row as it stands, left to right. Stepping is offered only
// in the directions that have a report.
func (m Model) reportButtons() []string {
previous, next := m.vulnerabilityNeighbors()
buttons := make([]string, 0, 4)
if previous {
buttons = append(buttons, reportPrev)
}
if next {
buttons = append(buttons, reportNext)
}
return append(buttons, reportCopy, reportDone)
}
// focusedReportButton is the button Enter would press. It falls back to Done when
// the focused one has gone, which happens when stepping to either end drops a
// direction from the row.
func (m Model) focusedReportButton() string {
for _, button := range m.reportButtons() {
if button == m.reportFocus {
return button
}
}
return reportDone
}
// stepReportFocus moves along the row, wrapping at its ends.
func (m *Model) stepReportFocus(delta int) {
buttons := m.reportButtons()
current := 0
for i, button := range buttons {
if button == m.focusedReportButton() {
current = i
}
}
m.reportFocus = buttons[clampCycle(current+delta, len(buttons))]
}
// vulnerabilityNeighbors reports which way the open report can be stepped. The
// 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
}
// reportButton renders one button of the report row. Copy reports the outcome of
// the last attempt in its own label.
func (m Model) reportButton(label string, focused bool) string {
if label == reportCopy {
switch {
case m.vulnerabilityCopied:
label = "Copied!"
case m.vulnerabilityCopyError != "":
label = "Copy failed"
}
}
if focused {
return lipgloss.NewStyle().Background(lipgloss.Color("#363636")).
Foreground(brightWhite).Bold(true).Padding(0, 1).Render(label)
}
return lipgloss.NewStyle().Foreground(lipgloss.Color("#525252")).Render(label)
}
func (m *Model) startVulnerabilityCopy() tea.Cmd { func (m *Model) startVulnerabilityCopy() tea.Cmd {
m.vulnerabilityCopied = false m.vulnerabilityCopied = false
m.vulnerabilityCopyError = "" m.vulnerabilityCopyError = ""
+71 -8
View File
@@ -4,6 +4,9 @@ import threading
from collections import Counter from collections import Counter
from collections.abc import Iterator from collections.abc import Iterator
from pathlib import Path from pathlib import Path
from typing import TypeGuard
import yaml
from strix.telemetry import posthog, scarf from strix.telemetry import posthog, scarf
from strix.utils.resource_paths import get_strix_resource_path from strix.utils.resource_paths import get_strix_resource_path
@@ -11,12 +14,17 @@ from strix.utils.resource_paths import get_strix_resource_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) _FRONTMATTER_PATTERN = re.compile(r"^---\s*\n(?P<body>.*?)\n---\s*\n", re.DOTALL)
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"}) _INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
_ROOT_SKILL_CATEGORY = "root" _ROOT_SKILL_CATEGORY = "root"
_EXTRA_SKILL_DIRS: list[Path] = [] _EXTRA_SKILL_DIRS: list[Path] = []
_SKILL_METADATA_CACHE: dict[tuple[Path, int, int], dict[str, str]] = {}
def _is_frontmatter_mapping(value: object) -> TypeGuard[dict[object, object]]:
return isinstance(value, dict)
def register_skill_dir(path: str | Path) -> None: def register_skill_dir(path: str | Path) -> None:
@@ -109,13 +117,18 @@ def _get_ambiguous_skill_names() -> set[str]:
return {name for name, count in counts.items() if count > 1} return {name for name, count in counts.items() if count > 1}
def _qualified_skill_files(skill_name: str) -> list[Path]: def _qualified_skill_file_for_name(skill_name: str) -> Path | None:
category, _, name = skill_name.partition("/") category, _, name = skill_name.partition("/")
for skills_dir in skill_search_dirs(): for skills_dir in skill_search_dirs():
candidate = _qualified_skill_file(skills_dir, category, name) candidate = _qualified_skill_file(skills_dir, category, name)
if candidate is not None: if candidate is not None:
return [candidate] return candidate
return [] return None
def _qualified_skill_files(skill_name: str) -> list[Path]:
candidate = _qualified_skill_file_for_name(skill_name)
return [candidate] if candidate is not None else []
def _bare_skill_files(skill_name: str) -> list[Path]: def _bare_skill_files(skill_name: str) -> list[Path]:
@@ -145,10 +158,59 @@ def _bare_skill_files(skill_name: str) -> list[Path]:
return candidates return candidates
def get_available_skills() -> dict[str, list[str]]: def _parse_skill_content(content: str, source: Path | None = None) -> tuple[dict[str, str], str]:
grouped: dict[str, list[str]] = {} """Parse skill frontmatter once and return metadata plus markdown body."""
frontmatter = _FRONTMATTER_PATTERN.match(content)
if frontmatter is None:
return {}, content.lstrip()
try:
parsed: object = yaml.safe_load(frontmatter.group("body"))
except yaml.YAMLError as error:
logger.warning("Failed to parse skill frontmatter %s: %s", source or "<content>", error)
parsed = None
if not _is_frontmatter_mapping(parsed):
logger.warning("Skill frontmatter is not a mapping: %s", source or "<content>")
return {}, content[frontmatter.end() :].lstrip()
metadata = {str(key): "" if value is None else str(value) for key, value in parsed.items()}
return metadata, content[frontmatter.end() :].lstrip()
def _read_skill_metadata(file_path: Path) -> dict[str, str]:
try:
stat = file_path.stat()
except OSError:
logger.warning("Skill file disappeared while reading metadata: %s", file_path)
return {}
cache_key = (file_path, stat.st_mtime_ns, stat.st_size)
cached = _SKILL_METADATA_CACHE.get(cache_key)
if cached is not None:
return cached
try:
content = file_path.read_text(encoding="utf-8")
except (OSError, ValueError):
logger.warning("Failed to read skill metadata: %s", file_path)
return {}
metadata, _ = _parse_skill_content(content, file_path)
_SKILL_METADATA_CACHE[cache_key] = metadata
return metadata
def get_available_skills() -> dict[str, list[dict[str, str]]]:
grouped: dict[str, list[dict[str, str]]] = {}
for category, name in _iter_user_skill_files(): for category, name in _iter_user_skill_files():
grouped.setdefault(category, []).append(name) file_path = _qualified_skill_file_for_name(f"{category}/{name}")
if file_path is None:
logger.warning(
"Skill disappeared while gathering available skills: %s/%s",
category,
name,
)
continue
metadata = _read_skill_metadata(file_path)
description = " ".join(metadata.get("description", "").split())
grouped.setdefault(category, []).append({"name": name, "description": description})
return grouped return grouped
@@ -228,7 +290,8 @@ def load_skills(skill_names: list[str]) -> dict[str, str]:
continue continue
var_name = skill_name.split("/")[-1] var_name = skill_name.split("/")[-1]
skill_content[var_name] = _FRONTMATTER_PATTERN.sub("", content).lstrip() _, skill_body = _parse_skill_content(content, file_path)
skill_content[var_name] = skill_body
logger.debug("Loaded skill: %s -> %s", skill_name, var_name) logger.debug("Loaded skill: %s -> %s", skill_name, var_name)
_track_skill_loaded(var_name, file_path) _track_skill_loaded(var_name, file_path)
+1 -1
View File
@@ -16,7 +16,7 @@ GCP misconfigurations expose project data, service account keys, and lateral mov
**Storage & Data** **Storage & Data**
- Cloud Storage (GCS) buckets and objects - Cloud Storage (GCS) buckets and objects
- BigQuery datasets, Cloud SQL instances, Firestore (see `firebase_firestore` skill) - BigQuery datasets, Cloud SQL instances, Firestore (see `firebase` skill)
- Secret Manager, Cloud KMS keys - Secret Manager, Cloud KMS keys
**Compute** **Compute**
@@ -1,9 +1,9 @@
--- ---
name: firebase-firestore name: firebase
description: Firebase/Firestore security testing covering security rules, Cloud Functions, and client-side trust issues description: Firebase security testing covering Firestore, Storage rules, Realtime Database, Auth, Functions, and client-side trust issues
--- ---
# Firebase / Firestore # Firebase
Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation. Security testing for Firebase applications. Focus on Firestore/Realtime Database rules, Cloud Storage exposure, callable/onRequest Functions trusting client input, and incorrect ID token validation.
@@ -30,7 +30,17 @@ Security testing for Firebase applications. Focus on Firestore/Realtime Database
**Endpoints** **Endpoints**
- Firestore REST: `https://firestore.googleapis.com/v1/projects/<project>/databases/(default)/documents/<path>` - Firestore REST: `https://firestore.googleapis.com/v1/projects/<project>/databases/(default)/documents/<path>`
- Realtime DB: `https://<project>.firebaseio.com/.json` - Realtime DB: `https://<project>.firebaseio.com/.json`
- Storage REST: `https://storage.googleapis.com/storage/v1/b/<bucket>` - GCS JSON API: `https://storage.googleapis.com/storage/v1/b/<bucket>`
- Firebase Storage rules API: `https://firebasestorage.googleapis.com/v0/b/<bucket>/o`
Cloud Storage has two front doors with different authorization engines:
| Front door | Authorization engine |
| --- | --- |
| `storage.googleapis.com/<bucket>/<object>` and `/storage/v1/b/<bucket>` | GCS IAM and per-object ACLs |
| `firebasestorage.googleapis.com/v0/b/<bucket>/o` | Firebase Storage Security Rules |
A `403` from a GCS URL does not prove that Firebase Storage rules deny access. Always test both doors.
**Auth** **Auth**
- Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/<project>`) - Google-signed ID tokens (iss: `accounts.google.com` or `securetoken.google.com/<project>`)
@@ -117,9 +127,43 @@ exists(/databases/(default)/documents/orgs/$(org)/members/$(request.auth.uid))
- Public reads on sensitive buckets/paths - Public reads on sensitive buckets/paths
- Signed URLs with long TTL, no content-disposition controls, replayable across tenants - Signed URLs with long TTL, no content-disposition controls, replayable across tenants
- List operations exposed: `/o?prefix=` enumerates object keys - List operations exposed: `/o?prefix=` enumerates object keys
- Firebase Storage rules allowing unauthenticated or overly broad reads and writes
**Firebase Storage rules checks**
Probe the rules door separately from GCS IAM and ACLs:
1. Unauthenticated list: `GET https://firebasestorage.googleapis.com/v0/b/<bucket>/o?prefix=<known-prefix>`
2. Unauthenticated read of a known object path
3. Unauthenticated write/upload to a uniquely named test object
4. Repeat list, read, and write as an anonymous-auth principal when anonymous sign-in is enabled
5. Repeat the same matrix as a low-privilege authenticated user
Write access is as important as read access and is routinely missed. Record status, response body, and object existence after each attempt; clean up only test objects that the test principal created.
Review rules source when present and flag:
- `allow read, write: if request.time < timestamp.date(...)` — the common console test-mode time gate
- `{allPaths=**}` catch-alls
- `request.auth != null` as the sole authorization gate
- Claim-presence checks such as `request.auth.token.roles.size() > 0` without role or tenant validation
Storage rules use OR-across-matches semantics: a later permissive match can reopen a path that an earlier match denied. Review every matching path, not only the most specific-looking deny.
**Bucket discovery**
- Extract `storageBucket` from `firebase.apps[0].options` and `NEXT_PUBLIC_FIREBASE_*` values in JavaScript bundles and source.
- Check `<project>.appspot.com` and `<project>.firebasestorage.app` bucket conventions.
**ACL and IAM checks are separate**
- Sweep object ACLs for `allUsers` and `allAuthenticatedUsers`, including objects made public by Admin SDK `makePublic()` or writers using `public: true`. Per-object public ACLs persist after Firebase rules are tightened and can remain on older prefixes.
- Check bucket IAM for `allUsers` and `allAuthenticatedUsers`.
- Check whether Uniform Bucket-Level Access is disabled; legacy object ACLs matter when it is off.
- Account for CDN caching of previously public objects; cache-bust when verifying a revocation.
**Tests** **Tests**
- GET gs:// paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment` - GET GCS object paths via HTTPS without auth; verify Content-Type and `Content-Disposition: attachment`
- Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants - Generate and reuse signed URLs across accounts and paths; try case/URL-encoding variants
- Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution - Upload HTML/SVG and verify `X-Content-Type-Options: nosniff`; check for script execution
@@ -189,12 +233,19 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
## Testing Methodology ## Testing Methodology
1. **Extract config** - Get project config from client bundle 1. **Extract config** - Get project and storage bucket config from client bundles and source
2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, admin 2. **Obtain principals** - Collect tokens for unauth, anonymous, user A/B, and admin where authorized
3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions 3. **Build matrix** - Resource × Action × Principal across Firestore/Realtime/Storage/Functions
4. **SDK vs REST** - Exercise every action via both to detect parity gaps 4. **Exercise both Storage doors** - Test Firebase Storage rules endpoints separately from GCS IAM/ACL URLs
5. **Seed IDs** - Start from list/query paths to gather document IDs 5. **SDK vs REST** - Exercise every action via both to detect parity gaps
6. **Cross-principal** - Swap document paths, tenants, and user IDs across principals 6. **Seed IDs** - Start from list/query paths to gather document and object paths
7. **Cross-principal** - Swap document paths, tenants, and user IDs across principals
## Whitebox Rules Review
- Inspect `firebase.json`, `.firebaserc`, deployment scripts, CI configuration, and infrastructure code for `storage.rules` / `firestore.rules` declarations.
- If `firebase.json` has no `storage` or `firestore` block, or the referenced rules file is absent from the tree, treat the live rules as unmanaged and force the live probe matrix. Absence of rules IaC is itself a finding; never conclude that there is nothing to review.
- Correlate configured rule files with deployed project and bucket identifiers. A source rule file for a different project does not establish live protection.
## Tooling ## Tooling
@@ -206,6 +257,7 @@ Apps often implement multi-tenant data models (`orgs/<orgId>/...`). Bind tenant
## Validation Requirements ## Validation Requirements
- Owner vs non-owner Firestore queries showing unauthorized access or metadata leak - Owner vs non-owner Firestore queries showing unauthorized access or metadata leak
- Cloud Storage read/write beyond intended scope (public object, signed URL reuse, list exposure) - Firebase Storage unauthenticated, anonymous, or low-privilege read/list/write beyond intended scope, with minimal reproducible requests and observed deltas
- GCS object ACL or bucket IAM access beyond intended scope, including public object persistence after rules changes
- Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId` - Function accepting forged/foreign identity (wrong `aud`/`iss`) or trusting client `uid`/`orgId`
- Minimal reproducible requests with roles/tokens used and observed deltas - Minimal reproducible requests with roles/tokens used and observed deltas
+121 -7
View File
@@ -1,8 +1,10 @@
from collections.abc import Iterator
from pathlib import Path from pathlib import Path
import pytest import pytest
import strix.skills as skills_mod import strix.skills as skills_mod
from strix.agents.prompt import render_system_prompt
from strix.skills import ( from strix.skills import (
get_all_skill_names, get_all_skill_names,
get_available_skills, get_available_skills,
@@ -12,10 +14,11 @@ from strix.skills import (
skill_search_dirs, skill_search_dirs,
validate_requested_skills, validate_requested_skills,
) )
from strix.utils.resource_paths import get_strix_resource_path
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _clear_extra_dirs() -> None: def _clear_extra_dirs() -> Iterator[None]:
original = list(skills_mod._EXTRA_SKILL_DIRS) original = list(skills_mod._EXTRA_SKILL_DIRS)
skills_mod._EXTRA_SKILL_DIRS.clear() skills_mod._EXTRA_SKILL_DIRS.clear()
try: try:
@@ -37,9 +40,11 @@ def _write_root_skill(root: Path, name: str, body: str) -> None:
def test_no_registration_leaves_builtin_only() -> None: def test_no_registration_leaves_builtin_only() -> None:
assert registered_skill_dirs() == () assert registered_skill_dirs() == ()
builtin = skills_mod.get_strix_resource_path("skills") builtin = get_strix_resource_path("skills")
assert skill_search_dirs() == (builtin,) assert skill_search_dirs() == (builtin,)
assert {"nmap", "subfinder"}.issubset(get_available_skills()["tooling"]) assert {"nmap", "subfinder"}.issubset(
{skill["name"] for skill in get_available_skills()["tooling"]}
)
def test_register_is_idempotent_and_ordered(tmp_path: Path) -> None: def test_register_is_idempotent_and_ordered(tmp_path: Path) -> None:
@@ -61,16 +66,125 @@ def test_registered_dir_adds_new_skill(tmp_path: Path) -> None:
register_skill_dir(tmp_path) register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names() assert "widget" in get_all_skill_names()
assert get_available_skills()["extra"] == ["widget"] assert get_available_skills()["extra"] == [{"name": "widget", "description": ""}]
assert load_skills(["widget"]) == {"widget": "widget body"} assert load_skills(["widget"]) == {"widget": "widget body"}
def test_available_skill_includes_frontmatter_description(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"widget",
"---\nname: widget\ndescription: Useful widget guidance\n---\nwidget body",
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [
{"name": "widget", "description": "Useful widget guidance"}
]
def test_available_skill_supports_colon_in_description(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"widget",
'---\nname: widget\ndescription: "Useful widget: handles YAML"\n---\nwidget body',
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [
{"name": "widget", "description": "Useful widget: handles YAML"}
]
def test_available_skill_normalizes_quoted_description(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"widget",
'---\nname: widget\ndescription: "Useful: widget guidance"\n---\nwidget body',
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [
{"name": "widget", "description": "Useful: widget guidance"}
]
def test_available_skill_normalizes_multiline_descriptions(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"block",
"---\nname: block\n\ndescription: |\n"
" First paragraph\n\n Second paragraph\n\n---\nblock body",
)
_write_skill(
tmp_path,
"extra",
"plain",
"---\nname: plain\n\ndescription: First line\n Second line\n\n---\nplain body",
)
register_skill_dir(tmp_path)
available = {skill["name"]: skill["description"] for skill in get_available_skills()["extra"]}
assert available == {
"block": "First paragraph Second paragraph",
"plain": "First line Second line",
}
def test_available_skill_supports_block_scalar_trailing_comment(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"commented",
"---\nname: commented\ndescription: | # paragraph\n"
" First line\n Second line\n---\ncommented body",
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [
{"name": "commented", "description": "First line Second line"}
]
def test_malformed_frontmatter_keeps_skill_body(tmp_path: Path) -> None:
_write_skill(
tmp_path,
"extra",
"broken",
"---\nname: [broken\ndescription: should be empty\n---\nbroken body",
)
register_skill_dir(tmp_path)
assert get_available_skills()["extra"] == [{"name": "broken", "description": ""}]
assert load_skills(["extra/broken"]) == {"broken": "broken body"}
def test_system_prompt_renders_skill_descriptions() -> None:
prompt = render_system_prompt(scan_mode="quick", is_root=True)
assert "- technologies/firebase: Firebase security testing covering" in prompt
def test_system_prompt_omits_empty_skill_description(tmp_path: Path) -> None:
_write_skill(tmp_path, "extra", "widget", "---\nname: widget\ndescription:\n---\nwidget body")
register_skill_dir(tmp_path)
prompt = render_system_prompt(scan_mode="quick", is_root=True)
assert "- extra/widget\n" in prompt
assert "- extra/widget: " not in prompt
def test_registered_root_skill_is_discoverable_and_valid(tmp_path: Path) -> None: def test_registered_root_skill_is_discoverable_and_valid(tmp_path: Path) -> None:
_write_root_skill(tmp_path, "widget", "widget body") _write_root_skill(tmp_path, "widget", "widget body")
register_skill_dir(tmp_path) register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names() assert "widget" in get_all_skill_names()
assert get_available_skills()["root"] == ["widget"] assert get_available_skills()["root"] == [{"name": "widget", "description": ""}]
assert validate_requested_skills(["widget"]) is None assert validate_requested_skills(["widget"]) is None
assert validate_requested_skills(["root/widget"]) is None assert validate_requested_skills(["root/widget"]) is None
assert load_skills(["widget"]) == {"widget": "widget body"} assert load_skills(["widget"]) == {"widget": "widget body"}
@@ -83,8 +197,8 @@ def test_ambiguous_bare_skill_requires_qualified_name(tmp_path: Path) -> None:
register_skill_dir(tmp_path) register_skill_dir(tmp_path)
assert "widget" in get_all_skill_names() assert "widget" in get_all_skill_names()
assert get_available_skills()["alpha"] == ["widget"] assert get_available_skills()["alpha"] == [{"name": "widget", "description": ""}]
assert get_available_skills()["beta"] == ["widget"] assert get_available_skills()["beta"] == [{"name": "widget", "description": ""}]
assert validate_requested_skills(["alpha/widget"]) is None assert validate_requested_skills(["alpha/widget"]) is None
assert validate_requested_skills(["beta/widget"]) is None assert validate_requested_skills(["beta/widget"]) is None
Generated
+1 -1
View File
@@ -2378,7 +2378,7 @@ wheels = [
[[package]] [[package]]
name = "strix-agent" name = "strix-agent"
version = "1.5.0" version = "1.5.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "caido-sdk-client" }, { name = "caido-sdk-client" },