Skip to content

Commit fee8bbe

Browse files
jkyberneeesclaude
andcommitted
fix(security): sanitize untrusted terminal output; fix CI lint action
Verification-protocol finding (Axis 2.8 Adversarial Surface / 2.3 Security): agent-influenced output — streamed tokens, tool args/results, reasoning, engine notices, and resumed transcripts — was written to the terminal without stripping ANSI/control escape sequences, a terminal-injection risk (cursor/screen manipulation, OSC 52 clipboard exfiltration). - add sanitize()/isControl(): strip C0 control bytes + DEL (incl. ESC), keeping newlines/tabs; applied to all untrusted display paths (collapse, streamed token + thinking ingestion, addNote, resumed transcripts) - tests for the sanitizer and end-to-end defanging CI: golangci-lint v2 requires golangci-lint-action v7+ (v6 rejects v2.x) — bump the action so the Lint job runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012rWqu7pktbd2ejfNw3a7Vf
1 parent 71d1fd3 commit fee8bbe

4 files changed

Lines changed: 78 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,6 @@ jobs:
5252
cache: true
5353

5454
- name: golangci-lint
55-
uses: golangci/golangci-lint-action@v6
55+
uses: golangci/golangci-lint-action@v7
5656
with:
5757
version: v2.5.0

internal/tui/gaps_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package tui
22

33
import (
4+
"strings"
45
"testing"
56

67
"github.com/BackendStack21/bodek/internal/client"
@@ -45,3 +46,39 @@ func TestArgPreviewURLKey(t *testing.T) {
4546
t.Errorf("argPreview url = %q", got)
4647
}
4748
}
49+
50+
func TestSanitizeStripsControlSequences(t *testing.T) {
51+
// ESC-based screen clear + OSC 52 clipboard write must be defanged.
52+
evil := "ok\x1b[2Jclear\x1b]52;c;ZXZpbA==\x07 \x7f\x00 plain\ttab\nnl"
53+
got := sanitize(evil)
54+
for _, bad := range []rune{'\x1b', '\x07', '\x00', '\x7f'} {
55+
if strings.ContainsRune(got, bad) {
56+
t.Errorf("sanitize left control byte %q in %q", bad, got)
57+
}
58+
}
59+
if !strings.Contains(got, "plain") || !strings.Contains(got, "\t") || !strings.Contains(got, "\n") {
60+
t.Errorf("sanitize dropped legitimate text/whitespace: %q", got)
61+
}
62+
// Fast path: clean input is returned unchanged.
63+
if sanitize("hello world") != "hello world" {
64+
t.Error("sanitize altered clean input")
65+
}
66+
}
67+
68+
func TestUntrustedOutputDefanged(t *testing.T) {
69+
m := wired(t)
70+
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true})
71+
m.curIdx = 0
72+
m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: "{\"command\":\"x\x1b[2J\"}"})
73+
m.handleEvent(client.Event{Type: "tool_result", Name: "shell", Data: "out\x1b]0;pwn"})
74+
m.handleEvent(client.Event{Type: "token", Content: "hi\x1b[31m"})
75+
76+
if strings.ContainsRune(m.msgs[0].content, '\x1b') {
77+
t.Error("streamed token escape not sanitized")
78+
}
79+
for _, s := range m.msgs[0].steps {
80+
if strings.ContainsRune(s.arg, '\x1b') || strings.ContainsRune(s.result, '\x1b') {
81+
t.Error("tool step escape not sanitized")
82+
}
83+
}
84+
}

internal/tui/model.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,12 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
351351
m.sandbox = ev.Sandbox
352352

353353
case "thinking":
354-
m.thinking.WriteString(ev.Content)
354+
m.thinking.WriteString(sanitize(ev.Content))
355355
m.status = "thinking"
356356

357357
case "token":
358358
if i := m.cur(); i >= 0 {
359-
m.msgs[i].content += ev.Content
359+
m.msgs[i].content += sanitize(ev.Content)
360360
m.msgs[i].streaming = true
361361
}
362362
m.status = "responding"
@@ -509,7 +509,7 @@ func (m *Model) finalize() {
509509
}
510510

511511
func (m *Model) addNote(s string) {
512-
m.notices = append(m.notices, s)
512+
m.notices = append(m.notices, sanitize(s))
513513
if len(m.notices) > 6 {
514514
m.notices = m.notices[len(m.notices)-6:]
515515
}
@@ -695,7 +695,37 @@ func linePreview(data string) string {
695695
}
696696

697697
func collapse(s string) string {
698-
return strings.Join(strings.Fields(s), " ")
698+
return strings.Join(strings.Fields(sanitize(s)), " ")
699+
}
700+
701+
// sanitize strips terminal control sequences from untrusted content before it
702+
// is rendered. Agent output — streamed tokens, tool results, file contents,
703+
// resumed transcripts — is attacker-influenced; raw C0 control bytes (notably
704+
// ESC, 0x1b) could drive ANSI/OSC escapes that move the cursor, clear the
705+
// screen, or exfiltrate via OSC 52. We keep newlines and tabs and drop every
706+
// other control byte (and DEL), which defangs escape sequences by removing
707+
// their introducer while leaving readable text intact.
708+
func sanitize(s string) string {
709+
if !strings.ContainsFunc(s, isControl) {
710+
return s
711+
}
712+
var b strings.Builder
713+
b.Grow(len(s))
714+
for _, r := range s {
715+
if !isControl(r) {
716+
b.WriteRune(r)
717+
}
718+
}
719+
return b.String()
720+
}
721+
722+
// isControl reports whether r is a control character we strip from untrusted
723+
// text (C0 controls and DEL, except newline and tab).
724+
func isControl(r rune) bool {
725+
if r == '\n' || r == '\t' {
726+
return false
727+
}
728+
return r < 0x20 || r == 0x7f
699729
}
700730

701731
// formatDuration renders a short, friendly elapsed time.

internal/tui/panels.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,14 +228,17 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) {
228228
m.sandbox = msg.sess.Sandbox
229229
m.msgs = m.msgs[:0]
230230
for _, mm := range msg.sess.Messages {
231+
// Persisted transcripts are attacker-influenced (agent output, and the
232+
// session file itself); strip terminal control sequences before display.
233+
content := sanitize(mm.Content)
231234
switch mm.Role {
232235
case "user":
233-
m.msgs = append(m.msgs, message{role: roleUser, content: mm.Content})
236+
m.msgs = append(m.msgs, message{role: roleUser, content: content})
234237
case "assistant":
235-
if strings.TrimSpace(mm.Content) == "" {
238+
if strings.TrimSpace(content) == "" {
236239
continue
237240
}
238-
m.msgs = append(m.msgs, message{role: roleAsst, content: mm.Content, rendered: m.render(mm.Content)})
241+
m.msgs = append(m.msgs, message{role: roleAsst, content: content, rendered: m.render(content)})
239242
}
240243
}
241244
m.addNote("resumed session " + shortID(msg.sess.ID))

0 commit comments

Comments
 (0)