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 T0–T4 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 ` -c ` 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 (T0–T4 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 }