Files
correx/apps/tui-go/internal/app/cmdcard.go
T
kami d247b19608 tui: migrate Bubble Tea v1 → v2 (charm.land), enable Shift+Enter
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).

Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
  retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
  carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).

Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:59:55 +00:00

595 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"sort"
"strings"
"charm.land/lipgloss/v2"
)
// cmdcard.go is layer 1 of tui-requirements §5 — the deterministic command-approval
// parse. NO LLM: it shell-lexes the proposed command, classifies binaries against a
// known-command table, and mechanically flags the constructs that widen blast radius
// (sudo, rm -rf, pipe-to-shell, redirects outside the workspace, network binaries,
// base64/eval indicators). Unparseable input is itself a red flag, never a silent gap.
//
// It is pure and replay-stable: same preview + workspaceRoot → same analysis, no
// environment reads. The optional model annotation (§5.2) is deliberately NOT here —
// the spec says ship layer 1 and live with it first.
// segSeverity ranks a token's danger. The renderer maps these onto the T0T4 tier
// ramp for preattentive severity (§8): plain → info → warn → danger.
type segSeverity int
const (
segPlain segSeverity = iota
segInfo // notable: a network-touching binary
segWarn // elevated: redirect outside workspace, base64/eval, bare pipe
segDanger // high: sudo, recursive delete, pipe-to-shell, unparseable
)
func maxSev(a, b segSeverity) segSeverity {
if a > b {
return a
}
return b
}
// cmdFlag is one mechanically-detected concern, rendered as a chip above the command.
type cmdFlag struct {
label string
detail string
sev segSeverity
}
// cmdToken is one display token of the reconstructed command line, tagged with the
// worst severity it triggered so the renderer can color it.
type cmdToken struct {
text string
sev segSeverity
}
// cmdAnalysis is the deterministic reading of a command approval: the reconstructed
// argv as severity-tagged tokens plus the deduped, worst-first flag list.
type cmdAnalysis struct {
tokens []cmdToken
flags []cmdFlag
}
// worst returns the highest severity found anywhere in the analysis.
func (a cmdAnalysis) worst() segSeverity {
w := segPlain
for _, f := range a.flags {
w = maxSev(w, f.sev)
}
return w
}
// addFlag inserts a flag, deduped by label (keeping the highest severity seen).
func (a *cmdAnalysis) addFlag(f cmdFlag) {
for i := range a.flags {
if a.flags[i].label == f.label {
a.flags[i].sev = maxSev(a.flags[i].sev, f.sev)
return
}
}
a.flags = append(a.flags, f)
}
// --- known-command tables -------------------------------------------------------
// privilege escalators — running anything through one of these steps outside the
// sandbox's authority, so the command is flagged regardless of what it then runs.
var privilegeBins = map[string]bool{
"sudo": true, "doas": true, "pkexec": true, "su": true,
}
// command wrappers that are transparent for "command position" — the token after
// them is still the effective command (so `sudo rm -rf` flags the rm, not just sudo).
var commandWrappers = map[string]bool{
"sudo": true, "doas": true, "pkexec": true, "env": true, "nohup": true,
"nice": true, "ionice": true, "time": true, "timeout": true, "xargs": true,
"stdbuf": true, "setsid": true,
}
// shell interpreters — a `-c` payload here is re-tokenized so dangers hidden inside
// the script string (pipes, redirects, fetch-and-run) are still surfaced.
var shellBins = map[string]bool{
"sh": true, "bash": true, "zsh": true, "dash": true, "ksh": true, "fish": true,
}
// network-touching binaries — move data across the trust boundary.
var networkBins = map[string]bool{
"curl": true, "wget": true, "nc": true, "ncat": true, "netcat": true,
"ssh": true, "scp": true, "sftp": true, "telnet": true, "ftp": true,
"rsync": true, "socat": true,
}
// decode / arbitrary-exec primitives — common obfuscation building blocks.
var evalBins = map[string]bool{
"eval": true, "base64": true, "xxd": true, "exec": true,
}
// separators that end a simple command within a tokenized line.
func isSeparator(tok string) bool {
switch tok {
case "|", "|&", "||", "&&", "&", ";":
return true
}
return false
}
// --- analysis -------------------------------------------------------------------
// analyzeCommand is the entry point: it normalizes the approval preview into a
// command line, lexes it, and classifies every token. workspaceRoot scopes the
// "redirect outside workspace" check (empty → any absolute redirect is outside).
func analyzeCommand(preview, workspaceRoot string) cmdAnalysis {
cmd := commandLineFromPreview(preview)
tokens, ok := shellSplit(cmd)
var a cmdAnalysis
if len(tokens) == 0 {
// Nothing tokenizable — surface the raw text verbatim, flagged. A command we
// cannot read is more dangerous than one we can, not less.
raw := strings.TrimSpace(preview)
if raw == "" {
raw = "(empty command)"
}
a.tokens = []cmdToken{{text: raw, sev: segDanger}}
a.addFlag(cmdFlag{"unparseable", "command could not be tokenized", segDanger})
return a
}
sev := make([]segSeverity, len(tokens))
if !ok {
a.addFlag(cmdFlag{"unparseable", "unterminated quote — argv is ambiguous", segDanger})
sev[len(sev)-1] = segDanger
}
a.scan(tokens, sev, workspaceRoot)
a.tokens = make([]cmdToken, len(tokens))
for i, tk := range tokens {
a.tokens[i] = cmdToken{text: tk, sev: sev[i]}
}
return a
}
// scan classifies tokens in place, raising sev[i] and appending flags. It runs on
// the top-level argv and recursively on a shell `-c` payload.
func (a *cmdAnalysis) scan(tokens []string, sev []segSeverity, workspaceRoot string) {
for i, tk := range tokens {
base := baseName(tk)
cmdPos := isCommandPosition(tokens, i)
switch {
case cmdPos && privilegeBins[base]:
sev[i] = maxSev(sev[i], segDanger)
a.addFlag(cmdFlag{base, "runs with elevated privileges", segDanger})
case cmdPos && networkBins[base]:
sev[i] = maxSev(sev[i], segInfo)
a.addFlag(cmdFlag{"network", base + " reaches across the network", segInfo})
case cmdPos && evalBins[base]:
sev[i] = maxSev(sev[i], segWarn)
a.addFlag(cmdFlag{base, "decode/eval primitive", segWarn})
}
if cmdPos && base == "rm" && hasRecursiveDelete(tokens[i+1:]) {
sev[i] = maxSev(sev[i], segDanger)
a.addFlag(cmdFlag{"rm -rf", "recursive delete", segDanger})
}
switch {
case tk == "|" || tk == "|&":
// Resolve what the pipe feeds, skipping wrappers/flags so `| sudo sh`
// and `| sh` both read as piping output straight into a shell.
if j := effectiveCmdIndex(tokens, i+1); j >= 0 && shellBins[baseName(tokens[j])] {
sev[i] = maxSev(sev[i], segDanger)
sev[j] = maxSev(sev[j], segDanger)
a.addFlag(cmdFlag{"pipe→shell", "output piped straight into a shell", segDanger})
}
case tk == ">" || tk == ">>":
if i+1 < len(tokens) && isOutsideWorkspace(tokens[i+1], workspaceRoot) {
sev[i] = maxSev(sev[i], segWarn)
sev[i+1] = maxSev(sev[i+1], segWarn)
a.addFlag(cmdFlag{"redirect", "writes outside the workspace: " + tokens[i+1], segWarn})
}
}
if cmdPos && shellBins[base] {
a.scanShellPayload(tokens, i, sev, workspaceRoot)
}
}
}
// scanShellPayload re-tokenizes a `<shell> -c <payload>` script and folds the worst
// severity it finds onto the payload token, so a fetch-and-run hidden in a quoted
// string still lights up the card.
func (a *cmdAnalysis) scanShellPayload(tokens []string, shellIdx int, sev []segSeverity, workspaceRoot string) {
payload, idx := dashCPayload(tokens, shellIdx)
if idx < 0 {
return
}
inner, ok := shellSplit(payload)
isev := make([]segSeverity, len(inner))
a.scan(inner, isev, workspaceRoot)
worst := segPlain
for _, s := range isev {
worst = maxSev(worst, s)
}
if !ok {
worst = maxSev(worst, segDanger)
a.addFlag(cmdFlag{"unparseable", "shell payload has an unterminated quote", segDanger})
}
sev[idx] = maxSev(sev[idx], worst)
}
// isCommandPosition reports whether tokens[i] is the program being invoked (vs an
// argument). True at the start, after a separator, or after a transparent wrapper.
func isCommandPosition(tokens []string, i int) bool {
if i == 0 {
return true
}
prev := tokens[i-1]
return isSeparator(prev) || commandWrappers[baseName(prev)]
}
// effectiveCmdIndex returns the index of the program a pipe segment runs, skipping
// transparent wrappers (sudo, env, …) and their option flags. -1 if none before the
// next separator.
func effectiveCmdIndex(tokens []string, start int) int {
for j := start; j < len(tokens); j++ {
t := tokens[j]
if isSeparator(t) {
return -1
}
if commandWrappers[baseName(t)] || strings.HasPrefix(t, "-") {
continue
}
return j
}
return -1
}
// hasRecursiveDelete reports whether the args to an rm (up to the next separator)
// request a recursive delete (-r/-R/--recursive, including bundled short flags).
func hasRecursiveDelete(args []string) bool {
for _, a := range args {
if isSeparator(a) {
break
}
if a == "--recursive" {
return true
}
if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && strings.ContainsAny(a, "rR") {
return true
}
}
return false
}
// dashCPayload finds the `-c` script argument after a shell binary and returns it
// with its token index, or ("", -1) if there is none before the next separator.
func dashCPayload(tokens []string, shellIdx int) (string, int) {
for j := shellIdx + 1; j < len(tokens); j++ {
t := tokens[j]
if isSeparator(t) {
return "", -1
}
if t == "-c" || (strings.HasPrefix(t, "-") && !strings.HasPrefix(t, "--") && strings.Contains(t, "c")) {
if j+1 < len(tokens) {
return tokens[j+1], j + 1
}
return "", -1
}
}
return "", -1
}
// isOutsideWorkspace reports whether a redirect target escapes the workspace. A
// relative path is assumed inside unless it climbs with ".."; an absolute path is
// outside unless it sits under root. /dev/null is always benign.
func isOutsideWorkspace(target, root string) bool {
if target == "/dev/null" {
return false
}
if strings.Contains(target, "..") {
return true
}
if !strings.HasPrefix(target, "/") {
return false
}
if root == "" {
return true
}
root = strings.TrimSuffix(root, "/")
return target != root && !strings.HasPrefix(target, root+"/")
}
// baseName strips any directory prefix so /usr/bin/curl classifies as curl.
func baseName(tok string) string {
if i := strings.LastIndexByte(tok, '/'); i >= 0 {
return tok[i+1:]
}
return tok
}
// --- lexing ---------------------------------------------------------------------
// commandLineFromPreview normalizes an approval preview to a command line. The
// shell tool's preview may arrive as the structured `{"argv":[...]}` form (older
// servers / replay) or as an already-rendered command line (current server); both
// resolve to the same string here.
func commandLineFromPreview(preview string) string {
s := strings.TrimSpace(preview)
if strings.HasPrefix(s, "{") {
var obj struct {
Argv []string `json:"argv"`
}
if err := json.Unmarshal([]byte(s), &obj); err == nil && len(obj.Argv) > 0 {
return shellJoin(obj.Argv)
}
}
return s
}
// shellSplit is a minimal POSIX-ish lexer: it splits on unquoted whitespace, honors
// single/double quotes and backslash escapes, and emits control operators (| || |&
// & && ; > >> <) as their own tokens even when not space-separated. ok is false on
// an unterminated quote — the caller treats that as a danger, not a parse to retry.
func shellSplit(s string) (tokens []string, ok bool) {
var cur strings.Builder
hasCur := false
flush := func() {
if hasCur {
tokens = append(tokens, cur.String())
cur.Reset()
hasCur = false
}
}
runes := []rune(s)
for i := 0; i < len(runes); {
c := runes[i]
switch {
case c == '\'':
hasCur = true
i++
for i < len(runes) && runes[i] != '\'' {
cur.WriteRune(runes[i])
i++
}
if i >= len(runes) {
flush()
return tokens, false
}
i++
case c == '"':
hasCur = true
i++
for i < len(runes) && runes[i] != '"' {
if runes[i] == '\\' && i+1 < len(runes) {
i++
}
cur.WriteRune(runes[i])
i++
}
if i >= len(runes) {
flush()
return tokens, false
}
i++
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
flush()
i++
case c == '|' || c == '&' || c == ';' || c == '>' || c == '<':
flush()
op := string(c)
if i+1 < len(runes) {
switch two := string(c) + string(runes[i+1]); two {
case "||", "&&", ">>", "|&":
op, i = two, i+1
}
}
tokens = append(tokens, op)
i++
case c == '\\' && i+1 < len(runes):
hasCur = true
cur.WriteRune(runes[i+1])
i += 2
default:
hasCur = true
cur.WriteRune(c)
i++
}
}
flush()
return tokens, true
}
// shellJoin renders argv as a command line, single-quoting any token that needs it.
func shellJoin(argv []string) string {
parts := make([]string, len(argv))
for i, a := range argv {
parts[i] = shellQuote(a)
}
return strings.Join(parts, " ")
}
const shellMeta = " \t\n'\"\\|&;<>(){}$`*?[]~#!"
// shellQuote wraps a token in single quotes when it contains whitespace or shell
// metacharacters, so the reconstructed line is unambiguous to read.
func shellQuote(tok string) string {
if tok == "" {
return "''"
}
if !strings.ContainsAny(tok, shellMeta) {
return tok
}
return "'" + strings.ReplaceAll(tok, "'", `'\''`) + "'"
}
// sortedFlags returns the flags worst-first (stable within a severity) for chips.
func sortedFlags(flags []cmdFlag) []cmdFlag {
out := make([]cmdFlag, len(flags))
copy(out, flags)
sort.SliceStable(out, func(i, j int) bool { return out[i].sev > out[j].sev })
return out
}
// --- rendering ------------------------------------------------------------------
// isCommandApproval reports whether an approval should render as a command card
// rather than a diff/plain preview. The shell tool is the canonical producer; the
// JSON-argv shape is a defensive fallback for replayed/older previews.
func isCommandApproval(a *Approval) bool {
if a == nil {
return false
}
if a.ToolName == "shell" {
return true
}
s := strings.TrimSpace(a.Preview)
return strings.HasPrefix(s, "{") && strings.Contains(s, `"argv"`)
}
// pendingCommand returns the selected session's pending approval iff it is a
// command (so the fullscreen ^x view can render the card instead of a flat diff).
func (m Model) pendingCommand() *Approval {
if s := m.session(m.selectedID); s != nil && s.Pending != nil && isCommandApproval(s.Pending) {
return s.Pending
}
return nil
}
// commandCardLines is the rendered body of a command approval: a worst-first row
// of severity chips, a blank, then the reconstructed command line with each token
// colored by its flag severity. width is the inner content width. The full slice is
// used both for sizing the band and (windowed) for the ^x fullscreen view, so the
// height math and the render never disagree.
func (m Model) commandCardLines(a *Approval, width int) []string {
if width < 8 {
width = 8
}
ws := ""
if s := m.session(a.SessionID); s != nil {
ws = s.WorkspaceRoot
}
an := analyzeCommand(a.Preview, ws)
lines := m.chipLines(an.flags, width)
if len(lines) > 0 {
lines = append(lines, "")
}
return append(lines, m.commandLines(an.tokens, width)...)
}
// chipLines renders the flag chips, wrapped to width. With no flags it states the
// deterministic parse came back clean (so a quiet card reads as "checked", not "TODO").
func (m Model) chipLines(flags []cmdFlag, width int) []string {
t := m.theme
if len(flags) == 0 {
return []string{t.span("✓ deterministic parse — nothing notable flagged", t.P.OK)}
}
chips := make([]string, 0, len(flags))
for _, f := range sortedFlags(flags) {
chips = append(chips, m.chip(f))
}
gap := t.span(" ", t.P.Bg)
return wrapStyled(chips, gap, 2, width)
}
// chip renders one flag as a colored glyph+label badge (▲ for warn/danger, ● for info).
func (m Model) chip(f cmdFlag) string {
t := m.theme
glyph, fg := "●", t.P.Accent2
switch f.sev {
case segDanger:
glyph, fg = "▲", t.P.Bad
case segWarn:
glyph, fg = "▲", t.P.Warn
}
return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Bold(f.sev == segDanger).Render(glyph + " " + f.label)
}
// commandLines lays out the reconstructed command line with a `$ ` prompt, wrapping
// to width with a two-space continuation indent. Each token is shell-quoted for an
// unambiguous read and colored by its severity. Overlong tokens are hard-split so
// the full command always survives (§3 — never truncate the command being approved).
func (m Model) commandLines(tokens []cmdToken, width int) []string {
t := m.theme
space := t.span(" ", t.P.Bg)
indent := t.span(" ", t.P.Bg)
prompt := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render("$ ")
type seg struct {
text string
sev segSeverity
}
var segs []seg
for _, tk := range tokens {
for _, chunk := range wrapLine(shellQuote(tk.text), width-2) {
segs = append(segs, seg{chunk, tk.sev})
}
}
lines := make([]string, 0, 2)
cur, curW, atStart := prompt, 2, true
for _, s := range segs {
w := len([]rune(s.text))
need := w
if !atStart {
need = w + 1
}
if !atStart && curW+need > width {
lines = append(lines, cur)
cur, curW, atStart = indent, 2, true
}
if !atStart {
cur += space
curW++
}
cur += m.cmdSpan(s.text, s.sev)
curW += w
atStart = false
}
return append(lines, cur)
}
// cmdSpan colors a command token by its severity (T0T4 ramp reuse, §8).
func (m Model) cmdSpan(s string, sev segSeverity) string {
t := m.theme
switch sev {
case segDanger:
return lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Bold(true).Render(s)
case segWarn:
return lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render(s)
case segInfo:
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(s)
default:
return t.span(s, t.P.FgStrong)
}
}
// wrapStyled packs already-styled items onto lines no wider than width, separated by
// gap (visible width gapW), starting a new line when the next item would overflow.
func wrapStyled(items []string, gap string, gapW, width int) []string {
var lines []string
cur, curW := "", 0
for _, it := range items {
w := lipgloss.Width(it)
if cur == "" {
cur, curW = it, w
continue
}
if curW+gapW+w > width {
lines = append(lines, cur)
cur, curW = it, w
continue
}
cur += gap + it
curW += gapW + w
}
if cur != "" {
lines = append(lines, cur)
}
return lines
}