Skip to content

Commit 9a0c0fa

Browse files
authored
fix(webui): unwrap untrusted-content envelope client-side, escape on render (#118)
Standardize on client-side sanitization for odek serve: the server sends all WebSocket content raw and unsanitized (it already did); the WebUI now handles the model-facing <untrusted_content_*> envelope properly instead of displaying the escaped tag text. - ui/js/untrusted.js: nonce-backreferenced envelope parser (parseUntrusted, unwrapUntrusted, hasUntrustedWrapper, stripAttachmentBodies) mirroring the grammar of cmd/odek/untrusted.go; forged/mismatched nonces pass through as plain text - ui/js/render.js: tool results unwrap the envelope and render via textContent only (no innerHTML/data-full for bodies); envelope source shows as a badge. Fixes live + history paths via shared helper. Also drops the redundant escapeHtml() under markdownToHtml in addMessage and renderAssistantMessage (markdownToHtml escapes all input by default), so history and live stream render identically - ui/js/utils.js: stripAttachmentBodies re-exported from untrusted.js; attachment envelopes collapse to chip placeholders again (stale '--- name (size) ---' format replaced by source="attachment:<name>") - ui/style.css: .tb-source badge styling - ui/js/untrusted.test.js: node:test regression suite incl. double-escape and escaped-render-chain contract pins - docs/WEBUI.md: content sanitization contract for third-party clients, corrected WS protocol table (tool_call/tool_result data fields, missing event types), updated frontend description
1 parent c7699de commit 9a0c0fa

6 files changed

Lines changed: 256 additions & 32 deletions

File tree

cmd/odek/ui/js/render.js

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Message rendering: streaming, thinking, tool blocks, sub-agent cards,
22
// session-history rendering, collapse/copy affordances, and the loading
3-
// indicator. Imports only from state/dom/utils/markdown.
3+
// indicator. Imports only from state/dom/utils/markdown/untrusted.
44
import { S } from './state.js';
55
import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js';
66
import {
@@ -9,6 +9,7 @@ import {
99
showCancel, hideCancel, announce,
1010
} from './utils.js';
1111
import { markdownToHtml } from './markdown.js';
12+
import { parseUntrusted } from './untrusted.js';
1213

1314
// ── Turn state ──
1415
// resetTurnState clears all per-turn streaming/tool/sub-agent state. Called
@@ -230,7 +231,7 @@ export function addMessage(role, content) {
230231
wrapper.innerHTML =
231232
'<div class="bubble">' +
232233
'<div class="sender">' + sender + '</div>' +
233-
'<div class="content">' + markdownToHtml(escapeHtml(content)) + '</div>' +
234+
'<div class="content">' + markdownToHtml(content) + '</div>' +
234235
'</div>';
235236
messagesEl.appendChild(wrapper);
236237
// Copy button and collapse check on the freshly appended bubble.
@@ -270,7 +271,7 @@ export function renderAssistantMessage(content) {
270271
wrapper.innerHTML =
271272
'<div class="bubble">' +
272273
'<div class="sender">assistant</div>' +
273-
'<div class="content">' + markdownToHtml(escapeHtml(content)) + '</div>' +
274+
'<div class="content">' + markdownToHtml(content) + '</div>' +
274275
'</div>';
275276
messagesEl.appendChild(wrapper);
276277
const bubble = wrapper.querySelector('.bubble');
@@ -375,18 +376,42 @@ export function addToolCall(name, data) {
375376
// long output behind a "show all" expander. Shared by the live path
376377
// (addToolResult) and session-history rendering.
377378
function appendToolResultContent(block, output) {
378-
const MAX_RESULT = 600;
379-
const truncated = output && output.length > MAX_RESULT;
380379
const resultEl = document.createElement('div');
381380
resultEl.className = 'tb-result';
382381
block.appendChild(resultEl);
383-
if (truncated) {
384-
resultEl.innerHTML =
385-
escapeHtml(output.slice(0, MAX_RESULT)) +
386-
'<span class="tb-result-more" role="button" tabindex="0" data-full="' +
387-
escapeAttr(output) + '"> …show all (' + output.length + ' chars)</span>';
388-
} else {
389-
resultEl.textContent = output || '';
382+
fillToolResult(resultEl, output || '', true);
383+
}
384+
385+
// fillToolResult renders tool output into resultEl. The server sends raw,
386+
// unsanitized content; tool output may embed the model-facing
387+
// <untrusted_content_*> envelope, which is unwrapped for display — the body
388+
// is inserted as text (never HTML) and the envelope source is shown as a
389+
// badge instead of the literal tag text. When truncate is true, long bodies
390+
// are cut behind a "show all" expander carrying the full output.
391+
function fillToolResult(resultEl, output, truncate) {
392+
const MAX_RESULT = 600;
393+
const segments = parseUntrusted(output);
394+
for (const seg of segments) {
395+
if (seg.source) {
396+
const badge = document.createElement('span');
397+
badge.className = 'tb-source';
398+
badge.textContent = '🔒 ' + seg.source;
399+
resultEl.appendChild(badge);
400+
resultEl.appendChild(document.createTextNode('\n'));
401+
}
402+
const body = seg.body;
403+
if (truncate && body.length > MAX_RESULT) {
404+
resultEl.appendChild(document.createTextNode(body.slice(0, MAX_RESULT)));
405+
const more = document.createElement('span');
406+
more.className = 'tb-result-more';
407+
more.setAttribute('role', 'button');
408+
more.tabIndex = 0;
409+
more.dataset.full = output;
410+
more.textContent = ' …show all (' + body.length + ' chars)';
411+
resultEl.appendChild(more);
412+
} else {
413+
resultEl.appendChild(document.createTextNode(body));
414+
}
390415
}
391416
}
392417

@@ -414,7 +439,10 @@ export function addToolResult(name, output) {
414439
function expandToolResult(el) {
415440
const full = el.dataset.full || '';
416441
const resultEl = el.parentElement;
417-
if (resultEl) resultEl.textContent = full;
442+
if (resultEl) {
443+
resultEl.textContent = '';
444+
fillToolResult(resultEl, full, false);
445+
}
418446
}
419447

420448
function toggleToolBody(header) {

cmd/odek/ui/js/untrusted.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Client-side parser for the server-side untrusted-content envelope
2+
// (<untrusted_content_<nonce> source="...">…</untrusted_content_<nonce>>),
3+
// mirroring the grammar produced by wrapUntrusted in cmd/odek/untrusted.go.
4+
//
5+
// The envelope is model-facing trust metadata, not user content: the WebUI
6+
// unwraps it for display (body shown escaped, source as a badge) instead of
7+
// rendering the literal tag text. Sanitization itself stays client-side —
8+
// the server always sends raw content.
9+
//
10+
// Server-side neutraliseWrapperLiterals guarantees bodies contain no literal
11+
// "untrusted_content" substring, so non-greedy matching cannot terminate
12+
// early inside a body.
13+
14+
// Nonce-backreferenced: the closing tag must repeat the opening nonce, so a
15+
// forged/mismatched envelope is treated as plain text rather than parsed.
16+
const RE_UNTRUSTED =
17+
/<untrusted_content_([0-9a-f]+) source="([^"]*)">\n?([\s\S]*?)\n?<\/untrusted_content_\1>/g;
18+
19+
// parseUntrusted splits text into segments: wrapped envelopes become
20+
// { source, body } (body trimmed of the envelope's framing newlines) and any
21+
// surrounding plain text becomes { source: null, body }. Empty plain-text
22+
// gaps between envelopes are omitted.
23+
export function parseUntrusted(text) {
24+
if (!text) return [];
25+
const segments = [];
26+
let last = 0;
27+
RE_UNTRUSTED.lastIndex = 0;
28+
let m;
29+
while ((m = RE_UNTRUSTED.exec(text)) !== null) {
30+
if (m.index > last) {
31+
segments.push({ source: null, body: text.slice(last, m.index) });
32+
}
33+
segments.push({ source: m[2], body: m[3] });
34+
last = m.index + m[0].length;
35+
}
36+
if (last < text.length) {
37+
segments.push({ source: null, body: text.slice(last) });
38+
}
39+
if (segments.length === 0) {
40+
segments.push({ source: null, body: text });
41+
}
42+
return segments;
43+
}
44+
45+
// unwrapUntrusted returns the concatenated bodies of every envelope in text
46+
// (plus any non-wrapped text), with all envelope tags removed.
47+
export function unwrapUntrusted(text) {
48+
return parseUntrusted(text).map((s) => s.body).join('');
49+
}
50+
51+
// hasUntrustedWrapper reports whether text contains at least one complete
52+
// nonce-matched envelope.
53+
export function hasUntrustedWrapper(text) {
54+
if (!text) return false;
55+
RE_UNTRUSTED.lastIndex = 0;
56+
return RE_UNTRUSTED.test(text);
57+
}
58+
59+
// stripAttachmentBodies collapses attachment envelopes in reloaded user
60+
// messages to chip-style placeholders so session history doesn't dump file
61+
// bodies. The server stores each attachment as an envelope with
62+
// source="attachment:<name>" (serve.go); this matches the 📎 chip rendering
63+
// used at send time (input.js). All other segments pass through unwrapped
64+
// (envelope tags removed, bodies kept).
65+
export function stripAttachmentBodies(content) {
66+
if (!content) return '';
67+
return parseUntrusted(content).map((seg) => {
68+
if (seg.source && seg.source.startsWith('attachment:')) {
69+
return '📎 ' + seg.source.slice('attachment:'.length) + '\n';
70+
}
71+
return seg.body;
72+
}).join('');
73+
}

cmd/odek/ui/js/untrusted.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Golden tests for the untrusted-content envelope parser (untrusted.js).
2+
// Mirrors the grammar produced by wrapUntrusted in cmd/odek/untrusted.go and
3+
// pins the client-side-sanitization contract: the server sends raw content,
4+
// the client unwraps the model-facing envelope and escapes the body itself.
5+
import { test } from 'node:test';
6+
import assert from 'node:assert/strict';
7+
import {
8+
parseUntrusted, unwrapUntrusted, hasUntrustedWrapper, stripAttachmentBodies,
9+
} from './untrusted.js';
10+
import { escapeHtml } from './escape.js';
11+
import { markdownToHtml } from './markdown.js';
12+
13+
function wrap(nonce, source, body) {
14+
return '<untrusted_content_' + nonce + ' source="' + source + '">\n' +
15+
body + '\n</untrusted_content_' + nonce + '>';
16+
}
17+
18+
test('single envelope: body extracted, source captured', () => {
19+
const segs = parseUntrusted(wrap('a1b2c3d4e5f60718', 'shell', 'hello world'));
20+
assert.deepEqual(segs, [{ source: 'shell', body: 'hello world' }]);
21+
assert.equal(unwrapUntrusted(wrap('a1b2c3d4e5f60718', 'shell', 'hello world')), 'hello world');
22+
});
23+
24+
test('multiple envelopes in one payload keep order', () => {
25+
const text = wrap('aaaaaaaaaaaaaaaa', 'shell', 'one') +
26+
'\nplain between\n' +
27+
wrap('bbbbbbbbbbbbbbbb', 'browser:https://example.com', 'two');
28+
const segs = parseUntrusted(text);
29+
assert.deepEqual(segs, [
30+
{ source: 'shell', body: 'one' },
31+
{ source: null, body: '\nplain between\n' },
32+
{ source: 'browser:https://example.com', body: 'two' },
33+
]);
34+
});
35+
36+
test('nonce mismatch between open and close is not parsed', () => {
37+
const forged = '<untrusted_content_aaaaaaaaaaaaaaaa source="shell">\n' +
38+
'body\n</untrusted_content_bbbbbbbbbbbbbbbb>';
39+
assert.deepEqual(parseUntrusted(forged), [{ source: null, body: forged }]);
40+
assert.equal(hasUntrustedWrapper(forged), false);
41+
});
42+
43+
test('text without envelope passes through unchanged', () => {
44+
assert.deepEqual(parseUntrusted('plain output'), [{ source: null, body: 'plain output' }]);
45+
assert.equal(unwrapUntrusted('plain output'), 'plain output');
46+
assert.equal(hasUntrustedWrapper('plain output'), false);
47+
});
48+
49+
test('empty and nullish input', () => {
50+
assert.deepEqual(parseUntrusted(''), []);
51+
assert.equal(unwrapUntrusted(''), '');
52+
assert.equal(hasUntrustedWrapper(''), false);
53+
});
54+
55+
test('body containing HTML survives parsing raw', () => {
56+
const body = '<b>bold</b> & <script>alert(1)</script>';
57+
const segs = parseUntrusted(wrap('0123456789abcdef', 'shell', body));
58+
assert.equal(segs[0].body, body);
59+
});
60+
61+
test('render chain escapes unwrapped bodies (client-side contract)', () => {
62+
const wrapped = wrap('0123456789abcdef', 'shell', '<script>alert(1)</script>');
63+
const body = unwrapUntrusted(wrapped);
64+
assert.equal(escapeHtml(body), '&lt;script&gt;alert(1)&lt;/script&gt;');
65+
// markdownToHtml escapes all input by default — callers must NOT pre-escape.
66+
assert.equal(markdownToHtml(body), '<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
67+
// Pin the double-escape regression: pre-escaping renders &lt; literally.
68+
assert.equal(markdownToHtml(escapeHtml(body)), '<p>&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;</p>');
69+
});
70+
71+
test('attachment envelope collapses to a chip, bodies dropped', () => {
72+
const text = wrap('a1a1a1a1a1a1a1a1', 'attachment:notes.txt', '--- notes.txt ---\nfile body here') +
73+
'\n\nwhat is in this file?';
74+
assert.equal(stripAttachmentBodies(text), '📎 notes.txt\n\n\nwhat is in this file?');
75+
});
76+
77+
test('non-attachment envelopes pass through unwrapped', () => {
78+
const text = wrap('c3c3c3c3c3c3c3c3', 'resource:@README.md', 'readme body');
79+
assert.equal(stripAttachmentBodies(text), 'readme body');
80+
});

cmd/odek/ui/js/utils.js

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
// Pure-ish helpers: escaping, formatting, clipboard, toast, scrolling, and
2-
// small DOM toggles. Imports only from state.js and dom.js.
2+
// small DOM toggles. Imports only from state.js, dom.js, and untrusted.js.
33
import { S } from './state.js';
44
import { messagesEl, scrollBottomBtn, cancelBtn } from './dom.js';
55

6+
// stripAttachmentBodies lives in untrusted.js (pure, node-testable);
7+
// re-exported here so render.js keeps a single utils import.
8+
export { stripAttachmentBodies } from './untrusted.js';
9+
610
// ── Escape helpers (implemented in escape.js so markdown.js can use them
711
// without importing the browser-dependent modules) ──
812
export { escapeHtml, escapeAttr } from './escape.js';
@@ -108,14 +112,6 @@ export function pruneMessages() {
108112
}
109113
}
110114

111-
// Replace inlined attachment blocks (--- name (size) ---\n...\n--- end name ---)
112-
// with chip-style placeholders so reloaded user messages don't dump file bodies.
113-
export function stripAttachmentBodies(content) {
114-
if (!content) return '';
115-
const re = /^--- (.+?) \(([^)]+)\) ---\n[\s\S]*?\n--- end \1 ---\n?/gm;
116-
return content.replace(re, (m, name, size) => '📎 ' + name + ' (' + size + ')\n');
117-
}
118-
119115
// ── Error message normalization ──
120116
export function formatErrorMessage(msg) {
121117
if (!msg) return 'Unknown error';

cmd/odek/ui/style.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,17 @@ body.light {
10441044
letter-spacing: .04em;
10451045
}
10461046
.tb-result-more:hover { text-decoration: underline; }
1047+
.tb-source {
1048+
display: inline-block;
1049+
font-size: 10px;
1050+
color: var(--text-3);
1051+
background: rgba(255,255,255,.05);
1052+
border: 1px solid rgba(255,255,255,.08);
1053+
border-radius: 3px;
1054+
padding: 1px 6px;
1055+
margin-bottom: 2px;
1056+
white-space: nowrap;
1057+
}
10471058

10481059
/* ── Sub-agent cards ───────────────────────────────────────────────── */
10491060
.subagent-group { margin: 12px 0; }

docs/WEBUI.md

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -119,25 +119,58 @@ The UI communicates entirely over a single WebSocket at `/ws`. Messages are newl
119119

120120
| Event Type | When | Fields |
121121
|------------|------|--------|
122-
| `session` | At start of response | `session_id`, `model` |
122+
| `session` | At start of response | `session_id`, `auth_token`, `model`, `sandbox` |
123123
| `token` | Streamed text content | `content` (markdown) |
124-
| `tool_call` | Agent invokes a tool | `name`, `command` |
125-
| `tool_result` | Tool returns output | `name`, `output` (truncated to 500 chars) |
126-
| `done` | Agent finishes | `latency` (seconds), `contextTokens`, `outputTokens`, `sessionContextTokens`, `sessionOutputTokens` |
124+
| `thinking` | Streamed reasoning content | `content` |
125+
| `tool_call` | Agent invokes a tool | `name`, `data` (raw tool-arguments JSON) |
126+
| `tool_result` | Tool returns output | `name`, `data` (full, untruncated output) |
127+
| `subagent_log` | Sub-agent progress within `delegate_tasks` | `task_idx`, `name`, `event`, `data` |
128+
| `done` | Agent finishes | `latency` (seconds), `contextTokens`, `outputTokens`, `cacheCreationTokens`, `cacheReadTokens`, `cachedTokens`, `sessionContextTokens`, `sessionOutputTokens` |
127129
| `error` | Agent or server error | `message` |
128-
| `approval_request` | Agent needs user approval for dangerous operation | `id`, `risk` (class name), `command` (or resource), `description`, `is_operation` |
130+
| `approval_request` | Agent needs user approval for dangerous operation | `id`, `risk` (class name), `command` (or resource), `description`, `is_operation`, `allow_trust`, `friction`, `friction_approvals` |
131+
| `approval_ack` | Server confirms an approval response | `id`, `action` |
132+
| `skill_event` | Skill lifecycle event | `event`, `skill_name`, `skills`, `heuristic` |
133+
| `memory_event` | Memory lifecycle event | `event`, `target`, `session_id`, `content`, `count`, `new_count`, `untrusted` |
134+
| `agent_signal` | Agent self-observability signal | `event`, `detail`, `tool`, `count` |
129135

130136
Example event sequence:
131137

132138
```jsonc
133139
{"type":"session","session_id":"20260519-x1y2z3","model":"deepseek-v4-flash"}
134140
{"type":"token","content":"Let me look at the source directory."}
135-
{"type":"tool_call","name":"shell","command":"ls -la src/"}
136-
{"type":"tool_result","name":"shell","output":"total 24\ndrwxr-xr-x ..."}
141+
{"type":"tool_call","name":"shell","data":"{\"command\":\"ls -la src/\"}"}
142+
{"type":"tool_result","name":"shell","data":"<untrusted_content_a1b2c3d4 source=\"shell\">\ntotal 24\ndrwxr-xr-x ...\n</untrusted_content_a1b2c3d4>"}
137143
{"type":"token","content":"The `src/` directory contains 3 files:"}
138144
{"type":"done","latency":4.2}
139145
```
140146

147+
### Content sanitization contract
148+
149+
The server sends all message content **raw and unsanitized**. HTML-escaping
150+
is the client's responsibility: any frontend (the bundled WebUI or a
151+
third-party client) MUST escape/sanitize every string field before inserting
152+
it into a DOM, terminal UI, or other rendering surface. Untrusted fields
153+
include `token.content`, `thinking.content`, `tool_call.data`,
154+
`tool_result.data`, `subagent_log.data`, `error.message`, all
155+
`approval_request` strings, `skill_event.*`, `memory_event.*`, and
156+
`agent_signal.*`.
157+
158+
Tool results (and user messages containing attachments or `@`-resource
159+
references) may embed the nonce'd untrusted-content envelope:
160+
161+
```
162+
<untrusted_content_<nonce> source="shell">
163+
...raw body...
164+
</untrusted_content_<nonce>>
165+
```
166+
167+
The envelope is **model-facing trust metadata** (prompt-injection defense),
168+
not user content. Clients SHOULD unwrap it for display: render the body
169+
(escaped) and, optionally, present the `source` attribute as a badge. The
170+
closing tag repeats the opening nonce — treat envelopes whose nonces don't
171+
match as plain text. The bundled WebUI implements this in
172+
`cmd/odek/ui/js/untrusted.js`.
173+
141174
## Implementation details
142175

143176
### Server stack (`cmd/odek/serve.go`)
@@ -159,14 +192,17 @@ Example event sequence:
159192
- Thread-safe writes via `sync.Mutex`
160193
- Error handling: returns `io.EOF` on clean close, raw `net.Error` on broken connection
161194

162-
### Frontend (`cmd/odek/ui/index.html`)
195+
### Frontend (`cmd/odek/ui/`)
163196

164-
- ~1,200 lines: single file with embedded CSS and vanilla JS (no frameworks)
197+
- Vanilla JS + CSS SPA split into native ES modules under `js/` (state, dom, utils, escape, markdown, untrusted, render, approvals, sessions, input, ws, main, net) — no build step
198+
- **Escaping**: all server-controlled strings are inserted escaped (`escapeHtml`/`escapeAttr`/`textContent`); `markdownToHtml` HTML-escapes all input by default and allowlists link schemes — see "Content sanitization contract" above
199+
- **Untrusted envelope**: `js/untrusted.js` unwraps the model-facing `<untrusted_content_*>` envelope before display (body shown, source as badge)
165200
- **Design system**: loaded from `https://assets.21no.de/css/tokens.css` — dark theme with CSS custom properties (`--bg-primary`, `--accent`, `--text-primary`, etc.)
166201
- **Typeface**: loaded from `https://assets.21no.de/fonts/fonts.css` — uses `var(--font-sans)` and `var(--font-mono)`
167202
- **Streaming**: token content is batch-rendered via `requestAnimationFrame` to avoid layout thrashing
168-
- **DOM budget**: message list is capped at 100 elements (`MAX_MESSAGES`), older messages are pruned
203+
- **DOM budget**: message list is capped at 80 elements (`MAX_MESSAGES`), older messages are pruned
169204
- **Resilience**: auto-reconnects WebSocket on disconnect with 2s backoff
205+
- **Tests**: `node --test "cmd/odek/ui/js/**/*.test.js"` (golden tests for the markdown renderer and the untrusted-envelope parser)
170206

171207
## Tips
172208

0 commit comments

Comments
 (0)