You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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
+
141
174
## Implementation details
142
175
143
176
### Server stack (`cmd/odek/serve.go`)
@@ -159,14 +192,17 @@ Example event sequence:
159
192
- Thread-safe writes via `sync.Mutex`
160
193
- Error handling: returns `io.EOF` on clean close, raw `net.Error` on broken connection
161
194
162
-
### Frontend (`cmd/odek/ui/index.html`)
195
+
### Frontend (`cmd/odek/ui/`)
163
196
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)
165
200
-**Design system**: loaded from `https://assets.21no.de/css/tokens.css` — dark theme with CSS custom properties (`--bg-primary`, `--accent`, `--text-primary`, etc.)
166
201
-**Typeface**: loaded from `https://assets.21no.de/fonts/fonts.css` — uses `var(--font-sans)` and `var(--font-mono)`
167
202
-**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
169
204
-**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)
0 commit comments