Skip to content

Commit 72fff76

Browse files
authored
Merge pull request #15 from BackendStack21/fix/tool-result-envelope-display
fix(tui): decode JSON tool envelopes and fold untrusted wrappers for display
2 parents 8d82973 + 1709cf8 commit 72fff76

2 files changed

Lines changed: 157 additions & 1 deletion

File tree

‎internal/tui/events.go‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package tui
33
import (
44
"encoding/json"
55
"fmt"
6+
"regexp"
7+
"sort"
8+
"strconv"
69
"strings"
710
"time"
811
"unicode"
@@ -225,6 +228,93 @@ func eventTail(ev client.Event) string {
225228
return s
226229
}
227230

231+
// normalizeToolResult prepares raw tool output for DISPLAY ONLY — the stored
232+
// and forwarded data is never touched. It handles two render-noise shapes:
233+
// 1. JSON envelopes (read_file, search_files, …): Go's encoding/json
234+
// HTML-escapes <, >, & in string values, so the odek prompt-injection
235+
// wrapper shows up as <untrusted_content_… — the envelope is
236+
// decoded and the real content rendered, with remaining scalar metadata
237+
// as a one-line footer.
238+
// 2. untrusted_content wrappers, folded into a "⚠ untrusted: <source>"
239+
// badge line. Both the literal tag form (live stream events) and the
240+
// < escaped form (undecoded JSON envelopes) are recognized.
241+
//
242+
// Fail-safe: anything that does not match a known shape exactly is returned
243+
// unchanged — normalization is never lossy.
244+
func normalizeToolResult(data string) string {
245+
return foldUntrustedWrappers(decodeToolEnvelope(data))
246+
}
247+
248+
// decodeToolEnvelope unwraps a JSON object with a string "content" field,
249+
// returning the decoded content plus a footer line of the remaining scalar
250+
// metadata (e.g. "total_lines: 430"). Nested objects/arrays, missing
251+
// content, or invalid JSON mean an unknown shape — returned unchanged.
252+
func decodeToolEnvelope(data string) string {
253+
t := strings.TrimSpace(data)
254+
if len(t) < 2 || t[0] != '{' {
255+
return data
256+
}
257+
var env map[string]any
258+
if err := json.Unmarshal([]byte(t), &env); err != nil {
259+
return data
260+
}
261+
content, ok := env["content"].(string)
262+
if !ok {
263+
return data
264+
}
265+
meta := make(map[string]string, len(env)-1)
266+
for k, v := range env {
267+
if k == "content" || v == nil {
268+
continue
269+
}
270+
switch x := v.(type) {
271+
case string:
272+
meta[k] = x
273+
case float64:
274+
meta[k] = strconv.FormatFloat(x, 'f', -1, 64)
275+
case bool:
276+
meta[k] = strconv.FormatBool(x)
277+
default:
278+
return data // nested value: not a known envelope shape
279+
}
280+
}
281+
if len(meta) == 0 {
282+
return content
283+
}
284+
keys := make([]string, 0, len(meta))
285+
for k := range meta {
286+
keys = append(keys, k)
287+
}
288+
sort.Strings(keys)
289+
parts := make([]string, 0, len(keys))
290+
for _, k := range keys {
291+
parts = append(parts, k+": "+meta[k])
292+
}
293+
return content + "\n(" + strings.Join(parts, " · ") + ")"
294+
}
295+
296+
// untrustedWrapperRe matches an odek prompt-injection wrapper in either its
297+
// literal form or the < escaped form produced by encoding/json.
298+
// Capture groups: 1 = source attribute, 2 = wrapped body.
299+
var untrustedWrapperRe = regexp.MustCompile(
300+
`(?s)(?:<|\\u003c)untrusted_content_[0-9a-f]+ source=\\?"([^"]*)\\?"(?:>|\\u003e)\n?(.*?)\n?(?:<|\\u003c)/untrusted_content_[0-9a-f]+(?:>|\\u003e)`)
301+
302+
// foldUntrustedWrappers replaces each untrusted_content wrapper with a badge
303+
// line naming the source, followed by the wrapped body.
304+
func foldUntrustedWrappers(s string) string {
305+
if !strings.Contains(s, "untrusted_content_") {
306+
return s
307+
}
308+
return untrustedWrapperRe.ReplaceAllStringFunc(s, func(m string) string {
309+
sub := untrustedWrapperRe.FindStringSubmatch(m)
310+
badge := "⚠ untrusted: " + sub[1]
311+
if body := sub[2]; body != "" {
312+
return badge + "\n" + body
313+
}
314+
return badge
315+
})
316+
}
317+
228318
// cur returns the index of the active streaming assistant message, or -1.
229319
func (m *Model) cur() int {
230320
if m.curIdx >= 0 && m.curIdx < len(m.msgs) {
@@ -331,7 +421,7 @@ func argPreview(data string) string {
331421
// lines, so the transcript can show a useful excerpt (rendered by renderSteps)
332422
// without retaining the unbounded output of a chatty tool.
333423
func resultPreview(data string) string {
334-
s := sanitize(data)
424+
s := sanitize(normalizeToolResult(data))
335425
lines := strings.Split(s, "\n")
336426
const cap = 200
337427
if len(lines) > cap {

‎internal/tui/normalize_test.go‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// read_file-style JSON envelope: Go's encoding/json HTML-escapes <, >, & in
9+
// the wrapped content, so the raw result exposes < untrusted_content_…
10+
// noise. resultPreview must decode the envelope and fold the wrapper.
11+
func TestResultPreviewJSONEnvelope(t *testing.T) {
12+
raw := `{"content":"<untrusted_content_dd96e237d3be7447 source=\"/path/to/file.go\">\n217|\n218|\tta := textarea.New()\n</untrusted_content_dd96e237d3be7447>","total_lines":300}`
13+
got := resultPreview(raw)
14+
if strings.Contains(got, `<`) || strings.Contains(got, "untrusted_content_") {
15+
t.Errorf("escaped wrapper still visible:\n%s", got)
16+
}
17+
if !strings.Contains(got, "⚠ untrusted: /path/to/file.go") {
18+
t.Errorf("missing source badge:\n%s", got)
19+
}
20+
if !strings.Contains(got, "218|\tta := textarea.New()") {
21+
t.Errorf("body line numbers not intact:\n%s", got)
22+
}
23+
if !strings.Contains(got, "total_lines: 300") {
24+
t.Errorf("missing scalar metadata footer:\n%s", got)
25+
}
26+
}
27+
28+
// Live (non-JSON) tool results carry the wrapper literally; it folds into a
29+
// badge line plus the body.
30+
func TestResultPreviewLiteralWrapper(t *testing.T) {
31+
raw := "<untrusted_content_ab12cd34 source=\"/etc/hosts\">\n127.0.0.1 localhost\n</untrusted_content_ab12cd34>"
32+
got := resultPreview(raw)
33+
if strings.Contains(got, "untrusted_content_") {
34+
t.Errorf("wrapper tags still visible:\n%s", got)
35+
}
36+
if !strings.Contains(got, "⚠ untrusted: /etc/hosts") {
37+
t.Errorf("missing source badge:\n%s", got)
38+
}
39+
if !strings.Contains(got, "127.0.0.1 localhost") {
40+
t.Errorf("body lost:\n%s", got)
41+
}
42+
}
43+
44+
// Plain, non-JSON tool output renders byte-identical.
45+
func TestResultPreviewPlainUnchanged(t *testing.T) {
46+
for _, s := range []string{
47+
"",
48+
"plain output",
49+
"exit status 1\nFAIL TestX",
50+
"line with <angle> & \"quotes\" kept as-is",
51+
`{"matches":[{"path":"a.go"}]}`, // JSON without a string content field
52+
`{"content":"x","nested":{"a":1}}`, // non-scalar metadata: not a known envelope
53+
} {
54+
if got := resultPreview(s); got != s {
55+
t.Errorf("resultPreview(%q) = %q, want unchanged", s, got)
56+
}
57+
}
58+
}
59+
60+
// A malformed envelope (truncated JSON) renders raw, never lossy.
61+
func TestResultPreviewMalformedEnvelope(t *testing.T) {
62+
raw := `{"content":"<untrusted_content_ab`
63+
if got := resultPreview(raw); got != raw {
64+
t.Errorf("malformed envelope rewritten: got %q", got)
65+
}
66+
}

0 commit comments

Comments
 (0)