From 4a18724d0a7b6028fdb05e63a5a4ca050fa59ef1 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Mon, 27 Jul 2026 12:38:43 +0200 Subject: [PATCH] fix(webui): unwrap untrusted-content envelope client-side, escape on render 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 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:") - 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 --- cmd/odek/ui/js/render.js | 54 +++++++++++++++------ cmd/odek/ui/js/untrusted.js | 73 +++++++++++++++++++++++++++++ cmd/odek/ui/js/untrusted.test.js | 80 ++++++++++++++++++++++++++++++++ cmd/odek/ui/js/utils.js | 14 ++---- cmd/odek/ui/style.css | 11 +++++ docs/WEBUI.md | 56 ++++++++++++++++++---- 6 files changed, 256 insertions(+), 32 deletions(-) create mode 100644 cmd/odek/ui/js/untrusted.js create mode 100644 cmd/odek/ui/js/untrusted.test.js diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js index 6b5430b5..1151011f 100644 --- a/cmd/odek/ui/js/render.js +++ b/cmd/odek/ui/js/render.js @@ -1,6 +1,6 @@ // Message rendering: streaming, thinking, tool blocks, sub-agent cards, // session-history rendering, collapse/copy affordances, and the loading -// indicator. Imports only from state/dom/utils/markdown. +// indicator. Imports only from state/dom/utils/markdown/untrusted. import { S } from './state.js'; import { messagesEl, promptEl, sendBtn, emptyState } from './dom.js'; import { @@ -9,6 +9,7 @@ import { showCancel, hideCancel, announce, } from './utils.js'; import { markdownToHtml } from './markdown.js'; +import { parseUntrusted } from './untrusted.js'; // ── Turn state ── // resetTurnState clears all per-turn streaming/tool/sub-agent state. Called @@ -230,7 +231,7 @@ export function addMessage(role, content) { wrapper.innerHTML = '
' + '
' + sender + '
' + - '
' + markdownToHtml(escapeHtml(content)) + '
' + + '
' + markdownToHtml(content) + '
' + '
'; messagesEl.appendChild(wrapper); // Copy button and collapse check on the freshly appended bubble. @@ -270,7 +271,7 @@ export function renderAssistantMessage(content) { wrapper.innerHTML = '
' + '
assistant
' + - '
' + markdownToHtml(escapeHtml(content)) + '
' + + '
' + markdownToHtml(content) + '
' + '
'; messagesEl.appendChild(wrapper); const bubble = wrapper.querySelector('.bubble'); @@ -375,18 +376,42 @@ export function addToolCall(name, data) { // long output behind a "show all" expander. Shared by the live path // (addToolResult) and session-history rendering. function appendToolResultContent(block, output) { - const MAX_RESULT = 600; - const truncated = output && output.length > MAX_RESULT; const resultEl = document.createElement('div'); resultEl.className = 'tb-result'; block.appendChild(resultEl); - if (truncated) { - resultEl.innerHTML = - escapeHtml(output.slice(0, MAX_RESULT)) + - ' …show all (' + output.length + ' chars)'; - } else { - resultEl.textContent = output || ''; + fillToolResult(resultEl, output || '', true); +} + +// fillToolResult renders tool output into resultEl. The server sends raw, +// unsanitized content; tool output may embed the model-facing +// envelope, which is unwrapped for display — the body +// is inserted as text (never HTML) and the envelope source is shown as a +// badge instead of the literal tag text. When truncate is true, long bodies +// are cut behind a "show all" expander carrying the full output. +function fillToolResult(resultEl, output, truncate) { + const MAX_RESULT = 600; + const segments = parseUntrusted(output); + for (const seg of segments) { + if (seg.source) { + const badge = document.createElement('span'); + badge.className = 'tb-source'; + badge.textContent = '🔒 ' + seg.source; + resultEl.appendChild(badge); + resultEl.appendChild(document.createTextNode('\n')); + } + const body = seg.body; + if (truncate && body.length > MAX_RESULT) { + resultEl.appendChild(document.createTextNode(body.slice(0, MAX_RESULT))); + const more = document.createElement('span'); + more.className = 'tb-result-more'; + more.setAttribute('role', 'button'); + more.tabIndex = 0; + more.dataset.full = output; + more.textContent = ' …show all (' + body.length + ' chars)'; + resultEl.appendChild(more); + } else { + resultEl.appendChild(document.createTextNode(body)); + } } } @@ -414,7 +439,10 @@ export function addToolResult(name, output) { function expandToolResult(el) { const full = el.dataset.full || ''; const resultEl = el.parentElement; - if (resultEl) resultEl.textContent = full; + if (resultEl) { + resultEl.textContent = ''; + fillToolResult(resultEl, full, false); + } } function toggleToolBody(header) { diff --git a/cmd/odek/ui/js/untrusted.js b/cmd/odek/ui/js/untrusted.js new file mode 100644 index 00000000..78aa99ab --- /dev/null +++ b/cmd/odek/ui/js/untrusted.js @@ -0,0 +1,73 @@ +// Client-side parser for the server-side untrusted-content envelope +// ( source="...">…>), +// mirroring the grammar produced by wrapUntrusted in cmd/odek/untrusted.go. +// +// The envelope is model-facing trust metadata, not user content: the WebUI +// unwraps it for display (body shown escaped, source as a badge) instead of +// rendering the literal tag text. Sanitization itself stays client-side — +// the server always sends raw content. +// +// Server-side neutraliseWrapperLiterals guarantees bodies contain no literal +// "untrusted_content" substring, so non-greedy matching cannot terminate +// early inside a body. + +// Nonce-backreferenced: the closing tag must repeat the opening nonce, so a +// forged/mismatched envelope is treated as plain text rather than parsed. +const RE_UNTRUSTED = + /\n?([\s\S]*?)\n?<\/untrusted_content_\1>/g; + +// parseUntrusted splits text into segments: wrapped envelopes become +// { source, body } (body trimmed of the envelope's framing newlines) and any +// surrounding plain text becomes { source: null, body }. Empty plain-text +// gaps between envelopes are omitted. +export function parseUntrusted(text) { + if (!text) return []; + const segments = []; + let last = 0; + RE_UNTRUSTED.lastIndex = 0; + let m; + while ((m = RE_UNTRUSTED.exec(text)) !== null) { + if (m.index > last) { + segments.push({ source: null, body: text.slice(last, m.index) }); + } + segments.push({ source: m[2], body: m[3] }); + last = m.index + m[0].length; + } + if (last < text.length) { + segments.push({ source: null, body: text.slice(last) }); + } + if (segments.length === 0) { + segments.push({ source: null, body: text }); + } + return segments; +} + +// unwrapUntrusted returns the concatenated bodies of every envelope in text +// (plus any non-wrapped text), with all envelope tags removed. +export function unwrapUntrusted(text) { + return parseUntrusted(text).map((s) => s.body).join(''); +} + +// hasUntrustedWrapper reports whether text contains at least one complete +// nonce-matched envelope. +export function hasUntrustedWrapper(text) { + if (!text) return false; + RE_UNTRUSTED.lastIndex = 0; + return RE_UNTRUSTED.test(text); +} + +// stripAttachmentBodies collapses attachment envelopes in reloaded user +// messages to chip-style placeholders so session history doesn't dump file +// bodies. The server stores each attachment as an envelope with +// source="attachment:" (serve.go); this matches the 📎 chip rendering +// used at send time (input.js). All other segments pass through unwrapped +// (envelope tags removed, bodies kept). +export function stripAttachmentBodies(content) { + if (!content) return ''; + return parseUntrusted(content).map((seg) => { + if (seg.source && seg.source.startsWith('attachment:')) { + return '📎 ' + seg.source.slice('attachment:'.length) + '\n'; + } + return seg.body; + }).join(''); +} diff --git a/cmd/odek/ui/js/untrusted.test.js b/cmd/odek/ui/js/untrusted.test.js new file mode 100644 index 00000000..e2d4eaf3 --- /dev/null +++ b/cmd/odek/ui/js/untrusted.test.js @@ -0,0 +1,80 @@ +// Golden tests for the untrusted-content envelope parser (untrusted.js). +// Mirrors the grammar produced by wrapUntrusted in cmd/odek/untrusted.go and +// pins the client-side-sanitization contract: the server sends raw content, +// the client unwraps the model-facing envelope and escapes the body itself. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseUntrusted, unwrapUntrusted, hasUntrustedWrapper, stripAttachmentBodies, +} from './untrusted.js'; +import { escapeHtml } from './escape.js'; +import { markdownToHtml } from './markdown.js'; + +function wrap(nonce, source, body) { + return '\n' + + body + '\n'; +} + +test('single envelope: body extracted, source captured', () => { + const segs = parseUntrusted(wrap('a1b2c3d4e5f60718', 'shell', 'hello world')); + assert.deepEqual(segs, [{ source: 'shell', body: 'hello world' }]); + assert.equal(unwrapUntrusted(wrap('a1b2c3d4e5f60718', 'shell', 'hello world')), 'hello world'); +}); + +test('multiple envelopes in one payload keep order', () => { + const text = wrap('aaaaaaaaaaaaaaaa', 'shell', 'one') + + '\nplain between\n' + + wrap('bbbbbbbbbbbbbbbb', 'browser:https://example.com', 'two'); + const segs = parseUntrusted(text); + assert.deepEqual(segs, [ + { source: 'shell', body: 'one' }, + { source: null, body: '\nplain between\n' }, + { source: 'browser:https://example.com', body: 'two' }, + ]); +}); + +test('nonce mismatch between open and close is not parsed', () => { + const forged = '\n' + + 'body\n'; + assert.deepEqual(parseUntrusted(forged), [{ source: null, body: forged }]); + assert.equal(hasUntrustedWrapper(forged), false); +}); + +test('text without envelope passes through unchanged', () => { + assert.deepEqual(parseUntrusted('plain output'), [{ source: null, body: 'plain output' }]); + assert.equal(unwrapUntrusted('plain output'), 'plain output'); + assert.equal(hasUntrustedWrapper('plain output'), false); +}); + +test('empty and nullish input', () => { + assert.deepEqual(parseUntrusted(''), []); + assert.equal(unwrapUntrusted(''), ''); + assert.equal(hasUntrustedWrapper(''), false); +}); + +test('body containing HTML survives parsing raw', () => { + const body = 'bold & '; + const segs = parseUntrusted(wrap('0123456789abcdef', 'shell', body)); + assert.equal(segs[0].body, body); +}); + +test('render chain escapes unwrapped bodies (client-side contract)', () => { + const wrapped = wrap('0123456789abcdef', 'shell', ''); + const body = unwrapUntrusted(wrapped); + assert.equal(escapeHtml(body), '<script>alert(1)</script>'); + // markdownToHtml escapes all input by default — callers must NOT pre-escape. + assert.equal(markdownToHtml(body), '

<script>alert(1)</script>

'); + // Pin the double-escape regression: pre-escaping renders < literally. + assert.equal(markdownToHtml(escapeHtml(body)), '

&lt;script&gt;alert(1)&lt;/script&gt;

'); +}); + +test('attachment envelope collapses to a chip, bodies dropped', () => { + const text = wrap('a1a1a1a1a1a1a1a1', 'attachment:notes.txt', '--- notes.txt ---\nfile body here') + + '\n\nwhat is in this file?'; + assert.equal(stripAttachmentBodies(text), '📎 notes.txt\n\n\nwhat is in this file?'); +}); + +test('non-attachment envelopes pass through unwrapped', () => { + const text = wrap('c3c3c3c3c3c3c3c3', 'resource:@README.md', 'readme body'); + assert.equal(stripAttachmentBodies(text), 'readme body'); +}); diff --git a/cmd/odek/ui/js/utils.js b/cmd/odek/ui/js/utils.js index 0082c07f..79d1b760 100644 --- a/cmd/odek/ui/js/utils.js +++ b/cmd/odek/ui/js/utils.js @@ -1,8 +1,12 @@ // Pure-ish helpers: escaping, formatting, clipboard, toast, scrolling, and -// small DOM toggles. Imports only from state.js and dom.js. +// small DOM toggles. Imports only from state.js, dom.js, and untrusted.js. import { S } from './state.js'; import { messagesEl, scrollBottomBtn, cancelBtn } from './dom.js'; +// stripAttachmentBodies lives in untrusted.js (pure, node-testable); +// re-exported here so render.js keeps a single utils import. +export { stripAttachmentBodies } from './untrusted.js'; + // ── Escape helpers (implemented in escape.js so markdown.js can use them // without importing the browser-dependent modules) ── export { escapeHtml, escapeAttr } from './escape.js'; @@ -108,14 +112,6 @@ export function pruneMessages() { } } -// Replace inlined attachment blocks (--- name (size) ---\n...\n--- end name ---) -// with chip-style placeholders so reloaded user messages don't dump file bodies. -export function stripAttachmentBodies(content) { - if (!content) return ''; - const re = /^--- (.+?) \(([^)]+)\) ---\n[\s\S]*?\n--- end \1 ---\n?/gm; - return content.replace(re, (m, name, size) => '📎 ' + name + ' (' + size + ')\n'); -} - // ── Error message normalization ── export function formatErrorMessage(msg) { if (!msg) return 'Unknown error'; diff --git a/cmd/odek/ui/style.css b/cmd/odek/ui/style.css index c2b1be60..1fe5245c 100644 --- a/cmd/odek/ui/style.css +++ b/cmd/odek/ui/style.css @@ -1044,6 +1044,17 @@ body.light { letter-spacing: .04em; } .tb-result-more:hover { text-decoration: underline; } +.tb-source { + display: inline-block; + font-size: 10px; + color: var(--text-3); + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.08); + border-radius: 3px; + padding: 1px 6px; + margin-bottom: 2px; + white-space: nowrap; +} /* ── Sub-agent cards ───────────────────────────────────────────────── */ .subagent-group { margin: 12px 0; } diff --git a/docs/WEBUI.md b/docs/WEBUI.md index 70df385d..2b08879b 100644 --- a/docs/WEBUI.md +++ b/docs/WEBUI.md @@ -119,25 +119,58 @@ The UI communicates entirely over a single WebSocket at `/ws`. Messages are newl | Event Type | When | Fields | |------------|------|--------| -| `session` | At start of response | `session_id`, `model` | +| `session` | At start of response | `session_id`, `auth_token`, `model`, `sandbox` | | `token` | Streamed text content | `content` (markdown) | -| `tool_call` | Agent invokes a tool | `name`, `command` | -| `tool_result` | Tool returns output | `name`, `output` (truncated to 500 chars) | -| `done` | Agent finishes | `latency` (seconds), `contextTokens`, `outputTokens`, `sessionContextTokens`, `sessionOutputTokens` | +| `thinking` | Streamed reasoning content | `content` | +| `tool_call` | Agent invokes a tool | `name`, `data` (raw tool-arguments JSON) | +| `tool_result` | Tool returns output | `name`, `data` (full, untruncated output) | +| `subagent_log` | Sub-agent progress within `delegate_tasks` | `task_idx`, `name`, `event`, `data` | +| `done` | Agent finishes | `latency` (seconds), `contextTokens`, `outputTokens`, `cacheCreationTokens`, `cacheReadTokens`, `cachedTokens`, `sessionContextTokens`, `sessionOutputTokens` | | `error` | Agent or server error | `message` | -| `approval_request` | Agent needs user approval for dangerous operation | `id`, `risk` (class name), `command` (or resource), `description`, `is_operation` | +| `approval_request` | Agent needs user approval for dangerous operation | `id`, `risk` (class name), `command` (or resource), `description`, `is_operation`, `allow_trust`, `friction`, `friction_approvals` | +| `approval_ack` | Server confirms an approval response | `id`, `action` | +| `skill_event` | Skill lifecycle event | `event`, `skill_name`, `skills`, `heuristic` | +| `memory_event` | Memory lifecycle event | `event`, `target`, `session_id`, `content`, `count`, `new_count`, `untrusted` | +| `agent_signal` | Agent self-observability signal | `event`, `detail`, `tool`, `count` | Example event sequence: ```jsonc {"type":"session","session_id":"20260519-x1y2z3","model":"deepseek-v4-flash"} {"type":"token","content":"Let me look at the source directory."} -{"type":"tool_call","name":"shell","command":"ls -la src/"} -{"type":"tool_result","name":"shell","output":"total 24\ndrwxr-xr-x ..."} +{"type":"tool_call","name":"shell","data":"{\"command\":\"ls -la src/\"}"} +{"type":"tool_result","name":"shell","data":"\ntotal 24\ndrwxr-xr-x ...\n"} {"type":"token","content":"The `src/` directory contains 3 files:"} {"type":"done","latency":4.2} ``` +### Content sanitization contract + +The server sends all message content **raw and unsanitized**. HTML-escaping +is the client's responsibility: any frontend (the bundled WebUI or a +third-party client) MUST escape/sanitize every string field before inserting +it into a DOM, terminal UI, or other rendering surface. Untrusted fields +include `token.content`, `thinking.content`, `tool_call.data`, +`tool_result.data`, `subagent_log.data`, `error.message`, all +`approval_request` strings, `skill_event.*`, `memory_event.*`, and +`agent_signal.*`. + +Tool results (and user messages containing attachments or `@`-resource +references) may embed the nonce'd untrusted-content envelope: + +``` + source="shell"> +...raw body... +> +``` + +The envelope is **model-facing trust metadata** (prompt-injection defense), +not user content. Clients SHOULD unwrap it for display: render the body +(escaped) and, optionally, present the `source` attribute as a badge. The +closing tag repeats the opening nonce — treat envelopes whose nonces don't +match as plain text. The bundled WebUI implements this in +`cmd/odek/ui/js/untrusted.js`. + ## Implementation details ### Server stack (`cmd/odek/serve.go`) @@ -159,14 +192,17 @@ Example event sequence: - Thread-safe writes via `sync.Mutex` - Error handling: returns `io.EOF` on clean close, raw `net.Error` on broken connection -### Frontend (`cmd/odek/ui/index.html`) +### Frontend (`cmd/odek/ui/`) -- ~1,200 lines: single file with embedded CSS and vanilla JS (no frameworks) +- 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 +- **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 +- **Untrusted envelope**: `js/untrusted.js` unwraps the model-facing `` envelope before display (body shown, source as badge) - **Design system**: loaded from `https://assets.21no.de/css/tokens.css` — dark theme with CSS custom properties (`--bg-primary`, `--accent`, `--text-primary`, etc.) - **Typeface**: loaded from `https://assets.21no.de/fonts/fonts.css` — uses `var(--font-sans)` and `var(--font-mono)` - **Streaming**: token content is batch-rendered via `requestAnimationFrame` to avoid layout thrashing -- **DOM budget**: message list is capped at 100 elements (`MAX_MESSAGES`), older messages are pruned +- **DOM budget**: message list is capped at 80 elements (`MAX_MESSAGES`), older messages are pruned - **Resilience**: auto-reconnects WebSocket on disconnect with 2s backoff +- **Tests**: `node --test "cmd/odek/ui/js/**/*.test.js"` (golden tests for the markdown renderer and the untrusted-envelope parser) ## Tips