Companion to architecture.md (§4 trust boundaries, §6 render flow) and
product_requirement_doc.md (§6 security model). Those documents place the
boundaries; this one states the operative stance — what is enforced, by which
mechanism, and which defaults were chosen deliberately.
The one-paragraph threat model: an artifact is untrusted code that executes in the visitor's browser. The server never executes it — artifact bytes are inert data at rest, stored and served. What must be protected is therefore (a) the app's session and API from artifact code, (b) the visitor from silent network egress, and (c) the server itself during ingest-time fetching. Each gets its own mechanism below, and every hard boundary is browser- or kernel-enforced machinery, never our own code convention.
- Artifacts are served only from
RENDER_ORIGIN, never the app origin. The gallery embeds them as<iframe src="RENDER_ORIGIN/a/:id" sandbox="allow-scripts" ...>— withoutallow-same-origin, so the frame runs in an opaque (null) origin. Artifact code cannot read app cookies, real-origin storage, or make authenticated same-origin requests; two artifacts cannot read each other. - The render surface is read-only. It looks up, wraps, and serves — it mutates
nothing, which is what makes the same path safe to expose unauthenticated for
share links (
/s/:shareID). - Every rendered document carries
frame-ancestors <APP_ORIGIN>in its CSP, so only the app's own pages may embed an artifact, andCache-Control: no-store, so a stale document (old render preamble, old state, old CSP) is never served from a cache. - The render preamble's write path is the only channel out of the sandbox: a
postMessagewithtargetOriginpinned to the app origin. The host page accepts a state message only after checking its shape and thate.sourceis the artifact iframe's own window (the sandboxed frame'se.originis"null", so identity is established by source window, not origin string). Only then does the host — same-origin with the API and authenticated — perform thePUT /api/artifacts/:id/state. The artifact itself never holds a credential and never reaches the API.
The opaque origin has one benign casualty. Chrome refuses to fetch a module
worker's script for an opaque origin, so a Worker(url, { type: 'module' })
constructed inside the sandbox fires onerror with an empty message and never
runs — with no securitypolicyviolation, so it is not a CSP fault and cannot
be relaxed with CSP. Classic blob:/data: workers run fine in the same frame
(av-x01o); only module workers trip this. The same module worker runs fine when
the artifact is opened top-level at RENDER_ORIGIN/a/:id, which has a real
origin. Practical impact: ffmpeg.wasm 0.12 always spawns its class worker as
{ type: 'module' }, so it transcodes correctly in a new tab or share link but
hangs on "Loading…" in the gallery's embedded preview.
Stance (av-yvtb): keep the opaque sandbox, detect and warn. We deliberately
do not fix this by giving the frame a real origin (per-artifact subdomains +
allow-same-origin). The opaque origin does double duty — it is the trust
boundary and the enforcement of "all state is server state": in a
no-allow-same-origin frame the real localStorage throws, so the storage shim
is the only possible store and cross-device is airtight. A real origin would hand
the artifact a disk-backed store for any surface the shim doesn't cover (e.g.
IndexedDB, still deferred), landing state per-device again. So a real origin
stays an explicit hardened opt-in, never the default (spec §12).
Instead the render preamble wraps the Worker constructor (framed-only, under
the same window.parent !== window guard as the other bridges): when it sees
{ type: 'module' } while self.origin === 'null' (the effective, opaque
origin — location.origin still reports the URL's tuple origin here, so it is
the wrong signal), it postMessages a diagnostic to the host frame (pinned to
the app origin, first occurrence only), then constructs the real worker
unchanged — runtime behavior is not altered; the worker fails on its own as
before. The diagnostic is deliberately capability-agnostic: a generic
__avCapabilityWarning message naming the capability (module-worker in phase 1)
plus an optional resource string, so future detections reuse the same channel and
banner rather than adding message types. The gallery detail page listens for it
and reveals a non-blocking banner: a generic, reusable headline for a
non-technical audience ("This artifact uses unsupported browser capabilities.
Open it directly to run it.") over a default-collapsed <details> whose copy is
selected from the reported capability — the specific failure and, when known, its
resource (the worker script URL) — with a generic fallback for any
not-yet-described capability. It offers "Open in new tab" (the top-level render,
which runs it). This converts a silent, indefinite hang into an explained,
actionable state.
SharedWorker and service-worker registration fail on an opaque origin too and
are a possible follow-on; phase 1 covers module Workers only, and a new
detection needs only a capability slug plus a copy entry, not a new message or
banner. An agent-assisted rewrite to a sandbox-compatible worker is tracked as
phase 2 of av-yvtb.
Storage is keyed by origin, and an opaque origin cannot produce a key — so the
sandboxed frame gets no storage area at all. localStorage, sessionStorage,
and indexedDB each throw a SecurityError on property access, before any
method call, which kills an artifact that reads storage at the top of its
script. The render preamble must therefore install something under both Web
Storage names; the only question is what backs each one.
localStorage→ the server. State is inlined at render and writes bridge through the host frame (§1). This is the cross-device store, and the opaque origin is what makes it airtight: there is no real per-device store to fall back to.sessionStorage→ a separate, purely in-memory namespace. Its own cache, no write-through, noartifact_staterows, nothing leaving the frame. The two namespaces are distinct objects over distinct caches, so a key written to one is not readable from the other — what the standard requires and what artifacts are written against. Because it produces no rows, giving state a principal (av-q0ub) leftsessionStorageuntouched: there is nothing stored to scope, and a frame-local, per-navigation namespace already belongs to exactly one viewer on exactly one device.
In-memory is not a degradation of sessionStorage here, it is its native
behavior: a sandboxed browsing context is assigned a fresh opaque origin on
every navigation, so native sessionStorage would also start empty after each
(re)load, and each frame's origin is unique, so two frames sharing one would be
the wrong behavior. Keeping it out of the server is also the conservative
choice: sessionStorage is where artifacts put what should not survive, so
persisting it would both invert the lifetime the author chose and turn
throwaway values into durable, cross-device rows.
The sessionStorage replacement is framed-only, under the same
window.parent !== window guard as the capability bridges. Opened top-level at
RENDER_ORIGIN/a/:id the document has a real origin where native
sessionStorage works, is tab-scoped, and survives a reload — replacing it
there would be a strict downgrade. localStorage installs unconditionally,
since it also serves the inlined reads top-level.
IndexedDB is not intercepted (deferred). Note it does not quietly fall back
to per-device storage in the frame either — like the others, it throws.
RENDER_ORIGIN holds no session and sets no cookie — asserted by a test
over every route it answers, because this is the failure mode that would break
silently rather than loudly.
The reason is that a top-level GET RENDER_ORIGIN/a/:id is not sandboxed. It is
a real-origin document with the artifact's own script inlined into it, so
anything scoped to that origin is readable by the artifact — which can post it
to any origin on its allowlist. A session cookie there would be handed to
untrusted code on every render. So the render origin cannot learn who it is
serving the way the app origin does; and it does need to know, because /a/:id
and /w/:id were previously unauthenticated, leaving an unguessable id as the
only thing between one owner's artifact — and the state inlined into it — and
anyone who learned that id.
The credential is a signed URL token instead (internal/rendertoken,
av-c5aq):
- Scope: one artifact, one owner, ten minutes. Nothing wider is ever minted
— no owner-wide token, no collection token, no long-lived one. The narrow
scope is what makes a URL-borne credential acceptable: the artifact can read
its own token out of
location.href, and that gains it only the access it already has, to itself, for a few more minutes. - Shape: HMAC-SHA256 over
(version, artifact id, claims), encoded<owner>.<expiry>[.a].<tag>in atquery parameter. Not a JWT: one issuer, one verifier, one algorithm, so an algorithm-negotiation surface would be pure cost. The artifact id is mixed into the MAC rather than carried as a field, so a token minted for artifact A does not verify on artifact B's route — the scoping is the signature itself, not a comparison a verifier could omit. The tag is the last field and everything before it is the signed message, so a claim can be added without changing what is authenticated; an unknown claim is rejected rather than ignored, since a message this version cannot fully read is one it must not act on half of. - The optional
aclaim renders for nobody (av-wmp6): a public instance mints it for a visitor with no credential, and the document it authorizes inlines no state and persists none. It lives inside the MAC because it subtracts authority — as a query parameter, the viewer could delete it and be handed the owner's data. - Key: derived from the existing server secret (
EXHIBIT_SECRET, or the generatedsecret.key), domain-separated from the AES-GCM key that seals agent provider keys. One secret for an operator to manage, not two. With no secret configured at all the process signs with an ephemeral random key, so tokens work but do not survive a restart — the strict answer, since the permissive one is an open render origin. - Verification is stateless — no table, no round trip — and fails
closed: no signer, no token, a bad signature, an expired token, or an
artifact belonging to another owner all answer
404, identically, so the surface is not an existence oracle for other tenants' libraries.
Where a token is minted matters for both cost and staleness:
- Frames — gallery card tiles, the detail page's viewer, the edit page's
widget panel, the agent preview pane, and the
/partials/*fragments — get their token minted during the page render, in memory, with the key already loaded. A gallery of forty cards costs forty HMACs and no extra I/O. - Links ("Open in new tab") carry no token at all. They point at the app
origin's
/artifacts/:id/open, which mints and redirects at click time. A link sits in an open tab indefinitely, so a token baked into the markup would be expired by the time anyone used it — and "copy link address" would spread a credential.
The verified owner is also the render surface's state principal: the answer
to "whose state should be inlined into this document". That answer is
load-bearing — artifact_state is keyed by (artifact_id, user_id, key)
(av-q0ub), and the token's principal is that user_id. A principal with rows
of their own gets exactly those; a principal with none gets an empty cache,
never somebody else's. An anonymous token has no principal at all, so the
state read is skipped entirely and the shim's write-through is short-circuited
in the same preamble — "no principal means no state, in or out" is one fact in
one file rather than two halves that have to keep agreeing.
/s/:shareID is unaffected and takes no token: the share row is the
authorization (architecture.md §7), which is what lets a shared link work for
someone with no account. A share render inlines the artifact owner's state,
because publishing an artifact is publishing it as its owner sees it.
A credential in the URL means every render response withholds its Referer
(av-nr0p). Referrer-Policy: no-referrer is set by middleware on the render mux,
so it is on all three routes and on their failures alike — a rejected token still
travelled in the URL that produced the 404. The case it closes has no attacker
in it: an honest artifact loads a font from an allowlisted CDN, and without the
header the render URL — token included — lands in that CDN's access logs. A
malicious artifact is answered by the token's scope and TTL instead, since it can
read location.href regardless.
Two reasons this is stated rather than inherited. The document is untrusted and
writes its own <head>, so it can ask for <meta name="referrer" content="unsafe-url">; a response header outranks the meta, which is what makes
the policy not the artifact's to choose. And the browser default
(strict-origin-when-cross-origin) would cover most of this today, but it is a
default — it has changed before and is not uniform across engines — while every
other property of this surface (CSP, sandbox, no-store) is explicit.
The app origin's /artifacts/:id/open redirect deliberately does not carry
the header. Its own URL holds no credential — the token is minted into the
Location, not the request — so a Referer computed from it leaks nothing, and
the credential-bearing URL is the render URL, which is governed by the response
that actually becomes the document. Setting it on the redirect would imply the
redirect is the risky half.
The session cookie (av-30rj) is an ambient credential: the browser attaches
it to every request the app origin receives, including ones another site caused.
The bearer token it joined has no such exposure — an attacker's page cannot set
an Authorization header — so cookie auth is what introduced cross-site request
forgery as a question here at all. It is answered by one cookie attribute rather
than by a token layer.
SameSite=Lax, set explicitly (internal/api/auth.go). That is sufficient,
for two reasons which both have to hold:
- Lax withholds the cookie on cross-site unsafe methods. A forged
POST/PUT/PATCH/DELETEfrom another origin arrives with no credential and is answered401. Setting the attribute explicitly matters on its own: Chrome's "Lax+POST" two-minute grace applies only to cookies carrying noSameSiteattribute, so the browser default is weaker than the value. - Lax does send the cookie on a cross-site top-level GET — which is safe
only because no GET route mutates. Every
r.Getin the API group is a read: list, detail, state, widget, transcripts, agent key, collections, tags.
The property is "every GET is a read", deliberately not "every GET is authenticated". A public instance (av-4ac9) serves some of those reads with no credential at all, and an unauthenticated GET has no credential to abuse — so opening a route up does not weaken this, while making one mutate does.
Both conditions are pinned by internal/api/csrf_test.go: the attribute
directly, and the no-mutating-GET rule by walking the app mux with chi.Walk and
requiring every registered GET route to be declared a read in an exact-match
list. A newly added GET route fails the suite until someone classifies it.
Two consequences worth stating, so neither is rediscovered as a compatibility problem:
- Changing
SameSiteis a security change, not a config tweak.None— the value an embed or a cross-origin browser client would ask for — hands every mutating route to any page the user visits. There is no CSRF token underneath to catch it. - Adding a mutating GET is a security change too. A
GET /api/artifacts/:id/refetchconvenience route is exactly the shape that would look harmless; it would be forgeable with an<img>tag. Mutations stay on unsafe methods.
No CSRF tokens. A token layer would be redundant machinery over a protection the browser already applies, on an API whose other credential cannot be forged at all. The cost of that choice is that the protection is one attribute deep — which is precisely why it is written down here and pinned by tests rather than left to be inferred from the code.
The login flow holds the only GET routes that do change state, and each is safe for its own reason rather than by the rule above:
GET /auth/loginmints short-lived state/verifier cookies before any session exists. Forging it starts a login the attacker cannot finish. (On an instance with a local credential it renders the login page and mints nothing; it is declared by its worse case.)GET /auth/ssois that provider redirect split out, so the login page has something to point its SSO button at when both login paths exist. Same cookies, same reason.GET /auth/callbackis a cross-site top-level GET by construction — the provider redirects the browser to it — and carries its own forgery defence: thestateit must match was parked in a cookie by this origin.GET /auth/logoutrevokes a session. A forged request achieves nothing worse than logging the user out, and logout stays a link because that is the affordance people expect.
POST /auth/local — the local credential's login (av-q30x) — is on an unsafe
method and so is covered by condition 1 like any other mutation. It is worth
stating that it needs nothing further: Lax protects requests that carry an
ambient credential, and this one runs before any session exists, so the only
thing a cross-site page could forge is a login it must already know the password
to complete. Its post-login destination arrives in a form field rather than a
query parameter, so it goes through the same safeNext and can still only be a
path on this origin.
Guessing, as opposed to forging, is throttled (av-t21v,
internal/api/loginratelimit.go). bcrypt's cost used to be the whole of that
answer — a guess costs the attacker the same tens of milliseconds it costs the
server — and it was a fair one while an instance had exactly one credential.
Issuing credentials for several people (av-sz4e) does not scale that attack, it
changes its shape: credential stuffing sprays one likely password across N
accounts, so a per-guess cost premised on thousands of guesses at one account
buys almost nothing. The endpoint is now rate-limited in process, as middleware
on the route rather than a check inside the handler, so that what a credential
is remains the handler's question and how often it may be asked is not.
Two token buckets, both of which must allow an attempt:
| Key | Budget | Why it is not enough alone |
|---|---|---|
| Source address | 20 failures at once, then one back every 3 s | Shared by a household behind one NAT — and, behind the operator's reverse proxy, potentially by everyone — so it is the generous one; and a botnet rotates past it |
| Username, case-folded | 10 failures at once, then one back every 30 s | Survives a botnet, since rotating addresses does not rotate the account being guessed — but the collateral lands on one named person |
The address is read from the peer, which a client cannot forge. X-Forwarded-For
is consulted only when the peer is itself loopback or private — plausibly the
operator's own proxy — and only its rightmost entry, the hop that proxy
appended; the leftmost entries are whatever the client sent and are exactly what
an attacker would spoof for a fresh budget per request.
Four properties are deliberate, and each is pinned by a test:
- Only failures are debited, and the check runs before the handler. A correct sign-in costs nothing and is never delayed by unrelated traffic's failures. Signing in also returns that username's budget, so two typos are not still held against the account tomorrow; the source's budget is not refunded, or anyone holding one valid credential could top it up between guesses at somebody else's.
- Nothing is disabled. An emptied bucket refills on a clock, so the worst an attacker can impose on a real user is a wait of one refill interval, with nothing for an operator to un-lock. A failed-attempt counter that disabled an identity would hand every attacker a denial of service against any name they could guess — the throttle exists to slow attempts, never to disable a person.
- There is no instance-wide budget. It would be the one key a single source could use to shut the front door on everybody, which is a worse failure than the brute force it would slow.
- Memory is bounded. Both keys are attacker-controlled, so a map that only grows is itself the denial of service. Each limiter holds at most 2×4096 live keys (two generations, the older dropped whole on rotation), a lookup never creates an entry — only a failure does — and a bucket refilled to full is deleted rather than kept, so an honest instance's map is empty rather than merely bounded.
Nothing is persisted, deliberately: attempt counters do not earn a table. A restart forfeits at most a few minutes of budget, and an attacker able to restart the process has already won something larger.
The proxy still matters, now as the complement rather than the answer. An in-process limiter is blindest to the case it is worst against — a distributed spray, many addresses and many accounts, one guess each — and it cannot refuse a request before the process has paid to read it. An instance on the open internet should keep a connection-rate or fail2ban policy at its ingress, where the rest of that deployment's ingress policy already lives. What has changed is that this is no longer the only thing between a stolen password list and the library.
The server-rendered pages are HTML. They sit outside the API's auth group, and
their own JavaScript authenticates the calls they make — so every page render
has to decide what credential to write into its bootstrap <script>. For as
long as every page visitor was the operator, the answer was the process's
AUTH_TOKEN and that was correct.
Sessions (§1.4) and public mode ended that, and left a real defect behind (av-5imk). A logged-in user loaded a page and was handed the operator's full-authority service credential. Logging out deleted the session row — but not the token in page source they had already loaded. That token grants write authority over every artifact, every collection, the share table and the BYO provider key; it is not per-user, so it cannot be revoked for one person; it can only be rotated for everyone. Logout did not revoke API access, which is the one property opaque server-side sessions were chosen to provide.
The credential is therefore derived from the request, in one place
(internal/api/pagecredential.go), and nowhere else reads cfg.AuthToken for
this purpose. Three cases:
| Visitor | TOKEN |
READ_ONLY |
Why |
|---|---|---|---|
| Session-authenticated browser | empty | false |
The cookie is already a per-user, server-side-revocable credential, and the browser attaches it to every same-origin fetch. authMiddleware checks the session before anything else, so the page's calls authenticate on it alone. An embedded bearer token would be a second, stronger credential that logout cannot take back. |
| Anonymous visitor on a public instance | empty | true |
There is no credential to give someone who presented none. The page refuses writes locally rather than sending them to be refused, so it degrades to read-only instead of erroring. |
| No identity provider configured | the static token | false |
A single-user instance issues no sessions, so the static token is the only credential its page JS can authenticate with — and its page visitor is by construction the operator who already holds it. Nothing changes for the self-hoster. |
The third case is written as "no identity provider", deliberately not "no
session". On an instance that has a provider, a page render that resolved no
session is either a public visitor or a gap in sessionGate, and the service
token is the right answer to neither. Falling back to it would turn every future
hole in the gate into a credential leak rather than a 401.
Two supporting pieces follow from the same decision:
- One client spends it.
web/gallery/api.jsexposesapiFetch; no page script builds anAuthorizationheader. The three cases are distinguished once, so a call site cannot get them individually wrong. - The SSE stream is the exception that proves it.
EventSourcesets no headers, so a token has to travel in the query string —apiEventSourceappends it only when the page was given one, and a session-authenticated stream carries the cookie instead and no token in a URL at all. The stream route accepts both (authorizeEventStream). Narrowing the query-string credential itself is av-rgp1.
Pinned by internal/api/pagecredential_test.go, which walks the app mux with
chi.Walk, requires an exact-match row for every registered GET route, requests
each one as a session-authenticated visitor, and asserts no response body
contains the token. As with §1.4's walk, a newly added page route fails the suite
until someone declares it — because the failure this prevents is silent: the page
works perfectly while it leaks.
§1.5 is about the credential a page hands its scripts. This is the adjacent question the same request has to answer: whose data the page renders server-side. The two are complementary, not alternatives — one decides what the page may do, the other what it may show — and the second is the more serious to get wrong, because a wrong library is served without anyone having to spend a credential at all.
owner_id became a real query predicate on every API read in av-ep8k. The page
routes did not get it (av-syug). They are registered outside the API's auth
group, so they never ran ownerMiddleware; sessionGate resolved the visitor's
user and propagated only the boolean §1.5 needed; and ownerIDFromCtx quietly
answered defaultOwnerID for a request nobody had attributed. A user whose
owner_id was 2 logged in, loaded /, and was served owner 1's library —
and because renderURLs takes its principal from the same helper, that page's
frame tokens named owner 1 too, so the render surface's a.OwnerID == principal
check (§1.3) passed and owner 1's artifacts rendered inside user 2's gallery,
bodies and inlined state included. Not exploitable while every owner was 1; live
the instant a second user existed.
The owner now reaches a page request the way the credential does — from the request, through middleware, in one place:
| Where | Who resolves the owner | To what |
|---|---|---|
API group (/api/*) |
authMiddleware |
the session's user, the agent grant's OwnerID, or PUBLIC_OWNER_ID for a public visitor; ownerMiddleware supplies the single-user default for a token-authenticated client |
Page group (/, /new, /artifacts/…, /agent, /admin/users, /profile, /partials/*) |
sessionGate |
the session's user — the same sessionUser lookup it already performed for §1.5, no longer discarded |
| Page group, instance with no login | ownerMiddleware |
the single-user default |
ownerMiddleware never overwrites an owner resolved upstream, which is what
lets it sit under both credential paths with no ordering rule to remember.
Membership of the page group in setupRoutes is the declaration that a route is
owner-scoped; a page route registered outside it gets no owner at all.
ownerIDFromCtx fails closed. A request nobody attributed resolves to
noOwner (0), which matches no row — owner ids start at 1 — so a scoped read
made with it returns the empty set. That mirrors the choice the store layer had
already made (ListArtifacts treats an unset OwnerID as matching nothing) and
corrects the asymmetry that made this bug invisible: a plausible default
produced no error, no zero value and no failing test, just the wrong shelf. It
is affordable because nothing depends on the guess any more — every route that
reads library data resolves an owner explicitly, so an unattributed request is a
wiring defect rather than a deployment shape. The two failure modes are not
comparable: an empty library is a visible bug its own operator reports, while
the wrong library is an invisible cross-tenant read its victim never learns
about. (Returning (int64, bool) would make the omission a compile error, but
at ~40 call sites answering it identically it buys a mechanical if !ok that is
copied rather than thought about; the enforcement that actually catches a new
unscoped page is the route walk below.)
Pinned by internal/api/pageowner_test.go, which walks the app mux like §1.4
and §1.5 and requires an explicit row per registered GET route — either
ownerScoped, or a stated reason it is not. Owner-scoped rows are then
exercised against two real owners: as owner 2, each route must render owner
2's own artifact (the non-vacuity control), must show no trace of owner 1's
title, source or stored state, and every render URL it emits must carry a token
that verifies to owner 2. Those URLs are then followed to the render origin,
because the leak being prevented is content and state, not filenames. The same
rows are walked again on a single-user instance, where sessionGate is a
pass-through and the owner can only come from the page group — which is what
makes group membership enforced rather than conventional.
§1.6 answers "whose library" and stops there, which is the right answer for every route that reads a library. Administration (av-utap) is the one surface where it is not enough: creating an account, resetting somebody's password and disabling somebody's login are not reads of a library at all, so no amount of owner scoping constrains them. They need a third property, and it is the one none of the three route walks above tests.
A session is not authorization here. That is the whole boundary. A person
acting on their own account needs nothing more than a session (av-g2dx, now
/profile — av-qo05); an admin acting on the instance needs strictly more, and
the two surfaces share page furniture. So the check lives on the route —
adminOnly (internal/api/admin.go) wraps the page and the whole
/api/admin/* group, and
no admin route shares a handler with a non-admin one. Getting this wrong in the
obvious way, by hanging an admin control off a settings page guarded only by
being logged in, lets any account reset the admin's password.
The most destructive route on the instance is not one of them. DELETE /api/account (av-4wyq) erases an account and the library it owns, and a plain
session is the whole of its authorization — because it takes no id, from the
path or the body, and can therefore only ever reach the account the session
already resolved to. That is the same argument /profile makes, applied to a
mutation: a route that cannot name a target needs no check that the target is
yours. It is registered outside the /api/admin/* group deliberately, so the
distinction is visible where the routes are, and it requires a session
specifically — the service token is not a person, and would resolve to the
single-user default owner's library. Its confirmation phrase is re-checked
server-side, but that is interlock rather than authorization: it guards against
a mis-tap, not against a caller who should not be here.
- It refuses with
404, before looking at the target. To a non-admin the surface does not exist, and "you may not touch user 7" is byte-identical to "there is no user 7" — an admin acting on a missing id gets the same 404 — so a refusal cannot be used to enumerate the directory. - Never an agent grant, never an anonymous public visitor. Both are checked first so no later branch can widen them. The service token is admin (it already holds full authority over every API route); a session is admin only while the account behind it is an enabled admin, re-read per request so a demotion lands on the next one.
- Disabling revokes, it does not merely refuse.
Store.SetUserDisableddeletes that user'ssessionsrows in the same transaction that sets the column, so the sessions §1.4 made server-side rows are gone rather than merely unrenewable. Login is then refused on every path, theLOGIN_USERNAMEbreak-glass pair included. - The last enabled admin cannot be demoted or disabled, guarded inside the
UPDATErather than by a read beforehand, so nothing can slip between the check and the write.
Pinned by internal/api/admin_test.go, which drives every admin route with a
real non-admin's real session and asserts the refusal is identical for an
account that exists and one that does not, then uses a live cookie after a
disable to prove the session really ended.
Each artifact carries a set of per-origin decisions (artifact_network_origins,
one row per origin). The origins decided allow are the allowlist; origins decided
block are "don't ask again" markers for the runtime prompt and are never part of
it. At render time the surface generates the document's Content-Security-Policy
from the allowlist:
default-src 'none'
script-src 'unsafe-inline' 'unsafe-eval' blob: data: <allowlisted origins>
worker-src blob: data: <allowlisted origins>
style-src 'unsafe-inline' <allowlisted origins>
img-src data: <allowlisted origins>
font-src data: <allowlisted origins>
media-src blob: <allowlisted origins>
connect-src <allowlisted origins, or 'none' if the list is empty>
form-action 'self' <allowlisted origins>
frame-ancestors <APP_ORIGIN>
Every source above belongs to one of two buckets, and sorting a new one into the right bucket is the whole design rule:
| Bucket | Examples | Gating |
|---|---|---|
| Network-reaching | a remote origin fetched, imported, styled from, or submitted to | scan → approve → allowlist (spec §6.2) |
| Local / no-egress | 'unsafe-inline', 'unsafe-eval', data:, blob: |
unconditional — always present |
A local source runs or renders bytes the artifact already carries, or a file the visitor picked on their own machine. Nothing leaves the browser, so gating it behind per-artifact approval buys no security while breaking canonical single-file patterns.
Points of stance embedded in that policy:
'unsafe-inline'/'unsafe-eval'inscript-srcis deliberate. The artifact is an inline script; blocking inline execution would block the product. CSP is not doing XSS duty here — containment of what the script can touch comes from the sandbox and origin isolation (§1); CSP's job is controlling what the script can reach over the network.- Inlined and locally constructed sources are exempt from approval because
they are not network requests:
style-srcalways permits inline styles,img-src/font-srcalways permitdata:URIs,media-srcalways permitsblob:, andscript-src/worker-srcalways permitblob:/data:. An artifact that carries its own CSS, images, and fonts, plays back a file the visitor picked, and spins up a Worker from ablob:URL (ffmpeg.wasm and friends) renders with zero egress — the "it's just a file" thesis in policy form. worker-srcis emitted explicitly, not left to fall back toscript-src, because a missingworker-srcfails silently: theWorkerconstructor succeeds, no error is logged, no promise rejects, and the worker body simply never runs — an indefinite "Loading…" with nothing to debug (av-x01o).- A no-network artifact gets
connect-src 'none'. Nothing is reachable by default. - An allowlist entry is an origin, and that is enforced at the single write
path (av-i7hd).
POST/PATCH /api/artifactsnormalize every entry throughorigin.NormalizeOrigin— absolutehttps://host[:port](plaintexthttp://only for loopback), lowercased, trailing host dot stripped, default port dropped, userinfo/path/query/fragment refused — and reject anything else with a400naming the value. It belongs there rather than in a client because these strings are pasted verbatim into the header above: a path-bearing entry is path-matched by CSP (so it means something other than what the approval UI showed), a keyword ordata:/blob:source is the CSP builder's to emit and never the user's to type, and near-duplicate spellings of one host would split a single decision into several rows. The store applies the same rule as an invariant, so no future caller can reintroduce a non-origin row. - The ingest scan is transparency, not enforcement. It parses the document with a real HTML tokenizer and surfaces the origins the artifact references, but its output never seeds the allowlist — only origins the user explicitly approves are written. A runtime attempt to reach anything else is blocked by the browser; the user can approve the origin afterward in the artifact's allowlist editor, which updates the CSP on next render.
URL ingest fetches the page once and stores its body as the artifact. With
snapshot: true the page's external assets are vendored (inlined) into that
body: images, scripts, stylesheets and fonts, including nested CSS
@import/url() chains, plus the binary payloads a page fetches from
JavaScript at runtime (wasm modules and similar, av-ghvs). Anything that cannot
be inlined keeps its original reference and is recorded as a typed failure, so
partial vendoring still yields a usable artifact.
Vendoring is a security property, not only a durability one. A fully
vendored page collapses its own network footprint toward connect-src 'none' —
there is nothing left for it to reach out to. It also removes a failure the
allowlist cannot address: relocating a page to the render origin turns its
same-origin runtime fetches into cross-origin ones, and because same-origin
requests never needed CORS headers, source sites do not send them. CSP permits
such a request while the browser refuses to read the response, so the artifact
breaks in a way that approving the origin does nothing to fix.
Bounded fetcher: all vendoring goes through one bounded Fetcher in
internal/snapshot, with per-asset and total size budgets, an asset-count cap,
request timeouts, a redirect limit, and a dial-time guard rejecting non-public
addresses (loopback, private ranges, link-local) to prevent SSRF. The
runtime-asset pass shares that fetcher, and so that budget and that guard, under
its own larger per-asset cap. The initial page fetch is still the exception:
POST /api/artifacts (URL branch) and POST .../refetch use a bare http.Get
with a 10 MiB body cap and no SSRF guard. That gap is not yet closed.
After ingest the stored copy never phones home. Updating it is an explicit user
action (POST /api/artifacts/:id/refetch). There are no live-linked imports and
no automatic refresh.
Render preamble taxonomy (canonical vocabulary for all docs). The JS
injected into the rendered frame as the first <head> script(s) — replacing
browser globals before any artifact code runs — is the render preamble.
Its pieces share a delivery mechanism, not a purpose, and by purpose they
are four families:
- Storage adapter (established name: storage shim) — intercepts a
storage API (IndexedDB and
window.storagedeferred) and replaces its backing behind an unchanged surface.localStorageis backed by the server → portable, cross-device state.sessionStorageis a separate namespace over a separate, purely in-memory cache, never persisted and never sent anywhere — see §1.2. - Capability bridge — re-grants a capability the sandbox denied (clipboard, downloads) by proxying the op to the trusted host under first-use approval. Not persistence. This section.
- Polyfill — reconstructs an API absent in this environment (e.g. File System Access pickers, deferred as av-70t9) atop available primitives.
- Compatibility shim — re-implements an operation the browser nominally
supports but refuses or mishandles in this frame, using only bytes the frame
already holds. The
data:fetch shim (agaf-02xs) is the one member: WebKit refuses largedata:fetches from an opaque-origin sandbox, sofetch()of adata:URL is answered from a locally constructed Response. Distinguishing it from the other three matters for review: it crosses no trust boundary, needs no approval, and adds no authority — adata:URL is inert content already in the document, and the shim reaches neither the host nor the network. A member of this family that did need either would belong in one of the families above.
The capability-registry work (av-u0vc) covers the capability-bridge family only; storage adapters and polyfills are orthogonal axes it does not touch. Bare "shim" never means the whole preamble — say "render preamble."
Widget renders take a narrowed preamble (av-fafu). A gallery card's widget
is served from the same render surface under the artifact's CSP, so its
network reach is identical — but it gets the storage adapter with writes
short-circuited, and no capability bridges and no polyfills at all (not
injected, rather than injected and disabled). The reasoning is the dividing line
this section already draws: a capability bridge re-grants something behind a
user gesture and a first-use decision, and a tile renders unattended in a card
behind pointer-events: none, where there is no gesture to attribute a prompt
to — and where the artifact's own approvals were granted for the tool the user
opened, not for its tile. A widget's authority is therefore a strict subset of
its artifact's, by construction. See widgets.md.
The dividing line for local capabilities: local interaction with a user gesture is allowed; anything that produces egress or bypasses a user decision is not.
- Clipboard —
navigator.clipboardread/write is mediated by the host frame with first-use approval — a capability bridge on the same host-mediation mechanism as downloads (below). An earlier attempt delegatedallow="clipboard-read; clipboard-write"into the frame, but a Permissions-Policyallow=keys on the frame's src origin, which is opaque (noallow-same-origin) and matches nothing — so the delegation was a no-op and copy/paste still threw a permissions-policy violation. The delegation is removed; instead:- The clipboard bridge replaces
navigator.clipboard.readText/writeTextinside the frame and posts each call to the host (pinned to the app origin), correlated by request id so the returned Promise settles with the host's answer. - On the artifact's first clipboard request the host prompts, naming the
artifact and the direction (read vs write). Approval persists server-side
(
clipboard_approved, PATCHed through the API — the single write path), survives reloads and devices, and is revocable from the toolbar. Denial rejects the call with aNotAllowedErrorDOMException— exactly what a real blocked clipboard call throws, so the artifact handles it unchanged. - Once approved the host performs the op on the app origin (which holds clipboard permission and, from the Allow click, transient user activation) and posts the result back into the frame.
- Native keyboard paste (Ctrl/Cmd+V into a focused field) is a browser event, not a Clipboard API call, so it always works and needs no approval; the bridge governs only programmatic API access.
- The clipboard bridge replaces
- File reads —
<input type="file">and drag-in work normally: the user picks the file, the artifact reads only what was picked, and the contents are subject to the same egress rules as any other data in the frame. - Downloads — the sandbox omits
allow-downloads, so nothing in an embedded artifact frame can initiate a download directly. Because export-a-file is a core capability for tools (CSV generators, image editors), downloads are instead mediated by the host frame with first-use approval, reusing the render preamble's postMessage channel (§1):- The download bridge intercepts the common export vectors inside the frame — anchor
activations with
blob:/data:hrefs, both user clicks (capture phase) and programmaticclick()— and posts filename + bytes to the host, pinned to the app origin. Bytes cross the boundary as transferred data, not a capability grant.blob:payloads are recovered from acreateObjectURLregistry the bridge keeps, so it needs no fetch (connect-srcis untouched). - On the artifact's first download attempt the host prompts, naming the
artifact and the filename. Approval is persisted server-side
(
downloads_approved, PATCHed through the API — the single write path), so it survives reloads and devices, and is revocable at any time from the artifact's toolbar. Denial drops the bytes without breaking the artifact. - Once approved, the host reconstructs the file and triggers the download from the app origin.
- The sandbox remains the wall. Approval never adds
allow-downloads; vectors the bridge doesn't catch (navigation-triggered downloads, an artifact deleting the bridge's hooks) simply stay blocked by the browser. Like the ingest scan, the bridge is UX, not enforcement — evading it gains nothing. - The bridge only installs when a host frame exists. An artifact opened directly on the render origin ("Open in new tab") is a top-level page, not a sandboxed frame, so downloads work there natively — the user has explicitly navigated to the tool, and the per-artifact CSP still applies via the response header. Share pages get no bridge: opened top-level they behave the same way; there is no authenticated host to mediate for them.
- The download bridge intercepts the common export vectors inside the frame — anchor
activations with
- External links — the sandbox omits
allow-popups, so atarget="_blank"anchor is dropped on click and a plain anchor would navigate the iframe itself, replacing the artifact with an external page that usually refuses framing (X-Frame-Options/frame-ancestors). External-link navigation is therefore mediated by the host frame with first-use approval, the third sibling of the download and clipboard bridges:- The link bridge intercepts anchor activations whose resolved URL is an
external
http(s)destination (capture phase, after the download-href check soblob:/data:still win) and posts only the URL to the host, pinned to the app origin. Only the URL crosses the boundary — a pointer to content the artifact already displays, not a capability grant. - On the artifact's first external-link click the host prompts, naming
the destination. Approval is persisted server-side (
links_approved, PATCHed through the API), survives reloads and devices, and is revocable from the toolbar. Denial drops the navigation without breaking the artifact. Once approved the host opens the URL in a new tab from the app origin; the click's transient activation covers the postMessage roundtrip. - The sandbox remains the wall. Approval never adds
allow-popupsorallow-top-navigation; popup vectors the bridge doesn't catch (a directwindow.open) simply stay blocked by the browser. Form submissions are not this bridge's to govern: the sandbox keepsallow-forms, and the existingform-actionpolicy (§2) —'self'plus the allowlist — already enforces the network allowlist for them, exactly as before. There is no CSP/allowlist interaction for the popup itself: it is its own top-level document governed by the target site's own policy. - This adds gesture convenience, not capability: right-click → "Open link in new tab" already reaches the same URL from browser chrome, which the sandbox does not govern. Like downloads, the bridge installs only when a host frame exists; top-level renders and share pages navigate natively.
- The link bridge intercepts anchor activations whose resolved URL is an
external
Every other client of this API is driven by the person using it. The agent
surface (docs/agent.md, architecture §3.7) is not: it is a pi subprocess
executing tool calls an LLM emits, and the LLM's context contains text Exhibit
did not author.
Where the untrusted text comes from. Three channels, all inherent to the product rather than incidental:
- Artifact bodies. URL ingest stores a remote page verbatim (§3), and architecture §4 already classes a stored body as untrusted data. Handing one to the agent — which is the entire point of "modify this artifact" — puts attacker-authored HTML, including comments the user never sees rendered, into the model's context.
- Artifact titles. On a URL ingest the title is scraped from the fetched page, so a hostile site chooses it.
- Snippet descriptors. The element picker ships the picked subtree's
outerHTML. The user believes they are pointing at a button; they are also forwarding whatever that subtree carries.
The enforced boundary is not prompt wording, and not the tool schema. It is an authorization check on the API, in the same shape as the rest of this document: the ingest scan is transparency and the CSP is the wall (§2); the download bridge is UX and the sandbox is the wall (§4).
-
A chat session is issued a credential of its own (
internal/agentscope) — never the operator's service token. It resolves to a scope: an owner, and at most one artifact. -
authMiddlewareresolves the bearer token to that scope and refuses anything outside it with a 403, before any handler runs. The reach is written as a deny-by-default allowlist, so adding an API route never silently widens it:Allowed When POST /api/artifactsonly while the session is still unbound GET/PATCH /api/artifacts/{id}only the session's own artifact GET/PUT/DELETE /api/artifacts/{id}/stateonly the session's own artifact GET/PUT/DELETE /api/artifacts/{id}/widgetonly the session's own artifact The two sub-resources are there because the agent has tools for them (av-lvi1, av-fafu) — one allowlist entry per shipped tool, named individually rather than by a "sub-paths of my artifact" wildcard, so a new artifact route is out of reach until someone adds it here on purpose.
Everything else is refused — the BYO provider key, shares, deletes, tags, collections, transcripts,
widget/generate(a session may not start another session), listing the library, and every other artifact in it. -
This is the per-artifact half of the boundary. The credential's owner becomes the request's
ownerID, so the owner-scoped Store methods (av-ep8k) bound the session to one tenant exactly as they bound a browser client, and the path check then narrows that tenant's library to one artifact. The two compose: an agent session reaches one artifact, and never another owner's anything. Neither check substitutes for the other — without the owner, the scope names an id that could belong to anybody; without the path check, the session holds ordinary full authority over its own user's whole library. -
Who may drive the session is the third question, and it was missed. The credential above bounds what a session may do; this bounds who may steer it. Sessions live in an in-memory registry rather than in SQLite, so they were the one piece of per-owner state av-ep8k's query sweep could not cover:
Manager.Gettook a session id and compared nothing, and any authenticated user holding one could prompt, abort, watch or kill another owner's live agent. Prompting is the sharp one — the tool calls that follow run on the victim's scoped credential, so an injected instruction is written into the victim's artifact, defeating the containment above rather than evading it. The registry lookup now takes the owner as a parameter (Manager.Get(ownerID, id)) rather than trusting each route to check, for the same reason the predicate lives inside the store's SQL; and another owner's session is not found — 404, byte-identical to an id that was never issued, never 403 — so the routes are not an oracle over which sessions are live. The SSE stream resolves the same owner itself, becauseEventSourcesets no headers and that route sits outside the middleware pair every other one runs. Pinned byinternal/api/agent_session_owner_test.go, which drives a realpisession with a second real account's cookie. -
A modify session is scoped at spawn to the artifact the user opened. A create session starts unbound and binds to the id its first create returns — bound from the row the create handler wrote, not from the tool result, whose contents model-supplied arguments shape. The scope only ever narrows: the first binding wins, and a second create is refused.
-
The credential is revoked when the session's subprocess exits, so a token never outlives the process that held it.
The extension's tools take no artifact id — a tool with no id parameter cannot be talked into a different target — but that is the ergonomic half. The server-side scope is what makes the guarantee hold if the tools are ever rewritten or bypassed.
Instructions and data sit in different places in the conversation.
-
The system prompt is entirely server-authored. No artifact title, body, or id is interpolated into it.
-
The artifact's source, its title, and any snippet descriptor travel in a user-role message, inside a fenced block:
-----BEGIN EXHIBIT UNTRUSTED DATA <nonce>----- label: current source of the artifact this session is editing … -----END EXHIBIT UNTRUSTED DATA <nonce>-----The delimiter carries a per-session random nonce, so content stored before the session existed cannot close the fence and impersonate an instruction. The system prompt states the contract and names the nonce; the nonce is redacted from block content, which closes the one path by which a session could plant its own fence id into a body and read it back.
-
The agent needs the body, not the title. The title rides along as fenced metadata and never appears inside an instruction sentence.
-
The artifact source is inlined into the session's opening message rather than fetched by a tool call, so the common case costs no round trip.
get_artifactremains for the re-read after the agent's own save or a concurrent human edit, and its output comes back in the same envelope.
No attempt is made to sanitize or strip instruction-shaped text. It is natural language; a filter for it would be theatre, and shipping one would invite trusting it.
Scoping bounds the blast radius to one artifact, not to zero. Delimiting reduces the success rate of an injection; it does not eliminate it. Injected content can still talk a session into writing a bad body into the artifact the user opened. What that costs, and what it does not:
- The change is visible — in the chat transcript, and in the preview pane that re-renders on every save.
- It cannot reach another artifact, the library listing, the provider key, or a share link: none of those is in the credential's scope.
- It is wrong, not exfiltrating — so long as a rewritten body cannot
silently inherit the artifact's prior network approvals. Closing that channel
is
av-hrtv; until it lands, an agent-written body keeps the approved origins of the body it replaced. - Artifact bodies have no version history (
av-1rvm), so an overwrite is not yet undoable.
Accepted, with eyes open (see the PRD §6.3): the model controls what an artifact
reaches, not what it displays — a malicious artifact can still render
convincing fake UI. The isolation in §1 caps the blast radius (no real session to
steal). Auth today is a single static bearer token scoped for single-user,
trusted-circle deployment; the middleware seam exists to swap in real identity
without changing the API contract. Because that one token is the whole library,
it travels only in an Authorization header, compared in constant time, and
never in a URL — the agent's SSE stream, which EventSource cannot send a
header on, takes a single-use, seconds-lived, session-bound ticket instead
(av-rgp1, architecture.md §3.7), so the master credential never appears in a
URL or browser history. That guarantee is about the URL, not every log: this
service's own request logger (internal/logging) records method, path, and —
at debug level — the raw query, but never headers, so it was never a channel
for a header-borne credential either way. An operator's reverse proxy is
outside that guarantee: if its access log is configured to record request
headers, the Authorization header must be redacted there.