|
7 | 7 | "os" |
8 | 8 | "strings" |
9 | 9 |
|
| 10 | + "github.com/charmbracelet/x/ansi" |
10 | 11 | "github.com/itchyny/gojq" |
11 | 12 |
|
12 | 13 | clioutput "github.com/basecamp/cli/output" |
@@ -421,22 +422,26 @@ func (w *Writer) writeLiteralMarkdown(v any) error { |
421 | 422 | type ResponseOption func(*Response) |
422 | 423 |
|
423 | 424 | // WithSummary adds a summary to the response. |
| 425 | +// Summaries frequently interpolate API-controlled strings (project/person/ |
| 426 | +// entity names), so ANSI/OSC escape sequences are stripped at the source to |
| 427 | +// prevent terminal injection in every styled/markdown sink. |
424 | 428 | func WithSummary(s string) ResponseOption { |
425 | | - return func(r *Response) { r.Summary = s } |
| 429 | + return func(r *Response) { r.Summary = ansi.Strip(s) } |
426 | 430 | } |
427 | 431 |
|
428 | 432 | // WithNotice adds an informational notice to the response. |
429 | 433 | // Use this for non-error messages like truncation warnings. |
| 434 | +// Like WithSummary, the value is ANSI-stripped at the source. |
430 | 435 | func WithNotice(s string) ResponseOption { |
431 | | - return func(r *Response) { r.Notice = s; r.noticeDiagnostic = false } |
| 436 | + return func(r *Response) { r.Notice = ansi.Strip(s); r.noticeDiagnostic = false } |
432 | 437 | } |
433 | 438 |
|
434 | 439 | // WithDiagnostic sets a notice that is also emitted to stderr in quiet mode. |
435 | 440 | // Use this for degraded-operation warnings (e.g. unresolved mentions) that |
436 | 441 | // automation consumers need to detect. Truncation and other informational |
437 | 442 | // notices should use WithNotice instead. |
438 | 443 | func WithDiagnostic(s string) ResponseOption { |
439 | | - return func(r *Response) { r.Notice = s; r.noticeDiagnostic = true } |
| 444 | + return func(r *Response) { r.Notice = ansi.Strip(s); r.noticeDiagnostic = true } |
440 | 445 | } |
441 | 446 |
|
442 | 447 | // WithBreadcrumbs adds breadcrumbs to the response. |
@@ -530,13 +535,16 @@ func (w *Writer) presentStyledEntity(resp *Response) bool { |
530 | 535 | var out strings.Builder |
531 | 536 | r := NewRenderer(w.opts.Writer, true) |
532 | 537 |
|
| 538 | + // ansi.Strip defends against terminal injection from API-controlled |
| 539 | + // summary/notice content (already stripped at the WithSummary/WithNotice |
| 540 | + // source; repeated here as defense-in-depth at the render sink). |
533 | 541 | if resp.Summary != "" { |
534 | | - out.WriteString(r.Summary.Render(resp.Summary)) |
| 542 | + out.WriteString(r.Summary.Render(ansi.Strip(resp.Summary))) |
535 | 543 | out.WriteString("\n") |
536 | 544 | } |
537 | 545 |
|
538 | 546 | if resp.Notice != "" { |
539 | | - out.WriteString(r.Hint.Render(resp.Notice)) |
| 547 | + out.WriteString(r.Hint.Render(ansi.Strip(resp.Notice))) |
540 | 548 | out.WriteString("\n") |
541 | 549 | } |
542 | 550 |
|
@@ -589,12 +597,13 @@ func (w *Writer) presentMarkdownEntity(resp *Response) bool { |
589 | 597 | var out strings.Builder |
590 | 598 | mr := NewMarkdownRenderer(w.opts.Writer) |
591 | 599 |
|
| 600 | + // Defense-in-depth ANSI stripping (see presentStyledEntity). |
592 | 601 | if resp.Summary != "" { |
593 | | - out.WriteString("## " + resp.Summary + "\n") |
| 602 | + out.WriteString("## " + ansi.Strip(resp.Summary) + "\n") |
594 | 603 | } |
595 | 604 |
|
596 | 605 | if resp.Notice != "" { |
597 | | - out.WriteString("*" + resp.Notice + "*\n") |
| 606 | + out.WriteString("*" + ansi.Strip(resp.Notice) + "*\n") |
598 | 607 | } |
599 | 608 |
|
600 | 609 | if resp.Summary != "" || resp.Notice != "" { |
|
0 commit comments