Wardian docs

Security model

Wardian runs code you did not write: apps from a colleague, from a zip, or from Claude a minute ago. So it treats every app as a stranger. The browser keeps each app in a sandbox, and the Wardian server checks every request an app makes through it.

This page says what each kind of app can and cannot do, how permissions work, and what Wardian does not protect. Each claim names the file that enforces it.

The short version#

Module appPage appSuite app
Runs whereWardian's own pageits own sandboxed frameits own sandboxed frame
Reaches the networkno (it has no imports)only its own package, but see three routesno, but see three routes
Reads Wardian's settings or other apps' datanonono
Keeps datano (its Arrange layout only)no (its Arrange layout only)with storage or db
Talks to other appsnothrough channels, with your permissionthrough the kernel; to other packages through channels, with your permission
Uses Splunk or Claudenonowith the capability and your permission

Module apps#

A module app is an app.wasm and nothing else. Wardian's own page compiles it and draws one card per function (static/index.html, hostImports).

Not protected: the functions run on Wardian's own page, in the same thread. A function that never returns freezes that browser tab.

Page apps#

A page app shows its own HTML page. The server sends every HTML and SVG file of a page app with a policy built for that package and the address you reached Wardian at (ADR-2610081003; src/domain/suite.rs, page_csp). For the app text-tools on 127.0.0.1:8000:

sandbox allow-scripts allow-forms allow-modals allow-popups allow-downloads;
default-src 'none';
script-src http://127.0.0.1:8000/apps/text-tools/ http://127.0.0.1:8000/sdk/ 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:;
style-src http://127.0.0.1:8000/apps/text-tools/ 'unsafe-inline' https://fonts.googleapis.com;
font-src http://127.0.0.1:8000/apps/text-tools/ https://fonts.gstatic.com data:;
img-src, media-src, connect-src: http://127.0.0.1:8000/apps/text-tools/ data: blob:;
worker-src, frame-src: http://127.0.0.1:8000/apps/text-tools/ blob:;
form-action http://127.0.0.1:8000/apps/text-tools/; base-uri 'none'; object-src 'none'

Wardian's page also puts the frame in an <iframe sandbox="…"> with the same sandbox list. The browser gives the page a unique, throwaway origin, even when you open it in its own tab (Package format 5.6). So a page app:

tests/run-page-sandbox-e2e.sh proves this. A hostile page, tests/fixtures/rogue-page, tries each of those requests against a second, "outside" server, and the test fails if one request reaches it, if the page reads Wardian's API or another package, or if the page cannot load its own files and /sdk/wardian.js. Three routes stay open, for page apps and suite parts alike; see below.

A page app talks to Wardian only by postMessage, for three things: channels (/sdk/wardian.js), its Arrange layout, and Save as web page. Wardian's page answers only the frame it made.

Suite apps#

A suite is several small apps. Wardian's kernel page (static/kernel.html) runs each app in its own sandboxed frame. The frames talk only to the kernel, and the kernel passes on only what suite.json allows.

The frame#

The server builds each frame as one document: the suite's styles, the app's view, its scripts, the kernel shim (static/shim.js) and app.js, all inlined (Package format 6.3). It sends the frame with this policy (src/domain/suite.rs, FRAME_CSP):

sandbox allow-scripts allow-forms allow-modals allow-popups allow-downloads;
default-src 'none'; script-src 'unsafe-inline' 'wasm-unsafe-eval' blob:; worker-src blob:;
style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com;
img-src data: blob:; connect-src 'none'; form-action 'none'; base-uri 'none'
RuleEffect
sandbox …A throwaway origin: no cookies, no browser storage, no reach into Wardian's page or other frames.
default-src 'none', connect-src 'none'No fetch, no XHR, no WebSocket, no outside file of any kind.
script-src 'unsafe-inline' 'wasm-unsafe-eval' blob:Only the inlined scripts and Web Workers made from them. WebAssembly may compile; JavaScript eval may not.
style-src, font-srcInline styles, plus Google Fonts and nothing else.
img-src data: blob:Images must be made in the frame.
form-action 'none', base-uri 'none'No form can post anywhere; no <base> can redirect links.

The test tests/run-suite-e2e.sh runs a hostile suite, tests/fixtures/rogue. Its probe app tries to fetch Wardian's API and the internet, read the host page, use localStorage, emit an undeclared topic, use undeclared capabilities, and skip the shim to post raw messages to the kernel. The test fails if any of these works.

The contract#

suite.json lists each app's contract: what it emits, listens to, provides, needs, its caps and its channels. The kernel enforces the contract in suite.json, never the one in the app's code (Package format 6.4). The copy in app.js must match it, or the shim reports a fault and does not start the app.

The kernel knows which app sent a message by the frame it came from (e.source), never by what the message says. A message from any other window is ignored.

Kernel guarantees#

These come from Package format 6.7, and static/kernel.html enforces each one:

  1. A suite app can reach nothing outside its frame except through the kernel.
  2. The kernel delivers a message only to apps whose listens contain its topic.
  3. The kernel replays the latest payload of a retained topic to every app that starts listening.
  4. The kernel forwards a call only if the caller needs it and the callee provides it. Only the called app can answer it.
  5. A call to an app that has not finished starting waits for up to 15 seconds.
  6. Any refusal is recorded as a fault. Faults show on the suite page and in Kernel.faults().

Every payload, argument and result is copied by postMessage (structured clone). No object is ever shared between two apps.

Capabilities and permissions#

An app starts with nothing. It gets a capability only if its contract lists it, and the kernel refuses every other one (Capabilities). Some capabilities also need your permission:

WhatPermission asked asChecked by
send or receive on a channelsend or receive on that channelWardian's page or kernel (static/channels.js)
splunkuse of splunkthe kernel, then the server on every search
claude:sampleuse of aithe kernel, then the server on every request
db, reading another package's tablesuse of tables.<package>the kernel, then the server on every read
db, the app's own tablesnonethe server: the app must declare db

The first time an app uses one of these, a bar asks you: Allow, Don't allow or Not now.

A permission belongs to the package, not to one app of a suite. But inside a suite, only the entries that declare a channel or capability can use it.

The server checks again#

The kernel is Wardian's own code, but the server does not rely on it. For every Splunk search, Claude request and database call, the server reads the package's suite.json and checks that the named app declares the capability, and that grants.json allows it (src/usecases/catalog.rs, check_host_cap; src/domain/grants.rs). The server only accepts a permission for a host capability it knows (splunk, ai) or for tables.<package>.

Channels are different: messages travel between Wardian tabs in your browser, through a BroadcastChannel. The server keeps only the latest message on each channel, in state/channels.json (ADR-2610071055), so a package that starts later gets it; it does not pass messages between packages. Wardian's page stamps each message with the sending package's name, so a package cannot pretend to be another. A package can only send or receive on channels it declares, and the kernel and Wardian's page check that before they ask you.

Splunk and Claude run with the server's account#

An app never sees the Splunk address, token or password, or the Claude key. The server runs the search or the request with its own account, and gives the app only the result. So the Splunk role you give Wardian is the real limit on what any app can read. Use a read-only role that sees only the indexes these apps need (Splunk).

Who is an admin#

Only an admin can change Settings, import or export apps, browse Drive, read an app's history, answer a permission question, and use the server for Splunk, Claude and databases (src/adapters/primary/http.rs, is_admin).

Without a token, every program on this machine is an admin. That is fine for one person on a laptop and wrong for a shared server. So Wardian refuses to start on any address but 127.0.0.0/8, ::1 or localhost unless an admin token is set (src/config.rs, admins). It is refused, not warned about. A token saved in Settings that cannot be read stops Wardian at start, so it never starts unlocked by mistake. While Wardian listens on such an address, the saved token cannot be removed.

Other web sites cannot post to the API from your browser either. Every API call that changes something needs Content-Type: application/json (or application/zip for an upload). A browser must ask the server first before it sends those across sites, and Wardian never says yes.

Keys and secrets#

Imports#

A zip from a stranger is an attack surface. Import refuses the whole zip, writes nothing, and says why, if any entry (src/domain/import_plan.rs, src/usecases/import.rs):

A zip may have at most 10,000 entries, and may be at most 100 MB (64 MB from Drive). Import unpacks into a hidden staging folder first, so a failed import changes nothing. It refuses an app whose name is taken, unless you tick Replace. A .wardian file's data is checked before it is installed: one manifest, plain files, an SQLite header, SQLite's quick_check, and the database size cap. Data is installed only when you ask, and permissions are never imported.

Import also takes a link. The server fetches it, so a link could try to reach places only the server can see. Wardian resolves every host itself, for the first request and for every redirect, and drops these addresses (src/adapters/secondary/link_fetch.rs):

An IPv4 address written as IPv6 gets the IPv4 rules. Only http:// and https:// links are taken. A download stops after 60 seconds.

App databases#

The db capability lets an app send its own SQL to the server. Several walls keep it inside its own file (src/adapters/secondary/sqlite_store.rs):

Each of these has a refusal test in the same file, including VACUUM INTO, a runaway recursive query and a database grown past its cap.

Save as web page#

Save as web page puts what an app shows into one .html file. The app writes that HTML, so Wardian does not trust it (Package format 7.4). Wardian's page cleans it in the browser, on inert parsed HTML, by an allow-list (static/index.html):

The file starts with a policy that blocks anything that gets through:

<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">

tests/run-snapshot-e2e.sh saves a suite whose own snapshot tries to put a script, an onerror and a javascript: link in the file, and checks that none gets in.

Cleaning keeps the file safe to open. It does not decide what is in it. Everything on screen goes into the file, and an app may add more than it shows, so check the file before you send it.

What Wardian does not protect#

Be clear about these before you run apps you did not write.

Three routes stay open#

A content policy cannot close these, in a page app or in a suite part. An app could put what it holds into the address it goes to:

RouteHow
A pop-upafter a click, window.open('https://…?data') opens any address (allow-popups)
Navigating itself awaylocation.href = 'https://…?data' loads any address in the app's own frame
WebRTCRTCPeerConnection connects to a TURN server at any address

tests/run-page-sandbox-e2e.sh proves each one is still open, from a page app and from a suite part (tests/fixtures/rogue-suite-open). If a browser closes one, the test fails, so this page is corrected rather than left claiming a risk that is gone.

Other limits#

Open risks before 1.0#

ADR-2610072033 lists what must be true before Wardian 1.0. Some items are done: the SQL wall and hostile-import tests, the admin rule, the secrets test, the stop log, the load test and CI. These are still open:

This page is docs/site/security.md in the repository. Something wrong or missing? Change that file.