Souspli

Souspli / Docs / Design notes

Design note — 2026-07-16. A decision record, written when this was built and kept as written. It uses the code's names (thing, shell, cage — see the glossary) and may describe things that have since changed. Current documentation: container.

Build brief — the cage

This document is a build brief for phase 1 of a larger project. It is deliberately scoped to one deliverable: a hardened Electron renderer (the "cage") that runs arbitrary untrusted HTML/CSS/JS with no network access and no ambient authority, plus a test suite of escape attempts that proves the cage holds.

Do not build the format, envelope/signing, naming, torrent transport, or feed UI here. Those are later phases. The only job now is: load a self-contained HTML file into a view that cannot reach the network, cannot persist data, cannot open windows, and cannot navigate — and demonstrate that with tests.


Context (read this first)

The larger system distributes content as self-contained "things": single HTML files (inline JS/CSS) that render themselves and can build new instances of themselves. State is passed in as an argument, never fetched. The security model is: a thing is untrusted code with nothing worth stealing and nowhere to send it. All authority — keys, networking, storage, signing — lives in a trusted shell outside the cage. The thing gets a tiny message bridge and nothing else.

The cage is the wall between those two worlds. If it holds, a malicious thing can at worst render deceptive pixels inside its own frame; it cannot exfiltrate, phone home, persist a tracking identifier, or escalate. This brief builds that wall and the tests that prove it.

Design stance: enforce "no network" at multiple independent layers so that any single layer failing is not a breach. Prefer denying by default and allowing narrowly. The cage should be small and auditable — resist adding convenience features to it.


Tech stack

Target this repo initially at a personal GitHub/npm account; it will move to an org later. Don't set up publishing, code signing, or auto-update — those are explicitly out of scope for now. A plain pnpm build that produces a runnable app is enough.


Architecture

Three parts:

  1. Main process — creates the app, owns a privileged custom protocol that serves only pre-supplied local bytes, and constructs the locked-down session/view for each thing.
  2. Cage — a WebContentsView (not a bare BrowserWindow webContents) configured with every hardening flag, an ephemeral session partition, and a request handler that cancels everything.
  3. A trivial host UI — just enough shell chrome to load a thing from a local file for testing, and a header strip the thing cannot draw over. This is a stand-in for the real shell; keep it minimal.

The thing is loaded via a registered scheme, e.g. thing://<id>/index.html, handled by protocol.handle(). The handler serves bytes only from an in-memory/local map that was populated before load. It never touches the filesystem based on thing-controlled input, and never touches the network. This is the only "fetch" path the thing has, and it resolves to content the thing already came with.

The bridge (stub only for now)

Expose a minimal postMessage-based bridge via a contextBridge preload. For phase 1 implement only:

Explicitly do not expose: any signing, any decryption, any network, any filesystem, any storage, any require/Node API. The preload must run with contextIsolation and expose only these two functions on a frozen object.


Hardening requirements (the cage)

Implement all of these. Each is an independent layer.

Layer 1 — process configuration

Per thing, create a WebContentsView with webPreferences:

Layer 2 — request interception

On that thing's session:

Layer 3 — non-webRequest egress paths

webRequest does not see WebRTC. Close that and related holes:

Layer 4 — Content Security Policy

Inject a strict CSP via session.webRequest.onHeadersReceived, applied to the thing: responses:

default-src 'none';
script-src 'unsafe-inline' thing:;
style-src 'unsafe-inline' thing:;
img-src thing: data: blob:;
media-src thing: blob:;
font-src thing: data:;
connect-src 'none';
frame-src 'none';
form-action 'none';
base-uri 'none';

connect-src 'none' alone kills fetch/XHR/WebSocket/EventSource at the CSP layer; the request handler kills them again at the network layer. Both must be present — that's the point of layering.

Unspoofable chrome

The header strip that will later show signer identity and verification status must be outside the cage's rendering surface — a separate view/region the thing cannot paint over, resize away, or cover with an overlay. For phase 1 it can just display the thing's id/hash and a static "UNSIGNED — test harness" label. The requirement being proven here is spatial: the thing's pixels are confined to the cage rectangle and cannot escape it.


The escape-attempt test suite (the deliverable that matters most)

Create a set of malicious test things — small HTML files that each try to break out — and assert every attempt is blocked. This suite is both the correctness proof for phase 1 and a future standalone conformance artifact, so structure it cleanly and comment each attack with what it's testing.

For each attack, the test should: load the malicious thing into a real cage, exercise the attempt, and assert no network egress occurred and no escalation succeeded. Where possible, detect egress by observing at the OS/process boundary (e.g. a local listener that should never receive a connection, plus assertions that the request handler fired cancel), not only by trusting in-page error callbacks — a thorough version verifies from outside the sandbox that nothing left.

Attacks to cover (each its own test thing):

Network egress

Persistence / tracking channels

Escalation / capability probing

Bridge abuse

Green-wall output

Provide a single command (pnpm test:cage or similar) that runs the whole suite and prints a clear pass/fail wall — one line per attack, all green when the cage holds. This output is the artifact you'll show people; make it legible.

Also include at least one positive test: a benign thing that renders supplied args correctly and successfully calls getArgs() and emit(), proving the cage isn't just "deny everything" but actually runs legitimate things.


Repo layout

/                     electron-vite project root
  src/
    main/             main process: app, protocol handler, cage construction
      cage.ts         constructs the hardened WebContentsView + session
      protocol.ts     thing:// handler serving only supplied local bytes
      bridge.ts       shell-side bridge (getArgs stub, emit logger)
    preload/
      bridge.ts       contextBridge exposure of the frozen {getArgs, emit}
    renderer/         minimal host UI (load-from-file, header strip)
  test/
    things/           malicious + benign test things (one .html per attack)
    cage.spec.ts      the escape-attempt suite
    canary.ts         local listener that must never receive a connection
  package.json
  README.md           how to run the app and the suite; summary of the model

Definition of done


Explicitly out of scope for this phase

Manifest/format, CBOR encoding, signing/verification, NIP-44 encryption, Ethereum/Nostr identity, ENS or any naming, torrent/webtorrent transport, the SQLite library index, the feed UI, blob-by-hash attachments, versioning/supersedes, npm publishing, code signing, auto-update. Do not build these. If a decision here would constrain them, leave a short // LATER: note rather than implementing.