on main help sign in
ci(sec): add more security jobs, improve build, enable race detector (#1849)

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

commit3fd9d970149e706149c47e86f3d007512472ff12 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateTue 13 Jan 2026, 14:34 (9 months ago) parent87df08c
11 files changed+199−48
.github/workflows/build.yml+22−6 .github/workflows/security.yml+88 Taskfile.yaml+6−4 go.mod+1−1 go.sum+2−2 internal/csync/maps_test.go+4−3 internal/permission/permission.go+11−2 internal/permission/permission_test.go+2−2 internal/shell/background.go+36−7 internal/shell/background_test.go+8−8 internal/tui/styles/theme.go+19−13
.github/workflows/build.yml+22−6
@@ -1,11 +1,27 @@
11name: build
22on: [push, pull_request]
33
4+permissions:
5+ contents: read
6+
7+concurrency:
8+ group: build-${{ github.event.pull_request.number || github.ref }}
9+ cancel-in-progress: true
10+
411jobs:
512 build:
6− uses: charmbracelet/meta/.github/workflows/build.yml@main
7− with:
8− go-version: ""
9− go-version-file: ./go.mod
10− secrets:
11− gh_pat: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
13+ strategy:
14+ matrix:
15+ os: [ubuntu-latest, macos-latest, windows-latest]
16+ runs-on: ${{ matrix.os }}
17+ steps:
18+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
19+ with:
20+ persist-credentials: false
21+ - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
22+ with:
23+ go-version-file: go.mod
24+ - run: go mod tidy
25+ - run: git diff --exit-code
26+ - run: go build -race ./...
27+ - run: go test -race -failfast ./...
.github/workflows/security.ymlnew file+88
@@ -0,0 +1,88 @@
1+name: "security"
2+
3+on:
4+ pull_request:
5+ push:
6+ branches: [main]
7+ schedule:
8+ - cron: "0 2 * * *"
9+
10+permissions:
11+ contents: read
12+
13+concurrency:
14+ group: security-${{ github.event.pull_request.number || github.ref }}
15+ cancel-in-progress: true
16+
17+jobs:
18+ codeql:
19+ runs-on: ubuntu-latest
20+ strategy:
21+ fail-fast: false
22+ matrix:
23+ language: ["go", "actions"]
24+ permissions:
25+ actions: read
26+ contents: read
27+ pull-requests: read
28+ security-events: write
29+ steps:
30+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
31+ with:
32+ persist-credentials: false
33+ - uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
34+ with:
35+ languages: ${{ matrix.language }}
36+ - uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
37+ - uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
38+
39+ grype:
40+ runs-on: ubuntu-latest
41+ permissions:
42+ security-events: write
43+ actions: read
44+ contents: read
45+ steps:
46+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
47+ with:
48+ persist-credentials: false
49+ - uses: anchore/scan-action@40a61b52209e9d50e87917c5b901783d546b12d0 # v7.2.1
50+ id: scan
51+ with:
52+ path: "."
53+ fail-build: true
54+ severity-cutoff: critical
55+ - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
56+ with:
57+ sarif_file: ${{ steps.scan.outputs.sarif }}
58+
59+ govulncheck:
60+ runs-on: ubuntu-latest
61+ permissions:
62+ security-events: write
63+ contents: read
64+ steps:
65+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
66+ with:
67+ persist-credentials: false
68+ - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
69+ with:
70+ output-format: sarif
71+ output-file: results.sarif
72+ - uses: github/codeql-action/upload-sarif@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
73+ with:
74+ sarif_file: results.sarif
75+
76+ dependency-review:
77+ runs-on: ubuntu-latest
78+ if: github.event_name == 'pull_request'
79+ permissions:
80+ contents: read
81+ steps:
82+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
83+ with:
84+ persist-credentials: false
85+ - uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
86+ with:
87+ fail-on-severity: critical
88+ allow-licenses: BSD-2-Clause, BSD-3-Clause, MIT, Apache-2.0, MPL-2.0, ISC
Taskfile.yaml+6−4
@@ -5,6 +5,8 @@ version: "3"
55vars:
66 VERSION:
77 sh: git describe --long 2>/dev/null || echo ""
8+ RACE:
9+ sh: test -f race.log && echo "1" || echo ""
810
911env:
1012 CGO_ENABLED: 0
@@ -37,20 +39,20 @@ tasks:
3739 vars:
3840 LDFLAGS: '{{if .VERSION}}-ldflags="-X github.com/charmbracelet/crush/internal/version.Version={{.VERSION}}"{{end}}'
3941 cmds:
40− - go build {{.LDFLAGS}} .
42+ - "go build {{if .RACE}}-race{{end}} {{.LDFLAGS}} ."
4143 generates:
4244 - crush
4345
4446 run:
4547 desc: Run build
4648 cmds:
47− - go build -o crush .
48− - ./crush {{.CLI_ARGS}}
49+ - task: build
50+ - "./crush {{.CLI_ARGS}} {{if .RACE}}2>race.log{{end}}"
4951
5052 test:
5153 desc: Run tests
5254 cmds:
53− - go test ./... {{.CLI_ARGS}}
55+ - go test -race -failfast ./... {{.CLI_ARGS}}
5456
5557 test:record:
5658 desc: Run tests and record all VCR cassettes again
go.mod+1−1
@@ -29,7 +29,7 @@ require (
2929 github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f
3030 github.com/charmbracelet/x/exp/ordered v0.1.0
3131 github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff
32− github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
32+ github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b
3333 github.com/charmbracelet/x/term v0.2.2
3434 github.com/denisbrodbeck/machineid v1.0.1
3535 github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
go.sum+2−2
@@ -118,8 +118,8 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff h1:Uwr+/
118118github.com/charmbracelet/x/exp/slice v0.0.0-20251201173703-9f73bfd934ff/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
119119github.com/charmbracelet/x/json v0.2.0 h1:DqB+ZGx2h+Z+1s98HOuOyli+i97wsFQIxP2ZQANTPrQ=
120120github.com/charmbracelet/x/json v0.2.0/go.mod h1:opFIflx2YgXgi49xVUu8gEQ21teFAxyMwvOiZhIvWNM=
121−github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
122−github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
121+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b h1:5ye9hzBKH623bMVz5auIuY6K21loCdxpRmFle2O9R/8=
122+github.com/charmbracelet/x/powernap v0.0.0-20260113142046-c1fa3de7983b/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
123123github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
124124github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
125125github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
internal/csync/maps_test.go+4−3
@@ -4,6 +4,7 @@ import (
44 "encoding/json"
55 "maps"
66 "sync"
7+ "sync/atomic"
78 "testing"
89 "testing/synctest"
910 "time"
@@ -46,12 +47,12 @@ func TestNewLazyMap(t *testing.T) {
4647
4748 waiter := sync.Mutex{}
4849 waiter.Lock()
49− loadCalled := false
50+ var loadCalled atomic.Bool
5051
5152 loadFunc := func() map[string]int {
5253 waiter.Lock()
5354 defer waiter.Unlock()
54− loadCalled = true
55+ loadCalled.Store(true)
5556 return map[string]int{
5657 "key1": 1,
5758 "key2": 2,
@@ -63,7 +64,7 @@ func TestNewLazyMap(t *testing.T) {
6364
6465 waiter.Unlock() // Allow the load function to proceed
6566 time.Sleep(100 * time.Millisecond)
66− require.True(t, loadCalled)
67+ require.True(t, loadCalled.Load())
6768 require.Equal(t, 2, m.Len())
6869
6970 value, ok := m.Get("key1")
internal/permission/permission.go+11−2
@@ -68,8 +68,9 @@ type permissionService struct {
6868 allowedTools []string
6969
7070 // used to make sure we only process one request at a time
71− requestMu sync.Mutex
72− activeRequest *PermissionRequest
71+ requestMu sync.Mutex
72+ activeRequest *PermissionRequest
73+ activeRequestMu sync.Mutex
7374}
7475
7576func (s *permissionService) GrantPersistent(permission PermissionRequest) {
@@ -86,9 +87,11 @@ func (s *permissionService) GrantPersistent(permission PermissionRequest) {
8687 s.sessionPermissions = append(s.sessionPermissions, permission)
8788 s.sessionPermissionsMu.Unlock()
8889
90+ s.activeRequestMu.Lock()
8991 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
9092 s.activeRequest = nil
9193 }
94+ s.activeRequestMu.Unlock()
9295}
9396
9497func (s *permissionService) Grant(permission PermissionRequest) {
@@ -101,9 +104,11 @@ func (s *permissionService) Grant(permission PermissionRequest) {
101104 respCh <- true
102105 }
103106
107+ s.activeRequestMu.Lock()
104108 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
105109 s.activeRequest = nil
106110 }
111+ s.activeRequestMu.Unlock()
107112}
108113
109114func (s *permissionService) Deny(permission PermissionRequest) {
@@ -117,9 +122,11 @@ func (s *permissionService) Deny(permission PermissionRequest) {
117122 respCh <- false
118123 }
119124
125+ s.activeRequestMu.Lock()
120126 if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
121127 s.activeRequest = nil
122128 }
129+ s.activeRequestMu.Unlock()
123130}
124131
125132func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRequest) (bool, error) {
@@ -190,7 +197,9 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
190197 }
191198 s.sessionPermissionsMu.RUnlock()
192199
200+ s.activeRequestMu.Lock()
193201 s.activeRequest = &permission
202+ s.activeRequestMu.Unlock()
194203
195204 respCh := make(chan bool, 1)
196205 s.pendingRequests.Set(permission.ID, respCh)
internal/permission/permission_test.go+2−2
@@ -189,7 +189,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
189189 events := service.Subscribe(t.Context())
190190
191191 var wg sync.WaitGroup
192− results := make([]bool, 0)
192+ results := make([]bool, 3)
193193
194194 requests := []CreatePermissionRequest{
195195 {
@@ -220,7 +220,7 @@ func TestPermissionService_SequentialProperties(t *testing.T) {
220220 go func(index int, request CreatePermissionRequest) {
221221 defer wg.Done()
222222 result, _ := service.Request(t.Context(), request)
223− results = append(results, result)
223+ results[index] = result
224224 }(i, req)
225225 }
226226
internal/shell/background.go+36−7
@@ -19,6 +19,30 @@ const (
1919 CompletedJobRetentionMinutes = 8 * 60
2020)
2121
22+// syncBuffer is a thread-safe wrapper around bytes.Buffer.
23+type syncBuffer struct {
24+ buf bytes.Buffer
25+ mu sync.RWMutex
26+}
27+
28+func (sb *syncBuffer) Write(p []byte) (n int, err error) {
29+ sb.mu.Lock()
30+ defer sb.mu.Unlock()
31+ return sb.buf.Write(p)
32+}
33+
34+func (sb *syncBuffer) WriteString(s string) (n int, err error) {
35+ sb.mu.Lock()
36+ defer sb.mu.Unlock()
37+ return sb.buf.WriteString(s)
38+}
39+
40+func (sb *syncBuffer) String() string {
41+ sb.mu.RLock()
42+ defer sb.mu.RUnlock()
43+ return sb.buf.String()
44+}
45+
2246// BackgroundShell represents a shell running in the background.
2347type BackgroundShell struct {
2448 ID string
@@ -28,8 +52,8 @@ type BackgroundShell struct {
2852 WorkingDir string
2953 ctx context.Context
3054 cancel context.CancelFunc
31− stdout *bytes.Buffer
32− stderr *bytes.Buffer
55+ stdout *syncBuffer
56+ stderr *syncBuffer
3357 done chan struct{}
3458 exitErr error
3559 completedAt int64 // Unix timestamp when job completed (0 if still running)
@@ -46,12 +70,17 @@ var (
4670 idCounter atomic.Uint64
4771)
4872
73+// newBackgroundShellManager creates a new BackgroundShellManager instance.
74+func newBackgroundShellManager() *BackgroundShellManager {
75+ return &BackgroundShellManager{
76+ shells: csync.NewMap[string, *BackgroundShell](),
77+ }
78+}
79+
4980// GetBackgroundShellManager returns the singleton background shell manager.
5081func GetBackgroundShellManager() *BackgroundShellManager {
5182 backgroundManagerOnce.Do(func() {
52− backgroundManager = &BackgroundShellManager{
53− shells: csync.NewMap[string, *BackgroundShell](),
54− }
83+ backgroundManager = newBackgroundShellManager()
5584 })
5685 return backgroundManager
5786}
@@ -80,8 +109,8 @@ func (m *BackgroundShellManager) Start(ctx context.Context, workingDir string, b
80109 Shell: shell,
81110 ctx: shellCtx,
82111 cancel: cancel,
83− stdout: &bytes.Buffer{},
84− stderr: &bytes.Buffer{},
112+ stdout: &syncBuffer{},
113+ stderr: &syncBuffer{},
85114 done: make(chan struct{}),
86115 }
87116
internal/shell/background_test.go+8−8
@@ -14,7 +14,7 @@ func TestBackgroundShellManager_Start(t *testing.T) {
1414
1515 ctx := context.Background()
1616 workingDir := t.TempDir()
17− manager := GetBackgroundShellManager()
17+ manager := newBackgroundShellManager()
1818
1919 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'hello world'", "")
2020 if err != nil {
@@ -51,7 +51,7 @@ func TestBackgroundShellManager_Get(t *testing.T) {
5151
5252 ctx := context.Background()
5353 workingDir := t.TempDir()
54− manager := GetBackgroundShellManager()
54+ manager := newBackgroundShellManager()
5555
5656 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'test'", "")
5757 if err != nil {
@@ -77,7 +77,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
7777
7878 ctx := context.Background()
7979 workingDir := t.TempDir()
80− manager := GetBackgroundShellManager()
80+ manager := newBackgroundShellManager()
8181
8282 // Start a long-running command
8383 bgShell, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
@@ -106,7 +106,7 @@ func TestBackgroundShellManager_Kill(t *testing.T) {
106106func TestBackgroundShellManager_KillNonExistent(t *testing.T) {
107107 t.Parallel()
108108
109− manager := GetBackgroundShellManager()
109+ manager := newBackgroundShellManager()
110110
111111 err := manager.Kill("non-existent-id")
112112 if err == nil {
@@ -119,7 +119,7 @@ func TestBackgroundShell_IsDone(t *testing.T) {
119119
120120 ctx := context.Background()
121121 workingDir := t.TempDir()
122− manager := GetBackgroundShellManager()
122+ manager := newBackgroundShellManager()
123123
124124 bgShell, err := manager.Start(ctx, workingDir, nil, "echo 'quick'", "")
125125 if err != nil {
@@ -142,7 +142,7 @@ func TestBackgroundShell_WithBlockFuncs(t *testing.T) {
142142
143143 ctx := context.Background()
144144 workingDir := t.TempDir()
145− manager := GetBackgroundShellManager()
145+ manager := newBackgroundShellManager()
146146
147147 blockFuncs := []BlockFunc{
148148 CommandsBlocker([]string{"curl", "wget"}),
@@ -180,7 +180,7 @@ func TestBackgroundShellManager_List(t *testing.T) {
180180
181181 ctx := context.Background()
182182 workingDir := t.TempDir()
183− manager := GetBackgroundShellManager()
183+ manager := newBackgroundShellManager()
184184
185185 // Start two shells
186186 bgShell1, err := manager.Start(ctx, workingDir, nil, "sleep 1", "")
@@ -224,7 +224,7 @@ func TestBackgroundShellManager_KillAll(t *testing.T) {
224224
225225 ctx := context.Background()
226226 workingDir := t.TempDir()
227− manager := GetBackgroundShellManager()
227+ manager := newBackgroundShellManager()
228228
229229 // Start multiple long-running shells
230230 shell1, err := manager.Start(ctx, workingDir, nil, "sleep 10", "")
internal/tui/styles/theme.go+19−13
@@ -4,6 +4,7 @@ import (
44 "fmt"
55 "image/color"
66 "strings"
7+ "sync"
78
89 "charm.land/bubbles/v2/filepicker"
910 "charm.land/bubbles/v2/help"
@@ -97,7 +98,8 @@ type Theme struct {
9798 AuthBorderUnselected lipgloss.Style
9899 AuthTextUnselected lipgloss.Style
99100
100− styles *Styles
101+ styles *Styles
102+ stylesOnce sync.Once
101103}
102104
103105type Styles struct {
@@ -134,9 +136,9 @@ type Styles struct {
134136}
135137
136138func (t *Theme) S() *Styles {
137− if t.styles == nil {
139+ t.stylesOnce.Do(func() {
138140 t.styles = t.buildStyles()
139− }
141+ })
140142 return t.styles
141143}
142144
@@ -500,27 +502,31 @@ type Manager struct {
500502 current *Theme
501503}
502504
503−var defaultManager *Manager
505+var (
506+ defaultManager *Manager
507+ defaultManagerOnce sync.Once
508+)
509+
510+func initDefaultManager() *Manager {
511+ defaultManagerOnce.Do(func() {
512+ defaultManager = newManager()
513+ })
514+ return defaultManager
515+}
504516
505517func SetDefaultManager(m *Manager) {
506518 defaultManager = m
507519}
508520
509521func DefaultManager() *Manager {
510− if defaultManager == nil {
511− defaultManager = NewManager()
512− }
513− return defaultManager
522+ return initDefaultManager()
514523}
515524
516525func CurrentTheme() *Theme {
517− if defaultManager == nil {
518− defaultManager = NewManager()
519− }
520− return defaultManager.Current()
526+ return initDefaultManager().Current()
521527}
522528
523−func NewManager() *Manager {
529+func newManager() *Manager {
524530 m := &Manager{
525531 themes: make(map[string]*Theme),
526532 }