Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ semantic styles (`StyleTitle`, `StyleSuccess`, `StyleError`, `StyleDim`, …).
| `Render(v)` | humanized fields | any API object (JSON only under `--json`) |
| `RenderTable` / `RenderCard` | | lists / result summaries |

Every one of those writes its text through `output.Sanitize` first. Most of
what the CLI shows came from somewhere else — API responses, store metadata,
an App User ID a customer picked for themselves — and a terminal *acts* on
control bytes rather than drawing them (OSC 52 rewrites the clipboard, CSI
moves the cursor, CR overwrites the line above). Sanitized, remote text can
only ever be shown: controls become their escaped literal (`\x1b`), newline
and tab pass through. The interactive browser sanitizes on the way into a
frame, so lazily-loaded children are covered too. `--json` is untouched: the
JSON encoder already escapes control bytes, so agents keep the exact value.

Strings the CLI styled itself (`Paint`, `Panel`, `Link`) carry deliberate
escapes and are built from our own literals, so they skip sanitizing — never
route remote text through them.

### Interaction (`internal/tui`)

- `tui.Form(...)` and `tui.Confirm*` are the only ways to prompt. They apply
Expand All @@ -67,6 +81,10 @@ Simple creates stay promptless.
colors outside the token layer, raw `huh.NewForm`, or hand-rolled
`AssumeYes` checks fail CI. Deliberate exceptions live in an allow list
with a written reason.
- Escape-neutralization tests — `internal/output/untrusted_test.go` (every
`Renderer` slot), `internal/tui/browser_escape_test.go` (every browser
view), and `internal/cli/customers_escape_test.go` (end to end, from an
API response to stdout). A writer that prints remote text raw fails here.
- `TestOutputSnapshots` — layout and copy of representative commands are
locked into golden files (`UPDATE_SNAPSHOTS=1` to regenerate after
intentional changes).
Expand Down
86 changes: 86 additions & 0 deletions internal/cli/customers_escape_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cli_test

import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

// A Customer's ID is whatever App User ID the app handed RevenueCat, and the
// v2 schema constrains it only by length (docs/specs/v2-developer.yaml —
// `Customer.id`: string, maxLength 1500, no pattern). So an ID can carry
// terminal control bytes, and human-mode output must show them rather than let
// the terminal act on them: OSC 52 replaces the reader's clipboard.
const osc52CustomerID = "rcbb_target\x1b]52;c;UkNCQjE5MQ==\x07"

// JSON-encoded form of the same ID, as the API would send it on the wire.
const osc52CustomerIDJSON = `rcbb_target\u001b]52;c;UkNCQjE5MQ==\u0007`

func customerEscapeServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
customer := `{"object":"customer","id":"` + osc52CustomerIDJSON + `","project_id":"proj_esc",` +
`"first_seen_at":1700000000000,"last_seen_at":1700000000000,"last_seen_platform":"ios",` +
`"last_seen_country":"` + osc52CustomerIDJSON + `","last_seen_app_version":"1.0.0",` +
`"active_entitlements":{"object":"list","items":[{"object":"customer.active_entitlement","entitlement_id":"` + osc52CustomerIDJSON + `"}]}}`
switch {
case strings.HasSuffix(r.URL.Path, "/subscriptions"):
io.WriteString(w, `{"object":"list","items":[{"object":"subscription","id":"`+osc52CustomerIDJSON+`","store":"app_store","status":"active"}]}`)
case strings.HasSuffix(r.URL.Path, "/purchases"):
io.WriteString(w, `{"object":"list","items":[]}`)
case strings.HasSuffix(r.URL.Path, "/customers"):
io.WriteString(w, `{"object":"list","items":[`+customer+`],"next_page":"/v2/projects/proj_esc/customers?starting_after=x"}`)
default:
io.WriteString(w, customer)
}
}))
t.Cleanup(server.Close)
return server
}

func TestCustomers_HumanOutputNeverLetsACustomerIDDriveTheTerminal(t *testing.T) {
server := customerEscapeServer(t)
t.Setenv("RC_BASE_URL", server.URL)

for _, args := range [][]string{
{"customers", "list"},
{"customers", "show", "cus_escape"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
args = append(args, "--no-input", "--no-color", "--project-id", "proj_esc", "--api-key", "sk_esc")
stdout, stderr, err := runAgentCmd(t, args...)
if err != nil {
t.Fatalf("execute: %v\nstderr: %s", err, stderr)
}
for name, stream := range map[string]string{"stdout": stdout, "stderr": stderr} {
if strings.ContainsRune(stream, 0x1b) || strings.ContainsRune(stream, 0x07) {
t.Errorf("%s hands the terminal a control sequence from the customer ID:\n%q", name, stream)
}
}
if !strings.Contains(stdout, `\x1b]52`) {
t.Errorf("the ID should still be readable as an escaped literal:\n%s", stdout)
}
})
}
}

// The agent contract is unchanged: --json carries the exact bytes, encoded.
func TestCustomersList_JSONStillCarriesTheRawIDEncoded(t *testing.T) {
server := customerEscapeServer(t)
t.Setenv("RC_BASE_URL", server.URL)

stdout, _, err := runAgentCmd(t, "customers", "list", "--json", "--no-input",
"--project-id", "proj_esc", "--api-key", "sk_esc")
if err != nil {
t.Fatalf("execute: %v", err)
}
if strings.ContainsRune(stdout, 0x1b) {
t.Errorf("--json leaked a raw escape byte:\n%q", stdout)
}
if !strings.Contains(stdout, osc52CustomerIDJSON) {
t.Errorf("--json must keep the exact ID:\n%s", stdout)
}
}
38 changes: 23 additions & 15 deletions internal/output/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,16 @@ func (r *Renderer) RenderCard(c Card) error {
emptyStyle := lipgloss.NewStyle().Faint(true).Italic(true)

if c.Title != "" {
fmt.Fprintln(r.stdout, r.style(StyleAccent, "▍ ")+r.style(titleStyle, c.Title))
fmt.Fprintln(r.stdout, r.style(StyleAccent, "▍ ")+r.style(titleStyle, Sanitize(c.Title)))
}
if c.Subtitle != "" {
fmt.Fprintln(r.stdout, " "+r.style(subtitleStyle, c.Subtitle))
fmt.Fprintln(r.stdout, " "+r.style(subtitleStyle, Sanitize(c.Subtitle)))
}

for _, s := range c.Sections {
fmt.Fprintln(r.stdout)
if s.Heading != "" {
fmt.Fprintln(r.stdout, r.style(headingStyle, s.Heading))
fmt.Fprintln(r.stdout, r.style(headingStyle, Sanitize(s.Heading)))
}
switch {
case len(s.Chips) > 0:
Expand All @@ -95,7 +95,7 @@ func (r *Renderer) RenderCard(c Card) error {
if msg == "" {
msg = "none"
}
fmt.Fprintln(r.stdout, r.style(emptyStyle, " "+msg))
fmt.Fprintln(r.stdout, r.style(emptyStyle, " "+Sanitize(msg)))
}
}
return nil
Expand All @@ -110,8 +110,9 @@ func (r *Renderer) writeChips(chips []Chip) {
}

func (r *Renderer) styleChip(c Chip) string {
label := Sanitize(c.Label)
if r.noColor {
return "[" + c.Label + "]"
return "[" + label + "]"
}
base := lipgloss.NewStyle().Padding(0, 1).Bold(true)
white := lipgloss.Color("15")
Expand All @@ -129,15 +130,20 @@ func (r *Renderer) styleChip(c Chip) string {
default:
base = base.Background(NeutralGray).Foreground(white)
}
return base.Render(c.Label)
return base.Render(label)
}

func (r *Renderer) writeCardTable(t CardTable) {
widths := make([]int, len(t.Columns))
for i, c := range t.Columns {
columns := sanitizeAll(t.Columns)
rows := make([][]string, len(t.Rows))
for i, row := range t.Rows {
rows[i] = sanitizeAll(row)
}
widths := make([]int, len(columns))
for i, c := range columns {
widths[i] = len(c)
}
for _, row := range t.Rows {
for _, row := range rows {
for i, cell := range row {
if i >= len(widths) {
continue
Expand All @@ -149,14 +155,14 @@ func (r *Renderer) writeCardTable(t CardTable) {
}
headerStyle := lipgloss.NewStyle().Bold(true)
fmt.Fprint(r.stdout, " ")
for i, c := range t.Columns {
for i, c := range columns {
if i > 0 {
fmt.Fprint(r.stdout, " ")
}
fmt.Fprint(r.stdout, r.style(headerStyle, padRight(c, widths[i])))
}
fmt.Fprintln(r.stdout)
for _, row := range t.Rows {
for _, row := range rows {
fmt.Fprint(r.stdout, " ")
for i, cell := range row {
if i > 0 {
Expand All @@ -169,14 +175,16 @@ func (r *Renderer) writeCardTable(t CardTable) {
}

func (r *Renderer) writeLines(lines []CardLine) {
safe := make([]CardLine, len(lines))
keyWidth := 0
for _, l := range lines {
if len(l.Key) > keyWidth {
keyWidth = len(l.Key)
for i, l := range lines {
safe[i] = CardLine{Key: Sanitize(l.Key), Value: Sanitize(l.Value)}
if len(safe[i].Key) > keyWidth {
keyWidth = len(safe[i].Key)
}
}
keyStyle := lipgloss.NewStyle().Faint(true)
for _, l := range lines {
for _, l := range safe {
fmt.Fprintf(r.stdout, " %s %s\n", r.style(keyStyle, padRight(l.Key+":", keyWidth+1)), l.Value)
}
}
Loading
Loading