on main help sign in
fix: close permission bypass and concurrency/caching bugs

- tools(bash): replace raw-string prefix matching for auto-approved commands with an AST-based check. Only a single simple foreground command with no redirects, backgrounding, chaining, command/process substitution, or inline assignments (e.g. LD_PRELOAD) and a literal argv matching the allowlist is treated as safe read-only. Drop wrapper commands (env/time/timeout/nohup/nice) from the safe list. Previously 'time rm -rf x', 'echo x > file', 'echo hi & rm -rf x', and command substitution all skipped the permission prompt. - csync(map): make GetOrSet consistent — fn runs outside the lock but exactly one value is ever stored and returned, so singleton values stay coherent under concurrent misses. - pubsub(broker): close done under the mutex to stop a double-close panic on concurrent Shutdown; wake per-subscriber goroutines on Shutdown too (was leaking on long-lived contexts); snapshot subscribers and send outside the read lock in PublishMustDeliver so a slow subscriber no longer stalls Subscribe/Shutdown (recover-guarded against concurrent close). Add broker tests (package had none). - tools(view): clamp negative offset to avoid a slice-bounds panic. - ui(sidebar): hash LSP/MCP error message text, not just presence, so a changed error within the same state invalidates the render cache. - agent(coordinator): fold AWS_BEARER_TOKEN_BEDROCK into the provider client cache key so a rotated token rebuilds the client. - permission: drop the vestigial, now-unread activeRequest field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commit3f5454114ec19ad4975abcc50f41943818f7e749 authoriko <mail@iko.soy> dateThu 2 Jul 2026, 03:36 (3 months ago) parent2c77d01
10 files changed+325−108
internal/agent/coordinator.go+8 internal/agent/tools/bash.go+1−13 internal/agent/tools/safe.go+85−22 internal/agent/tools/safe_test.go+51−24 internal/agent/tools/view.go+1−1 internal/csync/maps.go+20−5 internal/permission/permission.go+4−14 internal/pubsub/broker.go+57−25 internal/pubsub/broker_test.go+82 internal/ui/model/sidebar.go+16−4
internal/agent/coordinator.go+8
@@ -1087,6 +1087,14 @@ func (c *coordinator) providerCacheKey(providerCfg config.ProviderConfig, model
10871087 b.WriteByte(0)
10881088 b.WriteString(apiKey)
10891089 b.WriteByte(0)
1090+ // Bedrock falls back to AWS_BEARER_TOKEN_BEDROCK when no apiKey is
1091+ // configured, and that token is baked into the built client. Fold it
1092+ // into the key so a rotated token rebuilds the client instead of
1093+ // reusing a stale one.
1094+ if apiKey == "" && providerCfg.Type == bedrock.Name {
1095+ b.WriteString(os.Getenv("AWS_BEARER_TOKEN_BEDROCK"))
1096+ }
1097+ b.WriteByte(0)
10901098 b.WriteString(baseURL)
10911099 b.WriteByte(0)
10921100 for _, k := range slices.Sorted(maps.Keys(headers)) {
internal/agent/tools/bash.go+1−13
@@ -206,19 +206,7 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
206206 // Determine working directory
207207 execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
208208
209− isSafeReadOnly := false
210− cmdLower := strings.ToLower(params.Command)
211−
212− if !containsCommandChaining(params.Command) {
213− for _, safe := range safeCommands {
214− if strings.HasPrefix(cmdLower, safe) {
215− if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
216− isSafeReadOnly = true
217− break
218− }
219− }
220− }
221− }
209+ isSafeReadOnly := isSafeReadOnly(params.Command)
222210
223211 sessionID := GetSessionFromContext(ctx)
224212 if sessionID == "" {
internal/agent/tools/safe.go+85−22
@@ -2,10 +2,16 @@ package tools
22
33import (
44 "runtime"
5− "slices"
65 "strings"
6+
7+ "mvdan.cc/sh/v3/syntax"
78)
89
10+// safeCommands are read-only commands that can run without a permission
11+// prompt. Entries may be multi-word (e.g. "git log") to scope a
12+// subcommand. Do NOT add wrapper commands that execute an arbitrary
13+// argument (env, time, timeout, nohup, nice, xargs, sh, bash, find -exec,
14+// ...): those would let an unapproved command ride in on a safe prefix.
915var safeCommands = []string{
1016 // Bash builtins and core utils
1117 "cal",
@@ -13,7 +19,6 @@ var safeCommands = []string{
1319 "df",
1420 "du",
1521 "echo",
16− "env",
1722 "free",
1823 "groups",
1924 "hostname",
@@ -21,14 +26,10 @@ var safeCommands = []string{
2126 "kill",
2227 "killall",
2328 "ls",
24− "nice",
25− "nohup",
2629 "printenv",
2730 "ps",
2831 "pwd",
2932 "set",
30− "time",
31− "timeout",
3233 "top",
3334 "type",
3435 "uname",
@@ -58,22 +59,6 @@ var safeCommands = []string{
5859 "git tag",
5960}
6061
61−var chainingMetacharacters = []string{
62− ";",
63− "|",
64− "&&",
65− "$(",
66− "`",
67−}
68−
69−// containsCommandChaining reports whether s contains shell metacharacters
70−// that enable command chaining or substitution.
71−func containsCommandChaining(s string) bool {
72− return slices.ContainsFunc(chainingMetacharacters, func(c string) bool {
73− return strings.Contains(s, c)
74− })
75−}
76−
7762func init() {
7863 if runtime.GOOS == "windows" {
7964 safeCommands = append(
@@ -88,3 +73,81 @@ func init() {
8873 )
8974 }
9075}
76+
77+// isSafeReadOnly reports whether command is a single, simple, read-only
78+// command that can run without a permission prompt.
79+//
80+// It parses the command and only returns true when the whole input is
81+// exactly one simple command (no pipes, chaining, subshells, blocks, or
82+// negation), with no redirections, no backgrounding, no inline variable
83+// assignments (which could inject e.g. LD_PRELOAD), and no command or
84+// process substitution anywhere. The command name (and any subcommand
85+// tokens) must be plain literals matching an entry in safeCommands.
86+//
87+// This replaces raw-string prefix matching, which let wrapper commands
88+// and redirects/chaining smuggle unapproved commands past the gate
89+// (e.g. "time rm -rf x", "echo x > file", "echo hi & rm -rf x").
90+func isSafeReadOnly(command string) bool {
91+ parser := syntax.NewParser()
92+ file, err := parser.Parse(strings.NewReader(command), "")
93+ if err != nil {
94+ return false
95+ }
96+
97+ // Exactly one statement, and it must be a plain foreground command.
98+ if len(file.Stmts) != 1 {
99+ return false
100+ }
101+ stmt := file.Stmts[0]
102+ if stmt.Background || stmt.Negated || len(stmt.Redirs) > 0 {
103+ return false
104+ }
105+ call, ok := stmt.Cmd.(*syntax.CallExpr)
106+ if !ok || len(call.Args) == 0 || len(call.Assigns) > 0 {
107+ return false
108+ }
109+
110+ // Reject any command/process substitution anywhere in the statement:
111+ // those execute arbitrary commands the allowlist never sees.
112+ safe := true
113+ syntax.Walk(file, func(node syntax.Node) bool {
114+ switch node.(type) {
115+ case *syntax.CmdSubst, *syntax.ProcSubst:
116+ safe = false
117+ return false
118+ }
119+ return true
120+ })
121+ if !safe {
122+ return false
123+ }
124+
125+ // Extract literal argv. Non-literal words (quoting, parameter
126+ // expansion, globbing) yield "" via Word.Lit(); the command name and
127+ // any matched subcommand tokens must be plain literals.
128+ argv := make([]string, len(call.Args))
129+ for i, w := range call.Args {
130+ argv[i] = w.Lit()
131+ }
132+ if argv[0] == "" {
133+ return false
134+ }
135+
136+ for _, safeCmd := range safeCommands {
137+ tokens := strings.Fields(safeCmd)
138+ if len(argv) < len(tokens) {
139+ continue
140+ }
141+ match := true
142+ for i, tok := range tokens {
143+ if argv[i] != tok {
144+ match = false
145+ break
146+ }
147+ }
148+ if match {
149+ return true
150+ }
151+ }
152+ return false
153+}
internal/agent/tools/safe_test.go+51−24
@@ -6,7 +6,7 @@ import (
66 "github.com/stretchr/testify/assert"
77)
88
9−func TestContainsCommandChaining(t *testing.T) {
9+func TestIsSafeReadOnly(t *testing.T) {
1010 t.Parallel()
1111
1212 tests := []struct {
@@ -14,34 +14,61 @@ func TestContainsCommandChaining(t *testing.T) {
1414 input string
1515 expected bool
1616 }{
17− {"plain ls", "ls -la", false},
18− {"plain echo", "echo hello world", false},
19− {"plain pwd", "pwd", false},
20− {"plain git status", "git status", false},
21− {"ls with redirect", "ls > /tmp/out", false},
22− {"ls with pipe", "ls | grep foo", true},
23− {"ls with double ampersand", "ls && echo done", true},
24− {"ls with semicolon", "ls; echo done", true},
25− {"ls with pipe pipe", "ls || echo fail", true},
26− {"ls with backticks", "ls `echo foo`", true},
27− {"ls with subshell", "ls $(echo foo)", true},
28− {"ls with background ampersand", "ls & echo done", false},
29− {"rm -rf with && ls (rm first)", "rm -rf / && ls", true},
30− {"redirect with ampersand gt", "ls &> /dev/null", false},
31− {"redirect with gt ampersand", "ls >& /dev/null", false},
32− {"simple kill", "kill 1234", false},
33− {"kill with pipe", "kill 1234 | echo foo", true},
34− {"git log", "git log --oneline", false},
35− {"git log with pipe", "git log | head", true},
36− {"empty string", "", false},
37− {"dollar sign in argument", "echo $HOME", false},
17+ // Plain safe commands.
18+ {"plain ls", "ls -la", true},
19+ {"plain echo", "echo hello world", true},
20+ {"plain pwd", "pwd", true},
21+ {"git status", "git status", true},
22+ {"git log with flags", "git log --oneline", true},
23+ {"git config --get", "git config --get user.name", true},
24+ {"echo with param expansion", "echo $HOME", true},
25+ {"simple kill", "kill 1234", true},
26+
27+ // Not on the allowlist.
28+ {"rm", "rm -rf /tmp/x", false},
29+ {"unknown", "frobnicate", false},
30+ {"git push not allowed", "git push", false},
31+ {"prefix is not a match (lsof)", "lsof", false},
32+
33+ // Chaining / multiple statements.
34+ {"pipe", "ls | grep foo", false},
35+ {"and", "ls && echo done", false},
36+ {"or", "ls || echo fail", false},
37+ {"semicolon", "ls; echo done", false},
38+ {"newline", "echo hi\nrm -rf x", false},
39+ {"background then rm", "echo hi & rm -rf x", false},
40+ {"rm first with and", "rm -rf / && ls", false},
41+
42+ // Substitution.
43+ {"command subst", "ls $(rm -rf x)", false},
44+ {"backticks", "ls `rm -rf x`", false},
45+ {"param default with subst", "echo ${x:-$(rm -rf x)}", false},
46+
47+ // Redirection.
48+ {"redirect out", "echo x > /tmp/out", false},
49+ {"redirect append", "echo x >> ~/.bashrc", false},
50+ {"redirect in", "cat < /etc/passwd", false},
51+
52+ // Wrapper commands must not smuggle an unapproved command through.
53+ {"time wrapper", "time rm -rf x", false},
54+ {"env wrapper", "env FOO=bar rm -rf x", false},
55+ {"timeout wrapper", "timeout 10 rm -rf x", false},
56+ {"nohup wrapper", "nohup rm -rf x", false},
57+ {"nice wrapper", "nice rm -rf x", false},
58+
59+ // Inline assignment (e.g. LD_PRELOAD) is not safe.
60+ {"inline assignment", "LD_PRELOAD=/evil.so ls", false},
61+
62+ // Malformed / empty.
63+ {"empty", "", false},
64+ {"unbalanced quote", "echo \"unterminated", false},
3865 }
3966
4067 for _, tt := range tests {
4168 t.Run(tt.name, func(t *testing.T) {
4269 t.Parallel()
43− got := containsCommandChaining(tt.input)
44− assert.Equal(t, tt.expected, got, "containsCommandChaining(%q)", tt.input)
70+ got := isSafeReadOnly(tt.input)
71+ assert.Equal(t, tt.expected, got, "isSafeReadOnly(%q)", tt.input)
4572 })
4673 }
4774}
internal/agent/tools/view.go+1−1
@@ -461,7 +461,7 @@ func readBuiltinFile(params ViewParams, skillTracker *skills.Tracker) (fantasy.T
461461 }
462462
463463 lines := strings.Split(content, "\n")
464− offset := min(params.Offset, len(lines))
464+ offset := max(0, min(params.Offset, len(lines)))
465465 lines = lines[offset:]
466466
467467 hasMore := len(lines) > limit
internal/csync/maps.go+20−5
@@ -75,15 +75,30 @@ func (m *Map[K, V]) Len() int {
7575 return len(m.inner)
7676}
7777
78−// GetOrSet gets and returns the key if it exists, otherwise, it executes the
79−// given function, set its return value for the given key, and returns it.
78+// GetOrSet gets and returns the value for key if it exists, otherwise it
79+// executes fn, stores its return value for key, and returns it.
80+//
81+// The stored value and the returned value are always the same: if two
82+// goroutines race on a missing key, exactly one fn result is stored and
83+// every caller receives that stored value (the losers discard their own
84+// fn result). This keeps singleton values (a mutex, a client, a cache
85+// entry others mutate) consistent. fn may run more than once under
86+// contention, so it must be side-effect-free and safe to call redundantly.
8087func (m *Map[K, V]) GetOrSet(key K, fn func() V) V {
81− got, ok := m.Get(key)
82− if ok {
88+ if got, ok := m.Get(key); ok {
8389 return got
8490 }
91+ // Compute outside the lock so a slow fn (I/O, parsing) does not
92+ // serialize other map users.
8593 value := fn()
86− m.Set(key, value)
94+ m.mu.Lock()
95+ defer m.mu.Unlock()
96+ // Re-check under the write lock: another goroutine may have won the
97+ // race between our Get above and acquiring the lock.
98+ if got, ok := m.inner[key]; ok {
99+ return got
100+ }
101+ m.inner[key] = value
87102 return value
88103}
89104
internal/permission/permission.go+4−14
@@ -104,10 +104,10 @@ type permissionService struct {
104104 skip atomic.Bool
105105 allowedTools []string
106106
107− // used to make sure we only process one request at a time
108− requestMu sync.Mutex
109− activeRequest *PermissionRequest
110− activeRequestMu sync.Mutex
107+ // requestMu serializes the setup of a permission request (up to the
108+ // point the pending entry is registered); it is released before the
109+ // human wait so parallel requests don't queue behind one another.
110+ requestMu sync.Mutex
111111}
112112
113113// resolve atomically removes the pending request entry for the given
@@ -146,12 +146,6 @@ func (s *permissionService) resolve(permission PermissionRequest, granted, denie
146146 // per request because Take removes the entry under the map lock,
147147 // so this send never blocks.
148148 respCh <- granted
149−
150− s.activeRequestMu.Lock()
151− if s.activeRequest != nil && s.activeRequest.ID == permission.ID {
152− s.activeRequest = nil
153− }
154− s.activeRequestMu.Unlock()
155149 return true
156150}
157151
@@ -265,10 +259,6 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
265259 return nil, "", true, true
266260 }
267261
268− s.activeRequestMu.Lock()
269− s.activeRequest = &permission
270− s.activeRequestMu.Unlock()
271−
272262 respCh := make(chan bool, 1)
273263 s.pendingRequests.Set(permission.ID, respCh)
274264
internal/pubsub/broker.go+57−25
@@ -83,6 +83,12 @@ func (b *Broker[T]) SetMustDeliverTimeout(d time.Duration) {
8383}
8484
8585func (b *Broker[T]) Shutdown() {
86+ b.mu.Lock()
87+ defer b.mu.Unlock()
88+
89+ // Close b.done under the lock so concurrent Shutdown calls (and the
90+ // per-subscriber goroutines, which also close under the lock) cannot
91+ // double-close it and panic.
8692 select {
8793 case <-b.done: // Already closed
8894 return
@@ -90,9 +96,6 @@ func (b *Broker[T]) Shutdown() {
9096 close(b.done)
9197 }
9298
93− b.mu.Lock()
94− defer b.mu.Unlock()
95−
9699 for ch := range b.subs {
97100 delete(b.subs, ch)
98101 close(ch)
@@ -118,11 +121,20 @@ func (b *Broker[T]) Subscribe(ctx context.Context) <-chan Event[T] {
118121 b.subCount++
119122
120123 go func() {
121− <-ctx.Done()
124+ // Wake on either the subscriber's context ending or the broker
125+ // shutting down. Without the b.done case, a subscriber with a
126+ // long-lived context would park here forever after Shutdown,
127+ // leaking the goroutine.
128+ select {
129+ case <-ctx.Done():
130+ case <-b.done:
131+ }
122132
123133 b.mu.Lock()
124134 defer b.mu.Unlock()
125135
136+ // If the broker shut down, it already closed this channel under
137+ // the lock; don't close it again.
126138 select {
127139 case <-b.done:
128140 return
@@ -200,37 +212,57 @@ func (b *Broker[T]) Publish(t EventType, payload T) {
200212// re-fetch on the next session-visible event).
201213func (b *Broker[T]) PublishMustDeliver(ctx context.Context, t EventType, payload T) {
202214 b.mu.RLock()
203− defer b.mu.RUnlock()
204−
205215 select {
206216 case <-b.done:
217+ b.mu.RUnlock()
207218 return
208219 default:
209220 }
210−
211221 event := Event[T]{Type: t, Payload: payload}
212222 timeout := b.mustDeliverTimeout
213−
223+ // Snapshot the subscribers so the bounded-blocking sends below run
224+ // without holding b.mu. Holding the read lock across a blocking send
225+ // would stall Subscribe/Shutdown for up to N×timeout under saturation.
226+ subs := make([]chan Event[T], 0, len(b.subs))
214227 for sub := range b.subs {
215− // Fast path: non-blocking send.
216− select {
217− case sub <- event:
218− continue
219− default:
220− }
228+ subs = append(subs, sub)
229+ }
230+ b.mu.RUnlock()
221231
222− // Slow path: bounded blocking send.
223− timer := time.NewTimer(timeout)
224− select {
225− case sub <- event:
226− timer.Stop()
227− case <-timer.C:
228− b.mustDeliverDropCount.Add(1)
229− slog.Error("PublishMustDeliver timed out delivering event",
230− "type", t, "timeout", timeout)
231− case <-ctx.Done():
232− timer.Stop()
232+ for _, sub := range subs {
233+ if b.mustDeliver(ctx, sub, event, timeout) {
234+ // Context cancelled; stop delivering to the remaining subs.
233235 return
234236 }
235237 }
236238}
239+
240+// mustDeliver performs one bounded-blocking send. It reports whether the
241+// context was cancelled (so the caller stops delivering to remaining
242+// subscribers). Because sends happen without holding b.mu, the channel may
243+// be closed concurrently by Shutdown or an unsubscribe; sending on a closed
244+// channel panics, so we recover and treat that subscriber as gone.
245+func (b *Broker[T]) mustDeliver(ctx context.Context, sub chan Event[T], event Event[T], timeout time.Duration) (ctxDone bool) {
246+ defer func() { _ = recover() }()
247+
248+ // Fast path: non-blocking send.
249+ select {
250+ case sub <- event:
251+ return false
252+ default:
253+ }
254+
255+ // Slow path: bounded blocking send.
256+ timer := time.NewTimer(timeout)
257+ defer timer.Stop()
258+ select {
259+ case sub <- event:
260+ case <-timer.C:
261+ b.mustDeliverDropCount.Add(1)
262+ slog.Error("PublishMustDeliver timed out delivering event",
263+ "type", event.Type, "timeout", timeout)
264+ case <-ctx.Done():
265+ return true
266+ }
267+ return false
268+}
internal/pubsub/broker_test.gonew file+82
@@ -0,0 +1,82 @@
1+package pubsub
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+ "time"
8+)
9+
10+// TestConcurrentShutdown ensures concurrent Shutdown calls don't panic on a
11+// double-close of the done channel.
12+func TestConcurrentShutdown(t *testing.T) {
13+ t.Parallel()
14+ b := NewBroker[int]()
15+ ctx, cancel := context.WithCancel(context.Background())
16+ defer cancel()
17+ for range 8 {
18+ b.Subscribe(ctx)
19+ }
20+
21+ var wg sync.WaitGroup
22+ for range 16 {
23+ wg.Add(1)
24+ go func() {
25+ defer wg.Done()
26+ b.Shutdown()
27+ }()
28+ }
29+ wg.Wait()
30+}
31+
32+// TestPublishMustDeliverDuringShutdown ensures sending outside the lock is
33+// safe when a subscriber channel is closed concurrently.
34+func TestPublishMustDeliverDuringShutdown(t *testing.T) {
35+ t.Parallel()
36+ b := NewBrokerWithOptions[int](1)
37+ b.SetMustDeliverTimeout(time.Millisecond)
38+
39+ ctx, cancel := context.WithCancel(context.Background())
40+ defer cancel()
41+ for range 8 {
42+ b.Subscribe(ctx) // never drained, so buffers fill and sends block
43+ }
44+
45+ var wg sync.WaitGroup
46+ wg.Add(2)
47+ go func() {
48+ defer wg.Done()
49+ for range 50 {
50+ b.PublishMustDeliver(context.Background(), CreatedEvent, 1)
51+ }
52+ }()
53+ go func() {
54+ defer wg.Done()
55+ b.Shutdown()
56+ }()
57+ wg.Wait()
58+}
59+
60+// TestSubscribeGoroutineExitsOnShutdown ensures the per-subscriber goroutine
61+// unblocks on Shutdown even when its context outlives the broker.
62+func TestSubscribeGoroutineExitsOnShutdown(t *testing.T) {
63+ t.Parallel()
64+ b := NewBroker[int]()
65+ // Background context: without the b.done wakeup this goroutine would
66+ // leak forever after Shutdown.
67+ ch := b.Subscribe(context.Background())
68+ b.Shutdown()
69+
70+ select {
71+ case _, ok := <-ch:
72+ if ok {
73+ t.Fatal("expected channel to be closed after Shutdown")
74+ }
75+ case <-time.After(time.Second):
76+ t.Fatal("subscriber channel not closed after Shutdown")
77+ }
78+
79+ if got := b.GetSubscriberCount(); got != 0 {
80+ t.Fatalf("subscriber count = %d, want 0", got)
81+ }
82+}
internal/ui/model/sidebar.go+16−4
@@ -178,8 +178,11 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle) uint64 {
178178 for _, name := range lspNames {
179179 st := m.lspStates[name]
180180 counts := m.com.Workspace.LSPGetDiagnosticCounts(name)
181− fmt.Fprintf(h, "l=%s|%v|err=%t|%d/%d/%d/%d;",
182− st.Name, st.State, st.Error != nil,
181+ // Hash the error text, not just its presence: the render prints
182+ // the message, so a changed message within the same state must
183+ // invalidate the cache.
184+ fmt.Fprintf(h, "l=%s|%v|err=%s|%d/%d/%d/%d;",
185+ st.Name, st.State, errText(st.Error),
183186 counts.Error, counts.Warning, counts.Hint, counts.Information)
184187 }
185188
@@ -189,8 +192,8 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle) uint64 {
189192 if !ok {
190193 continue
191194 }
192− fmt.Fprintf(h, "m=%s|%v|err=%t|%d/%d/%d;",
193− state.Name, state.State, state.Error != nil,
195+ fmt.Fprintf(h, "m=%s|%v|err=%s|%d/%d/%d;",
196+ state.Name, state.State, errText(state.Error),
194197 state.Counts.Tools, state.Counts.Prompts, state.Counts.Resources)
195198 }
196199
@@ -202,6 +205,15 @@ func (m *UI) sidebarFingerprint(area uv.Rectangle) uint64 {
202205 return h.Sum64()
203206}
204207
208+// errText returns err's message, or "" when nil. Used to fold error text
209+// into cache fingerprints.
210+func errText(err error) string {
211+ if err == nil {
212+ return ""
213+ }
214+ return err.Error()
215+}
216+
205217// sidebar renders the chat sidebar containing session title, working
206218// directory, model info, file list, LSP status, and MCP status.
207219func (m *UI) drawSidebar(scr uv.Screen, area uv.Rectangle) {