Guide

Security & the trust model.

Aktion has two security surfaces, and almost every mistake comes from treating them as one. The program text is code — as privileged as a <script> tag. Everything the program renders is data — and that is what the sanitisers defend. This page explains where the line falls, and what to do when your program text is not fully trusted.

The mental model in one line

Sanitisers protect you from untrusted data flowing through a trusted program. They cannot protect you from an untrusted program author — that author would simply call eval. If program text can come from somewhere you do not control, call setGlobalAccessPolicy("safe") before you render anything.

Two layers, two threat models

Layer 1

The program text is trusted code

By default a program reaches the whole host realm: eval, Function, document, fetch, localStorage. Authoring a program is equivalent to shipping a JavaScript file. This is deliberate — it is what makes the language a productive authoring surface.

Layer 2

Everything it renders is untrusted data

API responses, LLM output, chat messages, tool results, URL parameters, user input. This is the surface the per-sink sanitisers defend, and it is the one that matters in the common case: a program you wrote, rendering values you did not.

The distinction decides which control you need. A cross-site-scripting attempt smuggled into a product name is a Layer 2 problem, and the sanitisers already handle it. A program authored by a prompt-injectable LLM is a Layer 1 problem, and no sanitiser addresses it — you need to narrow the global surface instead.

Aktion is not a sandbox by default

Do not read the sanitiser table below as "Aktion safely runs untrusted programs". Under the default policy it does not, and it does not try to. Reach for setGlobalAccessPolicy when the program author is not trusted, and treat that as the load-bearing control.

Which case are you in?

Where the program text comes fromTrust it asWhat to do
Your own repo, a build artefact, a reviewed .aktion file Your own source Default "all" policy is appropriate. Sanitisers still cover the data you render.
An LLM you prompt, with a system prompt you control, no user-controlled tool output in context Semi-trusted Prefer "safe". A program that only builds UI never needs eval or document.
An LLM whose context includes untrusted content (retrieved pages, emails, tool results) Untrusted — prompt injection is a code-execution path setGlobalAccessPolicy("safe"), mandatory.
A multi-tenant database, a user-editable template, a URL parameter Untrusted setGlobalAccessPolicy("safe"), or an explicit allow-list array.

Narrowing the global surface

setGlobalAccessPolicy gates the last step of identifier resolution — the passthrough to the embedding realm. Knowing where that step sits explains both what the policy can reach and what it cannot touch:

  1. user state ($name)
  2. bindings in scope
  3. action declarations
  4. user component declarations (PascalCase function)
  5. the curated GLOBAL_NAMESPACES fast path
  6. the component library
  7. cleanup
  8. host globals — policy-gated

Two consequences fall out of that ordering. Everything above step 8 is resolved before the policy is consulted, so the policy cannot break it. And a library component always wins over a same-named host constructor — the Text and Map components beat DOM Text and JS Map, under every policy.

import { setGlobalAccessPolicy, getGlobalAccessPolicy } from "aktion-runtime";

setGlobalAccessPolicy("safe");        // data + formatting only
getGlobalAccessPolicy();              // -> "safe"
PolicyReachable host globalsUse when
"all" (default) Everything on globalThis, including eval, Function, document, fetch, localStorage. Program text is as trusted as your own source code.
"safe" A fixed allow-list of data, formatting, and encoding globals — enumerated below. No code execution, no DOM, no network, no persistence. Program text may come from anywhere you do not fully control.
readonly string[] Exactly the names you list, and nothing else. You need one extra capability beyond "safe", or a stricter set than it.

What "safe" allows

Capabilities a program needs to compute with values — and nothing that grants code execution, DOM access, network access, or persistence:

CategoryGlobals
Values & collectionsBigInt Symbol Map Set WeakMap WeakSet Promise ArrayBuffer DataView and the nine typed-array constructors
Formatting & parsingIntl RegExp Error TypeError RangeError SyntaxError isNaN isFinite parseInt parseFloat
Encodingatob btoa TextEncoder TextDecoder encodeURIComponent decodeURIComponent encodeURI decodeURI structuredClone
URL parsing (inert — parses and formats, issues no request)URL URLSearchParams
Inert data containersBlob File FormData Headers
Diagnosticsconsole

What "safe" blocks, and why

ExcludedReason
eval Function WebAssemblyCode execution. Allowing any one of these makes the rest of the policy decorative.
window self globalThis top parent frames documentEach one re-exposes the whole realm, including the names excluded here.
fetch XMLHttpRequest EventSource WebSocket navigator WorkerNetwork and threads. $http is the vetted path — it flows through your host's interceptors.
localStorage sessionStorage indexedDB cachesPersistence. storage is the vetted path.
import Reflect ProxyReflection escapes — each can reconstruct a blocked capability.

The curated fast path is not affected by the policy

Math, JSON, Object, Array, Number, String, Boolean, Date, Map, Set, RegExp, Promise and friends resolve through a curated table before the host passthrough, so they keep working under every policy — they are already a vetted surface. The same is true of Aktion's own namespaces ($http, storage, $util, …) and of timers, which are handled by dedicated tracked handlers so they are cleared on dispose rather than leaking past a replan. Setting "safe" therefore does not break ordinary value-crunching programs.

A custom allow-list

Pass an array when "safe" is the wrong shape — either because you need one more capability, or because you want less. The array is exhaustive: nothing outside it resolves.

// "safe", plus crypto for client-side id generation
setGlobalAccessPolicy([
  "Map", "Set", "Promise", "Intl", "RegExp", "URL", "URLSearchParams",
  "encodeURIComponent", "decodeURIComponent", "atob", "btoa", "console",
  "crypto",
]);

// Stricter than "safe": formatting only, no containers, no console
setGlobalAccessPolicy(["Intl", "RegExp", "parseInt", "parseFloat", "isNaN"]);

A blocked name does not throw — it simply does not resolve, so the expression evaluates as if the identifier were undeclared. Turn on strict while developing to see those lookups reported instead of silently yielding undefined.

The policy is process-global

It is one setting for every program on the page — not per <aktion-app> and not per context. Call it once at host bootstrap, before mounting. In tests, reset it in an afterEach or a later suite will inherit it.

What breaks under "safe"

Worth knowing before you flip it, because the failures are quiet rather than loud:

Stops workingUse instead
eval("…"), Function("…")()Nothing — this is the point of the policy.
window.x, globalThis.x, self.x, top.x, document.…$dom where a vetted equivalent exists, otherwise restructure so the host does it.
bare fetch(…)$http({...}) — and it flows through your interceptors.
localStorage, sessionStoragestorage, the vetted namespace.
crypto.randomUUID(), navigator.…, alert/confirm/prompt, performance, location, history, matchMediaAdd the specific name via the array form if you genuinely need it.
$script({ src })disabled outright, not merely restrictedNothing. It loads and executes remote code, so any non-"all" policy switches it off. The resource bag comes back with error set and ready still false, so a program that gates on $s.ready degrades instead of crashing.

The rule is simply "not in the 46 names above", so treat that table as the allow-list and this one as the common cases — not as an exhaustive deny-list.

Why the policy is not trivially bypassable

Narrowing globals would accomplish nothing on its own, because every lambda a program can write is a real JavaScript function — and f.constructor is Function. So three property names are refused on every access path: dot access, computed access, method-call dispatch, and both write paths.

$f = () => 1
$f.constructor            // undefined — not the Function constructor
$f["cons" + "tructor"]    // undefined — computed access is checked too
$o.__proto__              // falsy
$o.prototype              // undefined

The same three segments (__proto__, constructor, prototype) are refused on state writes, where they would otherwise be a prototype-pollution primitive. A write through them is a silent no-op rather than an error.

Residual risk — "safe" bounds capability, it does not isolate the realm

Be clear-eyed about what you are buying. "safe" removes code execution, DOM, network, and storage capability. It is not a hardened sandbox and has not been adversarially audited as one. Specifically: Object stays available under every policy and still exposes getPrototypeOf and assign, which can be combined to reach and mutate shared prototypes; and $http, storage, $socket and $sse remain available by design, so a "safe" program can still talk to the network and still persist data.

For genuinely untrusted program text, do both: set the policy and host the app in a cross-origin <iframe> with a restrictive sandbox attribute, so a residual escape cannot reach your origin's cookies or DOM. Constrain the network at the host too — registerHttpInterceptors plus a CSP connect-src.

The shadow DOM is not a security boundary

<aktion-app> renders into a shadow root, which is easy to mistake for isolation. It is not. A shadow root gives you style encapsulation — the host page's CSS does not leak in, and the component stylesheet does not leak out. It gives you no origin separation: script running inside a shadow root has the host page's full origin privileges, and can read cookies, localStorage, and the rest of the document.

If you need a real boundary, use one the platform actually provides: a cross-origin <iframe> with a restrictive sandbox attribute. Inside the same document, setGlobalAccessPolicy is the control that matters.

The sink-to-sanitiser table

These defend Layer 2 — untrusted values flowing through a trusted program. They are always on; there is nothing to configure. The underlying principle is worth more than the table: every URL-typed prop in the library goes through one of two chokepoints (sanitiseHref for link targets, sanitiseImageSrc for image sources), so the guarantee holds for components added after this page was written.

These are runtime behaviours, not validation errors. A rejected value is dropped or replaced while rendering — the program still parses and still passes validateProgramSchema. So you will not catch a rel: "stylesheet" or a javascript: href in CI by validating the program; look for the missing output, or the console.warn where one is emitted.

SinkHelperWhat it does
Anchor hrefLink, BreadcrumbItem, NavbarItem, Markdown links sanitiseHref Allow-lists the http, https, mailto and tel schemes, plus fragment, root-relative, query-only and scheme-less relative paths. Rejects javascript:, vbscript:, data:, file:, protocol-relative //host/…, and control-character bypasses (java script: — C0 controls are stripped before scheme detection). A rejected URL collapses to a safe fallback: either # or no href at all, depending on the component.
Image srcImage, Avatar, MediaCard, Hero, Testimonial, ChatBubble sanitiseImageSrc Allow-lists http(s):, data:image/*, blob:, and relative paths. Anything else becomes an empty string so the caller renders a placeholder.
Inline style lengths — Container.maxWidth, Skeleton.height, … sanitiseCssLength Restricts the alphabet so semicolons and quotes cannot inject extra declarations.
background-image: url(…)Hero.imageSrc sanitiseCssUrl Drops characters that would close the url() literal.
helpers.openUrl(…) from an action body sanitiseHref (renderer) The renderer sanitises before calling window.open. External windows open with noopener,noreferrer.
Inline SVG — Svg, $theme({ icons }) sanitiseSvgMarkup Parses in an inert document and applies an element/attribute allow-list. Drops <script>, <foreignObject>, SVG <a> (whose href executes javascript:), <style>, <image>, SMIL <animate attributeName="href">, and every on*. Never assigns innerHTML on the live document.
Rich-text HTML — Markdown output, RichTextEditor value and read-back HTML sanitiser Same inert-document + allow-list strategy as the SVG path. Disallowed elements are unwrapped rather than deleted, so a stray <section> does not swallow the user's text. style is dropped outright and id is deliberately absent (a DOM-clobbering primitive); <img> survives, its onerror does not. It also sanitises on the way out of a contenteditable — a user can paste arbitrary markup, and an unsanitised read-back would persist it and re-inject it on the next render.
Inline style colours — sx colour props, token values sanitiseCssColor Restricted alphabet, 64-char cap, plus explicit rejection of url(, expression(, javascript:, and @import. var(--token) passes.
Styles — CSS body and the scope selector CSS filter + selector shape allow-list CSS is applied via textContent, never parsed as HTML, and is rejected wholesale if it contains </style, <script, expression(, javascript:, behavior:, or @import (64 KB cap). The scope prop is itself a sink — it is concatenated into the generated sheet, so it must match a plain-selector shape (128-char cap). On rejection the whole sheet is dropped rather than emitted unscoped, since an unscoped sheet would leak every rule into the shadow root. Every rejection is announced with console.warn.
$script({ src }) sanitiseScriptSrc + the policy gate http(s) or same-origin /, ./, ../ only; protocol-relative, javascript:, data: and blob: rejected. Disabled entirely whenever the global-access policy is not "all" — see what breaks under "safe".
WebComponentproperties and attributes blocked-property list + URL sanitisers Tag names must contain a hyphen, else the element collapses to a div with a one-time warn. on* is skipped on both maps; srcdoc is dropped; href/src/action/formaction/poster/ping attributes are sanitised. Sixteen property names are blocked, including innerHTML, outerHTML, insertAdjacentHTML, shadowRoot, contentEditable, style, id, and the three forbidden property names. Ordinary custom properties pass through untouched.
$theme({ icons }) registration registerIcons → SVG allow-list Icon names must match /^[a-zA-Z0-9:_-]+$/; markup is capped at 16 KB and re-sanitised again at render time, so a registry reached by another route still cannot inject.
Web-font import shorthand buildFontUrl Family names must match /^[A-Za-z0-9 ]{1,48}$/ and weights must be integers 100–900. The URL is constructed against fonts.googleapis.com — never taken from the program — so a hostile value cannot smuggle a different origin or a CSS payload into the page.
Markdown text and attributes text/attribute escapers Escapes attribute contexts as well as text (alt, fence info strings), decodes entities before scheme checks (&#106;avascript:), and isolates generated markup so a later pass cannot rewrite inside an earlier one.
$head({…}) — title, meta, link, base, htmlAttrs per-field allow-lists <base> limited to same-origin paths (an absolute base would re-target every relative URL in the host page); <link> limited to metadata/hint rel values (no stylesheet, preload, modulepreload); <html> attributes limited to lang/dir/class/data-*. Attribute names are validated so they cannot inject a second attribute into SSR output.
HTMLTag attributes tag + attribute allow-list Unknown tags collapse to div; on* dropped; href/src sanitised; srcset/srcdoc/data/background dropped; target="_blank" forced to carry rel="noopener noreferrer".
Cookies — storage.cookies attribute validation Name and value percent-encoded; path/domain validated so a ; cannot append attributes; SameSite always emitted, defaulting to Lax.
State-path writes — $a.b.c = … forbidden-segment check __proto__, constructor, and prototype segments are refused, so an untrusted key cannot reach Object.prototype.
CSV export — DataGrid formula-injection guard Cells starting =, +, -, @, TAB, or CR are prefixed so a spreadsheet reads them as text rather than a live formula.

External links rendered by Link, NavbarItem, and the Markdown renderer carry rel="noopener noreferrer", so the destination cannot read the opener's document.referrer.

Resource bounds

Every one of these limits is data-driven, so without them untrusted data — a 40 MB Markdown blob, an array with a billion entries, a self-nesting component tree — could freeze the host tab. That makes them a security control, not just a performance one.

BoundLimit
Component nesting depth150
Loop iterations per render, across all loops250,000
Array length for @Range / @Repeat100,000
Markdown document / single line128 KB / 8 KB
SVG sanitiser input / nodes / depth64 KB / 4,096 / 32
HTML sanitiser input / nodes / depth512 KB / 8,192 / 64
Styles CSS body / scope selector64 KB / 128 chars
Custom-icon markup16 KB
Progress.segments / Text.lines200 / 20 (clamped)
Highest materialised state index ($rows[n] = …)1,000,000

The failure modes differ, and the difference matters when you are debugging. Over-long Markdown is truncated, so the beginning still renders. Clamped props silently take the ceiling. Budget overruns throw RuntimeBudgetError, which the host surfaces as a banner rather than a hung tab — see Performance for tuning the budget and Error handling for catching it.

Keeping network access under host control

$http({...}) is the vetted network path, and it is the only one left under a "safe" policy. Every request flows through your host's interceptor chain, so auth headers, CORS workarounds, and refresh-token retries stay yours to decide:

app.registerHttpInterceptors({
  onRequest: (req) => ({ ...req, headers: { ...req.headers, Authorization: `Bearer ${token}` } }),
  onResponse: (res) => res,
  onError: (err) => { report(err); throw err; },
});

Use onRequest to enforce a destination allow-list when programs are semi-trusted — the interceptor sees the final URL and can reject it before the request leaves the page. See HTTP for the full contract.

Content-Security-Policy

A CSP hardens the host page. It is useful defence in depth, and the two controls compose neatly here — but only in one direction, so it is worth being precise about which does what.

The complete policy — including the style-src, font-src, and img-src directives the renderer actually requires — is in Production & deployment.

Node-side tooling

On the server, Layer 1 gets sharper

renderToString evaluates the program. In the browser an untrusted program is same-origin XSS; on your server it is remote code execution, with the filesystem, the internal network, and your environment variables in reach. Set the policy in the Node entry point too, before you render, and run it in a separate low-privilege process.

import { setGlobalAccessPolicy, renderToStaticMarkup } from "aktion-runtime";

setGlobalAccessPolicy("safe");           // before rendering anything untrusted
const html = renderToStaticMarkup(program);

Two more things to know about the SSR path. The $head allow-lists apply there as well — in fact the attribute-name validation exists specifically because the server path emits names outside quotes. And the hydration snapshot it hands back is your job to escape for the <script> context you serialise it into; the runtime does not do that for you.

The build tooling has its own path-confinement story:

Review checklist

Reporting a vulnerability

Report security issues privately — please do not open a public issue first. The repository's SECURITY.md carries the current contact route, the supported-version policy, and the reporting scope, including which behaviours are considered by-design rather than vulnerabilities. Behaviour reachable only under the default "all" policy with attacker-authored program text is in the by-design category — that is the surface setGlobalAccessPolicy exists to close.

Next