diff --git a/cmd/odek/ui/js/render.js b/cmd/odek/ui/js/render.js
index 6b5430b..1151011 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 0000000..78aa99a
--- /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 0000000..e2d4eaf
--- /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)), '<script>alert(1)</script>
');
+});
+
+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 0082c07..79d1b76 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 c2b1be6..1fe5245 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 70df385..2b08879 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